fix(ui): show task image results in compact thumbnail grids (#19626)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task feeds show agent messages, documents, and files in time order. > - Each image currently uses a full feed card. > - A turn with seven screenshots makes the conversation hard to scan. > - This pull request groups adjacent images from the same run in a compact grid. > - Users can scan the results and open each image in the existing gallery. ## Linked Issues or Issue Description **What existing behavior does this improve?** Image results in task history and the live task feed. **Current behavior** Each image uses a separate full-width card. Native image upload receipt messages can also separate related screenshots. **Proposed behavior** Adjacent images from one run share a wrapping thumbnail grid. New image results join the grid. Documents, video, and authored messages keep their positions. The grid opens the existing gallery. **Reason and benefit** Users can see a batch of screenshots without scrolling past one large card for each file. **Breaking changes** The feed layout changes. The stored comments, files, APIs, and permissions do not change. Related public work: Refs #17228. Refs #8785. Refs #7667. These changes supplied the artifact cards and media gallery that this pull request reuses. ## What Changed - Add one image grouping function for settled history, turn history, and live results. Group only images with the same known run. Keep the first row key and each image key when results arrive. - Add a token-based grid that reuses the media artifact cards and image gallery. - Add an optional typed attachment-preparation marker to file registration and chat-file reuse. Preserve the marker through comment normalization. Use it with matching image, author, and run links to hide generated receipts. Keep the exact historical sentence as a fallback for old receipts. - Add nine production component stories with seven sample-account screenshots. Cover mobile, Classic, mixed results, single images, missing images, streaming, gallery navigation, and the complete feed projection. - Add grouping and production-feed regressions. Add 18 browser cases across light and dark themes. - Improve contrast for the shared fallback label and gallery counter after the accessibility checks found low contrast. - Match alternate original-image paths when opening the gallery. Keep a separate Open original link beside Download in the gallery. - Record the feed layout in DESIGN.md. ## Verification - Final qualification at `09b12381ce85bee6593050bd8a2fa18cbe2c45a6`: [required CI passed](https://github.com/paperclipai/paperclip/actions/runs/42317080212/job/128237094513), including typecheck, build, all test lanes, E2E, and verify. The snapshot has 58 successful check runs, three conditional skips, one neutral advisory job, and a successful legacy Snyk status. No pending or failed checks remain. Greptile and Paperclip Review both score this exact head 5/5; all review threads are resolved. - Current-head local checks: 544 focused UI tests passed across 11 files, with one existing skip. UI/shared typechecks and token gates pass. All 18 focused Storybook browser cases passed after the CSS merge, with retries disabled. - Two database integration cases verify file registration and chat-file reuse receipt markers. All 52 issue-validator tests and six upstream default-model tests passed. - Regressions failed before the fixes for unknown-run grouping, receipt wording, and authored progress text with an image. The repair uses a typed receipt marker and requires matching author, attachment, image, and run links before hiding a generated receipt. All five review findings are resolved. - Browser checks cover loaded images, wrapping columns, overflow, missing-image fallback, streaming stability, keyboard gallery navigation, original-file links, and downloads. Fixtures use production renderers. These checks do not prove deployed persistence or provider behavior and do not compare visual baselines. - Local limits: the preflight helper could not finish because Cargo was absent. The earlier low-memory server typecheck and dependency-free selector did not replace final-head CI. The now-passing final CI supplies broad typecheck, build, server, Runner, and E2E coverage. - Prior independent CI failures remain recorded: one Slack timer case passed a single diagnosed rerun; its exact timer cause was not proved. A later chat shard lost its self-hosted runner without a test assertion failure. The current revision passes that shard and all applicable gates. Master conflict resolution retained both image-grid and patch CSS rules. ## Risks - Images without a run ID remain separate. Messages and other resource types end each batch. - Receipt suppression requires a matching preparation marker or the exact legacy sentence, plus matching attachment, image work product, run, and author. Tests preserve human messages and native progress text with attachments. The optional marker uses existing JSON metadata and needs no database migration. - Shared gallery and fallback text now have stronger contrast. ## Model Used OpenAI GPT-6, through Codex. The agent used reasoning, repository tools, code execution, and browser tests. The runtime did not expose a more specific model ID or context window size. ## 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 - [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 - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <[email protected]>
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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] },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<string, Array<{ status?: string }>> } }).__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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(<ImageGalleryModal items={[item]} initialIndex={0} open onOpenChange={() => undefined} />);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const original = document.body.querySelector<HTMLAnchorElement>('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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-white/40 tabular-nums text-xs">
|
||||
<span className="tabular-nums text-xs">
|
||||
{currentIndex + 1} / {items.length}
|
||||
</span>
|
||||
<a
|
||||
href={attachmentOpenPath(current)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
title="Open original"
|
||||
aria-label={`Open original ${filename}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-4.5 w-4.5" />
|
||||
</a>
|
||||
<a
|
||||
href={attachmentDownloadPath(current)}
|
||||
download={filename}
|
||||
|
||||
@@ -1248,6 +1248,42 @@ function renderThread(props: ComponentProps<typeof TaskChatThread>) {
|
||||
}
|
||||
|
||||
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<typeof TaskChatThread>;
|
||||
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<Props["workProducts"]>;
|
||||
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<Props["attachments"]>;
|
||||
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(<TaskChatThread {...props} />);
|
||||
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(<TaskChatThread {...props} comments={[...comments, { ...comment("human-receipt-words", "Prepared Screen 1 for this response."), authorAgentId: null, authorType: "user", authorUserId: "viewer" }]} />);
|
||||
expect(container.textContent).toContain("Prepared Screen 1 for this response.");
|
||||
// Native progress messages share the protocol marker and can have attachments.
|
||||
render(<TaskChatThread {...props} comments={[{ ...comments[0], body: "Keep this image context", metadata: { ...comments[0].metadata, attachmentPreparation: undefined } }, comments[1]]} />);
|
||||
expect(container.textContent).toContain("Keep this image context");
|
||||
});
|
||||
|
||||
it("aligns the desktop thread header with the side-panel tab row", () => {
|
||||
renderThread({
|
||||
comments: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -24,7 +24,7 @@ function PreviewFrame({ children, className }: { children: React.ReactNode; clas
|
||||
function PlaceholderPreview({ label }: { label?: string }) {
|
||||
return (
|
||||
<PreviewFrame className="flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-1.5 text-muted-foreground/50">
|
||||
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
|
||||
<Paperclip className="h-7 w-7" aria-hidden="true" />
|
||||
{label ? <span className="text-(length:--text-micro) font-medium uppercase tracking-wide">{label}</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const images = items.flatMap((item) => {
|
||||
const source = imageResourceSource(item);
|
||||
return source ? [{ item, source }] : [];
|
||||
});
|
||||
return (
|
||||
<IssueGalleryContext.Provider value={(src) => {
|
||||
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;
|
||||
}}>
|
||||
<div className="tc-image-grid" role="group" aria-label={`${images.length} ${images.length === 1 ? "image" : "images"}`} data-testid="task-chat-image-grid">
|
||||
{images.map(({ item, source }) => (
|
||||
<div key={item.id} className="min-w-0" data-thread-anchor={item.id}>
|
||||
{item.workProduct ? <RichWorkProductCard workProduct={item.workProduct} href={item.href} variant="gallery" /> : <MediaArtifactCard id={source.id} title={item.title} contentPath={source.contentPath}
|
||||
contentType={source.contentType} originalFilename={source.filename} downloadPath={source.downloadPath} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selected !== null && selected >= 0 ? (
|
||||
<ImageGalleryModal items={images.map(({ source }) => ({ ...source, originalFilename: source.filename }))}
|
||||
initialIndex={selected} open onOpenChange={(open) => { if (!open) setSelected(null); }} />
|
||||
) : null}
|
||||
</IssueGalleryContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<HTMLButtonElement>('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([
|
||||
|
||||
@@ -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<TaskChatProtocolItem, { surface: "resource" }>;
|
||||
}) {
|
||||
const { enabled: artifactsV1 } = useArtifactsV1Enabled();
|
||||
if (imageResourceSource(item)) {
|
||||
return <TaskChatImageGrid items={item.imageItems ?? [item]} />;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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 (
|
||||
<TaskChatShimmerScope items={items}><div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { groupImageResources } from "./image-resources";
|
||||
import { TaskBrowserActivity } from "../task-side-panel/TaskBrowserActivity";
|
||||
import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard";
|
||||
import { TaskChatSkillCreatedCard } from "./TaskChatSkillCreatedCard";
|
||||
@@ -331,7 +332,8 @@ export function TaskChatThreadView({
|
||||
onOpenBrowser,
|
||||
}: TaskChatThreadViewProps) {
|
||||
const streamlined = useStreamlinedTaskChatPresentation();
|
||||
const { items: visibleItems, retryMarkers } = useMemo(() => compactAdjacentErrors(items), [items]);
|
||||
const { items: compactedItems, retryMarkers } = useMemo(() => compactAdjacentErrors(items), [items]);
|
||||
const visibleItems = useMemo(() => groupImageResources(compactedItems), [compactedItems]);
|
||||
const retryableMarkerId =
|
||||
onRetryFailedRun || onTryAgainNoLiveExecutionPath
|
||||
? [...visibleItems]
|
||||
|
||||
@@ -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) => (
|
||||
<div
|
||||
className="min-w-0"
|
||||
key={child.id}
|
||||
@@ -277,7 +278,7 @@ export function TaskChatTurn({
|
||||
>
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
{historyMounted && foldedItems.map((child) => (
|
||||
{historyMounted && groupImageResources(foldedItems).map((child) => (
|
||||
<div key={child.id}>{renderChild(child)}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
const commentsById = new Map(comments.map(comment => [comment.id, comment]));
|
||||
const imagesByAttachment = new Map<string, IssueWorkProduct>();
|
||||
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<string>();
|
||||
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<T extends TaskChatItem>(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;
|
||||
}
|
||||
@@ -583,6 +583,7 @@ export interface TaskChatMaterializedResourceItem {
|
||||
document?: IssueDocumentSummary;
|
||||
workProduct?: IssueWorkProduct;
|
||||
attachment?: IssueAttachment;
|
||||
imageItems?: TaskChatMaterializedResourceItem[];
|
||||
}
|
||||
|
||||
export type TaskChatProtocolItem =
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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).
|
||||
@@ -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<typeof meta>;
|
||||
|
||||
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 <main className="min-h-screen bg-background p-4 text-foreground">
|
||||
<div className={narrow ? "mx-auto w-full max-w-sm" : "mx-auto w-full max-w-3xl"}>
|
||||
<div className="flex flex-col gap-1 px-1 pb-4">
|
||||
<p className="text-xs text-muted-foreground">PAP-2525 · Task conversation</p>
|
||||
<h1 className="text-lg font-semibold">Plan explicit AI account defaults</h1>
|
||||
</div>
|
||||
<TaskChatPresentationProvider mode={classic ? "production" : "streamlined"}>
|
||||
<TaskChatThreadView items={items} scroll={false} />
|
||||
</TaskChatPresentationProvider>
|
||||
{streaming ? <div className="flex items-center gap-3 pt-4">
|
||||
<Button onClick={() => setCount(n => Math.min(7, n + 1))} disabled={count === 7}>Add next image</Button>
|
||||
<span role="status" className="text-sm text-muted-foreground">{count === 7 ? "7 images · Complete" : `${count} images · Working`}</span>
|
||||
</div> : null}
|
||||
</div>
|
||||
</main>;
|
||||
}
|
||||
|
||||
export const Pap2525 = { render: () => <Feed />, 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: () => <Feed narrow /> } satisfies Story;
|
||||
export const Classic = { render: () => <Feed classic /> } satisfies Story;
|
||||
export const MixedResults = { render: () => <Feed mixed /> } satisfies Story;
|
||||
export const SingleImage = { render: () => <Feed single /> } satisfies Story;
|
||||
export const UnavailableImage = { render: () => <Feed broken /> } satisfies Story;
|
||||
export const StreamingResults = { render: () => <Feed streaming />, 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: () => <Feed />, 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 <main className="mx-auto flex min-h-dvh w-full max-w-3xl flex-col bg-background px-4 text-foreground">
|
||||
<TaskChatThread comments={comments} workProducts={products} attachments={attachments} issueStatus="done" onAdd={async () => {}}
|
||||
agentMap={new Map([[agent.id, agent]])} currentAssigneeValue={`agent:${agent.id}`}
|
||||
threadHeader={<header className="py-4"><p className="text-xs text-muted-foreground">PAP-2525 · Original upload sequence</p><h1 className="text-lg font-semibold">AI account default screenshots</h1></header>} />
|
||||
</main>;
|
||||
}, 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;
|
||||