From d2f1d15054022535326f9aff2576e8f7dbf33d8e Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Sat, 3 Oct 2026 22:38:20 -0300 Subject: [PATCH 1/3] fix(clients): a steer splits the settled work fold Each steer now starts its own "Worked for" fold, so work done after a steer sits under it instead of inside the fold above it. Durations run from steer to steer and still add up to the run. Each fold expands on its own. Fixes #15279 --- .../src/features/threads/ThreadFeed.tsx | 16 +-- apps/mobile/src/lib/threadActivity.test.ts | 47 ++++++++ apps/mobile/src/lib/threadActivity.ts | 108 +++++++++++------ .../chat/MessagesTimeline.logic.test.ts | 98 ++++++++++++++-- .../components/chat/MessagesTimeline.logic.ts | 109 +++++++++++++----- .../src/components/chat/MessagesTimeline.tsx | 16 +-- .../chat/timelineScrollAnchoring.ts | 2 +- 7 files changed, 304 insertions(+), 92 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7d591d59e8c5..48a249e93a90 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1503,7 +1503,7 @@ 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: (expandKey: string) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly markdownLinkHandlers: MarkdownLinkHandlers; @@ -1531,7 +1531,7 @@ function renderFeedEntry( props.onToggleTurnFold(entry.runId)} + onPress={() => props.onToggleTurnFold(entry.expandKey)} hitSlop={4} className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border-subtle px-2" style={{ @@ -2168,7 +2168,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: {}, @@ -2814,14 +2814,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const onToggleTurnFold = useCallback( - (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`run-fold:${runId}`); + (expandKey: string) => { + suspendEndScrollMaintenanceForDisclosure(`run-fold:${expandKey}`); setInteractionState((current) => { const next = new Set(current.expandedTurnIds); - if (next.has(runId)) { - next.delete(runId); + if (next.has(expandKey)) { + next.delete(expandKey); } 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..7abe5207b36c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -971,6 +971,53 @@ 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, + ]); + }); + 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..f6a588b09740 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}` for a fold that follows a steer. */ + readonly expandKey: string; readonly runId: RunId; readonly createdAt: string; readonly hiddenEntryIds: ReadonlySet; @@ -987,16 +990,23 @@ 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: string | null = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; + pendingSteer = entry.message.createdAt; runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } @@ -1009,10 +1019,15 @@ 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) { + group.segments.push({ startIndex: group.entries.length, startBoundary: pendingSteer }); + pendingSteer = null; + } group.entries.push(entry); if (entry.type === "message") { if (!firstAssistantMessageIdByRun.has(runId)) { @@ -1068,46 +1083,65 @@ 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) => ({ + startBoundary: section.startBoundary, + 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", - }); + // Sections span from their steer to the next one, so they add up to the run. + for (const [index, section] of sections.entries()) { + const anchor = section.hiddenEntries[0]; + if (!anchor) continue; + const nextSection = sections[index + 1]; + const elapsedMs = computeElapsedMs( + index === 0 ? runTiming.start : (section.startBoundary ?? runTiming.start), + nextSection?.startBoundary ?? runTiming.end, + ); + const duration = elapsedMs === null ? null : formatDuration(elapsedMs); + foldsByAnchorId.set(anchor.id, { + expandKey: index === 0 ? 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 +1180,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. */ @@ -1170,7 +1204,7 @@ export function deriveThreadFeedPresentation( ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { - if (!expandedRunIds.has(fold.runId)) { + if (!expandedRunIds.has(fold.expandKey)) { for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); } } @@ -1187,10 +1221,11 @@ export function deriveThreadFeedPresentation( entry.runId === activeRunId; const fold = foldsByAnchorId.get(entry.id); if (fold) { - const expanded = expandedRunIds.has(fold.runId); + const expanded = expandedRunIds.has(fold.expandKey); 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 +1233,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..324a1d9b52ae 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,88 @@ 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} 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); + }, + ); + 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..30feece34c98 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,14 @@ 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; anchorEntryId: string } | null = null; for (const [index, entry] of input.timelineEntries.entries()) { + if (entry.kind === "message" && entry.message.role === "user") { + const nextEntry = input.timelineEntries[index + 1]; + pendingSteer = nextEntry ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } : null; + } if (timelineEntryStartsResponse(entry)) { + pendingSteer = null; const nextEntry = input.timelineEntries[index + 1]; pendingBoundary = nextEntry ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } @@ -931,10 +942,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) { + group.segments.push({ + startIndex: group.entries.length, + startBoundary: pendingSteer.createdAt, + anchorEntryId: pendingSteer.anchorEntryId, + }); + pendingSteer = null; + } group.entries.push(entry); if (entry.kind === "message") { if (input.terminalAssistantMessageIds.has(entry.message.id)) { @@ -989,12 +1009,22 @@ 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), + })) + .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,43 @@ 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, - }); + }; + // Sections span from their steer to the next one, so they add up to the run. + for (const [index, section] of sections.entries()) { + const nextSection = sections[index + 1]; + const elapsedMs = computeElapsedMs( + index === 0 ? runTiming.start : (section.startBoundary ?? runTiming.start), + nextSection?.startBoundary ?? runTiming.end, + ); + 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: index === 0 ? 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 +1236,8 @@ export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestRun?: TimelineLatestRun | null; runningRunId?: RunId | null; - expandedRunIds?: ReadonlySet; + /** Turn fold expand keys: run ids, plus `${runId}:${anchorEntryId}` after a steer. */ + expandedRunIds?: ReadonlySet; expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; @@ -1251,7 +1295,7 @@ export function deriveMessagesTimelineRows(input: { }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedRunIds?.has(fold.runId)) { + if (!input.expandedRunIds?.has(fold.expandKey)) { for (const entryId of fold.hiddenEntryIds) { collapsedEntryIds.add(entryId); } @@ -1400,11 +1444,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: input.expandedRunIds?.has(turnFold.expandKey) ?? false, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ba6e6800db59..b7911b111c52 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: (expandKey: string) => 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,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, []); const onToggleTurnFold = useCallback( - (runId: RunId) => { - suspendEndScrollMaintenanceForDisclosure(`turn-fold:${runId}`); + (expandKey: string) => { + suspendEndScrollMaintenanceForDisclosure(`turn-fold:${expandKey}`); setExpandedRunIds((existing) => { const next = new Set(existing); - if (next.has(runId)) { - next.delete(runId); + if (next.has(expandKey)) { + next.delete(expandKey); } else { - next.add(runId); + next.add(expandKey); } return next; }); @@ -2436,7 +2436,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.runId)} + onClick={() => ctx.onToggleTurnFold(row.expandKey)} 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: { From 71d65b0afe509a3ffd07bc5425f7e820ef353482 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Sat, 3 Oct 2026 22:48:14 -0300 Subject: [PATCH 2/3] fix(clients): steer folds keep their own time and expansion - Only steers split a fold, and only in the steer's own run. - Time after a steer with no folded work joins the next fold. - Each fold anchors on its own first entry. - An interrupt or citation still expands every fold of the run. - A created-thread card follows the fold above it. --- .../src/features/threads/ThreadFeed.tsx | 22 ++++- apps/mobile/src/lib/threadActivity.test.ts | 70 +++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 39 +++++--- .../chat/MessagesTimeline.logic.test.ts | 89 ++++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 49 ++++++---- .../src/components/chat/MessagesTimeline.tsx | 10 ++- 6 files changed, 237 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 48a249e93a90..20f52d50afa9 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: (expandKey: string) => 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.expandKey)} + 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={{ @@ -2814,12 +2818,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const onToggleTurnFold = useCallback( - (expandKey: string) => { + ({ + 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(expandKey)) { + // An interrupt expands the whole run by its id. + if (expanded) { next.delete(expandKey); + next.delete(runId); } else { next.add(expandKey); } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 7abe5207b36c..268516e4655e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1016,6 +1016,76 @@ describe("buildThreadFeed", () => { 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", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f6a588b09740..6e8d6f6ac746 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -944,7 +944,7 @@ export function threadFeedActivityIsVisible( } interface ThreadFeedRunFold { - /** The run id, or `${runId}:${anchorId}` for a fold that follows a steer. */ + /** The run id, or `${runId}:${anchorId}` when steers split the run into several folds. */ readonly expandKey: string; readonly runId: RunId; readonly createdAt: string; @@ -1002,11 +1002,15 @@ function deriveThreadFeedRunFolds( // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingUserBoundary: string | null = null; - let pendingSteer: 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.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; } @@ -1024,8 +1028,11 @@ function deriveThreadFeedRunFolds( pendingSteer = null; groupsByRunId.set(runId, group); } - if (pendingSteer) { - group.segments.push({ startIndex: group.entries.length, startBoundary: pendingSteer }); + if (pendingSteer && (pendingSteer.runId === null || pendingSteer.runId === runId)) { + group.segments.push({ + startIndex: group.entries.length, + startBoundary: pendingSteer.createdAt, + }); pendingSteer = null; } group.entries.push(entry); @@ -1087,7 +1094,7 @@ function deriveThreadFeedRunFolds( if (!firstEntry || !lastEntry) continue; const sections = [{ startIndex: 0, startBoundary: group.startBoundary }, ...group.segments] .map((section, index, all) => ({ - startBoundary: section.startBoundary, + endBoundary: all[index + 1]?.startBoundary ?? null, hiddenEntries: group.entries .slice(section.startIndex, all[index + 1]?.startIndex) .filter((entry) => hiddenEntryIds.has(entry.id)), @@ -1117,18 +1124,19 @@ function deriveThreadFeedRunFolds( }; const interrupted = latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); - // Sections span from their steer to the next one, so they add up to the run. + // 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 elapsedMs = computeElapsedMs( - index === 0 ? runTiming.start : (section.startBoundary ?? runTiming.start), - nextSection?.startBoundary ?? runTiming.end, - ); + 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: index === 0 ? runId : `${runId}:${anchor.id}`, + expandKey: sections.length === 1 ? runId : `${runId}:${anchor.id}`, runId, createdAt: anchor.createdAt, hiddenEntryIds: new Set(section.hiddenEntries.map((entry) => entry.id)), @@ -1202,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.expandKey)) { + if (!foldExpanded(fold)) { for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); } } @@ -1221,7 +1232,7 @@ export function deriveThreadFeedPresentation( entry.runId === activeRunId; const fold = foldsByAnchorId.get(entry.id); if (fold) { - const expanded = expandedRunIds.has(fold.expandKey); + const expanded = foldExpanded(fold); let row = runFoldRowsCache.get(entry); if ( !row || diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 324a1d9b52ae..d1ec2cb1e564 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1828,7 +1828,7 @@ describe("deriveMessagesTimelineRows", () => { rows.map((row) => (row.kind === "turn-fold" ? `${row.id} ${row.label}` : row.id)), ).toEqual([ "initial-prompt", - `turn-fold:${runId} Worked for 30s`, + `turn-fold:${runId}:work-1 Worked for 30s`, "steer", `turn-fold:${runId}:work-2 Worked for 20s`, "final", @@ -1850,9 +1850,96 @@ describe("deriveMessagesTimelineRows", () => { }); 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 30feece34c98..3cb39466c639 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -679,7 +679,7 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray ({ ...section, entries: group.entries.slice(section.startIndex, all[index + 1]?.startIndex), + endBoundary: all[index + 1]?.startBoundary ?? null, })) .filter((section) => section.entries.some( @@ -1049,13 +1049,14 @@ function deriveTurnFolds(input: { maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? lastEntryEnd, }; - // Sections span from their steer to the next one, so they add up to the run. + // 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 elapsedMs = computeElapsedMs( - index === 0 ? runTiming.start : (section.startBoundary ?? runTiming.start), - nextSection?.startBoundary ?? runTiming.end, - ); + 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 @@ -1067,7 +1068,7 @@ function deriveTurnFolds(input: { : "Worked"; foldsByAnchorEntryId.set(section.anchorEntryId, { - expandKey: index === 0 ? runId : `${runId}:${section.anchorEntryId}`, + expandKey: sections.length === 1 ? runId : `${runId}:${section.anchorEntryId}`, runId, anchorEntryId: section.anchorEntryId, createdAt: section.startBoundary ?? firstEntry.createdAt, @@ -1236,7 +1237,7 @@ export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestRun?: TimelineLatestRun | null; runningRunId?: RunId | null; - /** Turn fold expand keys: run ids, plus `${runId}:${anchorEntryId}` after a steer. */ + /** Turn fold expand keys. A run id expands every fold of that run. */ expandedRunIds?: ReadonlySet; expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; @@ -1293,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.expandKey)) { + if (!turnFoldExpanded(fold)) { for (const entryId of fold.hiddenEntryIds) { collapsedEntryIds.add(entryId); } @@ -1449,7 +1453,7 @@ export function deriveMessagesTimelineRows(input: { runId: turnFold.runId, expandKey: turnFold.expandKey, label: turnFold.label, - expanded: input.expandedRunIds?.has(turnFold.expandKey) ?? false, + expanded: turnFoldExpanded(turnFold), }); } @@ -1843,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 = @@ -1866,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 b7911b111c52..f6aab6ce1541 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: (expandKey: string) => void; + onToggleTurnFold: (fold: { runId: RunId; expandKey: string; expanded: boolean }) => void; onToggleAttemptFold: (attemptId: RunAttemptId) => void; onFileOpen: (attachment: ChatFileAttachment) => void; onFileDownload: (attachment: ChatFileAttachment) => void; @@ -663,12 +663,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, []); const onToggleTurnFold = useCallback( - (expandKey: string) => { + ({ runId, expandKey, expanded }: { runId: RunId; expandKey: string; expanded: boolean }) => { suspendEndScrollMaintenanceForDisclosure(`turn-fold:${expandKey}`); setExpandedRunIds((existing) => { const next = new Set(existing); - if (next.has(expandKey)) { + // An interrupt or citation expands the whole run by its id. + if (expanded) { next.delete(expandKey); + next.delete(runId); } else { next.add(expandKey); } @@ -2436,7 +2438,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.expandKey)} + 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} From 90b0a2c80c87d5677223bd213703f10796c5142a Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Sat, 3 Oct 2026 22:50:09 -0300 Subject: [PATCH 3/3] fix(clients): the next turn folds every fold of the previous run --- apps/mobile/src/features/threads/ThreadFeed.tsx | 7 +++++-- apps/web/src/components/chat/MessagesTimeline.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20f52d50afa9..e0a31fadfc7f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2688,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]); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f6aab6ce1541..1f7e392af889 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -730,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]);