From cbe6395cc54c50b98968d163739e3e12c191f52c Mon Sep 17 00:00:00 2001 From: scotttong Date: Thu, 20 Aug 2026 00:17:55 -0700 Subject: [PATCH] fix(ui): task chat composer clears on send; align carets and composer with thread (#11772) 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. > - The task detail view uses a chat-style thread: agent turns, activity phases, and a message composer. > - A colleague's UX review found three defects: the composer draft stayed visible after send, disclosure carets in turns and status pills were misaligned, and the composer was not horizontally aligned with the thread column. > - These defects make the chat surface feel unpolished and cause confusion about whether a message was sent. > - This pull request fixes all three defects with small, targeted UI changes and adds regression tests for each. > - The benefit is a chat surface that behaves and aligns like users expect from a messaging UI. ## Linked Issues or Issue Description No public GitHub issue exists for this. Description of the underlying problems: **What happened?** Three UI defects in the chat-style task view: 1. After a user pressed send, the composer kept the draft text until the server round-trip finished. Fast typers could see stale text and doubt the message was sent. 2. The disclosure carets on collapsed agent turns and the caret inside the status pill did not share one alignment axis. They rendered at different x-offsets and sizes. 3. The composer container had different horizontal padding than the thread column above it, so the input box did not line up with the message bubbles. **Expected behavior** The composer clears the instant a send starts. All disclosure carets sit on one vertical axis with one size. The composer's left and right edges align with the thread column. **Steps to reproduce** Open any task in the chat-style task view. Type a message and press Enter — watch the composer text. Collapse and expand agent turns — compare caret positions. Compare the composer's horizontal edges with the message bubbles above it. ## What Changed - `TaskChatComposer.tsx`: clear the draft synchronously when a send starts instead of after the request resolves; restore the draft if the send fails. - `TaskChatTurn.tsx` and `TaskChatStatusPill.tsx`: use one shared caret alignment (size, x-offset) for turn disclosure and status pill carets, with supporting utility styles in `ui/src/index.css`. - `TaskChatThread.tsx`: align the composer container with the thread column padding. - Added or updated unit tests in `TaskChatComposer.test.tsx`, `TaskChatTurn.test.tsx`, `TaskChatActivityPhase.test.tsx`, and `TaskChatThread.test.tsx`. ## Verification - Run `pnpm vitest run src/components/task-chat/TaskChatComposer.test.tsx src/components/task-chat/TaskChatActivityPhase.test.tsx src/components/task-chat/TaskChatTurn.test.tsx src/components/TaskChatThread.test.tsx` in `ui/` — 4 files, 64 tests, all pass. - Manual: open a task in the chat view, send a message, and confirm the composer clears immediately. Collapse/expand turns and confirm the carets align. Compare composer edges with the thread column; the alignment fixes were also pixel-verified with screenshots during local review. ## Risks - Low risk. All changes are render-layer only; no server or data changes. - The composer now clears optimistically. If a send fails, the draft is restored, so no user text is lost. - Caret alignment uses shared CSS utilities; visual regressions would show in the existing component tests and in any screenshot diff. ## Model Used - Implementation: OpenAI Codex CLI coding agent (Codex model family, agentic tool use) via Paperclip's Codex adapter. - Review, verification, rebase, and PR preparation: Anthropic Claude, model id `claude-fable-5` (extended thinking, tool use), via Claude Code. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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: Paperclip Co-authored-by: Claude Fable 5 --- ui/src/components/TaskChatThread.test.tsx | 7 ++-- ui/src/components/TaskChatThread.tsx | 4 +-- .../task-chat/TaskChatActivityPhase.test.tsx | 1 + .../task-chat/TaskChatComposer.test.tsx | 23 +++++++++---- .../components/task-chat/TaskChatComposer.tsx | 32 +++++++++++++------ .../task-chat/TaskChatStatusPill.tsx | 14 ++++---- .../task-chat/TaskChatTurn.test.tsx | 5 +++ ui/src/components/task-chat/TaskChatTurn.tsx | 13 +++----- ui/src/components/task-chat/motion-tokens.ts | 1 + ui/src/index.css | 18 +++++++++++ 10 files changed, 81 insertions(+), 37 deletions(-) diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 1f3ec7d5b8..a4274bcbce 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -116,8 +116,8 @@ describe("TaskChatThread draft pass-through", () => { }); }); -describe("TaskChatThread composer alignment (PAP-498)", () => { - it("matches the thread width on mobile and stays narrower on larger screens", () => { +describe("TaskChatThread composer alignment", () => { + it("matches the thread width at every breakpoint", () => { render( {}} />); const dock = container @@ -125,7 +125,8 @@ describe("TaskChatThread composer alignment (PAP-498)", () => { ?.closest("div.sticky") as HTMLElement | null; expect(dock?.className).toContain("w-full"); - expect(dock?.className).toContain("md:w-(--pct-80)"); + expect(dock?.className).toContain("max-w-(--tc-shell-max-w)"); + expect(dock?.className).not.toContain("md:w-(--pct-80)"); }); }); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index fbbc444d70..666fdfc0f0 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -726,9 +726,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { isMobile ? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out" : "bottom-0 z-10", - // Match the thread width on mobile. Keep the intentionally - // narrower composer on larger screens. - "mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-4 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:w-(--pct-80)", + "mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-4 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60", )} > {composerAccessory} diff --git a/ui/src/components/task-chat/TaskChatActivityPhase.test.tsx b/ui/src/components/task-chat/TaskChatActivityPhase.test.tsx index ff4124cba6..260b784747 100644 --- a/ui/src/components/task-chat/TaskChatActivityPhase.test.tsx +++ b/ui/src/components/task-chat/TaskChatActivityPhase.test.tsx @@ -15,6 +15,7 @@ describe("TaskChatActivityPhase", () => { items: [{ id: "tool-1", kind: "tool", name: "Read", status: "completed" }], }} renderChild={(child) => } />)); const summary = container.querySelector('[data-testid="task-chat-phase-summary"]')!; + expect(summary.firstElementChild?.tagName).toBe("svg"); expect(summary.getAttribute("aria-expanded")).toBe("false"); expect(container.textContent).not.toContain("tool-1"); flushSync(() => summary.click()); diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index dd4b0a0448..8e3155c947 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -673,15 +673,16 @@ describe("TaskChatComposer", () => { } }); - it("clears the saved draft only after a successful send", async () => { + it("clears the composer and saved draft while the send is pending", async () => { localStorage.setItem(draftKey, "queued message"); - const onAdd = vi.fn().mockResolvedValue(undefined); + const onAdd = vi.fn().mockReturnValue(new Promise(() => {})); render(); pressKey("Enter", { metaKey: true }); await flushAsync(); expect(onAdd).toHaveBeenCalledWith("queued message", undefined, undefined); + expect(editable().textContent).toBe(""); expect(localStorage.getItem(draftKey)).toBeNull(); }); @@ -737,10 +738,13 @@ describe("TaskChatComposer", () => { .toContain("next.txt"); }); - it("keeps the body and saved draft when sending fails", async () => { + it("restores a failed send before text entered while it was pending", async () => { vi.useFakeTimers(); try { - const onAdd = vi.fn().mockRejectedValue(new Error("network down")); + let rejectSend!: (error: Error) => void; + const onAdd = vi.fn().mockReturnValue(new Promise((_resolve, reject) => { + rejectSend = reject; + })); render(); typeText("do not lose this"); vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); @@ -748,9 +752,16 @@ describe("TaskChatComposer", () => { pressKey("Enter", { metaKey: true }); await flushAsync(); + expect(editable().textContent).toBe(""); + expect(localStorage.getItem(draftKey)).toBeNull(); - expect(editable().textContent).toBe("do not lose this"); - expect(localStorage.getItem(draftKey)).toBe("do not lose this"); + typeText("next draft"); + rejectSend(new Error("network down")); + await flushAsync(); + await flushAsync(); + + expect(editable().textContent).toBe("do not lose this\n\nnext draft"); + expect(localStorage.getItem(draftKey)).toBe("do not lose this\n\nnext draft"); } finally { vi.useRealTimers(); } diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 4062b1ced6..8ff2a75ad2 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -336,21 +336,23 @@ export function TaskChatComposer({ const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined; const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined; + // The thread renders the outgoing comment optimistically, so remove its + // text from the composer at the same time. The editor remains writable for + // the next draft while the request is pending. + bodyRef.current = ""; + if (draftTimer.current) { + clearTimeout(draftTimer.current); + draftTimer.current = null; + } + if (draftKey) clearDraft(draftKey); + setBody(""); setSubmitting(true); try { if (pendingMode !== workMode && onWorkModeChange) { await onWorkModeChange(pendingMode); } await onAdd(fullBody, reopen, reassignment); - if (bodyRef.current === submittedBody) { - bodyRef.current = ""; - if (draftTimer.current) { - clearTimeout(draftTimer.current); - draftTimer.current = null; - } - if (draftKey) clearDraft(draftKey); - setBody(""); - } else if (draftKey) { + if (draftKey && bodyRef.current) { // The editor stays writable while the request is pending. Preserve // text entered after this submission started as the next draft. saveDraft(draftKey, bodyRef.current); @@ -362,7 +364,17 @@ export function TaskChatComposer({ setPendingAssignee(null); } } catch { - // Keep the body and its draft available for retry. + // Restore the failed message for retry without discarding a next draft + // that was entered while the request was pending. + const nextDraft = bodyRef.current; + const restoredBody = nextDraft ? `${submittedBody}\n\n${nextDraft}` : submittedBody; + bodyRef.current = restoredBody; + if (draftTimer.current) { + clearTimeout(draftTimer.current); + draftTimer.current = null; + } + if (draftKey) saveDraft(draftKey, restoredBody); + setBody(restoredBody); } finally { setSubmitting(false); } diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index b52cc044e5..6f2a33d6c9 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -263,7 +263,7 @@ interface TaskChatStatusPillProps { onApprovalDecision?: (optionId: string) => void; /** * When set, the live line is the header of an expandable parent row - * (TaskChatTurn): render a trailing chevron reflecting the open state — + * (TaskChatTurn): render a leading chevron reflecting the open state — * the same expand grammar as tool rows and the settled "Worked ·" line. */ chevronOpen?: boolean; @@ -317,6 +317,12 @@ export function TaskChatStatusPill({ : item.label; const statusLine = (
+ {chevronOpen !== undefined ? ( + + ) : null} {/* Fixed-size lead slot keeps the label from moving as tool icons come and go; the pulse dot renders unconditionally. */} @@ -346,12 +352,6 @@ export function TaskChatStatusPill({ ) : null} - {chevronOpen !== undefined ? ( - - ) : null}
); // The interstitial row is PERMANENTLY RESERVED while the turn is live diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx index 1ec01cf23b..ff29568825 100644 --- a/ui/src/components/task-chat/TaskChatTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx @@ -122,12 +122,15 @@ describe("TaskChatTurn", () => { expect(summaryBtn()?.textContent).toContain("Worked"); expect(summaryBtn()?.textContent).toContain("38s · 3 tools · +34 −3 · 12.3k tokens"); expect(fold()?.getAttribute("data-folded")).toBe("true"); + expect(summaryBtn()?.firstElementChild?.tagName).toBe("svg"); + expect(summaryBtn()?.querySelector(".tc-turn-metrics")?.getAttribute("data-visible")).toBe("false"); }); it("toggles open on summary click", () => { renderTurn(SETTLED); flushSync(() => summaryBtn()!.click()); expect(fold()?.getAttribute("data-folded")).toBe("false"); + expect(summaryBtn()?.querySelector(".tc-turn-metrics")?.getAttribute("data-visible")).toBe("true"); }); it("a headerless live turn renders expanded with no summary line", () => { @@ -149,6 +152,7 @@ describe("TaskChatTurn", () => { expect(header?.textContent).toContain("Editing files…"); expect(header?.textContent).toContain("Edit · server/src/routes/auth.ts"); expect(header?.getAttribute("aria-expanded")).toBe("false"); + expect(header?.firstElementChild?.firstElementChild?.tagName).toBe("svg"); // All activity is folded behind it — no rows visible, no summary line. expect(fold()?.getAttribute("data-folded")).toBe("true"); expect(summaryBtn()).toBeNull(); @@ -257,6 +261,7 @@ describe("TaskChatTurn", () => { // "2:34 PM · ✓ Worked · …" — timestamp first, always visible, and the // expand affordance still works from the same line. expect(summaryBtn()?.textContent).toMatch(/^2:34 PM·Worked/); + expect(summaryBtn()?.firstElementChild?.tagName).toBe("svg"); flushSync(() => summaryBtn()!.click()); expect(fold()?.getAttribute("data-folded")).toBe("false"); }); diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index 20455f89b7..762dd042ce 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -88,6 +88,7 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground" data-testid="task-chat-turn-summary" > + {timestampPrefix ? ( <> {timestampPrefix} @@ -101,16 +102,12 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta // DOM (and the accessible tree) but fades in only on hover/focus so the // settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when // the fold is open, so the metrics don't vanish while you read below. - - {turnSummaryMetrics(item.summary)} + + + {turnSummaryMetrics(item.summary)} + ) : null} - ) : parentRow ? ( // The pill renders the expand button itself, wrapped around only the diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index 2b598c0a2b..c6259c2f5c 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -58,6 +58,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 }, { name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-turn-meta", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-line-scroll", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-interstitial-dwell", group: "States", kind: "time", min: 0, max: 10000, step: 100 }, { name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, diff --git a/ui/src/index.css b/ui/src/index.css index 2bc7bc81de..c2f79d6155 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -245,6 +245,7 @@ --motion-count-tween: var(--motion-duration-slow); --motion-streaming-cursor-blink: 1.1s; --motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */ + --motion-turn-meta: var(--motion-duration-fast); /* finished-turn hover metadata reveal */ --motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */ --motion-interstitial-dwell: 4000ms; /* interstitial update hold: minimum before the next swaps in, and how long a finished update sits before sliding away */ --motion-scroll-pill-enter: 200ms; /* scroll-to-latest pill entry */ @@ -683,6 +684,22 @@ .tc-turn-fold[data-folded="true"] { grid-template-rows: 0fr; opacity: 0; } .tc-turn-fold > * { overflow: hidden; min-height: 0; } +/* Settled-turn metadata remains available on hover/focus and while expanded, + but its collapsed grid track consumes no width at rest. */ +.tc-turn-metrics { + display: grid; + grid-template-columns: 0fr; + opacity: 0; + transition: grid-template-columns var(--motion-turn-meta) var(--motion-ease-standard), + opacity var(--motion-turn-meta) var(--motion-ease-standard); +} +[data-testid="task-chat-turn-summary"]:hover .tc-turn-metrics, +[data-testid="task-chat-turn-summary"]:focus-visible .tc-turn-metrics, +.tc-turn-metrics[data-visible="true"] { + grid-template-columns: 1fr; + opacity: 1; +} + /* Properties-pane maximize/restore glide (left animates between docked and sidebar-flush while the pane is position:fixed). */ .tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); } @@ -698,6 +715,7 @@ .tc-scroll-pill-out, .tc-enter-plan-entry { animation: none; } .tc-turn-fold, + .tc-turn-metrics, .tc-line-scroll-inner, .tc-pane-glide { transition: none; } /* The pill keyframes carry the X-centering; with animation:none restore it. */