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;
+}