From 09bf981f1321b78f1f9d731d4747e72581493611 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 26 May 2026 23:01:42 +0700 Subject: [PATCH] Reshape stream head/tail spacing --- packages/app/src/agent-stream/layout.test.ts | 70 +++++++++++++++++++ packages/app/src/agent-stream/layout.ts | 2 - packages/app/src/agent-stream/model.ts | 2 - .../src/agent-stream/strategy-web.test.tsx | 2 - .../app/src/agent-stream/strategy-web.tsx | 12 ---- packages/app/src/agent-stream/view.tsx | 7 +- 6 files changed, 72 insertions(+), 23 deletions(-) diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index cf39b6920..fba61bdba 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -123,6 +123,43 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { + it.each(["web", "android"] as const)( + "keeps split assistant block spacing identical to unsplit history on %s", + (platform) => { + const firstBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 }); + const secondBlock = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 }); + const thirdBlock = assistantMessage("turn:block:2", 4, { groupId: "turn", index: 2 }); + const splitLayout = layoutFor({ + platform, + agentStatus: "running", + tail: [userMessage("u1", 1), firstBlock], + head: [secondBlock, thirdBlock], + timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id], + }); + const unsplitLayout = layoutFor({ + platform, + agentStatus: "running", + tail: [userMessage("u1", 1), firstBlock, secondBlock, thirdBlock], + timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id], + }); + + expect(findLayoutItem(splitLayout, firstBlock.id).belowItem?.id).toBe(secondBlock.id); + expect(findLayoutItem(splitLayout, secondBlock.id).aboveItem?.id).toBe(firstBlock.id); + expect(findLayoutItem(splitLayout, firstBlock.id).assistantSpacing).toBe( + findLayoutItem(unsplitLayout, firstBlock.id).assistantSpacing, + ); + expect(findLayoutItem(splitLayout, secondBlock.id).assistantSpacing).toBe( + findLayoutItem(unsplitLayout, secondBlock.id).assistantSpacing, + ); + expect(findLayoutItem(splitLayout, firstBlock.id).gapBelow).toBe( + findLayoutItem(unsplitLayout, firstBlock.id).gapBelow, + ); + expect(findLayoutItem(splitLayout, secondBlock.id).gapBelow).toBe( + findLayoutItem(unsplitLayout, secondBlock.id).gapBelow, + ); + }, + ); + it("does not duplicate footers when a native assistant turn spans history and live head", () => { const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 }); const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 }); @@ -195,6 +232,39 @@ describe("layoutStream", () => { expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop"); }); + it.each(["web", "android"] as const)( + "keeps split tool sequencing and gapBelow identical to unsplit history on %s", + (platform) => { + const shell = toolCall("tool-1", 2); + const thinking = thought("thought-1", 3); + const assistant = assistantMessage("a1", 4); + const splitLayout = layoutFor({ + platform, + tail: [userMessage("u1", 1), shell], + head: [thinking, assistant], + }); + const unsplitLayout = layoutFor({ + platform, + tail: [userMessage("u1", 1), shell, thinking, assistant], + }); + + expect(findLayoutItem(splitLayout, shell.id).belowItem?.id).toBe(thinking.id); + expect(findLayoutItem(splitLayout, thinking.id).aboveItem?.id).toBe(shell.id); + expect(findLayoutItem(splitLayout, shell.id).toolSequence).toBe( + findLayoutItem(unsplitLayout, shell.id).toolSequence, + ); + expect(findLayoutItem(splitLayout, thinking.id).toolSequence).toBe( + findLayoutItem(unsplitLayout, thinking.id).toolSequence, + ); + expect(findLayoutItem(splitLayout, shell.id).gapBelow).toBe( + findLayoutItem(unsplitLayout, shell.id).gapBelow, + ); + expect(findLayoutItem(splitLayout, thinking.id).gapBelow).toBe( + findLayoutItem(unsplitLayout, thinking.id).gapBelow, + ); + }, + ); + it("computes tool sequence position from strategy-aware neighbors", () => { const shell = toolCall("tool-1", 2); const thinking = thought("thought-1", 3); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index de0fe38f9..1c6e83882 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -32,7 +32,6 @@ export interface StreamLayout { history: StreamLayoutItem[]; liveHead: StreamLayoutItem[]; auxiliaryTurnFooter: TurnFooterHost | null; - historyToHeadGap: number; } export interface StreamLayoutInput { @@ -243,6 +242,5 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { history, liveHead, auxiliaryTurnFooter, - historyToHeadGap: getGapBetweenStreamItems(historyBoundaryItem, liveHeadBoundaryItem), }; } diff --git a/packages/app/src/agent-stream/model.ts b/packages/app/src/agent-stream/model.ts index 20f1f787c..fe92e63fc 100644 --- a/packages/app/src/agent-stream/model.ts +++ b/packages/app/src/agent-stream/model.ts @@ -19,7 +19,6 @@ export interface StreamHistoryBoundary { hasVirtualizedHistory: boolean; hasMountedHistory: boolean; hasLiveHead: boolean; - historyToHeadGap: number; } export interface StreamRenderAuxiliary { @@ -205,7 +204,6 @@ export function buildAgentStreamRenderModel( hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0, hasMountedHistory: splitHistory.segments.historyMounted.length > 0, hasLiveHead: orderedHead.length > 0, - historyToHeadGap: 0, }, auxiliary: EMPTY_AUXILIARY, }; diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index b4e14f895..3a689a866 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -125,7 +125,6 @@ describe("createWebStreamStrategy", () => { hasVirtualizedHistory: true, hasMountedHistory: false, hasLiveHead: false, - historyToHeadGap: 0, }, renderers: createRenderers(rowRenderCount), listEmptyComponent: null, @@ -171,7 +170,6 @@ describe("createWebStreamStrategy", () => { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false, - historyToHeadGap: 0, }, renderers: createRenderers(vi.fn()), listEmptyComponent: null, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 3053aded0..7f5bea596 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -584,9 +584,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ) : null} {mountedHistoryRows} - {boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? ( - - ) : null} {liveHeadRows} {liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null} @@ -634,12 +631,3 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight), }); } - -interface HistoryToHeadSpacerProps { - height: number; -} - -function HistoryToHeadSpacer({ height }: HistoryToHeadSpacerProps) { - const spacerStyle = useMemo(() => ({ height, width: "100%" as const }), [height]); - return
; -} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 372c66eb3..57f17970d 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -613,16 +613,13 @@ const AgentStreamViewComponent = forwardRef(() => { return { ...baseRenderModel, - boundary: { - ...baseRenderModel.boundary, - historyToHeadGap: streamLayout.historyToHeadGap, - }, + boundary: baseRenderModel.boundary, auxiliary: { pendingPermissions: pendingPermissionsNode, turnFooter: turnFooterNode, }, }; - }, [baseRenderModel, pendingPermissionsNode, streamLayout.historyToHeadGap, turnFooterNode]); + }, [baseRenderModel, pendingPermissionsNode, turnFooterNode]); const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []); const listEmptyComponent = useMemo(