From cfd1c9544c177b21ed55b1cd2cdfe84fcb5ad9dd Mon Sep 17 00:00:00 2001 From: binjamshid <143622128+binjamshid@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:56:19 +0400 Subject: [PATCH] fix(clients): collapsed turns show one context-compacted divider A finished turn that compacted more than once kept every compaction divider visible when folded, stacked under the Worked for row and reading as one duplicated line. A folded turn now keeps only its latest divider; earlier ones fold with the turn's other work and reappear in place when expanded. Single-compaction turns are unchanged. Fixes #33825 --- apps/mobile/src/lib/threadActivity.test.ts | 37 ++++++++++++ apps/mobile/src/lib/threadActivity.ts | 6 +- .../chat/MessagesTimeline.logic.test.ts | 57 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 4 +- 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f5b4d0232a902..eea8de2ba828a 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -248,6 +248,43 @@ it.each(["/compact", "Continue"])( }, ); +it("keeps only the latest compaction divider visible when a turn that compacted twice folds", () => { + const compaction = (id: string, ordinal: number, beforeTokenCount: number) => ({ + ...base(id, "2026-06-20T00:00:03.000Z", ordinal), + type: "compaction" as const, + driver: ProviderDriverKind.make("claude"), + status: "completed" as const, + beforeTokenCount, + afterTokenCount: 20_000, + }); + const feed = buildThreadFeed([ + projected(userMessage(), 0), + projected(compaction("first-compaction", 1, 100_000), 1), + projected(command(), 2), + projected(compaction("second-compaction", 3, 90_000), 3), + ]); + const latestRun = { + runId, + status: "completed" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:03.000Z", + }; + const compactionsIn = (rows: ReadonlyArray) => + rows.flatMap((entry) => + entry.type === "activity-group" + ? entry.activities.flatMap(({ projectedItem: { item } }) => + item.type === "compaction" ? [item.beforeTokenCount] : [], + ) + : [], + ); + const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); + expect(collapsed.filter((entry) => entry.type === "run-fold")).toHaveLength(1); + expect(compactionsIn(collapsed)).toEqual([90_000]); + expect(compactionsIn(deriveThreadFeedPresentation(feed, latestRun, new Set([runId])))).toEqual([ + 100_000, 90_000, + ]); +}); + it("binds a persisted resume answer only to the next compaction in its run", () => { const requestId = RuntimeRequestId.make("resume-compaction"); const question = projected( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 50c90a05106a2..a9961f4101ca8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1152,6 +1152,10 @@ function deriveThreadFeedRunFolds( continue; } const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId); + // Folded dividers lose their place in the turn, so only the latest stays visible. + const visibleCompactionId = group.entries.findLast( + (entry) => entry.type === "activity-group" && isContextCompactionActivityGroup(entry), + )?.id; const hiddenEntryIds = new Set( group.entries .filter( @@ -1159,7 +1163,7 @@ function deriveThreadFeedRunFolds( entry.id !== terminalAssistantId && entry.type !== "html-render" && entry.type !== "mcp-app" && - !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) && + entry.id !== visibleCompactionId && !( entry.type === "activity-group" && entry.activities.some( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ba0f508ebeecd..c5300b0a2b90c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -700,6 +700,63 @@ describe("deriveMessagesTimelineRows", () => { }, ); + it("keeps only the latest compaction divider visible when a turn that compacted twice folds", () => { + const fixture = makeStreamingTimelineFixture(); + const [, , , liveUser, liveWork, liveAssistant] = fixture.visibleTurnItems; + if (liveUser?.item.type !== "user_message" || liveAssistant?.item.type !== "assistant_message") + throw new Error("Expected live fixture items"); + if (!liveWork) throw new Error("Expected live work"); + const assistantItem = liveAssistant.item; + const compaction = (id: string, position: number, before: number, after: number) => ({ + ...liveAssistant, + position, + sourceItemId: TurnItemId.make(id), + item: { + ...assistantItem, + id: TurnItemId.make(id), + type: "compaction" as const, + driver: null, + status: "completed" as const, + beforeTokenCount: before, + afterTokenCount: after, + }, + }); + const timelineEntries = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + liveUser, + compaction("first-compaction", 4, 100_000, 20_000), + { ...liveWork, position: 5 }, + compaction("second-compaction", 6, 90_000, 30_000), + ], + optimisticMessages: [], + }); + const rowsFor = (expandedRunIds?: ReadonlySet) => + deriveMessagesTimelineRows({ + timelineEntries, + isWorking: false, + latestRun: { + runId: fixture.runId, + status: "completed", + startedAt: fixture.time(5), + completedAt: fixture.time(8), + }, + turnDiffSummaries: [], + supportsConversationRollback: false, + ...(expandedRunIds ? { expandedRunIds } : {}), + }); + const collapsed = rowsFor(); + expect(collapsed.filter((row) => row.kind === "turn-fold")).toHaveLength(1); + expect(collapsed.some((row) => row.kind === "work")).toBe(false); + expect(collapsed.filter((row) => row.kind === "context-compaction")).toMatchObject([ + { tokenCounts: { before: "90K", after: "30K" } }, + ]); + const expanded = rowsFor(new Set([fixture.runId])); + expect(expanded.filter((row) => row.kind === "context-compaction")).toMatchObject([ + { tokenCounts: { before: "100K", after: "20K" } }, + { tokenCounts: { before: "90K", after: "30K" } }, + ]); + }); + it("folds an answered compact-on-return question into its compaction row", () => { const fixture = makeStreamingTimelineFixture(); const [history0, history1, history2, liveUser, liveWork, liveAssistant] = diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4536e9a250386..f85b72ae80b15 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1123,6 +1123,8 @@ function deriveTurnFolds(input: { const terminalEntryIndex = group.terminalEntry ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id) : group.entries.length; + // Folded dividers lose their place in the turn, so only the latest stays visible. + const visibleCompactionId = group.entries.findLast(timelineEntryIsCompaction)?.id; for (const [index, entry] of group.entries.entries()) { if (entry.id === group.terminalEntry?.id) { continue; @@ -1136,7 +1138,7 @@ function deriveTurnFolds(input: { continue; } if ( - isCompaction || + entry.id === visibleCompactionId || timelineEntryIsPersistentResourceCard(entry) || input.liveSubagentEntryIds.has(entry.id) || input.completeSupersededReplyEntryIds.has(entry.id)