diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 0b0e9934f11..06e475bd8fa 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -13,7 +13,7 @@ import { ThemeProvider } from "@/context/ThemeContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ConnectivityProvider, createConnectivityStore } from "@/lib/connectivity"; -import { TaskChatThread } from "./TaskChatThread"; +import { INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS, TaskChatThread } from "./TaskChatThread"; import { IssueChatThread } from "./IssueChatThread"; import type { Agent, @@ -881,6 +881,162 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti expect((runtimeMode === "native" ? transcriptHookRuns.native : transcriptHookRuns.legacy).at(-1)).toMatchObject([{ id: "saved-run" }]); }); + const transcriptHookCalls = () => runtimeMode === "native" ? transcriptHookRuns.native : transcriptHookRuns.legacy; + const historyLoading = () => container.querySelector('[data-testid="task-chat-history-loading"]') !== null; + // React commits the budget's state update when act() exits; the reveal then + // needs a few more (fake) animation frames to confirm stable geometry. + const passTranscriptBudget = async () => { + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + for (let frame = 0; frame < 3; frame++) await act(async () => { await vi.advanceTimersByTimeAsync(50); }); + }; + + it("reveals saved messages after the transcript budget and keeps a slow settled run behind a fixed control", async () => { + vi.useFakeTimers(); + try { + const props = { + issueId: "slow-failed-run", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + linkedRuns: [{ ...retryRun, runId: "failed-run", status: "failed", startedAt: retryRun.createdAt }], + comments: createLongThreadComments(), + }; + render(); + expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "failed-run" }]); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); }); + expect(historyLoading()).toBe(true); + await passTranscriptBudget(); + expect(historyLoading()).toBe(false); + expect(container.textContent).toContain("Thread message 1"); + expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull(); + expect(container.textContent).not.toContain("Some task history is still loading."); + // The late transcript is no longer requested, so it cannot insert rows + // above the reader; the output stays one click away. + expect(transcriptHookCalls().at(-1)).toEqual([]); + const output = [...container.querySelectorAll("button")].find(button => button.textContent === "More run output"); + expect(output).toBeDefined(); + act(() => output!.click()); + expect(props.onExpandHistory).toHaveBeenCalledOnce(); + render(); + expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "failed-run" }]); + } finally { vi.useRealTimers(); } + }); + + it("reveals saved messages after the transcript budget while a live run keeps streaming", async () => { + vi.useFakeTimers(); + try { + const props = { + issueId: "slow-live-run", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + linkedRuns: [{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }], + comments: createLongThreadComments(), + }; + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); }); + expect(historyLoading()).toBe(true); + await passTranscriptBudget(); + expect(historyLoading()).toBe(false); + expect(container.textContent).toContain("Thread message 1"); + expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "live-run" }]); + // Finishing after the reveal keeps the visible transcript in place. + render(); + expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "live-run" }]); + expect(container.textContent).not.toContain("More run output"); + } finally { vi.useRealTimers(); } + }); + + it("keeps waiting for a settled transcript that would insert output when no compact control exists", async () => { + vi.useFakeTimers(); + try { + render( {}} comments={createLongThreadComments()} + linkedRuns={[{ ...retryRun, runId: "failed-run", status: "failed", startedAt: retryRun.createdAt }]} />); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); }); + await passTranscriptBudget(); + expect(historyLoading()).toBe(true); + expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "failed-run" }]); + } finally { vi.useRealTimers(); } + }); + + it("defers every slow settled run of a large compact task at the budget", async () => { + vi.useFakeTimers(); + try { + const linkedRuns = Array.from({ length: 300 }, (_, index) => ({ + ...retryRun, runId: `failed-run-${index}`, status: "failed", + createdAt: new Date(Date.parse(retryRun.createdAt) + index * 60_000).toISOString(), + startedAt: new Date(Date.parse(retryRun.createdAt) + index * 60_000).toISOString(), + })); + render( {}} onExpandHistory={vi.fn()} + comments={createLongThreadComments()} linkedRuns={linkedRuns} />); + expect(transcriptHookCalls().at(-1)?.length).toBeGreaterThan(0); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); }); + expect(historyLoading()).toBe(true); + await passTranscriptBudget(); + expect(historyLoading()).toBe(false); + expect(transcriptHookCalls().at(-1)).toEqual([]); + } finally { vi.useRealTimers(); } + }); + + it("keeps the transcript budget when another transcript hydrates first", async () => { + vi.useFakeTimers(); + try { + const props = { + issueId: "two-live-runs", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + linkedRuns: [ + { ...retryRun, runId: "fast-run", status: "running", startedAt: retryRun.createdAt }, + { ...retryRun, runId: "slow-run", status: "running", startedAt: retryRun.createdAt }, + ], + comments: createLongThreadComments(), + }; + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 100); }); + transcriptState.hydratedRunIds = new Set(["fast-run"]); + nativeTranscriptState.hydratedRunIds = new Set(["fast-run"]); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(99); }); + expect(historyLoading()).toBe(true); + // The budget still ends at its first deadline. It does not restart + // because the fast transcript finished. + await passTranscriptBudget(); + expect(historyLoading()).toBe(false); + expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "fast-run" }, { id: "slow-run" }]); + } finally { vi.useRealTimers(); } + }); + + it("keeps waiting for a live transcript that splits around a steering message", async () => { + vi.useFakeTimers(); + try { + const steering = { + ...createLongThreadComments()[0], id: "steering-comment", body: "Change course now.", + steeredIntoRunId: "live-run", conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"), + createdAt: new Date("2026-08-25T18:00:02.000Z"), updatedAt: new Date("2026-08-25T18:00:02.000Z"), + }; + render( {}} onExpandHistory={vi.fn()} + comments={[...createLongThreadComments(), steering]} + linkedRuns={[{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }]} />); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); }); + await passTranscriptBudget(); + // Its pre-steer output enters the main chronology above the steering + // message, so revealing before it loads would move visible messages. + expect(historyLoading()).toBe(true); + expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "live-run" }]); + } finally { vi.useRealTimers(); } + }); + + it("does not start the transcript budget while saved history is still loading", async () => { + vi.useFakeTimers(); + try { + const props = { + issueId: "saved-history-first", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), + linkedRuns: [{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }], + comments: createLongThreadComments(), + }; + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS * 3); }); + expect(historyLoading()).toBe(true); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); }); + expect(historyLoading()).toBe(true); + await passTranscriptBudget(); + expect(historyLoading()).toBe(false); + } finally { vi.useRealTimers(); } + }); + it("retains a live transcript after the saved reply arrives", () => { const props = { issueId: "live-to-saved", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(), linkedRuns: [{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }], comments: createLongThreadComments() }; diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index c802274f646..7301bc9ee22 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -55,6 +55,7 @@ import { taskChatStatusRelevance, withoutHistoricalRunStatus, } from "@/components/task-chat/status-relevance"; +import { selectBudgetedTranscriptRuns } from "@/components/task-chat/transcript-reveal-budget"; import { assembleThreadItems, attachSettledTurns, @@ -267,6 +268,12 @@ function isRunnerResponseComment(params: { // well within this as soon as the settled turn/comment lands. const SETTLING_TAIL_MAX_MS = 15_000; const INITIAL_HISTORY_REVEAL_TIMEOUT_MS = 15_000; +// Once saved history is ready, a run transcript may hold the first reveal only +// this long. A slow run-log read must not hide saved messages and the composer. +// Live runs then stream into the tail; settled compact runs keep a fixed +// "More run output" control instead of inserting output after the reveal. +export const INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS = 600; +const EMPTY_RUN_IDS: ReadonlySet = new Set(); const INITIAL_FONT_REVEAL_TIMEOUT_MS = 3000; const EMPTY_LIVE_ISSUE_IDS: ReadonlySet = new Set(); const LONG_THREAD_BLOCKER_REPEAT_COUNT = 4; @@ -1005,9 +1012,21 @@ export function TaskChatThread(props: TaskChatThreadProps) { if (requestedTranscripts.current.issueId !== issueId) { requestedTranscripts.current = { issueId, ids: new Set() }; } + // Runs whose transcript outlasted INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS on the + // first open of this issue. Latched per issue so the decision never flips. + // `deferred` holds only runs that had already settled at that moment, so a + // live transcript that finishes later stays visible. + const [slowTranscripts, setSlowTranscripts] = useState<{ + issueId: typeof issueId; ids: ReadonlySet; deferred: ReadonlySet; + } | null>(null); + const slowTranscriptsForIssue = slowTranscripts && slowTranscripts.issueId === issueId ? slowTranscripts : null; + const slowTranscriptRunIds = slowTranscriptsForIssue?.ids ?? EMPTY_RUN_IDS; + const slowSettledTranscriptRunIds = slowTranscriptsForIssue?.deferred ?? EMPTY_RUN_IDS; + const compactTranscriptDeferral = Boolean(props.compactHistory && !conversationMode && props.onExpandHistory && !scrollNavigation.hash); const deferredTranscriptRunIds = useMemo(() => { const deferred = new Set(); - if (!props.compactHistory || conversationMode || !props.onExpandHistory || scrollNavigation.hash) return deferred; + if (!compactTranscriptDeferral) return deferred; + for (const run of runs) if (slowSettledTranscriptRunIds.has(run.id)) deferred.add(run.id); for (const run of runs) { if (requestedTranscripts.current.ids.has(run.id) || run.status !== "succeeded" || !run.createdAt || toMs(run.createdAt) > openedAt) continue; @@ -1024,7 +1043,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { )) deferred.add(run.id); } return deferred; - }, [issueId, runs, projectedComments, linkedRunMetaById, interactions, props.compactHistory, conversationMode, props.onExpandHistory, scrollNavigation.hash, openedAt]); + }, [issueId, runs, projectedComments, linkedRunMetaById, interactions, compactTranscriptDeferral, slowSettledTranscriptRunIds, openedAt]); const transcriptRuns = useMemo(() => runs.filter(run => !deferredTranscriptRunIds.has(run.id) && !(props.compactHistory && !conversationMode && props.onExpandHistory && !scrollNavigation.hash && @@ -3185,7 +3204,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { initialCommentWindow.current.oldestAt ); }); - const transcriptHistoryPending = initialRuns.some((run) => { + const pendingTranscriptRuns = initialRuns.filter((run) => { + if (slowTranscriptRunIds.has(run.id)) return false; // A scheduled retry has not started and has no log to hydrate yet. if (run.status === "scheduled_retry") return false; if ( @@ -3204,6 +3224,20 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? !hydratedLogRunIds.has(run.id) : logsAreInitiallyHydrating; }); + const transcriptHistoryPending = pendingTranscriptRuns.length > 0; + // Only these may stop holding the reveal after the budget. See + // selectBudgetedTranscriptRuns for the live, steered and settled rules. + const budgetedTranscriptRuns = selectBudgetedTranscriptRuns(pendingTranscriptRuns, { + compactDeferral: compactTranscriptDeferral, + hasTimelineAnchors: (run) => ((isNativePaperclipRunnerRun(run) + ? steeringAnchorsByRun.get(run.id) + : legacyTimelineAnchorsByRun.get(run.id))?.length ?? 0) > 0, + }); + // The budget timer reads the runs that are still pending when it fires, so + // a transcript that hydrates early does not restart the budget. + const budgetedTranscriptRunsRef = useRef(budgetedTranscriptRuns); + budgetedTranscriptRunsRef.current = budgetedTranscriptRuns; + const hasBudgetedTranscriptRuns = budgetedTranscriptRuns.length > 0; // Hydrating a run inserts activity around its saved reply and can move the // latest viewport substantially. Compact opens hydrate only their bounded // recent window, but its saved cards and latest turn must arrive together so @@ -3213,7 +3247,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { (decisionReceipts.enabled && decisionReceipts.isPending) || (taskHandoffs.enabled && taskHandoffs.isPending) )); - const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending || chatMetadataPending; + const savedHistoryPending = initialHistoryPending || planLoading || chatMetadataPending; + const historyPending = savedHistoryPending || transcriptHistoryPending; useEffect(() => { recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending }); }, [issueId, planLoading, transcriptHistoryPending, historyPending]); @@ -3233,6 +3268,32 @@ export function TaskChatThread(props: TaskChatThreadProps) { useEffect(() => { if (revealCachedHeader) setRevealedHistoryKey(historyKey); }, [revealCachedHeader, historyKey]); + // The budget is one deadline per history key. It starts the first time + // saved history is ready, and later changes to the pending runs keep it. + const transcriptRevealDeadline = useRef<{ historyKey: typeof historyKey; at: number } | null>(null); + useEffect(() => { + if (historyRevealed || savedHistoryPending) return; + if (transcriptRevealDeadline.current?.historyKey !== historyKey) { + transcriptRevealDeadline.current = { historyKey, at: Date.now() + INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS }; + } + if (!hasBudgetedTranscriptRuns) return; + const timer = window.setTimeout(() => { + const expired = budgetedTranscriptRunsRef.current; + if (expired.length === 0) return; + setSlowTranscripts((current) => { + const previous = current && current.issueId === issueId ? current : null; + return { + issueId, + ids: new Set([...(previous?.ids ?? []), ...expired.map((run) => run.runId)]), + deferred: new Set([ + ...(previous?.deferred ?? []), + ...expired.filter((run) => run.settled).map((run) => run.runId), + ]), + }; + }); + }, Math.max(0, transcriptRevealDeadline.current.at - Date.now())); + return () => window.clearTimeout(timer); + }, [historyRevealed, savedHistoryPending, hasBudgetedTranscriptRuns, historyKey, issueId]); const historyShellRef = useRef(null); const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null); const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey; diff --git a/ui/src/components/task-chat/transcript-reveal-budget.test.ts b/ui/src/components/task-chat/transcript-reveal-budget.test.ts new file mode 100644 index 00000000000..6649094fa74 --- /dev/null +++ b/ui/src/components/task-chat/transcript-reveal-budget.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { selectBudgetedTranscriptRuns } from "./transcript-reveal-budget"; + +const STATUSES = ["running", "queued", "failed", "succeeded", "cancelled"] as const; + +function createRuns(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `run-${index}`, + status: STATUSES[index % STATUSES.length]!, + })); +} + +describe("selectBudgetedTranscriptRuns", () => { + it("budgets live runs without anchors and settled runs only in compact history", () => { + const runs = [ + { id: "live", status: "running" }, + { id: "steered", status: "running" }, + { id: "failed", status: "failed" }, + ]; + const hasTimelineAnchors = (run: { id: string }) => run.id === "steered"; + expect(selectBudgetedTranscriptRuns(runs, { compactDeferral: true, hasTimelineAnchors })).toEqual([ + { runId: "live", settled: false }, + { runId: "failed", settled: true }, + ]); + expect(selectBudgetedTranscriptRuns(runs, { compactDeferral: false, hasTimelineAnchors })).toEqual([ + { runId: "live", settled: false }, + ]); + }); + + it("does one anchor lookup for each live run in a large task", () => { + const runs = createRuns(5_000); + const liveRunCount = runs.filter((run) => run.status === "running" || run.status === "queued").length; + let lookups = 0; + const hasTimelineAnchors = (run: { id: string }) => { + lookups += 1; + if (lookups > liveRunCount) throw new Error(`anchor lookups exceeded ${liveRunCount}`); + return Number(run.id.slice("run-".length)) % 2 === 0; + }; + const budgeted = selectBudgetedTranscriptRuns(runs, { compactDeferral: true, hasTimelineAnchors }); + expect(lookups).toBe(liveRunCount); + expect(budgeted.filter((run) => run.settled)).toHaveLength(runs.length - liveRunCount); + expect(budgeted.filter((run) => !run.settled)).toHaveLength(liveRunCount / 2); + }); +}); diff --git a/ui/src/components/task-chat/transcript-reveal-budget.ts b/ui/src/components/task-chat/transcript-reveal-budget.ts new file mode 100644 index 00000000000..e5361f21bb4 --- /dev/null +++ b/ui/src/components/task-chat/transcript-reveal-budget.ts @@ -0,0 +1,38 @@ +import { isTerminalRunStatus } from "./transcript-adapter"; + +export interface TranscriptBudgetRun { + id: string; + status: string; +} + +export interface BudgetedTranscriptRun { + runId: string; + settled: boolean; +} + +/** + * Select the pending transcript runs that may stop holding the first reveal + * when the transcript budget expires. The work is one pass over the runs, with + * one anchor lookup for each live run. + * + * - A live run streams into the tail, so it may stop holding the reveal, + * unless it has steering or timeline anchors. Its pre-anchor segments enter + * the main chronology above the anchored messages. + * - A settled run may stop holding the reveal only when compact history can + * show it behind the fixed "More run output" control. + */ +export function selectBudgetedTranscriptRuns( + pendingRuns: readonly Run[], + options: { + compactDeferral: boolean; + hasTimelineAnchors: (run: Run) => boolean; + }, +): BudgetedTranscriptRun[] { + const budgeted: BudgetedTranscriptRun[] = []; + for (const run of pendingRuns) { + const settled = isTerminalRunStatus(run.status); + if (settled ? !options.compactDeferral : options.hasTimelineAnchors(run)) continue; + budgeted.push({ runId: run.id, settled }); + } + return budgeted; +}