From 6958cb112c58364860377dd57bae9f6306cb1fce Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:49:19 -0600 Subject: [PATCH] fix(ui): keep responder avatars visible across stale execution outcomes (#20293) ## Thinking Path > - Paperclip lets people manage agents and send follow-up messages in task chat. > - The responder identity should stay visible while a message starts a run. > - Comment storage, task reads, and run discovery finish separately. > - An older intent can resolve during admission of the new message. > - A reopened task can also retain its previous terminal status until the next read. > - This change keeps those older outcomes from clearing the new startup feedback. > - The avatar stays visible until the new run or its own admission outcome arrives. ## Linked Issues or Issue Description Related fixes: #17885 and #18312. A search found no other PR for these races. **What happened?** After Send, the agent avatar can disappear before the new run appears. A prior intent changing from working to resolved clears the new submission. Sending a follow-up that reopens a done or cancelled task also clears startup feedback against the old task status. **Expected behavior** Keep the responder visible through comment acknowledgement and run discovery. Stop startup feedback on a current failure, blocker, or completion. **Steps to reproduce** 1. Send a follow-up while a previous intent is still reported as working. 2. Let that same intent resolve before the new run read completes. 3. Observe the responder disappear. Alternatively, send a follow-up to a done or cancelled task and delay the reopened task read. **Paperclip version** Reproduced on source master at `33a23e0246`. Rebased onto `3ef45fdd92` before submission. **Deployment mode** Local component tests reproduce the read ordering. An isolated local trusted instance verifies the real Send-to-process-run path in Chromium. It does not invoke a live model provider. No staging message was sent. ## What Changed - Record the previous intent identity. Its later resolution cannot acknowledge the new message unless the observed run belongs to that message. - Pass the existing reopen request into shared submission state on both chat surfaces. - Keep startup feedback through the previous terminal status. Restore ordinary completion handling once the reopened status arrives. - Add six Send regression cases, a live-run handoff assertion, and coverage for later completion and ordinary terminal notes. - Clarify identity continuity in DESIGN.md. ## Verification - Red: all six new Send cases fail on unchanged master. - Green after rebase: 531 tests pass, one existing test is skipped, across TaskChatThread, IssueChatThread, and useRunnerSubmission. - Chromium: the existing compact-startup responder test passes on a disposable local instance. Frame sampling checks that the same identity stays mounted through acknowledgement and process startup. - UI typecheck and token gates pass. Initial typecheck lacked generated plugin SDK declarations; it passed after local server setup built that dependency. - Affected checks passed after rebase on `de9ed52c0b`. Final CI on `de9ed52c0bd6a6f7738614af31aec1eae2b871d0`: 59 checks passed, four skipped, no failures. [CI run](https://github.com/paperclipai/paperclip/actions/runs/42646038090). Greptile and Paperclip review both gave 5/5 with no actionable findings or unresolved review threads. ## Risks Low scope: client startup feedback only. An old intent must not hide a new admission, but a new failure must still end it. Existing backoff and failure tests remain covered. The reopen exception applies only to an explicit reopen and ends when the reopened status arrives. No API, database, permission, or provider behavior changes. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context-window size are not exposed in this session. Capabilities used: reasoning, source editing, shell execution, and browser verification. ## 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 | 2 ++ ui/src/components/IssueChatThread.tsx | 2 +- ui/src/components/TaskChatThread.test.tsx | 34 +++++++++++++++++++++++ ui/src/components/TaskChatThread.tsx | 2 +- ui/src/hooks/useRunnerSubmission.test.tsx | 25 +++++++++++++++++ ui/src/hooks/useRunnerSubmission.ts | 17 ++++++++---- 6 files changed, 75 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e2ccdd19602..e2ccd58ecf0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -111,6 +111,8 @@ Keep the runner turn's avatar, name, status header and current-activity slot in place from message submission through queued admission and native startup. Saving the human comment does not end startup feedback. Keep the identity mounted until its run appears or a newer execution outcome ends admission. +An older intent resolving cannot acknowledge a follow-up. When Send reopens a +completed task, retain startup feedback while the previous task status catches up. Use the sent message's selected recipient through reassignment. Choosing the 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 diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 13a1d44a56d..21a5bfef6cd 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -6226,7 +6226,7 @@ export function IssueChatThread({ async (body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) => { pendingSubmitScrollRef.current = true; clientRequestId ??= crypto.randomUUID(); - const attempt = beginSubmission(clientRequestId); + const attempt = beginSubmission(clientRequestId, undefined, reopen); try { await (runSettings ? onAdd(body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 06e475bd8fa..1bf3b9c9096 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -635,6 +635,40 @@ it.each((["task", "legacy"] as const).flatMap(surface => } }); +it.each((["task", "legacy"] as const).flatMap(surface => + ["done", "cancelled", "old-intent"].map(previous => ({ surface, previous })), +))("keeps $surface follow-up identity while $previous state catches up", async ({ surface, previous }) => { + const Component = surface === "task" ? TaskChatThread : IssueChatThread; + const onAdd = vi.fn["onAdd"]>().mockResolvedValue(undefined); + const interruption = { intentId: "old-intent", responsibility: "system" as const, state: "working" as const, + nextRetryAt: null, canRetry: false, userAction: null, sourceRunIds: ["old-run"], observedRunId: "old-run" }; + const props = { issueId: "issue", issueStatus: previous === "old-intent" ? "blocked" : previous, + issueAssigneeAgentId: "agent", currentAssigneeValue: "agent:agent", onAdd, comments: [], + interruption: previous === "old-intent" ? interruption : null, + agentMap: new Map([["agent", { id: "agent", name: "Worker", adapterType: "paperclip_runner" } as Agent]]) }; + render(); + await act(async () => { editorCallbacks.change("Please check again."); }); + const send = surface === "task" ? container.querySelector('[data-testid="task-chat-composer-send"]') + : [...container.querySelectorAll('[data-testid="issue-chat-composer"] button')].find(button => button.textContent === "Send"); + await act(async () => { send!.click(); }); + expect(onAdd).toHaveBeenCalledTimes(1); + const identity = container.querySelector('[data-testid="task-chat-agent-identity"]'); + expect(identity?.textContent).toBe("Worker"); + const saved = { id: "saved-message", companyId: "company", issueId: "issue", authorType: "user" as const, + authorUserId: "board", authorAgentId: null, body: "Please check again.", presentation: null, metadata: null, + clientRequestId: onAdd.mock.calls[0]?.[4], createdAt: new Date(), updatedAt: new Date() }; + render(); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + render(); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + if (surface === "task") { + const run = { ...nativeRunningRun(), agentId: "agent", agentName: "Worker" }; + render(); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + } +}); + it.each((["task", "legacy"] as const).flatMap(surface => [-300_000, 0, 300_000].map(clockOffset => ({ surface, clockOffset })), ))("keeps an existing $surface thread's responder mounted with clock offset $clockOffset", async ({ surface, clockOffset }) => { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 7301bc9ee22..a862cb4af10 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2907,7 +2907,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { const attempt = beginSubmission(args[4], args[2] ? { agentId: args[2].assigneeAgentId, agent: agentMap?.get(args[2].assigneeAgentId ?? ""), - } : undefined); + } : undefined, args[1]); const selectionVersion = ++inputSelectionVersionRef.current; explicitlyOpenedInputRef.current = null; setTakeoverMode("normal"); diff --git a/ui/src/hooks/useRunnerSubmission.test.tsx b/ui/src/hooks/useRunnerSubmission.test.tsx index af8609a945f..61bee779788 100644 --- a/ui/src/hooks/useRunnerSubmission.test.tsx +++ b/ui/src/hooks/useRunnerSubmission.test.tsx @@ -39,3 +39,28 @@ it("settles a newer saved message while the same Retry explanation remains visib await act(async () => root.unmount()); container.remove(); vi.useRealTimers(); } }); + +it("ends reopened startup on a later completion, but keeps a normal terminal note idle", async () => { + const container = document.createElement("div"); document.body.append(container); + const root = createRoot(container); + function View({ status }: { status: string }) { + const submission = useRunnerSubmission({ issueId: "task", agentId: "agent", agent: { status: "idle" }, + issueStatus: status, runId: null, comments: [] }); + return <> + + {submission.pending && Starting response}; + } + try { + await act(async () => root.render()); + await act(async () => container.querySelectorAll("button")[1].click()); + expect(container.textContent).not.toContain("Starting response"); + await act(async () => container.querySelector("button")!.click()); + expect(container.textContent).toContain("Starting response"); + await act(async () => root.render()); + expect(container.textContent).toContain("Starting response"); + await act(async () => root.render()); + expect(container.textContent).not.toContain("Starting response"); + } finally { + await act(async () => root.unmount()); container.remove(); + } +}); diff --git a/ui/src/hooks/useRunnerSubmission.ts b/ui/src/hooks/useRunnerSubmission.ts index 33f39b1493a..7aaa4757f34 100644 --- a/ui/src/hooks/useRunnerSubmission.ts +++ b/ui/src/hooks/useRunnerSubmission.ts @@ -12,6 +12,8 @@ interface Submission { runIds: Set; clientRequestId: string; interruptionKey: string; + interruptionIntentId: string | null; + reopeningStatus: string | null; takingLonger: boolean; } @@ -66,12 +68,13 @@ export function useRunnerSubmission({ new Date(run.createdAt).getTime() >= acknowledgedAt) )) : []; const settled = Boolean(submission && ( - runId || startupBlocked || isTerminalIssueStatus(issueStatus) || + runId || startupBlocked || + (isTerminalIssueStatus(issueStatus) && submission.reopeningStatus !== issueStatus) || (submission.agentId !== agentId && submission.previousAgentId !== agentId) || (submission.issueId && submission.issueId !== issueId) || (interruption && interruption.state !== "working" && interruptionKey !== submission.interruptionKey && - (interruption.intentId !== null || + ((interruption.intentId !== null && interruption.intentId !== submission.interruptionIntentId) || submissionRuns.some(run => run.runId === interruption.observedRunId))) || submissionRuns.some(run => run.status !== "queued" && run.status !== "running") @@ -83,8 +86,10 @@ export function useRunnerSubmission({ useLayoutEffect(() => { if (settled) { setSubmission(null); + } else if (submission?.reopeningStatus && !isTerminalIssueStatus(issueStatus)) { + setSubmission(current => current ? { ...current, reopeningStatus: null } : current); } - }, [settled]); + }, [settled, issueStatus, submission?.reopeningStatus]); useEffect(() => { if (!pending) return; @@ -96,17 +101,19 @@ export function useRunnerSubmission({ return () => window.clearTimeout(timeout); }, [pending, attemptId]); - const begin = useCallback((clientRequestId: string, recipient?: { agentId: string | null; agent?: SubmissionAgent }) => { + const begin = useCallback((clientRequestId: string, recipient?: { agentId: string | null; agent?: SubmissionAgent }, reopen = false) => { const targetAgentId = recipient ? recipient.agentId : agentId; const targetAgent = recipient ? recipient.agent : agent; const next: Submission | null = targetAgentId && !blocked && !agentBlocksStartup(targetAgent) ? { issueId, agentId: targetAgentId, previousAgentId: agentId, interruptionKey, clientRequestId, + interruptionIntentId: interruption?.intentId ?? null, + reopeningStatus: reopen && isTerminalIssueStatus(issueStatus) ? issueStatus! : null, attemptId: ++nextAttemptId.current, takingLonger: false, runIds: new Set(linkedRuns.map(run => run.runId)), } : null; setSubmission(next); return next?.attemptId ?? null; - }, [agentId, agent, blocked, issueId, interruptionKey, linkedRuns]); + }, [agentId, agent, blocked, issueId, issueStatus, interruptionKey, interruption?.intentId, linkedRuns]); const reject = useCallback((attempt: number | null, error: unknown) => { setSubmission(current => {