diff --git a/DESIGN.md b/DESIGN.md index a5fd4335b0f..e2ccdd19602 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -95,6 +95,16 @@ missing photos use the shared initials fallback. Match ownership by the stored author user ID and the authenticated viewer ID, never by display name or the bot that relayed a message from another channel. +Images produced in a task turn appear as compact thumbnails in a wrapping grid. +Keep the first image's row and each tile mounted as more results arrive. Documents, +video, and authored messages retain their chronological positions and separate +image batches. Open thumbnails in the existing gallery, with keyboard navigation +and downloads. Images without an originating run stay separate. Generated native +image preparation receipts are redundant with those tiles; identify them by the +attachment-preparation marker and links to the image, author, and originating run, +independent of receipt wording or image title. Older receipts use their exact +historical preparation sentence as a fallback. Authored progress text stays visible. + ## Contextual feedback Keep the runner turn's avatar, name, status header and current-activity slot diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 3d74f23e438..f5a8eaa5493 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1217,6 +1217,8 @@ export interface IssueCommentMetadata { }; /** Producer-classified diagnostics; task interruption UI owns system repair. */ recoveryResponsibility?: "system" | "user"; + /** Generated file-preparation receipt; presentation only, never authorization. */ + attachmentPreparation?: { attachmentId: string }; /** Inbound channel attribution; never an authorization input. */ sourceChannel?: ChatProvider; sourceRunId?: string | null; diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 80328c7d2c2..2ffd5488cda 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -55,6 +55,14 @@ describe("issue validators", () => { } }); + it("accepts an optional attachment-preparation identity and rejects malformed markers", () => { + const metadata = { version: 1, sections: [{ rows: [{ type: "text", text: "Details" }] }] }; + const attachmentPreparation = { attachmentId: "9af8228f-0be7-45ae-a104-6fbe0af6f1d3" }; + expect(issueCommentMetadataSchema.parse({ ...metadata, attachmentPreparation }).attachmentPreparation).toEqual(attachmentPreparation); + expect(issueCommentMetadataSchema.parse(metadata).attachmentPreparation).toBeUndefined(); + expect(issueCommentMetadataSchema.safeParse({ ...metadata, attachmentPreparation: { attachmentId: "bad-id" } }).success).toBe(false); + }); + it("uses the same bounded unique upload ID contract for comment and update requests", () => { const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3"; expect( diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 60b75f0ce6c..3218666734a 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1109,6 +1109,7 @@ export const issueCommentMetadataSchema = z contentHash: z.string().regex(/^[a-f0-9]{64}$/), }).strict().optional(), recoveryResponsibility: z.enum(["system", "user"]).optional(), + attachmentPreparation: z.object({ attachmentId: z.uuid() }).strict().optional(), sourceChannel: z.enum(CHAT_PROVIDERS).optional(), sourceRunId: z.string().guid().nullable().optional(), authorizationReason: z diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 33cecdfe369..d6911172f7b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1712,6 +1712,7 @@ function withAgentCommentAuthorizationMetadata( return { version: 1, ...(metadata?.chatMessage ? { chatMessage: metadata.chatMessage } : {}), + ...(metadata?.attachmentPreparation ? { attachmentPreparation: metadata.attachmentPreparation } : {}), ...(metadata?.sourceRunId !== undefined ? { sourceRunId: metadata.sourceRunId } : {}), diff --git a/server/src/services/native-runtime/chat-attachment-reuse.test.ts b/server/src/services/native-runtime/chat-attachment-reuse.test.ts index 7870a8033d9..627650e4810 100644 --- a/server/src/services/native-runtime/chat-attachment-reuse.test.ts +++ b/server/src/services/native-runtime/chat-attachment-reuse.test.ts @@ -564,6 +564,9 @@ describe("native same-conversation chat attachment reuse", () => { }); const preparedId = (first as { prepared: { attachmentId: string } }) .prepared.attachmentId; + const [preparationComment] = await db.select().from(issueComments) + .where(eq(issueComments.id, (first as { prepared: { commentId: string } }).prepared.commentId)); + expect(preparationComment.metadata).toMatchObject({ attachmentPreparation: { attachmentId: preparedId } }); const attachments = await db .select({ id: issueAttachments.id, diff --git a/server/src/services/native-runtime/chat-attachment-reuse.ts b/server/src/services/native-runtime/chat-attachment-reuse.ts index e4be5acb926..b037d8b1371 100644 --- a/server/src/services/native-runtime/chat-attachment-reuse.ts +++ b/server/src/services/native-runtime/chat-attachment-reuse.ts @@ -1637,6 +1637,7 @@ export async function prepareReusedChatAttachment(input: { { attachmentIds: [attachment.id], authorizationReason: "paperclip_runner_protocol", + metadata: { version: 1, sections: [], attachmentPreparation: { attachmentId: attachment.id } }, }, input.db, ); diff --git a/server/src/services/native-runtime/native-runner-file-handoff.test.ts b/server/src/services/native-runtime/native-runner-file-handoff.test.ts index e302c432190..a01a17d0dd7 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.test.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.test.ts @@ -508,6 +508,7 @@ describe("native runner file handoff", () => { body: "Prepared Requested answer for this response.", metadata: expect.objectContaining({ authorizationReason: "paperclip_runner_protocol", + attachmentPreparation: { attachmentId: first.entityRefs[0] }, }), }), ]); diff --git a/server/src/services/native-runtime/native-runner-file-handoff.ts b/server/src/services/native-runtime/native-runner-file-handoff.ts index 420380500ea..1df27c18660 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.ts @@ -1287,6 +1287,7 @@ export async function prepareNativeRunnerFileHandoff(input: { { attachmentIds: [attachment.id], authorizationReason: "paperclip_runner_protocol", + metadata: { version: 1, sections: [], attachmentPreparation: { attachmentId: attachment.id } }, }, input.db, ); diff --git a/tests/storybook-visual/feed-image-grids.spec.ts b/tests/storybook-visual/feed-image-grids.spec.ts new file mode 100644 index 00000000000..86ffb25fc5d --- /dev/null +++ b/tests/storybook-visual/feed-image-grids.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; + +for (const theme of ["light", "dark"]) { + for (const story of ["pap-2525", "mobile", "classic", "mixed-results", "single-image", "unavailable-image", "streaming-results", "gallery", "production-feed"]) { + test(`${story} [${theme}]`, async ({ page }, testInfo) => { + if (story === "mobile") await page.setViewportSize({ width: 390, height: 844 }); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(`/iframe.html?id=task-chat-feed-image-grids--${story}&viewMode=story&globals=theme:${theme}`); + await expect(page.getByTestId("task-chat-image-grid").first()).toBeVisible({ timeout: 60000 }); + await expect.poll(() => page.evaluate(() => { + const channel = (window as unknown as { __STORYBOOK_ADDONS_CHANNEL__?: { data: Record> } }).__STORYBOOK_ADDONS_CHANNEL__; + return channel?.data.storyFinished?.[0]?.status; + }), { timeout: 30000 }).toBeDefined(); + const report = await page.evaluate(() => { + const channel = (window as unknown as { __STORYBOOK_ADDONS_CHANNEL__: { data: { + playFunctionThrewException?: unknown; + storyFinished?: Array<{ status?: string; reporters?: Array<{ result?: { violations?: Array<{ id: string; nodes: Array<{ failureSummary: string }> }> } }> }>; + } } }).__STORYBOOK_ADDONS_CHANNEL__; + const result = channel.data.storyFinished?.[0]; + return { status: result?.status, exception: channel.data.playFunctionThrewException, + violations: result?.reporters?.flatMap((r) => r.result?.violations?.map((v) => ({ id: v.id, nodes: v.nodes.map((n) => n.failureSummary) })) ?? []) }; + }); + expect(report.status, JSON.stringify(report)).toBe("success"); + expect(errors).toEqual([]); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + const grid = page.getByTestId("task-chat-image-grid").first(); + if (story === "single-image" || story === "unavailable-image") await expect(grid.getByRole("button")).toHaveCount(1); + if (story === "mixed-results") await expect(page.getByTestId("task-chat-image-grid")).toHaveCount(2); + if (["pap-2525", "mobile", "classic", "production-feed", "streaming-results"].includes(story)) { + await expect(grid.getByRole("button")).toHaveCount(7); + const tiles = grid.getByRole("button"); + const first = await tiles.nth(0).boundingBox(); + const second = await tiles.nth(1).boundingBox(); + expect(second!.y).toBe(first!.y); + expect(second!.x).toBeGreaterThan(first!.x); + expect(first!.width).toBeLessThan(200); + expect(await grid.evaluate(el => getComputedStyle(el).gridTemplateColumns.split(" ").length)).toBe(story === "mobile" ? 2 : 4); + } + if (story !== "unavailable-image") { + for (const img of await page.locator('[data-testid="task-chat-image-grid"] img').all()) { + await img.scrollIntoViewIfNeeded(); + await expect.poll(() => img.evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth > 0)).toBe(true); + } + } else { + await expect(grid.locator("img")).toHaveCount(0); + await expect(grid.getByText("Image", { exact: true }).first()).toBeVisible(); + } + if (story === "production-feed") await expect(page.getByText(/^Prepared .* for this response\.$/)).toHaveCount(0); + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ path: testInfo.outputPath(`${story}-${theme}.png`), fullPage: true }); + }); + } +} diff --git a/ui/src/components/ImageGalleryModal.test.tsx b/ui/src/components/ImageGalleryModal.test.tsx index d203c9c5ecb..f361cf277f2 100644 --- a/ui/src/components/ImageGalleryModal.test.tsx +++ b/ui/src/components/ImageGalleryModal.test.tsx @@ -131,4 +131,18 @@ describe("ImageGalleryModal", () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); + + it.each(["/images/original.png", undefined])("offers the original image separately from download with openPath %s", async (openPath) => { + const item = makeMediaItem({ openPath }); + await act(async () => { + root.render( undefined} />); + }); + await flushReact(); + + const original = document.body.querySelector('a[aria-label="Open original screenshot.png"]'); + expect(original?.getAttribute("href")).toBe(openPath ?? item.contentPath); + expect(original?.target).toBe("_blank"); + expect(original?.rel).toContain("noopener"); + expect(document.body.querySelector('a[aria-label="Download screenshot.png"]')?.getAttribute("href")).toBe(item.downloadPath); + }); }); diff --git a/ui/src/components/ImageGalleryModal.tsx b/ui/src/components/ImageGalleryModal.tsx index 0c4feac0426..8279ff381d7 100644 --- a/ui/src/components/ImageGalleryModal.tsx +++ b/ui/src/components/ImageGalleryModal.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { Dialog as DialogPrimitive } from "radix-ui"; -import { ChevronLeft, ChevronRight, Download, X } from "lucide-react"; -import { attachmentDownloadPath, attachmentFilename } from "@/lib/issue-attachments"; +import { ChevronLeft, ChevronRight, Download, ExternalLink, X } from "lucide-react"; +import { attachmentDownloadPath, attachmentFilename, attachmentOpenPath } from "@/lib/issue-attachments"; import { isVideoLikeOutput } from "@/lib/issue-output"; export interface GalleryMediaItem { @@ -112,9 +112,20 @@ export function ImageGalleryModal({ {filename}
- + {currentIndex + 1} / {items.length} + e.stopPropagation()} + > + + ) { } describe("TaskChatThread draft pass-through", () => { + it.each(["original", "changed"])("groups PAP-2525 native image uploads with %s receipt wording", async (wording) => { + const comment = (id: string, body: string, runId?: string) => ({ + id, body, companyId: "company", issueId: "issue", authorType: "agent" as const, authorAgentId: "agent", authorUserId: null, + createdByRunId: runId ?? null, createdAt: new Date("2026-11-06T13:32:00Z"), updatedAt: new Date("2026-11-06T13:32:00Z"), + metadata: null, presentation: null, + }); + type Props = ComponentProps; + const workProducts = [1, 2].map(n => ({ + id: `image-${n}`, type: "artifact", provider: "paperclip", title: `Screen ${n}`, status: "active", reviewState: "none", healthStatus: "unknown", + createdByRunId: "images-run", createdAt: new Date(`2026-11-06T13:31:0${n}Z`), updatedAt: new Date(`2026-11-06T13:31:0${n}Z`), + metadata: { attachmentId: `00000000-0000-4000-8000-00000000000${n}`, originalFilename: `screen-${n}.png`, contentType: "image/png", byteSize: 2048, contentPath: `/api/attachments/00000000-0000-4000-8000-00000000000${n}/content`, openPath: `/api/attachments/00000000-0000-4000-8000-00000000000${n}/content`, downloadPath: `/api/attachments/00000000-0000-4000-8000-00000000000${n}/content?download=1` }, + })) as unknown as NonNullable; + const attachments = workProducts.map((w, n) => ({ id: w.metadata!.attachmentId, issueCommentId: `receipt-${n}`, originatingRunId: "images-run", createdByAgentId: "agent", + originalFilename: `screen-${n}.png`, contentType: "image/png", contentPath: w.metadata!.contentPath, + createdAt: w.createdAt, updatedAt: w.updatedAt, byteSize: 2048, + })) as unknown as NonNullable; + const comments = workProducts.map((w, n) => ({ ...comment(`receipt-${n}`, wording === "original" ? `Prepared ${w.title} for this response.` : `Image ready: ${w.title}`, "images-run"), + createdAt: new Date(new Date(w.createdAt).getTime() + 100), + metadata: { version: 1 as const, sections: [], authorizationReason: "paperclip_runner_protocol", + ...(wording === "changed" ? { attachmentPreparation: { attachmentId: String(w.metadata!.attachmentId) } } : {}), + }, + })); + const props: Props = { comments, workProducts, attachments, onAdd: async () => {}, issueStatus: "done" }; + render(); + await settleThreadLayout(); + for (const receipt of comments) expect(container.textContent).not.toContain(receipt.body); + expect(container.querySelectorAll('[data-testid="task-chat-image-grid"]')).toHaveLength(1); + expect(container.querySelectorAll('button[aria-label^="Open gallery:"]')).toHaveLength(2); + // The same words written by a person are conversation and must remain. + render(); + expect(container.textContent).toContain("Prepared Screen 1 for this response."); + // Native progress messages share the protocol marker and can have attachments. + render(); + expect(container.textContent).toContain("Keep this image context"); + }); + it("aligns the desktop thread header with the side-panel tab row", () => { renderThread({ comments: [ diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index b5ff2ac7dd9..e420633b044 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1,3 +1,4 @@ +import { imagePreparationReceiptIds } from "./task-chat/image-resources"; import type { ScrollingHistory } from "@/components/task-chat/TaskMessageScroller"; import { TaskChatShimmerScope } from "@/components/task-chat/TaskChatShimmer"; import { useRunnerSubmission } from "@/hooks/useRunnerSubmission"; @@ -909,9 +910,14 @@ export function TaskChatThread(props: TaskChatThreadProps) { // Historical placeholder comments remain untouched in storage. Projection // prefers the accepted semantic result so runs such as DOT-20 recover their // real upstream response without a data migration. + const imageReceipts = useMemo( + () => imagePreparationReceiptIds(comments, attachments, workProducts), + [comments, attachments, workProducts], + ); const projectedComments = useMemo( () => comments.flatMap((comment) => { + if (imageReceipts.has(comment.id)) return []; if (comment.authorType === "system" && comment.metadata?.recoveryResponsibility === "system") return []; if (statusRelevance.isHistoricalNotice(comment)) return []; if (isRedundantAiRecoveryNotice(comment, interactions)) return []; @@ -927,7 +933,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const summary = acceptedSemanticResultSummary(resultJson); return [summary ? { ...comment, body: summary } : comment]; }), - [comments, interactions, linkedRunMetaById, statusRelevance], + [comments, interactions, linkedRunMetaById, statusRelevance, imageReceipts], ); const commentItems = useMemo( diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index 0fbb5349d9c..db426c518f5 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -24,7 +24,7 @@ function PreviewFrame({ children, className }: { children: React.ReactNode; clas function PlaceholderPreview({ label }: { label?: string }) { return ( -
+
diff --git a/ui/src/components/task-chat/TaskChatImageGrid.tsx b/ui/src/components/task-chat/TaskChatImageGrid.tsx new file mode 100644 index 00000000000..1b33d959b9d --- /dev/null +++ b/ui/src/components/task-chat/TaskChatImageGrid.tsx @@ -0,0 +1,38 @@ +import { useContext, useState } from "react"; +import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard"; +import { ImageGalleryModal } from "@/components/ImageGalleryModal"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; +import { RichWorkProductCard } from "./RichWorkProductCard"; +import { imageResourceSource } from "./image-resources"; +import type { TaskChatMaterializedResourceItem } from "./task-chat-model"; + +export function TaskChatImageGrid({ items }: { items: TaskChatMaterializedResourceItem[] }) { + const openIssueGallery = useContext(IssueGalleryContext); + const [selected, setSelected] = useState(null); + const images = items.flatMap((item) => { + const source = imageResourceSource(item); + return source ? [{ item, source }] : []; + }); + return ( + { + if (openIssueGallery?.(src)) return true; + const index = images.findIndex(({ source }) => source.contentPath === src || source.openPath === src); + if (index < 0) return false; + setSelected(index); + return true; + }}> +
+ {images.map(({ item, source }) => ( +
+ {item.workProduct ? : } +
+ ))} +
+ {selected !== null && selected >= 0 ? ( + ({ ...source, originalFilename: source.filename }))} + initialIndex={selected} open onOpenChange={(open) => { if (!open) setSelected(null); }} /> + ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatLiveTail.tsx b/ui/src/components/task-chat/TaskChatLiveTail.tsx index 8c372c36009..378793336d8 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.tsx @@ -1,3 +1,4 @@ +import { groupImageResources } from "./image-resources"; import type { ReactElement } from "react"; import { MarkdownBody } from "@/components/MarkdownBody"; import type { TaskChatItem, TaskChatRuntimeRequestDecision, TaskChatRuntimeRequestItem } from "./task-chat-model"; @@ -38,7 +39,7 @@ export function TaskChatLiveTail({ const visibleItems = excludeFinal ? items.filter((item) => item.kind !== "message" || item.interstitial) : items; - const rows = buildTurnTimelineRows(visibleItems, true) + const rows = groupImageResources(buildTurnTimelineRows(visibleItems, true)) .map((item) => renderTailRow(item, onRuntimeRequestDecision)) .filter((row): row is ReactElement => row != null); diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx index 2202042154f..1df6eb58da8 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx @@ -177,10 +177,36 @@ describe("TaskChatProtocolCard", () => { workProduct: product, }); - const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Pending"); + const chip = container.querySelector(".status-chip"); expect(chip?.className).toContain("border-dashed"); expect(container.textContent).toContain("Image · 2.0 KB"); - expect(container.textContent).toContain("Open gallery"); + expect(container.querySelector('button[aria-label^="Open gallery:"]')).not.toBeNull(); + }); + + it("opens an image when its original path differs from its preview URL", async () => { + const product = workProduct({ + type: "artifact", + title: "screen.png", + url: "/images/preview.png", + metadata: { contentType: "image/png", originalFilename: "screen.png", openPath: "/images/original.png" }, + }); + renderCard(root, { + id: "resource:image", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "Image", + href: product.url, + workProduct: product, + }); + + await act(async () => { + container.querySelector('button[aria-label="Open gallery: screen.png"]')!.click(); + }); + + expect(document.body.querySelector('[role="dialog"]')).not.toBeNull(); + expect(document.body.querySelector('[role="dialog"] img')?.getAttribute("src")).toBe("/images/preview.png"); }); it.each([ diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.tsx index fe87ce7c94d..b56664bdc6e 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.tsx @@ -1,4 +1,6 @@ import { useContext, useEffect, useMemo, useState } from "react"; +import { TaskChatImageGrid } from "./TaskChatImageGrid"; +import { imageResourceSource } from "./image-resources"; import { TaskChatStatusNotice } from "./TaskChatStatusNotice"; import { isStatusNotice } from "./status-notice-presentation"; import { @@ -1088,6 +1090,9 @@ function ResourceCard({ item: Extract; }) { const { enabled: artifactsV1 } = useArtifactsV1Enabled(); + if (imageResourceSource(item)) { + return ; + } if (artifactsV1) { // Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): one full-width row named after the file. A // registered file keeps the shipped card's actions (gallery, text tab, open raw, download, CSV preview); diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 0f67e5ef92b..3ad93e30966 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -1,3 +1,4 @@ +import { groupImageResources } from "./image-resources"; import { useRef, type ReactNode } from "react"; import type { ExecutionProjection } from "@paperclipai/shared"; import { useSecondTick } from "@/hooks/useSecondTick"; @@ -244,10 +245,10 @@ export function TaskChatRunnerTurn({ const final = finalRef.current.item; const timelineItems = paperclipRunnerTimelineItems(turnItems); const currentActivityItems = currentActivityStatusItems(timelineItems); - const timelineRows = buildTurnTimelineRows( + const timelineRows = groupImageResources(buildTurnTimelineRows( omitProgressRepeatedByResponse(timelineItems, final?.text), !terminal, - ); + )); return (
compactAdjacentErrors(items), [items]); + const { items: compactedItems, retryMarkers } = useMemo(() => compactAdjacentErrors(items), [items]); + const visibleItems = useMemo(() => groupImageResources(compactedItems), [compactedItems]); const retryableMarkerId = onRetryFailedRun || onTryAgainNoLiveExecutionPath ? [...visibleItems] diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index 0b39df2c07a..24f0ab34909 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -1,3 +1,4 @@ +import { groupImageResources } from "./image-resources"; import { useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { TaskChatRunHealthBanner } from "./TaskChatRunHealthBanner"; @@ -115,7 +116,7 @@ export function TaskChatTurn({ className="flex min-w-0 flex-col gap-2 py-1" data-testid="task-chat-turn-timeline" > - {item.items.map((child) => ( + {groupImageResources(item.items).map((child) => (
- {historyMounted && foldedItems.map((child) => ( + {historyMounted && groupImageResources(foldedItems).map((child) => (
{renderChild(child)}
))}
diff --git a/ui/src/components/task-chat/image-resources.test.ts b/ui/src/components/task-chat/image-resources.test.ts new file mode 100644 index 00000000000..93a2a843277 --- /dev/null +++ b/ui/src/components/task-chat/image-resources.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared"; +import type { TaskChatItem, TaskChatMaterializedResourceItem } from "./task-chat-model"; +import { groupImageResources } from "./image-resources"; + +function image(id: string, run = "run-1", contentType = "image/png"): TaskChatMaterializedResourceItem { + return { id, kind: "protocol", surface: "resource", resourceKind: "deliverable", title: `${id}.png`, subtitle: "Image", href: `/images/${id}.png`, + workProduct: { id, type: "artifact", title: `${id}.png`, createdByRunId: run, + metadata: { contentType, contentPath: `/images/${id}.png` } } as unknown as IssueWorkProduct }; +} + +describe("image resources in a results stream", () => { + it("keeps the first row and each tile identity as images arrive, without mutating prior renders", () => { + const first = image("one"); + const before = groupImageResources([first, image("two")]); + const after = groupImageResources([first, image("two"), image("three")]); + expect(before).toHaveLength(1); + expect(after[0].id).toBe(before[0].id); + expect(before[0].imageItems?.map(i => i.id)).toEqual(["one", "two"]); + expect(after[0].imageItems?.map(i => i.id)).toEqual(["one", "two", "three"]); + expect(first.imageItems).toBeUndefined(); + }); + + it("preserves text, document, video and different-run boundaries in chronological order", () => { + const text: TaskChatItem = { id: "text", kind: "message", author: "agent", text: "Next version" }; + const doc = { ...image("doc"), resourceKind: "document" as const, workProduct: undefined }; + const video = image("video", "run-1", "video/mp4"); + const rows = groupImageResources([image("a"), image("b"), text, image("c"), doc, image("d"), video, image("e"), image("f", "run-2")]); + expect(rows.map(i => i.id)).toEqual(["a", "text", "c", "doc", "d", "video", "e", "f"]); + expect(rows[1]).toBe(text); + expect(rows[3]).toBe(doc); + expect(rows[5]).toBe(video); + }); + + it.each([null, undefined])("keeps images without a run separate (%s)", (runId) => { + const unattributed = (id: string) => { + const item = image(id); + return { ...item, workProduct: { ...item.workProduct!, createdByRunId: runId } as IssueWorkProduct }; + }; + const text: TaskChatItem = { id: "text", kind: "message", author: "agent", text: "Next turn" }; + const rows = groupImageResources([unattributed("a"), unattributed("b"), text, unattributed("c")]); + expect(rows.map(row => row.id)).toEqual(["a", "b", "text", "c"]); + expect(rows[0]).toMatchObject({ imageItems: [{ id: "a" }] }); + expect(rows[1]).toMatchObject({ imageItems: [{ id: "b" }] }); + }); + + it("groups unregistered image uploads with work products from the same run", () => { + const attachment: TaskChatMaterializedResourceItem = { id: "upload", kind: "protocol", surface: "resource", resourceKind: "attachment", title: "upload.png", subtitle: "Image", href: "/upload.png", + attachment: { id: "upload", contentType: "application/octet-stream", originalFilename: "upload.png", contentPath: "/upload.png", originatingRunId: "run-1" } as IssueAttachment } satisfies TaskChatMaterializedResourceItem; + expect(groupImageResources([image("one"), attachment])[0].imageItems?.map(i => i.id)).toEqual(["one", "upload"]); + expect(groupImageResources([attachment, image("two", "run-2")])).toHaveLength(2); + }); + + it("leaves missing-image-path resources available as ordinary cards", () => { + const broken = { ...image("broken"), href: null, workProduct: { ...image("broken").workProduct!, metadata: { contentType: "image/png" } } }; + expect(groupImageResources([broken])[0]).toBe(broken); + }); + + it("groups a realistic large feed in one pass and can safely group an already grouped feed", () => { + let reads = 0; + const items = Array.from({ length: 2000 }, (_, n) => { + const item = image(`${n}`, `run-${Math.floor(n / 8)}`); + const product = item.workProduct!; + Object.defineProperty(item, "workProduct", { enumerable: true, get() { if (++reads > 20000) throw new Error("repeated scan"); return product; } }); + return item; + }); + const rows = groupImageResources(items); + expect(rows).toHaveLength(250); + expect(rows.every(r => r.imageItems?.length === 8)).toBe(true); + expect(groupImageResources(rows)).toEqual(rows); + expect(reads).toBeLessThan(20000); + }); +}); diff --git a/ui/src/components/task-chat/image-resources.ts b/ui/src/components/task-chat/image-resources.ts new file mode 100644 index 00000000000..86fa0f6b163 --- /dev/null +++ b/ui/src/components/task-chat/image-resources.ts @@ -0,0 +1,66 @@ +import { fileRowSourceFromAttachment, fileRowSourceFromWorkProduct } from "@/components/artifacts/v1/adapters"; +import { isImageLikeOutput } from "@/lib/issue-output"; +import type { TaskChatItem, TaskChatMaterializedResourceItem } from "./task-chat-model"; +import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared"; +import type { IssueChatComment } from "@/lib/issue-chat-messages"; + +export function imagePreparationReceiptIds( + comments: readonly IssueChatComment[], attachments: readonly IssueAttachment[], products: readonly IssueWorkProduct[], +): Set { + const commentsById = new Map(comments.map(comment => [comment.id, comment])); + const imagesByAttachment = new Map(); + for (const product of products) { + const source = fileRowSourceFromWorkProduct(product); + if (source?.attachmentId && isImageLikeOutput(source.contentType, source.filename)) { + imagesByAttachment.set(source.attachmentId, product); + } + } + const ids = new Set(); + for (const attachment of attachments) { + const comment = attachment.issueCommentId ? commentsById.get(attachment.issueCommentId) : undefined; + const image = imagesByAttachment.get(attachment.id); + const receiptAttachmentId = comment?.metadata?.attachmentPreparation?.attachmentId; + // Older receipts predate the marker. Keep their exact historical format as + // a compatibility fallback; new receipts do not depend on wording or title. + const isPreparation = receiptAttachmentId === attachment.id || + (receiptAttachmentId == null && image && comment?.body === `Prepared ${image.title} for this response.`); + if (comment && comment.authorType === "agent" && attachment.originatingRunId && + image?.createdByRunId === attachment.originatingRunId && isPreparation && + (comment.runId ?? comment.createdByRunId) === attachment.originatingRunId && + comment.authorAgentId === attachment.createdByAgentId && + comment.metadata?.authorizationReason === "paperclip_runner_protocol") ids.add(comment.id); + } + return ids; +} + +export function imageResourceSource(item: TaskChatItem) { + if (item.kind !== "protocol" || item.surface !== "resource") return null; + const source = item.resourceKind === "attachment" && item.attachment + ? fileRowSourceFromAttachment(item.attachment) + : item.resourceKind === "deliverable" && item.workProduct + ? fileRowSourceFromWorkProduct(item.workProduct) + : null; + return source?.contentPath && isImageLikeOutput(source.contentType, source.filename) ? source : null; +} + +export function groupImageResources(items: readonly T[]): T[] { + const rows: T[] = []; + let group: TaskChatMaterializedResourceItem | null = null; + let runId: string | null | undefined; + for (const item of items) { + if (item.kind !== "protocol" || item.surface !== "resource" || !imageResourceSource(item)) { + group = null; + rows.push(item); + continue; + } + const nextRunId = item.workProduct?.createdByRunId ?? item.attachment?.originatingRunId; + if (group && runId != null && runId === nextRunId) { + group.imageItems!.push(...(item.imageItems ?? [item])); + } else { + group = { ...item, imageItems: [...(item.imageItems ?? [item])] }; + runId = nextRunId; + rows.push(group as T); + } + } + return rows; +} diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 3505a573c8a..a70dc07811b 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -583,6 +583,7 @@ export interface TaskChatMaterializedResourceItem { document?: IssueDocumentSummary; workProduct?: IssueWorkProduct; attachment?: IssueAttachment; + imageItems?: TaskChatMaterializedResourceItem[]; } export type TaskChatProtocolItem = diff --git a/ui/src/index.css b/ui/src/index.css index f3594d73bad..89fb6870da6 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -5329,6 +5329,19 @@ html:not(.dark) .coordinator-sidebar { .agent-selection-bar { transition: none; transform: translate(-50%, 0); } } +:root { + --tc-image-grid-columns: repeat(auto-fill, minmax(min(100%, 8rem), 1fr)); + --tc-image-grid-max-width: 40rem; +} + +.tc-image-grid { + display: grid; + grid-template-columns: var(--tc-image-grid-columns); + gap: calc(var(--spacing) * 2); + width: 100%; + max-width: var(--tc-image-grid-max-width); +} + .paperclip-patch-line { min-width: 0; } span.paperclip-patch-line { display: block; } .paperclip-patch-line[data-patch-line="add"] { color: var(--patch-add-fg); background: var(--diff-add-bg); } diff --git a/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000000/content b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000000/content new file mode 100644 index 00000000000..787cdb443c4 Binary files /dev/null and b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000000/content differ diff --git a/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000001/content b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000001/content new file mode 100644 index 00000000000..73f9cc5a9c3 Binary files /dev/null and b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000001/content differ diff --git a/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000002/content b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000002/content new file mode 100644 index 00000000000..e62eef83864 Binary files /dev/null and b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000002/content differ diff --git a/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000003/content b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000003/content new file mode 100644 index 00000000000..80df4b49229 Binary files /dev/null and b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000003/content differ diff --git a/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000004/content b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000004/content new file mode 100644 index 00000000000..a1aa3fdb462 Binary files /dev/null and b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000004/content differ diff --git a/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000005/content b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000005/content new file mode 100644 index 00000000000..d253bc66683 Binary files /dev/null and b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000005/content differ diff --git a/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000006/content b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000006/content new file mode 100644 index 00000000000..7f53edc544f Binary files /dev/null and b/ui/storybook/public/api/attachments/00000000-0000-4000-8000-000000000006/content differ diff --git a/ui/storybook/public/feed-images/README.md b/ui/storybook/public/feed-images/README.md new file mode 100644 index 00000000000..a10a5676e58 --- /dev/null +++ b/ui/storybook/public/feed-images/README.md @@ -0,0 +1,11 @@ +# PAP-2525 feed fixture + +Seven original design-proposal screenshots from the 2026-11-06 PAP-2525 turn +`0aedb180-45e4-436d-9e2d-e2853c8d7200`, reproduced here as image-grid fixture +assets. The depicted accounts and company are sample data. These files are +inputs to the production component stories, not new regression baselines. + +Source task: https://paperclip.staging.paperclip.app/PAP/issues/PAP-2525 + +The bytes live in `../api/attachments/00000000-0000-4000-8000-00000000000{0..6}/content` +to exercise the same canonical paths as uploaded artifacts (in source order). diff --git a/ui/storybook/stories/documents-artifacts/feed-image-grids.stories.tsx b/ui/storybook/stories/documents-artifacts/feed-image-grids.stories.tsx new file mode 100644 index 00000000000..3963f3a370a --- /dev/null +++ b/ui/storybook/stories/documents-artifacts/feed-image-grids.stories.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { IssueAttachment, IssueComment, IssueWorkProduct } from "@paperclipai/shared"; +import { expect, userEvent, within } from "storybook/test"; +import { TaskChatThreadView } from "../../../src/components/task-chat/TaskChatThreadView"; +import { TaskChatPresentationProvider } from "../../../src/components/task-chat/presentation-mode"; +import type { TaskChatItem, TaskChatMaterializedResourceItem } from "../../../src/components/task-chat/task-chat-model"; +import { TaskChatThread } from "../../../src/components/TaskChatThread"; +import { storybookAgents } from "../../fixtures/paperclipData"; +import { Button } from "../../../src/components/ui/button"; + +const meta = { + id: "task-chat-feed-image-grids", + title: "Tasks and conversations/Documents, attachments, and work products/Task Chat/Image grids", + parameters: { + layout: "fullscreen", + paperclip: { + capabilities: ["documents-artifacts/work-products", "documents-artifacts/attachments"], + status: "current", implementation: "production", boundary: "fixtures", + notes: "Production thread renderer and media gallery. Representative PAP-2525 conversation with seven original sample-account screenshots from its 2026-11-06 turn; no live task or provider actions. Streaming appends saved resources through the same renderer used during execution.", + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const screenshots = [ + ["01-connectors-crown.png", "01 — Crown marks your default"], + ["02-openai-kebab.png", "02 — Set as default for OpenAI"], + ["03-default-changed.png", "03 — Crown moves after save"], + ["04-claude-kebab.png", "04 — Set as default for Claude"], + ["05-mobile-kebab.png", "05 — Mobile connector menu"], + ["06-unavailable-default.png", "06 — Unavailable default stays selected"], + ["07-dark-kebab.png", "07 — Dark theme connector menu"], +]; +const images: TaskChatMaterializedResourceItem[] = screenshots.map(([filename, title], index) => { + const attachmentId = `00000000-0000-4000-8000-00000000000${index}`; + const href = `/api/attachments/${attachmentId}/content`; + const workProduct = { + id: `image-${index}`, companyId: "storybook-company", issueId: "pap-2525", projectId: null, executionWorkspaceId: null, runtimeServiceId: null, externalId: null, isPrimary: false, summary: null, url: null, type: "artifact", provider: "paperclip", title, + status: "active", reviewState: "none", healthStatus: "unknown", + createdByRunId: "pap-2525-revised-prototype", createdAt: new Date("2026-11-06T13:31:00Z"), updatedAt: new Date("2026-11-06T13:31:00Z"), + metadata: { attachmentId, byteSize: 118000, contentType: "image/png", originalFilename: filename, contentPath: href, openPath: href, downloadPath: `${href}?download=1` }, + } as unknown as IssueWorkProduct; + return { id: `resource:${workProduct.id}`, kind: "protocol", surface: "resource", resourceKind: "deliverable", title, subtitle: "Image", href, workProduct }; +}); +const message = (id: string, text: string, author: "human" | "agent" = "agent"): TaskChatItem => ({ + id, kind: "message", author, isCurrentUser: author === "human", authorName: author === "agent" ? "Codie" : "You", text, +}); +const document: TaskChatMaterializedResourceItem = { + id: "resource:document:plan", kind: "protocol", surface: "resource", resourceKind: "document", + title: "AI account defaults — revised plan", subtitle: "Document · rev 2", href: "#document-plan", +}; + +function Feed({ streaming = false, narrow = false, classic = false, mixed = false, broken = false, single = false }: { + streaming?: boolean; narrow?: boolean; classic?: boolean; mixed?: boolean; broken?: boolean; single?: boolean; +}) { + const [count, setCount] = useState(streaming ? 3 : single ? 1 : 7); + const selectedImages = broken + ? [{ ...images[0], workProduct: { ...images[0].workProduct!, metadata: { contentType: "image/png", originalFilename: "missing.png", contentPath: "/api/attachments/missing/content" } } }] + : images.slice(0, count); + const results: TaskChatItem[] = mixed + ? [...selectedImages.slice(0, 2), document, message("context", "The menu examples follow the account overview."), ...selectedImages.slice(2)] + : [document, ...selectedImages]; + const items = [ + message("request", "Show me how I can choose my default AI account and how it appears in the connector menu.", "human"), + message("progress", "I’ve revised the design around the crown and the connector menu. Here are the updated screens."), + ...results, + ...(streaming && count < 7 ? [] : [message("reply", "The crown marks your default for each provider. Use the connector menu to choose a different account. The screenshots include mobile, dark mode, and an unavailable default.")]), + ]; + return
+
+
+

PAP-2525 · Task conversation

+

Plan explicit AI account defaults

+
+ + + + {streaming ?
+ + {count === 7 ? "7 images · Complete" : `${count} images · Working`} +
: null} +
+
; +} + +export const Pap2525 = { render: () => , play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("group", { name: "7 images" })).toBeVisible(); + await expect(canvas.getAllByRole("button", { name: /^Open gallery:/ })).toHaveLength(7); +} } satisfies Story; +export const Mobile = { render: () => } satisfies Story; +export const Classic = { render: () => } satisfies Story; +export const MixedResults = { render: () => } satisfies Story; +export const SingleImage = { render: () => } satisfies Story; +export const UnavailableImage = { render: () => } satisfies Story; +export const StreamingResults = { render: () => , play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const first = canvas.getAllByRole("button", { name: /^Open gallery:/ })[0]; + const grid = canvas.getByRole("group", { name: "3 images" }); + for (let count = 4; count <= 7; count++) { + await userEvent.click(canvas.getByRole("button", { name: "Add next image" })); + await expect(canvas.getByRole("group", { name: `${count} images` })).toBe(grid); + await expect(canvas.getAllByRole("button", { name: /^Open gallery:/ })[0]).toBe(first); + } + await expect(canvas.getByRole("status")).toHaveTextContent("Complete"); +} } satisfies Story; +export const Gallery = { render: () => , play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getAllByRole("button", { name: /^Open gallery:/ })[1]); + const body = within(canvasElement.ownerDocument.body); + await expect(body.getByRole("dialog")).toBeVisible(); + await expect(body.getByRole("link", { name: "Open original 02-openai-kebab.png" })).toHaveAttribute("href", images[1].href); + await expect(body.getByRole("link", { name: "Download 02-openai-kebab.png" })).toBeVisible(); + await userEvent.keyboard("{ArrowRight}"); + await expect(body.getByRole("link", { name: "Open original 03-default-changed.png" })).toHaveAttribute("href", images[2].href); + await expect(body.getByRole("link", { name: "Download 03-default-changed.png" })).toBeVisible(); +} } satisfies Story; + + +export const ProductionFeed = { render: () => { + const agent = storybookAgents[0]; + const products = images.map((i, n) => ({ ...i.workProduct!, createdAt: new Date(1793971860000 + n * 3000), updatedAt: new Date(1793971860000 + n * 3000) })); + const attachments = products.map((p, n) => ({ + id: p.metadata!.attachmentId, issueCommentId: `receipt-${n}`, originatingRunId: p.createdByRunId, + createdByAgentId: agent.id, originalFilename: p.metadata!.originalFilename, contentType: "image/png", contentPath: p.metadata!.contentPath, + createdAt: p.createdAt, updatedAt: p.updatedAt, byteSize: 118000, + })) as unknown as IssueAttachment[]; + const comments = products.map((p, n) => ({ + id: `receipt-${n}`, body: `Prepared ${p.title} for this response.`, authorType: "agent", authorAgentId: agent.id, createdByRunId: p.createdByRunId, + createdAt: new Date(new Date(p.createdAt).getTime() + 100), updatedAt: p.updatedAt, metadata: { version: 1 as const, sections: [], authorizationReason: "paperclip_runner_protocol" }, + })) as unknown as IssueComment[]; + return
+ {}} + agentMap={new Map([[agent.id, agent]])} currentAssigneeValue={`agent:${agent.id}`} + threadHeader={

PAP-2525 · Original upload sequence

AI account default screenshots

} /> +
; +}, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("group", { name: "7 images" })).toBeVisible(); + await expect(canvas.queryByText(/^Prepared .* for this response\.$/)).not.toBeInTheDocument(); +} } satisfies Story;