mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
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.
This commit is contained in:
@@ -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<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-08-22T10:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: new Date("2026-08-22T10:00:01.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantBlock(
|
||||
id: string,
|
||||
blockIndex: number,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
...assistantMessage(id),
|
||||
blockGroupId: "response",
|
||||
blockIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||
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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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)(
|
||||
|
||||
@@ -49,6 +49,7 @@ interface LayoutSegmentInput {
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
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<StreamItem[], Map<string, StreamLayoutIte
|
||||
|
||||
export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
|
||||
const hasAuxiliaryFooter = input.isTurnActive || auxiliaryTurnFooter !== null;
|
||||
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
|
||||
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
|
||||
const historyBoundaryItem =
|
||||
@@ -313,6 +316,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
liveHeadBoundaryItem?.kind ?? "null",
|
||||
liveHeadBoundaryItem?.turnId ?? "null",
|
||||
auxiliaryTurnFooter?.itemId ?? "null",
|
||||
hasAuxiliaryFooter ? "footer" : "no-footer",
|
||||
].join(":");
|
||||
let byKey = historyLayoutCache.get(input.history);
|
||||
if (!byKey) {
|
||||
@@ -328,6 +332,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
@@ -347,6 +352,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
|
||||
@@ -74,6 +74,18 @@ describe("getAssistantBlockSpacing", () => {
|
||||
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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid={testID}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function"
|
||||
? (factory as (theme: Record<string, unknown>) => 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: <T,>(component: T) => component,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/message", () => ({
|
||||
AssistantTurnFooter: () => null,
|
||||
LiveElapsed: () => <span data-testid="running-turn-timestamp" />,
|
||||
STREAM_METADATA_FONT_SIZE: 11,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/assistant-fork-menu", () => ({
|
||||
AssistantForkMenu: () => <button data-testid="running-turn-fork" type="button" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/synced-loader", () => ({
|
||||
SyncedLoader: () => <span data-testid="running-turn-loader" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/retained-panel", () => ({
|
||||
useRetainedPanelActive: () => true,
|
||||
}));
|
||||
|
||||
import { TurnFooter } from "./turn-footer";
|
||||
|
||||
const unusedRunningTurnStrategy = null as unknown as React.ComponentProps<
|
||||
typeof TurnFooter
|
||||
>["strategy"];
|
||||
|
||||
describe("TurnFooter", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("places the running-turn fork between the loader and timestamp", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<TurnFooter
|
||||
isRunning
|
||||
inFlightTurnStartedAt={new Date("2026-08-01T10:00:00.000Z")}
|
||||
host={null}
|
||||
strategy={unusedRunningTurnStrategy}
|
||||
supportsTimelineCursor
|
||||
onForkInFlightTurn={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const footer = container.querySelector('[data-testid="turn-working-indicator"]');
|
||||
const controls = Array.from(footer?.querySelectorAll("[data-testid]") ?? []).map((node) =>
|
||||
node.getAttribute("data-testid"),
|
||||
);
|
||||
|
||||
expect(controls).toEqual([
|
||||
"running-turn-loader",
|
||||
"running-turn-fork",
|
||||
"running-turn-timestamp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
|
||||
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
@@ -220,7 +220,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
turnFooterRow: {
|
||||
marginTop: theme.spacing[4],
|
||||
marginTop: theme.spacing[2] + 5,
|
||||
},
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -260,6 +260,8 @@ export interface AgentStreamViewProps {
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
||||
bottomOverlayTailClearance?: number;
|
||||
/** Bottom offset required for controls floating above that overlay. */
|
||||
bottomOverlayControlClearance?: number;
|
||||
toast?: ToastApi | null;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -295,6 +297,10 @@ function useRetainedValue<T>(value: T, active: boolean): T {
|
||||
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
||||
|
||||
function resolveBottomOverlayControlOffset(clearance: number | undefined): number {
|
||||
return Math.max(16, clearance ?? 0);
|
||||
}
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -309,6 +315,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
bottomOverlayTailClearance = 0,
|
||||
bottomOverlayControlClearance,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
readOnly = false,
|
||||
@@ -934,6 +941,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
|
||||
|
||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||
const scrollToBottomContainerStyle = useMemo(
|
||||
() => [
|
||||
stylesheet.scrollToBottomContainer,
|
||||
{ bottom: resolveBottomOverlayControlOffset(bottomOverlayControlClearance) },
|
||||
],
|
||||
[bottomOverlayControlClearance],
|
||||
);
|
||||
const listEmptyComponent = useMemo(
|
||||
() =>
|
||||
renderListEmptyComponent({
|
||||
@@ -1065,7 +1079,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
onJumpToPrompt={chatOutline.jumpToPrompt}
|
||||
/>
|
||||
{(!isNearBottom || isTimelineDetached) && (
|
||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
||||
<View style={scrollToBottomContainerStyle} pointerEvents="box-none">
|
||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
@@ -1574,7 +1588,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
scrollToBottomContainer: {
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./web-virtualization";
|
||||
@@ -168,6 +169,55 @@ describe("estimateStreamItemHeight", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual row resize anchoring", () => {
|
||||
it("does not move the viewport when a visible row expands while detached", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 1200,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the reading position when a row above the viewport changes size", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves history prepend and bottom following to their existing anchors", () => {
|
||||
const baseInput = {
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: false,
|
||||
remainingDistanceFromBottom: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web virtualization test overrides", () => {
|
||||
it("uses defaults unless explicit positive integer overrides are present", () => {
|
||||
const globalWithOverrides = globalThis as typeof globalThis & {
|
||||
|
||||
@@ -10,6 +10,21 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||
|
||||
export function shouldAdjustScrollForVirtualRowResize(input: {
|
||||
isHistoryStartPrependActive: boolean;
|
||||
rowStart: number;
|
||||
scrollOffset: number;
|
||||
remainingDistanceFromBottom: number;
|
||||
bottomThreshold: number;
|
||||
}): boolean {
|
||||
if (input.isHistoryStartPrependActive) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
|
||||
);
|
||||
}
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,12 @@ export function resolveComposerPillClearance(isCompact: boolean): number {
|
||||
}
|
||||
|
||||
export function resolveComposerTrackTailClearance(isCompact: boolean): number {
|
||||
const composerClearance = resolveComposerPillClearance(isCompact);
|
||||
const transcriptClearance = isCompact ? SPACING[6] : 20;
|
||||
return transcriptClearance + COMPOSER_PILL_MIN_HEIGHT + composerClearance;
|
||||
}
|
||||
|
||||
export function resolveComposerTrackControlClearance(isCompact: boolean): number {
|
||||
const clearance = resolveComposerPillClearance(isCompact);
|
||||
return clearance + COMPOSER_PILL_MIN_HEIGHT + clearance;
|
||||
}
|
||||
|
||||
@@ -1,131 +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,
|
||||
},
|
||||
Pressable: "button",
|
||||
Text: "span",
|
||||
// The status ring reaches for plain React Native styles rather than Unistyles ones — see the
|
||||
// note on `rotatorStyles` in status-ring/frame.tsx.
|
||||
StyleSheet: { create: <T,>(styles: T): T => styles },
|
||||
View: ({
|
||||
children,
|
||||
style,
|
||||
onLayout,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
style?: unknown;
|
||||
onLayout?: unknown;
|
||||
}) => (
|
||||
<div data-style={JSON.stringify(style)} data-has-layout-handler={Boolean(onLayout)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: (theme: Record<string, unknown>) => unknown) =>
|
||||
factory({
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
borderRadius: { md: 6, lg: 8, xl: 12, "2xl": 16, full: 9999 },
|
||||
borderWidth: { 1: 1 },
|
||||
fontSize: { sm: 12 },
|
||||
colors: {
|
||||
statusDotWarning: "#1",
|
||||
statusDotDanger: "#2",
|
||||
statusDotRunning: "#3",
|
||||
statusDotSuccess: "#4",
|
||||
border: "#5",
|
||||
surface2: "#6",
|
||||
borderAccent: "#7",
|
||||
surface1: "#8",
|
||||
foregroundMuted: "#9",
|
||||
foreground: "#a",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/menu", () => ({
|
||||
MenuRoot: ({ children }: { children?: React.ReactNode }) => children,
|
||||
MenuSurface: ({ children }: { children?: React.ReactNode }) => children,
|
||||
MenuTrigger: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useMenuContext: () => ({ open: false }),
|
||||
}));
|
||||
|
||||
import { ComposerTrackBar } from "./tracks";
|
||||
import {
|
||||
COMPOSER_PILL_CLEARANCE,
|
||||
composerPillStyles,
|
||||
resolveComposerPillClearance,
|
||||
resolveComposerTrackTailClearance,
|
||||
} from "./pill-styles";
|
||||
|
||||
describe("ComposerTrackBar", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("floats over content without painting a background band", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(<ComposerTrackBar>track</ComposerTrackBar>));
|
||||
|
||||
const style = JSON.parse(container.firstElementChild?.getAttribute("data-style") ?? "{}");
|
||||
expect(style).toMatchObject({
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: { xs: 8, md: 10 },
|
||||
});
|
||||
expect(style).not.toHaveProperty("backgroundColor");
|
||||
expect(container.firstElementChild?.getAttribute("data-has-layout-handler")).toBe("false");
|
||||
|
||||
const trackStyle = JSON.parse(
|
||||
container.firstElementChild?.firstElementChild?.getAttribute("data-style") ?? "{}",
|
||||
);
|
||||
expect(trackStyle).toMatchObject({ flexDirection: "row" });
|
||||
expect(trackStyle).not.toHaveProperty("flexWrap");
|
||||
});
|
||||
|
||||
it("matches the composer's corner tangent without radius clamping", () => {
|
||||
expect(composerPillStyles.body).toMatchObject({
|
||||
minHeight: 32,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 16,
|
||||
});
|
||||
expect(composerPillStyles.label).toMatchObject({ fontSize: 12 });
|
||||
expect(resolveComposerPillClearance(true)).toBe(8);
|
||||
expect(resolveComposerPillClearance(false)).toBe(10);
|
||||
expect(resolveComposerTrackTailClearance(true)).toBe(48);
|
||||
expect(resolveComposerTrackTailClearance(false)).toBe(52);
|
||||
expect(COMPOSER_PILL_CLEARANCE).toEqual({ compact: 8, wide: 10 });
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,10 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { Composer } from "@/composer";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import { resolveComposerTrackTailClearance } from "@/composer/pill-styles";
|
||||
import {
|
||||
resolveComposerTrackControlClearance,
|
||||
resolveComposerTrackTailClearance,
|
||||
} from "@/composer/pill-styles";
|
||||
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
@@ -1444,6 +1447,9 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
const bottomOverlayTailClearance = hasVisibleComposerTracks
|
||||
? resolveComposerTrackTailClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const bottomOverlayControlClearance = hasVisibleComposerTracks
|
||||
? resolveComposerTrackControlClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
);
|
||||
@@ -1500,6 +1506,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
bottomOverlayTailClearance={bottomOverlayTailClearance}
|
||||
bottomOverlayControlClearance={bottomOverlayControlClearance}
|
||||
toast={toast}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
|
||||
@@ -329,7 +329,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
hr: {
|
||||
backgroundColor: theme.colors.border,
|
||||
height: 1,
|
||||
marginVertical: theme.spacing[6],
|
||||
marginVertical: 10,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user