diff --git a/DESIGN.md b/DESIGN.md index 69ba9efee3a..6f03ef5a066 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -124,6 +124,13 @@ starts this queued turn stays in history, including when it reopens an older task. This placement is not a consumption receipt. Later follow-ups remain in the composer queue with their existing edit, order and discard controls. +Queued messages render Markdown and show compact local attachment thumbnails. +Keep paragraph boundaries and wrap thumbnails and controls within narrow rows. +Omit inline images from the compact text preview. On +submit, move the text and attachment previews out of the composer together. +Retain upload receipts while saving so a rejected or uncertain send restores +the draft without discarding files added for the next message. + Stream the provider's final reply as the conversational answer. Completion-tool reports remain inspectable run data while work continues. Use their summary as an answer fallback only after the run ends and its final transcript fetch diff --git a/tests/storybook-visual/queued-message-screenshots.spec.ts b/tests/storybook-visual/queued-message-screenshots.spec.ts new file mode 100644 index 00000000000..628411ff738 --- /dev/null +++ b/tests/storybook-visual/queued-message-screenshots.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/test"; + +for (const mobile of [false, true]) { + test(`screenshot steering handoff ${mobile ? "mobile" : "desktop"}`, async ({ page }, testInfo) => { + if (mobile) await page.setViewportSize({ width: 390, height: 844 }); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.addInitScript(() => localStorage.setItem("storybook-screenshot-submit", "Check **mobile** and *desktop* with `pnpm verify`.")); + await page.goto("/iframe.html?id=composer-steering-and-queued-messages--screenshot-submission&viewMode=story&globals=theme:light"); + const composer = page.getByTestId("queued-composer"); + await expect(composer).toBeVisible({ timeout: 30_000 }); + // Attach real browser screenshots through the production file picker. + const screenshot = await page.getByRole("main").screenshot(); + await page.route("**/api/attachments/*/content", route => route.fulfill({ contentType: "image/png", body: screenshot })); + await composer.locator('input[type="file"]').setInputFiles([ + { name: "mobile.png", mimeType: "image/png", buffer: screenshot }, + { name: "desktop.png", mimeType: "image/png", buffer: screenshot }, + ]); + await expect(composer.getByRole("button", { name: "Remove mobile.png" })).toBeEnabled(); + await expect(composer.getByRole("button", { name: "Remove desktop.png" })).toBeEnabled(); + await page.screenshot({ path: testInfo.outputPath("before-submit.png"), fullPage: true }); + await composer.getByRole("button", { name: /^Send/ }).click(); + await expect(page.getByRole("button", { name: "Complete save" })).toBeVisible(); + const queue = page.getByTestId("task-chat-queued-messages"); + await expect(queue.locator("strong")).toHaveText("mobile"); + await expect(queue.locator("em")).toHaveText("desktop"); + await expect(queue.locator("code")).toHaveText("pnpm verify"); + await expect(queue.locator("img")).toHaveCount(2); + await expect.poll(() => queue.locator("img").evaluateAll(images => images.every(image => (image as HTMLImageElement).naturalWidth > 0))).toBe(true); + expect(await queue.evaluate(element => { + const bounds = element.getBoundingClientRect(); + return [...element.querySelectorAll("img")].every(image => { + const imageBounds = image.getBoundingClientRect(); + return imageBounds.top >= bounds.top && imageBounds.bottom <= bounds.bottom; + }); + })).toBe(true); + await expect(composer.getByTestId("task-chat-composer-images")).toHaveCount(0); + await expect(composer.locator('[contenteditable="true"]')).toHaveText(""); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath("save-pending.png"), fullPage: true }); + + // A rejected save restores the same draft and screenshots for retry. + await page.getByRole("button", { name: "Reject save" }).click(); + await expect(queue).toHaveCount(0); + await expect(composer.getByTestId("task-chat-composer-images").locator("img")).toHaveCount(2); + await expect(composer.locator("strong")).toContainText("mobile"); + await composer.getByRole("button", { name: /^Send/ }).click(); + await page.getByRole("button", { name: "Complete save" }).click(); + await expect(composer.getByTestId("task-chat-composer-images")).toHaveCount(0); + await expect(queue.locator("img")).toHaveCount(2); + await page.screenshot({ path: testInfo.outputPath("save-complete.png"), fullPage: true }); + await queue.getByRole("button", { name: "Steer", exact: true }).click(); + await expect(queue).toHaveCount(0); + await expect(page.getByRole("status")).toHaveText("Message steered into the active turn."); + expect(errors).toEqual([]); + }); +} + +test("paragraphs and four screenshots fit beside narrow auto-send controls", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 320, height: 844 }); + await page.clock.setFixedTime(new Date("2026-11-09T22:00:00Z")); + await page.addInitScript(() => localStorage.setItem("storybook-screenshot-submit", "First paragraph.\n\nSecond paragraph.")); + await page.goto("/iframe.html?id=composer-steering-and-queued-messages--screenshot-submission&viewMode=story&args=autoSteer:true;mobile:true&globals=theme:light"); + const composer = page.getByTestId("queued-composer"); + await expect(composer).toBeVisible({ timeout: 30_000 }); + const screenshot = await page.getByRole("main").screenshot(); + await page.route("**/api/attachments/*/content", route => route.fulfill({ contentType: "image/png", body: screenshot })); + await composer.locator('input[type="file"]').setInputFiles([1, 2, 3, 4].map(index => ({ + name: `screen-${index}.png`, mimeType: "image/png", buffer: screenshot, + }))); + await expect(composer.getByRole("button", { name: "Remove screen-4.png" })).toBeEnabled(); + await composer.getByRole("button", { name: /^Send/ }).click(); + const queue = page.getByTestId("task-chat-queued-messages"); + const first = queue.locator("p").filter({ hasText: "First paragraph." }); + const second = queue.locator("p").filter({ hasText: "Second paragraph." }); + await expect(first).toBeVisible(); + await expect(second).toBeVisible(); + expect(await first.evaluate(element => getComputedStyle(element).display)).toBe("block"); + expect(await first.evaluate(element => getComputedStyle(element).marginBottom)).toBe("0px"); + expect(await second.evaluate(element => getComputedStyle(element).marginTop)).toBe("0px"); + expect((await second.boundingBox())!.y).toBeGreaterThan((await first.boundingBox())!.y); + await expect(queue.getByText("Sending in 5s")).toBeVisible(); + const images = queue.getByRole("group", { name: "Queued images" }); + await expect(images.locator("img")).toHaveCount(3); + await expect.poll(() => images.locator("img").evaluateAll(nodes => nodes.every(image => (image as HTMLImageElement).naturalWidth > 0))).toBe(true); + await expect(images.getByText("+1")).toBeVisible(); + expect(await queue.evaluate(element => { + const bounds = element.getBoundingClientRect(); + return [...element.querySelectorAll('[aria-label="Queued images"] > *, button, [data-testid^="task-chat-queued-auto-steer"]')].every(child => { + const box = child.getBoundingClientRect(); + return box.left >= bounds.left && box.right <= bounds.right && box.top >= bounds.top && box.bottom <= bounds.bottom; + }); + })).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath("narrow-auto-steer.png"), fullPage: true }); +}); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 01636789cd6..455d0ec6d6b 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -130,8 +130,8 @@ interface MarkdownBodyProps { resolveLinkHref?: (href: string) => string | null; /** Called when a user clicks an inline image */ onImageClick?: (src: string, image: HTMLImageElement) => void; - /** Keep untrusted decision media inert: image references and diagram source only. */ - mediaMode?: "render" | "reference"; + /** Keep media inert as references, or omit images in compact text previews. */ + mediaMode?: "render" | "reference" | "omit"; /** * Resolver that decides which inline-code workspace file paths may be linked * to the issue file viewer. Omitting it (or returning null) leaves every @@ -967,7 +967,9 @@ function MarkdownBodyImpl({ ); }, }; - if (mediaMode === "reference") { + if (mediaMode === "omit") { + map.img = () => null; + } else if (mediaMode === "reference") { map.img = ({ src, alt, title }) => ( Image: {alt || "Untitled image"}{src ? ` (${src})` : ""} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index d033444878b..f02cf4b6d8d 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -19,6 +19,7 @@ import { instanceSettingsApi } from "@/api/instanceSettings"; import { CoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation"; import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft"; import { + loadDraftAttachments, loadDraftSubmission, saveDraft, saveDraftSubmission, @@ -1433,6 +1434,55 @@ describe("TaskChatComposer", () => { expect(onAdd.mock.calls[0]?.[0]).toBe("Review this image\n\n![shot.png](/attachments/shot.png)"); }); + it.each(["saved", "rejected", "unknown"] as const)("moves submitted screenshots out of the composer before the request is %s", async (outcome) => { + let resolveSend!: () => void; + let rejectSend!: (error: Error) => void; + const onAdd = vi.fn().mockReturnValue(new Promise((resolve, reject) => { + resolveSend = resolve; + rejectSend = reject; + })); + const draftKey = `screenshots-${outcome}`; + const onAttachImage = vi.fn().mockResolvedValueOnce({ + id: "8c365a89-5545-40f7-8cc0-ec6b20567827", contentPath: "/api/attachments/8c365a89-5545-40f7-8cc0-ec6b20567827/content", originalFilename: "shot.png", contentType: "image/png", + }).mockResolvedValue({ + id: "266ff1d0-6be9-47cb-b2ed-300fce5fbcd3", contentPath: "/api/attachments/266ff1d0-6be9-47cb-b2ed-300fce5fbcd3/content", originalFilename: "next.png", contentType: "image/png", + }); + renderComposer({ onAdd, workMode: "standard", draftKey, onAttachImage }); + typeText("Check **this** screenshot"); + pasteFiles([new File(["png"], "shot.png", { type: "image/png" })]); + await flushAsync(); + expect(container.querySelector('[data-testid="task-chat-composer-images"]')).not.toBeNull(); + + flushSync(() => sendButton().click()); + await flushAsync(); + expect(onAdd.mock.calls[0]?.[0]).toBe("Check **this** screenshot\n\n![shot.png](/api/attachments/8c365a89-5545-40f7-8cc0-ec6b20567827/content)"); + expect(onAdd.mock.calls[0]?.[3]).toEqual(["8c365a89-5545-40f7-8cc0-ec6b20567827"]); + expect(editable().textContent).toBe(""); + expect(container.querySelector('[data-testid="task-chat-composer-images"]')).toBeNull(); + // Hiding the submitted preview must not discard its reload/retry receipt. + expect(loadDraftAttachments(draftKey).map(item => item.attachmentId)).toEqual(["8c365a89-5545-40f7-8cc0-ec6b20567827"]); + typeText("Next draft"); + pasteFiles([new File(["next"], "next.png", { type: "image/png" })]); + await flushAsync(); + expect(container.querySelector('img[alt="next.png"]')).not.toBeNull(); + expect(container.querySelector('img[alt="shot.png"]')).toBeNull(); + if (outcome === "saved") resolveSend(); + else rejectSend(outcome === "unknown" ? new CommentSubmissionUnknownError() : new Error("Rejected")); + await flushAsync(); + await flushAsync(); + if (outcome === "saved") { + expect(container.querySelector('img[alt="shot.png"]')).toBeNull(); + expect(editable().textContent).toBe("Next draft"); + expect(loadDraftAttachments(draftKey).map(item => item.attachmentId)).toEqual(["266ff1d0-6be9-47cb-b2ed-300fce5fbcd3"]); + } else { + expect(container.querySelectorAll('img[alt="shot.png"]')).toHaveLength(1); + expect(editable().textContent).toBe("Check **this** screenshot\n\nNext draft"); + expect(loadDraftAttachments(draftKey).map(item => item.attachmentId)).toEqual(["8c365a89-5545-40f7-8cc0-ec6b20567827", "266ff1d0-6be9-47cb-b2ed-300fce5fbcd3"]); + if (outcome === "unknown") expect(loadDraftSubmission(draftKey)?.request?.attachmentIds).toEqual(["8c365a89-5545-40f7-8cc0-ec6b20567827"]); + } + expect(container.querySelectorAll('img[alt="next.png"]')).toHaveLength(1); + }); + it("keeps failed image uploads in the preview row for retry without changing the text", async () => { const onAdd = vi.fn(); const onAttachImage = vi.fn().mockRejectedValueOnce(new Error("Too large")) diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 5b81f86bd99..bf392b3d6d0 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -471,6 +471,9 @@ export function TaskChatComposer({ : [], ); const attachmentsRef = useRef(attachments); + // Keep upload receipts durable while the optimistic message owns the preview. + // A rejected or uncertain send returns these same attachments to the draft. + const [sendingAttachmentIds, setSendingAttachmentIds] = useState | null>(null); function setAttachments( update: | ComposerAttachment[] @@ -869,8 +872,9 @@ export function TaskChatComposer({ const attachedRefs = attachments.filter( (item) => item.status === "attached" && item.contentPath && !item.inline, ); - const imageAttachments = attachments.filter(item => isImage(item) && !item.inline); - const visibleAttachments = attachments.filter( + const draftAttachments = attachments.filter(item => !sendingAttachmentIds?.has(item.id)); + const imageAttachments = draftAttachments.filter(item => isImage(item) && !item.inline); + const visibleAttachments = draftAttachments.filter( (item) => (!item.inline || item.status !== "attached") && !isImage(item), ); // Sending mid-upload would silently drop the pending file from the comment; @@ -1030,6 +1034,7 @@ export function TaskChatComposer({ else clearDraft(draftKey); } setBody(""); + setSendingAttachmentIds(new Set(submittedAttachments.map(item => item.id))); setSubmitting(true); let attemptId: string | null = null; let request: ComposerSendRequest | null = null; @@ -1133,6 +1138,7 @@ export function TaskChatComposer({ setBody(restoredBody); } finally { if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null; + setSendingAttachmentIds(null); setSubmitting(false); } } diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx index 31e21fce020..f8faac83c84 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx @@ -5,6 +5,8 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IssueQueuedCommentQueue } from "@paperclipai/shared"; +import { ThemeProvider } from "@/context/ThemeContext"; +import { MemoryRouter } from "@/lib/router"; import { reorderQueuedMessageEntries, TaskChatQueuedMessages, @@ -76,7 +78,7 @@ describe("TaskChatQueuedMessages", () => { onDiscard: vi.fn().mockResolvedValue(undefined), ...overrides, }; - flushSync(() => root.render()); + flushSync(() => root.render()); return props; } @@ -116,6 +118,53 @@ describe("TaskChatQueuedMessages", () => { expect(container.textContent).toContain("Second queued message"); }); + it("renders queued Markdown and screenshot thumbnails without showing image syntax", () => { + const body = "Check **mobile** and *desktop* with `pnpm verify`.\n\n![mobile.png](/api/attachments/mobile/content)\n![desktop.png](/api/attachments/desktop/content)"; + render({ queue: { ...queue, entries: [{ ...queue.entries[0], comment: { ...queue.entries[0].comment, body } }] } }); + const row = container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')!; + expect(row.querySelector("strong")?.textContent).toBe("mobile"); + expect(row.querySelector("em")?.textContent).toBe("desktop"); + expect(row.querySelector("code")?.textContent).toBe("pnpm verify"); + expect([...row.querySelectorAll("img")].map(img => [img.alt, img.getAttribute("src")])).toEqual([ + ["mobile.png", "/api/attachments/mobile/content"], + ["desktop.png", "/api/attachments/desktop/content"], + ]); + expect(row.textContent).not.toContain("!["); + expect(row.textContent).not.toContain("/api/attachments"); + }); + + it("shows a thumbnail for an image-only queued message", () => { + const body = "![screen.png](/api/attachments/screen/content)"; + render({ queue: { ...queue, entries: [{ ...queue.entries[0], comment: { ...queue.entries[0].comment, body } }] } }); + expect(container.querySelector('img[alt="screen.png"]')?.getAttribute("src")).toBe("/api/attachments/screen/content"); + expect(container.textContent).not.toContain(body); + }); + + it("only loads local attachment images and counts the remaining screenshots", () => { + const urls = [ + "/api/attachments/first/content", "/api/assets/second/content?download=0", + "/api/attachments/third/content", "/api/attachments/fourth/content", + "https://other.example/api/attachments/pixel/content", "//other.example/pixel.png", + "javascript:alert", "data:image/png;base64,AAAA", "/other/pixel.png", + "/api/attachments/first/content/extra", "/api/attachments/first/content#fragment", + ]; + const body = urls.map((url, index) => `![screen-${index}](${url})`).join("\n"); + render({ queue: { ...queue, entries: [{ ...queue.entries[0], comment: { ...queue.entries[0].comment, body } }] } }); + const row = container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')!; + expect([...row.querySelectorAll("img")].map(image => image.getAttribute("src"))).toEqual(urls.slice(0, 3)); + expect(row.querySelector('[aria-label="Queued images"]')?.textContent).toBe("+1"); + }); + + it("omits inline images from the compact text without exposing their URLs", () => { + const body = "Check ![inline](/api/attachments/inline/content) then **retry**."; + render({ queue: { ...queue, entries: [{ ...queue.entries[0], comment: { ...queue.entries[0].comment, body } }] } }); + const row = container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')!; + expect(row.querySelector("strong")?.textContent).toBe("retry"); + expect(row.textContent).toContain("Check then retry."); + expect(row.textContent).not.toContain("/api/attachments"); + expect(row.querySelector("img")).toBeNull(); + }); + it.each(["legacy", "native", "native-plan"] as const)("shows a read-only response and sends it only on click for %s", async (runtime) => { const entry = { ...queue.entries[0], canEdit: false, canDiscard: false, comment: { ...queue.entries[0].comment, body: 'Accepted: Build the app\n\n{"revision": "v1"}' }, diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx index a1799df0f43..442f763f6c7 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx @@ -29,6 +29,8 @@ import type { IssueQueuedCommentQueue, } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { extractImageRefs, stripStandaloneImageEmbeds } from "./task-chat-attachments"; import { TaskChatComposerBar } from "./TaskChatComposerBar"; import { queuedMessageAutoSteerDeadline, @@ -121,7 +123,10 @@ function SortableQueuedMessage({ onCancelAutoSteer: () => void; }) { const immutableResponse = entry.source?.kind === "interaction"; - const label = queuedMessageLabel(entry); + const body = queuedMessageLabel(entry); + const images = extractImageRefs(body).filter(image => + /^\/api\/(?:attachments|assets)\/[^/?#\\]+\/content(?:\?[^#\\]*)?$/.test(image.url)); + const label = stripStandaloneImageEmbeds(body); const sortable = useSortable({ id: entry.comment.id, disabled: queueMutationDisabled || immutableResponse, @@ -144,7 +149,7 @@ function SortableQueuedMessage({ ref={sortable.setNodeRef} style={style} className={cn( - "group flex h-11 min-w-0 items-center gap-1 border-b border-border/55 bg-card/95 px-2.5 text-sm last:border-b-0", + "group flex min-h-11 min-w-0 flex-wrap items-center gap-1 border-b border-border/55 bg-card/95 px-2.5 py-1 text-sm last:border-b-0", sortable.isDragging && "relative z-20 rounded-lg border border-border shadow-lg", )} @@ -156,7 +161,7 @@ function SortableQueuedMessage({ {...sortable.attributes} {...sortable.listeners} disabled={queueMutationDisabled || immutableResponse} - aria-label={`Reorder queued message: ${label}`} + aria-label={`Reorder queued message: ${label || "Images"}`} className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-40" > @@ -166,9 +171,20 @@ function SortableQueuedMessage({ className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden /> - - {label} - +
+ {label ? {label} : null} + {images.length ?
+ {images.slice(0, 3).map(image => {image.name})} + {images.length > 3 ? +{images.length - 3} : null} +
: null} +
{autoSteerSecondsLeft !== null ? ( diff --git a/ui/storybook/stories/steering/composer-queued-messages.stories.tsx b/ui/storybook/stories/steering/composer-queued-messages.stories.tsx index 0d0d26f918f..88c25eb8b0d 100644 --- a/ui/storybook/stories/steering/composer-queued-messages.stories.tsx +++ b/ui/storybook/stories/steering/composer-queued-messages.stories.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import type { IssueQueuedCommentEntry, IssueQueuedCommentQueue } from "@paperclipai/shared"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages"; import { QUEUED_MESSAGE_AUTO_STEER_DELAY_MS } from "@/components/task-chat/queued-message-auto-steer"; +import { Button } from "@/components/ui/button"; import { composerAgentAppearance, composerAgents } from "../../prototypes/composer-model-picker/fixtures"; const agentMap = new Map(composerAgents.map((agent) => [agent.id, { @@ -46,10 +47,13 @@ type QueueStoryProps = { mobile: boolean; /** Production delay before an eligible queued message is steered automatically. */ autoSteer: boolean; + screenshotSubmission: boolean; }; -function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile, autoSteer }: QueueStoryProps) { - const [entries, setEntries] = useState(initialEntries); +function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile, autoSteer, screenshotSubmission }: QueueStoryProps) { + const [entries, setEntries] = useState(screenshotSubmission ? [] : initialEntries); + const [pendingSave, setPendingSave] = useState<{ resolve: () => void; reject: () => void } | null>(null); + const uploadIndex = useRef(0); const [editingId, setEditingId] = useState(initialEdit ? "queued-1" : null); const [sent, setSent] = useState([]); const [notice, setNotice] = useState(null); @@ -76,6 +80,10 @@ function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile, au

PAP-204 · Codie is working

Steering and queued messages

+ {pendingSave ?
+ + +
: null}

I’m updating the composer and checking the responsive layout.

@@ -95,17 +103,38 @@ function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile, au editingCommentId={editingId} autoSteerDelayMs={autoSteer ? QUEUED_MESSAGE_AUTO_STEER_DELAY_MS : null} /> : null} -
+
{ if (running) { - setEntries((current) => [...current, queuedEntry(body, `queued-${Date.now()}`, current.length)]); + const id = `queued-${Date.now()}`; + setEntries((current) => [...current, queuedEntry(body, id, current.length)]); setNotice("Message queued for the next turn."); + if (screenshotSubmission) { + try { + await new Promise((resolve, reject) => setPendingSave({ resolve, reject: () => reject(new Error("Save rejected")) })); + } catch (error) { + remove(id); + setNotice("Save rejected. The draft is restored."); + throw error; + } + } } else { setSent((current) => [...current, body]); } }} workMode="standard" + draftKey={screenshotSubmission ? "storybook-screenshot-submit" : undefined} + onAttachImage={screenshotSubmission ? async (file) => { + const id = `00000000-0000-4000-8000-00000000000${uploadIndex.current++ % 7}`; + return { + id, companyId: "storybook", issueId: queue.issueId, issueCommentId: null, + assetId: id, provider: "storybook", objectKey: file.name, contentType: file.type, + byteSize: file.size, sha256: "0".repeat(64), originalFilename: file.name, + createdByAgentId: null, createdByUserId: "storybook-user", createdAt: new Date(), updatedAt: new Date(), + contentPath: `/api/attachments/${id}/content`, + }; + } : undefined} onStop={running ? async () => setRunning(false) : undefined} queuedEdit={editingId ? { commentId: editingId, @@ -147,16 +176,19 @@ const meta = { ], "status": "current", "implementation": "production", - "boundary": "fixtures" + "boundary": "fixtures", + "notes": "Production composer and queue. ScreenshotSubmission uses static attachment receipts and controls the simulated pending, accepted or rejected save. No backend or provider runs." }, }, - args: { protocol: "paperclip_runner_v1", steeringDisposition: "available", initialEdit: false, mobile: false, autoSteer: false }, + args: { protocol: "paperclip_runner_v1", steeringDisposition: "available", initialEdit: false, mobile: false, autoSteer: false, screenshotSubmission: false }, } satisfies Meta; export default meta; type Story = StoryObj; export const QueuedWhileRunning: Story = {}; +/** Production composer and queue; the buttons control only the simulated save response. */ +export const ScreenshotSubmission: Story = { args: { screenshotSubmission: true } }; export const SteerIntoActiveTurn: Story = { play: async ({ canvasElement }) => { const page = within(canvasElement.ownerDocument.body);