diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7d591d59e8c5..e0a31fadfc7f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1503,7 +1503,11 @@ function renderFeedEntry( readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void; readonly onToggleWorkRow: (rowId: string, anchorKey?: string) => void; - readonly onToggleTurnFold: (runId: RunId) => void; + readonly onToggleTurnFold: (fold: { + readonly runId: RunId; + readonly expandKey: string; + readonly expanded: boolean; + }) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly markdownLinkHandlers: MarkdownLinkHandlers; @@ -1531,7 +1535,7 @@ function renderFeedEntry( props.onToggleTurnFold(entry.runId)} + onPress={() => props.onToggleTurnFold(entry)} hitSlop={4} className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border-subtle px-2" style={{ @@ -2168,7 +2172,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; readonly expandedWorkRows: Record; - readonly expandedTurnIds: ReadonlySet; + readonly expandedTurnIds: ReadonlySet; }>({ copiedRowId: null, expandedWorkGroups: {}, @@ -2684,11 +2688,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return; } setInteractionState((current) => { - if (!current.expandedTurnIds.has(previous.runId)) { + const previousKeys = [...current.expandedTurnIds].filter( + (key) => key === previous.runId || key.startsWith(`${previous.runId}:`), + ); + if (previousKeys.length === 0) { return current; } const next = new Set(current.expandedTurnIds); - next.delete(previous.runId); + for (const key of previousKeys) next.delete(key); return { ...current, expandedTurnIds: next }; }); }, [props.latestRun]); @@ -2814,14 +2821,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const onToggleTurnFold = useCallback( - (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`); + ({ + runId, + expandKey, + expanded, + }: { + readonly runId: RunId; + readonly expandKey: string; + readonly expanded: boolean; + }) => { + suspendEndScrollMaintenanceForDisclosure(`run-fold:${expandKey}`); setInteractionState((current) => { const next = new Set(current.expandedTurnIds); - if (next.has(runId)) { + // An interrupt expands the whole run by its id. + if (expanded) { + next.delete(expandKey); next.delete(runId); } else { - next.add(runId); + next.add(expandKey); } return { ...current, expandedTurnIds: next }; }); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 16fd407f537b..268516e4655e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -971,6 +971,123 @@ describe("buildThreadFeed", () => { ]); }); + it("splits a settled run fold at a steer so work sits under the message it answered", () => { + const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`; + const steer = { + ...userMessage(at(30)), + id: TurnItemId.make("item-steer"), + messageId: MessageId.make("message-steer"), + inputIntent: "steer" as const, + }; + const feed = buildThreadFeed([ + projected(userMessage(at(0)), 0), + projected(command(at(5)), 1), + projected(steer, 2), + projected({ ...command(at(35)), id: TurnItemId.make("item-command-2") }, 3), + projected(assistantMessage(at(50)), 4), + ]); + const latestRun = { + runId, + status: "completed" as const, + startedAt: at(0), + completedAt: at(50), + }; + + const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); + expect( + collapsed.map((entry) => + entry.type === "run-fold" + ? entry.label + : entry.type === "message" + ? entry.message.text + : entry.type, + ), + ).toEqual(["Run checks", "Worked for 30s", "Run checks", "Worked for 20s", "Done"]); + expect(new Set(collapsed.map((entry) => entry.id)).size).toBe(collapsed.length); + + const secondFold = collapsed.findLast((entry) => entry.type === "run-fold"); + if (secondFold?.type !== "run-fold") throw new Error("Expected the steer fold"); + const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([secondFold.expandKey])); + expect(expanded.map((entry) => (entry.type === "run-fold" ? entry.expanded : null))).toEqual([ + null, + false, + null, + true, + null, + null, + ]); + + const expandedRun = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); + expect( + expandedRun.flatMap((entry) => (entry.type === "run-fold" ? [entry.expanded] : [])), + ).toEqual([true, true]); + }); + + it("gives the time after a steer without folded work to the next fold", () => { + const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`; + const steer = (id: string, second: number) => ({ + ...userMessage(at(second)), + id: TurnItemId.make(id), + messageId: MessageId.make(id), + inputIntent: "steer" as const, + }); + const assistant = (id: string, second: number) => ({ + ...assistantMessage(at(second)), + id: TurnItemId.make(id), + messageId: MessageId.make(id), + }); + const feed = buildThreadFeed([ + projected(userMessage(at(0)), 0), + projected(command(at(5)), 1), + projected(steer("steer-1", 20), 2), + projected(assistant("first-answer", 25), 3), + projected(steer("steer-2", 30), 4), + projected({ ...command(at(35)), id: TurnItemId.make("item-command-2") }, 5), + projected(assistant("final-answer", 50), 6), + ]); + const latestRun = { + runId, + status: "completed" as const, + startedAt: at(0), + completedAt: at(50), + }; + + expect( + deriveThreadFeedPresentation(feed, latestRun, new Set()).flatMap((entry) => + entry.type === "run-fold" ? [entry.label] : [], + ), + ).toEqual(["Worked for 20s", "Worked for 30s"]); + }); + + it("does not split a run fold at a queued prompt", () => { + const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`; + const queued = { + ...userMessage(at(30)), + id: TurnItemId.make("item-queued"), + messageId: MessageId.make("message-queued"), + runId: RunId.make("next-run"), + }; + const feed = buildThreadFeed([ + projected(userMessage(at(0)), 0), + projected(command(at(5)), 1), + projected(queued, 2), + projected({ ...command(at(35)), id: TurnItemId.make("item-command-2") }, 3), + projected(assistantMessage(at(50)), 4), + ]); + const latestRun = { + runId, + status: "completed" as const, + startedAt: at(0), + completedAt: at(50), + }; + + expect( + deriveThreadFeedPresentation(feed, latestRun, new Set()).filter( + (entry) => entry.type === "run-fold", + ), + ).toHaveLength(1); + }); + it("keeps an active run expanded and detects failures from completed command output", () => { const failedCommand: OrchestrationV2TurnItem = { ...command(), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 3a77324a7ac8..6e8d6f6ac746 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -184,6 +184,7 @@ type ThreadFeedEntryContent = readonly id: string; readonly createdAt: string; readonly runId: RunId; + readonly expandKey: string; readonly label: string; readonly expanded: boolean; } @@ -943,6 +944,8 @@ export function threadFeedActivityIsVisible( } interface ThreadFeedRunFold { + /** The run id, or `${runId}:${anchorId}` when steers split the run into several folds. */ + readonly expandKey: string; readonly runId: RunId; readonly createdAt: string; readonly hiddenEntryIds: ReadonlySet; @@ -987,16 +990,27 @@ function deriveThreadFeedRunFolds( const failedRunIds = failedFeedRunIds(feed, latestRun); const groupsByRunId = new Map< RunId, - { entries: ThreadFeedEntry[]; startBoundary: string | null } + { + entries: ThreadFeedEntry[]; + startBoundary: string | null; + /** A steer splits the settled fold so work stays under the message it answered. */ + segments: Array<{ startIndex: number; startBoundary: string }>; + } >(); // Fold state is keyed by run, so each runless prompt lends its response a // stable key of its own. Decide per prompt, not per thread: a V1 thread's // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingUserBoundary: string | null = null; + let pendingSteer: { createdAt: string; runId: RunId | null } | null = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; + pendingSteer = + entry.message.inputIntent === "steer" || + entry.message.inputIntent === "promoted_queued_to_steer" + ? { createdAt: entry.message.createdAt, runId: entry.message.runId ?? null } + : null; runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } @@ -1009,10 +1023,18 @@ function deriveThreadFeedRunFolds( if (!runId) continue; let group = groupsByRunId.get(runId); if (!group) { - group = { entries: [], startBoundary: pendingUserBoundary }; + group = { entries: [], startBoundary: pendingUserBoundary, segments: [] }; pendingUserBoundary = null; + pendingSteer = null; groupsByRunId.set(runId, group); } + if (pendingSteer && (pendingSteer.runId === null || pendingSteer.runId === runId)) { + group.segments.push({ + startIndex: group.entries.length, + startBoundary: pendingSteer.createdAt, + }); + pendingSteer = null; + } group.entries.push(entry); if (entry.type === "message") { if (!firstAssistantMessageIdByRun.has(runId)) { @@ -1068,46 +1090,66 @@ function deriveThreadFeedRunFolds( .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; + if (!firstEntry || !lastEntry) continue; + const sections = [{ startIndex: 0, startBoundary: group.startBoundary }, ...group.segments] + .map((section, index, all) => ({ + endBoundary: all[index + 1]?.startBoundary ?? null, + hiddenEntries: group.entries + .slice(section.startIndex, all[index + 1]?.startIndex) + .filter((entry) => hiddenEntryIds.has(entry.id)), + })) + .filter((section) => + section.hiddenEntries.some( + (entry) => !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)), + ), + ); + if (sections.length === 0) 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 = + const runTiming = 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); + ? { start: latestRun.startedAt, end: latestRun.completedAt } + : { + start: group.startBoundary ?? firstEntry.createdAt, + end: + maxIsoTimestamp( + terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, + lastEntryEnd, + ) ?? lastEntryEnd, + }; const interrupted = latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); - foldsByAnchorId.set(firstHiddenEntry.id, { - runId, - createdAt: firstHiddenEntry.createdAt, - hiddenEntryIds, - label: interrupted - ? duration - ? `You stopped after ${duration}` - : "You stopped this response" - : duration - ? `Worked for ${duration}` - : "Worked", - }); + // Each fold runs until the steer after it; time without folded work joins + // the next fold, so the folds add up to the run. + let sectionStart = runTiming.start; + for (const [index, section] of sections.entries()) { + const anchor = section.hiddenEntries[0]; + if (!anchor) continue; + const nextSection = sections[index + 1]; + const sectionEnd = nextSection ? (section.endBoundary ?? runTiming.end) : runTiming.end; + const elapsedMs = computeElapsedMs(sectionStart, sectionEnd); + sectionStart = sectionEnd; + const duration = elapsedMs === null ? null : formatDuration(elapsedMs); + foldsByAnchorId.set(anchor.id, { + expandKey: sections.length === 1 ? runId : `${runId}:${anchor.id}`, + runId, + createdAt: anchor.createdAt, + hiddenEntryIds: new Set(section.hiddenEntries.map((entry) => entry.id)), + label: + interrupted && !nextSection + ? duration + ? `You stopped after ${duration}` + : "You stopped this response" + : duration + ? `Worked for ${duration}` + : "Worked", + }); + } } return foldsByAnchorId; } @@ -1146,7 +1188,7 @@ function settleSupersededReasoning( export function deriveThreadFeedPresentation( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, - expandedRunIds: ReadonlySet, + expandedRunIds: ReadonlySet, expandedWorkGroupIds: ReadonlySet = new Set(), activeWorkStartedAt: string | null = null, /** The live work is a provider-native subagent's runless root turn. */ @@ -1168,9 +1210,12 @@ export function deriveThreadFeedPresentation( latestRun, isWorking && runlessWorkActive, ); + // A run id expands every fold of that run. + const foldExpanded = (fold: ThreadFeedRunFold) => + expandedRunIds.has(fold.expandKey) || expandedRunIds.has(fold.runId); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { - if (!expandedRunIds.has(fold.runId)) { + if (!foldExpanded(fold)) { for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); } } @@ -1187,10 +1232,11 @@ export function deriveThreadFeedPresentation( entry.runId === activeRunId; const fold = foldsByAnchorId.get(entry.id); if (fold) { - const expanded = expandedRunIds.has(fold.runId); + const expanded = foldExpanded(fold); let row = runFoldRowsCache.get(entry); if ( !row || + row.expandKey !== fold.expandKey || row.runId !== fold.runId || row.createdAt !== fold.createdAt || row.label !== fold.label || @@ -1198,9 +1244,10 @@ export function deriveThreadFeedPresentation( ) { row = { type: "run-fold", - id: `run-fold:${fold.runId}`, + id: `run-fold:${fold.expandKey}`, createdAt: fold.createdAt, runId: fold.runId, + expandKey: fold.expandKey, label: fold.label, expanded, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 173a964d5efc..d1ec2cb1e564 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1755,13 +1755,15 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - expect(rows.slice(0, 3).map((row) => row.id)).toEqual([ - "initial-prompt", - isWorking ? "working-indicator-row" : `turn-fold:${runId}`, - "steer", - ]); - expect(rows[1]?.createdAt).toBe(time(0)); - if (!isWorking) expect(rows[1]).toMatchObject({ label: "Worked for 20s", expanded }); + // The live header stays at the initiating prompt; the settled fold + // sits under the steer that the work answered. + expect(rows.slice(0, 3).map((row) => row.id)).toEqual( + isWorking + ? ["initial-prompt", "working-indicator-row", "steer"] + : ["initial-prompt", "steer", `turn-fold:${runId}`], + ); + if (isWorking) expect(rows[1]?.createdAt).toBe(time(0)); + if (!isWorking) expect(rows[2]).toMatchObject({ label: "Worked for 20s", expanded }); expect(rows.some((row) => row.id === "final")).toBe(true); expect(rows.some((row) => row.id === "work")).toBe(isWorking || expanded); } @@ -1769,6 +1771,175 @@ describe("deriveMessagesTimelineRows", () => { }, ); + it.each(["steer", "promoted_queued_to_steer"] as const)( + "splits a settled fold at each %s so work sits under the message it answered", + (inputIntent) => { + const runId = RunId.make("steered-run"); + const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); + const prompt = (id: string, second: number, intent: "turn_start" | typeof inputIntent) => ({ + kind: "message" as const, + id, + createdAt: time(second), + message: { + id: MessageId.make(id), + role: "user" as const, + text: id, + runId, + inputIntent: intent, + createdAt: time(second), + updatedAt: time(second), + streaming: false, + }, + }); + const work = (id: string, second: number) => ({ + kind: "work" as const, + id, + createdAt: time(second), + entry: { id, createdAt: time(second), runId, label: "Ran command", tone: "tool" as const }, + }); + const final = { + kind: "message" as const, + id: "final", + createdAt: time(50), + message: { + id: MessageId.make("final"), + role: "assistant" as const, + text: "Done", + runId, + createdAt: time(50), + updatedAt: time(50), + streaming: false, + }, + }; + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + prompt("initial-prompt", 0, "turn_start"), + work("work-1", 5), + prompt("steer", 30, inputIntent), + work("work-2", 35), + final, + ], + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(50) }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect( + rows.map((row) => (row.kind === "turn-fold" ? `${row.id} ${row.label}` : row.id)), + ).toEqual([ + "initial-prompt", + `turn-fold:${runId}:work-1 Worked for 30s`, + "steer", + `turn-fold:${runId}:work-2 Worked for 20s`, + "final", + ]); + + const expandedAfterSteer = deriveMessagesTimelineRows({ + timelineEntries: [ + prompt("initial-prompt", 0, "turn_start"), + work("work-1", 5), + prompt("steer", 30, inputIntent), + work("work-2", 35), + final, + ], + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(50) }, + expandedRunIds: new Set([`${runId}:work-2`]), + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(expandedAfterSteer.some((row) => row.id === "work-1")).toBe(false); + expect(expandedAfterSteer.some((row) => row.id === "work-2")).toBe(true); + + // An interrupt or citation expands the run by id, which opens every fold. + const expandedRun = deriveMessagesTimelineRows({ + timelineEntries: [ + prompt("initial-prompt", 0, "turn_start"), + work("work-1", 5), + prompt("steer", 30, inputIntent), + work("work-2", 35), + final, + ], + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(50) }, + expandedRunIds: new Set([runId]), + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(expandedRun.filter((row) => row.id === "work-1" || row.id === "work-2")).toHaveLength( + 2, + ); + }, + ); + + it("splits only the steered run when another run's work follows the steer", () => { + const runId = RunId.make("steered-run"); + const otherRunId = RunId.make("other-run"); + const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); + const prompt = (id: string, second: number, inputIntent: "turn_start" | "steer") => ({ + kind: "message" as const, + id, + createdAt: time(second), + message: { + id: MessageId.make(id), + role: "user" as const, + text: id, + runId, + inputIntent, + createdAt: time(second), + updatedAt: time(second), + streaming: false, + }, + }); + const work = (id: string, second: number, workRunId: RunId) => ({ + kind: "work" as const, + id, + createdAt: time(second), + entry: { + id, + createdAt: time(second), + runId: workRunId, + label: "Ran command", + tone: "tool" as const, + }, + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + prompt("initial-prompt", 0, "turn_start"), + work("work-1", 5, runId), + prompt("steer", 30, "steer"), + work("other-work", 32, otherRunId), + work("work-2", 35, runId), + { + kind: "message" as const, + id: "final", + createdAt: time(50), + message: { + id: MessageId.make("final"), + role: "assistant" as const, + text: "Done", + runId, + createdAt: time(50), + updatedAt: time(50), + streaming: false, + }, + }, + ], + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(50) }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect( + rows.flatMap((row) => + row.kind === "turn-fold" && row.runId === runId ? [`${row.id} ${row.label}`] : [], + ), + ).toEqual([ + `turn-fold:${runId}:work-1 Worked for 30s`, + `turn-fold:${runId}:work-2 Worked for 20s`, + ]); + }); + it("keeps the previous turn folded while a newly sent message awaits its turn", () => { // Right after send, isWorking is true but latestRun still points at the // previous, settled turn — it must stay folded through that window. diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 280cc3672103..3cb39466c639 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -533,6 +533,7 @@ type MessagesTimelineRowContent = id: string; createdAt: string; runId: RunId; + expandKey: string; label: string; expanded: boolean; } @@ -678,6 +679,8 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray; } const groupsByRunId = new Map(); const runlessFailedKeys = new Set(); @@ -898,8 +903,13 @@ function deriveTurnFolds(input: { // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; + let pendingSteer: { createdAt: string; runId: RunId | null } | null = null; for (const [index, entry] of input.timelineEntries.entries()) { + if (entry.kind === "message" && entry.message.role === "user") { + pendingSteer = { createdAt: entry.createdAt, runId: entry.message.runId ?? null }; + } if (timelineEntryStartsResponse(entry)) { + pendingSteer = null; const nextEntry = input.timelineEntries[index + 1]; pendingBoundary = nextEntry ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } @@ -931,10 +941,19 @@ function deriveTurnFolds(input: { // to its own first entry. startBoundary: pendingBoundary?.createdAt ?? null, anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id, + segments: [], }; pendingBoundary = null; groupsByRunId.set(runId, group); } + if (pendingSteer && (pendingSteer.runId === null || pendingSteer.runId === runId)) { + group.segments.push({ + startIndex: group.entries.length, + startBoundary: pendingSteer.createdAt, + anchorEntryId: entry.id, + }); + pendingSteer = null; + } group.entries.push(entry); if (entry.kind === "message") { if (input.terminalAssistantMessageIds.has(entry.message.id)) { @@ -989,12 +1008,23 @@ function deriveTurnFolds(input: { } // A lone compaction row stays visible on its own; it only folds away as // part of a turn that already folds other work. - const hidesNonCompactionWork = group.entries.some( - (entry) => - hiddenEntryIds.has(entry.id) && - !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"), - ); - if (!hidesNonCompactionWork) { + const sections = [ + { startIndex: 0, startBoundary: group.startBoundary, anchorEntryId: group.anchorEntryId }, + ...group.segments, + ] + .map((section, index, all) => ({ + ...section, + entries: group.entries.slice(section.startIndex, all[index + 1]?.startIndex), + endBoundary: all[index + 1]?.startBoundary ?? null, + })) + .filter((section) => + section.entries.some( + (entry) => + hiddenEntryIds.has(entry.id) && + !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"), + ), + ); + if (sections.length === 0) { continue; } @@ -1010,30 +1040,44 @@ function deriveTurnFolds(input: { // terminal message — take whichever ended last. const lastEntryEnd = lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; - const elapsedMs = + const runTiming = 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) ?? + ? { start: input.latestRun.startedAt, end: input.latestRun.completedAt } + : { + start: group.startBoundary ?? firstEntry.createdAt, + end: + 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"; - - foldsByAnchorEntryId.set(group.anchorEntryId, { - runId, - anchorEntryId: group.anchorEntryId, - createdAt: group.startBoundary ?? firstEntry.createdAt, - hiddenEntryIds, - label, - }); + }; + // Each fold runs until the steer after it; time without folded work joins + // the next fold, so the folds add up to the run. + let sectionStart = runTiming.start; + for (const [index, section] of sections.entries()) { + const nextSection = sections[index + 1]; + const sectionEnd = nextSection ? (section.endBoundary ?? runTiming.end) : runTiming.end; + const elapsedMs = computeElapsedMs(sectionStart, sectionEnd); + sectionStart = sectionEnd; + const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; + const label = + isLatestInterruptedTurn && !nextSection + ? duration + ? `You stopped after ${duration}` + : "You stopped this response" + : duration + ? `Worked for ${duration}` + : "Worked"; + + foldsByAnchorEntryId.set(section.anchorEntryId, { + expandKey: sections.length === 1 ? runId : `${runId}:${section.anchorEntryId}`, + runId, + anchorEntryId: section.anchorEntryId, + createdAt: section.startBoundary ?? firstEntry.createdAt, + hiddenEntryIds: new Set( + section.entries.flatMap((entry) => (hiddenEntryIds.has(entry.id) ? [entry.id] : [])), + ), + label, + }); + } } return foldsByAnchorEntryId; } @@ -1193,7 +1237,8 @@ export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestRun?: TimelineLatestRun | null; runningRunId?: RunId | null; - expandedRunIds?: ReadonlySet; + /** Turn fold expand keys. A run id expands every fold of that run. */ + expandedRunIds?: ReadonlySet; expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; @@ -1249,9 +1294,12 @@ export function deriveMessagesTimelineRows(input: { unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive, }); + const turnFoldExpanded = (fold: TurnFold) => + input.expandedRunIds?.has(fold.expandKey) === true || + input.expandedRunIds?.has(fold.runId) === true; const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedRunIds?.has(fold.runId)) { + if (!turnFoldExpanded(fold)) { for (const entryId of fold.hiddenEntryIds) { collapsedEntryIds.add(entryId); } @@ -1400,11 +1448,12 @@ export function deriveMessagesTimelineRows(input: { if (turnFold) { nextRows.push({ kind: "turn-fold", - id: `turn-fold:${turnFold.runId}`, + id: `turn-fold:${turnFold.expandKey}`, createdAt: turnFold.createdAt, runId: turnFold.runId, + expandKey: turnFold.expandKey, label: turnFold.label, - expanded: input.expandedRunIds?.has(turnFold.runId) ?? false, + expanded: turnFoldExpanded(turnFold), }); } @@ -1798,13 +1847,17 @@ function attachCreatedThreadSummaries( timelineEntries: ReadonlyArray, ): MessagesTimelineRow[] { const terminalIndexes = new Map(); - const collapsedRuns = new Set(); + // A created-thread row follows the collapse state of the fold above it. + const firstFoldCollapsed = new Map(); + const currentFoldCollapsed = new Map(); const createdByRun = new Map>>(); for (const [index, row] of rows.entries()) { if (row.kind === "message" && row.showAssistantMeta && row.message.runId) { terminalIndexes.set(row.message.runId, index); } - if (row.kind === "turn-fold" && !row.expanded) collapsedRuns.add(row.runId); + if (row.kind === "turn-fold" && !firstFoldCollapsed.has(row.runId)) { + firstFoldCollapsed.set(row.runId, !row.expanded); + } } for (const entry of timelineEntries) { const projectedItem = @@ -1821,11 +1874,14 @@ function attachCreatedThreadSummaries( } } return rows.flatMap((row, index): MessagesTimelineRow[] => { + if (row.kind === "turn-fold") currentFoldCollapsed.set(row.runId, !row.expanded); if (row.kind === "event" && row.projectedItem.item.type === "thread_created") { const runId = row.projectedItem.item.runId; const terminalIndex = runId === null ? undefined : terminalIndexes.get(runId); - if (terminalIndex !== undefined && (collapsedRuns.has(runId!) || index > terminalIndex)) - return []; + const collapsed = + runId !== null && + (currentFoldCollapsed.get(runId) ?? firstFoldCollapsed.get(runId)) === true; + if (terminalIndex !== undefined && (collapsed || index > terminalIndex)) return []; } if (row.kind === "message" && row.showAssistantMeta && row.message.runId) { return [ diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ba6e6800db59..1f7e392af889 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -317,7 +317,7 @@ interface TimelineRowSharedState { readonly checkpointId: string; readonly scopeId: string; }) => void; - onToggleTurnFold: (runId: RunId) => void; + onToggleTurnFold: (fold: { runId: RunId; expandKey: string; expanded: boolean }) => void; onToggleAttemptFold: (attemptId: RunAttemptId) => void; onFileOpen: (attachment: ChatFileAttachment) => void; onFileDownload: (attachment: ChatFileAttachment) => void; @@ -551,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => readTimelinePosition(listIdentityKey), [listIdentityKey], ); - const [expandedRunIds, setExpandedRunIds] = useState>( + const [expandedRunIds, setExpandedRunIds] = useState>( () => rememberedPosition?.disclosures?.runs ?? new Set(), ); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>( @@ -663,14 +663,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, []); const onToggleTurnFold = useCallback( - (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`turn-fold:${runId}`); + ({ runId, expandKey, expanded }: { runId: RunId; expandKey: string; expanded: boolean }) => { + suspendEndScrollMaintenanceForDisclosure(`turn-fold:${expandKey}`); setExpandedRunIds((existing) => { const next = new Set(existing); - if (next.has(runId)) { + // An interrupt or citation expands the whole run by its id. + if (expanded) { + next.delete(expandKey); next.delete(runId); } else { - next.add(runId); + next.add(expandKey); } return next; }); @@ -728,11 +730,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } setExpandedRunIds((existing) => { - if (!existing.has(previous.runId)) { + const previousKeys = [...existing].filter( + (key) => key === previous.runId || key.startsWith(`${previous.runId}:`), + ); + if (previousKeys.length === 0) { return existing; } const next = new Set(existing); - next.delete(previous.runId); + for (const key of previousKeys) next.delete(key); return next; }); }, [latestRun]); @@ -2436,7 +2441,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/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index c83bb8b83ca4..e8403daab53c 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -148,7 +148,7 @@ export interface RememberedTimelinePosition { readonly scrollOffset: number; readonly atEnd: boolean; readonly disclosures?: { - readonly runs: ReadonlySet; + readonly runs: ReadonlySet; readonly workGroups: ReadonlySet; readonly attempts: ReadonlySet; readonly workGroupState: {