From 3e8744fa91f4c3f0bfddfa7ede8c2353fc0ad2d9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:06:42 -0600 Subject: [PATCH] perf(task-chat): stop a slow run transcript from hiding the chat (#20277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The task chat shows saved messages, run transcripts and the composer > - The chat hides the messages and the composer until every run transcript loads. This prevents layout movement > - On staging, one task's run-log read took 11–12 s. Saved messages and the composer stayed hidden for 13.9 s after a click, and for 14.6 s again on revisit > - This pull request limits how long a transcript can hold the first reveal after saved history is ready > - The benefit is that a slow run log no longer hides the conversation, and the layout contract stays in place ## Linked Issues or Issue Description **Describe the bug** When a task has a run whose transcript loads slowly, the whole chat stays behind the loading skeleton. The composer stays hidden too. On staging, a failed run with no saved reply needed an 11–12 s object-store log read. The chat appeared after 13.9 s. Saved messages that loaded in about 2 s were not visible during that time. **Steps to reproduce** 1. Open a task whose latest run failed before it saved a reply, and whose run log is large or slow to read. 2. Watch the chat area after the click. **Expected behavior** Saved messages and the composer appear when saved history is ready. Run output must not move the messages that are already visible. **Actual behavior** The chat stays hidden until the run log read finishes, or until the 15 s reveal timeout. Refs #20276 (the measurement harness and baseline plan). ## What Changed - `TaskChatThread` now waits at most `INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS` (600 ms) for run transcripts. The time starts when saved history, the plan and chat metadata are ready. The budget is one deadline. A transcript that loads early does not restart it. - When the budget expires, the slow runs are latched for that task, so the decision does not change later: - **Live runs** stop holding the reveal. Their output continues to stream into the live tail at the bottom of the chat. - **Settled runs in compact history** stop loading. They use the existing fixed **More run output** control, so late output cannot insert rows above the reader. Selecting the control loads the full history, as it does today. - **Settled runs in other modes** (expanded history, conversation mode, a deep link to a comment) still wait, as before. In those modes, output would insert rows around saved replies. - **Live runs with a steering message or other timeline anchors** still wait, as before. Their output before the anchor goes into the main conversation above the anchored message. - A live run that finishes after the reveal keeps its visible transcript. Only runs that were already settled when the budget expired are deferred. ## Verification - `cd ui && npx vitest run src/components/TaskChatThread.test.tsx`. New tests for legacy and native runs: - A slow failed run in compact history: the chat reveals at the budget, the transcript is no longer requested, **More run output** appears, and expanding loads the run. - A slow live run: the chat reveals at the budget, the transcript keeps streaming, and the transcript stays after the run finishes. - A slow settled run without the compact control: the chat still waits. - The budget does not start while saved history is pending. - When one of two transcripts loads before the budget ends, the chat still reveals at the first deadline. - A slow live run with a steering message: the chat still waits. - A compact task with 300 slow failed runs: the chat reveals at the budget and defers every run. - `cd ui && npx vitest run src/components/task-chat/transcript-reveal-budget.test.ts`. The budget selection is one pass. A 5,000-run test counts anchor lookups and throws above one lookup for each live run. - Red check: with the budget timer disabled, the 6 new positive tests fail. With the change, they pass. - `npx vitest run src/components/TaskChatThread.test.tsx src/components/task-chat src/pages/IssueDetail`: 64 files and 1,609 tests pass. - `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/task-loading-frame.spec.ts`: 7 of 7 pass. This covers a stable loading frame on desktop and mobile, composer geometry, the reading position, a stalled web font, and the Classic and mobile preference paths. - After deploy, staging is measured with the harness from #20276. The finish condition is that a slow run-log task opens in the same time as other tasks. ## Risks - After a slow transcript is deferred, a settled failed run in compact history shows its failure message and **More run output**, not its last transcript lines. The output is one click away. The server-side log read fix is a separate follow-up. - A live run's output can appear in the tail after the reveal. This is the normal streaming behaviour of the tail. - A 600 ms budget is a trade-off. Fast transcripts still arrive in the first frame, and slow ones no longer hide the chat. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code with tool use (shell, browser automation, file editing). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- ui/src/components/TaskChatThread.test.tsx | 158 +++++++++++++++++- ui/src/components/TaskChatThread.tsx | 69 +++++++- .../transcript-reveal-budget.test.ts | 44 +++++ .../task-chat/transcript-reveal-budget.ts | 38 +++++ 4 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 ui/src/components/task-chat/transcript-reveal-budget.test.ts create mode 100644 ui/src/components/task-chat/transcript-reveal-budget.ts 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; +}