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 => {