Files
PaperClipAI/ui/src/components/task-chat/TaskChatQueuedMessages.tsx
T
DottaandClaude Opus 5.5 1c165e9f1e fix(ui): bound the queued-message strip so it scrolls instead of covering the conversation (#19321)
## 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 <noreply@anthropic.com>
2026-11-05 14:37:24 -06:00

463 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
CornerDownRight,
GripVertical,
Loader2,
MoreHorizontal,
Pencil,
Trash2,
} from "lucide-react";
import type {
IssueQueuedCommentEntry,
IssueQueuedCommentQueue,
} from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { TaskChatComposerBar } from "./TaskChatComposerBar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type QueueAction = "steer" | "interrupt" | "discard" | null;
export function reorderQueuedMessageEntries(
entries: IssueQueuedCommentEntry[],
activeId: string,
overId: string,
) {
if (entries.some((entry) => entry.source?.kind === "interaction")) return null;
const from = entries.findIndex((entry) => entry.comment.id === activeId);
const to = entries.findIndex((entry) => entry.comment.id === overId);
if (from < 0 || to < 0 || from === to) return null;
return arrayMove(entries, from, to).map((entry, position) => ({
...entry,
position,
}));
}
function queueActionErrorCode(error: unknown): string | null {
if (typeof error !== "object" || error === null) return null;
const body = (error as { body?: unknown }).body;
if (typeof body !== "object" || body === null) return null;
const directCode = (body as { code?: unknown }).code;
if (typeof directCode === "string") return directCode;
const details = (body as { details?: unknown }).details;
if (typeof details !== "object" || details === null) return null;
const detailCode = (details as { code?: unknown }).code;
return typeof detailCode === "string" ? detailCode : null;
}
export interface TaskChatQueuedMessagesProps {
queue: IssueQueuedCommentQueue;
onEdit: (commentId: string) => void;
onReorder: (orderedCommentIds: string[], revision: string) => Promise<void>;
onSteer: (commentId: string, revision: string) => Promise<void>;
onInterrupt?: () => Promise<IssueQueuedCommentQueue | void>;
onDiscard: (commentId: string, revision: string) => Promise<void>;
}
function SortableQueuedMessage({
entry,
queue,
busy,
queueMutationDisabled,
action,
onEdit,
onSteer,
onInterrupt,
onDiscard,
}: {
entry: IssueQueuedCommentEntry;
queue: IssueQueuedCommentQueue;
busy: boolean;
queueMutationDisabled: boolean;
action: QueueAction;
onEdit: () => void;
onSteer: () => void;
onInterrupt?: () => void;
onDiscard: () => void;
}) {
const immutableResponse = entry.source?.kind === "interaction";
const label = immutableResponse ? entry.comment.body.split("\n")[0] : entry.comment.body;
const sortable = useSortable({
id: entry.comment.id,
disabled: queueMutationDisabled || immutableResponse,
});
const style = {
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
};
const steerDisabled =
queueMutationDisabled || queue.steeringDisposition !== "available";
const steerTitle =
queue.steeringDisposition === "unsupported"
? "This runner does not support steering"
: queue.steeringDisposition === "temporarily_unavailable"
? "Steering is temporarily unavailable"
: "Steer this message into the active turn";
return (
<div
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",
sortable.isDragging &&
"relative z-20 rounded-lg border border-border shadow-lg",
)}
data-testid={`task-chat-queued-message-${entry.comment.id}`}
>
<button
type="button"
ref={sortable.setActivatorNodeRef}
{...sortable.attributes}
{...sortable.listeners}
disabled={queueMutationDisabled || immutableResponse}
aria-label={`Reorder queued message: ${entry.comment.body}`}
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"
>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
</button>
<CornerDownRight
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate px-1" title={entry.comment.body}>
{label}
</span>
{(queue.targetRunId || queue.state === "deferred") && (!queue.targetRunId || queue.protocol === "legacy" || entry.source?.requiresFreshSession ? (
<button
type="button"
onClick={onInterrupt}
aria-label={!queue.targetRunId ? "Send queued messages" : undefined}
disabled={busy || !queue.queueId || !onInterrupt}
title={queue.targetRunId ? "Interrupt the active turn and send queued messages" : "Send queued messages now"}
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
data-testid={`task-chat-queued-interrupt-${entry.comment.id}`}
>
{action === "interrupt" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
)}
{queue.targetRunId ? "Interrupt" : "Send"}
</button>
) : (
<button
type="button"
onClick={onSteer}
disabled={steerDisabled}
title={steerTitle}
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
data-testid={`task-chat-queued-steer-${entry.comment.id}`}
>
{action === "steer" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
)}
Steer
</button>
))}
<button
type="button"
onClick={onDiscard}
disabled={
busy ||
(!queue.queueId && !entry.comment.id.startsWith("optimistic-")) ||
!entry.canDiscard
}
title="Discard queued message"
aria-label={`Discard queued message: ${entry.comment.body}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
data-testid={`task-chat-queued-discard-${entry.comment.id}`}
>
{action === "discard" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Trash2 className="h-3.5 w-3.5" aria-hidden />
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={queueMutationDisabled || immutableResponse}
title="Queued message actions"
aria-label={`Queued message actions: ${entry.comment.body}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem disabled={!entry.canEdit} onSelect={onEdit}>
<Pencil className="h-4 w-4" aria-hidden />
Edit message
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/** Compact production queue shown immediately above the default task composer. */
export function TaskChatQueuedMessages({
queue,
onEdit,
onReorder,
onSteer,
onInterrupt,
onDiscard,
}: TaskChatQueuedMessagesProps) {
const [entries, setEntries] = useState(queue.entries);
const latestQueue = useRef(queue);
latestQueue.current = queue;
const optimisticDeliveryIds = useRef(new Set<string>());
const [pending, setPending] = useState<{
commentId: string;
action: Exclude<QueueAction, null>;
} | null>(null);
const [reordering, setReordering] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [visibleError, setVisibleError] = useState<string | null>(null);
const [deferredStatus, setDeferredStatus] = useState<{ queueId: string | null; message: string } | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
useEffect(() => {
// Polling can return the old queue while delivery is still in flight.
setEntries(queue.entries.filter(
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
));
}, [queue.entries, queue.revision]);
useEffect(() => {
setDeferredStatus(current => {
if (!current) return null;
if (queue.state !== "deferred" || queue.queueId !== current.queueId || !queue.entries.length) return null;
const message = queue.executionWait?.message ?? "Messages are still queued. They will send when the task is ready.";
return current.message === message ? current : { ...current, message };
});
}, [queue.state, queue.queueId, queue.entries.length, queue.executionWait?.message]);
const ids = useMemo(
() => entries.map((entry) => entry.comment.id),
[entries],
);
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (
!queue.queueId ||
!over ||
active.id === over.id ||
reordering ||
pending
)
return;
const previous = entries;
const next = reorderQueuedMessageEntries(
entries,
String(active.id),
String(over.id),
);
if (!next) return;
const orderedIds = next.map((entry) => entry.comment.id);
const activeCommentId = String(active.id);
const to = next.findIndex((entry) => entry.comment.id === activeCommentId);
setEntries(next);
setReordering(true);
setVisibleError(null);
setAnnouncement(
`Moved queued message to position ${to + 1} of ${next.length}.`,
);
try {
await onReorder(orderedIds, queue.revision);
} catch (error) {
setEntries(previous);
setAnnouncement("");
setVisibleError(
queueActionErrorCode(error) === "queued_comment_revision_conflict"
? "The queue changed in another session. Its latest order has been restored."
: "Couldn’t reorder. Previous order restored.",
);
} finally {
setReordering(false);
}
}
async function runRowAction(
commentId: string,
action: Exclude<QueueAction, null>,
) {
const locallyDiscardable =
action === "discard" && commentId.startsWith("optimistic-");
if (
pending ||
reordering ||
(!queue.queueId && action !== "interrupt" && !locallyDiscardable)
) {
return;
}
const deliveredIds = action === "interrupt"
? entries.map((entry) => entry.comment.id)
: action === "steer" ? [commentId] : [];
for (const id of deliveredIds) optimisticDeliveryIds.current.add(id);
if (deliveredIds.length) {
setEntries((current) => current.filter(
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
));
}
setPending({ commentId, action });
setVisibleError(null); setDeferredStatus(null);
setAnnouncement(
action === "steer"
? "Steering queued message."
: action === "interrupt"
? "Sending queued messages."
: "Discarding queued message.",
);
try {
if (action === "steer") await onSteer(commentId, queue.revision);
else if (action === "interrupt") {
const updatedQueue = await onInterrupt?.();
if (updatedQueue?.state === "deferred") {
for (const entry of updatedQueue.entries) optimisticDeliveryIds.current.delete(entry.comment.id);
setEntries(updatedQueue.entries.filter(entry => !optimisticDeliveryIds.current.has(entry.comment.id)));
setDeferredStatus({ queueId: updatedQueue.queueId, message: updatedQueue.executionWait?.message ?? "Messages are still queued. They will send when the task is ready." });
setAnnouncement("Messages are still queued.");
return;
}
}
else await onDiscard(commentId, queue.revision);
if (action === "discard") {
setEntries((current) =>
current.filter((entry) => entry.comment.id !== commentId),
);
}
setAnnouncement(
action === "steer"
? "Message steered into the active turn."
: action === "interrupt"
? queue.targetRunId
? "Queued messages will be sent when the previous run has stopped."
: "Queued messages submitted. Waiting for the agent."
: "Queued message discarded.",
);
} catch (error) {
for (const id of deliveredIds) optimisticDeliveryIds.current.delete(id);
if (deliveredIds.length) {
setEntries(latestQueue.current.entries.filter(
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
));
}
setAnnouncement("");
const code = queueActionErrorCode(error);
setVisibleError(
code === "queued_comment_already_dispatching"
? "Too late to discard: this message is already being sent."
: action === "steer"
? "Couldn’t steer. Message is still queued."
: action === "interrupt"
? queue.targetRunId ? "Couldn’t interrupt. Message is still queued." : "Couldn’t send. Message is still queued."
: code === "queued_comment_revision_conflict"
? "The queue changed in another session. Review it and try again."
: "Couldn’t discard. Message is still queued.",
);
} finally {
setPending(null);
}
}
if (entries.length === 0 && !visibleError && !deferredStatus) return null;
return (
<TaskChatComposerBar
data-testid="task-chat-queued-messages"
aria-label="Queued messages"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDragEnd(event)}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{/* Bounded so a long queue scrolls here instead of covering the conversation. */}
<div
className="max-h-(--tc-queued-list-max-h) overflow-y-auto overscroll-contain"
data-testid="task-chat-queued-messages-list"
>
{entries.map((entry) => {
const action =
pending?.commentId === entry.comment.id ? pending.action : null;
const busy = Boolean(pending || reordering);
const queueMutationDisabled = Boolean(
!queue.queueId || pending || reordering,
);
return (
<SortableQueuedMessage
key={entry.comment.id}
entry={entry}
queue={queue}
busy={busy}
queueMutationDisabled={queueMutationDisabled}
action={action}
onEdit={() => onEdit(entry.comment.id)}
onSteer={() => void runRowAction(entry.comment.id, "steer")}
onInterrupt={
onInterrupt
? () => void runRowAction(entry.comment.id, "interrupt")
: undefined
}
onDiscard={() => void runRowAction(entry.comment.id, "discard")}
/>
);
})}
</div>
</SortableContext>
</DndContext>
{deferredStatus ? <p role="status" className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">{deferredStatus.message}</p> : null}
{visibleError ? (
<div
role="status"
aria-live="polite"
className="border-t border-destructive/20 bg-destructive/5 px-3 py-1.5 text-xs text-destructive"
>
{visibleError}
</div>
) : null}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</div>
</TaskChatComposerBar>
);
}