perf(task-chat): stop a slow run transcript from hiding the chat (#20277)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-09 09:06:42 -06:00
1 parent ac0d0374f2
commit 3e8744fa91
4 files changed
+304 -5

No files matched your search

+157 -1
View File
@@ -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(<TaskChatThread {...props} />);
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(<TaskChatThread {...props} compactHistory={false} />);
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(<TaskChatThread {...props} />);
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(<TaskChatThread {...props} linkedRuns={[{ ...props.linkedRuns[0], status: "succeeded" }]} />);
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(<TaskChatThread issueId="settled-full-history" onAdd={async () => {}} 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(<TaskChatThread issueId="large-slow-task" compactHistory onAdd={async () => {}} 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(<TaskChatThread {...props} />);
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(<TaskChatThread {...props} />);
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(<TaskChatThread issueId="steered-live-run" compactHistory onAdd={async () => {}} 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(<TaskChatThread {...props} initialHistoryPending />);
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS * 3); });
expect(historyLoading()).toBe(true);
render(<TaskChatThread {...props} />);
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() };
+65 -4
View File
@@ -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<string> = new Set<string>();
const INITIAL_FONT_REVEAL_TIMEOUT_MS = 3000;
const EMPTY_LIVE_ISSUE_IDS: ReadonlySet<string> = new Set<string>();
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<string>() };
}
// 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<string>; deferred: ReadonlySet<string>;
} | 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<string>();
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<HTMLDivElement>(null);
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null);
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey;
@@ -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);
});
});
@@ -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<Run extends TranscriptBudgetRun>(
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;
}