From bf95a7eae255b390749fa5a4bda56de1127aa4e1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:54:00 -0500 Subject: [PATCH] fix(ui): stabilize active-run steering queue (#12834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The issue detail page shows a live agent run and accepts follow-up instructions. > - A follow-up must stay in a stable queue until the user sends, reorders, or removes it. > - Native runners can receive a steering event in the active run. > - Legacy runners must interrupt the active run and start a follow-up run. > - The current UI moved comments between the queue and the transcript and could show duplicate text or ambiguous chronology. > - This pull request makes the queue projection durable, keeps each message in one clear place, and labels when queued input was actually steered or delivered. > - The benefit is predictable steering with stable ordering, no duplicate messages, and visible causal timing. ## Linked Issues or Issue Description Refs #11374. Refs #12591. **What happened?** During an active run, a new follow-up could first appear as a transcript bubble and then move into the steering queue. After a steer or remove action, it could appear again. Progress text could also repeat the final response text. Once consumed, a queued bubble displayed only its original submission time even though it moved to its later causal slot, and a native run split by steering looked like two unrelated runs. **Expected behavior** An active-run follow-up must appear in the queue immediately. A native steer must move it once into the active run. A legacy interrupt must move it once into the follow-up run. A removed item must stay removed. Progress text that is identical to the final response must appear once. Consumed follow-ups must show both queue and steer/delivery times, and post-steer native segments must identify themselves as continuations of the same run. **Steps to reproduce** 1. Start a long-running task. 2. Send two or more follow-up messages while the agent is active. 3. Reorder the messages and remove one message. 4. Send the first queued message as steering. 5. Observe the queue and transcript during and after both runs. **Paperclip version or commit** The problem reproduced on commit `da1e40302`. **Deployment mode** Local development with the embedded database. ## What Changed - Project queued comments into the steering well for native and legacy live runners. - Send native steering to the active run and use interrupt-and-follow-up for legacy runners. - Keep optimistic queue order stable across refreshes and roll back failed actions. - Remove discarded comments from the transcript cache and keep them removed when the queue becomes empty. - Collapse only the final progress occurrence matching the durable response, including across steered transcript segments. - Show `Queued … · Steered …` for same-run input and `Queued … · Delivered …` for successor-run input at their causal positions. - Label settled and live post-steer segments `Continued after steering` and time them from the steer boundary. - Add regression tests for queue display, steering, fallback interrupt, reorder, remove, rollback, duplicate text, causal timestamps, and live/settled continuation headers. ## Verification - Ran the final focused steering/chronology UI suite with 233 passing tests. - Ran the activity-service regression suite with 5 passing tests. - Ran the broader queue-focused UI suite with 298 passing tests before the final chronology refinement. - Ran `pnpm -r typecheck` successfully. - Ran `pnpm build` successfully. - Ran `pnpm check:token-gates` successfully. - Tested native steering in a real browser with a 90-second baseline wait and a three-second steering correction. - Confirmed that the old final response did not appear before the steered response. - Tested three queued messages in a real browser. - Confirmed that reorder changed delivery order and that the removed message was never sent or shown again. - Tested a legacy runner in a real browser. - Confirmed that it used the interrupt fallback and showed the follow-up once. - Reloaded a saved mixed-steer/successor-run thread and confirmed the causal timestamps and continuation header render in the correct positions. - The complete macOS suite reaches five unrelated platform assertions in workspace-runtime tests. Two compare `/var` with `/private/var`. Three require Linux `/proc` listener data. GitHub Actions provides the authoritative Linux run. ## Risks - Low risk. The change is limited to issue-chat queue projection and transcript presentation. - The server run-history API adds only a read-only `contextIssueId` projection; the database schema does not change. - Optimistic actions restore the prior UI state when a request fails. ## Model Used - OpenAI Codex with GPT-5, extended reasoning, browser automation, shell tools, and code execution. ## 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 --- server/src/__tests__/activity-service.test.ts | 1 + server/src/services/activity.ts | 1 + ui/src/api/activity.ts | 1 + ui/src/components/TaskChatThread.test.tsx | 427 ++++- ui/src/components/TaskChatThread.tsx | 580 +++--- .../task-chat/TaskChatQueuedMessages.test.tsx | 240 ++- .../task-chat/TaskChatQueuedMessages.tsx | 215 ++- .../task-chat/TaskChatRunnerTurn.test.tsx | 99 + .../task-chat/TaskChatRunnerTurn.tsx | 51 +- .../task-chat/TaskChatTurn.test.tsx | 13 + ui/src/components/task-chat/TaskChatTurn.tsx | 1 + .../task-chat/task-chat-adapter.test.ts | 73 +- .../components/task-chat/task-chat-adapter.ts | 26 +- .../components/task-chat/task-chat-model.ts | 2 + .../task-chat/transcript-adapter.test.ts | 29 + .../task-chat/transcript-adapter.ts | 59 + ui/src/lib/issue-queued-comment-queue.test.ts | 143 +- ui/src/lib/issue-queued-comment-queue.ts | 121 +- ui/src/pages/IssueDetail.test.tsx | 635 ++++++- ui/src/pages/IssueDetail.tsx | 1672 ++++++++++------- 20 files changed, 3202 insertions(+), 1187 deletions(-) diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts index df7d841b9a..24fa117c05 100644 --- a/server/src/__tests__/activity-service.test.ts +++ b/server/src/__tests__/activity-service.test.ts @@ -180,6 +180,7 @@ describeEmbeddedPostgres("activity service", () => { runId, agentId, invocationSource: "assignment", + contextIssueId: issueId, }); expect(runs[0]?.usageJson).toEqual({ inputTokens: 11, diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index ce2dfd172d..08b95f7d44 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -406,6 +406,7 @@ export function activityService(db: Db) { wakeCommentIds: sql`${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds'`, wakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`, contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`, + contextIssueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, }) .from(heartbeatRuns) .innerJoin( diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index 9f23c8954f..b3e2b327b6 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -31,6 +31,7 @@ export interface RunForIssue { wakeCommentIds?: string[] | null; wakeCommentId?: string | null; contextCommentId?: string | null; + contextIssueId?: string | null; contextSnapshot?: Record | null; environment?: { id: string; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 8ea15c6b60..56d84e5677 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -63,7 +63,10 @@ vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => planState, })); vi.mock("@/hooks/useStreamlinedUiEnabled", () => ({ - useStreamlinedUiEnabled: () => ({ enabled: streamlinedState.enabled, loaded: true }), + useStreamlinedUiEnabled: () => ({ + enabled: streamlinedState.enabled, + loaded: true, + }), })); vi.mock("@/lib/router", () => ({ Link: ({ @@ -1152,6 +1155,255 @@ describe("TaskChatThread runtime transcript selection", () => { ).toBeNull(); }); + it("does not repeat progress when the persisted completion comment owns the same text", () => { + const repeated = "BASELINE-DONE"; + nativeTranscriptState.transcriptByRun.set("native-repeated-final", [ + { + kind: "assistant", + ts: "2026-08-25T18:00:01.000Z", + text: repeated, + channel: "progress", + }, + { + kind: "assistant", + ts: "2026-08-25T18:00:02.000Z", + text: repeated, + channel: "final", + }, + ]); + + render( + {}} + linkedRuns={[ + { + runId: "native-repeated-final", + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:03.000Z", + resultJson: { + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "final_agent_message", + commentId: "native-repeated-final-comment", + }, + }, + }, + ]} + />, + ); + + expect(container.textContent?.split(repeated)).toHaveLength(2); + expect( + Array.from( + container.querySelectorAll( + '[data-testid="task-chat-phase-interstitial"]', + ), + ).some((element) => element.textContent?.includes(repeated)), + ).toBe(false); + expect( + container.querySelector('[data-testid="task-chat-agent-bubble"]') + ?.textContent, + ).toContain(repeated); + }); + + it("removes only the final repeated progress across steering segments", () => { + const repeated = "SAME-BEFORE-AND-AFTER"; + nativeTranscriptState.transcriptByRun.set("native-steered-repetition", [ + { + kind: "assistant", + ts: "2026-08-25T18:00:01.000Z", + text: repeated, + channel: "progress", + }, + { + kind: "assistant", + ts: "2026-08-25T18:00:03.000Z", + text: repeated, + channel: "progress", + }, + { + kind: "assistant", + ts: "2026-08-25T18:00:04.000Z", + text: repeated, + channel: "final", + }, + ]); + + render( + {}} + linkedRuns={[ + { + runId: "native-steered-repetition", + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:05.000Z", + resultJson: { + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "final_agent_message", + commentId: "native-steered-repetition-final", + }, + }, + }, + ]} + />, + ); + + const matchingPhases = Array.from( + container.querySelectorAll( + '[data-testid="task-chat-phase-interstitial"]', + ), + ).filter((element) => element.textContent?.includes(repeated)); + expect(matchingPhases).toHaveLength(1); + expect( + container.querySelector('[data-testid="task-chat-agent-bubble"]') + ?.textContent, + ).toContain(repeated); + expect(container.textContent).toContain( + `Queued ${new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · Steered ${new Date("2026-08-25T18:00:02.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`, + ); + const turnHeaders = Array.from( + container.querySelectorAll('[data-testid="task-chat-turn-summary"]'), + ); + expect(turnHeaders).toHaveLength(2); + expect(turnHeaders[0]?.textContent).not.toContain( + "Continued after steering", + ); + expect(turnHeaders[1]?.textContent).toContain( + "Continued after steering · Worked for", + ); + }); + + it("labels the live tail as a continuation after the steering bubble", () => { + nativeTranscriptState.transcriptByRun.set("native-live-steered", [ + { + kind: "assistant", + ts: "2026-08-25T18:00:01.000Z", + text: "Work before steering.", + channel: "progress", + }, + { + kind: "assistant", + ts: "2026-08-25T18:00:03.000Z", + text: "Work after steering.", + channel: "progress", + }, + ]); + + render( + {}} + issueStatus="in_progress" + activeRun={{ + id: "native-live-steered", + runtimeMode: "native", + status: "running", + invocationSource: "issue", + triggerDetail: null, + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: null, + createdAt: "2026-08-25T18:00:00.000Z", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + }} + />, + ); + + expect( + container.querySelector('[data-testid="task-chat-turn-summary"]') + ?.textContent, + ).not.toContain("Continued after steering"); + expect( + container.querySelector('[data-testid="task-chat-turn-status-header"]') + ?.textContent, + ).toContain("Continued after steering · Working for"); + expect(container.textContent).toContain( + `Queued ${new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · Steered ${new Date("2026-08-25T18:00:02.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`, + ); + }); + it("keeps a yielded question turn out of the final-response slot", () => { nativeTranscriptState.transcriptByRun.set("native-question", [ { @@ -1507,9 +1759,13 @@ describe("TaskChatThread composer alignment", () => { streamlinedState.enabled = false; render( {}} />); - const dock = container.querySelector('[data-testid="task-chat-composer-dock"]'); + const dock = container.querySelector( + '[data-testid="task-chat-composer-dock"]', + ); const composer = container.querySelector(".paperclip-task-chat-composer"); - const send = container.querySelector('[data-testid="task-chat-composer-send"]'); + const send = container.querySelector( + '[data-testid="task-chat-composer-send"]', + ); expect(dock?.classList).not.toContain("md:px-0"); expect(dock?.classList).not.toContain("md:pb-4"); @@ -1630,10 +1886,16 @@ describe("TaskChatThread blocker links", () => { expect(notices).toHaveLength(2); expect(notices[0]?.getAttribute("data-placement")).toBe("top"); expect(notices[1]?.getAttribute("data-placement")).toBe("bottom"); - expect(notices[0]?.textContent).toContain("Blocked byPAP-600Waiting in review"); + expect(notices[0]?.textContent).toContain( + "Blocked byPAP-600Waiting in review", + ); expect(notices[0]?.textContent).toContain("Root blockerPAP-777Actual work"); - expect(notices[1]?.textContent).toContain("Still blocked byPAP-600Waiting in review"); - expect(notices[1]?.textContent).toContain("Root blocker remainsPAP-777Actual work"); + expect(notices[1]?.textContent).toContain( + "Still blocked byPAP-600Waiting in review", + ); + expect(notices[1]?.textContent).toContain( + "Root blocker remainsPAP-777Actual work", + ); for (const notice of notices) { expect(notice.querySelector('a[href="/issues/PAP-600"]')).not.toBeNull(); expect(notice.querySelector('a[href="/issues/PAP-777"]')).not.toBeNull(); @@ -1717,17 +1979,28 @@ describe("TaskChatThread blocker links", () => { expect(notices[0]?.textContent).toContain("Waiting on live work"); expect(notices[1]?.textContent).toContain("Still waiting on live work"); for (const notice of notices) { - const orderedLinks = [...notice.querySelectorAll('[data-testid="task-chat-live-work-step"] a')] - .map((link) => link.textContent); + const orderedLinks = [ + ...notice.querySelectorAll( + '[data-testid="task-chat-live-work-step"] a', + ), + ].map((link) => link.textContent); expect(orderedLinks).toEqual([ "PAP-17424Run the guarded cutover", "PAP-17425Verify the live projection", "PAP-17427Verify the completed projection", ]); - expect(notice.textContent).toContain("Now runningPAP-17426Restore live alias projection"); - expect(notice.querySelector('a[href="/issues/PAP-17426"]')).not.toBeNull(); - expect(notice.querySelector('[data-testid="task-chat-live-work-step"] > a')).not.toBeNull(); - expect(notice.querySelector('[data-testid="task-chat-live-work-step"] > span')).toBeNull(); + expect(notice.textContent).toContain( + "Now runningPAP-17426Restore live alias projection", + ); + expect( + notice.querySelector('a[href="/issues/PAP-17426"]'), + ).not.toBeNull(); + expect( + notice.querySelector('[data-testid="task-chat-live-work-step"] > a'), + ).not.toBeNull(); + expect( + notice.querySelector('[data-testid="task-chat-live-work-step"] > span'), + ).toBeNull(); } expect( container.querySelector('[data-testid="task-chat-blocker-links"]'), @@ -1864,11 +2137,21 @@ describe("TaskChatThread blocker links", () => { />, ); - const notices = container.querySelectorAll('[data-testid="task-chat-blocker-links"]'); - expect(notices[0]?.textContent).toContain("Blocked byPAP-600Selected dependency"); - expect(notices[0]?.textContent).toContain("Root blockerPAP-650Stalled intermediate review"); - expect(notices[1]?.textContent).toContain("Still blocked byPAP-600Selected dependency"); - expect(notices[1]?.textContent).toContain("Root blocker remainsPAP-650Stalled intermediate review"); + const notices = container.querySelectorAll( + '[data-testid="task-chat-blocker-links"]', + ); + expect(notices[0]?.textContent).toContain( + "Blocked byPAP-600Selected dependency", + ); + expect(notices[0]?.textContent).toContain( + "Root blockerPAP-650Stalled intermediate review", + ); + expect(notices[1]?.textContent).toContain( + "Still blocked byPAP-600Selected dependency", + ); + expect(notices[1]?.textContent).toContain( + "Root blocker remainsPAP-650Stalled intermediate review", + ); expect(container.textContent).not.toContain("Unrelated dependency"); expect(container.textContent).not.toContain("Deeper structural leaf"); }); @@ -1906,19 +2189,23 @@ describe("TaskChatThread blocker links", () => { comments={createLongThreadComments().slice(0, 1)} onAdd={async () => {}} issueStatus="blocked" - blockedBy={[{ - id: "direct-1", - identifier: "PAP-500", - title: "Direct dependency", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }]} + blockedBy={[ + { + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ]} />, ); - const notices = container.querySelectorAll('[data-testid="task-chat-blocker-links"]'); + const notices = container.querySelectorAll( + '[data-testid="task-chat-blocker-links"]', + ); expect(notices).toHaveLength(1); expect(notices[0]?.getAttribute("data-placement")).toBe("top"); }); @@ -2096,6 +2383,92 @@ describe("TaskChatThread Paperclip Runner queue", () => { ).toBeNull(); expect(occurrenceCount(queuedComment.body)).toBe(1); }); + + it("keeps legacy follow-ups in the composer queue with an interrupt fallback", () => { + const onInterruptQueued = vi.fn(async () => {}); + render( + {}} + onInterruptQueued={onInterruptQueued} + queuedCommentQueue={{ + ...queue, + protocol: "legacy", + steeringDisposition: "unsupported", + }} + onEditQueuedComment={async () => {}} + onReorderQueuedComments={async () => {}} + onSteerQueuedComment={async () => {}} + onDiscardQueuedComment={async () => {}} + />, + ); + + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-queued-prp-1"]', + ), + ).not.toBeNull(); + expect(occurrenceCount(queuedComment.body)).toBe(1); + expect(container.textContent).not.toContain("QueuedInterrupt"); + + const interrupt = container.querySelector( + '[data-testid="task-chat-queued-interrupt-queued-prp-1"]', + ); + expect(interrupt).not.toBeNull(); + flushSync(() => interrupt!.click()); + expect(onInterruptQueued).toHaveBeenCalledWith("run-1"); + }); + + it("cancels an optimistic queued row locally before server acknowledgement", async () => { + const onCancelQueued = vi.fn(); + render( + {}} + onCancelQueued={onCancelQueued} + queuedCommentQueue={{ + ...queue, + queueId: null, + entries: [ + { + ...queue.entries[0], + comment: { + ...queuedComment, + id: "optimistic-local-1", + body: "Delete before acknowledgement", + }, + canEdit: false, + }, + ], + }} + onEditQueuedComment={async () => {}} + onReorderQueuedComments={async () => {}} + onSteerQueuedComment={async () => {}} + onDiscardQueuedComment={async () => {}} + />, + ); + + await act(async () => { + container + .querySelector( + '[data-testid="task-chat-queued-discard-optimistic-local-1"]', + ) + ?.click(); + }); + + expect(onCancelQueued).toHaveBeenCalledWith("optimistic-local-1"); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-optimistic-local-1"]', + ), + ).toBeNull(); + }); }); describe("TaskChatThread mobile composer dock (PAP-495)", () => { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 26f14a07d1..5b3e04af84 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -30,6 +30,7 @@ import { coalesceSettledTurns, isTerminalRunStatus, embedPlanDocumentAtWriteBoundary, + omitProgressRepeatedByResponseAcrossSegments, paperclipRunnerFinalResponse, paperclipRunnerTimelineItems, prependIssueBrief, @@ -217,11 +218,12 @@ const EMPTY_LIVE_ISSUE_IDS: ReadonlySet = new Set(); const LONG_THREAD_BLOCKER_REPEAT_COUNT = 4; export function shouldRepeatTaskChatBlockers(items: TaskChatItem[]): boolean { - const conversationItems = items.filter((item) => ( - item.kind === "brief" - || item.kind === "interaction" - || (item.kind === "message" && !item.interstitial) - )); + const conversationItems = items.filter( + (item) => + item.kind === "brief" || + item.kind === "interaction" || + (item.kind === "message" && !item.interstitial), + ); return conversationItems.length >= LONG_THREAD_BLOCKER_REPEAT_COUNT; } @@ -492,6 +494,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { onVote, draftKey, onInterruptQueued, + onCancelQueued, interruptingQueuedRunId, onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending = false, @@ -509,14 +512,16 @@ export function TaskChatThread(props: TaskChatThreadProps) { resumeAssigneePending = false, } = props; - const paperclipQueue = - queuedCommentQueue?.protocol === "paperclip_runner_v1" + const queuedMessageQueue = + queuedCommentQueue && queuedCommentQueue.entries.length > 0 ? queuedCommentQueue : null; const queuedCommentIds = useMemo( () => - new Set(paperclipQueue?.entries.map((entry) => entry.comment.id) ?? []), - [paperclipQueue], + new Set( + queuedMessageQueue?.entries.map((entry) => entry.comment.id) ?? [], + ), + [queuedMessageQueue], ); const [queuedEdit, setQueuedEdit] = useState<{ commentId: string; @@ -527,17 +532,17 @@ export function TaskChatThread(props: TaskChatThreadProps) { const beginQueuedEdit = useCallback( (commentId: string) => { - const entry = paperclipQueue?.entries.find( + const entry = queuedMessageQueue?.entries.find( (candidate) => candidate.comment.id === commentId, ); - if (!entry?.canEdit || !paperclipQueue) return; + if (!entry?.canEdit || !queuedMessageQueue) return; setQueuedEdit({ commentId, body: entry.comment.body, - revision: paperclipQueue.revision, + revision: queuedMessageQueue.revision, }); }, - [paperclipQueue], + [queuedMessageQueue], ); const saveQueuedEdit = useCallback( @@ -592,7 +597,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { useEffect(() => { if (!queuedEdit || queuedEdit.stale) return; - const targetStillQueued = paperclipQueue?.entries.some( + const targetStillQueued = queuedMessageQueue?.entries.some( (entry) => entry.comment.id === queuedEdit.commentId, ); if (!targetStillQueued) { @@ -600,16 +605,18 @@ export function TaskChatThread(props: TaskChatThreadProps) { current ? { ...current, stale: true } : current, ); } else if ( - paperclipQueue && - queuedEdit.revision !== paperclipQueue.revision + queuedMessageQueue && + queuedEdit.revision !== queuedMessageQueue.revision ) { // A concurrent reorder/edit refreshes the optimistic-lock token without // replacing the Markdown currently in the editor. setQueuedEdit((current) => - current ? { ...current, revision: paperclipQueue.revision } : current, + current + ? { ...current, revision: queuedMessageQueue.revision } + : current, ); } - }, [paperclipQueue, queuedEdit]); + }, [queuedEdit, queuedMessageQueue]); const liveWorkLinks = useMemo( () => @@ -1316,6 +1323,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? decidedCommentId : null; const sourceHasPresentationComment = sourcePresentationCommentId !== null; + const sourcePresentationText = sourcePresentationCommentId + ? (comments.find( + (comment) => comment.id === sourcePresentationCommentId, + )?.body ?? null) + : null; const sourceHasNativeResponse = sourceIsPaperclipRunner && !sourceYielded && @@ -1524,8 +1536,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { startSlotMs, timelineAnchors, ); - for (const [segmentIndex, segment] of segments.entries()) { - if (segment.entries.length === 0) continue; + const projectedSegments = segments.map((segment) => { const parsedTranscript = transcriptToTaskChatItems(segment.entries, { runId: source.id, agentName: meta?.agentName, @@ -1535,11 +1546,46 @@ export function TaskChatThread(props: TaskChatThreadProps) { source.id === planDocumentSourceRunId && planTurnItem ? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem) : parsedTranscript; - const children = settledRunChildren( - sourceIsPaperclipRunner + return { + segment, + parsed, + timelineItems: sourceIsPaperclipRunner ? paperclipRunnerTimelineItems(parsed) : parsed, - ); + }; + }); + const sourceResponseText = + sourceIsPaperclipRunner && !sourceYielded + ? (sourcePresentationText ?? + paperclipRunnerFinalResponse( + transcriptToTaskChatItems(entries, { + runId: source.id, + agentName: meta?.agentName, + running: false, + }), + { + runId: source.id, + agentName: meta?.agentName, + fallbackSummary: acceptedSummary, + }, + )?.text) + : undefined; + const timelineItemsBySegment = sourceIsPaperclipRunner + ? omitProgressRepeatedByResponseAcrossSegments( + projectedSegments.map(({ timelineItems }) => timelineItems), + sourceResponseText, + ) + : projectedSegments.map(({ timelineItems }) => timelineItems); + let lastPopulatedSegmentIndex = -1; + for (let index = projectedSegments.length - 1; index >= 0; index -= 1) { + if ((projectedSegments[index]?.segment.entries.length ?? 0) > 0) { + lastPopulatedSegmentIndex = index; + break; + } + } + for (const [segmentIndex, projected] of projectedSegments.entries()) { + const { segment, parsed } = projected; + if (segment.entries.length === 0) continue; const finalResponse = sourceIsPaperclipRunner && !sourceYielded && @@ -1547,9 +1593,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? paperclipRunnerFinalResponse(parsed, { runId: source.id, agentName: meta?.agentName, - fallbackSummary: acceptedSummary, + fallbackSummary: + segmentIndex === lastPopulatedSegmentIndex + ? acceptedSummary + : undefined, }) : undefined; + const children = settledRunChildren( + timelineItemsBySegment[segmentIndex] ?? [], + ); if (children.length === 0 && !finalResponse && !sourceIsPaperclipRunner) continue; settledRunIds.add(source.id); @@ -1583,6 +1635,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? agentMap?.get(meta.agentId)?.icon : undefined, standaloneHeader: sourceIsPaperclipRunner, + continuedAfterSteering: sourceIsPaperclipRunner && segmentIndex > 0, animateFold: liveSeenRef.current.has(source.id), items: children, finalResponse, @@ -1672,6 +1725,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? agentMap?.get(liveRun.agentId)?.icon : undefined, standaloneHeader: isNativePaperclipRunnerRun(liveRun), + continuedAfterSteering: + isNativePaperclipRunnerRun(liveRun) && segmentIndex > 0, items: children, summary: buildTurnSummary(segment.entries, { durationMs: segmentDurationMs, @@ -1870,18 +1925,19 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? `${tailPlanItem.id}:${tailPlanItem.document.body.length}` : ""; const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${tailPlanContentKey}:${blockerContentKey}`; - const repeatBlockersAtBottom = !streamlinedUiEnabled || shouldRepeatTaskChatBlockers(items); - const bottomBlockerLinks = repeatBlockersAtBottom - ? liveWorkLinks ? ( - - ) : blockerLinks ? ( - - ) : null - : null; + const repeatBlockersAtBottom = + !streamlinedUiEnabled || shouldRepeatTaskChatBlockers(items); + const bottomBlockerLinks = repeatBlockersAtBottom ? ( + liveWorkLinks ? ( + + ) : blockerLinks ? ( + + ) : null + ) : null; // Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish // (once terminal), and the "called N tools" summary. Memoized on the @@ -1895,11 +1951,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { const tailStatus = liveRun ? liveRun.status : (runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded"); - const tailStartedAtMs = liveRun - ? (tailSegmentStartMs ?? - (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? - toMs(liveRun.createdAt)) - : (settlingRun?.startedAtMs ?? null); + const tailStartedAtMs = + tailSegmentStartMs ?? + (liveRun + ? ((liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? + toMs(liveRun.createdAt)) + : (settlingRun?.startedAtMs ?? null)); const tailFinishedAtMs = liveRun ? liveRun.finishedAt ? toMs(liveRun.finishedAt) @@ -2182,7 +2239,6 @@ export function TaskChatThread(props: TaskChatThreadProps) { const renderQueuedAction = useCallback( (item: TaskChatMessageItem) => { - if (paperclipQueue) return null; const runId = item.queueTargetRunId; if (item.optimistic !== "queued" || !runId || !onInterruptQueued) return null; @@ -2200,7 +2256,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ); }, - [interruptingQueuedRunId, onInterruptQueued, paperclipQueue], + [interruptingQueuedRunId, onInterruptQueued], ); const renderInteraction = useCallback( @@ -2326,221 +2382,247 @@ export function TaskChatThread(props: TaskChatThreadProps) { useWindowAutoFollow(isMobile ? autoFollowContentKey : 0, isMobile); return ( - -
-
- {items.length === 0 && !tailRunId ? ( -
- {threadHeaderWithBlockers ? ( -
- {threadHeaderWithBlockers} +
+
+ {items.length === 0 && !tailRunId ? ( +
+ {threadHeaderWithBlockers ? ( +
+ {threadHeaderWithBlockers} +
+ ) : null} +
+ {emptyMessage}
- ) : null} -
- {emptyMessage} + {bottomBlockerLinks ? ( +
+ {bottomBlockerLinks} +
+ ) : null}
- {bottomBlockerLinks ? ( -
- {bottomBlockerLinks} -
- ) : null} -
- ) : ( - - : undefined - } - renderMessageActions={renderMessageActions} - renderQueuedAction={renderQueuedAction} - onTryAgainNoLiveExecutionPath={ - issueStatus === "blocked" - ? onTryAgainNoLiveExecutionPath - : undefined - } - tryAgainNoLiveExecutionPathPending={ - tryAgainNoLiveExecutionPathPending - } - onRetryFailedRun={onRetryFailedRun} - retryFailedRunId={retryFailedRunId} - tail={ - tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? ( - <> - {tailRunId || optimisticRunnerStartup ? ( -
- {paperclipRunnerTail || optimisticRunnerStartup ? ( - - ) : ( - <> - + : undefined + } + renderMessageActions={renderMessageActions} + renderQueuedAction={renderQueuedAction} + onTryAgainNoLiveExecutionPath={ + issueStatus === "blocked" + ? onTryAgainNoLiveExecutionPath + : undefined + } + tryAgainNoLiveExecutionPathPending={ + tryAgainNoLiveExecutionPathPending + } + onRetryFailedRun={onRetryFailedRun} + retryFailedRunId={retryFailedRunId} + tail={ + tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? ( + <> + {tailRunId || optimisticRunnerStartup ? ( +
+ {paperclipRunnerTail || optimisticRunnerStartup ? ( + - 0 + } + onRuntimeRequestDecision={ + handleRuntimeRequestDecision } /> - - )} -
- ) : null} - {bottomBlockerLinks} - - ) : null - } - contentKey={autoFollowContentKey} - className={isMobile ? undefined : "pt-3"} - scroll={!isMobile} - /> - )} -
- {assignedAgentForNotice?.status === "paused" ? ( -
- -
- ) : null} - {showComposer ? ( -
+ + + + )} +
+ ) : null} + {bottomBlockerLinks} + + ) : null + } + contentKey={autoFollowContentKey} + className={isMobile ? undefined : "pt-3"} + scroll={!isMobile} + /> )} - > - {composerAccessory} - {tailTurnStatus ? ( - - ) : null} -
- {paperclipQueue && paperclipQueue.entries.length > 0 ? ( - { - if (!onReorderQueuedComments) - throw new Error("Queue reordering is unavailable."); - await onReorderQueuedComments(orderedCommentIds, revision); - }} - onSteer={async (commentId, revision) => { - if (!onSteerQueuedComment) - throw new Error("Steering is unavailable."); - await onSteerQueuedComment(commentId, revision); - }} - onDiscard={async (commentId, revision) => { - if (!onDiscardQueuedComment) - throw new Error("Discard is unavailable."); - await onDiscardQueuedComment(commentId, revision); - if (queuedEdit?.commentId === commentId) setQueuedEdit(null); - }} - /> - ) : null} -
- setQueuedEdit(null)} - takeover={composerTakeover} - pendingTakeover={ - pendingComposerInputs.length > 0 - ? { - count: pendingComposerInputs.length, - label: `${pendingComposerInputs.length} pending input${pendingComposerInputs.length === 1 ? "" : "s"}`, - onOpen: openPendingTakeover, - } - : null - } - /> -
-
- {footer}
- ) : null} -
+ {assignedAgentForNotice?.status === "paused" ? ( +
+ +
+ ) : null} + {showComposer ? ( +
+ {composerAccessory} + {tailTurnStatus ? ( + + ) : null} +
+ {queuedMessageQueue ? ( + { + if (!onReorderQueuedComments) + throw new Error("Queue reordering is unavailable."); + await onReorderQueuedComments(orderedCommentIds, revision); + }} + onSteer={async (commentId, revision) => { + if (!onSteerQueuedComment) + throw new Error("Steering is unavailable."); + await onSteerQueuedComment(commentId, revision); + }} + onInterrupt={ + onInterruptQueued && queuedMessageQueue.targetRunId + ? async () => { + await onInterruptQueued( + queuedMessageQueue.targetRunId!, + ); + } + : undefined + } + onDiscard={async (commentId, revision) => { + if (commentId.startsWith("optimistic-")) { + if (!onCancelQueued) + throw new Error("Discard is unavailable."); + onCancelQueued(commentId); + return; + } + if (!onDiscardQueuedComment) + throw new Error("Discard is unavailable."); + await onDiscardQueuedComment(commentId, revision); + if (queuedEdit?.commentId === commentId) + setQueuedEdit(null); + }} + /> + ) : null} +
+ setQueuedEdit(null)} + takeover={composerTakeover} + pendingTakeover={ + pendingComposerInputs.length > 0 + ? { + count: pendingComposerInputs.length, + label: `${pendingComposerInputs.length} pending input${pendingComposerInputs.length === 1 ? "" : "s"}`, + onOpen: openPendingTakeover, + } + : null + } + /> +
+
+ {footer} +
+ ) : null} +
); } diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx index fd0ea64faa..055c61ac66 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx @@ -5,7 +5,10 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IssueQueuedCommentQueue } from "@paperclipai/shared"; -import { TaskChatQueuedMessages } from "./TaskChatQueuedMessages"; +import { + reorderQueuedMessageEntries, + TaskChatQueuedMessages, +} from "./TaskChatQueuedMessages"; function deferred() { let resolve!: (value: T) => void; @@ -23,24 +26,26 @@ const queue: IssueQueuedCommentQueue = { revision: "rev-1", protocol: "paperclip_runner_v1", steeringDisposition: "available", - entries: ["First queued message", "Second queued message"].map((body, position) => ({ - comment: { - id: `comment-${position + 1}`, - companyId: "company-1", - issueId: "issue-1", - authorType: "user", - authorAgentId: null, - authorUserId: "user-1", - body, - presentation: null, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - position, - canEdit: true, - canDiscard: true, - })), + entries: ["First queued message", "Second queued message"].map( + (body, position) => ({ + comment: { + id: `comment-${position + 1}`, + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body, + presentation: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + position, + canEdit: true, + canDiscard: true, + }), + ), }; describe("TaskChatQueuedMessages", () => { @@ -58,7 +63,9 @@ describe("TaskChatQueuedMessages", () => { container.remove(); }); - function render(overrides: Partial> = {}) { + function render( + overrides: Partial> = {}, + ) { const props = { queue, onEdit: vi.fn(), @@ -73,56 +80,122 @@ describe("TaskChatQueuedMessages", () => { it("renders each queued message once as a compact one-line row", () => { render(); - const pane = container.querySelector('[data-testid="task-chat-queued-messages"]'); + const pane = container.querySelector( + '[data-testid="task-chat-queued-messages"]', + ); expect(pane?.classList).toContain("mx-3"); expect(pane?.classList).toContain("rounded-b-none"); expect(pane?.classList).toContain("border-b-0"); expect(pane?.classList).toContain("-mb-px"); - expect(container.querySelectorAll('[data-testid^="task-chat-queued-message-"]')).toHaveLength(2); + expect( + container.querySelectorAll('[data-testid^="task-chat-queued-message-"]'), + ).toHaveLength(2); expect(container.textContent).toContain("First queued message"); expect(container.textContent).toContain("Second queued message"); }); - it("removes only the acknowledged steering row", async () => { - const props = render(); + it("reorders the complete queue and rewrites contiguous positions", () => { + const next = reorderQueuedMessageEntries( + queue.entries, + "comment-2", + "comment-1", + ); + + expect(next?.map((entry) => entry.comment.id)).toEqual([ + "comment-2", + "comment-1", + ]); + expect(next?.map((entry) => entry.position)).toEqual([0, 1]); + }); + + it("promotes only the selected steering row immediately", async () => { + const acknowledgement = deferred(); + const props = render({ + onSteer: vi.fn().mockReturnValue(acknowledgement.promise), + }); await act(async () => { - container.querySelector('[data-testid="task-chat-queued-steer-comment-1"]')?.click(); + container + .querySelector( + '[data-testid="task-chat-queued-steer-comment-1"]', + ) + ?.click(); + await Promise.resolve(); }); expect(props.onSteer).toHaveBeenCalledWith("comment-1", "rev-1"); - expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull(); - expect(container.querySelector('[data-testid="task-chat-queued-message-comment-2"]')).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-comment-1"]', + ), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-comment-2"]', + ), + ).not.toBeNull(); + + await act(async () => { + acknowledgement.resolve(); + await acknowledgement.promise; + }); }); it("keeps a row queued when steering fails and announces the retryable state", async () => { - render({ onSteer: vi.fn().mockRejectedValue(new Error("steering_timeout")) }); - await act(async () => { - container.querySelector('[data-testid="task-chat-queued-steer-comment-1"]')?.click(); + render({ + onSteer: vi.fn().mockRejectedValue(new Error("steering_timeout")), }); - expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).not.toBeNull(); - expect(container.textContent).toContain("Couldn’t steer. Message is still queued."); + await act(async () => { + container + .querySelector( + '[data-testid="task-chat-queued-steer-comment-1"]', + ) + ?.click(); + }); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-comment-1"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain( + "Couldn’t steer. Message is still queued.", + ); }); it("disables steering when the provider does not advertise it", () => { render({ queue: { ...queue, steeringDisposition: "unsupported" } }); - expect(container.querySelector('[data-testid="task-chat-queued-steer-comment-1"]')?.disabled) - .toBe(true); + expect( + container.querySelector( + '[data-testid="task-chat-queued-steer-comment-1"]', + )?.disabled, + ).toBe(true); }); it("waits for authoritative discard acknowledgement before removing the row", async () => { const acknowledgement = deferred(); render({ onDiscard: vi.fn().mockReturnValue(acknowledgement.promise) }); await act(async () => { - container.querySelector('[data-testid="task-chat-queued-discard-comment-1"]')?.click(); + container + .querySelector( + '[data-testid="task-chat-queued-discard-comment-1"]', + ) + ?.click(); await Promise.resolve(); }); - expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-comment-1"]', + ), + ).not.toBeNull(); expect(container.textContent).not.toContain("Queued message discarded."); await act(async () => { acknowledgement.resolve(); await acknowledgement.promise; }); - expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull(); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-comment-1"]', + ), + ).toBeNull(); expect(container.textContent).toContain("Queued message discarded."); }); @@ -133,10 +206,20 @@ describe("TaskChatQueuedMessages", () => { }), }); await act(async () => { - container.querySelector('[data-testid="task-chat-queued-discard-comment-1"]')?.click(); + container + .querySelector( + '[data-testid="task-chat-queued-discard-comment-1"]', + ) + ?.click(); }); - expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).not.toBeNull(); - expect(container.textContent).toContain("Too late to discard: this message is already being sent."); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-comment-1"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain( + "Too late to discard: this message is already being sent.", + ); }); it("shows stale revisions without announcing a discard", async () => { @@ -146,9 +229,15 @@ describe("TaskChatQueuedMessages", () => { }), }); await act(async () => { - container.querySelector('[data-testid="task-chat-queued-discard-comment-1"]')?.click(); + container + .querySelector( + '[data-testid="task-chat-queued-discard-comment-1"]', + ) + ?.click(); }); - expect(container.textContent).toContain("The queue changed in another session. Review it and try again."); + expect(container.textContent).toContain( + "The queue changed in another session. Review it and try again.", + ); expect(container.textContent).not.toContain("Queued message discarded."); }); @@ -162,4 +251,71 @@ describe("TaskChatQueuedMessages", () => { expect(props.onDiscard).not.toHaveBeenCalled(); }); + it("can discard a local optimistic row before the queue id is acknowledged", async () => { + const optimisticQueue = { + ...queue, + queueId: null, + state: "deferred" as const, + entries: [ + { + ...queue.entries[0], + comment: { + ...queue.entries[0].comment, + id: "optimistic-local-1", + }, + }, + ], + }; + const props = render({ queue: optimisticQueue }); + + await act(async () => { + container + .querySelector( + '[data-testid="task-chat-queued-discard-optimistic-local-1"]', + ) + ?.click(); + }); + + expect(props.onDiscard).toHaveBeenCalledWith("optimistic-local-1", "rev-1"); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-optimistic-local-1"]', + ), + ).toBeNull(); + }); + + it("uses interrupt instead of steer for legacy runners and keeps the row queued", async () => { + const onInterrupt = vi.fn().mockResolvedValue(undefined); + render({ + queue: { + ...queue, + protocol: "legacy", + steeringDisposition: "unsupported", + }, + onInterrupt, + }); + + await act(async () => { + container + .querySelector( + '[data-testid="task-chat-queued-interrupt-comment-1"]', + ) + ?.click(); + }); + + expect(onInterrupt).toHaveBeenCalledOnce(); + expect( + container.querySelector( + '[data-testid="task-chat-queued-steer-comment-1"]', + ), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid="task-chat-queued-message-comment-1"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain( + "Active turn interrupted. Message remains queued.", + ); + }); }); diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx index 1f9670e705..5d6dcf4453 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx @@ -36,7 +36,21 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -type QueueAction = "steer" | "discard" | null; +type QueueAction = "steer" | "interrupt" | "discard" | null; + +export function reorderQueuedMessageEntries( + entries: IssueQueuedCommentEntry[], + activeId: string, + overId: string, +) { + const from = entries.findIndex((entry) => entry.comment.id === activeId); + const to = entries.findIndex((entry) => entry.comment.id === overId); + if (from < 0 || to < 0 || from === to) return null; + return arrayMove(entries, from, to).map((entry, position) => ({ + ...entry, + position, + })); +} function queueActionErrorCode(error: unknown): string | null { if (typeof error !== "object" || error === null) return null; @@ -55,32 +69,41 @@ export interface TaskChatQueuedMessagesProps { onEdit: (commentId: string) => void; onReorder: (orderedCommentIds: string[], revision: string) => Promise; onSteer: (commentId: string, revision: string) => Promise; + onInterrupt?: () => Promise; onDiscard: (commentId: string, revision: string) => Promise; } function SortableQueuedMessage({ entry, queue, - disabled, + busy, + queueMutationDisabled, action, onEdit, onSteer, + onInterrupt, onDiscard, }: { entry: IssueQueuedCommentEntry; queue: IssueQueuedCommentQueue; - disabled: boolean; + busy: boolean; + queueMutationDisabled: boolean; action: QueueAction; onEdit: () => void; onSteer: () => void; + onInterrupt?: () => void; onDiscard: () => void; }) { - const sortable = useSortable({ id: entry.comment.id, disabled }); + const sortable = useSortable({ + id: entry.comment.id, + disabled: queueMutationDisabled, + }); const style = { transform: CSS.Transform.toString(sortable.transform), transition: sortable.transition, }; - const steerDisabled = disabled || queue.steeringDisposition !== "available"; + const steerDisabled = + queueMutationDisabled || queue.steeringDisposition !== "available"; const steerTitle = queue.steeringDisposition === "unsupported" ? "This runner does not support steering" @@ -94,7 +117,8 @@ function SortableQueuedMessage({ style={style} className={cn( "group flex h-11 min-w-0 items-center gap-1 border-b border-border/55 bg-card/95 px-2.5 text-sm last:border-b-0", - sortable.isDragging && "relative z-20 rounded-lg border border-border shadow-lg", + sortable.isDragging && + "relative z-20 rounded-lg border border-border shadow-lg", )} data-testid={`task-chat-queued-message-${entry.comment.id}`} > @@ -103,38 +127,63 @@ function SortableQueuedMessage({ ref={sortable.setActivatorNodeRef} {...sortable.attributes} {...sortable.listeners} - disabled={disabled} + disabled={queueMutationDisabled} aria-label={`Reorder queued message: ${entry.comment.body}`} className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-40" > - + {entry.comment.body} - + {queue.protocol === "legacy" ? ( + + ) : ( + + )} - {streamlinedTaskDetailEnabled ? ( - <> - - - {canArchiveFromInbox ? ( + {streamlinedTaskDetailEnabled ? ( + <> - ) : null} - - ) : null} - {canPauseLeafWork ? ( - - ) : null} - {canResumeLeafWork ? ( - - ) : null} - {canShowSubtreeControls ? ( - <> + + {canArchiveFromInbox ? ( + + ) : null} + + ) : null} + {canPauseLeafWork ? ( - {canResumeSubtree ? ( - - ) : null} + ) : null} + {canResumeLeafWork ? ( - {canRestoreSubtree ? ( + ) : null} + {canShowSubtreeControls ? ( + <> - ) : null} - - ) : null} - + {canResumeSubtree ? ( + + ) : null} + + {canRestoreSubtree ? ( + + ) : null} + + ) : null} +
@@ -6982,12 +7142,16 @@ export function IssueDetail() { onSave={(title) => updateIssue.mutateAsync({ title })} as="h2" className={ - taskChatShellEnabled ? "text-base font-semibold" : "text-xl font-bold" + taskChatShellEnabled + ? "text-base font-semibold" + : "text-xl font-bold" } /> ) : null} - {taskChatShellEnabled && !streamlinedTaskDetailEnabled ? subTasksTree : null} + {taskChatShellEnabled && !streamlinedTaskDetailEnabled + ? subTasksTree + : null} ) : ( -
- This task is paused by ancestor{" "} - {activePauseHoldRoot?.identifier ? ( - - {activePauseHoldRoot.identifier} - - ) : ( - activePauseHold.rootIssueId.slice(0, 8) - )} - . Resume from the root task to deliver deferred work. -
- )} -
- )} - - {taskChatShellEnabled ? null : issueHeaderBlock} - - {taskChatShellEnabled ? null : pluginOutletsBlock} - - {taskChatShellEnabled ? null : showRichSubIssuesSection ? ( -
-
-

Sub-tasks

+
+ This task is paused by ancestor{" "} + {activePauseHoldRoot?.identifier ? ( + + {activePauseHoldRoot.identifier} + + ) : ( + activePauseHold.rootIssueId.slice(0, 8) + )} + . Resume from the root task to deliver deferred work. +
+ )}
- -
- ) : ( -
- -
- )} - - {!taskChatShellEnabled && showPlanDecompositionsSection ? ( - - ) : null} - - {/* Flag ON: attachments/work products/workspace live in the properties - pane (Artifacts tab) — the center column belongs to the thread. */} - {taskChatShellEnabled ? null : ( - { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }} - onVote={async (revisionId, vote, options) => { - await feedbackVoteMutation.mutateAsync({ - targetType: "issue_document_revision", - targetId: revisionId, - vote, - reason: options?.reason, - allowSharing: options?.allowSharing, - sharingPreferenceAtSubmit: feedbackDataSharingPreference, - }); - }} - extraActions={!hasAttachments ? attachmentUploadButton : null} - agentMap={agentMap} - userProfileMap={userProfileMap} - /> - )} - - {taskChatShellEnabled ? null : ( - { - const meta = item.metadata; - if (!meta) return; - const idx = mediaGalleryItems.findIndex((galleryItem) => ( - galleryItem.contentPath === meta.contentPath || - galleryItem.id === `work-product-${item.id}` || - galleryItem.id === meta.attachmentId - )); - setGalleryIndex(idx >= 0 ? idx : 0); - setGalleryOpen(true); - }} - /> - )} - - {taskChatShellEnabled ? null : attachmentsInitialLoading ? ( - - ) : hasAttachments ? ( - deleteAttachment.mutate(attachmentId)} - onImageClick={(attachment) => { - const idx = mediaGalleryItems.findIndex((a) => a.id === attachment.id); - setGalleryIndex(idx >= 0 ? idx : 0); - setGalleryOpen(true); - }} - onDragEnter={(evt) => { - evt.preventDefault(); - setAttachmentDragActive(true); - }} - onDragOver={(evt) => { - evt.preventDefault(); - setAttachmentDragActive(true); - }} - onDragLeave={(evt) => { - if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return; - setAttachmentDragActive(false); - }} - onDrop={(evt) => void handleAttachmentDrop(evt)} - /> - ) : null} - - - - {taskChatShellEnabled ? null : ( - updateIssue.mutate(data)} - onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined} - onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined} - /> - )} - - {!taskChatShellEnabled && fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => { - const workProductsWithFileRefs = issue.workProducts - .map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) })) - .filter(({ fileRef }) => fileRef !== null); - - if (workProductsWithFileRefs.length === 0) return null; - - return ( -
-
-

Artifacts

-
-
- {workProductsWithFileRefs.map(({ product, fileRef }) => ( - - ))} -
-
- ); - })()} - - {taskChatShellEnabled ? null : } - - - {/* Redesign: the chat IS the page — the Chat/Activity/Related-work tab - strip is hidden and the thread renders as the only surface. */} - {taskChatShellEnabled ? null : ( - - - - Chat - - - - Activity - - - - Related work - - {issuePluginTabItems.map((item) => ( - - {item.label} - - ))} - )} - {/* The chat shell keeps the page's responsive 16px/24px gutters so - thread content and the composer do not touch either sidebar. */} - +
+

+ Sub-tasks +

+
+ +
+ ) : ( +
+ +
+ )} + + {!taskChatShellEnabled && showPlanDecompositionsSection ? ( + + ) : null} + + {/* Flag ON: attachments/work products/workspace live in the properties + pane (Artifacts tab) — the center column belongs to the thread. */} + {taskChatShellEnabled ? null : ( + { + const attachment = await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }} + onVote={async (revisionId, vote, options) => { + await feedbackVoteMutation.mutateAsync({ + targetType: "issue_document_revision", + targetId: revisionId, + vote, + reason: options?.reason, + allowSharing: options?.allowSharing, + sharingPreferenceAtSubmit: feedbackDataSharingPreference, + }); + }} + extraActions={!hasAttachments ? attachmentUploadButton : null} + agentMap={agentMap} + userProfileMap={userProfileMap} + /> + )} + + {taskChatShellEnabled ? null : ( + { + const meta = item.metadata; + if (!meta) return; + const idx = mediaGalleryItems.findIndex( + (galleryItem) => + galleryItem.contentPath === meta.contentPath || + galleryItem.id === `work-product-${item.id}` || + galleryItem.id === meta.attachmentId, + ); + setGalleryIndex(idx >= 0 ? idx : 0); + setGalleryOpen(true); + }} + /> + )} + + {taskChatShellEnabled ? null : attachmentsInitialLoading ? ( + + ) : hasAttachments ? ( + deleteAttachment.mutate(attachmentId)} + onImageClick={(attachment) => { + const idx = mediaGalleryItems.findIndex( + (a) => a.id === attachment.id, + ); + setGalleryIndex(idx >= 0 ? idx : 0); + setGalleryOpen(true); + }} + onDragEnter={(evt) => { + evt.preventDefault(); + setAttachmentDragActive(true); + }} + onDragOver={(evt) => { + evt.preventDefault(); + setAttachmentDragActive(true); + }} + onDragLeave={(evt) => { + if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) + return; + setAttachmentDragActive(false); + }} + onDrop={(evt) => void handleAttachmentDrop(evt)} + /> + ) : null} + + + + {taskChatShellEnabled ? null : ( + updateIssue.mutate(data)} + onBrowseFiles={ + fileViewerEnabled + ? () => setFileViewerPromptOpen(true) + : undefined + } + onOpenFileByPath={ + fileViewerEnabled + ? () => setFileViewerPromptOpen(true) + : undefined + } + /> + )} + + {!taskChatShellEnabled && + fileViewerEnabled && + issue.workProducts && + issue.workProducts.length > 0 && + (() => { + const workProductsWithFileRefs = issue.workProducts + .map((product) => ({ + product, + fileRef: extractWorkspaceFileRefFromWorkProduct(product), + })) + .filter(({ fileRef }) => fileRef !== null); + + if (workProductsWithFileRefs.length === 0) return null; + + return ( +
+
+

+ Artifacts +

+
+
+ {workProductsWithFileRefs.map(({ product, fileRef }) => ( + + ))} +
+
+ ); + })()} + + {taskChatShellEnabled ? null : ( + + )} + + - {resolvedDetailTab === "chat" ? ( - updateIssue.mutateAsync({ description }), - mentions: mentionOptions, - externalReferences: externalObjectsState.isEnabled - ? externalObjectsState.markdownReferences - : undefined, - imageUploadHandler: async (file) => { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }, - onDropFile: async (file) => { - await uploadAttachment.mutateAsync(file); - }, - } + {/* Redesign: the chat IS the page — the Chat/Activity/Related-work tab + strip is hidden and the thread renders as the only surface. */} + {taskChatShellEnabled ? null : ( + + + + Chat + + + + Activity + + + + Related work + + {issuePluginTabItems.map((item) => ( + + {item.label} + + ))} + + )} + + {/* The chat shell keeps the page's responsive 16px/24px gutters so + thread content and the composer do not touch either sidebar. */} + + {resolvedDetailTab === "chat" ? ( + + updateIssue.mutateAsync({ description }), + mentions: mentionOptions, + externalReferences: externalObjectsState.isEnabled + ? externalObjectsState.markdownReferences + : undefined, + imageUploadHandler: async (file) => { + const attachment = + await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }, + onDropFile: async (file) => { + await uploadAttachment.mutateAsync(file); + }, + } + : undefined + } + issueId={issue.id} + companyId={issue.companyId} + projectId={issue.projectId ?? null} + issueStatus={issue.status} + issueAssigneeAgentId={issue.assigneeAgentId} + issueWorkMode={issue.workMode ?? "standard"} + executionRunId={issue.executionRunId ?? null} + blockedBy={issue.blockedBy ?? []} + liveIssueIds={liveIssueIds} + blockerAttention={issue.blockerAttention ?? null} + successfulRunHandoff={issue.successfulRunHandoff ?? null} + scheduledRetry={issue.scheduledRetry ?? null} + recoveryAction={issue.activeRecoveryAction ?? null} + onResolveRecoveryAction={handleResolveRecoveryAction} + onReissueIsolatedRecoveryAction={ + handleReissueIsolatedRecoveryAction + } + reissueIsolatedRecoveryActionPending={ + reissueIsolatedRecoveryAction.isPending + } + onReconcileForwardRecoveryAction={ + handleReconcileForwardRecoveryAction + } + onBreakGlassOverrideRecoveryAction={ + handleBreakGlassOverrideRecoveryAction + } + onQuarantineRestoreRecoveryAction={ + handleQuarantineRestoreRecoveryAction + } + quarantineRestoreRecoveryActionPending={ + reconcileRecoveryAction.isPending + } + canBreakGlassRecoveryAction={canManageBoardRuntime} + reconcileRecoveryActionPending={ + reconcileRecoveryAction.isPending + } + canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction} + legacyRecoverySourceIssue={legacyRecoverySourceIssue} + comments={threadComments} + commentsInitialLoading={commentsLoading} + locallyQueuedCommentRunIds={locallyQueuedCommentRunIds} + interactions={interactions} + documents={issue.documentSummaries ?? []} + workProducts={workProducts ?? []} + attachments={attachments ?? []} + hasOlderComments={hasOlderComments} + commentsLoadingOlder={commentsLoadingOlder} + onLoadOlderComments={loadOlderComments} + onRefreshLatestComments={refetchLatestComments} + composerRef={commentComposerRef} + composerAccessory={ + hasVisibleMonitorSurface(issue) ? ( + checkIssueMonitorNow.mutate()} + checkingNow={checkIssueMonitorNow.isPending} + /> + ) : null + } + footer={ + !taskChatShellEnabled && siblingNavigation ? ( + + ) : null + } + feedbackVotes={feedbackVotes} + feedbackDataSharingPreference={feedbackDataSharingPreference} + feedbackTermsUrl={FEEDBACK_TERMS_URL} + agentMap={agentMap} + currentUserId={currentUserId} + userLabelMap={userLabelMap} + userProfileMap={userProfileMap} + draftKey={`paperclip:issue-comment-draft:${issue.id}`} + reassignOptions={commentReassignOptions} + currentAssigneeValue={actualAssigneeValue} + suggestedAssigneeValue={suggestedAssigneeValue} + mentions={mentionOptions} + composerDisabledReason={null} + composerHint={composerHint} + queuedCommentReason={queuedCommentReason} + onVote={handleCommentVote} + onAdd={handleChatAdd} + onImageUpload={handleCommentImageUpload} + onAttachImage={handleCommentAttachImage} + onInterruptQueued={handleInterruptQueuedRun} + onDeleteComment={(commentId) => + deleteComment.mutateAsync({ commentId }).then(() => undefined) + } + onPauseWorkRun={ + canManageTreeControl + ? (runId) => + pauseIssueWorkRun + .mutateAsync({ runId, scope: treeControlScope }) + .then(() => undefined) + : undefined + } + runFinalizationActions={runFinalizationActions} + onWorkModeChange={(nextMode) => { + const currentMode: IssueWorkMode = + issue.workMode ?? "standard"; + if (currentMode === nextMode) return; + return updateIssue + .mutateAsync({ workMode: nextMode }) + .then(() => undefined); + }} + onCancelQueued={handleCancelQueuedComment} + interruptingQueuedRunId={ + interruptQueuedComment.isPending + ? (interruptQueuedComment.variables ?? null) + : null + } + pausingWorkRunId={ + pauseIssueWorkRun.isPending + ? (pauseIssueWorkRun.variables?.runId ?? null) + : null + } + onImageClick={handleChatImageClick} + onAcceptInteraction={handleAcceptInteraction} + onRejectInteraction={handleRejectInteraction} + onSubmitInteractionAnswers={handleSubmitInteractionAnswers} + onCancelInteraction={handleCancelInteraction} + onSkipInteraction={handleSkipInteraction} + onSubmitInteractionVerdicts={handleSubmitInteractionVerdicts} + assigneeUserId={issue.assigneeUserId ?? null} + onResumeFromBacklog={ + canResumeFromBacklog ? handleResumeFromBacklog : undefined + } + resumeFromBacklogPending={ + updateIssue.isPending && + updateIssue.variables?.status === "todo" + } + onResumeAssignee={ + issue.assigneeAgentId ? handleResumeAssignee : undefined + } + resumeAssigneePending={resumeAssigneeAgent.isPending} + onTryAgainNoLiveExecutionPath={ + issue.status === "blocked" && issue.activeRecoveryAction + ? handleTryAgainNoLiveExecutionPath + : undefined + } + tryAgainNoLiveExecutionPathPending={ + resolveRecoveryAction.isPending && + resolveRecoveryAction.variables?.sourceIssueStatus === "todo" + } + externalReferences={ + externalObjectsState.isEnabled + ? externalObjectsState.markdownReferences + : undefined + } + linkCaseReferences={casesChipsEnabled} + /> + ) : null} + + + + {detailTab === "activity" ? ( + { + approvalDecision.mutate({ approvalId, action }); + }} + externalReferences={ + externalObjectsState.isEnabled + ? externalObjectsState.markdownReferences + : undefined + } + /> + ) : null} + + + + checkIssueMonitorNow.mutate()} - checkingNow={checkIssueMonitorNow.isPending} - /> - ) : null - } - footer={ - !taskChatShellEnabled && siblingNavigation ? ( - - ) : null - } - feedbackVotes={feedbackVotes} - feedbackDataSharingPreference={feedbackDataSharingPreference} - feedbackTermsUrl={FEEDBACK_TERMS_URL} - agentMap={agentMap} - currentUserId={currentUserId} - userLabelMap={userLabelMap} - userProfileMap={userProfileMap} - draftKey={`paperclip:issue-comment-draft:${issue.id}`} - reassignOptions={commentReassignOptions} - currentAssigneeValue={actualAssigneeValue} - suggestedAssigneeValue={suggestedAssigneeValue} - mentions={mentionOptions} - composerDisabledReason={null} - composerHint={composerHint} - queuedCommentReason={queuedCommentReason} - onVote={handleCommentVote} - onAdd={handleChatAdd} - onImageUpload={handleCommentImageUpload} - onAttachImage={handleCommentAttachImage} - onInterruptQueued={handleInterruptQueuedRun} - onDeleteComment={(commentId) => deleteComment.mutateAsync({ commentId }).then(() => undefined)} - onPauseWorkRun={canManageTreeControl - ? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined) - : undefined} - runFinalizationActions={runFinalizationActions} - onWorkModeChange={(nextMode) => { - const currentMode: IssueWorkMode = issue.workMode ?? "standard"; - if (currentMode === nextMode) return; - return updateIssue.mutateAsync({ workMode: nextMode }).then(() => undefined); - }} - onCancelQueued={handleCancelQueuedComment} - interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} - pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables?.runId ?? null : null} - onImageClick={handleChatImageClick} - onAcceptInteraction={handleAcceptInteraction} - onRejectInteraction={handleRejectInteraction} - onSubmitInteractionAnswers={handleSubmitInteractionAnswers} - onCancelInteraction={handleCancelInteraction} - onSkipInteraction={handleSkipInteraction} - onSubmitInteractionVerdicts={handleSubmitInteractionVerdicts} - assigneeUserId={issue.assigneeUserId ?? null} - onResumeFromBacklog={canResumeFromBacklog ? handleResumeFromBacklog : undefined} - resumeFromBacklogPending={ - updateIssue.isPending && updateIssue.variables?.status === "todo" - } - onResumeAssignee={issue.assigneeAgentId ? handleResumeAssignee : undefined} - resumeAssigneePending={resumeAssigneeAgent.isPending} - onTryAgainNoLiveExecutionPath={ - issue.status === "blocked" && issue.activeRecoveryAction - ? handleTryAgainNoLiveExecutionPath + externalObjectsLoading={ + externalObjectsState.isEnabled + ? externalObjectsState.isLoading : undefined } - tryAgainNoLiveExecutionPathPending={ - resolveRecoveryAction.isPending && - resolveRecoveryAction.variables?.sourceIssueStatus === "todo" + externalObjectsError={ + externalObjectsState.isEnabled + ? externalObjectsState.isError + : undefined + } + onRetryExternalObjects={ + externalObjectsState.isEnabled + ? externalObjectsState.refetch + : undefined } - externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} - linkCaseReferences={casesChipsEnabled} - /> - ) : null} - - - - {detailTab === "activity" ? ( - { - approvalDecision.mutate({ approvalId, action }); - }} - externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} - /> - ) : null} - - - - - - - {activePluginTab && ( - - - )} - + + {activePluginTab && ( + + + + )} +