diff --git a/DESIGN.md b/DESIGN.md index af538661b68..a0293e0244c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -89,6 +89,8 @@ that relayed a message from another channel. Keep the runner turn's avatar, name, Working 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. Runtime discovery must not replace the turn with a generic spinner or a "run started" receipt. Queued and running headers use the same spacing. diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index a2000c86a1e..3b0b64cdfd7 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -4,6 +4,7 @@ import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "./DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; import { TaskChatRunnerTurn } from "./task-chat/TaskChatRunnerTurn"; +import { useRunnerSubmission } from "@/hooks/useRunnerSubmission"; import type { ComposerRunSettings } from "./task-chat/composer-run-settings"; import { ComposerRunSettingsPicker } from "./task-chat/ComposerRunSettingsPicker"; import { ComposerAddMenu, ComposerModeChip } from "./task-chat/ComposerAddMenu"; @@ -6097,47 +6098,39 @@ export function IssueChatThread({ return true; } - const [submissionPending, setSubmissionPending] = useState(false); - const [submissionTakingLonger, setSubmissionTakingLonger] = useState(false); - const submissionRequestId = useRef(null); - useEffect(() => { - const confirmed = submissionRequestId.current && comments.some(comment => - comment.clientRequestId === submissionRequestId.current && !("clientStatus" in comment && comment.clientStatus)); - // The previous intent's projection cannot acknowledge this submission. - if (isRunning || confirmed) { - setSubmissionPending(false); - setSubmissionTakingLonger(false); - } - }, [isRunning, comments, submissionPending]); - useEffect(() => { - if (!submissionPending) return; - const timer = window.setTimeout(() => { - setSubmissionTakingLonger(true); - void onRefreshLatestComments?.(); - }, 10_000); - return () => window.clearTimeout(timer); - }, [submissionPending, onRefreshLatestComments]); + const { + pending: submissionPending, + takingLonger: submissionTakingLonger, + begin: beginSubmission, + reject: rejectSubmission, + blocked: startupBlocked, + } = useRunnerSubmission({ + issueId, + agentId: issueAssigneeAgentId, + agent: agentMap?.get(issueAssigneeAgentId ?? ""), + blocked: Boolean(composerPause), + comments, + issueStatus, + runId: displayLiveRuns[0]?.id ?? null, + linkedRuns, + interruption, + onRefresh: onRefreshLatestComments, + }); const sendComposerComment = useCallback( async (body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) => { pendingSubmitScrollRef.current = true; - if (issueAssigneeAgentId) { - submissionRequestId.current = clientRequestId ?? null; - setSubmissionPending(true); - setSubmissionTakingLonger(false); - } + clientRequestId ??= crypto.randomUUID(); + const attempt = beginSubmission(clientRequestId); try { await (runSettings ? onAdd(body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) : onAdd(body, reopen, reassignment, attachmentIds, clientRequestId)); - setSubmissionPending(false); - setSubmissionTakingLonger(false); } catch (error) { - setSubmissionPending(error instanceof CommentSubmissionUnknownError); - setSubmissionTakingLonger(error instanceof CommentSubmissionUnknownError); + rejectSubmission(attempt, error); throw error; } }, - [onAdd, issueAssigneeAgentId], + [onAdd, beginSubmission, rejectSubmission], ); const runtime = usePaperclipIssueRuntime({ messages, @@ -6666,7 +6659,7 @@ export function IssueChatThread({ /> )) )} - {!isRunning && issueAssigneeAgentId && (submissionPending || retryPreparing || interruption?.state === "working") ? ( + {!isRunning && !startupBlocked && issueAssigneeAgentId && (submissionPending || retryPreparing || interruption?.state === "working") ? ( root!.render( - {ui} + {ui} , ), ); @@ -459,17 +460,19 @@ it("shows Thinking during admission and clears it when the saved follow-up enter it.each((["task", "legacy"] as const).flatMap(surface => ([null, "failed", "resolved"] as const).map(previousState => ({ surface, previousState })), -))("keeps $surface admission Thinking through an old $previousState projection until acknowledgement", async ({ surface, previousState }) => { +))("keeps $surface admission identity through an old $previousState projection and comment acknowledgement", async ({ surface, previousState }) => { let acknowledge!: () => void; - const onAdd = vi.fn(() => new Promise(resolve => { acknowledge = resolve; })); + const onAdd = vi.fn["onAdd"]>(() => new Promise(resolve => { acknowledge = resolve; })); const refresh = vi.fn(); const Component = surface === "task" ? TaskChatThread : IssueChatThread; - render(); + />); await act(async () => { editorCallbacks.change("Save this note."); }); const send = surface === "task" ? container.querySelector('[data-testid="task-chat-composer-send"]') @@ -480,17 +483,84 @@ it.each((["task", "legacy"] as const).flatMap(surface => await act(async () => { send!.click(); }); expect(onAdd).toHaveBeenCalledTimes(1); expect(container.textContent).toContain("Thinking"); + const identity = container.querySelector('[data-testid="task-chat-agent-identity"]'); + render(); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); expect(container.textContent).toContain("Taking longer than usual"); expect(refresh).toHaveBeenCalledTimes(1); await act(async () => { acknowledge(); }); - expect(container.textContent).not.toContain("Thinking"); - expect(container.textContent).not.toContain("Taking longer than usual"); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + expect(container.textContent).toContain("Taking longer than usual"); + render(); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBeNull(); } finally { vi.useRealTimers(); } }); +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 }) => { + const serverTime = new Date("2026-01-02T00:00:00Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(serverTime + clockOffset); + const Component = surface === "task" ? TaskChatThread : IssueChatThread; + let acknowledge!: () => void; + const onAdd = vi.fn["onAdd"]>(() => new Promise(resolve => { acknowledge = resolve; })); + const prior = { id: "prior-reply", companyId: "company", issueId: "issue", authorType: "agent" as const, + authorAgentId: "agent", authorUserId: null, body: "Here is the previous answer.", presentation: null, metadata: null, + createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01") }; + const props = { issueId: "issue", issueStatus: "in_progress", currentUserId: "board", issueAssigneeAgentId: "agent", onAdd, + comments: [prior], 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.querySelectorAll('[data-testid="task-chat-agent-identity"]'); + const responder = identity[identity.length - 1]; + const avatar = responder?.querySelector("img"); + expect(responder?.textContent).toBe("Worker"); + expect(avatar).not.toBeNull(); + const saved = { ...prior, id: "saved-message", authorType: "user" as const, authorAgentId: null, authorUserId: "board", + body: "Please check again.", clientRequestId: onAdd.mock.calls[0]?.[4], createdAt: new Date(serverTime), updatedAt: new Date(serverTime) }; + render(); + await act(async () => { acknowledge(); }); + expect(container.contains(responder)).toBe(true); + expect(responder.querySelector("img")).toBe(avatar); + render(); + expect(container.contains(responder)).toBe(true); + render(); + expect(container.contains(responder)).toBe(false); +}); + +it.each((["task", "legacy"] as const).flatMap(surface => [ + ...(["paused", "pending_approval", "terminated"] as const).map(status => ({ surface, reason: status, status, orgChainHealth: undefined })), + ...(["missing_manager", "terminated_ancestor", "cycle"] as const).map(reason => ({ surface, reason, status: "idle" as const, + orgChainHealth: { status: "invalid_org_chain", reason } as Agent["orgChainHealth"] })), +]))("does not show startup work with $reason in $surface chat", async ({ surface, status, orgChainHealth }) => { + const Component = surface === "task" ? TaskChatThread : IssueChatThread; + const onAdd = vi.fn["onAdd"]>().mockResolvedValue(undefined); + const agentMap = new Map([["agent", { id: "agent", name: "Worker", status, orgChainHealth, adapterType: "paperclip_runner" } as Agent]]); + const props = { issueId: "issue", comments: [], issueStatus: "todo", issueAssigneeAgentId: "agent", agentMap, onAdd }; + render(); + await act(async () => { editorCallbacks.change("Save a note for later."); }); + 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); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBeNull(); + expect(container.textContent).not.toContain("Thinking"); + expect(container.textContent).not.toContain("Taking longer than usual"); +}); + it("prepares comment-link history and its composer before reveal", async () => { const props = { issueId: "linked-issue", @@ -1112,7 +1182,7 @@ describe("TaskChatThread runtime transcript selection", () => { expect(nativeRuns.map((run) => run.id)).toEqual(["native-run"]); }); - it.each([false, true])("keeps the runner identity and activity mounted through startup (mobile=%s)", (mobile) => { + it.each([false, true])("keeps the runner identity and activity mounted through startup (mobile=%s)", async (mobile) => { sidebarState.isMobile = mobile; const run = nativeRunningRun(); const comment = { @@ -1127,6 +1197,11 @@ describe("TaskChatThread runtime transcript selection", () => { currentUserId: "board", issueAssigneeAgentId: "agent-1", agentMap: new Map([["agent-1", { id: "agent-1", name: "Runner", adapterType: "paperclip_runner", icon: "code" } as Agent]]), }; + renderThread({ ...props, comments: [] }); + await act(async () => { editorCallbacks.change(comment.body); }); + await act(async () => { + container.querySelector('[data-testid="task-chat-composer-send"]')!.click(); + }); renderThread({ ...props, comments: [{ ...comment, clientStatus: "pending" }] }); const identity = container.querySelector('[data-testid="task-chat-agent-identity"]'); const header = container.querySelector('[data-testid="task-chat-runner-identity-row"]'); @@ -1135,6 +1210,11 @@ describe("TaskChatThread runtime transcript selection", () => { expect(identity?.querySelector("svg, img")).not.toBeNull(); expect(activity?.textContent).toBe("Thinking"); + renderThread(props); + 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); + for (const startup of [ { ...run, runtimeMode: undefined, status: "queued", startedAt: null }, { ...run, runtimeMode: "legacy" as const, currentStatusMessage: "run started" }, diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 7848fedce7f..48333ea7345 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1,5 +1,5 @@ import { TaskChatShimmerScope } from "@/components/task-chat/TaskChatShimmer"; -import { CommentSubmissionUnknownError } from "../lib/comment-submit-result"; +import { useRunnerSubmission } from "@/hooks/useRunnerSubmission"; import { ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE, @@ -2647,55 +2647,45 @@ export function TaskChatThread(props: TaskChatThreadProps) { ); const runtimeComposerDisabledReason = composerDisabledReason ?? undefined; const hasAgentAssignee = Boolean(issueAssigneeAgentId); - const [runnerSubmissionPending, setRunnerSubmissionPending] = useState(false); - const [submissionTakingLonger, setSubmissionTakingLonger] = useState(false); - const submissionRequestId = useRef(null); - useEffect(() => { - const confirmed = submissionRequestId.current && comments.some(comment => - comment.clientRequestId === submissionRequestId.current && !("clientStatus" in comment && comment.clientStatus)); - // The previous intent's projection cannot acknowledge this submission. - if (tailRunId || confirmed) { - setRunnerSubmissionPending(false); - setSubmissionTakingLonger(false); - } - }, [tailRunId, comments, runnerSubmissionPending]); - useEffect(() => { - if (!runnerSubmissionPending || tailRunId) return; - const timeout = window.setTimeout( - () => { - setSubmissionTakingLonger(true); - void props.onRefreshLatestComments?.(); - }, - 10_000, - ); - return () => window.clearTimeout(timeout); - }, [runnerSubmissionPending, tailRunId]); + const { + pending: runnerSubmissionPending, + takingLonger: submissionTakingLonger, + begin: beginSubmission, + reject: rejectSubmission, + blocked: startupBlocked, + } = useRunnerSubmission({ + issueId: issueId ?? null, + agentId: issueAssigneeAgentId, + agent: agentMap?.get(issueAssigneeAgentId ?? ""), + blocked: Boolean(composerPause), + comments, + issueStatus, + runId: liveRun?.id ?? null, + linkedRuns, + interruption: props.interruption, + onRefresh: props.onRefreshLatestComments, + }); const handleThreadAdd = useCallback( async (...args: Parameters) => { - if (hasAgentAssignee) { - submissionRequestId.current = args[4] ?? null; - setRunnerSubmissionPending(true); - setSubmissionTakingLonger(false); - } + args[4] ??= crypto.randomUUID(); + const attempt = beginSubmission(args[4]); try { await onAdd(...args); - setRunnerSubmissionPending(false); - setSubmissionTakingLonger(false); if (selectedPendingInput?.kind === "durable" && selectedPendingInput.interaction.kind === "ask_user_questions") { dismissInput(selectedPendingInput.interaction.id); setSelectedPendingKey(null); setTakeoverMode("normal"); } } catch (error) { - setRunnerSubmissionPending(error instanceof CommentSubmissionUnknownError); - setSubmissionTakingLonger(error instanceof CommentSubmissionUnknownError); + rejectSubmission(attempt, error); throw error; } }, - [hasAgentAssignee, onAdd, selectedPendingInput, dismissInput], + [beginSubmission, rejectSubmission, onAdd, selectedPendingInput, dismissInput], ); const optimisticRunnerStartup = hasAgentAssignee && + !startupBlocked && !tailRunId && (props.retryPreparing || props.interruption?.state === "working" || runnerSubmissionPending || commentItems.some( diff --git a/ui/src/hooks/useRunnerSubmission.ts b/ui/src/hooks/useRunnerSubmission.ts new file mode 100644 index 00000000000..9d362b26cbe --- /dev/null +++ b/ui/src/hooks/useRunnerSubmission.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { isAgentStatusInvokable, type Agent, type TaskInterruptionPresentation } from "@paperclipai/shared"; +import type { IssueChatComment, IssueChatLinkedRun } from "@/lib/issue-chat-messages"; +import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result"; +import { isTerminalIssueStatus } from "@/lib/liveIssueIds"; + +interface Submission { + attemptId: number; + issueId: string | null; + agentId: string; + runIds: Set; + clientRequestId: string; + interruptionKey: string; + takingLonger: boolean; +} + +export function useRunnerSubmission({ + issueId, + agentId, + agent, + blocked = false, + comments, + issueStatus, + runId, + linkedRuns = [], + interruption, + onRefresh, +}: { + issueId: string | null; + agentId: string | null; + agent?: Partial>; + blocked?: boolean; + comments: readonly IssueChatComment[]; + issueStatus?: string; + runId: string | null; + linkedRuns?: readonly IssueChatLinkedRun[]; + interruption?: TaskInterruptionPresentation | null; + onRefresh?: () => void | Promise; +}) { + const [submission, setSubmission] = useState(null); + const nextAttemptId = useRef(0); + const refreshRef = useRef(onRefresh); + refreshRef.current = onRefresh; + const interruptionKey = JSON.stringify([ + interruption?.intentId, interruption?.state, interruption?.observedRunId, + ]); + const startupBlocked = blocked || + (agent?.status !== undefined && !isAgentStatusInvokable(agent.status)) || + agent?.orgChainHealth?.status === "invalid_org_chain"; + const savedComment = submission && comments.find(comment => + comment.clientRequestId === submission.clientRequestId && !comment.clientStatus); + const acknowledgedAt = savedComment ? new Date(savedComment.createdAt).getTime() : null; + const submissionRuns = submission && savedComment ? linkedRuns.filter(run => + run.agentId === submission.agentId && ( + run.runId === savedComment.consumedByRunId || run.runId === savedComment.steeredIntoRunId || + (!submission.runIds.has(run.runId) && acknowledgedAt !== null && + new Date(run.createdAt).getTime() >= acknowledgedAt) + )) : []; + const settled = Boolean(submission && ( + runId || startupBlocked || isTerminalIssueStatus(issueStatus) || + submission.agentId !== agentId || + (submission.issueId && submission.issueId !== issueId) || + (interruption && interruption.state !== "working" && + interruptionKey !== submission.interruptionKey && + (interruption.intentId !== null || + submissionRuns.some(run => run.runId === interruption.observedRunId))) || + submissionRuns.some(run => + run.status !== "queued" && run.status !== "running") + )); + const pending = submission !== null && !settled; + const takingLonger = submission?.takingLonger ?? false; + const attemptId = submission?.attemptId; + + useLayoutEffect(() => { + if (settled) { + setSubmission(null); + } + }, [settled]); + + useEffect(() => { + if (!pending) return; + const timeout = window.setTimeout(() => { + setSubmission(current => current && current.attemptId === attemptId + ? { ...current, takingLonger: true } : current); + void refreshRef.current?.(); + }, 10_000); + return () => window.clearTimeout(timeout); + }, [pending, attemptId]); + + const begin = useCallback((clientRequestId: string) => { + const next: Submission | null = agentId && !startupBlocked ? { + issueId, agentId, interruptionKey, clientRequestId, + attemptId: ++nextAttemptId.current, takingLonger: false, + runIds: new Set(linkedRuns.map(run => run.runId)), + } : null; + setSubmission(next); + return next?.attemptId ?? null; + }, [agentId, issueId, interruptionKey, linkedRuns, startupBlocked]); + + const reject = useCallback((attempt: number | null, error: unknown) => { + setSubmission(current => { + if (!current || current.attemptId !== attempt) return current; + return error instanceof CommentSubmissionUnknownError + ? { ...current, takingLonger: true } : null; + }); + }, []); + + return { pending, takingLonger, begin, reject, blocked: startupBlocked }; +}