From 15e95691578800f21ee9dd8b8de4ea75c549cfbd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Mar 2026 12:56:18 +0700 Subject: [PATCH] refactor: extract stream render model and segment-based rendering --- .../agent-stream-render-model.test.ts | 88 +++++ .../components/agent-stream-render-model.ts | 178 +++++++++++ .../agent-stream-render-strategy.ts | 1 + .../app/src/components/agent-stream-view.tsx | 302 ++++++++++-------- .../src/components/stream-strategy-native.tsx | 82 +++-- .../src/components/stream-strategy-web.tsx | 178 +++++------ .../app/src/components/stream-strategy.ts | 29 +- .../components/desktop-updates-section.tsx | 17 +- packages/app/src/screens/settings-screen.tsx | 74 ++--- packages/app/src/styles/theme.ts | 4 - packages/website/src/routes/index.tsx | 163 ++++++++-- 11 files changed, 766 insertions(+), 350 deletions(-) create mode 100644 packages/app/src/components/agent-stream-render-model.test.ts create mode 100644 packages/app/src/components/agent-stream-render-model.ts diff --git a/packages/app/src/components/agent-stream-render-model.test.ts b/packages/app/src/components/agent-stream-render-model.test.ts new file mode 100644 index 000000000..ca20bdd33 --- /dev/null +++ b/packages/app/src/components/agent-stream-render-model.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import { buildAgentStreamRenderModel } from "./agent-stream-render-model"; + +function createTimestamp(seed: number): Date { + return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); +} + +function userMessage(id: string, seed: number): StreamItem { + return { + kind: "user_message", + id, + text: id, + timestamp: createTimestamp(seed), + }; +} + +function assistantMessage(id: string, seed: number): StreamItem { + return { + kind: "assistant_message", + id, + text: id, + timestamp: createTimestamp(seed), + }; +} + +describe("buildAgentStreamRenderModel", () => { + it("keeps head separate from committed history on desktop web", () => { + const tail: StreamItem[] = []; + for (let index = 0; index < 60; index += 1) { + const seed = index * 2; + tail.push(userMessage(`u${index}`, seed + 1)); + tail.push(assistantMessage(`a${index}`, seed + 2)); + } + const head = [assistantMessage("live-a", 121)]; + + const model = buildAgentStreamRenderModel({ + tail, + head, + platform: "web", + isMobileBreakpoint: false, + }); + + expect(model.segments.historyVirtualized.length).toBeGreaterThan(0); + expect(model.segments.historyMounted.length).toBeGreaterThan(0); + expect(model.segments.liveHead.map((item) => item.id)).toEqual(["live-a"]); + expect(model.history).not.toContain(head[0]); + }); + + it("keeps the full committed tail mounted on mobile web", () => { + const tail = [userMessage("u1", 1), assistantMessage("a1", 2)]; + const head = [assistantMessage("live-a", 3)]; + + const model = buildAgentStreamRenderModel({ + tail, + head, + platform: "web", + isMobileBreakpoint: true, + }); + + expect(model.segments.historyVirtualized).toHaveLength(0); + expect(model.segments.historyMounted).toBe(tail); + expect(model.segments.liveHead).toBe(head); + }); + + it("reuses ordered committed history when only the live head changes", () => { + const tail = [userMessage("u1", 1), assistantMessage("a1", 2)]; + const firstHead = [assistantMessage("live-a", 3)]; + const secondHead = [assistantMessage("live-b", 4)]; + + const first = buildAgentStreamRenderModel({ + tail, + head: firstHead, + platform: "native", + isMobileBreakpoint: false, + }); + const second = buildAgentStreamRenderModel({ + tail, + head: secondHead, + platform: "native", + isMobileBreakpoint: false, + }); + + expect(first.history).toBe(second.history); + expect(first.segments.historyMounted).toBe(second.segments.historyMounted); + expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]); + }); +}); diff --git a/packages/app/src/components/agent-stream-render-model.ts b/packages/app/src/components/agent-stream-render-model.ts new file mode 100644 index 000000000..057749073 --- /dev/null +++ b/packages/app/src/components/agent-stream-render-model.ts @@ -0,0 +1,178 @@ +import type { ReactNode } from "react"; +import type { StreamItem } from "@/types/stream"; +import { + findMountedWindowStart, + getWebMountedRecentStreamItems, + getWebPartialVirtualizationThreshold, +} from "./agent-stream-web-virtualization"; +import { + orderHeadForStreamRenderStrategy, + orderTailForStreamRenderStrategy, + resolveStreamRenderStrategy, +} from "./stream-strategy"; + +export type StreamRenderSegments = { + historyVirtualized: StreamItem[]; + historyMounted: StreamItem[]; + liveHead: StreamItem[]; +}; + +export type StreamHistoryBoundary = { + hasVirtualizedHistory: boolean; + hasMountedHistory: boolean; + hasLiveHead: boolean; + historyToHeadGap: number; +}; + +export type StreamRenderAuxiliary = { + pendingPermissions: ReactNode; + workingIndicator: ReactNode; +}; + +export type AgentStreamRenderModel = { + history: StreamItem[]; + segments: StreamRenderSegments; + boundary: StreamHistoryBoundary; + auxiliary: StreamRenderAuxiliary; +}; + +export type BuildAgentStreamRenderModelInput = { + tail: StreamItem[]; + head: StreamItem[]; + platform: "web" | "native"; + isMobileBreakpoint: boolean; +}; + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const EMPTY_AUXILIARY: StreamRenderAuxiliary = { + pendingPermissions: null, + workingIndicator: null, +}; + +const orderedTailCache = new WeakMap>(); +const orderedHeadCache = new WeakMap>(); +const splitHistoryCache = new WeakMap< + StreamItem[], + Map> +>(); + +function getOrderedItems(params: { + cache: WeakMap>; + source: StreamItem[]; + cacheKey: string; + order: (items: StreamItem[]) => StreamItem[]; +}): StreamItem[] { + const { cache, source, cacheKey, order } = params; + let cachedByKey = cache.get(source); + if (!cachedByKey) { + cachedByKey = new Map(); + cache.set(source, cachedByKey); + } + const cached = cachedByKey.get(cacheKey); + if (cached) { + return cached; + } + const ordered = order(source); + cachedByKey.set(cacheKey, ordered); + return ordered; +} + +function splitOrderedTail(params: { + orderedTail: StreamItem[]; + platform: "web" | "native"; + isMobileBreakpoint: boolean; +}): Pick { + const { orderedTail, platform, isMobileBreakpoint } = params; + const shouldSplitHistory = + platform === "web" && + !isMobileBreakpoint && + orderedTail.length > getWebPartialVirtualizationThreshold(); + const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`; + let cachedByKey = splitHistoryCache.get(orderedTail); + if (!cachedByKey) { + cachedByKey = new Map(); + splitHistoryCache.set(orderedTail, cachedByKey); + } + const cached = cachedByKey.get(cacheKey); + if (cached) { + return cached; + } + + if (!shouldSplitHistory) { + const unsplit = { + history: orderedTail, + segments: { + historyVirtualized: EMPTY_STREAM_ITEMS, + historyMounted: orderedTail, + liveHead: EMPTY_STREAM_ITEMS, + }, + } satisfies Pick; + cachedByKey.set(cacheKey, unsplit); + return unsplit; + } + + const mountedWindowStart = findMountedWindowStart({ + items: orderedTail, + minMountedCount: getWebMountedRecentStreamItems(), + }); + const split = { + history: orderedTail, + segments: { + historyVirtualized: orderedTail.slice(0, mountedWindowStart), + historyMounted: orderedTail.slice(mountedWindowStart), + liveHead: EMPTY_STREAM_ITEMS, + }, + } satisfies Pick; + cachedByKey.set(cacheKey, split); + return split; +} + +export function buildAgentStreamRenderModel( + input: BuildAgentStreamRenderModelInput +): AgentStreamRenderModel { + const strategy = resolveStreamRenderStrategy({ + platform: input.platform === "web" ? "web" : "native", + isMobileBreakpoint: input.isMobileBreakpoint, + }); + const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`; + const orderedTail = getOrderedItems({ + cache: orderedTailCache, + source: input.tail, + cacheKey: orderingCacheKey, + order: (items) => + orderTailForStreamRenderStrategy({ + strategy, + streamItems: items, + }), + }); + const orderedHead = getOrderedItems({ + cache: orderedHeadCache, + source: input.head, + cacheKey: orderingCacheKey, + order: (items) => + orderHeadForStreamRenderStrategy({ + strategy, + streamHead: items, + }), + }); + const splitHistory = splitOrderedTail({ + orderedTail, + platform: input.platform, + isMobileBreakpoint: input.isMobileBreakpoint, + }); + + return { + history: splitHistory.history, + segments: { + ...splitHistory.segments, + liveHead: orderedHead, + }, + boundary: { + 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/components/agent-stream-render-strategy.ts b/packages/app/src/components/agent-stream-render-strategy.ts index 378455bcb..38c64153e 100644 --- a/packages/app/src/components/agent-stream-render-strategy.ts +++ b/packages/app/src/components/agent-stream-render-strategy.ts @@ -1 +1,2 @@ export * from "./stream-strategy"; +export * from "./agent-stream-render-model"; diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 386ad814d..9fc2f99ae 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -53,13 +53,12 @@ import { ToolCallDetailsContent } from "./tool-call-details"; import { QuestionFormCard } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; import { + buildAgentStreamRenderModel, collectAssistantTurnContentForStreamRenderStrategy, - getStreamEdgeSlotProps, getStreamNeighborItem, - orderHeadForStreamRenderStrategy, - orderTailForStreamRenderStrategy, resolveStreamRenderStrategy, - type StreamEdgeSlotProps, + type AgentStreamRenderModel, + type StreamSegmentRenderers, type StreamViewportHandle, } from "./agent-stream-render-strategy"; import { @@ -199,12 +198,14 @@ export const AgentStreamView = forwardRef { - return orderTailForStreamRenderStrategy({ - strategy: streamRenderStrategy, - streamItems, + const baseRenderModel = useMemo(() => { + return buildAgentStreamRenderModel({ + tail: streamItems, + head: streamHead ?? [], + platform: Platform.OS === "web" ? "web" : "native", + isMobileBreakpoint: isMobile, }); - }, [streamItems, streamRenderStrategy]); + }, [isMobile, streamHead, streamItems]); useImperativeHandle(ref, () => ({ scrollToBottom(reason = "jump-to-bottom") { viewportRef.current?.scrollToBottom(reason); @@ -218,68 +219,48 @@ export const AgentStreamView = forwardRef { - return orderHeadForStreamRenderStrategy({ - strategy: streamRenderStrategy, - streamHead: streamHead ?? [], - }); - }, [streamHead, streamRenderStrategy]); - const tightGap = theme.spacing[1]; // 4px const looseGap = theme.spacing[4]; // 16px - const getGapBelow = useCallback( - (item: StreamItem, index: number, items: StreamItem[]) => { - const belowItem = getStreamNeighborItem({ - strategy: streamRenderStrategy, - items, - index, - relation: "below", - }); - if (!belowItem) { + const getGapBetween = useCallback( + (item: StreamItem | null, belowItem: StreamItem | null) => { + if (!item || !belowItem) { return 0; } - // Same type groups get tight gap (4px) if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { return tightGap; } - if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { return tightGap; } - - // Give user messages more breathing room before tool sequences. if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { return looseGap; } - - // Keep tool sequences visually connected to the preceding user/assistant message. if ( (item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem) ) { return tightGap; } - - // Keep todo lists visually connected to the following tool sequence (symmetry). if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) { return tightGap; } - - // Keep tool sequences visually connected to the assistant response (symmetry). if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { return tightGap; } - - // Different types get loose gap (16px) return looseGap; }, - [looseGap, streamRenderStrategy, tightGap] + [looseGap, tightGap] ); const renderStreamItemContent = useCallback( - (item: StreamItem, index: number, items: StreamItem[]) => { + ( + item: StreamItem, + index: number, + items: StreamItem[], + seamAboveItem: StreamItem | null = null + ) => { const handleInlineDetailsExpandedChange = (expanded: boolean) => { if ( !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() @@ -299,12 +280,13 @@ export const AgentStreamView = forwardRef { - const content = renderStreamItemContent(item, index, items); + ( + item: StreamItem, + index: number, + items: StreamItem[], + seamAboveItem: StreamItem | null = null + ) => { + const content = renderStreamItemContent(item, index, items, seamAboveItem); if (!content) { return null; } - const gapBelow = getGapBelow(item, index, items); const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "below", }); + const gapBelow = getGapBetween(item, nextItem ?? null); const isEndOfAssistantTurn = item.kind === "assistant_message" && (nextItem?.kind === "user_message" || @@ -460,7 +447,7 @@ export const AgentStreamView = forwardRef { - const hasPermissions = pendingPermissionItems.length > 0; - const hasHeadItems = orderedStreamHead.length > 0; - - if (!hasPermissions && !showBottomBar && !hasHeadItems) { - return null; - } - - const leftContent = showWorkingIndicator ? : null; - - return ( - - - {hasPermissions ? ( - - {pendingPermissionItems.map((permission) => ( - - ))} - - ) : null} - - {hasHeadItems - ? orderedStreamHead.map((item, index) => { - const rendered = renderStreamItemContent( - item, - index, - orderedStreamHead - ); - return rendered ? ( - - {rendered} - - ) : null; - }) - : null} - - {showBottomBar ? {leftContent} : null} + const renderModel = useMemo(() => { + const pendingPermissionsNode = + pendingPermissionItems.length > 0 ? ( + + {pendingPermissionItems.map((permission) => ( + + ))} + ) : null; + const workingIndicatorNode = showWorkingIndicator ? ( + + - ); + ) : null; + + return { + ...baseRenderModel, + boundary: { + ...baseRenderModel.boundary, + historyToHeadGap: getGapBetween( + baseRenderModel.history.at(-1) ?? null, + baseRenderModel.segments.liveHead[0] ?? null + ), + }, + auxiliary: { + pendingPermissions: pendingPermissionsNode, + workingIndicator: workingIndicatorNode, + }, + }; }, [ + baseRenderModel, + client, + getGapBetween, pendingPermissionItems, showWorkingIndicator, - client, - orderedStreamHead, - renderStreamItemContent, - showBottomBar, - tightGap, ]); - const listEdgeSlotProps = useMemo(() => { - if (!listEdgeSlotComponent) { - return {}; - } - return getStreamEdgeSlotProps({ - strategy: streamRenderStrategy, - component: listEdgeSlotComponent, - gapSize: tightGap, - }); - }, [listEdgeSlotComponent, streamRenderStrategy, tightGap]); - const listEmptyComponent = useMemo(() => { - const hasPermissions = pendingPermissionItems.length > 0; - const hasHeadItems = orderedStreamHead.length > 0; - if (hasPermissions || hasHeadItems) { + if ( + renderModel.boundary.hasVirtualizedHistory || + renderModel.boundary.hasMountedHistory || + renderModel.boundary.hasLiveHead || + renderModel.auxiliary.pendingPermissions || + renderModel.auxiliary.workingIndicator + ) { return null; } - const shouldShowWorking = agent.status === "running"; - - if (shouldShowWorking) { - return ( - - - Working… - - ); - } - return ( @@ -646,12 +590,86 @@ export const AgentStreamView = forwardRef ); - }, [ - agent.status, - pendingPermissionItems.length, - orderedStreamHead, - theme.colors.foregroundMuted, - ]); + }, [renderModel]); + + const historyItems = renderModel.history; + const liveHeadItems = renderModel.segments.liveHead; + const { boundary, auxiliary } = renderModel; + const lastHistoryItem = historyItems.at(-1) ?? null; + + const historyIndexById = useMemo(() => { + const indexById = new Map(); + historyItems.forEach((item, index) => { + indexById.set(item.id, index); + }); + return indexById; + }, [historyItems]); + + const renderHistoryRow = useCallback( + (item: StreamItem) => { + const historyIndex = historyIndexById.get(item.id); + if (historyIndex === undefined) { + return null; + } + return renderStreamItem(item, historyIndex, historyItems); + }, + [historyIndexById, historyItems, renderStreamItem] + ); + + const renderHistoryVirtualizedRow = useCallback( + (item) => renderHistoryRow(item), + [renderHistoryRow] + ); + const renderHistoryMountedRow = useCallback( + (item) => renderHistoryRow(item), + [renderHistoryRow] + ); + const renderLiveHeadRow = useCallback( + (item, index, items) => + renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null), + [lastHistoryItem, renderStreamItem] + ); + const renderLiveAuxiliary = useCallback( + () => { + if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) { + return null; + } + return ( + + + {auxiliary.pendingPermissions} + {auxiliary.workingIndicator} + + + ); + }, + [ + auxiliary.pendingPermissions, + auxiliary.workingIndicator, + boundary.hasLiveHead, + tightGap, + ] + ); + + const renderers = useMemo( + () => ({ + renderHistoryVirtualizedRow, + renderHistoryMountedRow, + renderLiveHeadRow, + renderLiveAuxiliary, + }), + [ + renderHistoryVirtualizedRow, + renderHistoryMountedRow, + renderLiveHeadRow, + renderLiveAuxiliary, + ] + ); const streamScrollEnabled = !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || @@ -663,8 +681,9 @@ export const AgentStreamView = forwardRef {streamRenderStrategy.render({ agentId, - rows: orderedStreamItems, - renderRow: renderStreamItem, + segments: renderModel.segments, + boundary, + renderers, listEmptyComponent, viewportRef, routeBottomAnchorRequest, @@ -674,7 +693,6 @@ export const AgentStreamView = forwardRef {!isNearBottom && ( diff --git a/packages/app/src/components/stream-strategy-native.tsx b/packages/app/src/components/stream-strategy-native.tsx index 533b93b29..fa1337478 100644 --- a/packages/app/src/components/stream-strategy-native.tsx +++ b/packages/app/src/components/stream-strategy-native.tsx @@ -8,9 +8,7 @@ import { type NativeSyntheticEvent, } from "react-native"; import type { StreamItem } from "@/types/stream"; -import { - useBottomAnchorController, -} from "./use-bottom-anchor-controller"; +import { useBottomAnchorController } from "./use-bottom-anchor-controller"; import type { StreamRenderInput, StreamStrategy, @@ -30,8 +28,9 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) { const { agentId, - rows, - renderRow, + segments, + boundary, + renderers, listEmptyComponent, viewportRef, routeBottomAnchorRequest, @@ -40,7 +39,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat scrollEnabled, listStyle, baseListContentContainerStyle, - edgeSlotProps, strategy, } = props; const flatListRef = useRef>(null); @@ -58,6 +56,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); + const historyRows = useMemo(() => { + if (segments.historyVirtualized.length === 0) { + return segments.historyMounted; + } + return [...segments.historyVirtualized, ...segments.historyMounted]; + }, [segments.historyMounted, segments.historyVirtualized]); + const clearNativeViewportSettling = useCallback(() => { if (nativeViewportSettlingFrameIdRef.current !== null) { cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current); @@ -90,19 +95,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat [isNativeViewportSettling, strategy] ); - const scrollToBottom = useCallback((animated: boolean) => { - programmaticScrollEventBudgetRef.current = 3; - flatListRef.current?.scrollToOffset({ - offset: 0, - animated, - }); - scrollOffsetYRef.current = 0; - streamViewportMetricsRef.current = { - ...streamViewportMetricsRef.current, - offsetY: 0, - }; - onNearBottomChange(true); - }, [onNearBottomChange]); + const scrollToBottom = useCallback( + (animated: boolean) => { + programmaticScrollEventBudgetRef.current = 3; + flatListRef.current?.scrollToOffset({ + offset: 0, + animated, + }); + scrollOffsetYRef.current = 0; + streamViewportMetricsRef.current = { + ...streamViewportMetricsRef.current, + offsetY: 0, + }; + onNearBottomChange(true); + }, + [onNearBottomChange] + ); const bottomAnchorController = useBottomAnchorController({ agentId, @@ -163,7 +171,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat useEffect(() => { bottomAnchorController.prepareForStickyContentChange(); - }, [bottomAnchorController, rows]); + }, [bottomAnchorController, historyRows, segments.liveHead]); useEffect(() => { const handle: StreamViewportHandle = { @@ -272,30 +280,50 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const renderItem = useCallback( ({ item, index }: ListRenderItemInfo) => { - const rendered = renderRow(item, index, rows); + const rendered = renderers.renderHistoryMountedRow(item, index, historyRows); return rendered ? {rendered} : null; }, - [renderRow, rows] + [historyRows, renderers] ); + const liveHeaderContent = useMemo(() => { + const liveHeadRows = segments.liveHead.map((item, index) => ( + + {renderers.renderLiveHeadRow(item, index, segments.liveHead)} + + )); + const liveAuxiliary = renderers.renderLiveAuxiliary(); + if ( + liveHeadRows.length === 0 && + !liveAuxiliary && + !boundary.hasMountedHistory && + !boundary.hasVirtualizedHistory + ) { + return listEmptyComponent ? {listEmptyComponent} : null; + } + return ( + + {liveHeadRows} + {liveAuxiliary} + + ); + }, [boundary, listEmptyComponent, renderers, segments.liveHead]); + return ( item.id} testID="agent-chat-scroll" nativeID="agent-chat-scroll-native-virtualized" - {...edgeSlotProps} + ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined} contentContainerStyle={baseListContentContainerStyle} style={listStyle} onLayout={handleListLayout} onScroll={handleScroll} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} - ListEmptyComponent={ - listEmptyComponent ? () => {listEmptyComponent} : undefined - } maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION} initialNumToRender={12} windowSize={10} diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 313928e35..2a30721d1 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -1,8 +1,6 @@ import { Fragment, type CSSProperties, - createElement, - isValidElement, useCallback, useEffect, useLayoutEffect, @@ -11,13 +9,7 @@ import { useState, } from 'react' import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual' -import { View } from 'react-native' -import { - estimateStreamItemHeight, - getWebMountedRecentStreamItems, - getWebPartialVirtualizationThreshold, - splitWebVirtualizedHistory, -} from './agent-stream-web-virtualization' +import { estimateStreamItemHeight } from './agent-stream-web-virtualization' import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy' import { createStreamStrategy } from './stream-strategy' @@ -68,28 +60,6 @@ function isScrollContainerAtBottom( return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX) } -function renderEdge( - content: StreamRenderInput['edgeSlotProps']['ListHeaderComponent'], - style?: CSSProperties -) { - if (!content) { - return null - } - const rendered = isValidElement(content) ? content : createElement(content) - return ( -
- {rendered} -
- ) -} - function scrollElementToBottom( scrollContainer: HTMLElement, behavior: ScrollBehaviorLike = 'auto' @@ -127,18 +97,15 @@ function isScrollContainerOverscrolledPastBottom( function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { - rows, - renderRow, + segments, + boundary, + renderers, listEmptyComponent, viewportRef, routeBottomAnchorRequest, isAuthoritativeHistoryReady, onNearBottomChange, scrollEnabled, - listStyle, - baseListContentContainerStyle, - forwardListContentContainerStyle, - edgeSlotProps, isMobileBreakpoint, } = props const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } = @@ -147,7 +114,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const contentRef = useRef(null) const [followOutput, setFollowOutputr] = useState(true) const setFollowOutput = (value: boolean) => { - console.trace('setFollowOutput', value) setFollowOutputr(value) return value } @@ -160,32 +126,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollTimeoutRef = useRef(null) const streamScrollbarMetrics = useWebDesktopScrollbarMetrics() const showDesktopWebScrollbar = !isMobileBreakpoint - const shouldUseVirtualizer = - !isMobileBreakpoint && rows.length > getWebPartialVirtualizationThreshold() + const shouldUseVirtualizer = segments.historyVirtualized.length > 0 + const { + renderHistoryVirtualizedRow, + renderHistoryMountedRow, + renderLiveHeadRow, + renderLiveAuxiliary, + } = renderers followOutputRef.current = followOutput - const indexedRows = useMemo(() => rows.map((item, index) => ({ item, index })), [rows]) - const webVirtualizedHistoryWindow = useMemo(() => { - if (!shouldUseVirtualizer) { - return null - } - return splitWebVirtualizedHistory({ - entries: indexedRows, - minMountedCount: getWebMountedRecentStreamItems(), - }) - }, [indexedRows, shouldUseVirtualizer]) - const virtualizedEntries = webVirtualizedHistoryWindow?.virtualizedEntries ?? [] - const mountedEntries = webVirtualizedHistoryWindow?.mountedEntries ?? indexedRows const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady const rowVirtualizer = useVirtualizer({ - count: virtualizedEntries.length, + count: segments.historyVirtualized.length, getScrollElement: () => scrollContainerRef.current, - getItemKey: (index: number) => virtualizedEntries[index]?.item.id ?? index, + getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { - const row = virtualizedEntries[index]?.item + const row = segments.historyVirtualized[index] return row ? estimateStreamItemHeight(row) : 120 }, measureElement: measureVirtualElement, @@ -220,7 +179,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, []) const scrollMessagesToBottom = useCallback( - (behavior: ScrollBehaviorLike = 'auto', source = 'unknown') => { + (behavior: ScrollBehaviorLike = 'auto') => { const scrollContainer = scrollContainerRef.current if (!scrollContainer) { return @@ -236,7 +195,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ) const scheduleStickToBottom = useCallback( - (source = 'unknown') => { + () => { const scrollContainer = scrollContainerRef.current if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) { return @@ -249,15 +208,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (!followOutputRef.current) { return } - scrollMessagesToBottom('auto', source) + scrollMessagesToBottom('auto') }) }, [scrollMessagesToBottom] ) + const forceStickToBottom = useCallback(() => { cancelPendingStickToBottom() - scrollMessagesToBottom('auto', 'force') - scheduleStickToBottom('force') + scrollMessagesToBottom('auto') + scheduleStickToBottom() }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]) const updateScrollMetrics = useCallback(() => { @@ -303,7 +263,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } const currentScrollTop = scrollContainer.scrollTop - const isNearBottom = syncNearBottom(scrollContainer, onNearBottomChange) const isAtBottom = isScrollContainerAtBottom(scrollContainer) const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON @@ -325,7 +284,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool lastKnownScrollTopRef.current = currentScrollTop updateScrollMetrics() - }, [cancelPendingStickToBottom, onNearBottomChange, updateScrollMetrics]) + }, [cancelPendingStickToBottom, updateScrollMetrics]) useLayoutEffect(() => { if (!isActivationReady) { @@ -334,13 +293,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool setFollowOutput(true) forceStickToBottom() const timeout = window.setTimeout(() => { - console.log('timeout', { - followOutputRef: followOutputRef.current, - scrollContainerRef: scrollContainerRef.current, - isScrollContainerNearBottom: scrollContainerRef.current - ? isScrollContainerNearBottom(scrollContainerRef.current) - : null, - }) if (!followOutputRef.current) { return } @@ -351,7 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (isScrollContainerNearBottom(scrollContainer)) { return } - scheduleStickToBottom('activation-timeout') + scheduleStickToBottom() }, WEB_BOTTOM_SETTLE_TIMEOUT_MS) return () => { window.clearTimeout(timeout) @@ -362,22 +314,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (!followOutputRef.current) { return } - scheduleStickToBottom('rows-effect') - }, [rows, scheduleStickToBottom]) + scheduleStickToBottom() + }, [ + scheduleStickToBottom, + segments.historyMounted, + segments.historyVirtualized, + segments.liveHead, + ]) useEffect(() => { - if (!followOutputRef.current) { + if (!followOutputRef.current || !shouldUseVirtualizer) { return } - if (!webVirtualizedHistoryWindow) { - return - } - scheduleStickToBottom('virtual-total-size-effect') - }, [scheduleStickToBottom, virtualTotalSize, webVirtualizedHistoryWindow]) + scheduleStickToBottom() + }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]) useEffect(() => { updateScrollMetrics() - }, [updateScrollMetrics, virtualTotalSize, rows.length]) + }, [ + segments.historyMounted.length, + segments.historyVirtualized.length, + segments.liveHead.length, + updateScrollMetrics, + virtualTotalSize, + ]) useEffect(() => { const scrollContainer = scrollContainerRef.current @@ -392,7 +352,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (!followOutputRef.current) { return } - scheduleStickToBottom('resize-observer') + scheduleStickToBottom() }) observer.observe(scrollContainer) if (contentNode) { @@ -478,7 +438,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (!followOutputRef.current) { return } - scheduleStickToBottom('prepare-for-viewport-change') + scheduleStickToBottom() }, } viewportRef.current = handle @@ -488,7 +448,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } cancelPendingStickToBottom() } - }, [cancelPendingStickToBottom, scheduleStickToBottom, forceStickToBottom, viewportRef]) + }, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]) const contentContainerStyle = useMemo( (): CSSProperties => ({ @@ -513,11 +473,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }), [scrollEnabled] ) - const headerEdgeContent = renderEdge(edgeSlotProps.ListHeaderComponent) - const footerEdgeContent = renderEdge( - edgeSlotProps.ListFooterComponent, - edgeSlotProps.ListFooterComponentStyle as CSSProperties | undefined - ) const virtualRowsContainerStyle = useMemo( (): CSSProperties => ({ position: 'relative', @@ -538,6 +493,30 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }), [] ) + const mountedHistoryRows = useMemo( + () => + segments.historyMounted.map((item, index) => ( + + {renderHistoryMountedRow(item, index, segments.historyMounted)} + + )), + [renderHistoryMountedRow, segments.historyMounted] + ) + const liveHeadRows = useMemo( + () => + segments.liveHead.map((item, index) => ( + + {renderLiveHeadRow(item, index, segments.liveHead)} + + )), + [renderLiveHeadRow, segments.liveHead] + ) + const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]) + const shouldRenderEmpty = + !boundary.hasMountedHistory && + !boundary.hasVirtualizedHistory && + !boundary.hasLiveHead && + !liveAuxiliary return ( <> @@ -556,12 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }} style={contentContainerStyle} > - {headerEdgeContent} - {webVirtualizedHistoryWindow ? ( + {shouldUseVirtualizer ? (
{virtualRows.map((virtualRow) => { - const entry = virtualizedEntries[virtualRow.index] - if (!entry) { + const item = segments.historyVirtualized[virtualRow.index] + if (!item) { return null } return ( @@ -571,17 +549,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ref={rowVirtualizer.measureElement} style={renderVirtualRowStyle(virtualRow.start)} > - {renderRow(entry.item, entry.index, rows)} + {renderHistoryVirtualizedRow( + item, + virtualRow.index, + segments.historyVirtualized + )}
) })} ) : null} - {mountedEntries.map((entry) => ( - {renderRow(entry.item, entry.index, rows)} - ))} - {rows.length === 0 ? listEmptyComponent : null} - {footerEdgeContent} + {mountedHistoryRows} + {boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? ( +
+ ) : null} + {liveHeadRows} + {liveAuxiliary} + {shouldRenderEmpty ? listEmptyComponent : null}
void; }; +export type StreamSegmentRenderers = { + renderHistoryVirtualizedRow: ( + item: StreamItem, + index: number, + items: StreamItem[] + ) => ReactNode; + renderHistoryMountedRow: ( + item: StreamItem, + index: number, + items: StreamItem[] + ) => ReactNode; + renderLiveHeadRow: ( + item: StreamItem, + index: number, + items: StreamItem[] + ) => ReactNode; + renderLiveAuxiliary: () => ReactNode; +}; + export type StreamRenderInput = { agentId: string; - rows: StreamItem[]; - renderRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; + segments: StreamRenderSegments; + boundary: StreamHistoryBoundary; + renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; viewportRef: RefObject; routeBottomAnchorRequest: BottomAnchorRouteRequest | null; @@ -57,7 +81,6 @@ export type StreamRenderInput = { listStyle: StyleProp; baseListContentContainerStyle: StyleProp; forwardListContentContainerStyle: StyleProp; - edgeSlotProps: StreamEdgeSlotProps; }; export type ResolveStreamRenderStrategyInput = { diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index e0d77d0a7..301d9e010 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { ActivityIndicator, Alert, Image, Pressable, Text, View } from "react-native"; +import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import * as QRCode from "qrcode"; import { useFocusEffect } from "@react-navigation/native"; @@ -336,14 +336,17 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) { Built-in daemon - void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)} + diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9ee2c3ded..9d16eb07b 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -4,7 +4,6 @@ import { View, Text, ScrollView, - Pressable, Alert, Platform, } from "react-native"; @@ -221,17 +220,12 @@ const styles = StyleSheet.create((theme) => ({ hostSettingsButton: { width: 28, height: 28, + paddingVertical: 0, + paddingHorizontal: 0, borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", - borderWidth: 1, - borderColor: "transparent", - backgroundColor: "transparent", + gap: 0, marginLeft: theme.spacing[2], }, - hostSettingsButtonActive: { - backgroundColor: theme.colors.surface3, - }, advancedTrigger: { flexDirection: "row", alignItems: "center", @@ -256,20 +250,10 @@ const styles = StyleSheet.create((theme) => ({ }, // Add host button addButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, borderStyle: "dashed", }, addButtonText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, }, // Add/Edit form formCard: { @@ -712,16 +696,19 @@ export default function SettingsScreen() { }) )} - { setAddConnectionTargetServerId(null); setPendingEditReopenServerId(null); setIsAddHostMethodVisible(true); }} > - + Add connection - + + Add connection + ); })} - - + Add connection - + + Add connection + ) : null} @@ -1312,11 +1302,14 @@ function ConnectionRow({ {latencyText} - - - Remove - - +
); } @@ -1403,23 +1396,18 @@ function DaemonCard({
) : null} - [ - styles.hostSettingsButton, - (pressed || hovered) && styles.hostSettingsButtonActive, - ]} + {connectionError ? {connectionError} : null} diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 7f47f824a..f5080f351 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -133,8 +133,6 @@ const lightSemanticColors = { // Legacy aliases (for gradual migration) background: "#ffffff", - card: "#ffffff", - cardForeground: "#09090b", popover: "#ffffff", popoverForeground: "#09090b", primary: "#18181b", @@ -204,8 +202,6 @@ const darkSemanticColors = { // Legacy aliases (for gradual migration) background: "#18181c", - card: "#27272a", - cardForeground: "#fafafa", popover: "#27272a", popoverForeground: "#fafafa", primary: "#fafafa", diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index e7a43f027..00d0949c8 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -110,7 +110,7 @@ function Home() {
- +