diff --git a/tests/storybook-visual/queued-messages-dock-layout.spec.ts b/tests/storybook-visual/queued-messages-dock-layout.spec.ts new file mode 100644 index 00000000000..6cea06c936e --- /dev/null +++ b/tests/storybook-visual/queued-messages-dock-layout.spec.ts @@ -0,0 +1,103 @@ +import { expect, test, type Page } from "@playwright/test"; + +// BUG-04: the queued/steering strip above the task composer grew without a +// height bound. With several long queued messages it covered the transcript, +// pushed the Coordinator dock past the top of the thread and clipped against +// the page chrome. These cases render the production dock and strip. + +type LayoutCase = { + name: string; + story: string; + width: number; + height: number; + theme?: "dark"; +}; + +const CASES: LayoutCase[] = [ + { name: "desktop", story: "coordinator-dock-queued-messages--many-queued-messages", width: 1245, height: 760 }, + { name: "desktop dark", story: "coordinator-dock-queued-messages--many-queued-messages-dark", width: 1245, height: 760, theme: "dark" }, + { name: "short desktop window", story: "coordinator-dock-queued-messages--many-queued-messages", width: 1024, height: 600 }, + { name: "very short desktop window", story: "coordinator-dock-queued-messages--many-queued-messages", width: 1024, height: 420 }, + { name: "narrow phone", story: "coordinator-dock-queued-messages--many-queued-messages-phone", width: 390, height: 700 }, +]; + +async function renderStory(page: Page, layout: LayoutCase) { + await page.setViewportSize({ width: layout.width, height: layout.height }); + const globals = layout.theme ? `&globals=theme:${layout.theme}` : ""; + await page.goto(`/iframe.html?id=${layout.story}&viewMode=story${globals}`, { waitUntil: "load" }); + await page.waitForFunction(() => { + const body = document.body; + return body.classList.contains("sb-show-main") || body.classList.contains("sb-show-errordisplay"); + }); + expect(await page.locator(".sb-show-errordisplay").count(), `story ${layout.story} threw during render`).toBe(0); + await expect(page.getByTestId("task-chat-queued-messages")).toBeVisible({ timeout: 15_000 }); + // Let the dock's ResizeObserver publish its measured height. + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); +} + +async function box(page: Page, testId: string) { + const rect = await page.getByTestId(testId).first().boundingBox(); + expect(rect, `${testId} should be rendered`).not.toBeNull(); + return rect!; +} + +for (const layout of CASES) { + test(`queued messages stay bounded inside the composer dock (${layout.name})`, async ({ page }) => { + await renderStory(page, layout); + + const header = await box(page, "story-task-header"); + const thread = await box(page, "task-chat-thread"); + const dock = await box(page, "task-chat-composer-dock"); + const strip = await box(page, "task-chat-queued-messages"); + + // The dock stays inside the thread: it never rides over the task header. + expect(dock.y, "composer dock overlaps the task header").toBeGreaterThanOrEqual(header.y + header.height - 1); + expect(dock.y, "composer dock escapes the top of the thread").toBeGreaterThanOrEqual(thread.y - 1); + expect(dock.y + dock.height, "composer dock is clipped by the viewport").toBeLessThanOrEqual(layout.height + 1); + + // The strip takes at most a bounded share of the thread so the + // conversation stays readable; the remaining queue scrolls inside it. + expect(strip.height, "queued strip covers the conversation").toBeLessThanOrEqual(thread.height * 0.4); + + const scroll = await page.getByTestId("task-chat-queued-messages").evaluate((element) => { + const list = element.querySelector("[data-testid=task-chat-queued-messages-list]") ?? element; + return { scrollHeight: list.scrollHeight, clientHeight: list.clientHeight, overflowY: getComputedStyle(list).overflowY }; + }); + expect(scroll.scrollHeight, "every queued message is reachable").toBeGreaterThan(scroll.clientHeight); + expect(["auto", "scroll"], "queued list scrolls instead of clipping").toContain(scroll.overflowY); + + // The last queued row and its Steer action are reachable by scrolling the strip. + const lastSteer = page.getByTestId("task-chat-queued-steer-queued-8"); + await lastSteer.scrollIntoViewIfNeeded(); + const steerBox = (await lastSteer.boundingBox())!; + const stripAfter = await box(page, "task-chat-queued-messages"); + expect(steerBox.y).toBeGreaterThanOrEqual(stripAfter.y - 1); + expect(steerBox.y + steerBox.height).toBeLessThanOrEqual(stripAfter.y + stripAfter.height + 1); + + // With the transcript scrolled to the end, the latest message is visible + // above the dock rather than hidden under it. + await page.getByTestId("task-chat-scroller").evaluate((element) => { element.scrollTop = element.scrollHeight; }); + const latest = await box(page, "story-latest-message"); + const dockAfter = await box(page, "task-chat-composer-dock"); + expect(latest.y + latest.height, "latest transcript message is covered by the dock").toBeLessThanOrEqual(dockAfter.y + 1); + expect(latest.y, "latest transcript message is pushed above the thread").toBeGreaterThanOrEqual(thread.y - 1); + }); +} + +test("queued messages still reorder by drag inside the scrolling list", async ({ page }) => { + await renderStory(page, CASES[0]); + const rowOrder = () => page.locator("[data-testid^=task-chat-queued-message-]").evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-testid")!.replace("task-chat-queued-message-", ""))); + expect((await rowOrder()).slice(0, 3)).toEqual(["queued-1", "queued-2", "queued-3"]); + + const handle = page.getByRole("button", { name: /^Reorder queued message: Before you continue/ }); + const from = (await handle.boundingBox())!; + const to = (await page.getByTestId("task-chat-queued-message-queued-3").boundingBox())!; + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 8, { steps: 4 }); + await page.mouse.move(from.x + from.width / 2, to.y + to.height / 2 + 4, { steps: 12 }); + await page.mouse.up(); + + await expect.poll(async () => (await rowOrder()).slice(0, 3)).toEqual(["queued-2", "queued-3", "queued-1"]); +}); diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx index 4404a193e29..f4bc42deded 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx @@ -410,6 +410,11 @@ export function TaskChatQueuedMessages({ onDragEnd={(event) => void handleDragEnd(event)} > + {/* Bounded so a long queue scrolls here instead of covering the conversation. */} +
{entries.map((entry) => { const action = pending?.commentId === entry.comment.id ? pending.action : null; @@ -436,6 +441,7 @@ export function TaskChatQueuedMessages({ /> ); })} +
{deferredStatus ?

{deferredStatus.message}

: null} diff --git a/ui/src/index.css b/ui/src/index.css index 9a86a555b65..b8a6258a475 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -545,6 +545,8 @@ --artifact-play-scrim: rgba(0, 0, 0, 0.55); --artifact-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1); --tc-interaction-card-max-h: 50dvh; + /* About 3.5 queued rows, and less on short windows so the conversation stays visible. */ + --tc-queued-list-max-h: min(calc(var(--spacing) * 40), 28dvh); --shadow-task-composer: 0 2px 8px -2px color-mix(in oklab, var(--foreground) 7%, transparent), 0 12px 32px -8px color-mix(in oklab, var(--foreground) 12%, transparent); --profile-popover-width: 248px; diff --git a/ui/storybook/stories/steering/coordinator-dock-queued-messages.stories.tsx b/ui/storybook/stories/steering/coordinator-dock-queued-messages.stories.tsx new file mode 100644 index 00000000000..57d35b7a61c --- /dev/null +++ b/ui/storybook/stories/steering/coordinator-dock-queued-messages.stories.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { IssueQueuedCommentEntry, IssueQueuedCommentQueue } from "@paperclipai/shared"; +import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock"; +import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages"; + +const LONG_BODIES = [ + "Before you continue, please double-check that the storefront hero copy matches the brand brief we agreed on and that the call to action points at the right collection page.", + "Also make sure the mobile navigation collapses into a single menu button below the tablet breakpoint, and that the cart badge stays visible while the menu is closed.", + "Use the GitHub connection for the repository and post the preview link in Slack once the first deploy finishes so the team can review it together.", + "If the product grid feels sparse, add two more placeholder tees with the same price tier so the layout reads as a real catalogue instead of a demo.", + "Please keep the checkout flow out of scope for this pass; a clickable stub is enough as long as it explains that payments are coming later.", + "When you are done, summarise what changed, what is still open, and which follow-up tasks you think should be created for Brand & Design.", + "One more thing: the footer links should open the policy pages in the same tab, not a new window.", + "And finally, confirm the favicon renders on a dark browser tab bar as well as a light one.", +]; + +function queuedEntry(body: string, index: number): IssueQueuedCommentEntry { + return { + comment: { + id: `queued-${index + 1}`, + companyId: "storybook", + issueId: "coordinator-dock-queue-story", + authorType: "user", + authorAgentId: null, + authorUserId: "storybook-user", + body, + presentation: null, + metadata: null, + createdAt: new Date("2026-11-05T11:40:00Z"), + updatedAt: new Date("2026-11-05T11:40:00Z"), + }, + position: index, + canEdit: true, + canDiscard: true, + }; +} + +const TRANSCRIPT = [ + "Here’s the plan for the storefront prototype. I’ll start with the brand brief and wireframes, then build the clickable prototype in the GitHub repository.", + "Brand & Design finished the brief and wireframes. Frontend Engineer is now building the storefront prototype.", + "GitHub is connected, so the continuation run can push the prototype branch.", + "I’m wiring the product grid and the hero section now. I’ll post a preview link once the first deploy finishes.", +]; + +function CoordinatorDockQueue({ count, mobile }: { count: number; mobile: boolean }) { + const [entries, setEntries] = useState(() => + Array.from({ length: count }, (_, index) => queuedEntry(LONG_BODIES[index % LONG_BODIES.length], index)), + ); + const queue: IssueQueuedCommentQueue = { + issueId: "coordinator-dock-queue-story", + queueId: "queue-story", + state: "deferred", + targetRunId: "active-run", + revision: "story-revision", + protocol: "paperclip_runner_v1", + steeringDisposition: "available", + entries, + }; + const remove = (id: string) => setEntries((current) => current.filter((entry) => entry.comment.id !== id)); + + return ( +
+
+ ONB-1 · Paperclip onboarding +
+
+
+
+
+ {TRANSCRIPT.map((text, index) => ( +

+ {text} +

+ ))} +
+
+ +
+ undefined} + onReorder={async () => undefined} + onSteer={async (id) => remove(id)} + onDiscard={async (id) => remove(id)} + /> +
+ undefined} workMode="standard" onStop={async () => undefined} mobile={mobile} /> +
+
+
+
+
+
+ ); +} + +const meta = { + id: "coordinator-dock-queued-messages", + title: "Tasks and conversations/Steering and queued messages/Composer/Coordinator dock with many queued messages", + component: CoordinatorDockQueue, + parameters: { + layout: "fullscreen", + docs: { description: { component: "The production queued-message strip inside the production Coordinator composer dock with many long queued messages. The strip must scroll within a bounded height so the transcript above it stays readable." } }, + paperclip: { + "capabilities": [ + "steering/queue" + ], + "status": "current", + "implementation": "production", + "boundary": "fixtures" + }, + }, + args: { count: 8, mobile: false }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ManyQueuedMessages: Story = {}; +export const ManyQueuedMessagesDark: Story = { globals: { theme: "dark" } }; +export const ManyQueuedMessagesPhone: Story = { + args: { mobile: true }, + globals: { viewport: { value: "mobile", isRotated: false } }, +};