From 65fbe38dd34878163840a31445fa26f661642a73 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 22 Aug 2026 20:50:20 +0200 Subject: [PATCH] Restore compact and stable chat timelines (#3716) Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position. --- .../src/agent-stream/footer-spacing.test.ts | 139 ++++++++++++++++++ packages/app/src/agent-stream/layout.test.ts | 2 +- packages/app/src/agent-stream/layout.ts | 16 +- packages/app/src/agent-stream/spacing.test.ts | 12 ++ packages/app/src/agent-stream/spacing.ts | 8 +- .../app/src/agent-stream/strategy-web.tsx | 18 ++- .../app/src/agent-stream/turn-footer.test.tsx | 108 -------------- packages/app/src/agent-stream/turn-footer.tsx | 4 +- packages/app/src/agent-stream/view.tsx | 17 ++- .../agent-stream/web-virtualization.test.ts | 50 +++++++ .../src/agent-stream/web-virtualization.ts | 15 ++ packages/app/src/composer/pill-styles.ts | 6 + packages/app/src/composer/tracks.test.tsx | 131 ----------------- packages/app/src/panels/agent-panel.tsx | 9 +- packages/app/src/styles/markdown-styles.ts | 2 +- 15 files changed, 279 insertions(+), 258 deletions(-) create mode 100644 packages/app/src/agent-stream/footer-spacing.test.ts delete mode 100644 packages/app/src/agent-stream/turn-footer.test.tsx delete mode 100644 packages/app/src/composer/tracks.test.tsx diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts new file mode 100644 index 0000000000..f18d7ea27f --- /dev/null +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { TurnTiming } from "@/timeline/turn-time"; +import type { StreamItem } from "@/types/stream"; +import { layoutStream } from "./layout"; +import type { StreamStrategy } from "./strategy"; + +function strategyFor(platform: "web" | "native"): StreamStrategy { + const inverted = platform === "native"; + const neighborStep = inverted ? 1 : -1; + const getHistoryBoundaryIndex = (items: StreamItem[]) => { + if (items.length === 0) return null; + return inverted ? 0 : items.length - 1; + }; + const getLiveHeadBoundaryIndex = (items: StreamItem[]) => { + if (items.length === 0) return null; + return inverted ? items.length - 1 : 0; + }; + return { + orderTail: (items) => (inverted ? [...items].toReversed() : items), + orderHead: (items) => (inverted ? [...items].toReversed() : items), + getNeighborIndex: (index, relation) => + relation === "above" ? index + neighborStep : index - neighborStep, + getNeighborItem: (items, index, relation) => { + const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep; + return items[neighborIndex]; + }, + getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex, + getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex, + getLatestItemIndex: getHistoryBoundaryIndex, + getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"), + } as StreamStrategy; +} + +function userMessage(id: string): Extract { + return { + kind: "user_message", + id, + text: id, + timestamp: new Date("2026-08-22T10:00:00.000Z"), + }; +} + +function assistantMessage(id: string): Extract { + return { + kind: "assistant_message", + id, + text: id, + timestamp: new Date("2026-08-22T10:00:01.000Z"), + }; +} + +function assistantBlock( + id: string, + blockIndex: number, +): Extract { + return { + ...assistantMessage(id), + blockGroupId: "response", + blockIndex, + }; +} + +function timingFor(assistantId: string): Map { + return new Map([ + [ + assistantId, + { + startedAt: new Date("2026-08-22T10:00:00.000Z"), + completedAt: new Date("2026-08-22T10:00:02.000Z"), + durationMs: 2_000, + }, + ], + ]); +} + +function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) { + const strategy = strategyFor(input.platform); + const assistant = assistantMessage("assistant"); + return { + assistant, + layout: layoutStream({ + strategy, + isTurnActive: input.isTurnActive, + history: strategy.orderTail([userMessage("user"), assistant]), + liveHead: [], + timingByAssistantId: timingFor(assistant.id), + }), + }; +} + +describe("turn footer spacing", () => { + it.each(["web", "native"] as const)( + "compacts the last assistant against the completed footer on %s", + (platform) => { + const { assistant, layout } = layoutFor({ platform, isTurnActive: false }); + const assistantLayout = layout.history.find((item) => item.item.id === assistant.id); + + expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id); + expect(assistantLayout?.assistantSpacing).toBe("compactBottom"); + }, + ); + + it.each(["web", "native"] as const)( + "keeps the same compact edge while the footer is running on %s", + (platform) => { + const { assistant, layout } = layoutFor({ platform, isTurnActive: true }); + const assistantLayout = layout.history.find((item) => item.item.id === assistant.id); + + expect(layout.auxiliaryTurnFooter).toBeNull(); + expect(assistantLayout?.assistantSpacing).toBe("compactBottom"); + }, + ); + + it.each([ + ["web", false], + ["web", true], + ["native", false], + ["native", true], + ] as const)( + "compacts both edges of the final split paragraph on %s when active=%s", + (platform, isTurnActive) => { + const strategy = strategyFor(platform); + const first = assistantBlock("first", 0); + const last = assistantBlock("last", 1); + const layout = layoutStream({ + strategy, + isTurnActive, + history: strategy.orderTail([userMessage("user"), first]), + liveHead: strategy.orderHead([last]), + timingByAssistantId: timingFor(last.id), + }); + const firstLayout = layout.history.find((item) => item.item.id === first.id); + const lastLayout = layout.liveHead.find((item) => item.item.id === last.id); + + expect(firstLayout?.assistantSpacing).toBe("compactBottom"); + expect(lastLayout?.assistantSpacing).toBe("compactBoth"); + }, + ); +}); diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index 7d1a28c06a..9cc463590d 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -330,7 +330,7 @@ describe("layoutStream", () => { }); expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom"); - expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop"); + expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth"); }); it.each(["web", "android"] as const)( diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index ca52dfbf85..bc140189c7 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -49,6 +49,7 @@ interface LayoutSegmentInput { items: StreamItem[]; timingByAssistantId: Map; auxiliaryTurnFooter: TurnFooterHost | null; + hasAuxiliaryFooter: boolean; frameOrder: StreamFrameChildOrder; boundaryIndex: number | null; boundaryAboveItem: StreamItem | null; @@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { boundaryIndex: input.boundaryIndex, boundaryItem: input.boundaryBelowItem, }); - const assistantSpacing = getAssistantBlockSpacing({ - item, - aboveItem, - belowItem, - }); const completedFooter = resolveCompletedFooter({ strategy: input.strategy, items: input.items, @@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, }); + const assistantSpacing = getAssistantBlockSpacing({ + item, + aboveItem, + belowItem, + hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null), + }); return { item, @@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap { expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default"); }); + it("compacts the bottom edge when the turn footer follows the assistant block", () => { + const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 }); + expect( + getAssistantBlockSpacing({ + item: a, + aboveItem: null, + belowItem: null, + hasFooterBelow: true, + }), + ).toBe("compactBottom"); + }); + it("returns compactTop when the item above is in the same block group", () => { const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 }); const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 }); diff --git a/packages/app/src/agent-stream/spacing.ts b/packages/app/src/agent-stream/spacing.ts index 4d337debe3..87bdf75f88 100644 --- a/packages/app/src/agent-stream/spacing.ts +++ b/packages/app/src/agent-stream/spacing.ts @@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: { item: StreamItem; aboveItem: StreamItem | null | undefined; belowItem: StreamItem | null | undefined; + hasFooterBelow?: boolean; }): "default" | "compactTop" | "compactBottom" | "compactBoth" { if (params.item.kind !== "assistant_message") { return "default"; } const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem }); - const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem }); + const compactBottom = + params.hasFooterBelow || + isSameAssistantBlockGroup({ item: params.item, other: params.belowItem }); if (compactTop && compactBottom) return "compactBoth"; if (compactTop) return "compactTop"; if (compactBottom) return "compactBottom"; @@ -44,6 +47,9 @@ export function getGapBetweenStreamItems( if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { return SPACING[1]; } + if (item.kind === "user_message" && belowItem.kind === "assistant_message") { + return 0; + } if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { return 0; } diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index aa5e67bd3a..4f19448a80 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; -import { estimateStreamItemHeight } from "./web-virtualization"; +import { + estimateStreamItemHeight, + shouldAdjustScrollForVirtualRowResize, +} from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy } from "./strategy"; import { @@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool overscan: 8, }); useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { - if (historyStartPrependAnchorActiveRef.current) { - return false; - } + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + return shouldAdjustScrollForVirtualRowResize({ + isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current, + rowStart: item.start, + scrollOffset, + remainingDistanceFromBottom: remainingDistance, + bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX, + }); }; return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; diff --git a/packages/app/src/agent-stream/turn-footer.test.tsx b/packages/app/src/agent-stream/turn-footer.test.tsx deleted file mode 100644 index ab33c03bff..0000000000 --- a/packages/app/src/agent-stream/turn-footer.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("react-native", () => ({ - Platform: { - OS: "web", - select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default, - }, - View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => ( -
{children}
- ), -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" - ? (factory as (theme: Record) => unknown)({ - spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 }, - colors: { - foregroundMuted: "#aaa", - palette: { amber: { 500: "#f0b429", 700: "#b7791f" } }, - }, - }) - : factory, - }, - useUnistyles: () => ({ rt: { breakpoint: "md" } }), - withUnistyles: (component: T) => component, -})); - -vi.mock("@/components/message", () => ({ - AssistantTurnFooter: () => null, - LiveElapsed: () => , - STREAM_METADATA_FONT_SIZE: 11, -})); - -vi.mock("@/components/assistant-fork-menu", () => ({ - AssistantForkMenu: () =>