// @vitest-environment jsdom import type { ReactElement } from "react"; import { act, forwardRef, useImperativeHandle, type ForwardedRef } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ThemeProvider } from "@/context/ThemeContext"; import { TaskChatThread } from "./TaskChatThread"; import type { IssueDocument, IssueQueuedCommentQueue, IssueThreadInteraction, } from "@paperclipai/shared"; import { heartbeatsApi } from "@/api/heartbeats"; const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() })); const nativeTranscriptState = vi.hoisted(() => ({ transcriptByRun: new Map() })); const transcriptHookRuns = vi.hoisted(() => ({ legacy: [] as unknown[][], native: [] as unknown[][] })); const sidebarState = vi.hoisted(() => ({ isMobile: false })); const planState = vi.hoisted(() => ({ data: null as IssueDocument | null })); const DIRECT_ADAPTER_TYPES = [ "codex_local", "claude_local", "opencode_local", "process", "http", "custom_plugin", ] as const; vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({ useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => { transcriptHookRuns.legacy.push(runs); return { transcriptByRun: new Map(transcriptState.transcriptByRun) }; }, })); vi.mock("@/components/transcript/useNativeRunTranscripts", () => ({ useNativeRunTranscripts: (runs: unknown[]) => { transcriptHookRuns.native.push(runs); return { transcriptByRun: new Map(nativeTranscriptState.transcriptByRun) }; }, })); vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: sidebarState.isMobile }), })); vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => planState, })); vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode; }) => ( {children} ), })); vi.mock("@/components/MarkdownEditor", () => ({ MarkdownEditor: forwardRef(function MockMarkdownEditor( { value }: { value: string }, ref: ForwardedRef, ) { useImperativeHandle(ref, () => ({ insertMarkdown: () => {}, focus: () => {}, })); return
{value}
; }), })); let container: HTMLDivElement; let root: Root | null = null; beforeEach(() => { localStorage.clear(); transcriptState.transcriptByRun.clear(); nativeTranscriptState.transcriptByRun.clear(); transcriptHookRuns.legacy.length = 0; transcriptHookRuns.native.length = 0; sidebarState.isMobile = false; planState.data = null; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { flushSync(() => root?.unmount()); root = null; container.remove(); localStorage.clear(); vi.restoreAllMocks(); }); function render(ui: ReactElement) { flushSync(() => root!.render({ui})); } function fakeScrollGeometry( element: HTMLElement, { scrollHeight = 1000, clientHeight = 400, scrollTop = 600 } = {}, ) { let currentScrollTop = scrollTop; Object.defineProperty(element, "scrollHeight", { value: scrollHeight, configurable: true, }); Object.defineProperty(element, "clientHeight", { value: clientHeight, configurable: true, }); Object.defineProperty(element, "scrollTop", { get: () => currentScrollTop, set: (value: number) => { currentScrollTop = value; }, configurable: true, }); } function planDocument(overrides: Partial = {}): IssueDocument { return { id: "document-plan", companyId: "company-1", issueId: "issue-1", key: "plan", title: "Plan", format: "markdown", body: "# Preview the Plan\n- Reuse the review card.\n- Stream live steps.\n- Reconcile the revision.", latestRevisionId: "revision-3", latestRevisionNumber: 3, createdByAgentId: "agent-1", createdByUserId: null, updatedByAgentId: "agent-1", updatedByUserId: null, lockedAt: null, lockedByAgentId: null, lockedByUserId: null, createdAt: new Date("2026-08-23T10:00:00.000Z"), updatedAt: new Date("2026-08-23T10:01:00.000Z"), ...overrides, }; } function planReviewInteraction( status: "pending" | "accepted" | "expired" = "pending", revisionId = "revision-3", sourceRunId: string | null = "run-plan", ): IssueThreadInteraction { const isResolved = status !== "pending"; return { id: "plan-review", companyId: "company-1", issueId: "issue-1", kind: "request_confirmation", title: "Review the Plan", summary: null, status, continuationPolicy: "wake_assignee", resolverPolicy: "anyone", requestedResolverPolicy: "anyone", effectiveResolverPolicy: "anyone", resolverPolicyProvenance: "inherited", effectiveResolverPolicySource: "requested", legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents", }, createdByAgentId: "agent-1", createdByUserId: null, sourceRunId, resolvedByAgentId: null, resolvedByUserId: isResolved ? "user-1" : null, createdAt: new Date("2026-08-23T10:01:01.000Z"), updatedAt: new Date("2026-08-23T10:01:01.000Z"), resolvedAt: isResolved ? new Date("2026-08-23T10:02:00.000Z") : null, payload: { version: 1, prompt: "Approve this Plan?", target: { type: "issue_document", issueId: "issue-1", documentId: "document-plan", key: "plan", revisionId, revisionNumber: 3, label: "Plan revision 3", }, }, result: status === "accepted" ? { outcome: "accepted" } : status === "expired" ? { outcome: "superseded_by_comment", commentId: "follow-up-comment", } : null, } as IssueThreadInteraction; } function questionInteraction( id: string, prompt: string, createdAt: string, ): IssueThreadInteraction { return { id, companyId: "company-1", issueId: "issue-1", kind: "ask_user_questions", title: prompt, status: "pending", continuationPolicy: "wake_assignee", resolverPolicy: "anyone", requestedResolverPolicy: "anyone", effectiveResolverPolicy: "anyone", resolverPolicyProvenance: "inherited", effectiveResolverPolicySource: "requested", legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents", }, createdByAgentId: "agent-1", createdByUserId: null, resolvedByAgentId: null, resolvedByUserId: null, createdAt: new Date(createdAt), updatedAt: new Date(createdAt), resolvedAt: null, payload: { version: 1, questions: [ { id: `${id}-question`, prompt, selectionMode: "single", required: true, options: [ { id: "yes", label: "Yes" }, { id: "no", label: "No" }, ], }, ], }, result: null, } as IssueThreadInteraction; } describe("TaskChatThread draft pass-through", () => { it("aligns the desktop thread header with the side-panel tab row", () => { render( {}} />, ); const scroller = container.querySelector( '[data-testid="task-chat-scroller"]', ); expect(scroller?.firstElementChild?.classList).toContain("pt-3"); }); it("keeps the composer dock aligned with the thread's horizontal padding", () => { render( {}} />, ); const dock = container.querySelector( '[data-testid="task-chat-composer-dock"]', ); expect(dock?.classList).toContain("px-4"); expect(dock?.classList).not.toContain("px-1"); }); it("forwards draftKey so the composer restores a task's saved draft", () => { localStorage.setItem("task-chat-draft:issue-1", "half-written thought"); render( {}} draftKey="task-chat-draft:issue-1" />, ); expect( container.querySelector('[data-testid="mock-editor"]')?.textContent, ).toBe("half-written thought"); }); it("forwards human profiles to the selected composer assignee", () => { render( {}} enableReassign reassignOptions={[{ id: "user:user-1", label: "Riley Board" }]} currentAssigneeValue="user:user-1" userProfileMap={ new Map([ ["user-1", { label: "Riley Board", image: "/riley-avatar.png" }], ]) } />, ); expect( container.querySelector('[data-assignee-trigger-avatar="user-1"]'), ).not.toBeNull(); }); }); describe("TaskChatThread runtime transcript selection", () => { it("selects persisted runtime facts while leaving direct adapters on the legacy parser", () => { render( {}} linkedRuns={[ { runId: "native-run", runtimeMode: "native", status: "succeeded", agentId: "agent-1", adapterType: "paperclip_runner", createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", }, ...DIRECT_ADAPTER_TYPES.map((adapterType, index) => ({ runId: `legacy-run-${index}`, runtimeMode: "legacy" as const, status: "succeeded", agentId: `agent-${index + 2}`, adapterType, createdAt: `2026-08-25T18:0${index + 1}:00.000Z`, startedAt: `2026-08-25T18:0${index + 1}:00.000Z`, })), ]} />, ); const legacyRuns = transcriptHookRuns.legacy.at(-1) as Array<{ id: string }>; const nativeRuns = transcriptHookRuns.native.at(-1) as Array<{ id: string }>; expect(legacyRuns.map((run) => run.id)).toEqual( DIRECT_ADAPTER_TYPES.map((_, index) => `legacy-run-${index}`), ); expect(nativeRuns.map((run) => run.id)).toEqual(["native-run"]); }); it("uses runner-only controls only for an actual native Paperclip Runner run", () => { nativeTranscriptState.transcriptByRun.set("native-run", [ { kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Checking the task.", channel: "progress", }, { kind: "assistant", ts: "2026-08-25T18:00:02.000Z", text: "The task is ready.", channel: "final", }, ]); const run = { id: "native-run", runtimeMode: "native" as const, status: "running" as const, invocationSource: "issue" as const, 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", }; render( {}} issueStatus="in_progress" activeRun={run} />, ); expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).not.toBeNull(); expect(container.textContent).toContain("Checking the task."); expect(container.textContent).toContain("The task is ready."); render( {}} issueStatus="in_progress" activeRun={{ ...run, runtimeMode: "legacy" }} />, ); expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull(); render( {}} issueStatus="in_progress" activeRun={{ ...run, adapterType: "codex_local" }} />, ); expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull(); }); it("keeps legacy channel-less native messages readable across settlement", () => { nativeTranscriptState.transcriptByRun.set("native-run", [ { kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Persisted before message channels existed.", channel: "unknown", }, ]); const run = { id: "native-run", runtimeMode: "native" as const, status: "running" as const, invocationSource: "issue" as const, 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", }; render( {}} issueStatus="in_progress" activeRun={run} />, ); expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent) .toContain("Persisted before message channels existed."); expect(container.querySelector('[data-testid="task-chat-final-response"]')).toBeNull(); render( {}} issueStatus="done" activeRun={{ ...run, status: "succeeded", finishedAt: "2026-08-25T18:00:02.000Z" }} />, ); expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')).toBeNull(); expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent) .toContain("Persisted before message channels existed."); }); it("recomputes a runner turn when only the message channel changes", () => { const run = { id: "native-run", runtimeMode: "native" as const, status: "running" as const, invocationSource: "issue" as const, 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", }; const renderRun = () => render( {}} issueStatus="in_progress" activeRun={run} />, ); nativeTranscriptState.transcriptByRun.set("native-run", [{ kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Same text.", channel: "progress", }]); renderRun(); expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent) .toContain("Same text."); expect(container.querySelector('[data-testid="task-chat-final-response"]')).toBeNull(); nativeTranscriptState.transcriptByRun.set("native-run", [{ kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Same text.", channel: "final", }]); renderRun(); expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')).toBeNull(); expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent) .toContain("Same text."); }); it("recomputes a runner turn when only usage totals change", () => { const run = { id: "native-run", runtimeMode: "native" as const, status: "running" as const, invocationSource: "issue" as const, 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", }; const usageEntry = (inputTokens: number) => ({ kind: "result" as const, ts: "2026-08-25T18:00:01.000Z", text: "", inputTokens, outputTokens: 5, cachedTokens: 0, costUsd: 0, subtype: "paperclip_runner_usage", isError: false, errors: [], }); const renderRun = () => render( {}} issueStatus="in_progress" activeRun={run} />, ); const revealUsage = () => { const summary = container.querySelector( '[data-testid="task-chat-phase-summary"]', ); expect(summary).not.toBeNull(); if (summary?.getAttribute("aria-expanded") !== "true") { flushSync(() => summary!.click()); } }; nativeTranscriptState.transcriptByRun.set("native-run", [usageEntry(10)]); renderRun(); revealUsage(); expect(container.textContent).toContain("↑10"); nativeTranscriptState.transcriptByRun.set("native-run", [usageEntry(20)]); renderRun(); revealUsage(); expect(container.textContent).toContain("↑20"); expect(container.textContent).not.toContain("↑10"); }); it.each(DIRECT_ADAPTER_TYPES)( "keeps active %s output on the legacy live-tail surface", (adapterType) => { const runId = `active-${adapterType}`; transcriptState.transcriptByRun.set(runId, [ { kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: `Active ${adapterType} output`, }, ]); render( {}} issueStatus="in_progress" activeRun={{ id: runId, runtimeMode: "legacy", 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: "Direct agent", adapterType, }} />, ); expect( container.querySelector('[data-testid="task-chat-live-run-pill"]'), ).not.toBeNull(); expect( container.querySelector('[data-testid="task-chat-phase-interstitial"]') ?.textContent, ).toContain(`Active ${adapterType} output`); expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), ).toBeNull(); expect( container.querySelector('[data-testid="task-chat-queued-messages"]'), ).toBeNull(); }, ); it.each(DIRECT_ADAPTER_TYPES)( "keeps settled %s activity in the shared legacy turn", (adapterType) => { const runId = `settled-${adapterType}`; transcriptState.transcriptByRun.set(runId, [ { kind: "tool_call", ts: "2026-08-25T18:00:01.000Z", name: "Read", toolUseId: `${runId}-tool`, input: { file_path: "src/legacy.ts" }, }, { kind: "tool_result", ts: "2026-08-25T18:00:02.000Z", toolUseId: `${runId}-tool`, toolName: "Read", content: "legacy contents", isError: false, }, ]); render( {}} linkedRuns={[ { runId, runtimeMode: "legacy", status: "succeeded", agentId: "agent-1", agentName: "Direct agent", adapterType, createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", finishedAt: "2026-08-25T18:00:03.000Z", }, ]} />, ); expect( container.querySelector('[data-testid="task-chat-turn"]'), ).not.toBeNull(); expect( container.querySelector('[data-testid="task-chat-turn-summary"]') ?.textContent, ).toContain("Worked"); expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), ).toBeNull(); expect( container.querySelector('[data-testid="task-chat-final-response"]'), ).toBeNull(); }, ); it.each(DIRECT_ADAPTER_TYPES)( "keeps an empty active %s run on the legacy status surface", (adapterType) => { render( {}} issueStatus="in_progress" activeRun={{ id: `empty-${adapterType}`, runtimeMode: "legacy", status: "running", invocationSource: "issue", triggerDetail: null, currentStatusMessage: "Preparing direct runtime", startedAt: "2026-08-25T18:00:00.000Z", finishedAt: null, createdAt: "2026-08-25T18:00:00.000Z", agentId: "agent-1", agentName: "Direct agent", adapterType, }} />, ); const tail = container.querySelector( '[data-testid="task-chat-live-transcript"]', ); expect(tail?.textContent).toContain("Preparing direct runtime"); expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), ).toBeNull(); expect( container.querySelector('[data-testid="task-chat-composer-takeover"]'), ).toBeNull(); }, ); it.each(DIRECT_ADAPTER_TYPES)( "keeps durable questions usable for an active %s run without runner controls", (adapterType) => { render( {}} onSubmitInteractionAnswers={async () => {}} issueStatus="in_progress" activeRun={{ id: `question-run-${adapterType}`, runtimeMode: "legacy", 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: "Direct agent", adapterType, }} />, ); expect( container.querySelector('[data-testid="task-chat-composer-takeover"]') ?.textContent, ).toContain("Which legacy path should continue?"); expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), ).toBeNull(); expect( container.querySelector('[data-testid="task-chat-queued-messages"]'), ).toBeNull(); }, ); }); describe("TaskChatThread composer alignment", () => { it("matches the thread width at every breakpoint", () => { render( {}} />); const dock = container .querySelector('[data-testid="mock-editor"]') ?.closest("div.sticky") as HTMLElement | null; expect(dock?.className).toContain("w-full"); expect(dock?.className).toContain("max-w-(--tc-shell-max-w)"); expect(dock?.className).not.toContain("md:w-(--pct-80)"); }); }); describe("TaskChatThread no-live-execution-path recovery", () => { const noLivePathComment = { id: "comment-no-live-path", companyId: "company-1", issueId: "issue-1", authorType: "system" as const, authorAgentId: null, authorUserId: null, body: "Paperclip retried continuation, but it still has no live execution path.", presentation: { kind: "system_notice" as const, tone: "danger" as const, title: "No live execution path", detailsDefaultOpen: false, }, metadata: null, createdAt: new Date("2026-08-26T12:00:00.000Z"), updatedAt: new Date("2026-08-26T12:00:00.000Z"), }; it("offers Try again only while the task is blocked", async () => { const onTryAgain = vi.fn(); const props = { comments: [noLivePathComment], onAdd: async () => {}, onTryAgainNoLiveExecutionPath: onTryAgain, showComposer: false, }; render(); const tryAgain = container.querySelector( '[data-testid="task-chat-no-live-path-try-again"]', ); expect(tryAgain).not.toBeNull(); flushSync(() => tryAgain!.click()); await Promise.resolve(); expect(onTryAgain).toHaveBeenCalledTimes(1); render(); expect(container.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).toBeNull(); }); }); describe("TaskChatThread blocker links", () => { it("shows the direct and server-selected terminal blocker at the top and bottom", () => { const terminalBlocker = { id: "terminal-2", identifier: "PAP-777", title: "Actual work", status: "in_progress" as const, priority: "high" as const, assigneeAgentId: "agent-2", assigneeUserId: null, }; const directBlocker = { id: "direct-2", identifier: "PAP-600", title: "Waiting in review", status: "in_review" as const, priority: "medium" as const, assigneeAgentId: "agent-1", assigneeUserId: null, terminalBlockers: [terminalBlocker], }; render( {}} issueStatus="blocked" blockedBy={[ { id: "direct-1", identifier: "PAP-500", title: "Different dependency", status: "todo", priority: "low", assigneeAgentId: null, assigneeUserId: null, }, directBlocker, ]} blockerAttention={{ state: "needs_attention", reason: "attention_required", unresolvedBlockerCount: 2, coveredBlockerCount: 0, stalledBlockerCount: 0, attentionBlockerCount: 1, sampleBlockerIdentifier: "PAP-777", sampleStalledBlockerIdentifier: null, terminalBlockerIssueId: terminalBlocker.id, }} />, ); const notices = container.querySelectorAll( '[data-testid="task-chat-blocker-links"]', ); expect(notices).toHaveLength(2); expect(notices[0]?.getAttribute("data-placement")).toBe("top"); expect(notices[1]?.getAttribute("data-placement")).toBe("bottom"); for (const notice of notices) { expect(notice.textContent).toContain( "Blocked byPAP-600Waiting in review", ); expect(notice.textContent).toContain( "Ultimately blocked byPAP-777Actual work", ); expect(notice.querySelector('a[href="/issues/PAP-600"]')).not.toBeNull(); expect(notice.querySelector('a[href="/issues/PAP-777"]')).not.toBeNull(); } expect(container.textContent).not.toContain("Different dependency"); expect(container.textContent).not.toContain( "This task resumes automatically", ); }); it("shows the ordered live-work queue at the top and bottom", () => { const terminalBlocker = { id: "terminal-running", identifier: "PAP-17426", title: "Restore live alias projection", status: "in_progress" as const, priority: "high" as const, assigneeAgentId: "agent-3", assigneeUserId: null, }; render( {}} issueStatus="blocked" liveIssueIds={new Set(["direct-running", "terminal-running"])} blockerAttention={{ state: "covered", reason: "active_dependency", unresolvedBlockerCount: 2, coveredBlockerCount: 2, stalledBlockerCount: 0, attentionBlockerCount: 0, sampleBlockerIdentifier: "PAP-17426", sampleStalledBlockerIdentifier: null, blockingTreeLive: true, directBlockerIssueId: "direct-running", terminalBlockerIssueId: terminalBlocker.id, terminalBlocker, }} blockedBy={[ { id: "direct-queued", identifier: "PAP-17427", title: "Verify the completed projection", status: "todo", priority: "medium", assigneeAgentId: "agent-4", assigneeUserId: null, }, { id: "direct-running", identifier: "PAP-17425", title: "Verify the live projection", status: "in_progress", priority: "medium", assigneeAgentId: "agent-2", assigneeUserId: null, terminalBlockers: [terminalBlocker], }, { id: "direct-done", identifier: "PAP-17424", title: "Run the guarded cutover", status: "done", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, }, ]} />, ); const notices = container.querySelectorAll( '[data-testid="task-chat-live-work-links"]', ); expect(notices).toHaveLength(2); expect(notices[0]?.getAttribute("data-placement")).toBe("top"); expect(notices[1]?.getAttribute("data-placement")).toBe("bottom"); for (const notice of notices) { expect(notice.textContent).toContain("Waiting on live work"); 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( container.querySelector('[data-testid="task-chat-blocker-links"]'), ).toBeNull(); }); it("keeps the compact blocker rows when covered work is no longer live", () => { render( {}} issueStatus="blocked" liveIssueIds={new Set()} blockerAttention={{ state: "covered", reason: "active_dependency", unresolvedBlockerCount: 1, coveredBlockerCount: 1, stalledBlockerCount: 0, attentionBlockerCount: 0, sampleBlockerIdentifier: "PAP-500", sampleStalledBlockerIdentifier: null, blockingTreeLive: false, }} blockedBy={[ { id: "direct-1", identifier: "PAP-500", title: "Direct dependency", status: "todo", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, }, ]} />, ); expect( container.querySelector('[data-testid="task-chat-live-work-links"]'), ).toBeNull(); expect( container.querySelectorAll('[data-testid="task-chat-blocker-links"]'), ).toHaveLength(2); }); it("shows only the direct row when the blocker has no deeper unresolved leaf", () => { render( {}} issueStatus="blocked" blockedBy={[ { id: "direct-1", identifier: "PAP-500", title: "Direct dependency", status: "in_progress", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, }, ]} />, ); expect( container.querySelectorAll('[data-testid="task-chat-blocker-links"]'), ).toHaveLength(2); expect(container.textContent).toContain( "Blocked byPAP-500Direct dependency", ); expect(container.textContent).not.toContain("Ultimately blocked by"); }); it("keeps a server-selected intermediate blocker on its direct chain", () => { const selectedIntermediate = { id: "intermediate-2", identifier: "PAP-650", title: "Stalled intermediate review", }; const selectedDirect = { id: "direct-2", identifier: "PAP-600", title: "Selected dependency", status: "blocked" as const, priority: "medium" as const, assigneeAgentId: "agent-1", assigneeUserId: null, terminalBlockers: [ { id: "leaf-2", identifier: "PAP-700", title: "Deeper structural leaf", status: "todo" as const, priority: "medium" as const, assigneeAgentId: "agent-2", assigneeUserId: null, }, ], }; render( {}} issueStatus="blocked" blockedBy={[ { id: "direct-1", identifier: "PAP-500", title: "Unrelated dependency", status: "todo", priority: "low", assigneeAgentId: null, assigneeUserId: null, }, selectedDirect, ]} blockerAttention={{ state: "stalled", reason: "stalled_review", unresolvedBlockerCount: 2, coveredBlockerCount: 0, stalledBlockerCount: 1, attentionBlockerCount: 1, sampleBlockerIdentifier: "PAP-650", sampleStalledBlockerIdentifier: "PAP-650", directBlockerIssueId: selectedDirect.id, terminalBlockerIssueId: selectedIntermediate.id, terminalBlocker: selectedIntermediate, }} />, ); for (const notice of container.querySelectorAll( '[data-testid="task-chat-blocker-links"]', )) { expect(notice.textContent).toContain( "Blocked byPAP-600Selected dependency", ); expect(notice.textContent).toContain( "Ultimately blocked byPAP-650Stalled intermediate review", ); } expect(container.textContent).not.toContain("Unrelated dependency"); expect(container.textContent).not.toContain("Deeper structural leaf"); }); it("auto-follows the new bottom blocker row when a pinned thread becomes blocked", () => { const comment = { id: "comment-1", companyId: "company-1", issueId: "issue-1", authorType: "user" as const, authorAgentId: null, authorUserId: "user-1", body: "Waiting for the dependency.", presentation: null, metadata: null, createdAt: new Date("2026-08-15T12:00:00.000Z"), updatedAt: new Date("2026-08-15T12:00:00.000Z"), }; const directBlocker = { id: "direct-1", identifier: "PAP-500", title: "Direct dependency", status: "in_progress" as const, priority: "medium" as const, assigneeAgentId: "agent-1", assigneeUserId: null, }; const baseProps = { comments: [comment], onAdd: async () => {}, blockedBy: [directBlocker], }; render(); const scroller = container.querySelector( '[data-testid="task-chat-scroller"]', )!; fakeScrollGeometry(scroller); render(); expect(scroller.scrollTop).toBe(scroller.scrollHeight); }); it("does not show blocker rows outside the blocked state", () => { render( {}} issueStatus="in_progress" blockedBy={[ { id: "direct-1", identifier: "PAP-500", title: "Direct dependency", status: "in_progress", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, }, ]} />, ); expect( container.querySelector('[data-testid="task-chat-blocker-links"]'), ).toBeNull(); }); }); describe("TaskChatThread queued message actions", () => { it("interrupts the exact run that a persisted queued message is waiting behind", () => { const onInterruptQueued = vi.fn(async () => {}); const queuedComment = { id: "comment-queued", companyId: "company-1", issueId: "issue-1", authorType: "user" as const, authorAgentId: null, authorUserId: "user-1", body: "Use the latest requirements instead.", presentation: null, metadata: null, queueState: "queued" as const, queueTargetRunId: "run-active", createdAt: new Date("2026-08-14T12:00:00.000Z"), updatedAt: new Date("2026-08-14T12:00:00.000Z"), }; render( {}} onInterruptQueued={onInterruptQueued} />, ); const interrupt = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Interrupt", ); expect(container.textContent).toContain("Queued"); expect(interrupt).not.toBeUndefined(); flushSync(() => interrupt!.click()); expect(onInterruptQueued).toHaveBeenCalledOnce(); expect(onInterruptQueued).toHaveBeenCalledWith("run-active"); }); it("disables the action while the queued run is being interrupted", () => { render( {}} onInterruptQueued={async () => {}} interruptingQueuedRunId="run-active" />, ); const interrupting = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Interrupting…", ); expect(interrupting).not.toBeUndefined(); expect(interrupting?.disabled).toBe(true); }); }); describe("TaskChatThread Paperclip Runner queue", () => { const queuedComment = { id: "queued-prp-1", companyId: "company-1", issueId: "issue-1", authorType: "user" as const, authorAgentId: null, authorUserId: "user-1", body: "Render this queued message exactly once.", presentation: null, metadata: null, createdAt: new Date("2026-08-22T15:00:00.000Z"), updatedAt: new Date("2026-08-22T15:00:00.000Z"), }; const queue: IssueQueuedCommentQueue = { issueId: "issue-1", queueId: "wake-1", state: "deferred", targetRunId: "run-1", revision: "revision-1", protocol: "paperclip_runner_v1", steeringDisposition: "available", entries: [ { comment: queuedComment, position: 0, canEdit: true, canDiscard: true }, ], }; function occurrenceCount(text: string) { return container.textContent?.split(text).length! - 1; } it("suppresses the transcript echo until the queued entry is consumed", async () => { const props = { comments: [queuedComment], onAdd: async () => {}, queuedCommentQueue: queue, onEditQueuedComment: async () => {}, onReorderQueuedComments: async () => {}, onSteerQueuedComment: async () => {}, onDiscardQueuedComment: async () => {}, }; render(); const stack = container.querySelector( '[data-testid="task-chat-composer-stack"]', ); const queuePane = container.querySelector( '[data-testid="task-chat-queued-messages"]', ); expect(queuePane?.parentElement).toBe(stack); expect(stack?.classList).not.toContain("gap-2"); expect( container.querySelector( '[data-testid="task-chat-queued-message-queued-prp-1"]', ), ).not.toBeNull(); expect(occurrenceCount(queuedComment.body)).toBe(1); await act(async () => { render( , ); await Promise.resolve(); }); expect( container.querySelector( '[data-testid="task-chat-queued-message-queued-prp-1"]', ), ).toBeNull(); expect(occurrenceCount(queuedComment.body)).toBe(1); }); }); describe("TaskChatThread mobile composer dock (PAP-495)", () => { it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => { sidebarState.isMobile = true; render( {}} draftKey="task-chat-draft:issue-mobile" />, ); const dock = container .querySelector('[data-testid="mock-editor"]') ?.closest("div.sticky") as HTMLElement | null; expect(dock).not.toBeNull(); // Bottom offset comes from --tc-composer-bottom (Layout raises it to the nav // height while the nav is on screen) — NOT the raw safe-area dock, which is // what let the nav occlude the action row before PAP-495. expect(dock?.className).toContain("bottom-(--tc-composer-bottom)"); expect(dock?.className).not.toContain("bottom-(--sz-calc-8)"); }); }); describe("TaskChatThread live transcript", () => { it("places the turn status island below composer accessories and hides it when the turn is terminal", () => { nativeTranscriptState.transcriptByRun.set("run-status", [ { kind: "provider_activity", ts: "2026-08-24T12:00:01.000Z", family: "plan", eventType: "plan.updated", status: "running", title: "Plan", payload: { planId: "turn-1", steps: [ { stepId: "one", body: "Inspect", status: "completed" }, { stepId: "two", body: "Build", status: "in_progress" }, ], }, }, { kind: "workspace_change", ts: "2026-08-24T12:00:02.000Z", changeSetId: "turn-1:workspace", revision: 1, source: "harness_reported", complete: false, files: [ { path: "ui/src/App.tsx", operation: "modify", previousPath: null, additions: 4, deletions: 1, binary: false, diff: null, }, ], totals: { files: 1, additions: 4, deletions: 1 }, patchArtifactRef: null, }, ]); const activeRun = { id: "run-status", status: "running" as const, invocationSource: "issue" as const, triggerDetail: null, startedAt: "2026-08-24T12:00:00.000Z", finishedAt: null, createdAt: "2026-08-24T12:00:00.000Z", agentId: "agent-1", agentName: "Codex", adapterType: "paperclip_runner", runtimeMode: "native" as const, }; render( {}} issueStatus="in_progress" activeRun={activeRun} composerAccessory={
Monitor
} />, ); const dock = container.querySelector( '[data-testid="task-chat-composer-dock"]', )!; const accessory = container.querySelector( '[data-testid="composer-accessory"]', )!; const island = container.querySelector( '[data-testid="task-chat-turn-status-island"]', )!; const stack = container.querySelector( '[data-testid="task-chat-composer-stack"]', )!; expect(island.textContent).toContain("Step 2 / 2"); expect(island.textContent).toContain("1 file changed"); expect(accessory.compareDocumentPosition(island)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); expect(island.compareDocumentPosition(stack)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); expect(dock.contains(island)).toBe(true); render( {}} issueStatus="in_progress" activeRun={{ ...activeRun, status: "succeeded", finishedAt: "2026-08-24T12:01:00.000Z", }} />, ); expect( container.querySelector('[data-testid="task-chat-turn-status-island"]'), ).toBeNull(); }); it("keeps an unlinked persisted runner reply hidden while the live turn still owns that response", () => { nativeTranscriptState.transcriptByRun.set("run-runner", [ { kind: "assistant", ts: "2026-08-21T15:44:20.000Z", text: "Completed the requested streaming test.", channel: "final", delta: true, }, ]); const comment = { id: "comment-runner", companyId: "company-1", issueId: "issue-1", authorType: "agent" as const, authorAgentId: "agent-1", authorUserId: null, body: "Completed the requested streaming test.", presentation: null, metadata: null, runId: null, createdAt: new Date("2026-08-21T15:44:22.000Z"), updatedAt: new Date("2026-08-21T15:44:22.000Z"), }; render( {}} issueStatus="in_progress" activeRun={{ id: "run-runner", status: "running", invocationSource: "issue", triggerDetail: null, startedAt: "2026-08-21T15:44:00.000Z", finishedAt: null, createdAt: "2026-08-21T15:44:00.000Z", agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", runtimeMode: "native", }} />, ); expect( container.textContent?.match(/Completed the requested streaming test\./g), ).toHaveLength(1); expect( container.querySelectorAll('[data-testid="task-chat-agent-bubble"]'), ).toHaveLength(1); expect( container.querySelector('[data-testid="task-chat-live-transcript"]'), ).not.toBeNull(); }); it("surfaces the live runtime status while no transcript has streamed yet", () => { // Sandbox runs spend their first minutes in preparation phases (config // seed, workspace sync) with zero transcript entries. The tail must show // the run's runtime-progress status instead of an opaque wait message. const baseRun = { id: "run-prep", status: "running" as const, invocationSource: "issue" as const, triggerDetail: null, startedAt: "2026-08-07T00:00:00.000Z", finishedAt: null, createdAt: "2026-08-07T00:00:00.000Z", agentId: "agent-1", agentName: "Coder", adapterType: "claude_local", }; render( {}} issueStatus="in_progress" activeRun={{ ...baseRun, currentStatusMessage: "Syncing workspace to environment", }} />, ); const tail = container.querySelector( '[data-testid="task-chat-live-transcript"]', ); expect(tail).not.toBeNull(); expect(tail!.textContent).toContain("Syncing workspace to environment"); expect(tail!.textContent).not.toContain("Waiting for transcript..."); // Without a runtime status, the generic wait message still shows. render( {}} issueStatus="in_progress" activeRun={{ ...baseRun, id: "run-prep-2" }} />, ); const tail2 = container.querySelector( '[data-testid="task-chat-live-transcript"]', ); expect(tail2!.textContent).toContain("Waiting for transcript..."); }); it("renders in-flight output through TaskChatLiveTail, dropping the debug plumbing (PAP-463 C1)", () => { // Interleave the exact noise the old RunTranscriptView tail surfaced (init // row, stdout/stderr/system dumps) with real content. Only the streamed // reply markdown and the tool row may reach the thread. transcriptState.transcriptByRun.set("run-1", [ { kind: "init", ts: "2026-08-07T00:00:00.000Z", model: "claude", sessionId: "sess-INITMARKER", }, { kind: "system", ts: "2026-08-07T00:00:00.000Z", text: "SYSTEMNOISE environment hint", }, { kind: "stdout", ts: "2026-08-07T00:00:00.000Z", text: "STDOUTNOISE raw json dump", }, { kind: "stderr", ts: "2026-08-07T00:00:00.000Z", text: "STDERRNOISE adapter timeout note", }, { kind: "assistant", ts: "2026-08-07T00:00:00.000Z", text: "Streaming through the shared renderer", }, { kind: "tool_call", ts: "2026-08-07T00:00:00.000Z", name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" }, }, ]); render( {}} issueStatus="in_progress" activeRun={{ id: "run-1", status: "running", invocationSource: "issue", triggerDetail: null, startedAt: "2026-08-07T00:00:00.000Z", finishedAt: null, createdAt: "2026-08-07T00:00:00.000Z", agentId: "agent-1", agentName: "Coder", adapterType: "codex_local", }} />, ); const tail = container.querySelector( '[data-testid="task-chat-live-transcript"]', ); expect(tail).not.toBeNull(); // Clean content survives: streamed reply markdown + compact phase summary. expect(tail!.textContent).toContain( "Streaming through the shared renderer", ); const phaseSummary = tail!.querySelector( '[data-testid="task-chat-phase-summary"]', ); expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true"); expect(tail!.textContent).toContain("src/app.ts"); // None of the debug plumbing reaches the thread. for (const noise of [ "INITMARKER", "SYSTEMNOISE", "STDOUTNOISE", "STDERRNOISE", ]) { expect(container.textContent).not.toContain(noise); } }); it("resolves a visible canonical input even while run adapter metadata is stale", async () => { transcriptState.transcriptByRun.set("run-input", [ { kind: "runtime_request", ts: "2026-08-23T20:00:00.000Z", requestId: "question-1", requestKind: "runtime", turnId: "turn-1", requestType: "input", status: "pending", prompt: "Codex needs your input.", choices: [], fields: [], questionSet: { schema: "paperclip.question_set.v1", questions: [ { id: "goal", prompt: "What should the server do?", required: true, answerMode: "single_select", options: [{ id: "api", label: "Starter API" }], }, ], }, }, ]); const resolveRuntimeRequest = vi .spyOn(heartbeatsApi, "resolveRuntimeRequest") .mockResolvedValue({} as never); render( {}} issueStatus="in_progress" activeRun={{ id: "run-input", status: "running", invocationSource: "issue", triggerDetail: null, startedAt: "2026-08-23T20:00:00.000Z", finishedAt: null, createdAt: "2026-08-23T20:00:00.000Z", agentId: "agent-1", agentName: "Runner", // Reproduces the lag that caused DOT-202: the transcript already has // a Paperclip request while the linked-run adapter classification is // still stale. adapterType: "codex_local", }} />, ); const option = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("Starter API"), ); await act(async () => option?.click()); const submit = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.trim() === "Submit answers", ); await act(async () => submit?.click()); expect(resolveRuntimeRequest).toHaveBeenCalledWith({ runId: "run-input", requestId: "question-1", turnId: "turn-1", requestKind: "runtime", resolution: { action: "submit", response: { schema: "paperclip.question_response.v1", answers: { goal: { selectedOptionIds: ["api"] } }, }, }, }); expect(container.textContent).not.toContain("no longer attached"); }); it("keeps the transcript mounted through run settle until the settled turn renders (PAP-462 B4)", () => { transcriptState.transcriptByRun.set("run-1", [ { kind: "assistant", ts: "2026-08-07T00:00:00.000Z", text: "Last words before the run stops", }, ]); const liveProps = { comments: [] as never[], onAdd: async () => {}, issueStatus: "in_progress", activeRun: { id: "run-1", status: "running", invocationSource: "issue" as const, triggerDetail: null, startedAt: "2026-08-07T00:00:00.000Z", finishedAt: null, createdAt: "2026-08-07T00:00:00.000Z", agentId: "agent-1", agentName: "Coder", adapterType: "codex_local", }, }; render(); expect( container.querySelector('[data-testid="task-chat-live-transcript"]'), ).not.toBeNull(); // The run settles: the issue goes terminal and the run reports succeeded, so // `liveRun` flips to null — but no reply comment has landed yet. The // transcript must NOT vanish; it stays mounted (now as a settled tail) until // its settled turn/comment renders. render( , ); expect( container.querySelector('[data-testid="task-chat-live-transcript"]'), ).not.toBeNull(); expect(container.textContent).toContain("Last words before the run stops"); // The pill has settled to its "Worked" state rather than flipping back to a // spinner while it waits for the reply comment. expect(container.textContent).toContain("Worked"); }); });