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]); }