From e34aa277a28d15c691c53caa0dc2b27f40a38907 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 11:57:50 +0100 Subject: [PATCH] fix(app): preserve reading position when older turns fold Item-based history pages can complete and collapse the row at the reading position. Transfer its anchor to the folded turn host so reaching history start does not drain older pages. Also omit reasoning from action summaries and align sender attribution with its message bubble. --- .../owned-subscriptions-old-daemon.spec.ts | 148 +++++++++++++----- .../app/e2e/browser/turn-collapse.spec.ts | 21 ++- .../support/helpers/timeline-pagination.ts | 47 ++++++ .../src/agent-stream/reading-anchor.test.ts | 18 +++ .../app/src/agent-stream/reading-anchor.ts | 23 ++- .../app/src/agent-stream/strategy-web.tsx | 23 ++- packages/app/src/agent-stream/strategy.ts | 2 + .../agent-stream/turn-collapse/model.test.ts | 11 ++ .../src/agent-stream/turn-collapse/model.ts | 22 +++ packages/app/src/agent-stream/view.tsx | 1 + .../app/src/components/user-message/index.tsx | 2 + .../tool-calls/detail-level/overview/label.ts | 4 +- 12 files changed, 271 insertions(+), 51 deletions(-) diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 1fdef794a0..f735f71098 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -5,22 +5,86 @@ import { getTerminalBufferText, } from "../support/helpers/terminal-perf"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; -import { metroTest as test, expect } from "../support/fixtures"; +import { metroTest as test, expect, type Page } from "../support/fixtures"; import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; import { + expectFoldedTurnAtReadingPosition, + expectOldestLoadedTurnPartial, + holdOlderHistoryPages, openAgentTimeline, expectTimelinePromptVisible, + userScrollsTimelineToHistoryStart, } from "../support/helpers/timeline-pagination"; +interface PublishedDaemonAgent { + serverId: string; + daemon: Awaited>; + agent: MockAgentWorkspace; + /** Register the host in the browser and apply app settings before the first load. */ + seedBrowser(page: Page, appSettings?: Record): Promise; + close(): Promise; +} + +async function startPublishedDaemonAgent(options: { + publishedVersion: string; + serverIdPrefix: string; + repoPrefix: string; + title: string; +}): Promise { + const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const daemon = await startIsolatedHostDaemon(serverId, { + publishedVersion: options.publishedVersion, + }); + const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port }); + const createdAgent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: options.title, + modeId: "load-test", + model: "ten-second-stream", + }); + const agent: MockAgentWorkspace = { + agentId: createdAgent.id, + workspaceId: workspace.workspaceId, + cwd: workspace.repoPath, + client: workspace.client, + cleanup: workspace.cleanup, + }; + return { + serverId, + daemon, + agent, + async seedBrowser(page, appSettings) { + const host = buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript( + ({ seededHost, preferences, settings }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings)); + }, + { seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings }, + ); + }, + async close() { + await agent.cleanup(); + await daemon.close(); + }, + }; +} + test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({ page, }) => { test.setTimeout(120_000); - const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; - const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); const observationSockets = new Set(); page.on("websocket", (socket) => { socket.on("framesent", ({ payload }) => { @@ -34,43 +98,18 @@ test("loads history across browser reconnection while retaining old-daemon conne observationSockets.add(socket); }); }); - const workspace = await seedWorkspace({ + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.2.5", + serverIdPrefix: "srv_old_pagination_", repoPrefix: "timeline-old-daemon-pagination-", - port: daemon.port, - }); - const createdAgent = await workspace.client.createAgent({ - provider: "mock", - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, title: "Published daemon pagination regression", - modeId: "load-test", - model: "ten-second-stream", }); - const agent: MockAgentWorkspace = { - agentId: createdAgent.id, - workspaceId: workspace.workspaceId, - cwd: workspace.repoPath, - client: workspace.client, - cleanup: workspace.cleanup, - }; + const { agent, serverId } = published; try { await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates"); await agent.client.waitForFinish(agent.agentId, 15_000); - - const host = buildSeededHost({ - serverId, - endpoint: `127.0.0.1:${daemon.port}`, - nowIso: new Date().toISOString(), - }); - await page.addInitScript( - ({ seededHost, preferences }) => { - localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); - }, - { seededHost: host, preferences: buildCreateAgentPreferences() }, - ); + await published.seedBrowser(page); await openAgentTimeline(page, agent, serverId); await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates"); @@ -87,7 +126,7 @@ test("loads history across browser reconnection while retaining old-daemon conne const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal"); expect(terminal.error).toBeNull(); const terminalId = terminal.terminal!.id; - const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId); + const route = buildHostWorkspaceRoute(serverId, agent.workspaceId); await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`); await focusTerminalSurface(page); await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n"); @@ -97,7 +136,44 @@ test("loads history across browser reconnection while retaining old-daemon conne await agent.client.killTerminal(terminalId); }); } finally { - await agent.cleanup(); - await daemon.close(); + await published.close(); + } +}); + +// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn +// starts mid-turn and only folds once the next page completes it; the reader must stay +// with that turn instead of landing at history start and draining every older page. +test("keeps the reader on the turn that folds when an older item page completes it", async ({ + page, +}) => { + test.setTimeout(240_000); + const published = await startPublishedDaemonAgent({ + publishedVersion: "0.13.0-beta.2", + serverIdPrefix: "srv_old_fold_", + repoPrefix: "timeline-old-daemon-fold-", + title: "Published daemon folded turn pagination", + }); + const { agent, serverId } = published; + const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`); + + try { + for (const prompt of prompts) { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 30_000); + } + await published.seedBrowser(page, { chatDisplay: "collapseTurns" }); + const history = await holdOlderHistoryPages(page, agent, published.daemon.port); + await openAgentTimeline(page, agent, serverId); + await expectTimelinePromptVisible(page, prompts.at(-1)!); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts); + + history.releasePage(1); + await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt); + await history.expectSettledWithRequestedPages(1); + } finally { + await published.close(); } }); diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index c170386266..cd080fd5b0 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -193,6 +193,7 @@ test("received messages show their sender and open that agent", async ({ page }) .getByText("Sent by Reviewer", { exact: true }) .evaluate((element) => getComputedStyle(element).color); await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor); + await expectSenderAlignedWithBubble(attribution); await attribution.scrollIntoViewIfNeeded(); await page.screenshot({ path: "/tmp/sender-attribution.png" }); await attribution.click(); @@ -202,6 +203,22 @@ test("received messages show their sender and open that agent", async ({ page }) } }); +async function expectSenderAlignedWithBubble(attribution: Locator) { + const edges = await attribution.evaluate((button) => { + const bubble = button.nextElementSibling; + const label = button.querySelector('[dir="auto"]'); + if (!bubble || !label) throw new Error("Sender label or message bubble missing"); + return { + labelRight: label.getBoundingClientRect().right, + bubbleRight: bubble.getBoundingClientRect().right, + }; + }); + expect( + Math.abs(edges.labelRight - edges.bubbleRight), + "sender and bubble share the right edge", + ).toBeLessThanOrEqual(1); +} + async function seedWholeTurnHistory() { const agent = await seedMockAgentWorkspace({ repoPrefix: "whole-turn-history-", @@ -301,6 +318,7 @@ for (const autoExpandReasoning of [false, true]) { await expectComposerVisible(page); const summary = page.getByTestId("turn-summary").last().getByRole("button"); await expect(summary).toBeVisible(); + await expect(summary).not.toContainText(/thought/i); await expect(reasoning).toHaveCount(0); await summary.click(); await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0); @@ -339,9 +357,10 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole await expect(reasoning).toHaveCount(0); const group = page .getByTestId("tool-call-group") - .filter({ hasText: /thought/, visible: true }) + .filter({ hasText: /read 1 file/i, visible: true }) .last(); await expect(group).toContainText(/read 1 file/i); + await expect(group.getByRole("button").first()).not.toContainText(/thought/i); await group.getByRole("button").first().click(); await expect(reasoning).toBeVisible(); } diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 52027df956..b152585d5a 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -423,6 +423,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend( expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2); } +/** + * Item-based pages from older hosts split turns, so the oldest loaded turn starts + * mid-turn: its prompt is above loaded history. Returns that prompt. + */ +export async function expectOldestLoadedTurnPartial( + page: Page, + prompts: readonly string[], +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const oldestLoadedPrompt = await timeline + .getByTestId("user-message") + .first() + .evaluate((element) => element.textContent ?? ""); + const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt)); + expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0); + const oldestRow = timeline.locator("[data-history-row-id]").first(); + await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount( + 0, + ); + const partialPrompt = prompts[loadedIndex - 1]!; + await expectTimelinePromptNotMounted(page, partialPrompt); + return partialPrompt; +} + +/** The reader stays with the turn they were reading once it folds behind its summary. */ +export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt }); + await expect(promptRow).toBeVisible({ timeout: 15_000 }); + // The host row carries the folded turn's summary line. + await expect(promptRow.getByTestId("turn-summary")).toBeVisible(); + await expect + .poll( + async () => { + const [timelineBox, promptBox] = await Promise.all([ + timeline.boundingBox(), + promptRow.boundingBox(), + ]); + if (!timelineBox || !promptBox) return false; + const offset = promptBox.y - timelineBox.y; + return offset >= 0 && offset <= timelineBox.height / 2; + }, + { message: "folded turn sits in the upper half of the viewport" }, + ) + .toBe(true); +} + export async function expectTimelineAtHistoryStart(page: Page): Promise { await expect .poll(async () => (await readTimelineViewport(page)).scrollTop) diff --git a/packages/app/src/agent-stream/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts index 74b2bd5bcd..d7d1b2beb4 100644 --- a/packages/app/src/agent-stream/reading-anchor.test.ts +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { createReadingAnchor } from "./reading-anchor"; describe("reading anchor", () => { + it("pins the folded turn host when a prepend replaces the row being read", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]); + const replacements = new Map([["tool", "prompt"]]); + expect(anchor.getRowId(replacements)).toBe("prompt"); + expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000); + // Projection does not consume the correction; an unmounted host keeps the pin. + expect(anchor.reconcile(0, [], false, replacements)).toBe(0); + expect(anchor.getRowId()).toBe("tool"); + expect( + anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + expect(anchor.getRowId()).toBe("prompt"); + expect( + anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements), + ).toBe(1000); + }); + const rows = [ { id: "above", top: 0, height: 500 }, { id: "reading", top: 500, height: 500 }, diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts index 4299f1d9c8..132ab16eda 100644 --- a/packages/app/src/agent-stream/reading-anchor.ts +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -6,6 +6,7 @@ interface RowGeometry { // A row must clear the reading line before the next row takes ownership. const READING_POSITION_OFFSET_PX = 8; +const NO_REPLACEMENTS: ReadonlyMap = new Map(); // Content coordinates survive user scrolling. Layout commits replace the geometry; // scroll events reuse it, without another DOM measurement pass. @@ -14,10 +15,15 @@ export function createReadingAnchor() { let geometry: readonly RowGeometry[] = []; const readingRow = (scrollTop: number) => geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); - const project = (scrollTop: number, row: Pick | undefined) => - scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0); + const getRowId = (replacements = NO_REPLACEMENTS) => + anchor ? (replacements.get(anchor.id) ?? anchor.id) : null; + const project = ( + scrollTop: number, + row: Pick | undefined, + replacements = NO_REPLACEMENTS, + ) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0); return { - getRowId: () => anchor?.id ?? null, + getRowId, getReadingRowId: (scrollTop: number) => readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null, project, @@ -28,13 +34,18 @@ export function createReadingAnchor() { const next = readingRow(scrollTop); anchor = next ? { id: next.id, top: next.top } : null; }, - reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + reconcile( + scrollTop: number, + rows: readonly RowGeometry[], + userScrolled = false, + replacements = NO_REPLACEMENTS, + ): number { geometry = rows; - const previous = anchor && rows.find((row) => row.id === anchor?.id); + const previous = anchor && rows.find((row) => row.id === getRowId(replacements)); // The first virtualized commit can precede its mounted range. Keep the // pinned reader until it mounts, rather than adopting an unrelated row. if (anchor && !previous && !userScrolled) return scrollTop; - const correctedTop = project(scrollTop, previous ?? undefined); + const correctedTop = project(scrollTop, previous ?? undefined, replacements); // A prepend can expose the bottom of an estimated row above the reader. // Do not transfer ownership to it until the user moves the reading position. const next = previous && !userScrolled ? previous : readingRow(correctedTop); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 8e8bdf7680..9dae42bcc0 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rangeExtractor = useCallback( (range: VirtualRange): number[] => { const anchorIndex = segments.historyVirtualized.findIndex( - (row) => row.id === readingAnchor.getRowId(), + (row) => row.id === readingAnchor.getRowId(props.readingRowReplacements), ); const container = scrollContainerRef.current; // TanStack has computed the new measurements before calling the extractor. @@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const measurement = virtualizer.measurementsCache[anchorIndex]; const correctedOffset = container && measurement - ? readingAnchor.project(container.scrollTop, { - id: String(measurement.key), - top: measurement.start, - }) + ? readingAnchor.project( + container.scrollTop, + { + id: String(measurement.key), + top: measurement.start, + }, + props.readingRowReplacements, + ) : container?.scrollTop; let visibleRange = range; if ( @@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ]), ].sort((left, right) => left - right); }, - [chatFindRowIndexes, readingAnchor, segments.historyVirtualized], + [chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements], ); const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); const observeVirtualOffset = useCallback((instance, onOffset) => { @@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const shouldAnchor = !followOutputRef.current && !isJumpSettling(); if (!shouldAnchor) readingAnchor.reset(); - const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); + const correctedTop = readingAnchor.reconcile( + container.scrollTop, + rows, + false, + props.readingRowReplacements, + ); if (!shouldAnchor) { readingAnchor.reset(); return; diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 3d9df4d201..1db6a3c1c9 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -78,6 +78,8 @@ export interface StreamRenderInput { segments: StreamRenderSegments; historyRowRevision?: StreamHistoryRowRevision; liveHeadRowRevision?: unknown; + /** Presentation rows replaced by another row after history is folded. */ + readingRowReplacements?: ReadonlyMap; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 6cefaf3651..d525f47d45 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -48,6 +48,17 @@ function collapse(tail: StreamItem[], options: Partial = {}) const ids = (items: StreamItem[]) => items.map((item) => item.id); describe("collapseTurns", () => { + it("maps a partial turn's former group host to the completed turn host", () => { + const result = collapse([user("u1"), shell("earlier-call"), reply("final")], { + getGroupedCalls: () => [shell("earlier-call"), shell("previous-group-host")], + }); + expect(result.hostIdByHiddenRowId.get("previous-group-host")).toBe("u1"); + expect(result.hostIdByHiddenRowId.get("earlier-call")).toBe("u1"); + const expanded = collapse([user("u1"), shell("c1"), reply("final")], { + expandedHostIds: new Set(["u1"]), + }); + expect(expanded.hostIdByHiddenRowId.size).toBe(0); + }); it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => { const create = shell("create"); if (create.payload.source !== "agent") throw new Error("Expected agent tool"); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index 20ab84a817..fe7dfb81d3 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -25,6 +25,8 @@ export interface TurnCollapse { nestedItemIds: ReadonlySet; /** The turn a folded message belongs to, for surfaces that must reveal it. */ hostIdByFoldedMessageId: ReadonlyMap; + /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ + hostIdByHiddenRowId: ReadonlyMap; } export interface CollapseTurnsInput { @@ -61,6 +63,22 @@ function staysVisibleWhenCollapsed(item: StreamItem): boolean { return isTurnTrigger(item) || item.kind === "compaction"; } +function recordHiddenRows( + items: readonly StreamItem[], + hostId: string, + getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], + hosts: Map, +): void { + for (const item of items) { + if (staysVisibleWhenCollapsed(item)) continue; + hosts.set(item.id, hostId); + // A prepend can change which call hosts a group before the turn folds. + if (item.kind === "tool_call" || item.kind === "thought") { + for (const call of getGroupedCalls(item.id) ?? []) hosts.set(call.id, hostId); + } + } +} + function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { for (let index = end - 1; index >= start; index -= 1) { if (tail[index]?.kind === "assistant_message") return index; @@ -80,6 +98,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const turnsByHostId = new Map(); const nestedItemIds = new Set(); const hostIdByFoldedMessageId = new Map(); + const hostIdByHiddenRowId = new Map(); let runStart = 0; const flushRun = (runEnd: number) => { @@ -102,6 +121,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { return; } const expanded = input.expandedHostIds.has(host.id); + if (!expanded) recordHiddenRows(folded, host.id, input.getGroupedCalls, hostIdByHiddenRowId); turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); const revealed = expanded ? folded.flatMap((item) => @@ -139,6 +159,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { turnsByHostId: EMPTY_TURNS, nestedItemIds: EMPTY_IDS, hostIdByFoldedMessageId: EMPTY_HOSTS, + hostIdByHiddenRowId: EMPTY_HOSTS, }; } return { @@ -149,5 +170,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { turnsByHostId, nestedItemIds, hostIdByFoldedMessageId, + hostIdByHiddenRowId, }; } diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 049f0d704b..82ae2ab718 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1203,6 +1203,7 @@ const AgentStreamViewComponent = forwardRef {streamRenderStrategy.render({ + readingRowReplacements: presentation.turns.hostIdByHiddenRowId, agentId, segments: renderModel.segments, historyRowRevision, diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx index d420ffdde5..8204b01794 100644 --- a/packages/app/src/components/user-message/index.tsx +++ b/packages/app/src/components/user-message/index.tsx @@ -73,6 +73,7 @@ export function MessageSender({ variant="ghost" size="sm" leftIcon={renderProviderIcon} + style={styles.sender} disabled={!host} onPress={openSender} > @@ -84,6 +85,7 @@ export function MessageSender({ const styles = StyleSheet.create((theme) => ({ preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" }, body: { flexShrink: 0 }, + sender: { paddingRight: 0, borderRightWidth: 0 }, toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 }, fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] }, })); diff --git a/packages/app/src/tool-calls/detail-level/overview/label.ts b/packages/app/src/tool-calls/detail-level/overview/label.ts index 7a34685d62..605e9ce211 100644 --- a/packages/app/src/tool-calls/detail-level/overview/label.ts +++ b/packages/app/src/tool-calls/detail-level/overview/label.ts @@ -44,7 +44,9 @@ export function useOverviewSummaryLabel( count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`, ); } - if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase()); + // Reasoning stays in the details; it is not an action in a work summary. + // Keep a label for a group containing only reasoning. + if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought"); return joinSummaryParts(parts, t("toolCallGroup.and")); }, [leadingParts, summary, t]); }