From dff8bed007e01c32f3826144b893ec80603d1335 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:04:52 -0600 Subject: [PATCH] fix(ui): show chat activity immediately during admission (#20559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Chat uses a shared runner turn to show activity after Send. > - The turn currently shows Queued while the server admits the request. > - People need immediate activity feedback during this short wait. > - This change uses the existing Working header and Thinking activity during ordinary admission. > - Specific server holds stay visible so people can see setup or maintenance delays. ## Linked Issues or Issue Description **What existing behavior does this improve?** The activity indicator in task chat and agent chat after Send. **Current behavior** Ordinary admission shows Queued in the header and activity row. It changes to Working and Thinking when execution starts. **Proposed behavior** Show Working and Thinking from message submission through ordinary queued admission. Keep the same avatar, name, and activity row in place. Preserve specific server waits and the existing queue controls. **Subsystem affected** ui/ — React board UI. ## What Changed - Use the default activity presentation when a queued turn has no specific server hold. - Retain maintenance, agent setup, server restart, previous-run, provider, and preparation labels. - Omit the elapsed-time suffix until a start time is known. This prevents a frozen Working for 0s header before a run exists. - Update component tests for submission, acknowledgement, reassignment, and runtime discovery on desktop and mobile. - Update the activity guidance in DESIGN.md. - Keep this behavior in the shared runner turn. No API, queue, or execution contract changes are needed. ## Verification - Chat component suites: 582 passed, 1 existing skip. - Command: `pnpm exec vitest run ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx ui/src/components/TaskChatThread.test.tsx ui/src/components/IssueChatThread.test.tsx`. - `pnpm check:token-gates`: passed. - `pnpm verify`: exit 2. DESIGN.md is a global CI input, so the selector requires broader CI coverage. The three focused chat suites passed again on final commit `e85b8e43b007288f3ef41e0a42e801d5d2c41e54`. Required CI passed on this revision. See the CI result below. - The earlier CI attempt on `796fac80a06769d67af5b899fb186a875bfb228d` failed the unchanged Rust test `process_lineage::journal::tests::reaping_stops_a_dead_runners_provider_and_its_escaped_tool_job` with `unattributed_orphan`. This UI change does not alter its compiled inputs. The cause is not yet established. The current revision passed the Rust check. The earlier failure cause remains unknown. [Failed job](https://github.com/paperclipai/paperclip/actions/runs/42843806417/job/129926132490). - The workspace preflight installed dependencies and built shared and plugin SDK outputs. Its unrelated Runner build could not start because cargo is unavailable. The current revision passed CI typecheck and build. - Both current-head reviews are 5/5 with no remaining findings. There are no unresolved review threads. - Current-head typecheck, build, general tests, serialized server tests, all eight E2E shards, and Rust checks passed. [CI run](https://github.com/paperclipai/paperclip/actions/runs/42845383745). - The Runner support suite reported one 30-second timeout in `private-reporter-artifacts.test.ts`; 11,291 other tests passed. Both tests in that file passed locally in isolation (1.64 seconds for the full command). The one rerun passed in CI attempt 2. The timeout cause remains unknown. [Failed job](https://github.com/paperclipai/paperclip/actions/runs/42845383745/job/129932351496). - Final CI result: attempt 2 passed on `e85b8e43b007288f3ef41e0a42e801d5d2c41e54`. Typecheck, build, tests, Rust checks, and all eight E2E shards passed. [CI attempt 2](https://github.com/paperclipai/paperclip/actions/runs/42845383745/attempts/2). ## Risks - Low risk. This changes presentation only. A queued turn can show Thinking before provider execution starts. - Specific server holds remain visible. Scheduling, permissions, and saved message controls do not change. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The runtime does not expose a more specific deployment model ID or context window size. ## 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 --------- Co-authored-by: Paperclip --- DESIGN.md | 8 +-- ui/src/components/TaskChatThread.test.tsx | 20 +++++--- .../task-chat/TaskChatRunnerTurn.test.tsx | 49 ++++++++++++------- .../task-chat/TaskChatRunnerTurn.tsx | 4 +- 4 files changed, 51 insertions(+), 30 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 05d8865d46d..d11b7132122 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -118,9 +118,11 @@ recipient for a later draft must not change the response already starting. Runtime discovery must not replace the turn with a generic spinner or a "run started" receipt. Queued and running headers use the same spacing. -A queued turn shows `Queued`, or `Waiting for maintenance` while the server holds -admission. Reserve `Working` and `Thinking` for execution. A saved message that -starts this queued turn stays in history, including when it reopens an older +A queued turn shows the same `Working` header and shimmering `Thinking` activity +as a running turn, starting at message submission. Keep specific server holds, +such as `Waiting for maintenance` or agent setup, visible during admission. +This is presentation only; queue and execution authority stay unchanged. A saved +message that starts this queued turn stays in history, including when it reopens an older task. This placement is not a consumption receipt. Later follow-ups remain in the composer queue with their existing edit, order and discard controls. diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 17d62058637..8c8e6041e26 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -573,7 +573,7 @@ it.each([true, false])("keeps an upload attached when a conversation draft becom expect(onAdd.mock.calls[0]?.[3]).toEqual([id]); }); -it("shows Queued during admission and clears it when the saved follow-up enters backoff", async () => { +it("shows activity during admission and clears it when the saved follow-up enters backoff", async () => { const pending = { id: "optimistic-recovery", companyId: "company", issueId: "issue", authorType: "user" as const, authorAgentId: null, authorUserId: "board", body: "Confirm the existing result.", presentation: null, metadata: null, createdAt: new Date(), updatedAt: new Date(), clientStatus: "pending" as const }; @@ -586,7 +586,9 @@ it("shows Queued during admission and clears it when the saved follow-up enters agentMap={new Map([["agent", { id: "agent", name: "Worker", adapterType: "paperclip_runner" } as Agent]])} />); show(false); expect(container.textContent).toContain("Confirm the existing result."); - expect(container.textContent).toContain("Queued"); + expect(container.textContent).toContain("Working"); + expect(container.textContent).toContain("Thinking"); + expect(container.textContent).not.toContain("Queued"); show(true); expect(container.textContent).not.toContain("Thinking"); expect(container.textContent).not.toContain("Working"); @@ -616,7 +618,9 @@ it.each((["task", "legacy"] as const).flatMap(surface => try { await act(async () => { send!.click(); }); expect(onAdd).toHaveBeenCalledTimes(1); - expect(container.textContent).toContain("Queued"); + expect(container.textContent).toContain("Working"); + expect(container.textContent).toContain("Thinking"); + expect(container.textContent).not.toContain("Queued"); const identity = container.querySelector('[data-testid="task-chat-agent-identity"]'); render(); expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); @@ -757,7 +761,9 @@ it.each([null, "previous-agent"].flatMap(previousAgentId => ["paperclip_runner", render(); await act(async () => acknowledge()); expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); - expect(container.textContent).toContain("Queued"); + expect(container.textContent).toContain("Working"); + expect(container.textContent).toContain("Thinking"); + expect(container.textContent).not.toContain("Queued"); // Choosing a recipient for the next draft does not change the sent request. await act(async () => container.querySelector('[data-testid="task-chat-composer-assignee"]')!.click()); await act(async () => document.querySelector('[data-assignee-identity="agent:previous-agent"]')!.closest("button")!.click()); @@ -1681,7 +1687,7 @@ describe("TaskChatThread runtime transcript selection", () => { const activity = container.querySelector('[data-testid="task-chat-current-activity-label"]'); expect(identity?.textContent).toBe("Runner"); expect(identity?.querySelector("svg, img")).not.toBeNull(); - expect(activity?.textContent).toBe("Queued"); + expect(activity?.textContent).toBe("Thinking"); renderThread(props); expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); @@ -1697,7 +1703,9 @@ describe("TaskChatThread runtime transcript selection", () => { expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); expect(container.querySelector('[data-testid="task-chat-runner-identity-row"]')).toBe(header); expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')).toBe(activity); - expect(activity?.textContent).toBe(startup.status === "queued" ? "Queued" : "Thinking"); + expect(activity?.textContent).toBe("Thinking"); + expect(header?.textContent).toContain("Working"); + expect(container.textContent).not.toContain("Queued"); expect(container.querySelector('[data-testid="task-chat-live-run-pill"]')).toBeNull(); expect(container.textContent).not.toContain("run started"); } diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx index 5fd1cc50f38..1c050084bbb 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx @@ -57,7 +57,7 @@ describe("TaskChatRunnerTurn", () => { status={status} execution={execution} preparationLabel={preparationLabel} - startedAtMs={Date.now() - 2_000} + startedAtMs={runId ? Date.now() - 2_000 : null} awaitingContinuation={awaitingContinuation} continuedAfterSteering={continuedAfterSteering} onRuntimeRequestDecision={onRuntimeRequestDecision} @@ -68,35 +68,46 @@ describe("TaskChatRunnerTurn", () => { ), ); - it("shows admission waiting instead of Thinking for a queued turn", () => { - render([], "queued"); - expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain("Queued"); - expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Queued"); - expect(container.textContent).not.toContain("Working"); - expect(container.textContent).not.toContain("Thinking"); - }); - - it("shows queued startup before a run ID or preparation label exists", () => { - render([], "queued", null); - expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain("Queued"); - expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Queued"); - expect(container.textContent).not.toContain("Thinking"); - }); + it.each([null, "run-1"].flatMap(runId => [false, true].map(projected => ({ runId, projected }))))( + "shows Working and Thinking during ordinary admission (runId=$runId, projected=$projected)", + ({ runId, projected }) => { + render([], "queued", runId, undefined, false, false, projected ? { + phase: "queued", label: "Queued", cause: null, + lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3, + recoveryOwner: null, nextAction: null, permittedActions: ["inspect_run"], + predecessorRunId: null, successorRunId: null, + } : undefined); + expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain("Working"); + if (!runId) { + expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toBe("Working"); + } + const activity = container.querySelector('[data-testid="task-chat-current-activity-label"]'); + expect(activity?.textContent).toBe("Thinking"); + expect(activity?.classList.contains("shimmer-text")).toBe(true); + expect(container.textContent).not.toContain("Queued"); + }, + ); it("preserves a specific preparation label before a run ID exists", () => { render([], "queued", null, undefined, false, false, undefined, "Preparing workspace"); expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Preparing workspace"); }); - it("shows the server's maintenance wait while admission is held", () => { + it.each([ + ["task_drain", "Waiting for maintenance"], + ["agent_setup_pending", "Waiting for agent setup"], + ["agent_setup_failed", "Agent setup needs attention"], + ["server_restarting", "Waiting for server restart"], + ["previous_run_settling", "Waiting for previous run to stop"], + ])("shows the server's %s wait while admission is held", (cause, label) => { render([], "queued", "run-1", undefined, false, false, { - phase: "queued", label: "Waiting for maintenance", cause: "task_drain", + phase: "queued", label, cause, lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3, recoveryOwner: null, nextAction: "Starts automatically when maintenance finishes.", permittedActions: ["inspect_run"], predecessorRunId: null, successorRunId: null, }); - expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain("Waiting for maintenance"); - expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Waiting for maintenance"); + expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent).toContain(label); + expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe(label); expect(container.textContent).not.toContain("Thinking"); }); diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 3ad93e30966..dad7616ece8 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -94,7 +94,7 @@ function RunnerTurnStatus({ const label = terminal ? (failed ? "Stopped" : "Worked") : waitingLabel ?? "Working"; const suffix = terminal ? elapsed ? ` ${failed ? "after" : "for"} ${elapsed}` : "" - : waitingLabel ? elapsed ? ` · ${elapsed}` : "" : ` for ${elapsed ?? "0s"}`; + : elapsed ? `${waitingLabel ? " · " : " for "}${elapsed}` : ""; return (