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 && ( + + + + )} +