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. */