From 1c165e9f1ed72c952c69d8fbdc682ee8dbe9b71f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:37:24 -0600 Subject: [PATCH] fix(ui): bound the queued-message strip so it scrolls instead of covering the conversation (#19321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - In task chat, a user can queue messages while an agent run is active. The queued messages show in a strip above the composer, with Steer, Interrupt, Discard and Edit actions. > - An onboarding test on staging reported that the steering strip clipped and covered nearby UI. > - The strip adds one 44px row for each queued message and has no height limit. With several queued messages it takes most of the thread. In the Coordinator layout the dock is absolutely positioned at the bottom of the thread, so the strip grows up over the conversation. > - This pull request puts the rows in a bounded list that scrolls. The status and error lines stay below the list. > - The benefit is that the conversation stays readable at desktop and narrow widths, and every queued message stays reachable. ## Linked Issues or Issue Description **What happened?** With several queued messages, the queued/steering strip above the task composer grew without a limit. It covered the transcript and nearby components. At a 1024x600 window, 8 queued messages made the strip 353px tall in a 555px thread. **Expected behavior** The strip stays readable and inside the available space at normal and narrow widths. Extra queued messages scroll inside the strip. **Steps to reproduce** 1. Open a task with an active run in the Coordinator workspace. 2. Queue 6 or more messages. 3. See that the strip covers most of the conversation. **Paperclip version or commit** `master` at 03eb49053f. Also seen on a staging cloud deployment during an onboarding test (BUG-04, P2). ## What Changed - `TaskChatQueuedMessages`: wrap the sortable rows in a list with `max-h-(--tc-queued-list-max-h) overflow-y-auto overscroll-contain`. Status and error lines stay outside the list, so they are always visible. - `ui/src/index.css`: new token `--tc-queued-list-max-h: min(calc(var(--spacing) * 40), 28dvh)`. This is about 3.5 rows, and less on short windows. - New Storybook story `coordinator-dock-queued-messages`: the production `TaskChatComposerDock`, queued strip and composer inside the Coordinator thread layout, with 8 long queued messages (light, dark, phone). - New Playwright spec `tests/storybook-visual/queued-messages-dock-layout.spec.ts`: measures the dock and strip against the header, thread and viewport at desktop, desktop dark, short desktop (1024x600), very short desktop (1024x420) and phone sizes. It also checks that the last queued Steer action is reachable by scrolling, and that the latest transcript message is not under the dock. A separate test drags the first queued row below the third and checks the new order inside the scrolling list. ## Verification Red first (spec run on unchanged `master` code, Storybook dev server): ``` STORYBOOK_TEST_BASE_URL=http://localhost:6016 npx playwright test --config tests/storybook-visual/playwright.config.ts --project=stories tests/storybook-visual/queued-messages-dock-layout.spec.ts --retries=0 --workers=1 ✘ 1 … queued messages stay bounded inside the composer dock (desktop) ✘ 2 … queued messages stay bounded inside the composer dock (desktop dark) ✘ 3 … queued messages stay bounded inside the composer dock (short desktop window) ✘ 4 … queued messages stay bounded inside the composer dock (narrow phone) Error: queued strip covers the conversation Expected: <= 286 Received: 353 … Error: queued strip covers the conversation Expected: <= 222 Received: 353 4 failed ``` Green after the fix (same command): ``` ✓ 1 … queued messages stay bounded inside the composer dock (desktop) (14.8s) ✓ 2 … queued messages stay bounded inside the composer dock (desktop dark) (3.3s) ✓ 3 … queued messages stay bounded inside the composer dock (short desktop window) (2.1s) ✓ 4 … queued messages stay bounded inside the composer dock (narrow phone) (2.3s) 4 passed (25.7s) ``` Second round, after review feedback that a fixed 160px cap does not shrink on very short windows. Red with the fixed `max-h-40` cap: ``` ✓ 1 … (desktop) ✓ 2 … (desktop dark) ✓ 3 … (short desktop window) ✘ 4 … (very short desktop window) ✓ 5 … (narrow phone) ✓ 6 … queued messages still reorder by drag inside the scrolling list Error: queued strip covers the conversation Expected: <= 150 Received: 161 1 failed 5 passed (18.6s) ``` Green with the `--tc-queued-list-max-h` token: ``` ✓ 1 … (desktop) (6.2s) ✓ 2 … (desktop dark) (1.9s) ✓ 3 … (short desktop window) (3.3s) ✓ 4 … (very short desktop window) (1.3s) ✓ 5 … (narrow phone) (1.4s) ✓ 6 … queued messages still reorder by drag inside the scrolling list (1.5s) 6 passed (16.1s) ``` Measured layout (strip height / dock top in a thread): | Case | Before | After | | --- | --- | --- | | 1245x760 dark | strip 353px, dock top 272 | strip 161px, dock top 464 | | 1024x600 | strip 353px, dock top 112 | strip 161px, dock top 304 | | 390x700 phone | strip 353px, dock top 228 | strip 161px, dock top 420 | Neighbouring suites and gates: - `cd ui && npx vitest run src/components/task-chat/`: 55 files, 884 tests passed (includes `TaskChatQueuedMessages.test.tsx`). - `pnpm check:token-gates`: All gates clean. - `node scripts/storybook-catalog.mjs`: in sync. Screenshots (1024x600, then 390x700; taken with the first-round 160px cap, which is the same height at these sizes): | Before | After | | --- | --- | | ![before short desktop](https://raw.githubusercontent.com/paperclipai/paperclip/0cb0f9537f4ec3f226ff974ee0f895cccebb706b/before-short-desktop.png) | ![after short desktop](https://raw.githubusercontent.com/paperclipai/paperclip/0cb0f9537f4ec3f226ff974ee0f895cccebb706b/after-short-desktop.png) | | ![before phone](https://raw.githubusercontent.com/paperclipai/paperclip/0cb0f9537f4ec3f226ff974ee0f895cccebb706b/before-phone.png) | ![after phone](https://raw.githubusercontent.com/paperclipai/paperclip/0cb0f9537f4ec3f226ff974ee0f895cccebb706b/after-phone.png) | ## Risks - Low risk. The change is one wrapper element with token-scale Tailwind classes. - Drag to reorder now happens inside a scroll container. The new spec covers a drag reorder. dnd-kit auto-scrolls the container during a drag. A dragged row can be clipped at the list edge while it moves, as before at the strip edge. - The original report mentioned a "black bar". The original screenshot was not available. I reproduced the overlap and the unbounded growth. I did not see a separate black bar in light or dark theme. The row action menu uses a Radix portal, so it does not clip. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, browser, Playwright). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../queued-messages-dock-layout.spec.ts | 103 ++++++++++++++ .../task-chat/TaskChatQueuedMessages.tsx | 6 + ui/src/index.css | 2 + ...ordinator-dock-queued-messages.stories.tsx | 130 ++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 tests/storybook-visual/queued-messages-dock-layout.spec.ts create mode 100644 ui/storybook/stories/steering/coordinator-dock-queued-messages.stories.tsx 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 } }, +};