Reshape stream head/tail spacing

This commit is contained in:
Mohamed Boudra
2026-05-26 23:01:42 +07:00
parent 3cf92ad6c5
commit 09bf981f13
6 changed files with 72 additions and 23 deletions

View File

@@ -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);

View File

@@ -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),
};
}

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -584,9 +584,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
</div>
) : null}
{mountedHistoryRows}
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
<HistoryToHeadSpacer height={boundary.historyToHeadGap} />
) : 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 <div style={spacerStyle} />;
}

View File

@@ -613,16 +613,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderModel = useMemo<AgentStreamRenderModel>(() => {
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(