diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index ef366dc33d409..639b0f559c96a 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { applyReviewDiffMetadata, buildReviewParsedDiff, + getReviewSectionIdForCheckpoint, + parseReviewRangeSectionId, resolveReviewSelectedSectionId, type ReviewSectionItem, } from "./reviewModel"; @@ -304,3 +306,13 @@ describe("applyReviewDiffMetadata", () => { expect(applyReviewDiffMetadata(parsed, null)).toEqual(parsed); }); }); + +describe("review range sections", () => { + it("round-trips a response group's turn range through its section id", () => { + const id = getReviewSectionIdForCheckpoint({ checkpointTurnCount: 9, rangeFromTurnCount: 4 }); + expect(id).toBe("turns:4:9"); + expect(parseReviewRangeSectionId(id)).toEqual({ fromTurnCount: 4, toTurnCount: 9 }); + expect(getReviewSectionIdForCheckpoint({ checkpointTurnCount: 9 })).toBe("turn:9"); + expect(parseReviewRangeSectionId("turn:9")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index bc0e23b336582..32df3fee6fac2 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -103,7 +103,9 @@ export type ReviewParsedDiff = }; function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { - return `Turn ${checkpoint.checkpointTurnCount}`; + return checkpoint.rangeFromTurnCount === undefined + ? `Turn ${checkpoint.checkpointTurnCount}` + : `Turns ${checkpoint.rangeFromTurnCount + 1}–${checkpoint.checkpointTurnCount}`; } function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { @@ -484,9 +486,19 @@ function mapRenderableFile( } export function getReviewSectionIdForCheckpoint( - checkpoint: Pick, + checkpoint: Pick, ): string { - return `turn:${checkpoint.checkpointTurnCount}`; + return checkpoint.rangeFromTurnCount === undefined + ? `turn:${checkpoint.checkpointTurnCount}` + : `turns:${checkpoint.rangeFromTurnCount}:${checkpoint.checkpointTurnCount}`; +} + +/** The turn range a response group's diff section covers, from its section id. */ +export function parseReviewRangeSectionId( + sectionId: string | null, +): { readonly fromTurnCount: number; readonly toTurnCount: number } | null { + const match = sectionId === null ? null : /^turns:(\d+):(\d+)$/.exec(sectionId); + return match ? { fromTurnCount: Number(match[1]), toTurnCount: Number(match[2]) } : null; } export function getReadyReviewCheckpoints( @@ -500,13 +512,18 @@ export function getReadyReviewCheckpoints( } export function reviewTurnDiffsBySectionId( - checkpoints: ReadonlyArray>, + checkpoints: ReadonlyArray< + Pick + >, diffByTurnCount: Readonly>, ): Readonly> { + // Only single turns are cached; a range section waits for its live query. return Object.fromEntries( checkpoints.map((checkpoint) => [ getReviewSectionIdForCheckpoint(checkpoint), - diffByTurnCount[checkpoint.checkpointTurnCount], + checkpoint.rangeFromTurnCount === undefined + ? diffByTurnCount[checkpoint.checkpointTurnCount] + : undefined, ]), ); } diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 4fd506732e5d3..5f3b8f3946985 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { Atom } from "effect/reactivity"; +import { mergeResponseGroupCheckpoints } from "@t3tools/client-runtime/state/response-groups"; import { deriveThreadCheckpointSummaries } from "@t3tools/client-runtime/state/thread-checkpoints"; import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts"; @@ -17,6 +18,7 @@ import { buildReviewSectionItems, getReadyReviewCheckpoints, getReviewSectionIdForCheckpoint, + parseReviewRangeSectionId, resolveReviewSelectedSectionId, reviewTurnDiffsBySectionId, type ReviewSectionItem, @@ -73,8 +75,24 @@ export function useReviewSections(input: { : NO_TURN_DIFFS_ATOM, ); + const checkpoints = + selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection); + // A response group's stat opens one section spanning its turns; it exists + // only while selected. + const selectedRange = parseReviewRangeSectionId(reviewCache.selectedSectionId); + const range = selectedRange + ? mergeResponseGroupCheckpoints( + checkpoints.filter( + (checkpoint) => + checkpoint.checkpointTurnCount > selectedRange.fromTurnCount && + checkpoint.checkpointTurnCount <= selectedRange.toTurnCount, + ), + ) + : null; const readyCheckpoints = getReadyReviewCheckpoints( - selectedThread === null ? [] : deriveThreadCheckpointSummaries(selectedThread.projection), + range?.rangeFromTurnCount === undefined + ? checkpoints + : [...checkpoints, { ...range, rangeFromTurnCount: range.rangeFromTurnCount }], ); const checkpointBySectionId = new Map( readyCheckpoints.map((checkpoint) => [getReviewSectionIdForCheckpoint(checkpoint), checkpoint]), @@ -103,7 +121,10 @@ export function useReviewSections(input: { environmentId: enabled ? (environmentId ?? null) : null, threadId: enabled ? (threadId ?? null) : null, fromTurnCount: - enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null, + enabled && activeCheckpoint + ? (activeCheckpoint.rangeFromTurnCount ?? + Math.max(0, activeCheckpoint.checkpointTurnCount - 1)) + : null, toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null, ignoreWhitespace: false, }); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ca9e6ba18d06d..27e0633cac77e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -197,7 +197,7 @@ import { isSecretRequestActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestRun, - messageFoldRunId, + messageFoldRunIds, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { @@ -1257,7 +1257,7 @@ function renderFeedEntry( readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void; readonly onToggleWorkRow: (rowId: string, anchorKey?: string, groupId?: string) => void; - readonly onToggleTurnFold: (runId: RunId) => void; + readonly onToggleTurnFold: (row: Extract) => void; readonly onOpenTurnDiff: (checkpoint: ThreadCheckpointSummary) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPlaybackStart: () => void; @@ -1308,7 +1308,7 @@ function renderFeedEntry( props.onToggleTurnFold(entry.runId)} + onPress={() => props.onToggleTurnFold(entry)} hitSlop={4} className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2" style={{ @@ -2138,6 +2138,7 @@ export const ThreadFeed = memo(function ThreadFeed({ const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); + const [autoExpandedTurnId, setAutoExpandedTurnId] = useState(null); const userScrollSettleTimerRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); @@ -2743,15 +2744,15 @@ export const ThreadFeed = memo(function ThreadFeed({ useThreadFeedPresentation(props, expandedTurnIds, expandedWorkGroups), guestShare, ); - const jumpRunId = + const jumpFoldKeys = jumpReady && !presentedFeed.some((entry) => entry.type === "message" && entry.id === jumpToMessageId) - ? messageFoldRunId(props.feed, jumpToMessageId) - : null; - if (jumpRunId && !expandedTurnIds.has(jumpRunId)) { + ? messageFoldRunIds(props.feed, jumpToMessageId) + : []; + if (jumpFoldKeys.some((key) => !expandedTurnIds.has(key))) { setInteractionState((current) => ({ ...current, - expandedTurnIds: new Set(current.expandedTurnIds).add(jumpRunId), + expandedTurnIds: new Set([...current.expandedTurnIds, ...jumpFoldKeys]), })); } const setupAnchorIndex = presentedFeed.findIndex( @@ -2862,7 +2863,8 @@ export const ThreadFeed = memo(function ThreadFeed({ } return new Set(terminalIdsByTurn.values()); })(); - // An interrupted run unfolds; a run that is no longer latest folds back. + // An in-session interrupt expands its turn so the user keeps their place; the next + // run folds only that expansion, never one the user opened. const [previousLatestRun, setPreviousLatestRun] = useState(props.latestRun); if (previousLatestRun !== props.latestRun) { setPreviousLatestRun(props.latestRun); @@ -2870,14 +2872,21 @@ export const ThreadFeed = memo(function ThreadFeed({ const previous = previousLatestRun; if (latest && previous) { if (latest.runId !== previous.runId) { - if (expandedTurnIds.has(previous.runId)) { + if (autoExpandedTurnId === previous.runId) { + setAutoExpandedTurnId(null); setInteractionState((current) => { + if (!current.expandedTurnIds.has(previous.runId)) return current; const next = new Set(current.expandedTurnIds); next.delete(previous.runId); return { ...current, expandedTurnIds: next }; }); } - } else if (previous.status === "running" && latest.status === "interrupted") { + } else if ( + previous.status === "running" && + latest.status === "interrupted" && + !expandedTurnIds.has(latest.runId) + ) { + setAutoExpandedTurnId(latest.runId); setInteractionState((current) => ({ ...current, expandedTurnIds: new Set(current.expandedTurnIds).add(latest.runId), @@ -2997,14 +3006,18 @@ export const ThreadFeed = memo(function ThreadFeed({ }); }; - const onToggleTurnFold = (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`); + // A response's only run answers to both its own key and its group's, so a + // fold opened before the first background wake stays open after it. + const onToggleTurnFold = (row: Extract) => { + suspendEndScrollMaintenanceForDisclosure(row.id); + if (autoExpandedTurnId !== null && row.foldKeys.includes(autoExpandedTurnId)) { + setAutoExpandedTurnId(null); + } setInteractionState((current) => { const next = new Set(current.expandedTurnIds); - if (next.has(runId)) { - next.delete(runId); - } else { - next.add(runId); + for (const key of row.foldKeys) { + if (row.expanded) next.delete(key); + else next.add(key); } return { ...current, expandedTurnIds: next }; }); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 0961ccc238063..6731f3c1a2941 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -17,7 +17,10 @@ import { type OrchestrationV2ProjectedTurnItem, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; -import { deriveThreadCheckpointSummaries } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { + deriveThreadCheckpointSummaries, + type ThreadCheckpointSummary, +} from "@t3tools/client-runtime/state/thread-checkpoints"; import { getReviewSectionIdForCheckpoint } from "../features/review/reviewModel"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; @@ -27,7 +30,7 @@ import { threadFeedCompactionLabels, createThreadFeedCache, deriveThreadFeedPresentation, - messageFoldRunId, + messageFoldRunIds, type ThreadFeedCache, type ThreadFeedEntry, type ThreadFeedLatestRun, @@ -749,16 +752,77 @@ describe("buildThreadFeed", () => { projected(runless ? { ...item, runId: null } : item, position), ), ); - const foldRunId = messageFoldRunId(feed, "message-middle"); - expect(foldRunId, String(runless)).toBe(runless ? "runless:message-user" : runId); + const foldRunIds = messageFoldRunIds(feed, "message-middle"); + expect(foldRunIds, String(runless)).toEqual([ + runless ? "runless:message-user" : runId, + "group:message-user", + ]); const ids = (expanded: ReadonlySet) => deriveThreadFeedPresentation(feed, null, expanded).map((entry) => entry.id); expect(ids(new Set())).not.toContain("message-middle"); - expect(ids(new Set([foldRunId!]))).toContain("message-middle"); + expect(ids(new Set([foldRunIds[0]!]))).toContain("message-middle"); } - expect(messageFoldRunId(buildThreadFeed([projected(userMessage(), 0)]), "message-user")).toBe( - null, + expect( + messageFoldRunIds(buildThreadFeed([projected(userMessage(), 0)]), "message-user"), + ).toEqual([]); + }); + + it("folds every background wake since the prompt behind one row", () => { + const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`; + const inRun = (run: string, item: OrchestrationV2TurnItem): OrchestrationV2TurnItem => ({ + ...item, + runId: RunId.make(run), + }); + const step = (run: string, id: string, second: number) => + inRun(run, { ...command(at(second)), id: TurnItemId.make(id) }); + const answer = (run: string, id: string, second: number) => + inRun(run, { + ...assistantMessage(at(second)), + id: TurnItemId.make(id), + messageId: MessageId.make(id), + }); + const wake = (run: string, id: string, second: number) => + inRun(run, { + ...base(id, at(second), 0), + type: "notification", + source: { kind: "background_task" }, + outcome: "completed", + summary: "Background task finished", + }); + const feed = buildThreadFeed( + [ + inRun("initial", userMessage(at(0))), + step("initial", "launch", 1), + answer("initial", "launched", 5), + wake("wake-a", "wake-a-notice", 20), + step("wake-a", "read-a", 21), + answer("wake-a", "ack-a", 25), + wake("wake-b", "wake-b-notice", 40), + step("wake-b", "read-b", 41), + answer("wake-b", "ack-b", 45), + ].map((item, position) => projected(item, position)), ); + const latestRun = { + runId: RunId.make("wake-b"), + status: "completed" as const, + startedAt: at(40), + completedAt: at(45), + }; + const present = (expanded: ReadonlySet) => + deriveThreadFeedPresentation(feed, latestRun, expanded); + const groupKey = RunId.make("group:message-user"); + + const collapsed = present(new Set()); + expect(collapsed.map((entry) => entry.id)).toEqual([ + "message-user", + `run-fold:${groupKey}`, + "ack-b", + ]); + expect(collapsed[1]).toMatchObject({ label: "Worked for 45s · 2 updates" }); + + const expanded = present(new Set([groupKey])).map((entry) => entry.type); + expect(expanded.filter((type) => type === "run-fold")).toHaveLength(4); + expect(messageFoldRunIds(feed, "ack-a")).toEqual(["wake-a", groupKey]); }); it("folds progress messages while keeping the final assistant answer visible", () => { @@ -1338,7 +1402,7 @@ describe("retained v2 feed presentation", () => { const presented = deriveThreadFeedPresentation( feed, null, - new Set([runId, RunId.make("other-run")]), + new Set([RunId.make(`group:partial:start`), runId, RunId.make("other-run")]), ); const groups = presented.flatMap((entry) => entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.type === "subagent" @@ -1878,3 +1942,152 @@ describe("streamed feed updates", () => { expect(showsDelegation(steps[3]!)).toBe(false); }); }); + +describe("response groups", () => { + const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`; + const inRun = (run: string, item: OrchestrationV2TurnItem): OrchestrationV2TurnItem => ({ + ...item, + runId: RunId.make(run), + }); + const prompt = (run: string, id: string, second: number, extra: object = {}) => + inRun(run, { + ...userMessage(at(second)), + id: TurnItemId.make(id), + messageId: MessageId.make(id), + ...extra, + } as OrchestrationV2TurnItem); + const step = (run: string, id: string, second: number) => + inRun(run, { ...command(at(second)), id: TurnItemId.make(id) }); + const answer = (run: string, id: string, second: number) => + inRun(run, { + ...assistantMessage(at(second)), + id: TurnItemId.make(id), + messageId: MessageId.make(id), + }); + const wake = (run: string, id: string, second: number) => + inRun(run, { + ...base(id, at(second), 0), + type: "notification", + source: { kind: "background_task" }, + outcome: "completed", + summary: "Background task finished", + }); + const checkpoint = ( + run: string, + turnCount: number, + answerId: string | null, + path: string, + ): ThreadCheckpointSummary => ({ + runId: RunId.make(run), + checkpointTurnCount: turnCount, + checkpointRef: `refs/t3/${run}` as ThreadCheckpointSummary["checkpointRef"], + status: "ready", + files: [{ path, kind: "modified", additions: 1, deletions: 0 }], + assistantMessageId: answerId === null ? null : MessageId.make(answerId), + completedAt: at(turnCount), + }); + const completed = (run: string, startedAt: number, completedAt: number) => ({ + runId: RunId.make(run), + status: "completed" as const, + startedAt: at(startedAt), + completedAt: at(completedAt), + }); + const ids = (rows: ReadonlyArray) => rows.map((entry) => entry.id); + + it("splits a steered run into two responses, with its stat on the later row", () => { + const run = "steered"; + const feed = buildThreadFeed( + [ + prompt(run, "prompt", 0), + step(run, "before", 1), + answer(run, "intro", 2), + prompt(run, "steer", 3, { inputIntent: "steer" }), + step(run, "after", 4), + answer(run, "final", 5), + ].map((item, position) => projected(item, position)), + { checkpointSummaries: [checkpoint(run, 1, "final", "a.ts")] }, + ); + const rows = deriveThreadFeedPresentation(feed, completed(run, 0, 5), new Set()); + const steerRow = `run-fold:${run}:steer:steer`; + expect(ids(rows)).toEqual(["prompt", `run-fold:${run}`, "intro", "steer", steerRow, "final"]); + expect(rows.find((entry) => entry.id === `run-fold:${run}`)?.checkpointSummary).toBeUndefined(); + expect(rows.find((entry) => entry.id === steerRow)?.checkpointSummary).toMatchObject({ + runId: run, + }); + }); + + it("folds runs before the first loaded prompt, and counts a tools-only wake's files", () => { + const feedItems = [ + step("initial", "launch", 1), + answer("initial", "launched", 5), + wake("wake-a", "wake-a-notice", 20), + step("wake-a", "read-a", 21), + ].map((item, position) => projected(item, position)); + const feed = buildThreadFeed(feedItems, { + checkpointSummaries: [ + checkpoint("initial", 4, "launched", "a.ts"), + checkpoint("wake-a", 5, null, "b.ts"), + ], + }); + const rows = deriveThreadFeedPresentation(feed, completed("wake-a", 20, 22), new Set()); + expect(ids(rows)).toEqual(["run-fold:group:partial:start", "launched"]); + expect(messageFoldRunIds(feed, "launched")).toEqual(["initial", "group:partial:start"]); + // A rebuild keeps the checkpoint-carrying group's identity. + const rebuild = () => + buildThreadFeed(feedItems, { checkpointSummaries: [checkpoint("wake-a", 5, null, "b.ts")] }); + const first = rebuild(); + const second = rebuild(); + expect(second.at(-1)).toBe(first.at(-1)); + const recaptured = buildThreadFeed(feedItems, { + checkpointSummaries: [checkpoint("wake-a", 5, null, "c.ts")], + }).at(-1); + expect(recaptured).not.toBe(first.at(-1)); + expect(recaptured?.type === "activity-group" && recaptured.runCheckpoint?.files[0]?.path).toBe( + "c.ts", + ); + expect(rows[0]).toMatchObject({ + label: "Worked · 1 update", + checkpointSummary: { + checkpointTurnCount: 5, + rangeFromTurnCount: 3, + files: [{ path: "a.ts" }, { path: "b.ts" }], + }, + }); + }); + + it("matches a fresh build when an idle compaction lands inside a response", () => { + const compact = prompt("compaction", "compact", 30, { + text: "/compact", + createdBy: "agent", + creationSource: "server", + }); + const steps = [ + [prompt("initial", "prompt", 0), step("initial", "launch", 1), answer("initial", "ok", 5)], + [compact], + [wake("wake-a", "wake-a-notice", 40), step("wake-a", "read-a", 41)], + [answer("wake-a", "ack-a", 45)], + ]; + const cache = createThreadFeedCache(); + const items: OrchestrationV2ProjectedTurnItem[] = []; + for (const [index, added] of steps.entries()) { + for (const item of added) items.push(projected(item, items.length)); + const latestRun = completed(index < 2 ? "initial" : "wake-a", 0, 45); + const cached = deriveThreadFeedPresentation( + buildThreadFeed(items, { cache }), + latestRun, + new Set(), + new Set(), + null, + false, + cache, + ); + const fresh = deriveThreadFeedPresentation(buildThreadFeed(items), latestRun, new Set()); + expect(ids(cached), `step ${index}`).toEqual(ids(fresh)); + } + expect( + ids( + deriveThreadFeedPresentation(buildThreadFeed(items), completed("wake-a", 0, 45), new Set()), + ), + ).toContain("run-fold:group:prompt"); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f0f36b119a0e7..3c242864aad11 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -5,6 +5,12 @@ import type { } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { + mergeResponseGroupCheckpoints, + messageStartsResponseGroup, + responseGroupFoldLabel, + responseGroupKey, +} from "@t3tools/client-runtime/state/response-groups"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; @@ -63,7 +69,6 @@ import { formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; -import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { truncate } from "@t3tools/shared/String"; import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; import type { McpAppReference } from "@t3tools/shared/mcpApp"; @@ -210,6 +215,8 @@ type ThreadFeedEntryContent = readonly createdAt: string; readonly runId: RunId | null; readonly activities: ReadonlyArray; + /** The checkpoint of a run with no answer to carry it; read for group stats only. */ + readonly runCheckpoint?: ThreadCheckpointSummary; } | { readonly type: "work-toggle"; @@ -234,6 +241,10 @@ type ThreadFeedEntryContent = readonly id: string; readonly createdAt: string; readonly runId: RunId; + /** Toggling the row opens or closes all of these. */ + readonly foldKeys: ReadonlyArray; + /** The run whose changed-files stat this row may show; null for a group or pre-steer row. */ + readonly checkpointRunId: RunId | null; readonly label: string; readonly expanded: boolean; } @@ -340,10 +351,55 @@ const presentedActivityGroupsCache = new WeakMap< readonly rows: ReadonlyArray; } >(); -const runFoldRowsCache = new WeakMap< +/** Summaries can be rebuilt per event, and a re-capture can rewrite files under one ref. */ +function sameRunCheckpoint(left: ThreadCheckpointSummary, right: ThreadCheckpointSummary) { + return ( + left.checkpointRef === right.checkpointRef && + left.status === right.status && + left.files.length === right.files.length && + left.files.every((file, index) => { + const other = right.files[index]; + return ( + other !== undefined && + file.path === other.path && + file.kind === other.kind && + file.additions === other.additions && + file.deletions === other.deletions + ); + }) + ); +} + +type RunFoldRow = Extract; +const runCheckpointGroupsCache = new WeakMap< ThreadFeedEntry, - Extract + Extract >(); +const runFoldRowsCache = new WeakMap(); +// A response group's row and its first run's row can share an anchor entry. +const groupFoldRowsCache = new WeakMap(); + +function cachedRunFoldRow( + cache: WeakMap, + anchor: ThreadFeedEntry, + fold: Omit, +): RunFoldRow { + const cached = cache.get(anchor); + if ( + cached && + cached.runId === fold.runId && + cached.createdAt === fold.createdAt && + cached.label === fold.label && + cached.expanded === fold.expanded && + cached.checkpointRunId === fold.checkpointRunId && + cached.foldKeys.join("\0") === fold.foldKeys.join("\0") + ) { + return cached; + } + const row: RunFoldRow = { type: "run-fold", id: `run-fold:${fold.runId}`, ...fold }; + cache.set(anchor, row); + return row; +} let cachedThinkingRow: Extract | null = null; export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup): boolean { @@ -1094,13 +1150,6 @@ export function threadFeedActivityIsVisible( ); } -interface ThreadFeedRunFold { - readonly runId: RunId; - readonly createdAt: string; - readonly hiddenEntryIds: ReadonlySet; - readonly label: string; -} - export function failedFeedRunIds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, @@ -1123,26 +1172,124 @@ export function failedFeedRunIds( return failed; } +interface ThreadFeedRunFold { + /** The segment's own key: its run id, a runless prompt key, or a post-steer key. */ + readonly runId: RunId; + /** Any of these keys opens the fold. A response's only segment also answers to its group key. */ + foldKeys: ReadonlyArray; + /** Set when a multi-segment response group owns this fold; its collapsed row hides this one. */ + groupKey: RunId | null; + /** The run whose checkpoint this row shows: only its last segment's row claims it. */ + readonly checkpointRunId: RunId | null; + readonly createdAt: string; + readonly hiddenEntryIds: ReadonlySet; + label: string; +} + /** - * The key a run fold uses for the message `entryId`: its run, or for an - * imported message with no run, the runless key of the user message before - * it. Null for user messages, which never fold. + * One row for every run from a prompt to the latest answer, so the background + * wakes of a long `/goal` thread fold away together. Expanded, it shows the + * per-run folds as before. */ -export function messageFoldRunId( +interface ThreadFeedGroupFold { + readonly key: RunId; + readonly createdAt: string; + /** Hidden while collapsed: earlier runs and their notifications. */ + readonly hiddenEntryIds: ReadonlySet; + /** + * The latest run's own fold also hides under a collapsed group, unless it was + * opened on its own (an interrupt keeps the stopped run open). + */ + readonly trailingFold: ThreadFeedRunFold | null; + readonly label: string; + /** Runs whose checkpoint belongs here. A steer-split run's belongs to the later group. */ + readonly checkpointRunIds: ReadonlyArray; + /** The answer a collapsed group always shows. */ + readonly finalEntryId: string | null; + /** Stopped or failed runs: the group's diff cannot end at their checkpoint. */ + readonly unfinishedRunIds: ReadonlySet; +} + +const PARTIAL_GROUP_PREFIX = "group:partial:"; + +function isSteerMessage(message: ThreadFeedMessage): boolean { + return message.inputIntent === "steer" || message.inputIntent === "promoted_queued_to_steer"; +} + +function isNotificationEntry(entry: ThreadFeedEntry): boolean { + return ( + entry.type === "activity-group" && + entry.activities.some((activity) => activity.projectedItem.item.type === "notification") + ); +} + +/** Cards and records a fold never hides. */ +function feedEntryAlwaysVisibleInFold(entry: ThreadFeedEntry): boolean { + return ( + entry.type === "html-render" || + entry.type === "mcp-app" || + (entry.type === "activity-group" && + (isContextCompactionActivityGroup(entry) || + entry.activities.some( + (activity) => + // A failed error is prominent too, but an earlier run's failure + // folds away; the group row counts it instead. + (activity.prominent && activity.projectedItem.item.type !== "error") || + activity.workEntry.questionAnswer !== undefined || + activity.projectedItem.item.type === "handoff" || + // A child still working outlives its settled launching run; + // its group stays visible while any member is live. + isLiveSubagentTurnItem(activity.projectedItem.item), + ))) + ); +} + +/** + * The fold keys a message hides behind: its run's fold and its response + * group's. Empty for user messages, which never fold. + */ +export function messageFoldRunIds( feed: ReadonlyArray, entryId: string, -): RunId | null { +): ReadonlyArray { let runlessKey: RunId | null = null; + let groupKey: RunId | null = null; + let seenPrompt = false; + const runsWithOutput = new Set(); + const steerKeyByRun = new Map(); for (const entry of feed) { - if (entry.type !== "message") continue; - if (entry.message.role === "user") { - if (entry.id === entryId) return null; - runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; - } else if (entry.id === entryId) { - return entry.message.runId ?? runlessKey; + if (entry.type === "message" && entry.message.role === "user") { + if (entry.id === entryId) return []; + const runId: RunId | null = + entry.message.runId ?? (isSteerMessage(entry.message) ? runlessKey : null); + if (isSteerMessage(entry.message)) { + if (runId !== null && runsWithOutput.has(runId)) { + groupKey = RunId.make(responseGroupKey(entry.id)); + steerKeyByRun.set(runId, RunId.make(`${runId}:steer:${entry.id}`)); + } + continue; + } + runlessKey = runId == null ? RunId.make(`runless:${entry.id}`) : null; + if (messageStartsResponseGroup(entry.message)) { + groupKey = RunId.make(responseGroupKey(entry.id)); + seenPrompt = true; + } + continue; + } + const runId = feedEntryRunId(entry) ?? runlessKey; + // Runs before the first loaded prompt share a partial group named by that prompt. + if (!seenPrompt && groupKey === null && runId !== null) { + const firstPrompt = feed.find(isResponseStartEntry); + groupKey = RunId.make(`${PARTIAL_GROUP_PREFIX}${firstPrompt?.id ?? "start"}`); + } + if (runId !== null && !isNotificationEntry(entry)) runsWithOutput.add(runId); + if (entry.type === "message" && entry.id === entryId) { + const ownRunId = entry.message.runId ?? runlessKey; + const runKey = ownRunId === null ? null : (steerKeyByRun.get(ownRunId) ?? ownRunId); + return [runKey, groupKey].filter((key): key is RunId => key !== null); } } - return null; + return []; } function deriveThreadFeedRunFolds( @@ -1150,37 +1297,120 @@ function deriveThreadFeedRunFolds( latestRun: ThreadFeedLatestRun | null, runlessWorkActive: boolean, feedFailedRunIds: ReadonlySet, -): ReadonlyMap { - const terminalAssistantMessageIdByRun = new Map(); +): { + readonly runFolds: ReadonlyMap; + readonly groupFolds: ReadonlyMap; +} { const interruptedRunIds = new Set(); const failedRunIds = new Set(feedFailedRunIds); const promptIndexByRunId = new Map(); + // Prompts and wake notifications both open a run, so a resumed subagent + // card left above either keeps folding with the run it started in. for (const [index, entry] of feed.entries()) { - if ( - entry.type !== "message" || - entry.message.role !== "user" || - entry.message.inputIntent === "steer" || - entry.message.inputIntent === "promoted_queued_to_steer" - ) { - continue; - } - const runId = entry.message.runId; + const runId = + entry.type === "message" && entry.message.role === "user" + ? isSteerMessage(entry.message) + ? null + : entry.message.runId + : isNotificationEntry(entry) + ? feedEntryRunId(entry) + : null; if (runId != null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index); } - const groupsByRunId = new Map< - RunId, - { entries: ThreadFeedEntry[]; startBoundary: string | null } - >(); + + interface Segment { + readonly key: RunId; + readonly runId: RunId; + readonly entries: ReadonlyArray; + readonly firstIndex: number; + readonly startedAt: string; + readonly lastOfRun: boolean; + readonly terminalEntryId: string | null; + readonly startedByNotification: boolean; + /** Never folds: live, streaming, failed, or interrupted. */ + readonly unfolded: boolean; + readonly live: boolean; + fold: ThreadFeedRunFold | null; + } + interface Response { + readonly key: RunId; + /** Null when the history window opens after the prompt. */ + readonly startedAt: string | null; + readonly segments: Segment[]; + } + interface RunGroup { + readonly entries: ThreadFeedEntry[]; + readonly indexes: number[]; + readonly startBoundary: string | null; + readonly response: Response | null; + /** Each steer after the run has output starts a new segment, and response, here. */ + readonly steerSegments: Array<{ + startIndex: number; + steerAt: string; + steerId: string; + response: Response; + }>; + } + const groupsByRunId = new Map(); + const responses: Response[] = []; + const openResponse = (entry: ThreadFeedEntry): Response => { + const response = { + key: RunId.make(responseGroupKey(entry.id)), + startedAt: entry.createdAt, + segments: [], + }; + responses.push(response); + return response; + }; + let firstPromptIndex: number | null = null; + let partialResponse: Response | null = null; + // Named by the prompt it precedes, so loading older history keeps it open. + const openPartialResponse = (): Response => { + const partial = { + key: RunId.make( + `${PARTIAL_GROUP_PREFIX}${firstPromptIndex === null ? "start" : (feed[firstPromptIndex]?.id ?? "start")}`, + ), + startedAt: null, + segments: [], + }; + responses.unshift(partial); + return partial; + }; let runlessKey: RunId | null = null; let responseKey: RunId | null = null; + let response: Response | null = null; let pendingUserBoundary: string | null = null; for (const [index, entry] of feed.entries()) { if (entry.type === "message" && entry.message.role === "user") { + if (isSteerMessage(entry.message)) { + const steerRunId = entry.message.runId ?? responseKey; + const group = steerRunId === null ? undefined : groupsByRunId.get(steerRunId); + // A wake's notification is not output yet; a steer right after it stays put. + if (group !== undefined && group.entries.some((entry) => !isNotificationEntry(entry))) { + // Back-to-back steers split once, after the last of them. + if (group.steerSegments.at(-1)?.startIndex === group.entries.length) { + group.steerSegments.pop(); + } + response = openResponse(entry); + group.steerSegments.push({ + startIndex: group.entries.length, + steerAt: entry.message.createdAt, + steerId: entry.id, + response, + }); + } + continue; + } pendingUserBoundary = entry.message.createdAt; runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; responseKey = entry.message.runId ?? runlessKey; + if (messageStartsResponseGroup(entry.message)) { + response = openResponse(entry); + firstPromptIndex ??= index; + } continue; } + if (isNotificationEntry(entry)) responseKey = feedEntryRunId(entry) ?? responseKey; const ownRunId = entry.type === "message" && entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) @@ -1197,14 +1427,18 @@ function deriveThreadFeedRunFolds( : ownRunId; let group = groupsByRunId.get(runId); if (!group) { - group = { entries: [], startBoundary: pendingUserBoundary }; + group = { + entries: [], + indexes: [], + startBoundary: pendingUserBoundary, + response, + steerSegments: [], + }; pendingUserBoundary = null; groupsByRunId.set(runId, group); } group.entries.push(entry); - if (entry.type === "message") { - terminalAssistantMessageIdByRun.set(runId, entry.id); - } + group.indexes.push(index); if (entry.type !== "activity-group") continue; for (const activity of entry.activities) { const item = activity.projectedItem.item; @@ -1221,85 +1455,193 @@ function deriveThreadFeedRunFolds( } const activeRunId = unsettledRunId(latestRun); - const foldsByAnchorId = new Map(); + const segments: Segment[] = []; for (const [runId, group] of groupsByRunId) { - if ( - runId === activeRunId || - (runlessWorkActive && runId === runlessKey) || - interruptedRunIds.has(runId) || - failedRunIds.has(runId) || - group.entries.some((entry) => entry.type === "message" && entry.message.streaming) - ) { - continue; + const live = runId === activeRunId || (runlessWorkActive && runId === runlessKey); + const runUnfolded = live || interruptedRunIds.has(runId) || failedRunIds.has(runId); + const slices = [ + { startIndex: 0, steerAt: null, steerId: null, response: group.response }, + ...group.steerSegments, + ]; + for (const [sliceIndex, slice] of slices.entries()) { + const entries = group.entries.slice(slice.startIndex, slices[sliceIndex + 1]?.startIndex); + const firstEntry = entries[0]; + if (firstEntry === undefined) continue; + const streaming = entries.some( + (entry) => entry.type === "message" && entry.message.streaming, + ); + const segment: Segment = { + key: slice.steerId === null ? runId : RunId.make(`${runId}:steer:${slice.steerId}`), + runId, + entries, + firstIndex: group.indexes[slice.startIndex] ?? 0, + startedAt: + slice.steerAt ?? + group.startBoundary ?? + (latestRun?.runId === runId ? latestRun.startedAt : null) ?? + firstEntry.createdAt, + lastOfRun: sliceIndex === slices.length - 1, + terminalEntryId: entries.findLast((entry) => entry.type === "message")?.id ?? null, + startedByNotification: slice.steerId === null && isNotificationEntry(firstEntry), + unfolded: runUnfolded || streaming, + live: live || streaming, + fold: null, + }; + segments.push(segment); + // A history window can open mid-response, so runs before the first + // loaded prompt fold together under a group with no known start. + const owner = + slice.response ?? + (segment.firstIndex < (firstPromptIndex ?? Infinity) + ? (partialResponse ??= openPartialResponse()) + : null); + owner?.segments.push(segment); + } + } + + const segmentEnd = (segment: Segment): string => { + if (segment.lastOfRun && latestRun?.runId === segment.runId && latestRun.completedAt) { + return latestRun.completedAt; } - const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId); + const terminal = segment.entries.find((entry) => entry.id === segment.terminalEntryId); + const lastEntry = segment.entries.at(-1)!; + const lastEntryEnd = + lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; + return ( + maxIsoTimestamp( + terminal?.type === "message" ? terminal.message.updatedAt : null, + lastEntryEnd, + ) ?? lastEntryEnd + ); + }; + const segmentStopped = (segment: Segment) => + segment.lastOfRun && + (interruptedRunIds.has(segment.runId) || + (latestRun?.runId === segment.runId && + (latestRun.status === "interrupted" || latestRun.status === "cancelled"))); + + for (const segment of segments) { + if (segment.unfolded) continue; const hiddenEntryIds = new Set( - group.entries + segment.entries .filter( (entry) => - entry.id !== terminalAssistantId && - entry.type !== "html-render" && - entry.type !== "mcp-app" && - !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) && - !( - entry.type === "activity-group" && - entry.activities.some( - (activity) => - activity.prominent || - activity.workEntry.questionAnswer !== undefined || - activity.projectedItem.item.type === "notification" || - activity.projectedItem.item.type === "handoff" || - // A child still working outlives its settled launching run; - // its group stays visible while any member is live. - isLiveSubagentTurnItem(activity.projectedItem.item), - ) - ), + entry.id !== segment.terminalEntryId && + !feedEntryAlwaysVisibleInFold(entry) && + !isNotificationEntry(entry), ) .map((entry) => entry.id), ); - const firstEntry = group.entries[0]; - const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id)); - const lastEntry = group.entries.at(-1); - if (!firstHiddenEntry || !firstEntry || !lastEntry) continue; - const hidesNonCompactionWork = group.entries.some( - (entry) => - hiddenEntryIds.has(entry.id) && - !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)), - ); - if (!hidesNonCompactionWork) continue; - const terminalEntry = terminalAssistantId - ? group.entries.find((entry) => entry.id === terminalAssistantId) - : null; - const latestRunMatches = latestRun?.runId === runId; - const lastEntryEnd = - lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; - const elapsedMs = - latestRunMatches && latestRun.startedAt && latestRun.completedAt - ? computeElapsedMs(latestRun.startedAt, latestRun.completedAt) - : computeElapsedMs( - group.startBoundary ?? firstEntry.createdAt, - maxIsoTimestamp( - terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, - lastEntryEnd, - ) ?? lastEntryEnd, - ); - const duration = elapsedMs === null ? null : formatDuration(elapsedMs); - const interrupted = - latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); - foldsByAnchorId.set(firstHiddenEntry.id, { - runId, + const firstHiddenEntry = segment.entries.find((entry) => hiddenEntryIds.has(entry.id)); + if (!firstHiddenEntry) continue; + segment.fold = { + runId: segment.key, + foldKeys: [segment.key], + groupKey: null, + checkpointRunId: segment.lastOfRun ? segment.runId : null, createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, - label: interrupted - ? duration - ? `You stopped after ${duration}` - : "You stopped this response" - : duration - ? `Worked for ${duration}` - : "Worked", + label: responseGroupFoldLabel({ + durationMs: computeElapsedMs(segment.startedAt, segmentEnd(segment)), + updates: 0, + failed: 0, + stopped: segmentStopped(segment), + live: false, + }), + }; + } + + const runFolds = new Map(); + for (const segment of segments) { + const anchor = segment.entries.find((entry) => segment.fold?.hiddenEntryIds.has(entry.id)); + if (segment.fold && anchor) runFolds.set(anchor.id, segment.fold); + } + + const groupFolds = new Map(); + for (const { key, startedAt, segments: members } of responses) { + // A run that only left cards a fold never hides adds nothing to fold. + const ordered = members + .filter( + (segment) => + segment.startedByNotification || + segment.entries.some((entry) => !feedEntryAlwaysVisibleInFold(entry)), + ) + .sort((left, right) => left.firstIndex - right.firstIndex); + const last = ordered.at(-1); + if (last === undefined) continue; + const live = ordered.some((segment) => segment.live); + const end = live ? null : ordered.map(segmentEnd).reduce(maxIsoTimestamp, null); + const label = responseGroupFoldLabel({ + durationMs: end === null || startedAt === null ? null : computeElapsedMs(startedAt, end), + updates: ordered.filter((segment) => segment.startedByNotification).length, + failed: new Set( + ordered + .slice(0, -1) + .filter( + (segment) => failedRunIds.has(segment.runId) && !interruptedRunIds.has(segment.runId), + ) + .map((segment) => segment.runId), + ).size, + stopped: segmentStopped(last), + live, + }); + if (ordered.length === 1) { + // A lone run with no loaded prompt keeps its own timing. + if (last.fold && startedAt !== null) { + last.fold.label = label; + last.fold.foldKeys = [last.key, key]; + } + continue; + } + if (ordered.every((segment) => segment.live)) continue; + + const hiddenEntryIds = new Set(); + for (const segment of ordered.slice(0, -1)) { + for (const entry of segment.entries) { + if (!feedEntryAlwaysVisibleInFold(entry)) hiddenEntryIds.add(entry.id); + } + } + if (!last.unfolded) { + for (const entry of last.entries) { + if (isNotificationEntry(entry)) hiddenEntryIds.add(entry.id); + } + } + // A wake that only ran tools has no answer of its own; the last one stays. + const finalEntryId = ordered.findLast( + (segment) => !segment.live && segment.terminalEntryId !== null, + )?.terminalEntryId; + if (finalEntryId) hiddenEntryIds.delete(finalEntryId); + const trailingFold = last.unfolded ? null : last.fold; + const anchor = ordered + .flatMap((segment) => segment.entries) + .find((entry) => hiddenEntryIds.has(entry.id) || trailingFold?.hiddenEntryIds.has(entry.id)); + if (anchor === undefined) continue; + for (const segment of ordered) { + if (segment.fold) segment.fold.groupKey = key; + } + groupFolds.set(anchor.id, { + key, + createdAt: anchor.createdAt, + hiddenEntryIds, + trailingFold, + label, + finalEntryId: finalEntryId ?? null, + checkpointRunIds: ordered + .filter((segment) => segment.lastOfRun) + .map((segment) => segment.runId), + unfinishedRunIds: new Set( + ordered + .map((segment) => segment.runId) + .filter( + (runId) => + failedRunIds.has(runId) || + interruptedRunIds.has(runId) || + (latestRun?.runId === runId && latestRun.status !== "completed"), + ), + ), }); } - return foldsByAnchorId; + return { runFolds, groupFolds }; } const supersededReasoningGroups = new WeakMap(); @@ -1363,15 +1705,18 @@ function feedEntryRunId(entry: ThreadFeedEntry): RunId | null { return entry.type === "message" ? (entry.message.runId ?? null) : entry.runId; } -function isUserMessageEntry(entry: ThreadFeedEntry | undefined): boolean { - return entry?.type === "message" && entry.message.role === "user"; +function isResponseStartEntry(entry: ThreadFeedEntry | undefined): boolean { + return ( + entry?.type === "message" && + messageStartsResponseGroup(entry.message) && + !isSteerMessage(entry.message) + ); } /** - * A user message starts every fold and is never hidden or grouped, so the - * rows before it depend only on the entries before it, their runs, and the - * shared inputs. Returns the user message entry to restart from, or null to - * present the whole feed. + * A prompt starts every response group and never hides, so rows before it + * depend only on earlier entries. Idle `/compact` joins the group before it, so + * it is no restart. Returns the prompt to restart from, or null for all. */ function presentationRestart( previous: ThreadFeedPresentationBuild | null, @@ -1396,7 +1741,7 @@ function presentationRestart( let index = previous.restarts.length - 1; while (index >= 0 && previous.restarts[index]!.entry > firstChanged) index -= 1; const restart = previous.restarts[index]; - if (restart === undefined || !isUserMessageEntry(feed[restart.entry])) return null; + if (restart === undefined || !isResponseStartEntry(feed[restart.entry])) return null; for (let entryIndex = restart.entry; entryIndex < feed.length; entryIndex += 1) { const runId = feedEntryRunId(feed[entryIndex]!); const firstEntry = runId === null ? undefined : previous.firstEntryByRun.get(runId); @@ -1475,22 +1820,37 @@ export function deriveThreadFeedPresentation( } } const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; - const foldsByAnchorId = deriveThreadFeedRunFolds( + const { runFolds: foldsByAnchorId, groupFolds: groupFoldsByAnchorId } = deriveThreadFeedRunFolds( sourceFeed, latestRun, isWorking && runlessWorkActive, failedRunIds, ); + const foldExpanded = (keys: ReadonlyArray) => keys.some((key) => expandedRunIds.has(key)); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { - if (!expandedRunIds.has(fold.runId)) { + if (!foldExpanded(fold.foldKeys)) { for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); } } + const collapsedGroupKeys = new Set(); + const openTrailingFoldKeys = new Set(); + for (const group of groupFoldsByAnchorId.values()) { + if (expandedRunIds.has(group.key)) continue; + collapsedGroupKeys.add(group.key); + for (const entryId of group.hiddenEntryIds) collapsedEntryIds.add(entryId); + if (group.trailingFold && !foldExpanded(group.trailingFold.foldKeys)) { + for (const entryId of group.trailingFold.hiddenEntryIds) collapsedEntryIds.add(entryId); + } else if (group.trailingFold) { + // A latest run opened on its own keeps its row, so it can be closed again. + openTrailingFoldKeys.add(group.trailingFold.runId); + } + if (group.finalEntryId !== null) collapsedEntryIds.delete(group.finalEntryId); + } for (let sourceIndex = 0; sourceIndex < sourceFeed.length; sourceIndex += 1) { const entry = sourceFeed[sourceIndex]!; const feedIndex = retainedFeedIndices[sourceIndex]!; - if (isUserMessageEntry(entry)) { + if (isResponseStartEntry(entry)) { restarts.push({ entry: feedIndex, rowsLength: result.length }); } const runId = feedEntryRunId(entry); @@ -1505,28 +1865,36 @@ export function deriveThreadFeedPresentation( activeTailGroup?.type === "activity-group" && activeTailGroup.id === entry.id && entry.runId === activeRunId; + const groupFold = groupFoldsByAnchorId.get(entry.id); + if (groupFold) { + result.push( + cachedRunFoldRow(groupFoldRowsCache, entry, { + runId: groupFold.key, + foldKeys: [groupFold.key], + checkpointRunId: null, + createdAt: groupFold.createdAt, + label: groupFold.label, + expanded: !collapsedGroupKeys.has(groupFold.key), + }), + ); + } const fold = foldsByAnchorId.get(entry.id); - if (fold) { - const expanded = expandedRunIds.has(fold.runId); - let row = runFoldRowsCache.get(entry); - if ( - !row || - row.runId !== fold.runId || - row.createdAt !== fold.createdAt || - row.label !== fold.label || - row.expanded !== expanded - ) { - row = { - type: "run-fold", - id: `run-fold:${fold.runId}`, - createdAt: fold.createdAt, + if ( + fold && + (fold.groupKey === null || + !collapsedGroupKeys.has(fold.groupKey) || + openTrailingFoldKeys.has(fold.runId)) + ) { + result.push( + cachedRunFoldRow(runFoldRowsCache, entry, { runId: fold.runId, + foldKeys: fold.foldKeys, + checkpointRunId: fold.checkpointRunId, + createdAt: fold.createdAt, label: fold.label, - expanded, - }; - runFoldRowsCache.set(entry, row); - } - result.push(row); + expanded: foldExpanded(fold.foldKeys), + }), + ); } if ( (entry.type === "message" && @@ -1595,16 +1963,45 @@ export function deriveThreadFeedPresentation( checkpointByRunId.set(entry.message.checkpointSummary.runId, entry.message.checkpointSummary); } } + // A response group's row carries one stat for all of its runs, so their + // answers drop their own. + const groupFoldByKey = new Map(); + for (const group of groupFoldsByAnchorId.values()) groupFoldByKey.set(group.key, group); + // A wake that only ran tools has no answer to carry its checkpoint, but it + // still belongs in its group's stat and diff range. + const groupCheckpointByRunId = new Map(checkpointByRunId); + for (const entry of sourceFeed) { + if (entry.type === "activity-group" && entry.runCheckpoint) { + groupCheckpointByRunId.set(entry.runCheckpoint.runId, entry.runCheckpoint); + } + } + const groupCoveredRunIds = new Set(); + for (let index = tailStart; index < result.length; index += 1) { + const row = result[index]!; + const group = row.type === "run-fold" ? groupFoldByKey.get(row.runId) : undefined; + if (group === undefined) continue; + const runIds = group.checkpointRunIds; + const merged = mergeResponseGroupCheckpoints( + runIds.flatMap((runId) => { + const summary = groupCheckpointByRunId.get(runId); + return summary === undefined ? [] : [summary]; + }), + group.unfinishedRunIds, + ); + for (const runId of runIds) groupCoveredRunIds.add(runId); + if (merged && merged.files.length > 0) result[index] = { ...row, checkpointSummary: merged }; + } const workRowByRunId = new Map(); for (let index = tailStart; index < result.length; index += 1) { const row = result[index]!; + const checkpointRunId = + row.type === "run-fold" ? row.checkpointRunId : row.type === "work-toggle" ? row.runId : null; if ( - (row.type === "run-fold" || row.type === "work-toggle") && - row.runId !== null && - checkpointByRunId.has(row.runId) && - (row.type === "run-fold" || !workRowByRunId.has(row.runId)) + checkpointRunId !== null && + checkpointByRunId.has(checkpointRunId) && + (row.type === "run-fold" || !workRowByRunId.has(checkpointRunId)) ) { - workRowByRunId.set(row.runId, index); + workRowByRunId.set(checkpointRunId, index); } } for (const [runId, index] of workRowByRunId) { @@ -1615,7 +2012,8 @@ export function deriveThreadFeedPresentation( if ( row.type !== "message" || !row.message.checkpointSummary || - !workRowByRunId.has(row.message.checkpointSummary.runId) + (!workRowByRunId.has(row.message.checkpointSummary.runId) && + !groupCoveredRunIds.has(row.message.checkpointSummary.runId)) ) continue; const message = { ...row.message }; @@ -2367,6 +2765,38 @@ export function buildThreadFeed( feedLength: prefix.length + groupedLength, }), ); + const answerlessCheckpointByRun = new Map( + (options?.checkpointSummaries ?? []) + .filter( + (checkpoint) => checkpoint.status === "ready" && checkpoint.assistantMessageId === null, + ) + .map((checkpoint) => [checkpoint.runId, checkpoint]), + ); + for ( + let index = grouped.length - 1; + index >= 0 && answerlessCheckpointByRun.size > 0; + index -= 1 + ) { + const entry = grouped[index]!; + const runCheckpoint = + entry.type === "activity-group" && entry.runId !== null + ? answerlessCheckpointByRun.get(entry.runId) + : undefined; + if (entry.type !== "activity-group" || runCheckpoint === undefined) continue; + answerlessCheckpointByRun.delete(runCheckpoint.runId); + // Keep the group's identity across builds: caches and restarts compare by reference. + const cached = runCheckpointGroupsCache.get(entry); + if ( + cached?.runCheckpoint !== undefined && + sameRunCheckpoint(cached.runCheckpoint, runCheckpoint) + ) { + grouped[index] = cached; + continue; + } + const withCheckpoint = { ...entry, runCheckpoint }; + runCheckpointGroupsCache.set(entry, withCheckpoint); + grouped[index] = withCheckpoint; + } const feed = prefix.length === 0 ? grouped : prefix.concat(grouped); if (cache) { cache.feedBuild = diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index aae4a4a38b662..adfcc339e7db1 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -190,6 +190,56 @@ it.effect.each([false, true])( }, ); +it.effect.each([false, true])( + "diffs a multi-turn range that starts after a stopped run (stopped run captured: %s)", + (stoppedRunCheckpoint) => { + const fourthRunId = RunId.make("run:checkpoint-diff-v2:4"); + const projection = makeProjectionAfterStoppedRun(stoppedRunCheckpoint); + const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed("diff --git a/file b/file"), + ); + const layer = layerFor({ + projection: Effect.succeed({ + ...projection, + runs: [...projection.runs, { id: fourthRunId, ordinal: 4, status: "completed" as const }], + checkpoints: [ + ...projection.checkpoints, + // The stopped run's work in another scope must not start the range. + { + scopeId: secondScopeId, + runId: secondRunId, + ordinalWithinScope: 3, + appRunOrdinal: 2, + status: "ready" as const, + ref: CheckpointRef.make("refs/t3/test/tool/stopped"), + }, + { + scopeId: firstScopeId, + runId: fourthRunId, + ordinalWithinScope: 4, + appRunOrdinal: 4, + status: "ready" as const, + ref: CheckpointRef.make("refs/t3/test/ordinal/4"), + }, + ], + }), + diffCheckpoints, + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* query.getTurnDiff({ threadId, fromTurnCount: 2, toTurnCount: 4 }); + assert.deepEqual( + diffCheckpoints.mock.calls.map(([input]) => ({ + from: input.fromCheckpointRef, + to: input.toCheckpointRef, + })), + [{ from: "refs/t3/test/ordinal/2", to: "refs/t3/test/ordinal/4" }], + ); + }).pipe(Effect.provide(layer)); + }, +); + const layerVcsProcess = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const layerStore = CheckpointStore.layer.pipe( Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(layerVcsProcess))), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index fb4513c2a16f0..ffb7ee19c00a0 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -163,17 +163,39 @@ export const make = Effect.gen(function* () { }); } - // Capture compares against the preceding scope ordinal, even when that - // checkpoint belongs to a stopped run or has no run. - const parentCheckpoint = + // Capture compares against the preceding scope ordinal, even for a stopped + // or runless checkpoint. A wider range starts from its first checkpoint: + // at `from` in the same scope, else that checkpoint's parent, since run + // ordinals skip (queued runs, rewinds). + const firstIncludedCheckpoint = input.fromTurnCount === input.toTurnCount - 1 - ? projection.checkpoints.find( + ? toCheckpoint + : projection.checkpoints + .filter( + (checkpoint) => + checkpoint.status === "ready" && + checkpoint.appRunOrdinal !== null && + checkpoint.appRunOrdinal > input.fromTurnCount && + checkpoint.appRunOrdinal <= input.toTurnCount, + ) + .toSorted((left, right) => (left.appRunOrdinal ?? 0) - (right.appRunOrdinal ?? 0))[0]; + const rangeStartCheckpoint = + firstIncludedCheckpoint === undefined + ? undefined + : ((input.fromTurnCount === input.toTurnCount - 1 + ? undefined + : projection.checkpoints.find( + (checkpoint) => + checkpoint.status === "ready" && + checkpoint.scopeId === firstIncludedCheckpoint.scopeId && + checkpoint.appRunOrdinal === input.fromTurnCount, + )) ?? + projection.checkpoints.find( (checkpoint) => checkpoint.status === "ready" && - checkpoint.scopeId === toCheckpoint.scopeId && - checkpoint.ordinalWithinScope === toCheckpoint.ordinalWithinScope - 1, - ) - : undefined; + checkpoint.scopeId === firstIncludedCheckpoint.scopeId && + checkpoint.ordinalWithinScope === firstIncludedCheckpoint.ordinalWithinScope - 1, + )); const firstScope = input.fromTurnCount === 0 @@ -188,7 +210,7 @@ export const make = Effect.gen(function* () { ordinalWithinScope: 0, }).pipe(Effect.provideService(Crypto.Crypto, crypto)) : ( - parentCheckpoint ?? + rangeStartCheckpoint ?? readyCheckpoints.find( (checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount, ) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 9ade0b59c2b60..e231d0ed8788b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -281,29 +281,44 @@ export default function DiffPanel({ : (orderedTurnDiffSummaries.find((summary) => summary.runId === selectedRunId) ?? orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn?.checkpointTurnCount; + // A response group's changed-files card asks for every turn since its prompt. + const selectedRangeFromTurnCount = + diffSelection.kind === "turn" && + diffSelection.fromTurnCount !== undefined && + selectedTurn?.runId === diffSelection.turnId && + selectedCheckpointTurnCount !== undefined && + diffSelection.fromTurnCount < selectedCheckpointTurnCount - 1 + ? diffSelection.fromTurnCount + : null; + const selectedTurnLabel = + selectedRangeFromTurnCount === null + ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + : `Turns ${selectedRangeFromTurnCount + 1}–${selectedCheckpointTurnCount}`; const latestTurn = orderedTurnDiffSummaries[0]; const selectedScopeLabel = selectedRunId === null ? selectedGitScope === "unstaged" ? "Uncommitted" : "Changes" - : selectedTurn?.runId === latestTurn?.runId + : selectedTurn?.runId === latestTurn?.runId && selectedRangeFromTurnCount === null ? "Latest turn" - : `Turn ${selectedCheckpointTurnCount ?? "?"}`; - const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope; + : selectedTurnLabel; + const reviewSectionId = selectedTurn + ? `turn:${selectedTurn.runId}${selectedRangeFromTurnCount === null ? "" : `:from:${selectedRangeFromTurnCount}`}` + : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; const reviewSectionTitle = selectedTurn - ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + ? selectedTurnLabel : selectedGitScope === "unstaged" ? "Uncommitted" : "Changes"; const selectedCheckpointRange = typeof selectedCheckpointTurnCount === "number" ? { - fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), + fromTurnCount: selectedRangeFromTurnCount ?? Math.max(0, selectedCheckpointTurnCount - 1), toTurnCount: selectedCheckpointTurnCount, } : null; @@ -314,7 +329,7 @@ export default function DiffPanel({ fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null, toTurnCount: selectedCheckpointRange?.toTurnCount ?? null, ignoreWhitespace: diffIgnoreWhitespace, - cacheScope: selectedTurn ? `turn:${selectedTurn.runId}` : null, + cacheScope: selectedTurn ? reviewSectionId : null, }, { enabled: isGitRepo && selectedTurn !== undefined }, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 6ea5ea56562de..4c9d28b402d19 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -489,6 +489,259 @@ describe("MessagesTimeline citations", () => { }); }); +describe("MessagesTimeline turn folds", () => { + it("keeps a turn the user expanded open when the next run starts", async () => { + const props = baseProps(); + const firstRun = RunId.make("run-1"); + const secondRun = RunId.make("run-2"); + const at = (second: number) => new Date(Date.parse(CREATED_AT) + second * 1000).toISOString(); + const settled = [ + message("First prompt", { runId: firstRun, inputIntent: "turn_start" }, "user-1"), + message( + "Interim progress note", + { role: "assistant", runId: firstRun, createdAt: at(2), updatedAt: at(2) }, + "assistant-interim", + ), + message( + "Final reply", + { role: "assistant", runId: firstRun, createdAt: at(9), updatedAt: at(9) }, + "assistant-final", + ), + ]; + const timeline = (entries: Entry[], extra: Partial) => ( + + ); + await using screen = await renderElement( + timeline(settled, { + latestRun: { runId: firstRun, status: "completed", startedAt: at(0), completedAt: at(9) }, + }), + ); + const fold = () => screen.button(/^Worked for/); + expect(screen.text()).not.toContain("Interim progress note"); + await click(fold()); + expect(screen.text()).toContain("Interim progress note"); + + await screen.rerender( + timeline( + [ + ...settled, + message( + "Follow-up", + { runId: secondRun, inputIntent: "turn_start", createdAt: at(20), updatedAt: at(20) }, + "user-2", + ), + ], + { + isWorking: true, + activeTurnInProgress: true, + latestRun: { runId: secondRun, status: "running", startedAt: at(21), completedAt: null }, + }, + ), + ); + + expect(fold().getAttribute("aria-expanded")).toBe("true"); + expect(screen.text()).toContain("Interim progress note"); + }); + + it("keeps a restored expanded turn open when the next run starts", async () => { + const props = baseProps(); + const firstRun = RunId.make("run-1"); + const secondRun = RunId.make("run-2"); + const at = (second: number) => new Date(Date.parse(CREATED_AT) + second * 1000).toISOString(); + const settled = [ + message("First prompt", { runId: firstRun, inputIntent: "turn_start" }, "user-1"), + message( + "Interim progress note", + { role: "assistant", runId: firstRun, createdAt: at(2), updatedAt: at(2) }, + "assistant-interim", + ), + message( + "Final reply", + { role: "assistant", runId: firstRun, createdAt: at(9), updatedAt: at(9) }, + "assistant-final", + ), + ]; + rememberTimelinePosition( + props.routeThreadKey, + { + rowId: "user-1", + offsetWithinRow: 0, + scrollOffset: 0, + atEnd: true, + disclosures: { + runs: new Set([firstRun]), + workGroups: new Set(), + attempts: new Set(), + workGroupState: { scrollPositions: new Map(), expandedEntries: new Set() }, + }, + }, + false, + ); + const timeline = (entries: Entry[], extra: Partial) => ( + + ); + await using screen = await renderElement( + timeline(settled, { + latestRun: { runId: firstRun, status: "completed", startedAt: at(0), completedAt: at(9) }, + }), + ); + const fold = () => screen.button(/^Worked for/); + expect(fold().getAttribute("aria-expanded")).toBe("true"); + expect(screen.text()).toContain("Interim progress note"); + + await screen.rerender( + timeline( + [ + ...settled, + message( + "Follow-up", + { runId: secondRun, inputIntent: "turn_start", createdAt: at(20), updatedAt: at(20) }, + "user-2", + ), + ], + { + isWorking: true, + activeTurnInProgress: true, + latestRun: { runId: secondRun, status: "running", startedAt: at(21), completedAt: null }, + }, + ), + ); + + expect(fold().getAttribute("aria-expanded")).toBe("true"); + expect(screen.text()).toContain("Interim progress note"); + }); + + it("folds an interrupted turn it expanded once the next run starts", async () => { + const props = baseProps(); + const firstRun = RunId.make("run-1"); + const secondRun = RunId.make("run-2"); + const at = (second: number) => new Date(Date.parse(CREATED_AT) + second * 1000).toISOString(); + const settled = [ + message("First prompt", { runId: firstRun, inputIntent: "turn_start" }, "user-1"), + message( + "Interim progress note", + { role: "assistant", runId: firstRun, createdAt: at(2), updatedAt: at(2) }, + "assistant-interim", + ), + message( + "Final reply", + { role: "assistant", runId: firstRun, createdAt: at(9), updatedAt: at(9) }, + "assistant-final", + ), + ]; + const timeline = (entries: Entry[], extra: Partial) => ( + + ); + await using screen = await renderElement( + timeline(settled, { + isWorking: true, + activeTurnInProgress: true, + latestRun: { runId: firstRun, status: "running", startedAt: at(0), completedAt: null }, + }), + ); + await screen.rerender( + timeline(settled, { + latestRun: { runId: firstRun, status: "interrupted", startedAt: at(0), completedAt: at(9) }, + }), + ); + expect(screen.button(/^You stopped after/).getAttribute("aria-expanded")).toBe("true"); + + await screen.rerender( + timeline( + [ + ...settled, + message( + "Follow-up", + { runId: secondRun, inputIntent: "turn_start", createdAt: at(20), updatedAt: at(20) }, + "user-2", + ), + ], + { + isWorking: true, + activeTurnInProgress: true, + latestRun: { runId: secondRun, status: "running", startedAt: at(21), completedAt: null }, + }, + ), + ); + + expect(screen.button(/^Worked for/).getAttribute("aria-expanded")).toBe("false"); + expect(screen.text()).not.toContain("Interim progress note"); + }); + + it("keeps an interrupted turn that was already expanded open when the next run starts", async () => { + const props = baseProps(); + const firstRun = RunId.make("run-1"); + const secondRun = RunId.make("run-2"); + const at = (second: number) => new Date(Date.parse(CREATED_AT) + second * 1000).toISOString(); + const settled = [ + message("First prompt", { runId: firstRun, inputIntent: "turn_start" }, "user-1"), + message( + "Interim progress note", + { role: "assistant", runId: firstRun, createdAt: at(2), updatedAt: at(2) }, + "assistant-interim", + ), + message( + "Final reply", + { role: "assistant", runId: firstRun, createdAt: at(9), updatedAt: at(9) }, + "assistant-final", + ), + ]; + rememberTimelinePosition( + props.routeThreadKey, + { + rowId: "user-1", + offsetWithinRow: 0, + scrollOffset: 0, + atEnd: true, + disclosures: { + runs: new Set([firstRun]), + workGroups: new Set(), + attempts: new Set(), + workGroupState: { scrollPositions: new Map(), expandedEntries: new Set() }, + }, + }, + false, + ); + const timeline = (entries: Entry[], extra: Partial) => ( + + ); + await using screen = await renderElement( + timeline(settled, { + isWorking: true, + activeTurnInProgress: true, + latestRun: { runId: firstRun, status: "running", startedAt: at(0), completedAt: null }, + }), + ); + await screen.rerender( + timeline(settled, { + latestRun: { runId: firstRun, status: "interrupted", startedAt: at(0), completedAt: at(9) }, + }), + ); + expect(screen.button(/^You stopped after/).getAttribute("aria-expanded")).toBe("true"); + + await screen.rerender( + timeline( + [ + ...settled, + message( + "Follow-up", + { runId: secondRun, inputIntent: "turn_start", createdAt: at(20), updatedAt: at(20) }, + "user-2", + ), + ], + { + isWorking: true, + activeTurnInProgress: true, + latestRun: { runId: secondRun, status: "running", startedAt: at(21), completedAt: null }, + }, + ), + ); + + expect(screen.button(/^Worked for/).getAttribute("aria-expanded")).toBe("true"); + expect(screen.text()).toContain("Interim progress note"); + }); +}); + describe("MessagesTimeline automation prompts", () => { it("folds an automation prompt into a bubble titled by its task", async () => { setup({ scheduledTasks: [{ id: TASK_ID, title: "Nightly triage" }] }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 169acd8630d05..b4b347ebf396b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -27,7 +27,7 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, - timelineEntryTurnFoldRunId, + timelineTurnFoldRunIdsByEntryId, deriveMessagesTimelineRowsWithState, shouldCollapseUserMessage, resolveAssistantMessageCopyState, @@ -1401,7 +1401,7 @@ describe("deriveMessagesTimelineRows", () => { }, ); - it("splits a settled turn's work at each steer and keeps the total time on top", () => { + it("splits a settled turn's work at each steer into its own response", () => { const runId = RunId.make("steered-run"); const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); const message = ( @@ -1455,8 +1455,10 @@ describe("deriveMessagesTimelineRows", () => { `turn-fold:${segmentKey}`, "final", ]); - expect(collapsed[1]).toMatchObject({ label: "Worked for 30s" }); - expect(collapsed[3]).toMatchObject({ label: "Continued for 20s", createdAt: time(10) }); + // Each side of the steer is its own response: the prompt's ends at its last + // output before the steer, and the steer's runs to the end of the turn. + expect(collapsed[1]).toMatchObject({ label: "Worked for 4.0s" }); + expect(collapsed[3]).toMatchObject({ label: "Worked for 20s", createdAt: time(10) }); expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([ "prompt", @@ -2154,15 +2156,15 @@ describe("deriveMessagesTimelineRows", () => { message("imported-answer", "assistant", 8), ]; const foldInput = { timelineEntries, latestRun: null, isWorking: false }; - const foldRunId = timelineEntryTurnFoldRunId(foldInput, "imported-update"); - expect(foldRunId).not.toBeNull(); + const foldRunId = timelineTurnFoldRunIdsByEntryId(foldInput).get("imported-update")?.[0]; + expect(foldRunId).toBeDefined(); const foldRow = deriveMessagesTimelineRows({ ...foldInput, turnDiffSummaries: [], supportsConversationRollback: false, }).find((row) => row.kind === "turn-fold"); expect(foldRow?.kind === "turn-fold" ? foldRow.runId : null).toBe(foldRunId); - expect(timelineEntryTurnFoldRunId(foldInput, "imported-answer")).toBeNull(); + expect(timelineTurnFoldRunIdsByEntryId(foldInput).get("imported-answer")).toBeUndefined(); const expanded = deriveMessagesTimelineRows({ ...foldInput, expandedRunIds: new Set([foldRunId!]), @@ -3390,9 +3392,10 @@ describe("linked timeline resources", () => { event("d", "subagent"), event("e", "subagent", RunId.make("other-run")), ], - expandedRunIds: new Set([runId, RunId.make("other-run")]), + expandedRunIds: new Set([RunId.make(`group:partial:start`), runId, RunId.make("other-run")]), }); expect(rows.map((row) => row.id)).toEqual([ + `turn-fold:group:partial:start`, "turn-fold:resource-run", "a", "c", @@ -3400,7 +3403,7 @@ describe("linked timeline resources", () => { "turn-fold:other-run", "e", ]); - expect(rows[1]).toMatchObject({ subagents: [{ item: { id: "a" } }, { item: { id: "b" } }] }); + expect(rows[2]).toMatchObject({ subagents: [{ item: { id: "a" } }, { item: { id: "b" } }] }); }); it.each([ @@ -3594,8 +3597,9 @@ describe("linked timeline resources", () => { expandedRunIds: new Set(expanded), }).map((row) => row.id); expect(rowIds([runId])).not.toContain("created"); + // The output before the steer is that response's answer, so it stays visible. expect(rowIds([segmentKey])).toEqual([ - `turn-fold:${runId}`, + "intro", "steer", `turn-fold:${segmentKey}`, "created", @@ -3608,7 +3612,7 @@ describe("linked timeline resources", () => { }); it.each([true, false])( - "keeps completed responses folded across notification wakes, working=%s", + "folds wakes before the first loaded prompt under one group, working=%s", (isWorking) => { const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); const work = (id: string, run: string, second: number, notification = false) => ({ @@ -3660,19 +3664,376 @@ it.each([true, false])( turnDiffSummaries: [], supportsConversationRollback: false, }); + // The window opened after the prompt, so the group has no start to time from. const ids = rows.map((row) => row.id); - expect(ids).toContain("turn-fold:initial"); + const group = "turn-fold:group:partial:start"; + expect(rows[0]).toMatchObject({ + id: group, + label: isWorking ? "Working · 2 updates" : "Worked · 2 updates", + }); + if (!isWorking) { + expect(ids).toEqual([group, "ack-b"]); + return; + } expect(ids).not.toContain("launch-tools"); - expect(ids).toContain("turn-fold:wake-a"); - expect(ids.indexOf("notification-a")).toBeLessThan(ids.indexOf("turn-fold:wake-a")); - expect(ids.indexOf("turn-fold:wake-a")).toBeLessThan(ids.indexOf("ack-a")); - const boundary = isWorking ? "working-indicator-row" : "turn-fold:wake-b"; - expect(ids).toContain(boundary); - expect(ids.indexOf("notification-b")).toBeLessThan(ids.indexOf(boundary)); - if (isWorking) expect(rows.find((row) => row.id === boundary)?.createdAt).toBe(time(40)); + expect(ids).not.toContain("notification-a"); + expect(ids.indexOf("ack-a")).toBeLessThan(ids.indexOf("notification-b")); + expect(ids.indexOf("notification-b")).toBeLessThan(ids.indexOf("working-indicator-row")); + expect(rows.find((row) => row.id === "working-indicator-row")?.createdAt).toBe(time(40)); }, ); +describe("response groups", () => { + const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); + const prompt = ( + id: string, + run: string, + second: number, + extra: Partial = {}, + ): TimelineEntry => ({ + kind: "message", + id, + createdAt: time(second), + message: { + id: MessageId.make(id), + role: "user", + text: id, + runId: RunId.make(run), + createdAt: time(second), + updatedAt: time(second), + streaming: false, + ...extra, + }, + }); + const work = ( + id: string, + run: string, + second: number, + extra: Partial["entry"]> = {}, + ): TimelineEntry => ({ + kind: "work", + id, + createdAt: time(second), + entry: { + id, + runId: RunId.make(run), + createdAt: time(second), + label: id, + tone: "tool", + ...extra, + }, + }); + const notification = (id: string, run: string, second: number) => + work(id, run, second, { tone: "info", itemType: "notification" }); + const answer = (id: string, run: string, second: number): TimelineEntry => ({ + kind: "message", + id, + createdAt: time(second), + message: { + id: MessageId.make(id), + role: "assistant", + text: id, + runId: RunId.make(run), + createdAt: time(second), + updatedAt: time(second), + streaming: false, + }, + }); + type TimelineEntryMessage = Extract["message"]; + const goalThread = (options: { lastWakeAnswers?: boolean } = {}) => [ + prompt("prompt", "initial", 0), + work("launch-tools", "initial", 1), + answer("launched", "initial", 5), + notification("notification-a", "wake-a", 20), + work("read-a", "wake-a", 21), + answer("ack-a", "wake-a", 25), + notification("notification-b", "wake-b", 40), + work("read-b", "wake-b", 41), + ...(options.lastWakeAnswers === false ? [] : [answer("ack-b", "wake-b", 45)]), + ]; + const settled = ( + timelineEntries: ReadonlyArray, + options: { + expandedRunIds?: ReadonlySet; + status?: "completed" | "interrupted"; + turnDiffSummaries?: ReadonlyArray; + } = {}, + ) => + deriveMessagesTimelineRows({ + timelineEntries, + latestRun: { + runId: RunId.make("wake-b"), + status: options.status ?? "completed", + startedAt: time(40), + completedAt: time(45), + }, + isWorking: false, + ...(options.expandedRunIds ? { expandedRunIds: options.expandedRunIds } : {}), + turnDiffSummaries: options.turnDiffSummaries ?? [], + supportsConversationRollback: false, + }); + const groupKey = RunId.make("group:prompt"); + + it("folds every wake since the prompt behind one row timed from the prompt", () => { + const collapsed = settled(goalThread()); + expect(collapsed.map((row) => row.id)).toEqual(["prompt", `turn-fold:${groupKey}`, "ack-b"]); + expect(collapsed[1]).toMatchObject({ label: "Worked for 45s · 2 updates", expanded: false }); + + const expanded = settled(goalThread(), { expandedRunIds: new Set([groupKey]) }); + expect(expanded.map((row) => row.id)).toEqual([ + "prompt", + `turn-fold:${groupKey}`, + "turn-fold:initial", + "launched", + "notification-a", + "turn-fold:wake-a", + "ack-a", + "notification-b", + "turn-fold:wake-b", + "ack-b", + ]); + }); + + it("keeps the last answer when the latest wake only ran tools", () => { + expect(settled(goalThread({ lastWakeAnswers: false })).map((row) => row.id)).toEqual([ + "prompt", + `turn-fold:${groupKey}`, + "ack-a", + ]); + }); + + it("keeps the previous answer and the live wake visible while it works", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: goalThread({ lastWakeAnswers: false }), + latestRun: { + runId: RunId.make("wake-b"), + status: "running", + startedAt: time(40), + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: time(0), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + const ids = rows.map((row) => row.id); + expect(rows.find((row) => row.id === `turn-fold:${groupKey}`)).toMatchObject({ + label: "Working · 2 updates", + }); + expect(ids).not.toContain("launched"); + expect(ids).not.toContain("notification-a"); + expect(ids.indexOf("ack-a")).toBeGreaterThan(ids.indexOf(`turn-fold:${groupKey}`)); + expect(ids.indexOf("notification-b")).toBeGreaterThan(ids.indexOf("ack-a")); + }); + + it("counts an earlier failed run and labels a stopped latest run", () => { + const failure = work("error-a", "wake-a", 22, { + tone: "error", + itemType: "error", + toolLifecycleStatus: "failed", + projectedItem: { + item: { type: "error", status: "failed", parentItemId: null, runId: RunId.make("wake-a") }, + } as never, + }); + const entries = goalThread(); + entries.splice(entries.findIndex((entry) => entry.id === "read-a") + 1, 0, failure); + const rows = settled(entries, { status: "interrupted" }); + const ids = rows.map((row) => row.id); + expect(rows[1]).toMatchObject({ label: "You stopped after 45s · 2 updates · 1 failed" }); + expect(ids).not.toContain("error-a"); + }); + + it("opens a lone run by its own key or its group key, so a later wake keeps it open", () => { + const single = settled([prompt("prompt", "initial", 0), ...goalThread().slice(1, 3)], {}); + const fold = single.find((row) => row.kind === "turn-fold"); + expect(fold).toMatchObject({ id: "turn-fold:initial", foldKeys: ["initial", groupKey] }); + + const opened = new Set(fold?.kind === "turn-fold" ? fold.foldKeys : []); + const ids = settled(goalThread(), { expandedRunIds: opened }).map((row) => row.id); + expect(ids.slice(0, 4)).toEqual([ + "prompt", + `turn-fold:${groupKey}`, + "turn-fold:initial", + "launch-tools", + ]); + }); + + it("starts a response at every prompt, including agent-sent ones, but not at idle compaction", () => { + const entries = [ + ...goalThread(), + prompt("compact", "compaction", 50, { + text: "/compact", + createdBy: "agent", + creationSource: "server", + }), + work("compaction-work", "compaction", 51, { sourceActivityKind: "context-compaction" }), + prompt("agent-prompt", "delegated", 60, { createdBy: "agent", creationSource: "mcp" }), + work("delegated-tools", "delegated", 61), + answer("delegated-answer", "delegated", 65), + ]; + const ids = deriveMessagesTimelineRows({ + timelineEntries: entries, + latestRun: { + runId: RunId.make("delegated"), + status: "completed", + startedAt: time(60), + completedAt: time(65), + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }).flatMap((row) => (row.kind === "turn-fold" ? [row.runId] : [])); + expect(ids).toEqual([groupKey, "delegated"]); + }); + + it("shows one changed-files card for the group that opens the whole range", () => { + const summary = (run: string, turnCount: number, files: TurnDiffSummary["files"]) => + ({ + runId: RunId.make(run), + checkpointTurnCount: turnCount, + checkpointRef: `refs/t3/${run}` as never, + status: "ready", + files, + assistantMessageId: null, + completedAt: time(turnCount), + }) satisfies TurnDiffSummary; + const rows = settled(goalThread(), { + turnDiffSummaries: [ + summary("initial", 4, [{ path: "a.ts", kind: "modified", additions: 1, deletions: 0 }]), + summary("wake-b", 6, [ + { path: "a.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "b.ts", kind: "added", additions: 5, deletions: 0 }, + ]), + ], + }); + const final = rows.find((row) => row.id === "ack-b"); + expect(final?.kind === "message" ? final.assistantTurnDiffSummary : null).toMatchObject({ + runId: "wake-b", + checkpointTurnCount: 6, + rangeFromTurnCount: 3, + files: [ + { path: "a.ts", kind: "modified", additions: 3, deletions: 1 }, + { path: "b.ts", kind: "added", additions: 5, deletions: 0 }, + ], + }); + }); + + it("ends the group's diff at its last completed run and drops the merged card when open", () => { + const summary = (run: string, turnCount: number, path: string) => + ({ + runId: RunId.make(run), + checkpointTurnCount: turnCount, + checkpointRef: `refs/t3/${run}` as never, + status: "ready", + files: [{ path, kind: "modified", additions: 1, deletions: 0 }], + assistantMessageId: run === "wake-b" ? MessageId.make("ack-b") : null, + completedAt: time(turnCount), + }) satisfies TurnDiffSummary; + const turnDiffSummaries = [ + summary("initial", 4, "a.ts"), + summary("wake-a", 5, "b.ts"), + summary("wake-b", 6, "c.ts"), + ]; + const card = (rows: ReadonlyArray) => { + const final = rows.find((row) => row.id === "ack-b"); + return final?.kind === "message" ? final.assistantTurnDiffSummary : undefined; + }; + // The stopped wake's checkpoint cannot end a diff, but its files still count. + expect(card(settled(goalThread(), { status: "interrupted", turnDiffSummaries }))).toMatchObject( + { + checkpointTurnCount: 5, + rangeFromTurnCount: 3, + files: [{ path: "a.ts" }, { path: "b.ts" }, { path: "c.ts" }], + }, + ); + const open = settled(goalThread(), { expandedRunIds: new Set([groupKey]), turnDiffSummaries }); + expect(card(open)).toBe(turnDiffSummaries[2]); + }); + + it("keeps the latest run's row while it is open under a collapsed group", () => { + const ids = settled(goalThread(), { expandedRunIds: new Set([RunId.make("wake-b")]) }).map( + (row) => row.id, + ); + expect(ids).toEqual(["prompt", `turn-fold:${groupKey}`, "turn-fold:wake-b", "read-b", "ack-b"]); + }); + + it("never shows a partial reply from a replaced attempt as the group's answer", () => { + const entries = goalThread({ lastWakeAnswers: false }); + const partial = answer("partial-b", "wake-b", 43); + entries.push({ + ...partial, + attempt: { + id: "attempt-b" as never, + runId: RunId.make("wake-b"), + status: "superseded", + completedAt: time(44), + } as never, + }); + const ids = settled(entries).map((row) => row.id); + expect(ids).toContain("ack-a"); + expect(ids).not.toContain("partial-b"); + }); + + it("shows the group's answer even when its own run's fold would hide it", () => { + const entries = goalThread({ lastWakeAnswers: false }); + const late = answer("late-a", "wake-a", 26); + entries.splice(entries.findIndex((entry) => entry.id === "ack-a") + 1, 0, { + ...late, + attempt: { + id: "attempt-a" as never, + runId: RunId.make("wake-a"), + status: "superseded", + completedAt: time(27), + } as never, + }); + const ids = settled(entries).map((row) => row.id); + expect(ids).toContain("ack-a"); + expect(ids).not.toContain("late-a"); + + // The same holds when the late output lands in the latest run. + const latest = goalThread(); + latest.push({ + ...answer("late-b", "wake-b", 46), + attempt: { + id: "attempt-b" as never, + runId: RunId.make("wake-b"), + status: "superseded", + completedAt: time(47), + } as never, + }); + expect(settled(latest).map((row) => row.id)).toContain("ack-b"); + }); + + it("keeps the group row when the prompt's first entry is an interrupt request", () => { + const entries = goalThread(); + entries.splice(1, 0, { + kind: "event", + id: "interrupt", + createdAt: time(1), + projectedItem: { + item: { type: "run_interrupt_request", runId: RunId.make("initial") }, + } as never, + }); + const ids = settled(entries).map((row) => row.id); + expect(ids).toEqual(["prompt", `turn-fold:${groupKey}`, "ack-b"]); + }); + + it("lets find open both the group and the run an entry hides behind", () => { + const keys = timelineTurnFoldRunIdsByEntryId({ + timelineEntries: goalThread(), + latestRun: { + runId: RunId.make("wake-b"), + status: "completed", + startedAt: time(40), + completedAt: time(45), + }, + isWorking: false, + }); + expect(keys.get("read-a")).toEqual(["wake-a", groupKey]); + expect(keys.get("ack-a")).toEqual([groupKey]); + }); +}); + it("keeps the working header in place across worktree setup handoff", () => { const snapshot: WorktreeSetupSnapshot = { threadId: ThreadId.make("thread-setup"), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 0648d1832f078..a234c2d6f1c12 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,6 +1,12 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + mergeResponseGroupCheckpoints, + messageStartsResponseGroup, + responseGroupFoldLabel, + responseGroupKey, +} from "@t3tools/client-runtime/state/response-groups"; import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; import { @@ -32,7 +38,6 @@ import { export { toolGroupAction } from "@t3tools/client-runtime/work-log/presentation"; import { deriveRevertTurnCountByUserMessageId, - formatDuration, isStreamingMessageTextUpdate, isStreamingTurnItemTextUpdate, timelineEntryIsPersistentResourceCard, @@ -569,6 +574,8 @@ type MessagesTimelineRowContent = id: string; createdAt: string; runId: RunId; + /** Toggling the row opens or closes all of these. */ + foldKeys: ReadonlyArray; label: string; expanded: boolean; } @@ -733,16 +740,6 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray; - /** Every entry in the segment, including the cards a fold never hides. */ - entryIds: ReadonlySet; - label: string; -} - interface SupersededAttemptFold { readonly runId: RunId; readonly attemptId: RunAttemptId; @@ -987,15 +984,80 @@ function failedTimelineRunIds( return failed; } +interface TurnFold { + /** The segment's own key: its run id, a runless prompt key, or a post-steer key. */ + runId: RunId; + /** Any of these keys opens the fold. A response's only segment also answers to its group key. */ + foldKeys: ReadonlyArray; + /** Set when a multi-segment response group owns this fold; its collapsed row hides this one. */ + groupKey: RunId | null; + anchorEntryId: string; + createdAt: string; + hiddenEntryIds: ReadonlySet; + /** Every entry in the segment, including the cards a fold never hides. */ + entryIds: ReadonlySet; + label: string; +} + +/** + * One row for every run from a prompt to the latest answer, so the background + * wakes of a long `/goal` thread fold away together. Expanded, it shows the + * per-run folds as before. + */ +interface ResponseGroupFold { + key: RunId; + anchorEntryId: string; + createdAt: string; + /** Hidden while collapsed: earlier runs and their notifications. */ + hiddenEntryIds: ReadonlySet; + /** + * The latest run's own fold also hides under a collapsed group, unless it was + * opened on its own (an interrupt keeps the stopped run open). + */ + trailingFold: TurnFold | null; + label: string; + /** The answer that stays visible; its changed-files card covers the whole group. */ + finalMessageId: MessageId | null; + finalEntryId: string | null; + /** Runs whose checkpoint belongs here. A steer-split run's belongs to the later group. */ + checkpointRunIds: ReadonlyArray; + /** Stopped or failed runs: the group's diff cannot end at their checkpoint. */ + unfinishedRunIds: ReadonlySet; +} + +interface TimelineFolds { + turnFolds: ReadonlyMap; + groupFolds: ReadonlyMap; +} + +const PARTIAL_GROUP_PREFIX = "group:partial:"; + +function timelineEntryAlwaysVisibleInFold( + entry: TimelineEntry, + input: { + liveSubagentEntryIds: ReadonlySet; + completeSupersededReplyEntryIds: ReadonlySet; + }, +): boolean { + return ( + timelineEntryIsCompaction(entry) || + timelineEntryIsPersistentResourceCard(entry) || + input.liveSubagentEntryIds.has(entry.id) || + input.completeSupersededReplyEntryIds.has(entry.id) || + // User input stays visible after the surrounding work settles. + (entry.kind === "work" && entry.entry.questionAnswer !== undefined) + ); +} + function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; - terminalAssistantMessageIds: ReadonlySet; latestRun: TimelineLatestRun | null; - unfoldedRunIds: ReadonlySet; + activeRunIds: ReadonlySet; + failedRunIds: ReadonlySet; runlessWorkActive: boolean; liveSubagentEntryIds: ReadonlySet; completeSupersededReplyEntryIds: ReadonlySet; -}): ReadonlyMap { +}): TimelineFolds { const interruptedRunIds = new Set(); const promptIndexByRunId = new Map(); for (const [index, entry] of input.timelineEntries.entries()) { @@ -1013,35 +1075,98 @@ function deriveTurnFolds(input: { } } + interface Segment { + key: RunId; + runId: RunId; + entries: ReadonlyArray; + firstIndex: number; + startedAt: string; + anchorEntryId: string; + /** The wake notification that started this run, when one did. */ + notificationEntryId: string | null; + lastOfRun: boolean; + terminalEntry: Extract | null; + /** Never folds: live, streaming, failed, or interrupted. */ + unfolded: boolean; + live: boolean; + fold: TurnFold | null; + } + interface Response { + key: RunId; + /** Null when the history window opens after the prompt. */ + startedAt: string | null; + segments: Segment[]; + } interface TurnGroup { entries: Array; - terminalEntry: Extract | null; - hasStreamingMessage: boolean; - // Entry timestamps omit startup and can collapse steered turns to an instant. + indexes: Array; + /** + * Entry timestamps alone undercount the duration (the first entry appears + * only once the provider starts producing output). + */ startBoundary: string | null; anchorEntryId: string; + notificationEntryId: string | null; + response: Response | null; /** - * Each steer after the turn has produced output starts a new segment at - * this index into `entries`, so the steer sits between the work it split. + * Each steer after the turn has produced output starts a new segment, and + * a new response, at this index into `entries`. */ - steerSegments: Array<{ startIndex: number; steer: TimelineEntry }>; + steerSegments: Array<{ startIndex: number; steer: TimelineEntry; response: Response }>; } const groupsByRunId = new Map(); const runlessFailedKeys = new Set(); + const responses: Response[] = []; + const openResponse = (entry: TimelineEntry): Response => { + const response = { + key: RunId.make(responseGroupKey(entry.id)), + startedAt: entry.createdAt, + segments: [], + }; + responses.push(response); + return response; + }; + let firstPromptIndex: number | null = null; + let partialResponse: Response | null = null; + // Named by the prompt it precedes, so loading older history keeps it open. + const openPartialResponse = (): Response => { + const partial = { + key: RunId.make( + `${PARTIAL_GROUP_PREFIX}${firstPromptIndex === null ? "start" : (input.timelineEntries[firstPromptIndex]?.id ?? "start")}`, + ), + startedAt: null, + segments: [], + }; + responses.unshift(partial); + return partial; + }; // Key runless responses per prompt to prevent V2 runs unfolding imported turns. let runlessKey: RunId | null = null; let responseKey: RunId | null = null; - let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; + let response: Response | null = null; + let pendingBoundary: { + createdAt: string; + anchorEntryId: string; + notificationEntryId: string | null; + } | null = null; for (const [index, entry] of input.timelineEntries.entries()) { if (timelineEntryStartsResponse(entry)) { const nextEntry = input.timelineEntries[index + 1]; pendingBoundary = nextEntry - ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } + ? { + createdAt: entry.createdAt, + anchorEntryId: nextEntry.id, + notificationEntryId: entry.kind === "work" ? entry.id : null, + } : null; const boundaryRunId = responseBoundaryRunId(entry); runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null; responseKey = boundaryRunId ?? runlessKey; + if (entry.kind === "message" && messageStartsResponseGroup(entry.message)) { + response = openResponse(entry); + firstPromptIndex ??= index; + } continue; } if (entry.kind === "message" && timelineEntryIsSteer(entry)) { @@ -1052,7 +1177,8 @@ function deriveTurnFolds(input: { if (group.steerSegments.at(-1)?.startIndex === group.entries.length) { group.steerSegments.pop(); } - group.steerSegments.push({ startIndex: group.entries.length, steer: entry }); + response = openResponse(entry); + group.steerSegments.push({ startIndex: group.entries.length, steer: entry, response }); } continue; } @@ -1072,138 +1198,231 @@ function deriveTurnFolds(input: { if (!group) { group = { entries: [], - terminalEntry: null, - hasStreamingMessage: false, + indexes: [], startBoundary: pendingBoundary?.createdAt ?? null, anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id, + notificationEntryId: pendingBoundary?.notificationEntryId ?? null, + response, steerSegments: [], }; pendingBoundary = null; groupsByRunId.set(runId, group); } group.entries.push(entry); - if (entry.kind === "message") { - if (input.terminalAssistantMessageIds.has(entry.message.id)) { - group.terminalEntry = entry; - } - if (entry.message.streaming) { - group.hasStreamingMessage = true; - } - } + group.indexes.push(index); } - const foldsByAnchorEntryId = new Map(); + const failedRunIds = new Set([...input.failedRunIds, ...runlessFailedKeys]); + const segments: Segment[] = []; for (const [runId, group] of groupsByRunId) { + const live = input.activeRunIds.has(runId) || (input.runlessWorkActive && runId === runlessKey); + const runUnfolded = live || failedRunIds.has(runId) || interruptedRunIds.has(runId); + const slices = [ + { startIndex: 0, steer: null, response: group.response }, + ...group.steerSegments, + ]; + for (const [sliceIndex, slice] of slices.entries()) { + const entries = group.entries.slice(slice.startIndex, slices[sliceIndex + 1]?.startIndex); + const firstEntry = entries[0]; + if (firstEntry === undefined) continue; + const streaming = entries.some( + (entry) => entry.kind === "message" && entry.message.streaming, + ); + const segment: Segment = { + key: slice.steer === null ? runId : RunId.make(`${runId}:steer:${slice.steer.id}`), + runId, + entries, + firstIndex: group.indexes[slice.startIndex] ?? 0, + startedAt: + slice.steer !== null + ? slice.steer.createdAt + : (group.startBoundary ?? + (input.latestRun?.runId === runId ? input.latestRun.startedAt : null) ?? + firstEntry.createdAt), + anchorEntryId: slice.steer === null ? group.anchorEntryId : firstEntry.id, + notificationEntryId: slice.steer === null ? group.notificationEntryId : null, + lastOfRun: sliceIndex === slices.length - 1, + // Late output from the attempt a steer replaced is not the new answer. + terminalEntry: + entries.findLast( + (entry): entry is Extract => + entry.kind === "message" && + entry.message.role === "assistant" && + (slice.steer === null || entry.attempt?.status !== "superseded"), + ) ?? null, + unfolded: runUnfolded || streaming, + live: live || streaming, + fold: null, + }; + segments.push(segment); + // A history window can open mid-response, so runs before the first + // loaded prompt fold together under a group with no known start. + const owner = + slice.response ?? + (segment.firstIndex < (firstPromptIndex ?? Infinity) + ? (partialResponse ??= openPartialResponse()) + : null); + owner?.segments.push(segment); + } + } + + const segmentEnd = (segment: Segment): string => { if ( - input.unfoldedRunIds.has(runId) || - interruptedRunIds.has(runId) || - runlessFailedKeys.has(runId) || - (input.runlessWorkActive && runId === runlessKey) + segment.lastOfRun && + input.latestRun?.runId === segment.runId && + input.latestRun.completedAt ) { - continue; - } - if (group.hasStreamingMessage) { - continue; + return input.latestRun.completedAt; } + // A turn cut short by a steer leaves trailing work entries behind its + // terminal message — take whichever ended last. + const lastEntryEnd = timelineEntryEnd(segment.entries.at(-1)!); + return ( + maxIsoTimestamp(segment.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? + lastEntryEnd + ); + }; + const segmentStopped = (segment: Segment) => + segment.lastOfRun && + (interruptedRunIds.has(segment.runId) || + (input.latestRun?.runId === segment.runId && input.latestRun.status === "interrupted")); + + for (const segment of segments) { + if (segment.unfolded) continue; const hiddenEntryIds = new Set(); - const terminalEntryIndex = group.terminalEntry - ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id) - : group.entries.length; - for (const [index, entry] of group.entries.entries()) { - if (entry.id === group.terminalEntry?.id) { - continue; - } + let hidesNonCompactionWork = false; + const terminalEntryIndex = segment.terminalEntry + ? segment.entries.indexOf(segment.terminalEntry) + : segment.entries.length; + for (const [index, entry] of segment.entries.entries()) { + if (entry === segment.terminalEntry) continue; const isCompaction = timelineEntryIsCompaction(entry); const isFoldableTrailingActivity = entry.kind === "work" && entry.entry.toolLifecycleStatus !== "inProgress" && !workEntryDisplayIndicatesToolFailure(entry.entry); - if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) { - continue; - } - if ( - isCompaction || - timelineEntryIsPersistentResourceCard(entry) || - input.liveSubagentEntryIds.has(entry.id) || - input.completeSupersededReplyEntryIds.has(entry.id) - ) { - continue; - } - if (entry.kind === "work" && entry.entry.itemType === "notification") continue; - if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) continue; + if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) continue; + if (timelineEntryAlwaysVisibleInFold(entry, input)) continue; hiddenEntryIds.add(entry.id); - } - if (hiddenEntryIds.size === 0) { - continue; - } - const hidesNonCompactionWork = (entries: ReadonlyArray) => - entries.some((entry) => hiddenEntryIds.has(entry.id) && !timelineEntryIsCompaction(entry)); - if (!hidesNonCompactionWork(group.entries)) { - continue; - } + hidesNonCompactionWork ||= !isCompaction; + } + if (!hidesNonCompactionWork) continue; + segment.fold = { + runId: segment.key, + foldKeys: [segment.key], + groupKey: null, + anchorEntryId: segment.anchorEntryId, + createdAt: segment.startedAt, + hiddenEntryIds, + entryIds: new Set(segment.entries.map((entry) => entry.id)), + label: responseGroupFoldLabel({ + durationMs: computeElapsedMs(segment.startedAt, segmentEnd(segment)), + updates: 0, + failed: 0, + stopped: segmentStopped(segment), + live: false, + }), + }; + } - const firstEntry = group.entries[0]; - const lastEntry = group.entries.at(-1); - if (!firstEntry || !lastEntry) { + const groupFolds = new Map(); + for (const { key, startedAt, segments: members } of responses) { + // A run that only left cards a fold never hides adds nothing to fold. + const ordered = members + .filter( + (segment) => + segment.notificationEntryId !== null || + segment.entries.some((entry) => !timelineEntryAlwaysVisibleInFold(entry, input)), + ) + .toSorted((left, right) => left.firstIndex - right.firstIndex); + const last = ordered.at(-1); + if (last === undefined) continue; + const live = ordered.some((segment) => segment.live); + const end = live ? null : ordered.map(segmentEnd).reduce(maxIsoTimestamp, null); + const label = responseGroupFoldLabel({ + durationMs: end === null || startedAt === null ? null : computeElapsedMs(startedAt, end), + updates: ordered.filter((segment) => segment.notificationEntryId !== null).length, + failed: new Set( + ordered + .slice(0, -1) + .filter( + (segment) => failedRunIds.has(segment.runId) && !interruptedRunIds.has(segment.runId), + ) + .map((segment) => segment.runId), + ).size, + stopped: segmentStopped(last), + live, + }); + if (ordered.length === 1) { + // A lone run with no loaded prompt keeps its own timing. + if (last.fold && startedAt !== null) { + last.fold.label = label; + last.fold.foldKeys = [last.key, key]; + } continue; } + if (ordered.every((segment) => segment.live)) continue; - const isLatestInterruptedTurn = - input.latestRun?.runId === runId && input.latestRun.status === "interrupted"; - // A turn cut short by a steer leaves trailing work entries behind its - // terminal message — take whichever ended last. - const lastEntryEnd = timelineEntryEnd(lastEntry); - const elapsedMs = - input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt - ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) - : computeElapsedMs( - group.startBoundary ?? firstEntry.createdAt, - maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? - lastEntryEnd, - ); - const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; - const label = isLatestInterruptedTurn - ? duration - ? `You stopped after ${duration}` - : "You stopped this response" - : duration - ? `Worked for ${duration}` - : "Worked"; - - // The top row keeps the whole turn's time, steers included. Work after a - // steer folds under its own row below that steer, keyed apart from the run - // so each segment opens on its own. - const segmentHiddenIds = (entries: ReadonlyArray) => - new Set(entries.flatMap((entry) => (hiddenEntryIds.has(entry.id) ? [entry.id] : []))); - const firstSegment = group.entries.slice(0, group.steerSegments[0]?.startIndex); - foldsByAnchorEntryId.set(group.anchorEntryId, { - runId, - anchorEntryId: group.anchorEntryId, - createdAt: group.startBoundary ?? firstEntry.createdAt, - hiddenEntryIds: segmentHiddenIds(firstSegment), - entryIds: new Set(firstSegment.map((entry) => entry.id)), + const hiddenEntryIds = new Set(); + for (const segment of ordered.slice(0, -1)) { + if (segment.notificationEntryId !== null) hiddenEntryIds.add(segment.notificationEntryId); + for (const entry of segment.entries) { + if (!timelineEntryAlwaysVisibleInFold(entry, input)) hiddenEntryIds.add(entry.id); + } + } + if (!last.unfolded && last.notificationEntryId !== null) { + hiddenEntryIds.add(last.notificationEntryId); + } + // The newest settled answer stays, even when the latest wake only ran + // tools. Output from an attempt a steer replaced counts only if complete. + const finalEntry = ordered + .filter((segment) => !segment.live) + .flatMap((segment) => segment.entries) + .findLast( + (entry): entry is Extract => + entry.kind === "message" && + entry.message.role === "assistant" && + !entry.message.streaming && + (entry.attempt?.status !== "superseded" || + input.completeSupersededReplyEntryIds.has(entry.id)), + ); + if (finalEntry) hiddenEntryIds.delete(finalEntry.id); + const trailingFold = last.unfolded ? null : last.fold; + if (hiddenEntryIds.size === 0 && trailingFold === null) continue; + for (const segment of ordered) { + if (segment.fold) segment.fold.groupKey = key; + } + const first = ordered[0]!; + groupFolds.set(first.anchorEntryId, { + key, + anchorEntryId: first.anchorEntryId, + createdAt: startedAt ?? first.startedAt, + hiddenEntryIds, + trailingFold, label, + finalMessageId: finalEntry?.message.id ?? null, + finalEntryId: finalEntry?.id ?? null, + checkpointRunIds: ordered + .filter((segment) => segment.lastOfRun) + .map((segment) => segment.runId), + unfinishedRunIds: new Set( + ordered + .map((segment) => segment.runId) + .filter( + (runId) => + failedRunIds.has(runId) || + interruptedRunIds.has(runId) || + (input.latestRun?.runId === runId && input.latestRun.status !== "completed"), + ), + ), }); - for (const [index, { startIndex, steer }] of group.steerSegments.entries()) { - const entries = group.entries.slice(startIndex, group.steerSegments[index + 1]?.startIndex); - const anchorEntry = entries[0]; - if (anchorEntry === undefined || !hidesNonCompactionWork(entries)) continue; - const segmentMs = computeElapsedMs( - steer.createdAt, - entries.map(timelineEntryEnd).reduce(maxIsoTimestamp, null) ?? - steer.createdAt, - ); - foldsByAnchorEntryId.set(anchorEntry.id, { - runId: RunId.make(`${runId}:steer:${steer.id}`), - anchorEntryId: anchorEntry.id, - createdAt: steer.createdAt, - hiddenEntryIds: segmentHiddenIds(entries), - entryIds: new Set(entries.map((entry) => entry.id)), - label: segmentMs !== null ? `Continued for ${formatDuration(segmentMs)}` : "Continued", - }); - } } - return foldsByAnchorEntryId; + + const turnFolds = new Map(); + for (const segment of segments) { + if (segment.fold) turnFolds.set(segment.anchorEntryId, segment.fold); + } + return { turnFolds, groupFolds }; } function attachTrailingToolGroupsToAssistant( @@ -1370,9 +1589,9 @@ function deriveTimelineTurnFolds( }); return deriveTurnFolds({ timelineEntries, - terminalAssistantMessageIds: deriveTerminalAssistantMessageIds(timelineEntries), latestRun: input.latestRun ?? null, - unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), + activeRunIds: activeVisualResponseRunIds, + failedRunIds, runlessWorkActive: input.isWorking && input.runlessWorkActive === true, liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries), completeSupersededReplyEntryIds: completeSupersededReplyEntryIds(timelineEntries), @@ -1434,19 +1653,27 @@ export function deriveMessagesTimelineRows(input: { isWorking: input.isWorking, }); const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; - const foldsByAnchorEntryId = deriveTurnFolds({ - timelineEntries: timelineEntries, - terminalAssistantMessageIds, - latestRun: input.latestRun ?? null, - unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), - runlessWorkActive, - liveSubagentEntryIds, - completeSupersededReplyEntryIds: completeSupersededReplyIds, - }); + const { turnFolds: foldsByAnchorEntryId, groupFolds: groupFoldsByAnchorEntryId } = + deriveTurnFolds({ + timelineEntries: timelineEntries, + latestRun: input.latestRun ?? null, + activeRunIds: activeVisualResponseRunIds, + failedRunIds, + runlessWorkActive, + liveSubagentEntryIds, + completeSupersededReplyEntryIds: completeSupersededReplyIds, + }); + const foldExpanded = (keys: ReadonlyArray) => + keys.some((key) => input.expandedRunIds?.has(key) === true); + const foldAnchorEntryIds = new Set([ + ...foldsByAnchorEntryId.keys(), + ...groupFoldsByAnchorEntryId.keys(), + ]); const collapsedEntryIds = new Set(); const collapsedFoldMemberIds = new Set(); + const collapsedGroupKeys = new Set(); for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedRunIds?.has(fold.runId)) { + if (!foldExpanded(fold.foldKeys)) { for (const entryId of fold.hiddenEntryIds) { collapsedEntryIds.add(entryId); } @@ -1455,6 +1682,32 @@ export function deriveMessagesTimelineRows(input: { } } } + const groupTurnDiffSummaryByMessageId = new Map(); + const openTrailingFoldKeys = new Set(); + for (const group of groupFoldsByAnchorEntryId.values()) { + if (input.expandedRunIds?.has(group.key)) continue; + collapsedGroupKeys.add(group.key); + // Collapsed, the answer's card covers the whole group; open, each run shows its own. + if (group.finalMessageId !== null) { + const runIds = new Set(group.checkpointRunIds); + const merged = mergeResponseGroupCheckpoints( + input.turnDiffSummaries.filter((summary) => runIds.has(summary.runId)), + group.unfinishedRunIds, + ); + if (merged) groupTurnDiffSummaryByMessageId.set(group.finalMessageId, merged); + } + // A latest run opened on its own keeps its row, so it can be closed again. + if (group.trailingFold && foldExpanded(group.trailingFold.foldKeys)) { + openTrailingFoldKeys.add(group.trailingFold.runId); + } + for (const entryId of group.hiddenEntryIds) collapsedEntryIds.add(entryId); + if (group.trailingFold && !foldExpanded(group.trailingFold.foldKeys)) { + for (const entryId of group.trailingFold.hiddenEntryIds) collapsedEntryIds.add(entryId); + } + // Its run's own fold may hide it (late output from a replaced attempt + // came after it), but a collapsed group always shows its answer. + if (group.finalEntryId !== null) collapsedEntryIds.delete(group.finalEntryId); + } const collapsedSupersededEntryIds = new Set(); for (const fold of supersededFoldsByAnchorEntryId.values()) { if (!input.expandedAttemptIds?.has(fold.attemptId)) { @@ -1490,7 +1743,7 @@ export function deriveMessagesTimelineRows(input: { entry.entry.sourceActivityKind === "context-compaction" || collapsedEntryIds.has(entry.id) || collapsedSupersededEntryIds.has(entry.id) || - foldsByAnchorEntryId.has(entry.id) || + foldAnchorEntryIds.has(entry.id) || supersededFoldsByAnchorEntryId.has(entry.id) ) { break; @@ -1664,30 +1917,51 @@ export function deriveMessagesTimelineRows(input: { appendActiveWorkRows(); } - // A stopped compaction's own divider already says it stopped. - if ( - timelineEntry.kind === "event" && - (timelineEntry.projectedItem.item.type === "run_interrupt_request" || - (timelineEntry.projectedItem.item.type === "run_interrupt_result" && - timelineEntry.projectedItem.item.runId !== null && - compactionRuns.has(timelineEntry.projectedItem.item.runId) && - compactedRunIds.has(timelineEntry.projectedItem.item.runId))) - ) { - continue; + // Fold rows come first: a fold can anchor on an interrupt request, which + // shows no row of its own. + const groupFold = groupFoldsByAnchorEntryId.get(timelineEntry.id); + if (groupFold) { + nextRows.push({ + kind: "turn-fold", + id: `turn-fold:${groupFold.key}`, + createdAt: groupFold.createdAt, + runId: groupFold.key, + foldKeys: [groupFold.key], + label: groupFold.label, + expanded: !collapsedGroupKeys.has(groupFold.key), + }); } const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); - if (turnFold) { + if ( + turnFold && + (turnFold.groupKey === null || + !collapsedGroupKeys.has(turnFold.groupKey) || + openTrailingFoldKeys.has(turnFold.runId)) + ) { nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, createdAt: turnFold.createdAt, runId: turnFold.runId, + foldKeys: turnFold.foldKeys, label: turnFold.label, - expanded: input.expandedRunIds?.has(turnFold.runId) ?? false, + expanded: foldExpanded(turnFold.foldKeys), }); } + // A stopped compaction's own divider already says it stopped. + if ( + timelineEntry.kind === "event" && + (timelineEntry.projectedItem.item.type === "run_interrupt_request" || + (timelineEntry.projectedItem.item.type === "run_interrupt_result" && + timelineEntry.projectedItem.item.runId !== null && + compactionRuns.has(timelineEntry.projectedItem.item.runId) && + compactedRunIds.has(timelineEntry.projectedItem.item.runId))) + ) { + continue; + } + if (collapsedEntryIds.has(timelineEntry.id)) { continue; } @@ -1791,7 +2065,7 @@ export function deriveMessagesTimelineRows(input: { activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || collapsedSupersededEntryIds.has(nextEntry.id) || - foldsByAnchorEntryId.has(nextEntry.id) || + foldAnchorEntryIds.has(nextEntry.id) || supersededFoldsByAnchorEntryId.has(nextEntry.id) || (nextEntry.entry.runId ?? null) !== (timelineEntry.entry.runId ?? null) || nextEntry.attempt?.id !== timelineEntry.attempt?.id @@ -2014,7 +2288,8 @@ export function deriveMessagesTimelineRows(input: { assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress, assistantTurnDiffSummary: timelineEntry.message.role === "assistant" - ? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) + ? (groupTurnDiffSummaryByMessageId.get(timelineEntry.message.id) ?? + turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)) : undefined, revertTurnCount: timelineEntry.message.role === "user" @@ -2187,28 +2462,31 @@ export interface MessagesTimelineRowsProjection { } /** - * Runless (imported V1) turns and work after a steer fold under synthetic - * keys, so the entry's own run id is not enough to open them. + * Every fold key an entry hides behind: its run's fold and its response + * group's. Runless (imported V1) turns and work after a steer fold under + * synthetic keys, so the entry's own run id is not enough to open them. */ -export function timelineEntryTurnFoldRunId( - input: Pick< - MessagesTimelineRowsInput, - "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId" - >, - entryId: string, -): RunId | null { - return timelineTurnFoldRunIdsByEntryId(input).get(entryId) ?? null; -} - export function timelineTurnFoldRunIdsByEntryId( input: Pick< MessagesTimelineRowsInput, "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId" >, -): ReadonlyMap { - const byEntryId = new Map(); - for (const fold of deriveTimelineTurnFolds(input).values()) { - for (const entryId of fold.hiddenEntryIds) byEntryId.set(entryId, fold.runId); +): ReadonlyMap> { + const byEntryId = new Map(); + const add = (entryId: string, key: RunId) => { + const keys = byEntryId.get(entryId); + if (keys === undefined) byEntryId.set(entryId, [key]); + else if (!keys.includes(key)) keys.push(key); + }; + const { turnFolds, groupFolds } = deriveTimelineTurnFolds(input); + for (const fold of turnFolds.values()) { + for (const entryId of fold.hiddenEntryIds) { + for (const key of fold.foldKeys) add(entryId, key); + } + } + for (const group of groupFolds.values()) { + for (const entryId of group.hiddenEntryIds) add(entryId, group.key); + for (const entryId of group.trailingFold?.hiddenEntryIds ?? []) add(entryId, group.key); } return byEntryId; } @@ -2225,6 +2503,22 @@ function sameCheckpointSummaries( return true; } +/** A response group's merged summary is rebuilt on each derivation; compare it by value. */ +function sameTurnDiffSummary( + previous: TurnDiffSummary | undefined, + next: TurnDiffSummary | undefined, +): boolean { + if (previous === next) return true; + if (previous === undefined || next === undefined) return false; + const { files: previousFiles, ...previousRest } = previous; + const { files: nextFiles, ...nextRest } = next; + return ( + shallow(previousRest, nextRest) && + previousFiles.length === nextFiles.length && + previousFiles.every((file, index) => shallow(file, nextFiles[index])) + ); +} + function replaceStreamingMessageRows( input: MessagesTimelineRowsInput, previous: MessagesTimelineRowsProjection, @@ -2366,7 +2660,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "turn-fold": { const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; + return ( + a.createdAt === bf.createdAt && + a.label === bf.label && + a.expanded === bf.expanded && + a.foldKeys.join("\0") === bf.foldKeys.join("\0") + ); } case "attempt-fold": { @@ -2457,7 +2756,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.showAssistantMeta === bm.showAssistantMeta && a.showAssistantCopyButton === bm.showAssistantCopyButton && a.assistantCopyStreaming === bm.assistantCopyStreaming && - a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && + sameTurnDiffSummary(a.assistantTurnDiffSummary, bm.assistantTurnDiffSummary) && a.revertTurnCount === bm.revertTurnCount ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dfcdce4c557b3..a6abeddb5accb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -233,7 +233,7 @@ interface MessagesTimelineProps { displayThreadKey?: string; /** Off where another region already speaks the thread's status, like a subagent's bar. */ announceTurnStatus?: boolean; - onOpenTurnDiff?: (runId: RunId, filePath?: string) => void; + onOpenTurnDiff?: (runId: RunId, filePath?: string, fromTurnCount?: number) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; turnItemSource?: TimelineRowSharedState["turnItemSource"]; @@ -441,6 +441,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ } const citationThreadRef = parseScopedThreadKey(routeThreadKey); const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); + const expandCitedRun = (runIds: ReadonlyArray) => { + setExpandedRunIds((current) => + runIds.every((runId) => current.has(runId)) ? current : new Set([...current, ...runIds]), + ); + }; const [minimapStripMap] = useState(() => new Map()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const disclosureAnchorKeyRef = useRef(null); @@ -497,14 +502,23 @@ const ConversationTimeline = memo(function ConversationTimeline({ return disclosureAnchorKey === null || row.id === disclosureAnchorKey; }; - const onToggleTurnFold = (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`turn-fold:${runId}`); + const [autoExpandedRunId, setAutoExpandedRunId] = useState(null); + // A response's only run answers to both its own key and its group's, so a + // fold opened before the first background wake stays open after it. + const onToggleTurnFold = (row: { + id: string; + foldKeys: ReadonlyArray; + expanded: boolean; + }) => { + suspendEndScrollMaintenanceForDisclosure(row.id); + if (autoExpandedRunId !== null && row.foldKeys.includes(autoExpandedRunId)) { + setAutoExpandedRunId(null); + } setExpandedRunIds((existing) => { const next = new Set(existing); - if (next.has(runId)) { - next.delete(runId); - } else { - next.add(runId); + for (const key of row.foldKeys) { + if (row.expanded) next.delete(key); + else next.add(key); } return next; }); @@ -534,7 +548,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }; - // An interrupted run opens its fold; a newer run closes the previous run's. + // An in-session interrupt expands its turn so the user keeps their place; the next + // run folds only that expansion, never one the user, a citation, or find opened. const [observedLatestRun, setObservedLatestRun] = useState({ identity: listIdentityKey, runId: latestRun?.runId, @@ -551,16 +566,20 @@ const ConversationTimeline = memo(function ConversationTimeline({ status: latestRun?.status, }); const previousRunId = observedLatestRun.runId; - if ( - observedLatestRun.identity === listIdentityKey && - latestRun && - previousRunId !== undefined - ) { + if (observedLatestRun.identity !== listIdentityKey) { + setAutoExpandedRunId(null); + } else if (latestRun && previousRunId !== undefined) { if (latestRun.runId === previousRunId) { - if (observedLatestRun.status === "running" && latestRun.status === "interrupted") { + if ( + observedLatestRun.status === "running" && + latestRun.status === "interrupted" && + !expandedRunIds.has(latestRun.runId) + ) { + setAutoExpandedRunId(latestRun.runId); setExpandedRunIds((existing) => new Set(existing).add(latestRun.runId)); } - } else { + } else if (autoExpandedRunId === previousRunId) { + setAutoExpandedRunId(null); setExpandedRunIds((existing) => { if (!existing.has(previousRunId)) return existing; const next = new Set(existing); @@ -581,16 +600,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ runlessWorkActive, }) : null; - const activeFindRunId = + const activeFindRunIds = findActive && activeFindMatch ? findFoldRunIds?.get(activeFindMatch.entryId) : undefined; - const [expandedFindRunId, setExpandedFindRunId] = useState(); - if (expandedFindRunId !== activeFindRunId) { - setExpandedFindRunId(activeFindRunId); - if (activeFindRunId) { - setExpandedRunIds((previous) => - previous.has(activeFindRunId) ? previous : new Set(previous).add(activeFindRunId), - ); - } + const activeFindRunKey = activeFindRunIds?.join("\0"); + const [expandedFindRunKey, setExpandedFindRunKey] = useState(); + if (expandedFindRunKey !== activeFindRunKey) { + setExpandedFindRunKey(activeFindRunKey); + if (activeFindRunIds) expandCitedRun(activeFindRunIds); } // A cited response folded inside a turn opens its fold once per activation; // the user can fold it again. Only a citation that can still land opens one. @@ -612,7 +628,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ ) : undefined; if (citationRequest && citedEntry) { - const citedRunId = ( + const citedRunIds = ( findFoldRunIds ?? timelineTurnFoldRunIdsByEntryId({ timelineEntries, @@ -623,11 +639,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }) ).get(citedEntry.id); setCitationExpandedFor(citationRequest.key); - if (citedRunId) { - setExpandedRunIds((previous) => - previous.has(citedRunId) ? previous : new Set(previous).add(citedRunId), - ); - } + if (citedRunIds) expandCitedRun(citedRunIds); const citedAttempt = citedEntry.attempt; if (citedAttempt?.status === "superseded") { setExpandedAttemptIds((previous) => @@ -636,9 +648,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ } } const visibleExpandedRunIds = - !activeFindRunId || paintedExpandedRunIds.has(activeFindRunId) + !activeFindRunIds || activeFindRunIds.every((runId) => paintedExpandedRunIds.has(runId)) ? paintedExpandedRunIds - : new Set(paintedExpandedRunIds).add(activeFindRunId); + : new Set([...paintedExpandedRunIds, ...activeFindRunIds]); const activeFindAttemptId = activeFindMatch ? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id @@ -1567,7 +1579,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.runId)} + onClick={() => ctx.onToggleTurnFold(row)} className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 89b52ae5c5cba..e4887df039985 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -297,7 +297,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( routeThreadKey: string; resolvedTheme: "light" | "dark"; displayThreadKey?: string; - onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + onOpenTurnDiff: (runId: RunId, filePath?: string, fromTurnCount?: number) => void; }) { if (!turnSummary) return null; const checkpointFiles = turnSummary.files; @@ -326,7 +326,7 @@ function AssistantChangedFilesSectionInner({ routeThreadKey: string; resolvedTheme: "light" | "dark"; displayThreadKey?: string; - onOpenTurnDiff: (runId: RunId, filePath?: string) => void; + onOpenTurnDiff: (runId: RunId, filePath?: string, fromTurnCount?: number) => void; }) { const ctx = use(TimelineRowCtx); const persistedExpanded = useUiStateStore( @@ -342,6 +342,12 @@ function AssistantChangedFilesSectionInner({ : null, ); const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId); + const { rangeFromTurnCount } = turnSummary; + // A response group's card opens the diff for the whole group, not its last run. + const openTurnDiff = + rangeFromTurnCount === undefined + ? onOpenTurnDiff + : (runId: RunId, filePath?: string) => onOpenTurnDiff(runId, filePath, rangeFromTurnCount); return ( setExpanded(routeThreadKey, turnSummary.runId, !allDirectoriesExpanded) } - onOpenTurnDiff={onOpenTurnDiff} + onOpenTurnDiff={openTurnDiff} onFileContextMenu={(filePath, event) => onFileContextMenu( { diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index 0b20a001ac78d..9f0408f5a65a0 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -57,7 +57,7 @@ export interface TimelineRowSharedState { onImageExpand: (preview: ExpandedImagePreview) => void; displayThreadKey?: string; /** Unset where there is no diff to open, so the timeline shows no "Open diff" button. */ - onOpenTurnDiff: ((runId: RunId, filePath?: string) => void) | undefined; + onOpenTurnDiff: ((runId: RunId, filePath?: string, fromTurnCount?: number) => void) | undefined; /** * Draws a published HTML visual. Unset, the row loads it from the environment; the guest page * loads it through its own route. @@ -75,7 +75,11 @@ export interface TimelineRowSharedState { readonly checkpointId: string; readonly scopeId: string; }) => void; - onToggleTurnFold: (runId: RunId) => void; + onToggleTurnFold: (row: { + id: string; + foldKeys: ReadonlyArray; + expanded: boolean; + }) => void; onToggleAttemptFold: (attemptId: RunAttemptId) => void; onFileOpen: (attachment: ChatFileAttachment) => void; onFileDownload: (attachment: ChatFileAttachment) => void; diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index be4c9dd9cbfd5..bf2e6a49eccfd 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -279,12 +279,12 @@ export function useRightPanelActions(input: RightPanelActionsInput) { useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); }; const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); - const onOpenTurnDiff = (runId: RunId, filePath?: string) => { + const onOpenTurnDiff = (runId: RunId, filePath?: string, fromTurnCount?: number) => { if (!isServerThread || !activeThreadRef) return; const diffOpen = selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; - useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); + useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath, fromTurnCount); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }; diff --git a/apps/web/src/components/chat/useThreadFindPositionReader.ts b/apps/web/src/components/chat/useThreadFindPositionReader.ts index 6568db9d04dfe..36c7309e387b4 100644 --- a/apps/web/src/components/chat/useThreadFindPositionReader.ts +++ b/apps/web/src/components/chat/useThreadFindPositionReader.ts @@ -27,7 +27,7 @@ export function useThreadFindPositionReader({ readonly threadKey: string; readonly navigate: () => void; readonly timelineViewportElement: HTMLDivElement | null; - readonly findFoldRunIds: ReadonlyMap | null; + readonly findFoldRunIds: ReadonlyMap> | null; readonly timelineEntries: ReadonlyArray; readonly latestRun: TimelineLatestRun | null; readonly runningRunId: RunId | null; @@ -63,16 +63,16 @@ export function useThreadFindPositionReader({ continue; } const entry = timelineEntries.find((entry) => { - const runId = + const inFold = row.kind === "turn-fold" - ? foldRunIds.get(entry.id) - : entry.kind === "message" - ? entry.message.runId - : entry.kind === "proposed-plan" - ? entry.proposedPlan.runId - : null; + ? foldRunIds.get(entry.id)?.includes(row.runId) === true + : (entry.kind === "message" + ? entry.message.runId + : entry.kind === "proposed-plan" + ? entry.proposedPlan.runId + : null) === row.runId; return ( - runId === row.runId && + inFold && (entry.kind !== "message" || entry.message.role === "assistant") && (row.kind !== "attempt-fold" || entry.attempt?.id === row.attemptId) ); diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts index b7238e8edf906..cf7591bbca76a 100644 --- a/apps/web/src/diffPanelStore.test.ts +++ b/apps/web/src/diffPanelStore.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; + import { useDiffPanelSessionStore, useDiffPanelStore } from "./diffPanelStore"; describe("diffPanelStore", () => { @@ -41,4 +43,18 @@ describe("diffPanelStore", () => { ); expect(defaults).toEqual(new Set(["src/app.ts", "src/index.ts"])); }); + + it("keeps a response group's range until the selected turn disappears", () => { + const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; + const latest = RunId.make("run-9"); + useDiffPanelStore.getState().selectTurn(ref, latest, undefined, 4); + expect(Object.values(useDiffPanelStore.getState().byThreadKey)[0]).toMatchObject({ + turnId: latest, + fromTurnCount: 4, + }); + useDiffPanelStore.getState().reconcileTurnSelection(ref, [RunId.make("run-10")]); + const reconciled = Object.values(useDiffPanelStore.getState().byThreadKey)[0]; + expect(reconciled).toMatchObject({ turnId: "run-10" }); + expect(reconciled).not.toHaveProperty("fromTurnCount"); + }); }); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index eb6f0e9d455c8..27f09e1f23cdc 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -8,7 +8,14 @@ import { resolveStorage } from "./lib/storage"; export type DiffPanelSelection = | { kind: "branch"; baseRef: string | null } | { kind: "unstaged" } - | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; + | { + kind: "turn"; + turnId: RunId; + filePath: string | null; + revealRequestId: number; + /** Set when a response group's card opened the diff: it spans from here to `turnId`. */ + fromTurnCount?: number; + }; const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; @@ -17,7 +24,12 @@ interface DiffPanelStoreState { branchBaseRefByThreadKey: Record; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; - selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; + selectTurn: ( + ref: ScopedThreadRef, + turnId: RunId, + filePath?: string, + fromTurnCount?: number, + ) => void; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -69,7 +81,7 @@ export const useDiffPanelStore = create()( }, }; }), - selectTurn: (ref, turnId, filePath) => + selectTurn: (ref, turnId, filePath, fromTurnCount) => set((state) => { const threadKey = scopedThreadKey(ref); const previous = state.byThreadKey[threadKey]; @@ -81,6 +93,7 @@ export const useDiffPanelStore = create()( turnId, filePath: filePath?.trim() || null, revealRequestId: previous?.kind === "turn" ? previous.revealRequestId + 1 : 1, + ...(fromTurnCount === undefined ? {} : { fromTurnCount }), }, }, }; @@ -97,10 +110,11 @@ export const useDiffPanelStore = create()( ) { return state; } + const { fromTurnCount: _staleRange, ...selection } = previous; return { byThreadKey: { ...state.byThreadKey, - [threadKey]: { ...previous, turnId: latestTurnId }, + [threadKey]: { ...selection, turnId: latestTurnId }, }, }; }), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index eb61b244075a3..8d7629bf46616 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,7 +52,6 @@ import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; -export { formatDuration } from "@t3tools/shared/orchestrationTiming"; export { workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolFailure, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c08c6e59a2ec8..60507da6752d5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -315,6 +315,10 @@ "types": "./src/state/threadRelationships.ts", "default": "./src/state/threadRelationships.ts" }, + "./state/response-groups": { + "types": "./src/state/responseGroups.ts", + "default": "./src/state/responseGroups.ts" + }, "./state/thread-checkpoints": { "types": "./src/state/threadCheckpoints.ts", "default": "./src/state/threadCheckpoints.ts" diff --git a/packages/client-runtime/src/state/responseGroups.test.ts b/packages/client-runtime/src/state/responseGroups.test.ts new file mode 100644 index 0000000000000..3e7a1690723c1 --- /dev/null +++ b/packages/client-runtime/src/state/responseGroups.test.ts @@ -0,0 +1,107 @@ +import { RunId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + mergeResponseGroupCheckpoints, + messageStartsResponseGroup, + responseGroupFoldLabel, +} from "./responseGroups.ts"; +import type { ThreadCheckpointSummary } from "./threadCheckpoints.ts"; + +const summary = ( + turnCount: number, + files: ThreadCheckpointSummary["files"], + status: ThreadCheckpointSummary["status"] = "ready", +): ThreadCheckpointSummary => ({ + runId: RunId.make(`run-${turnCount}`), + checkpointTurnCount: turnCount, + checkpointRef: `refs/t3/${turnCount}` as ThreadCheckpointSummary["checkpointRef"], + status, + files, + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00.000Z", +}); + +describe("mergeResponseGroupCheckpoints", () => { + it("sums stats per path and keeps a file's net kind across runs", () => { + const merged = mergeResponseGroupCheckpoints([ + summary(5, [ + { path: "kept.ts", kind: "added", additions: 4, deletions: 0 }, + { path: "gone.ts", kind: "added", additions: 2, deletions: 0 }, + ]), + summary(6, [], "missing"), + summary(7, [ + { path: "kept.ts", kind: "modified", additions: 1, deletions: 1 }, + { path: "gone.ts", kind: "deleted", additions: 0, deletions: 2 }, + ]), + ]); + expect(merged).toMatchObject({ + runId: "run-7", + checkpointTurnCount: 7, + rangeFromTurnCount: 4, + files: [ + { path: "kept.ts", kind: "added", additions: 5, deletions: 1 }, + { path: "gone.ts", kind: "deleted", additions: 2, deletions: 2 }, + ], + }); + }); + + it("ends the range at the last completed run while still counting a stopped run's files", () => { + const stopped = summary(6, [{ path: "late.ts", kind: "modified", additions: 3, deletions: 0 }]); + const merged = mergeResponseGroupCheckpoints( + [ + summary(4, [{ path: "a.ts", kind: "modified", additions: 1, deletions: 0 }]), + summary(5, []), + stopped, + ], + new Set([stopped.runId]), + ); + expect(merged).toMatchObject({ checkpointTurnCount: 5, rangeFromTurnCount: 3 }); + expect(merged?.files.map((file) => file.path)).toEqual(["a.ts", "late.ts"]); + const none = mergeResponseGroupCheckpoints( + [summary(5, []), stopped], + new Set([RunId.make("run-5"), stopped.runId]), + ); + expect(none).not.toHaveProperty("rangeFromTurnCount"); + // Ending at the first turn is that turn's own diff, not a range. + const single = mergeResponseGroupCheckpoints( + [summary(5, [{ path: "a.ts", kind: "modified", additions: 1, deletions: 0 }]), stopped], + new Set([stopped.runId]), + ); + expect(single).toMatchObject({ checkpointTurnCount: 5 }); + expect(single).not.toHaveProperty("rangeFromTurnCount"); + }); + + it("returns a lone ready checkpoint unchanged, and nothing without one", () => { + const only = summary(3, [{ path: "a.ts", kind: "modified", additions: 1, deletions: 0 }]); + expect(mergeResponseGroupCheckpoints([only, summary(4, [], "error")])).toBe(only); + expect(mergeResponseGroupCheckpoints([summary(4, [], "missing")])).toBeNull(); + }); +}); + +describe("messageStartsResponseGroup", () => { + it("starts at any prompt, including an agent's, but not at T3 Code's idle compaction", () => { + expect(messageStartsResponseGroup({ role: "user", text: "Go", createdBy: "agent" })).toBe(true); + expect(messageStartsResponseGroup({ role: "user", text: "/compact" })).toBe(true); + expect( + messageStartsResponseGroup({ role: "user", text: "/compact", idleCompaction: "onReturn" }), + ).toBe(false); + expect(messageStartsResponseGroup({ role: "assistant", text: "Done" })).toBe(false); + }); +}); + +describe("responseGroupFoldLabel", () => { + it("names the time, the wakes it folds, and earlier failures", () => { + const base = { durationMs: 99 * 60_000, updates: 4, failed: 0, stopped: false, live: false }; + expect(responseGroupFoldLabel(base)).toBe("Worked for 1h 39m · 4 updates"); + expect(responseGroupFoldLabel({ ...base, updates: 1, failed: 1 })).toBe( + "Worked for 1h 39m · 1 update · 1 failed", + ); + expect(responseGroupFoldLabel({ ...base, stopped: true, updates: 0 })).toBe( + "You stopped after 1h 39m", + ); + expect(responseGroupFoldLabel({ ...base, live: true, durationMs: null })).toBe( + "Working · 4 updates", + ); + }); +}); diff --git a/packages/client-runtime/src/state/responseGroups.ts b/packages/client-runtime/src/state/responseGroups.ts new file mode 100644 index 0000000000000..4cdec345b1e26 --- /dev/null +++ b/packages/client-runtime/src/state/responseGroups.ts @@ -0,0 +1,108 @@ +import { idleCompactionOf, type RunId } from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; + +import type { ThreadCheckpointSummary } from "./threadCheckpoints.ts"; + +/** + * A response group is one prompt plus every background wake after it, folded + * behind one row. Any prompt starts one, even an agent's; a steer does once its + * run has output. Wakes are notifications and idle `/compact` is housekeeping, + * so both join the current group. + */ +export function messageStartsResponseGroup( + message: Parameters[0], +): boolean { + return message.role === "user" && idleCompactionOf(message) === undefined; +} + +export function responseGroupKey(startEntryId: string): string { + return `group:${startEntryId}`; +} + +export interface ResponseGroupLabelInput { + readonly durationMs: number | null; + /** Runs a notification started inside the group. */ + readonly updates: number; + /** Earlier runs in the group that failed. Interrupts are not failures. */ + readonly failed: number; + /** The group's latest run was interrupted. */ + readonly stopped: boolean; + readonly live: boolean; +} + +/** + * The live label carries no time: the working row already ticks from the same + * start, and a ticking label here would re-derive every row each second. + */ +export function responseGroupFoldLabel(input: ResponseGroupLabelInput): string { + const duration = input.durationMs === null ? null : formatDuration(input.durationMs); + const head = input.live + ? "Working" + : input.stopped + ? duration + ? `You stopped after ${duration}` + : "You stopped this response" + : duration + ? `Worked for ${duration}` + : "Worked"; + const parts = [head]; + if (input.updates > 0) + parts.push(`${input.updates} ${input.updates === 1 ? "update" : "updates"}`); + if (input.failed > 0) parts.push(`${input.failed} failed`); + return parts.join(" · "); +} + +/** + * Per-run stats are deltas, so the sum counts lines touched, not the net diff. + * `rangeFromTurnCount` makes "Open diff" span the group. The server diffs only + * to a completed run's checkpoint, so the range ends at the last finished run. + */ +export function mergeResponseGroupCheckpoints( + summaries: ReadonlyArray, + unfinishedRunIds: ReadonlySet = new Set(), +): ThreadCheckpointSummary | null { + const ready = summaries + .filter((summary) => summary.status === "ready") + .sort((left, right) => left.checkpointTurnCount - right.checkpointTurnCount); + const first = ready[0]; + const end = ready.findLast((summary) => !unfinishedRunIds.has(summary.runId)); + const last = end ?? ready.at(-1); + if (first === undefined || last === undefined) return null; + if (ready.length === 1) return first; + + const files = new Map< + string, + { kind: string; firstKind: string; additions: number; deletions: number } + >(); + for (const summary of ready) { + for (const file of summary.files) { + const existing = files.get(file.path); + if (existing === undefined) { + files.set(file.path, { + kind: file.kind, + firstKind: file.kind, + additions: file.additions, + deletions: file.deletions, + }); + continue; + } + existing.kind = file.kind; + existing.additions += file.additions; + existing.deletions += file.deletions; + } + } + return { + ...last, + // With no completed run to end at, or only one turn to show, the card + // opens that run's own diff. + ...(end === undefined || end === first + ? {} + : { rangeFromTurnCount: first.checkpointTurnCount - 1 }), + files: Array.from(files, ([path, file]) => ({ + path, + kind: file.kind !== "deleted" && file.firstKind === "added" ? "added" : file.kind, + additions: file.additions, + deletions: file.deletions, + })), + }; +} diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 4501007e20053..60c34bce414d8 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -23,6 +23,8 @@ export interface ThreadCheckpointSummary { }>; readonly assistantMessageId: MessageId | null; readonly completedAt: string; + /** Set on a response group's merged summary: its diff starts after this turn, not one before. */ + readonly rangeFromTurnCount?: number; } export function deriveThreadCheckpointSummaries(