mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents produce artifacts: documents, files, pictures, video and links. The app shows them on the Artifacts page, in the right panel's Artifacts tab and in the task thread > - The artifact directions lab (#17213) settled a presentation for those surfaces, MVP V1, drawn from the PCLP-Core mocks. It is a reference, not product code > - Shipping it means product components on product data, token-only, behind a switch, one surface at a time, so the current presentation stays the default until the new one has been used > - This pull request adds the switch (Settings > Experimental > Artifacts V1), the `--artifact-*` tokens, the V1 primitives as product components, and the V1 presentation on all three surfaces behind the switch: the Artifacts page, the panel's Artifacts tab and the task thread > - The benefit is the redesign in the product, reversible by one setting, with every existing behaviour of those surfaces kept ## Linked Issues or Issue Description No public issue exists. This section follows the feature request template; the plan is `doc/plans/2026-10-22-artifacts-mvp-v1.md`. **Subsystem affected** The web UI (`ui/`): the Artifacts page, the right panel's Artifacts tab and the task thread. The experimental settings contract in `packages/shared` and `server` gains one boolean. **Problem or motivation** The three artifact surfaces grew separately and their artifact presentation is dated and inconsistent. The lab produced a measured direction; nothing in the product uses it yet. **Proposed solution** An experimental setting, `enableArtifactsV1`, default off, with the same plumbing as the other experimental settings. Product primitives under `ui/src/components/artifacts/v1` (kind helpers and adapters, the sheet and print artwork, the stack, the page tiles with the Paper Mono pill, the row the panel and thread will share), driven by `--artifact-*` tokens in `index.css` with light and dark values. With the setting on: the Artifacts page shows one row of views (Tasks, All files, Images, Videos, Documents, Text, Files) and the V1 tiles, a task with several artifacts as a stack whose newest artifact is the first page, everything else as single tiles; the panel's Artifacts tab is one flat list of V1 rows, newest first, with the expandable rows (Markdown review surface, document annotations, deep links) keeping their behaviour behind the row as their face; the thread shows a message's files as rows under its footer and registered deliverables and documents as one row each. The hover movement respects reduced motion. **Alternatives considered** Replacing the current cards outright: no fallback and three surfaces to land at once. Mounting the lab's components: they are typed on lab fixtures and keep lab-local tokens, which the token gates reject. The primitives were ported instead and the lab stays a reference. **Roadmap alignment** Supports the "Artifacts & Work Products" theme in `ROADMAP.md`. Presentation only; no data model change. **Additional context** No new API fields: the tiles show title, identifier, count and "Updated …"; rows take sizes from the work-product and attachment payloads; a picture's aspect ratio comes from the loaded image, 4:3 until it loads. The plan records what each surface keeps and what it drops (run sections in the panel; the "Images · N / Files · N" labels and the four-up grid in the thread). ## What Changed - Add the `enableArtifactsV1` experimental setting: shared type, validator and feature catalog, server defaults and managed keys, the `useArtifactsV1Enabled` hook, and the Settings > Experimental toggle (in its alphabetical place). - Add the `--artifact-*` tokens and the Artifacts V1 component rules to `ui/src/index.css`: light values on `:root`, dark overrides, and the hover movement under `prefers-reduced-motion: no-preference`. - Add `ui/src/components/artifacts/v1/`: `kinds.ts` (kind treatment, labels, glyphs, the server's media-kind rule, adapters from `CompanyArtifact`), `geometry.ts` (the measured constants), `when.ts`, `ArtifactSheet`, `ArtifactPrint`, `ArtifactStack`, `ArtifactFront`, `ArtifactTile` (stack and single tiles, caption, grid), `ArtifactRow` (the row and its inline group), `ArtifactsV1Filters`, with the mock's SVG assets. - Wire the Artifacts page: with the setting on, the toolbar's filter row becomes the V1 views and the grid renders V1 tiles; the toolbar takes an optional `filters` row for this. The old cards and kind filters stay the default. - Wire the panel's Artifacts tab (`IssuePropertiesArtifactsTab`): with the setting on, one flat list of V1 rows, newest first; `DocumentRow` and `MarkdownWorkProductRow` take a `face` render prop so the V1 row fronts their unchanged expansion, review and deep-link behaviour; `PanelFileRow` opens attachments and attachment-backed work products the way the shipped card does (gallery, text tab, new tab) with open and download beside the name; other work products are a link row with their status. - Wire the task thread: `TaskChatBubble` hangs a message's files under its footer as V1 rows inline (pictures and video to the gallery, text to a text tab, files to a new tab) in place of the media grid and file chips; `TaskChatProtocolCard`'s resource rows render deliverables, documents and attachments as one V1 row each, opening where the card did. - `ArtifactRow` keeps trailing controls beside its own control (no nested interactive elements) and can be a link, an in-app anchor, a new-tab anchor or a button; adapters read work products, documents, attachments and message references. - Add a catalogued Storybook story, `Components/Artifact V1 primitives`, under `documents-artifacts`, on fixtures. - Add tests: the primitives (kinds, dates, print fitting, tiles, rows), the hook, the Artifacts page, the panel's Artifacts tab, the task-chat bubble and the protocol card, each with the setting on and off; the settings page and server tests list the new key. - Add `doc/plans/2026-10-22-artifacts-mvp-v1.md`. ## Verification - Run `cd ui && pnpm typecheck`: the Storybook catalog accepts the new story, and `tsc -b` is clean apart from the five pre-existing `PluginOrganizationSwitcher` errors. - Run `pnpm check:token-gates` and `node scripts/check-maps.mjs`: clean. - Run `cd ui && npx vitest run src/components/artifacts src/components/issue-properties src/components/task-chat/TaskChatBubble.test.tsx src/components/task-chat/TaskChatBubble.artifactsV1.test.tsx src/components/task-chat/TaskChatProtocolCard.test.tsx src/components/task-chat/TaskChatProtocolCard.artifactsV1.test.tsx src/pages/Artifacts.test.tsx src/hooks/useArtifactsV1Enabled.test.ts src/pages/InstanceExperimentalSettings.test.tsx`: the suites pass (151 tests in the last run). - Run `cd packages/shared && npx vitest run src/feature-catalog.test.ts` and `cd server && npx vitest run src/__tests__/instance-settings-service.test.ts`: pass. - Manual: open Settings > Experimental, turn on Artifacts V1. On the Artifacts page the view row reads Tasks, All files, Images, Videos, Documents, Text, Files; tasks with several artifacts show as stacks, hovering fans the pages and lifts the artwork (not under reduced motion); Images shows single tiles with bordered prints. Open a task's Artifacts tab: one list of rows, newest first; a Markdown work product still expands into its review surface, a document still opens in its tab. In the thread, a message's files sit under its footer as rows. Turn the setting off: everything returns to the current presentation. - Storybook: `Tasks and conversations / Documents, attachments, and work products / Components / Artifact V1 primitives`. - Not run: the full `pnpm test:run` and `pnpm build` (CI runs them), and a browser pass against a dev instance with real artifacts on the three surfaces; that pass is the remaining step before this leaves draft. ## Risks - Off by default; with the setting off, the only changes on a product path are an optional `filters` prop on the toolbar, an optional `face` prop on two panel rows, a read of the setting in the bubble and the protocol card, and the new token declarations. - With the setting on, the Artifacts page's view tabs set two URL parameters (`groupBy`, `kind`) at once; the group-by menu and search are unchanged. - A picture with unknown dimensions renders at 4:3 until it loads, then the print refits once; the frame's height is fixed so nothing below it moves. - About thirty new `--artifact-*` tokens, namespaced and documented in place. ## Model Used - Claude Fable 5.1 by Anthropic, model ID `claude-fable-5-1`, used through Claude Code in the desktop app. Extended reasoning was on. Capabilities used: tool use (shell, file edits, a headless Playwright browser for the lab's measurements, the Figma MCP server for the mocks during the lab work) and image input. The context window is the Claude Code session's; its exact size is not surfaced to the session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above (#17213 is the lab this builds on) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (the suites listed above; CI runs the full set) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (the plan document; no command or product behaviour changes outside the setting) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
467 lines
20 KiB
TypeScript
467 lines
20 KiB
TypeScript
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
|
|
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
|
|
import { ArtifactRow, ArtifactRowGroup, inlineRowStyle, subjectFromAttachmentRef } from "@/components/artifacts/v1";
|
|
import { isTextAttachment } from "@/lib/issue-attachments";
|
|
import { ArtifactPreview } from "@/components/artifacts/ArtifactCard";
|
|
import { isVideoLikeOutput } from "@/lib/issue-output";
|
|
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
|
|
import { Identity } from "../Identity";
|
|
import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author";
|
|
import { useCallback, useContext, useState, type ReactNode } from "react";
|
|
import { useEmailComment } from "@/components/EmailMessageCard";
|
|
import type { IssueAttachment } from "@paperclipai/shared";
|
|
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
|
import { cn } from "@/lib/utils";
|
|
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
|
import { MarkdownBody } from "@/components/MarkdownBody";
|
|
import {
|
|
ImageGalleryModal,
|
|
type GalleryMediaItem,
|
|
} from "@/components/ImageGalleryModal";
|
|
import {
|
|
Attachment,
|
|
AttachmentContent,
|
|
AttachmentDescription,
|
|
AttachmentGroup,
|
|
AttachmentMedia,
|
|
AttachmentTitle,
|
|
AttachmentTrigger,
|
|
} from "@/components/ui/attachment";
|
|
import {
|
|
extractAttachmentRefs,
|
|
extractImageRefs,
|
|
fileKindForAttachment,
|
|
formatFileSize,
|
|
hydrateAttachmentRefs,
|
|
isImageAttachment,
|
|
stripStandaloneImageEmbeds,
|
|
type AttachmentRef,
|
|
} from "./task-chat-attachments";
|
|
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
|
|
import type { TaskChatMessageItem } from "./task-chat-model";
|
|
|
|
interface TaskChatBubbleProps {
|
|
item: TaskChatMessageItem;
|
|
/** Requeues a blocked task after a no-live-execution-path recovery notice. */
|
|
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
|
|
tryAgainNoLiveExecutionPathPending?: boolean;
|
|
/** Disable the entrance animation when replacing an already-visible live response. */
|
|
animateEntry?: boolean;
|
|
/** Action shown beside the queued state for an interruptible message. */
|
|
queuedAction?: ReactNode;
|
|
/**
|
|
* The settled run turn rendered on this bubble's footer line (round 9):
|
|
* replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools"
|
|
* (the timestamp leads the summary), expandable to the nested tool history.
|
|
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
|
|
*/
|
|
attachedTurn?: ReactNode;
|
|
/** New-runner Worked header, rendered above the durable final response. */
|
|
beforeTurn?: ReactNode;
|
|
/** The durable runner turn already owns the shared agent identity row. */
|
|
hideAgentIdentity?: boolean;
|
|
/**
|
|
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
|
|
* Rendered here only for a runless reply (leading the bare timestamp); when
|
|
* an attached turn is present it owns these via its `leading` slot instead,
|
|
* so this bubble skips them. Human/system bubbles pass nothing.
|
|
*/
|
|
actions?: ReactNode;
|
|
attachments?: IssueAttachment[];
|
|
}
|
|
|
|
function initialsForName(name: string) {
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) {
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
return name.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
export function TaskChatAgentIdentity({
|
|
agentName,
|
|
agentIcon,
|
|
agent,
|
|
}: {
|
|
agentName: string;
|
|
agentIcon?: string | null;
|
|
agent?: AvatarAgent;
|
|
}) {
|
|
return (
|
|
<span
|
|
className="flex items-center gap-2 px-1"
|
|
data-testid="task-chat-agent-identity"
|
|
>
|
|
<span data-testid="task-chat-agent-avatar"><AgentAvatar agent={agent} name={agentName} size={24} /></span>
|
|
<span className="text-sm font-semibold text-foreground">{agentName}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Viewer-relative message row — the primary legibility signal. Own messages sit
|
|
* right in a solid accent bubble; agents and other humans sit on the left
|
|
* directly on the page surface with their own avatar and name;
|
|
* system notices are centered and recede.
|
|
*/
|
|
function galleryItemForMedia(
|
|
src: string,
|
|
name?: string,
|
|
attachment?: ReturnType<typeof hydrateAttachmentRefs>[number],
|
|
): GalleryMediaItem {
|
|
return {
|
|
id: attachment?.id ?? src,
|
|
contentPath: attachment?.openPath ?? src,
|
|
openPath: attachment?.openPath,
|
|
downloadPath: attachment?.downloadPath,
|
|
contentType: attachment?.contentType ?? "",
|
|
originalFilename: name?.trim() ? name : "image",
|
|
};
|
|
}
|
|
|
|
function uniqueAttachmentRefs(refs: AttachmentRef[]): AttachmentRef[] {
|
|
return refs.filter(
|
|
(ref, index) =>
|
|
refs.findIndex(
|
|
(candidate) =>
|
|
(ref.id && candidate.id === ref.id) || candidate.url === ref.url,
|
|
) === index,
|
|
);
|
|
}
|
|
|
|
export function TaskChatBubble(props: TaskChatBubbleProps) {
|
|
const email = useEmailComment(props.item.id);
|
|
return email ?? <TaskChatBubbleContent {...props} />;
|
|
}
|
|
function TaskChatBubbleContent({
|
|
item,
|
|
animateEntry = true,
|
|
queuedAction,
|
|
attachedTurn,
|
|
beforeTurn,
|
|
hideAgentIdentity = false,
|
|
actions,
|
|
attachments = [],
|
|
onTryAgainNoLiveExecutionPath,
|
|
tryAgainNoLiveExecutionPathPending,
|
|
}: TaskChatBubbleProps) {
|
|
const streamlined = useStreamlinedTaskChatPresentation();
|
|
const { enabled: artifactsV1 } = useArtifactsV1Enabled();
|
|
const openText = useContext(TextAttachmentContext);
|
|
// Task attachments share the page gallery; standalone images retain the bubble viewer.
|
|
const openIssueGallery = useContext(IssueGalleryContext);
|
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
|
// Keep MarkdownBody's memo boundary intact when only the live tail changes.
|
|
// A fresh callback here reparses every historical response on every update.
|
|
const openImage = useCallback((src: string) => {
|
|
if (!openIssueGallery?.(src)) setLightboxSrc(src);
|
|
}, [openIssueGallery]);
|
|
if (item.interstitial) {
|
|
// Interstitial updates are ephemeral (PAP-361): while streaming the text
|
|
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
|
|
// once finished it renders nowhere — the run log / classic transcript
|
|
// remain the archive. Never rendered as a bubble.
|
|
return null;
|
|
}
|
|
|
|
if (item.author === "system") {
|
|
// Collapsed humanized one-liner, expandable to the full detail (PAP-443).
|
|
return (
|
|
<TaskChatSystemNotice
|
|
item={item}
|
|
onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath}
|
|
tryAgainNoLiveExecutionPathPending={tryAgainNoLiveExecutionPathPending}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const isHuman = item.author === "human";
|
|
const isOwnMessage = isHuman && item.isCurrentUser === true;
|
|
const sentFromIMessage = isHuman && item.sourceChannel === "imessage-photon";
|
|
// Non-image file references ("[name](/api/attachments/…/content)") render as
|
|
// attachment chips under the bubble; link-only lines leave the body text.
|
|
const { refs: linkedRefs, text: bodyWithoutAttachmentLinks } =
|
|
extractAttachmentRefs(item.text);
|
|
const embeddedImageRefs = extractImageRefs(bodyWithoutAttachmentLinks);
|
|
const bodyText = stripStandaloneImageEmbeds(bodyWithoutAttachmentLinks);
|
|
const hydratedLinkedRefs = hydrateAttachmentRefs(linkedRefs, attachments);
|
|
const hydratedEmbeddedRefs = hydrateAttachmentRefs(
|
|
embeddedImageRefs,
|
|
attachments,
|
|
);
|
|
const boundAttachmentRefs: AttachmentRef[] = attachments
|
|
.filter((attachment) => attachment.issueCommentId === item.id)
|
|
.map((attachment) => ({
|
|
id: attachment.id,
|
|
name: attachment.originalFilename?.trim() || "attachment",
|
|
url: attachment.contentPath,
|
|
contentType: attachment.contentType,
|
|
byteSize: attachment.byteSize,
|
|
openPath: attachment.openPath,
|
|
downloadPath: attachment.downloadPath,
|
|
}));
|
|
const imageRefs = uniqueAttachmentRefs([
|
|
...hydratedEmbeddedRefs,
|
|
...hydratedLinkedRefs.filter(isImageAttachment),
|
|
...boundAttachmentRefs.filter(isImageAttachment),
|
|
]);
|
|
const attachmentRefs = uniqueAttachmentRefs([
|
|
...hydratedLinkedRefs.filter((ref) => !isImageAttachment(ref)),
|
|
...boundAttachmentRefs.filter((ref) => !isImageAttachment(ref)),
|
|
]);
|
|
const mediaRefs = [...imageRefs, ...attachmentRefs.filter((ref) => isVideoLikeOutput(ref.contentType, ref.name))];
|
|
const galleryItems: GalleryMediaItem[] =
|
|
lightboxSrc !== null && !mediaRefs.some((ref) => ref.url === lightboxSrc)
|
|
? // A clicked image the extractor missed (e.g. inline HTML) still gets a
|
|
// single-item lightbox rather than nothing.
|
|
[galleryItemForMedia(lightboxSrc)]
|
|
: mediaRefs.map((ref) => galleryItemForMedia(ref.url, ref.name, ref));
|
|
const lightboxIndex =
|
|
lightboxSrc === null
|
|
? -1
|
|
: Math.max(0, mediaRefs.findIndex((ref) => ref.url === lightboxSrc));
|
|
return (
|
|
<div
|
|
data-conversation-message={isHuman ? "human" : "agent"}
|
|
className={cn(
|
|
"flex w-full flex-col gap-1",
|
|
animateEntry && "tc-enter-bubble",
|
|
isOwnMessage ? "items-end" : "items-start",
|
|
)}
|
|
>
|
|
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
|
|
{isHuman && !item.isCurrentUser && item.authorName ? (
|
|
<span className="px-1 text-sm font-semibold text-foreground" data-testid="task-chat-human-identity" data-author-user-id={item.authorUserId}>
|
|
<Identity name={item.authorName} avatarUrl={paperclipHumanAvatarUrl(item.authorAvatarUrl)} size="default" className="gap-2" />
|
|
</span>
|
|
) : null}
|
|
{!isHuman && item.authorName && !hideAgentIdentity ? (
|
|
<TaskChatAgentIdentity
|
|
agentName={item.authorName}
|
|
agentIcon={item.agentIcon}
|
|
agent={item.agent}
|
|
/>
|
|
) : null}
|
|
{bodyText.length > 0 ? (
|
|
<div
|
|
data-conversation-text={isHuman ? (isOwnMessage ? "human" : "progress") : item.channel === "progress" ? "progress" : "final"}
|
|
// Stable hook so the TaskChatLab bubble-treatment explorations
|
|
// (PAP-501) can scope background/border overrides to the agent
|
|
// bubble body without touching the live thread.
|
|
data-testid={
|
|
isHuman ? "task-chat-human-bubble" : "task-chat-agent-bubble"
|
|
}
|
|
className={cn(
|
|
"break-words py-2 text-sm",
|
|
isOwnMessage
|
|
? "max-w-(--pct-85) rounded-2xl rounded-br-sm bg-(--liveness-blue) px-3.5 text-white"
|
|
: "w-full bg-transparent px-1 text-foreground",
|
|
)}
|
|
>
|
|
<MarkdownBody
|
|
// The own-message bubble sits on the solid --liveness-blue accent, so the
|
|
// prose body text must follow the bubble's `text-white` rather than
|
|
// the default light-mode prose color (which reads as black on blue).
|
|
// `paperclip-markdown-on-accent` flips prose tokens to currentColor
|
|
// (== inherited white) in both themes; dark mode was already correct
|
|
// only because `prose-invert` happened to lighten the text.
|
|
className={isOwnMessage ? "paperclip-markdown-on-accent" : undefined}
|
|
softBreaks
|
|
linkIssueReferences
|
|
onImageClick={openImage}
|
|
>
|
|
{bodyText}
|
|
</MarkdownBody>
|
|
</div>
|
|
) : null}
|
|
{!artifactsV1 && imageRefs.length > 0 ? (
|
|
<div
|
|
className="flex max-w-(--pct-85) flex-col gap-2"
|
|
data-testid="task-chat-bubble-media"
|
|
>
|
|
<span className="text-xs text-muted-foreground">
|
|
Images · {imageRefs.length}
|
|
</span>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{imageRefs
|
|
.slice(0, imageRefs.length > 4 ? 3 : 4)
|
|
.map((ref, index) => (
|
|
<button
|
|
key={ref.url}
|
|
type="button"
|
|
className="group aspect-video min-w-0 overflow-hidden rounded-md bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={`Open ${ref.name || `image ${index + 1}`}`}
|
|
onClick={() => openImage(ref.url)}
|
|
>
|
|
<img
|
|
src={ref.openPath ?? ref.url}
|
|
alt={ref.name}
|
|
loading="lazy"
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-(--s-1_02)"
|
|
/>
|
|
</button>
|
|
))}
|
|
{imageRefs.length > 4 ? (
|
|
<button
|
|
type="button"
|
|
className="aspect-video min-w-0 rounded-md bg-muted text-sm font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={`Open ${imageRefs.length - 3} more screenshots`}
|
|
onClick={() => openImage(imageRefs[3].url)}
|
|
>
|
|
+{imageRefs.length - 3}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{imageRefs.map((ref) => ref.name || "image").join(" · ")}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{!artifactsV1 && attachmentRefs.length > 0 ? (
|
|
<div className="flex max-w-(--pct-85) flex-col gap-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
Files · {attachmentRefs.length}
|
|
</span>
|
|
<AttachmentGroup data-testid="task-chat-bubble-attachments">
|
|
{attachmentRefs.map((ref) => {
|
|
const kind = fileKindForAttachment(ref);
|
|
const KindIcon = kind.icon;
|
|
const size = formatFileSize(ref.byteSize);
|
|
const video = isVideoLikeOutput(ref.contentType, ref.name);
|
|
return (
|
|
<Attachment key={ref.url} size="sm">
|
|
<AttachmentMedia className={video ? "aspect-video group-data-[size=sm]/attachment:w-20" : undefined}>
|
|
{video ? <ArtifactPreview artifact={{ title: ref.name, contentPath: ref.openPath ?? ref.url, mediaKind: "video" }} /> : <KindIcon aria-hidden />}
|
|
</AttachmentMedia>
|
|
<AttachmentContent>
|
|
<AttachmentTitle className="max-w-48">
|
|
{ref.name}
|
|
</AttachmentTitle>
|
|
<AttachmentDescription className="max-w-48">
|
|
{size ? `${kind.label} · ${size}` : kind.label}
|
|
</AttachmentDescription>
|
|
</AttachmentContent>
|
|
<AttachmentTrigger
|
|
aria-label={`Open ${ref.name}`}
|
|
onClick={video ? () => openImage(ref.url) : undefined}
|
|
render={video ? <button type="button" /> :
|
|
<a
|
|
href={ref.openPath ?? ref.url}
|
|
onClick={(event) => {
|
|
if (openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name }) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) {
|
|
event.preventDefault(); openText(ref.id, ref.name);
|
|
}
|
|
}}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
/>
|
|
}
|
|
/>
|
|
</Attachment>
|
|
);
|
|
})}
|
|
</AttachmentGroup>
|
|
</div>
|
|
) : null}
|
|
{!isHuman && item.verificationCaveats?.length ? (
|
|
<div
|
|
className="mx-1 w-(--sz-calc-7) rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2 text-xs"
|
|
data-testid="task-chat-verification-caveats"
|
|
>
|
|
<p className="font-medium text-amber-800 dark:text-amber-200">
|
|
Verification caveat
|
|
</p>
|
|
<ul className="mt-1 space-y-1 text-muted-foreground">
|
|
{item.verificationCaveats.map((caveat, index) => (
|
|
<li key={`${caveat.commandOrCheck}:${index}`}>
|
|
<span className="font-mono text-foreground">
|
|
{caveat.commandOrCheck}
|
|
</span>
|
|
{caveat.reasonCode
|
|
? ` · ${caveat.reasonCode.replaceAll("_", " ")}`
|
|
: ""}
|
|
{caveat.detail ? (
|
|
<span className="block">{caveat.detail}</span>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
{item.optimistic ? (
|
|
<span className="flex items-center gap-1 px-1 text-(length:--text-micro) text-muted-foreground">
|
|
<span>{item.optimistic === "queued" ? "Queued" : "Sending…"}</span>
|
|
{item.optimistic === "queued" ? queuedAction : null}
|
|
</span>
|
|
) : attachedTurn ? (
|
|
// The settled turn takes over the footer line: timestamp + "✓ Worked"
|
|
// summary, always visible; expanding stretches beneath the bubble. The
|
|
// copy/👍/👎 actions (PAP-413) ride the turn's summary row via its
|
|
// `leading` slot — not this wrapper — so they stay anchored to the
|
|
// summary line when the tool history expands beneath it.
|
|
<div
|
|
className="self-stretch"
|
|
data-testid="task-chat-bubble-attached-turn"
|
|
>
|
|
{attachedTurn}
|
|
</div>
|
|
) : actions ? (
|
|
streamlined ? (
|
|
<div className="flex w-full items-center justify-between gap-2 px-1">
|
|
{item.timestamp ? (
|
|
<span className="text-(length:--text-micro) text-muted-foreground">
|
|
{item.timestamp}
|
|
</span>
|
|
) : null}
|
|
{actions}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1">
|
|
{actions}
|
|
{item.timestamp ? (
|
|
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
|
{item.timestamp}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)
|
|
) : item.timestamp || sentFromIMessage ? (
|
|
// Timestamps are always visible (round 9) — no longer hover-revealed.
|
|
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
|
{sentFromIMessage ? "Sent from iMessage" : null}
|
|
{sentFromIMessage && item.timestamp ? " · " : null}
|
|
{item.timestamp}
|
|
</span>
|
|
) : null}
|
|
{artifactsV1 && imageRefs.length + attachmentRefs.length > 0 ? (
|
|
// Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): the files a message produced hang under its
|
|
// footer, 12px below, as rows inline at equal widths, wrapping past three. Pictures and video open the
|
|
// gallery, text opens a text tab when the panel offers one, anything else opens in a new tab.
|
|
<div className="w-full pt-2" data-testid="task-chat-bubble-artifacts-v1">
|
|
<ArtifactRowGroup>
|
|
{[...imageRefs, ...attachmentRefs].map((ref) => {
|
|
const subject = subjectFromAttachmentRef(ref);
|
|
const picture = isImageAttachment(ref) || isVideoLikeOutput(ref.contentType, ref.name);
|
|
const textTab = Boolean(openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name }));
|
|
if (picture) return <ArtifactRow key={ref.url} subject={subject} onClick={() => openImage(ref.url)} style={inlineRowStyle} />;
|
|
// A text file keeps its link (modifier clicks, copying the address) while a plain click opens the text tab.
|
|
if (textTab) return <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external onClick={() => openText?.(ref.id!, ref.name)} style={inlineRowStyle} />;
|
|
return <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external style={inlineRowStyle} />;
|
|
})}
|
|
</ArtifactRowGroup>
|
|
</div>
|
|
) : null}
|
|
{lightboxSrc !== null && lightboxIndex >= 0 ? (
|
|
<ImageGalleryModal
|
|
items={galleryItems}
|
|
initialIndex={lightboxIndex}
|
|
open
|
|
onOpenChange={(open) => {
|
|
if (!open) setLightboxSrc(null);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|