diff --git a/design-system/apps/design-lab/src/i18n/componentMetadata.ts b/design-system/apps/design-lab/src/i18n/componentMetadata.ts index 8aaf44405e..880918553a 100644 --- a/design-system/apps/design-lab/src/i18n/componentMetadata.ts +++ b/design-system/apps/design-lab/src/i18n/componentMetadata.ts @@ -15,6 +15,7 @@ const categoryKeys: Readonly> = { }; const descriptionKeys: Readonly> = { + SplitView: "component.SplitView.description", ListModelsToolCard: "component.ListModelsToolCard.description", ControlHubToolCard: "component.ControlHubToolCard.description", ThinkingBlock: "component.ThinkingBlock.description", diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index 45f7795cc5..bed278b497 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -5,6 +5,7 @@ import { designEn, designZhCN, designZhTW } from "./designMessages"; import { flowChatEn, flowChatZhCN, flowChatZhTW } from "./flowChatMessages"; import { subagentMotionEn, subagentMotionZhCN, subagentMotionZhTW } from "./subagentMotionMessages"; import { thinkingIndicatorEn, thinkingIndicatorZhCN, thinkingIndicatorZhTW } from "./thinkingIndicatorMessages"; +import { splitViewEn, splitViewZhCN, splitViewZhTW } from "./splitViewMessages"; import type { DesignLabLocale } from "./core.mjs"; export const enUSMessages = { @@ -14,6 +15,7 @@ export const enUSMessages = { ...brandAssetsEn, ...subagentMotionEn, ...thinkingIndicatorEn, + ...splitViewEn, ...flowChatEn, "component.OpenBitFunSolidMark.description": "The original silver solid mark on a transparent canvas, preserving its material and shading.", "component.OpenBitFunAppIcon.description": "The application icon used by the installed product: silver mark, black rounded-square tile, and assets for different display sizes.", @@ -818,6 +820,7 @@ export const zhCNMessages = { ...brandAssetsZhCN, ...subagentMotionZhCN, ...thinkingIndicatorZhCN, + ...splitViewZhCN, ...flowChatZhCN, "component.OpenBitFunSolidMark.description": "透明底的银色实体标志,保留原稿的材质、光泽与立体明暗。", "component.OpenBitFunAppIcon.description": "实际应用使用的图标:银色实体标志、黑色圆角底板,以及适配不同显示尺寸的资源。", @@ -1620,6 +1623,7 @@ export const zhTWMessages = { ...brandAssetsZhTW, ...subagentMotionZhTW, ...thinkingIndicatorZhTW, + ...splitViewZhTW, ...flowChatZhTW, "patterns.description": "查看元件在常見介面中的組合與互動。", "patterns.title": "組合範例", diff --git a/design-system/apps/design-lab/src/i18n/splitViewMessages.ts b/design-system/apps/design-lab/src/i18n/splitViewMessages.ts new file mode 100644 index 0000000000..73675a6471 --- /dev/null +++ b/design-system/apps/design-lab/src/i18n/splitViewMessages.ts @@ -0,0 +1,41 @@ +export const splitViewEn = { + 'component.SplitView.description': 'A resizable pair of panes. Content swaps without moving the divider or remounting either pane.', + 'splitView.split': 'Show both panes', + 'splitView.hide': 'Hide content', + 'splitView.maximize': 'Maximize content', + 'splitView.resize': 'Resize panes', + 'splitView.swap': 'Swap panes', + 'splitView.primary': 'Conversation', + 'splitView.secondary': 'Content', + 'splitView.draft': 'Write a draft', + 'splitView.edit': 'Edit content', + 'splitView.help': 'Hover over the divider to swap panes. Drag or use arrow keys to resize; F6 moves focus between panes. Your text survives every layout change.', +} as const; + +export const splitViewZhCN = { + 'component.SplitView.description': '可调整大小的双窗格。交换内容时保留分隔线位置,两个窗格持续挂载。', + 'splitView.split': '显示双窗格', + 'splitView.hide': '隐藏内容', + 'splitView.maximize': '内容全屏', + 'splitView.resize': '调整窗格宽度', + 'splitView.swap': '交换左右窗格', + 'splitView.primary': '聊天', + 'splitView.secondary': '内容', + 'splitView.draft': '输入草稿', + 'splitView.edit': '编辑内容', + 'splitView.help': '悬停分隔线可交换窗格,拖动或使用方向键调整宽度,F6 切换窗格焦点。布局切换会保留已输入的文字。', +} satisfies Record; + +export const splitViewZhTW = { + 'component.SplitView.description': '可調整大小的雙窗格。交換內容時保留分隔線位置,兩個窗格持續掛載。', + 'splitView.split': '顯示雙窗格', + 'splitView.hide': '隱藏內容', + 'splitView.maximize': '內容全螢幕', + 'splitView.resize': '調整窗格寬度', + 'splitView.swap': '交換左右窗格', + 'splitView.primary': '聊天', + 'splitView.secondary': '內容', + 'splitView.draft': '輸入草稿', + 'splitView.edit': '編輯內容', + 'splitView.help': '懸停分隔線可交換窗格,拖動或使用方向鍵調整寬度,F6 切換窗格焦點。佈局切換會保留已輸入的文字。', +} satisfies Record; diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index c926d48961..61b8bf0de4 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -130,6 +130,7 @@ import previewImage from "../assets/design-system-hero.webp"; import { IconCompositionPreview } from "../preview/IconCompositionPreview"; import { RollingTextPreview } from "../preview/RollingTextPreview"; import { ThinkingIndicatorPreview } from "../preview/ThinkingIndicatorPreview"; +import { SplitViewPreview } from "../preview/SplitViewPreview"; import { NestedMenuPattern } from "./ReferencePatterns"; import { useI18n, type MessageKey } from "../i18n"; import { @@ -443,6 +444,9 @@ export function ComponentDetailPage({ const inspectorStates = states; const codeSample = useMemo(() => { + if (component.name === "SplitView") { + return `import { SplitView } from "@openbitfun/ui";\n\n}\n secondary={}\n mode={mode}\n secondarySide={contentSide}\n rightSize={rightSize}\n onRightSizeChange={setRightSize}\n dividerLabel="${t('splitView.resize')}"\n dividerActions={}\n/>`; + } if (component.name === "ThinkingIndicator") { return `import { ThinkingIndicator } from "@openbitfun/ui";\n\n\n`; } @@ -903,6 +907,7 @@ export function ComponentDetailPage({ if (component.name === "ThinkingIndicator") { return ; } + if (component.name === "SplitView") return ; if (component.name === "MobileButton") { return {t("components.preview.actionCardTitle")}; diff --git a/design-system/apps/design-lab/src/preview/SplitViewPreview.css b/design-system/apps/design-lab/src/preview/SplitViewPreview.css new file mode 100644 index 0000000000..b9b1ffde84 --- /dev/null +++ b/design-system/apps/design-lab/src/preview/SplitViewPreview.css @@ -0,0 +1,30 @@ +.split-view-preview { display: grid; inline-size: 100%; min-inline-size: 0; gap: var(--openbitfun-space-4); } +.split-view-preview__controls { display: flex; flex-wrap: wrap; gap: var(--openbitfun-space-2); } +.split-view-preview__viewport { + block-size: 20rem; + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-md); + overflow: hidden; +} +.split-view-preview__pane { + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-4); + padding: var(--openbitfun-space-4); + min-inline-size: 0; + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-body-sm-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); + letter-spacing: var(--openbitfun-type-body-sm-letter-spacing); +} +.split-view-preview__note { + margin: 0; + color: var(--openbitfun-color-content-secondary); + font-family: var(--openbitfun-type-support-font-family); + font-size: var(--openbitfun-type-support-font-size); + font-weight: var(--openbitfun-type-support-font-weight); + line-height: var(--openbitfun-type-support-line-height); + letter-spacing: var(--openbitfun-type-support-letter-spacing); +} diff --git a/design-system/apps/design-lab/src/preview/SplitViewPreview.tsx b/design-system/apps/design-lab/src/preview/SplitViewPreview.tsx new file mode 100644 index 0000000000..b5d6b210bc --- /dev/null +++ b/design-system/apps/design-lab/src/preview/SplitViewPreview.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { ArrowLeftRight } from 'lucide-react'; +import { Button, Icon, IconButton, Input, SplitView, Tooltip, type SplitViewMode } from '@openbitfun/ui'; +import { useI18n } from '../i18n'; +import './SplitViewPreview.css'; + +export function SplitViewPreview({ state }: { state: string }) { + const { t } = useI18n(); + const [mode, setMode] = useState('split'); + const [side, setSide] = useState<'left' | 'right'>('right'); + const [rightSize, setRightSize] = useState(280); + useEffect(() => { setMode(state === 'primary' || state === 'secondary' ? state : 'split'); }, [state]); + + return
+
+ + + +
+
+ } onClick={() => setSide(value => value === 'right' ? 'left' : 'right')} />} + primary={
{t('splitView.primary')}
} + secondary={
{t('splitView.secondary')}
} + /> +
+

{t('splitView.help')}

+
; +} diff --git a/design-system/apps/design-lab/src/preview/componentPresentation.ts b/design-system/apps/design-lab/src/preview/componentPresentation.ts index 4172487780..87fa3537a5 100644 --- a/design-system/apps/design-lab/src/preview/componentPresentation.ts +++ b/design-system/apps/design-lab/src/preview/componentPresentation.ts @@ -17,7 +17,7 @@ const presentations: Record = { Dialog: "interactive", ConfirmDialog: "interactive", Sheet: "interactive", Combobox: "interactive", MultiSelect: "interactive", Select: "interactive", Tooltip: "interactive", Disclosure: "composition", Composer: "composition", Listbox: "composition", Menu: "composition", - NavigationPanel: "composition", PageHeader: "composition", ScrollArea: "composition", + NavigationPanel: "composition", PageHeader: "composition", ScrollArea: "composition", SplitView: "composition", SegmentedControl: "composition", TabGroup: "composition", Icon: "icons", RollingText: "motion", ShimmerText: "motion", VoiceCallPanel: "motion", ThinkingIndicator: "motion", diff --git a/design-system/packages/theme-openbitfun/src/dark.tokens.json b/design-system/packages/theme-openbitfun/src/dark.tokens.json index 2c5323928d..80d243d143 100644 --- a/design-system/packages/theme-openbitfun/src/dark.tokens.json +++ b/design-system/packages/theme-openbitfun/src/dark.tokens.json @@ -150,8 +150,8 @@ } }, "link": { - "default": { "$type": "color", "$value": "{ref.color.blue.400}" }, - "hover": { "$type": "color", "$value": "{ref.color.blue.300}" } + "default": { "$type": "color", "$value": "{ref.color.cyan.500}" }, + "hover": { "$type": "color", "$value": "{ref.color.cyan.400}" } }, "border": { "subtle": { "$type": "color", "$value": "rgba(255, 255, 255, 0.12)" }, diff --git a/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json b/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json index cf866f9a75..c8fc924507 100644 --- a/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json +++ b/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json @@ -42,6 +42,10 @@ "secondary": { "$type": "color", "$value": "{ref.color.gray.50}" }, "muted": { "$type": "color", "$value": "{ref.color.gray.150}" } }, + "link": { + "default": { "$type": "color", "$value": "{ref.color.cyan.300}" }, + "hover": { "$type": "color", "$value": "{ref.color.cyan.200}" } + }, "border": { "subtle": { "$type": "color", "$value": "{ref.color.gray.400}" }, "default": { "$type": "color", "$value": "{ref.color.gray.250}" }, diff --git a/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json b/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json index 176263afd6..de48c327e6 100644 --- a/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json +++ b/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json @@ -42,6 +42,10 @@ "secondary": { "$type": "color", "$value": "{ref.color.gray.800}" }, "muted": { "$type": "color", "$value": "{ref.color.gray.700}" } }, + "link": { + "default": { "$type": "color", "$value": "{ref.color.cyan.700}" }, + "hover": { "$type": "color", "$value": "{ref.color.cyan.800}" } + }, "border": { "subtle": { "$type": "color", "$value": "{ref.color.gray.500}" }, "default": { "$type": "color", "$value": "{ref.color.gray.650}" }, diff --git a/design-system/packages/theme-openbitfun/src/light.tokens.json b/design-system/packages/theme-openbitfun/src/light.tokens.json index 76d743d75c..7a60875036 100644 --- a/design-system/packages/theme-openbitfun/src/light.tokens.json +++ b/design-system/packages/theme-openbitfun/src/light.tokens.json @@ -150,8 +150,8 @@ } }, "link": { - "default": { "$type": "color", "$value": "{ref.color.blue.600}" }, - "hover": { "$type": "color", "$value": "{ref.color.blue.700}" } + "default": { "$type": "color", "$value": "{ref.color.cyan.500}" }, + "hover": { "$type": "color", "$value": "{ref.color.cyan.600}" } }, "border": { "subtle": { "$type": "color", "$value": "rgba(16, 26, 39, 0.08)" }, diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.meta.ts b/design-system/packages/ui/src/components/SplitView/SplitView.meta.ts new file mode 100644 index 0000000000..efdfb88699 --- /dev/null +++ b/design-system/packages/ui/src/components/SplitView/SplitView.meta.ts @@ -0,0 +1,20 @@ +import type { ComponentMeta } from '../../registry.types'; + +export const splitViewMeta = { + category: 'primitive', + description: 'A controlled two-pane layout with stable content containers, reversible placement, and accessible resizing.', + maturity: 'stable', + name: 'SplitView', + props: [ + { name: 'primary', type: 'ReactNode' }, + { name: 'secondary', type: 'ReactNode' }, + { name: 'mode', type: 'split | primary | secondary', defaultValue: 'split' }, + { name: 'secondarySide', type: 'left | right', defaultValue: 'right' }, + { name: 'rightSize', type: 'number' }, + { name: 'onRightSizeChange', type: '(size: number) => void' }, + { name: 'dividerActions', type: 'ReactNode' }, + { name: 'dividerLabel', type: 'string' }, + ], + states: ['split', 'primary', 'secondary'], + tokens: ['color.border.subtle', 'color.surface.raised', 'color.accent.default', 'color.focus.ring', 'space.1', 'space.2', 'space.3', 'radius.lg'], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.module.css b/design-system/packages/ui/src/components/SplitView/SplitView.module.css new file mode 100644 index 0000000000..ed54d698af --- /dev/null +++ b/design-system/packages/ui/src/components/SplitView/SplitView.module.css @@ -0,0 +1,94 @@ +@layer openbitfun.components { + .root { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--openbitfun-border-width-default) minmax(0, var(--_split-view-right-size)); + grid-template-areas: "primary divider secondary"; + min-inline-size: 0; + min-block-size: 0; + inline-size: 100%; + block-size: 100%; + isolation: isolate; + } + + .root[data-secondary-side="left"] { + grid-template-areas: "secondary divider primary"; + } + + .root[data-mode="primary"] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "primary"; } + .root[data-mode="secondary"] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "secondary"; } + + .pane { + display: flex; + flex-direction: column; + min-inline-size: 0; + min-block-size: 0; + overflow: hidden; + } + + .primary { grid-area: primary; } + .secondary { grid-area: secondary; } + .pane[hidden], .divider[hidden] { display: none; } + + .divider { + grid-area: divider; + position: relative; + background: var(--openbitfun-color-border-subtle); + z-index: var(--openbitfun-layer-sticky); + } + + .resizeHandle { + position: absolute; + inset-block: 0; + inset-inline: calc(-1 * var(--openbitfun-space-2)); + cursor: col-resize; + touch-action: none; + outline: none; + } + + .resizeHandle::after { + content: ""; + position: absolute; + inset-block: 0; + inset-inline-start: 50%; + inline-size: var(--openbitfun-border-width-default); + transform: translateX(-50%); + background: var(--openbitfun-color-accent-default); + opacity: 0; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + .resizeHandle:hover::after, + .resizeHandle:focus-visible::after, + .root[data-resizing] .resizeHandle::after { opacity: 1; } + + .resizeHandle:focus-visible::after { background: var(--openbitfun-color-focus-ring); } + + .actions { + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + gap: var(--openbitfun-space-1); + padding: var(--openbitfun-space-1); + background: var(--openbitfun-color-surface-raised); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-lg); + opacity: 0; + pointer-events: none; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + .divider:hover .actions, .divider:has(:focus-visible) .actions { opacity: 1; pointer-events: auto; } + .root[data-resizing] .pane, .root[data-resizing] .actions { pointer-events: none; } + + @media (prefers-reduced-motion: reduce) { + .resizeHandle::after, .actions { transition: none; } + } + + @media (hover: none) { + .actions { opacity: 1; pointer-events: auto; } + .resizeHandle { inset-inline: calc(-1 * var(--openbitfun-space-3)); } + } +} diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.tsx b/design-system/packages/ui/src/components/SplitView/SplitView.tsx new file mode 100644 index 0000000000..92b1f23c91 --- /dev/null +++ b/design-system/packages/ui/src/components/SplitView/SplitView.tsx @@ -0,0 +1,204 @@ +import { + useCallback, useEffect, useId, useLayoutEffect, useRef, useState, + type CSSProperties, type HTMLAttributes, type PointerEvent, type ReactNode, +} from 'react'; +import { classNames } from '../../internal/classNames'; +import styles from './SplitView.module.css'; + +export type SplitViewMode = 'split' | 'primary' | 'secondary'; + +export interface SplitViewProps extends Omit, 'children'> { + primary: ReactNode; + secondary: ReactNode; + mode?: SplitViewMode; + secondarySide?: 'left' | 'right'; + /** Physical right slot width. Swapping content never moves the divider. */ + rightSize: number; + minLeftSize?: number; + minRightSize?: number; + maxRightSize?: number; + defaultRightSize?: number; + onRightSizeChange: (size: number) => void; + onResizeStateChange?: (resizing: boolean) => void; + dividerLabel: string; + dividerActions?: ReactNode; + primaryPaneProps?: HTMLAttributes; + secondaryPaneProps?: HTMLAttributes; +} + +/** Shrink to the available space without mutating the owner's preferred size. */ +function sizeBounds(width: number, dividerWidth: number, minLeft: number, minRight: number, maxRight: number) { + const available = Math.max(0, width - dividerWidth); + const max = Math.max(0, Math.min(maxRight, available - Math.min(minLeft, available / 2))); + return { min: Math.min(minRight, max), max }; +} + +export function SplitView({ + primary, secondary, mode = 'split', secondarySide = 'right', rightSize, + minLeftSize = 0, minRightSize = 0, maxRightSize = Number.MAX_SAFE_INTEGER, + defaultRightSize = rightSize, onRightSizeChange, onResizeStateChange, + dividerLabel, dividerActions, primaryPaneProps, secondaryPaneProps, + className, style, onKeyDown, ...rest +}: SplitViewProps) { + const rootRef = useRef(null); + const dividerRef = useRef(null); + const primaryRef = useRef(null); + const secondaryRef = useRef(null); + const generatedId = useId(); + const primaryId = primaryPaneProps?.id ?? `${generatedId}-primary`; + const secondaryId = secondaryPaneProps?.id ?? `${generatedId}-secondary`; + const [width, setWidth] = useState(null); + const [dividerWidth, setDividerWidth] = useState(0); + const [resizing, setResizing] = useState(false); + const frameRef = useRef(null); + const dragRef = useRef<{ + pointerId: number; target: HTMLDivElement; x: number; size: number; latest: number; + cursor: string; userSelect: string; + } | null>(null); + const callbacks = useRef({ onRightSizeChange, onResizeStateChange }); + callbacks.current = { onRightSizeChange, onResizeStateChange }; + const bounds = width === null ? { min: minRightSize, max: maxRightSize } + : sizeBounds(width, dividerWidth, minLeftSize, minRightSize, maxRightSize); + const resolvedSize = Math.min(bounds.max, Math.max(bounds.min, rightSize)); + const resolvedSizeRef = useRef(resolvedSize); + resolvedSizeRef.current = resolvedSize; + + useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return; + const measure = () => { + const next = root.getBoundingClientRect().width; + // A hidden host has no useful dimensions; wait until its next layout. + if (next > 0) setWidth(previous => previous === next ? previous : next); + const divider = dividerRef.current?.getBoundingClientRect().width ?? 0; + if (divider > 0) setDividerWidth(divider); + }; + measure(); + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure); + observer?.observe(root); + window.addEventListener('resize', measure); + return () => { observer?.disconnect(); window.removeEventListener('resize', measure); }; + }, [mode]); + + const finishResize = useCallback((commit: boolean) => { + const drag = dragRef.current; + if (!drag) return; + dragRef.current = null; + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + document.body.style.cursor = drag.cursor; + document.body.style.userSelect = drag.userSelect; + if (drag.target.hasPointerCapture?.(drag.pointerId)) drag.target.releasePointerCapture(drag.pointerId); + rootRef.current?.style.setProperty('--_split-view-right-size', `${resolvedSizeRef.current}px`); + if (commit) callbacks.current.onRightSizeChange(drag.latest); + setResizing(false); + callbacks.current.onResizeStateChange?.(false); + }, []); + + useLayoutEffect(() => { finishResize(false); }, [mode, secondarySide, finishResize]); + useEffect(() => () => { finishResize(false); }, [finishResize]); + + useLayoutEffect(() => { + const hiddenPane = mode === 'primary' ? secondaryRef.current : mode === 'secondary' ? primaryRef.current : null; + if (hiddenPane?.contains(document.activeElement) || (mode !== 'split' && dividerRef.current?.contains(document.activeElement))) { + (mode === 'primary' ? primaryRef : secondaryRef).current?.focus({ preventScroll: true }); + } + }, [mode]); + + const updateDrag = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const measuredWidth = rootRef.current?.getBoundingClientRect().width ?? 0; + const currentBounds = measuredWidth > 0 + ? sizeBounds(measuredWidth, dividerWidth, minLeftSize, minRightSize, maxRightSize) : bounds; + const delta = drag.x - event.clientX; + drag.latest = Math.min(currentBounds.max, Math.max(currentBounds.min, drag.size + delta)); + if (frameRef.current !== null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + if (dragRef.current) rootRef.current?.style.setProperty('--_split-view-right-size', `${dragRef.current.latest}px`); + }); + }; + + return ( +
{ + onKeyDown?.(event); + if (event.defaultPrevented || event.key !== 'F6' || event.altKey || event.ctrlKey || event.metaKey) return; + const panes = mode === 'primary' ? [primaryRef.current] : mode === 'secondary' ? [secondaryRef.current] + : secondarySide === 'left' ? [secondaryRef.current, primaryRef.current] : [primaryRef.current, secondaryRef.current]; + const current = panes.findIndex(pane => pane?.contains(document.activeElement)); + const next = current < 0 ? (event.shiftKey ? panes.length - 1 : 0) + : (current + (event.shiftKey ? -1 : 1) + panes.length) % panes.length; + event.preventDefault(); + event.stopPropagation(); + panes[next]?.focus({ preventScroll: true }); + }} + > + + + ); +} diff --git a/design-system/packages/ui/src/components/SplitView/index.ts b/design-system/packages/ui/src/components/SplitView/index.ts new file mode 100644 index 0000000000..e3f53d44b4 --- /dev/null +++ b/design-system/packages/ui/src/components/SplitView/index.ts @@ -0,0 +1,2 @@ +export { SplitView } from './SplitView'; +export type { SplitViewProps, SplitViewMode } from './SplitView'; diff --git a/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx b/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx index 10225ee30c..0901512381 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx +++ b/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx @@ -25,7 +25,7 @@ export function FileRevisionSummary({ file, itemCount, expanded, onToggle, conte itemCount: number; expanded: boolean; onToggle?: () => void; - contentId: string; + contentId?: string; description: string; component: string; testId: string; diff --git a/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx b/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx index 091e755250..f8fabdd795 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx +++ b/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx @@ -61,6 +61,8 @@ export interface FlowGroupProps extends Omit, 'ch streaming?: boolean; /** Opt into an internal scroll area for embedded hosts; transcripts use natural height. */ bounded?: boolean; + /** The host renders members as siblings in its single virtual timeline. */ + externalContent?: boolean; receiveFeedback?: FlowGroupReceiveFeedback; contentRef?: Ref; onContentScroll?: HTMLAttributes['onScroll']; @@ -78,12 +80,13 @@ export interface FlowGroupProps extends Omit, 'ch export const FlowGroup = forwardRef(function FlowGroup({ children, expanded, onExpandedChange, leading, summary, summaryItems, summaryDescription, itemCount = 0, placement = 'standalone', streaming = false, bounded = false, - receiveFeedback, contentRef, onContentScroll, contentProps, browser, fileRevision, className = '', + receiveFeedback, contentRef, onContentScroll, contentProps, browser, fileRevision, externalContent = false, className = '', 'data-openbitfun-component': component = 'flow-group', 'data-testid': testId = 'chat-flow-group', 'data-group-kind': kind, ...props }, ref) { - const contentId = useId(); + const internalContentId = useId(); + const contentId = externalContent ? undefined : internalContentId; const Content = bounded ? ScrollArea : 'div'; const rootRef = useRef(null); const bindRoot = useCallback((node: HTMLDivElement | null) => { @@ -155,7 +158,7 @@ export const FlowGroup = forwardRef(function Flo
} } - @@ -167,7 +170,8 @@ export const FlowGroup = forwardRef(function Flo {browser?.empty &&
{browser.emptyLabel}
} {children}
- + } + {externalContent && expanded && browser?.empty &&
{browser.emptyLabel}
} ; }); diff --git a/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx b/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx index 5bd6ced075..3ad3ddd51a 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx +++ b/design-system/packages/ui/src/flow-chat/conversation/ThinkingBlock.tsx @@ -30,6 +30,8 @@ export interface ThinkingBlockProps extends Omit, capsuleLabel?: string; /** Dock a settled disclosure beside a continuation marked data-thinking-continuation. */ collapseIntoNext?: boolean; + /** Recycled rows settle initial placement and measure resting side controls on interaction. */ + virtualized?: boolean; /** Resolve a continuation in a peer container when it is not a direct sibling. */ resolveContinuation?: ThinkingContinuationResolver; /** Coordinate the first live successor with the outgoing row, without remounting it. */ @@ -56,7 +58,7 @@ export const ThinkingBlock = forwardRef(func children, expanded, streaming = false, visuallyStreaming = streaming, streamingExpanded = false, onStreamingExpandedChange, retainStreamingViewport = false, reasoningKind = 'reasoning', status, context = 'default', label, capsuleLabel = label, onToggle, onOpenDetails, collapseIntoNext = false, resolveContinuation, scrollOwner = 'self', - coordinateContinuation = false, handoffIdentity, onContinuationReadyChange, + coordinateContinuation = false, handoffIdentity, onContinuationReadyChange, virtualized = false, contentRef, expandContainerRef, mountContent = true, contentProps, scrollState = { hasScroll: false, atTop: true, atBottom: true }, className = '', style, ...props }, ref) { @@ -81,9 +83,9 @@ export const ThinkingBlock = forwardRef(func if (liveViewport !== undefined || (expanded && !retainStreamingViewport)) lastLiveViewport.current = liveViewport; }, [expanded, liveViewport, retainStreamingViewport]); const wantsDock = collapseIntoNext && !expanded && !visuallyStreaming; - const { hasContinuation, tooltipBlocked, tooltipActive, canShowTooltip } = useThinkingAnnotation(rootRef, toggleRef, wantsDock, resolveContinuation); + const { hasContinuation, tooltipBlocked, tooltipActive, canShowTooltip } = useThinkingAnnotation(rootRef, toggleRef, wantsDock, resolveContinuation, virtualized); const docked = wantsDock && hasContinuation; - const handoff = useThinkingHandoff(rootRef, docked, label, wantsDock); + const handoff = useThinkingHandoff(rootRef, docked, label, wantsDock, virtualized); const exchange = useThinkingSuccessor(rootRef, { identity: handoffIdentity, enabled: coordinateContinuation, expanded, visuallyStreaming, resolveContinuation, onReadyChange: onContinuationReadyChange, diff --git a/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts b/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts index f740bfae05..6136122ab8 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts +++ b/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts @@ -63,6 +63,7 @@ export function useThinkingAnnotation( toggleRef: RefObject, docked: boolean, resolveContinuation?: ThinkingContinuationResolver, + deferInactiveGeometry = false, ) { const blockedRef = useRef(false); const [hasContinuation, setHasContinuation] = useState(false); @@ -107,6 +108,10 @@ export function useThinkingAnnotation( setHasContinuation(Boolean(successor && !successor.hidden && successor.querySelector(':scope > :not(:empty)'))); if (!successor) return; + // A virtualized history row has no visible side control at rest. Its + // exact glyph position is needed on hover/focus, before revealing it. + // Avoid one forced layout per dormant thought during a group mount. + if (deferInactiveGeometry && !hovered && !focused) return; const header = successor.querySelector(successor.hasAttribute('data-flow-group') ? ':scope > .explore-region__toolbar .explore-region__header' : CARD_HEADER); const first = header ? null : textEdge(successor); @@ -314,7 +319,7 @@ export function useThinkingAnnotation( // The last-center fallback retains the outgoing anchor through its fade. // A block does not consume it; the next valid successor replaces it. }; - }, [docked, resolveContinuation, rootRef, toggleRef]); + }, [docked, resolveContinuation, rootRef, toggleRef, deferInactiveGeometry]); return { hasContinuation: docked && hasContinuation, tooltipBlocked: tooltip.blocked, tooltipActive: tooltip.active, canShowTooltip }; } diff --git a/design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts b/design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts index abbb3b7004..051a225290 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts +++ b/design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts @@ -12,7 +12,7 @@ interface Handoff { /** Move the retained control only after it has faded out. CSS owns the fold and * its reversal; this hook waits for those actual transitions, never a timer. */ -export function useThinkingHandoff(rootRef: RefObject, side: boolean, label: string, wantsDock: boolean) { +export function useThinkingHandoff(rootRef: RefObject, side: boolean, label: string, wantsDock: boolean, virtualized = false) { const previousLabel = useRef(label); const [motionReady, setMotionReady] = useState(false); const [handoff, setHandoff] = useState(() => ({ @@ -39,7 +39,7 @@ export function useThinkingHandoff(rootRef: RefObject, side: boo const position = side ? 'side' : 'block'; // History/remounts have no running transitions. Hidden groups and reduced // motion also settle directly rather than queueing an invisible handoff. - if (!header?.getAnimations || !view || root?.closest('[hidden], [inert]') + if ((virtualized && !motionReady) || !header?.getAnimations || !view || root?.closest('[hidden], [inert]') || view.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { setHandoff({ target: side, phase: position, origin: position, label: previousLabel.current }); return; @@ -51,7 +51,7 @@ export function useThinkingHandoff(rootRef: RefObject, side: boo label: handoff.phase === 'leaving' ? handoff.label : previousLabel.current, top: view.getComputedStyle(header).insetBlockStart, }); - }, [handoff, rootRef, side]); + }, [handoff, rootRef, side, motionReady, virtualized]); useIsomorphicLayoutEffect(() => { if (handoff.phase !== 'leaving' || handoff.target !== side) return; diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/SearchResultsToolCards.module.css b/design-system/packages/ui/src/flow-chat/tool-cards/SearchResultsToolCards.module.css index be0a972847..0f16ce7b68 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/SearchResultsToolCards.module.css +++ b/design-system/packages/ui/src/flow-chat/tool-cards/SearchResultsToolCards.module.css @@ -40,6 +40,11 @@ overflow-wrap: anywhere; } + [data-openbitfun-tool-card="glob-search"] .detail, + [data-openbitfun-tool-card="glob-search"] .detailLabel { + color: var(--openbitfun-color-content-primary); + } + .results { display: flex; min-inline-size: 0; @@ -125,6 +130,12 @@ overflow-wrap: anywhere; } + [data-openbitfun-tool-card="glob-search"] .resultTitle, + [data-openbitfun-tool-card="glob-search"] .resultButton, + [data-openbitfun-tool-card="glob-search"] .resultDescription { + color: var(--openbitfun-color-content-secondary); + } + .resultButton { appearance: none; padding: 0; diff --git a/design-system/packages/ui/src/index.ts b/design-system/packages/ui/src/index.ts index 03007ef67f..19078bcdef 100644 --- a/design-system/packages/ui/src/index.ts +++ b/design-system/packages/ui/src/index.ts @@ -304,3 +304,5 @@ export { } from "./primitives/ThemeRoot"; export { LayersPlusIcon } from "./components/Icon"; +export { SplitView } from './components/SplitView'; +export type { SplitViewProps, SplitViewMode } from './components/SplitView'; diff --git a/design-system/packages/ui/src/registry.ts b/design-system/packages/ui/src/registry.ts index d930a1c09c..46f824ce1b 100644 --- a/design-system/packages/ui/src/registry.ts +++ b/design-system/packages/ui/src/registry.ts @@ -31,6 +31,7 @@ import { radioMeta } from "./components/Radio/Radio.meta"; import { rollingTextMeta } from "./components/RollingText/RollingText.meta"; import { shimmerTextMeta } from "./components/ShimmerText/ShimmerText.meta"; import { scrollAreaMeta } from "./components/ScrollArea/ScrollArea.meta"; +import { splitViewMeta } from "./components/SplitView/SplitView.meta"; import { searchFieldMeta } from "./components/SearchField/SearchField.meta"; import { segmentedControlMeta } from "./components/SegmentedControl/SegmentedControl.meta"; import { selectMeta } from "./components/Select/Select.meta"; @@ -202,6 +203,7 @@ export const componentRegistry = [ shimmerTextMeta, runCodeToolCardMeta, scrollAreaMeta, + splitViewMeta, searchFieldMeta, segmentedControlMeta, selectMeta, diff --git a/design-system/packages/ui/tests/registry.test.mjs b/design-system/packages/ui/tests/registry.test.mjs index 54ece71bca..5043924867 100644 --- a/design-system/packages/ui/tests/registry.test.mjs +++ b/design-system/packages/ui/tests/registry.test.mjs @@ -101,6 +101,7 @@ test("registry exposes only the formal stable components", () => { "ShimmerText", "RunCodeToolCard", "ScrollArea", + "SplitView", "SearchField", "SegmentedControl", "Select", diff --git a/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx b/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx index 4e29e5df13..96d6a276b1 100644 --- a/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx +++ b/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx @@ -32,6 +32,7 @@ export interface ExecProcessPresentationProps { renderStatus: (props: ExecStatusPresentation) => ReactNode; clock?: PresentationClock; initialExpanded?: boolean; + expanded?: boolean; /** Public card pseudo-state used only by static component specimens. */ previewState?: 'hover'; } @@ -83,7 +84,7 @@ export function ExecProcessPresentation(props: ExecProcessPresentationProps) { function ExecCommandPresentation({ toolItem, model, attention = 'prominent', t, rootRef, onExpandedChange, primaryCopied = false, onCopyPrimary, renderOutput, renderOutputAction, renderStatus, - initialExpanded = false, previewState, + initialExpanded = false, expanded, previewState, }: ExecProcessPresentationProps) { const status = toolItem.status || 'pending'; const isParamsStreaming = Boolean(toolItem.isParamsStreaming); @@ -101,7 +102,8 @@ function ExecCommandPresentation({ ? 'toolCards.terminal.rejected' : 'toolCards.terminal.cancelled'; const toolId = toolItem.id ?? toolItem.toolCall?.id; - const [isExpanded, setIsExpandedState] = useState(initialExpanded); + const [localExpanded, setIsExpandedState] = useState(initialExpanded); + const isExpanded = expanded ?? localExpanded; const outputRendererRef = useRef(null); const toggleExpanded = useCallback(() => { const nextExpanded = !isExpanded; diff --git a/scripts/audit-appearance-contracts.mjs b/scripts/audit-appearance-contracts.mjs index 3d222e2d7e..b0a57f44c9 100644 --- a/scripts/audit-appearance-contracts.mjs +++ b/scripts/audit-appearance-contracts.mjs @@ -9,6 +9,7 @@ const sceneRoot = path.join(sourceRoot, 'app', 'scenes'); const registryFile = path.join(sourceRoot, 'infrastructure', 'appearance', 'registry', 'defaultAppearanceRegistry.ts'); const retiredOwnershipFile = path.join(sourceRoot, 'infrastructure', 'appearance', 'registry', 'appearanceSourceOwnership.ts'); const externalAppearanceContractFiles = [ + path.join(repoRoot, 'design-system', 'packages', 'ui', 'src', 'components', 'SplitView', 'SplitView.tsx'), path.join(repoRoot, 'design-system', 'packages', 'ui', 'src', 'components', 'ConfirmDialog', 'ConfirmDialog.tsx'), ...Object.values(flowChatAppearanceSources).map(file => path.join(repoRoot, file)), path.join(repoRoot, 'packages/flow-chat-presentation/src/terminal/TerminalOutputRenderer.tsx'), diff --git a/src/apps/data-migrator/ui/generated/design-system.css b/src/apps/data-migrator/ui/generated/design-system.css index 23b7f1dbfa..b74316bd01 100644 --- a/src/apps/data-migrator/ui/generated/design-system.css +++ b/src/apps/data-migrator/ui/generated/design-system.css @@ -861,8 +861,8 @@ --openbitfun-color-identity-harness-ultimate: #ff8c00; --openbitfun-color-key-hint-background: rgba(0, 0, 0, 0.08); --openbitfun-color-key-hint-content: rgba(0, 0, 0, 0.60); - --openbitfun-color-link-default: #2563eb; - --openbitfun-color-link-hover: #005fcc; + --openbitfun-color-link-default: #059cb0; + --openbitfun-color-link-hover: #07879a; --openbitfun-color-number-badge-background: rgba(0, 0, 0, 0.08); --openbitfun-color-overlay-scrim: rgba(16, 26, 39, 0.20); --openbitfun-color-scrollbar-thumb: rgba(0, 0, 0, 0.20); @@ -1037,8 +1037,8 @@ --openbitfun-color-identity-harness-ultimate: #ff8c00; --openbitfun-color-key-hint-background: rgba(255, 255, 255, 0.1); --openbitfun-color-key-hint-content: #858585; - --openbitfun-color-link-default: #60a5fa; - --openbitfun-color-link-hover: #93c5fd; + --openbitfun-color-link-default: #059cb0; + --openbitfun-color-link-hover: #36c6d7; --openbitfun-color-number-badge-background: rgba(255, 255, 255, 0.1); --openbitfun-color-overlay-scrim: rgba(0, 0, 0, 0.56); --openbitfun-color-scrollbar-thumb: rgba(255, 255, 255, 0.20); @@ -1149,6 +1149,8 @@ --openbitfun-color-identity-global-search-open-terminal: #000000; --openbitfun-color-key-hint-background: #cccccc; --openbitfun-color-key-hint-content: #343434; + --openbitfun-color-link-default: #0a6d7d; + --openbitfun-color-link-hover: #0d5764; --openbitfun-color-number-badge-background: #e6e6e6; --openbitfun-color-selection-surface: #cccccc; --openbitfun-color-status-danger-content: #8e1413; @@ -1223,6 +1225,8 @@ --openbitfun-color-identity-global-search-open-terminal: #ffffff; --openbitfun-color-key-hint-background: #343434; --openbitfun-color-key-hint-content: #d0d0d0; + --openbitfun-color-link-default: #7cdee9; + --openbitfun-color-link-hover: #b4eff5; --openbitfun-color-number-badge-background: #343434; --openbitfun-color-selection-surface: #505050; --openbitfun-color-status-danger-content: #f7a7a5; diff --git a/src/apps/desktop/src/generated/bootstrap_theme.css b/src/apps/desktop/src/generated/bootstrap_theme.css index 35de3d368c..54249d02a7 100644 --- a/src/apps/desktop/src/generated/bootstrap_theme.css +++ b/src/apps/desktop/src/generated/bootstrap_theme.css @@ -108,8 +108,8 @@ --openbitfun-color-identity-harness-ultimate: #ff8c00; --openbitfun-color-key-hint-background: rgba(255, 255, 255, 0.1); --openbitfun-color-key-hint-content: #858585; - --openbitfun-color-link-default: #60a5fa; - --openbitfun-color-link-hover: #93c5fd; + --openbitfun-color-link-default: #059cb0; + --openbitfun-color-link-hover: #36c6d7; --openbitfun-color-number-badge-background: rgba(255, 255, 255, 0.1); --openbitfun-color-overlay-scrim: rgba(0, 0, 0, 0.56); --openbitfun-color-scrollbar-thumb: rgba(255, 255, 255, 0.20); diff --git a/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html b/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html index 1fbb2bd0ec..2a73e0d542 100644 --- a/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html +++ b/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html @@ -26,7 +26,7 @@ --openbitfun-element-bg: rgba(255, 255, 255, 0.1); --openbitfun-element-hover: rgba(255, 255, 255, 0.12); --openbitfun-focus-ring: #60a5fa; - --openbitfun-link: #60a5fa; + --openbitfun-link: #059cb0; --openbitfun-field-bg: #1c1c1f; --openbitfun-field-border: rgba(255, 255, 255, 0.18); --openbitfun-field-border-focus: #858585; @@ -62,7 +62,7 @@ --openbitfun-element-bg: rgba(0, 0, 0, 0.05); --openbitfun-element-hover: rgba(0, 0, 0, 0.08); --openbitfun-focus-ring: #6a6a6a; - --openbitfun-link: #2563eb; + --openbitfun-link: #059cb0; --openbitfun-field-bg: #ffffff; --openbitfun-field-border: rgba(0, 0, 0, 0.08); --openbitfun-field-border-focus: #858585; diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index c6d5d3e035..a8199b584e 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -35,8 +35,10 @@ export interface ContentCanvasProps { createTabEventName?: string; /** Reveal this host after an explicit content-open request. */ onReveal?: () => void; - /** Close the containing panel, when this host is collapsible. */ + /** Hide the containing panel, when this host is collapsible. */ onCollapsePanel?: () => void; + /** Host-owned layout actions, replacing the group-level close-all button. */ + toolbarActions?: React.ReactNode; /** Suspend terminal fit/PTY resize while the hosting panel is animating. */ terminalResizeSuspended?: boolean; /** Whether this host exposes Mission Control. */ @@ -54,6 +56,7 @@ export const ContentCanvas: React.FC = ({ createTabEventName, onReveal, onCollapsePanel, + toolbarActions, terminalResizeSuspended = false, missionControlEnabled = true, emptyState, @@ -186,7 +189,7 @@ export const ContentCanvas: React.FC = ({ // Show empty state when there are no visible tabs and no terminal keep-alive tabs. if (!hasRenderableTabs) { return ( - + {emptyState} ); @@ -205,6 +208,7 @@ export const ContentCanvas: React.FC = ({ onTabCloseWithDirtyCheck={handleCloseWithDirtyCheck} onTabCloseAllWithDirtyCheck={handleCloseAllWithDirtyCheck} disablePopOut={disablePopOut} + toolbarActions={toolbarActions} terminalResizeSuspended={terminalResizeSuspended} /> diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx index 29a1010baa..b683b33451 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx @@ -18,6 +18,7 @@ export interface EditorAreaProps { onTabCloseWithDirtyCheck?: (tabId: string, groupId: EditorGroupId) => Promise; onTabCloseAllWithDirtyCheck?: (groupId: EditorGroupId) => Promise; disablePopOut?: boolean; + toolbarActions?: React.ReactNode; terminalResizeSuspended?: boolean; } @@ -30,6 +31,7 @@ export const EditorArea: React.FC = ({ onTabCloseWithDirtyCheck, onTabCloseAllWithDirtyCheck, disablePopOut = false, + toolbarActions, terminalResizeSuspended = false, }) => { const containerRef = useRef(null); @@ -126,6 +128,10 @@ export const EditorArea: React.FC = ({ [setTabFileDeletedFromDisk] ); + // Keep host layout controls in the upper-right corner of every split layout. + const toolbarGroupId = layout.splitMode === 'horizontal' || layout.splitMode === 'grid' + ? 'secondary' : 'primary'; + const renderEditorGroup = (groupId: EditorGroupId, group: typeof primaryGroup) => ( = ({ onCloseAllTabs={handleCloseAllTabs(groupId)} onInteraction={onInteraction} disablePopOut={disablePopOut} + toolbarActions={toolbarActions === undefined || groupId === toolbarGroupId ? toolbarActions : null} terminalResizeSuspended={terminalResizeSuspended} /> ); diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx index 4b800cd609..bf63321944 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx @@ -85,6 +85,7 @@ export interface EditorGroupProps { onCloseAllTabs?: () => Promise | void; onInteraction?: (itemId: string, userInput: string) => Promise; disablePopOut?: boolean; + toolbarActions?: React.ReactNode; terminalResizeSuspended?: boolean; } @@ -114,6 +115,7 @@ export const EditorGroup: React.FC = ({ onCloseAllTabs, onInteraction, disablePopOut = false, + toolbarActions, terminalResizeSuspended = false, }) => { const { t } = useTranslation('components'); @@ -261,6 +263,7 @@ export const EditorGroup: React.FC = ({ onReorderTab={onReorderTab} onOpenMissionControl={onOpenMissionControl} onCloseAllTabs={onCloseAllTabs} + toolbarActions={toolbarActions} onTabPopOut={disablePopOut ? undefined : handleTabPopOut} /> diff --git a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss index 648cea36cc..976a2ba478 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.scss @@ -15,6 +15,7 @@ display: flex; align-items: center; justify-content: flex-end; + gap: var(--openbitfun-layout-toolbar-group-gap-sm); padding: 4px 6px; flex-shrink: 0; } diff --git a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx index e3513841fe..69a7a4e618 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/empty-state/EmptyState.tsx @@ -12,10 +12,11 @@ import { Icon, Tooltip } from '@openbitfun/ui'; export interface EmptyStateProps { onClose?: () => void; + toolbarActions?: React.ReactNode; children?: React.ReactNode; } -export const EmptyState: React.FC = ({ onClose, children }) => { +export const EmptyState: React.FC = ({ onClose, toolbarActions, children }) => { const { t } = useTranslation('components'); const hasEmbeddedContent = children !== undefined && children !== null; @@ -26,16 +27,16 @@ export const EmptyState: React.FC = ({ onClose, children }) => return (
- {onClose && ( + {(toolbarActions !== undefined || onClose) && (
- + {toolbarActions !== undefined ? toolbarActions : - + }
)}
{ container.remove(); }); + it('preserves tabs and width through fullscreen, restore, hide and reopen', async () => { + let toggleFullscreen: () => void; + function LayoutProbe() { + toggleFullscreen = useApp().toggleChatPanel; + return null; + } + const tabId = useAgentCanvasStore.getState().addTab(content('unfinished'), 'active'); + useAgentCanvasStore.getState().setTabDirty(tabId, 'primary', true); + const originalTabs = useAgentCanvasStore.getState().primaryGroup; + expandSessionAuxPane(); + await act(async () => root.render()); + + const flushLayout = async (action: () => void) => act(async () => { + action(); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + }); + await flushLayout(() => toggleFullscreen()); + expect(appManager.getState().layout).toMatchObject({ chatCollapsed: true, rightPanelCollapsed: false, rightPanelWidth: 520 }); + await flushLayout(() => toggleFullscreen()); + expect(appManager.getState().layout).toMatchObject({ chatCollapsed: false, rightPanelCollapsed: false, rightPanelWidth: 520 }); + + await flushLayout(() => toggleFullscreen()); + await flushLayout(hideSessionAuxPane); + expect(appManager.getState().layout).toMatchObject({ chatCollapsed: false, rightPanelCollapsed: true }); + await flushLayout(expandSessionAuxPane); + expect(appManager.getState().layout).toMatchObject({ chatCollapsed: false, rightPanelCollapsed: false, rightPanelWidth: 520 }); + expect(useAgentCanvasStore.getState().primaryGroup).toBe(originalTabs); + }); + + it('reveals the bottom terminal from fullscreen without opening hidden content from chat-only', () => { + expandSessionBottomTerminalPane(260); + expect(appManager.getState().layout).toMatchObject({ + chatCollapsed: false, rightPanelCollapsed: true, bottomTerminalPanelCollapsed: false, + }); + appManager.updateLayout({ chatCollapsed: true }); + collapseSessionBottomTerminalPane(); + expandSessionBottomTerminalPane(260); + expect(appManager.getState().layout).toMatchObject({ + chatCollapsed: false, rightPanelCollapsed: false, bottomTerminalPanelCollapsed: false, + bottomTerminalPanelHeight: 260, rightPanelWidth: 520, + }); + }); + it.each([true, false])('preserves a session panel with collapsed=%s through file-view lifetime', async collapsed => { appManager.updateLayout({ rightPanelCollapsed: collapsed }); useAgentCanvasStore.getState().addTab(content('session'), 'active'); diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.test.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.test.tsx index 6e41b605df..e236b8578c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.test.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.test.tsx @@ -128,6 +128,25 @@ describe('canvas TabGroup integration', () => { expect(contextMenu.showMenu.mock.calls[0][1].map((item: { id: string }) => item.id)).toContain('tab-toggle-pin'); }); + it('replaces the close-all button with host controls while retaining close-all in the tab menu', () => { + const hidePanel = vi.fn(); + props.toolbarActions = ; + render(); + expect(container.querySelector('.canvas-tab-bar__close-all')).toBeNull(); + act(() => container.querySelector('.canvas-tab-bar__actions button')!.click()); + expect(hidePanel).toHaveBeenCalledTimes(1); + expect(props.onCloseAllTabs).not.toHaveBeenCalled(); + + act(() => tab('a').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }))); + const closeAll = contextMenu.showMenu.mock.calls[0][1].find((item: { id: string }) => item.id === 'tab-close-all'); + expect(closeAll.disabled).toBe(false); + + props.toolbarActions = null; + render(); + expect(container.querySelector('.canvas-tab-bar__close-all')).toBeNull(); + expect(container.querySelector('.canvas-tab-bar__actions')?.textContent).not.toContain('Hide panel'); + }); + it('retains group identity when dragging and reordering standard tabs', () => { props.draggingTabId = 'a'; render(); diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx index 04871fc500..1b00445c2e 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx @@ -49,6 +49,8 @@ export interface TabBarProps { onCloseAllTabs?: () => Promise | void; /** Pop out tab as independent scene */ onTabPopOut?: (tabId: string) => void; + /** undefined keeps default actions; null omits them in non-owning split groups. */ + toolbarActions?: React.ReactNode; } export const TabBar: React.FC = ({ @@ -67,6 +69,7 @@ export const TabBar: React.FC = ({ onOpenMissionControl, onCloseAllTabs, onTabPopOut, + toolbarActions, }) => { const { t } = useTranslation('components'); const [overflowTabIds, setOverflowTabIds] = useState([]); @@ -89,13 +92,19 @@ export const TabBar: React.FC = ({ if (!list || !toolbar || list.clientWidth === 0) return; const toolbarStyle = getComputedStyle(toolbar); - const closeWidth = actionsRef.current - ?.querySelector('.canvas-tab-bar__close-all')?.offsetWidth ?? 0; + const actions = actionsRef.current; + const fixedActions = actions + ? Array.from(actions.children).filter((child): child is HTMLElement => + child instanceof HTMLElement && child.dataset.openbitfunProductComponent !== 'canvas-tab-overflow') + : []; + const actionsGap = actions ? parseFloat(getComputedStyle(actions).columnGap) || 0 : 0; + const fixedActionsWidth = fixedActions.reduce((width, action) => width + action.offsetWidth, 0) + + Math.max(0, fixedActions.length - 1) * actionsGap; const availableWithoutOverflow = toolbar.clientWidth - (parseFloat(toolbarStyle.paddingLeft) || 0) - (parseFloat(toolbarStyle.paddingRight) || 0) - (parseFloat(toolbarStyle.columnGap) || 0) - - closeWidth; + - fixedActionsWidth; const canFitWithoutMenu = !onOpenMissionControl && list.scrollWidth <= availableWithoutOverflow; const viewport = list.getBoundingClientRect(); @@ -382,8 +391,8 @@ export const TabBar: React.FC = ({ /> )} - {/* Close all tabs button */} - {onCloseAllTabs && visibleTabs.length > 0 && ( + {/* Hosts own layout actions; close-all remains available in tab menus. */} + {toolbarActions !== undefined ? toolbarActions : onCloseAllTabs && visibleTabs.length > 0 && ( { expect(globalSearch).toContain('variant="modal"'); expect(auxPane).not.toContain('GlobalSearchContent'); expect(auxPane).toContain('missionControlEnabled={false}'); - expect(contentCanvas).toContain(''); + expect(contentCanvas).toContain(''); expect(contentCanvas).toContain( 'onOpenMissionControl={missionControlEnabled ? handleOpenMissionControl : undefined}', ); diff --git a/src/web-ui/src/app/hooks/useApp.ts b/src/web-ui/src/app/hooks/useApp.ts index da983bb6ce..5fff74182e 100644 --- a/src/web-ui/src/app/hooks/useApp.ts +++ b/src/web-ui/src/app/hooks/useApp.ts @@ -13,6 +13,7 @@ import { PanelType } from '../types'; import { appManager } from '../services/AppManager'; +import { sessionPaneLayoutStore } from '../scenes/session/sessionPaneLayoutStore'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('useApp'); @@ -41,10 +42,8 @@ export const useApp = (): UseAppReturn => { }, [state.layout.leftPanelCollapsed]); const toggleRightPanel = useCallback(() => { - appManager.updateLayout({ - rightPanelCollapsed: !state.layout.rightPanelCollapsed - }); - }, [state.layout.rightPanelCollapsed]); + sessionPaneLayoutStore.getState().toggleContent(); + }, []); const toggleBottomTerminalPanel = useCallback(() => { appManager.updateLayout({ @@ -53,14 +52,8 @@ export const useApp = (): UseAppReturn => { }, [state.layout.bottomTerminalPanelCollapsed]); const toggleChatPanel = useCallback(() => { - const nextChatCollapsed = !state.layout.chatCollapsed; - appManager.updateLayout({ - chatCollapsed: nextChatCollapsed, - // Keep behavior aligned with editor-mode layout: - // when chat is hidden, ensure the right panel is visible to occupy center space. - rightPanelCollapsed: nextChatCollapsed ? false : state.layout.rightPanelCollapsed - }); - }, [state.layout.chatCollapsed, state.layout.rightPanelCollapsed]); + sessionPaneLayoutStore.getState().toggleMaximized(); + }, []); const switchLeftPanelTab = useCallback((tab: PanelType) => { appManager.updateLayout({ @@ -90,14 +83,7 @@ export const useApp = (): UseAppReturn => { }, []); const updateRightPanelWidth = useCallback((width: number) => { - // Clamp width: 200px min, 1200px max - const MIN_WIDTH = 200; - const MAX_WIDTH = 1200; - const clampedWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)); - - appManager.updateLayout({ - rightPanelWidth: clampedWidth - }); + sessionPaneLayoutStore.getState().resizeRightPane(width); }, []); const updateBottomTerminalPanelHeight = useCallback((height: number) => { @@ -111,10 +97,8 @@ export const useApp = (): UseAppReturn => { }, []); const toggleCenterPanel = useCallback(() => { - appManager.updateLayout({ - centerPanelCollapsed: !state.layout.centerPanelCollapsed - }); - }, [state.layout.centerPanelCollapsed]); + sessionPaneLayoutStore.getState().toggleMaximized(); + }, []); const updateAgentConfig = useCallback(async (agentId: string, config: Partial): Promise => { try { diff --git a/src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx b/src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx index 7955731587..ff42146ce1 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx @@ -22,7 +22,9 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('../../components/panels/content-canvas', () => ({ - ContentCanvas: () =>
, + ContentCanvas: ({ toolbarActions }: { toolbarActions?: React.ReactNode }) => ( +
{toolbarActions}
+ ), useCanvasStore: (selector: (state: typeof mocks.canvasStore) => unknown) => ( selector(mocks.canvasStore) ), @@ -44,10 +46,12 @@ vi.mock('@/shared/utils/logger', () => ({ import AuxPane from './AuxPane'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { hideSessionAuxPane } from './sessionPanelLayout'; vi.mock('./sessionPanelLayout', () => ({ expandSessionAuxPane: vi.fn(), collapseSessionAuxPane: vi.fn(), + hideSessionAuxPane: vi.fn(), })); globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -62,6 +66,7 @@ describe('AuxPane session canvas switching', () => { beforeEach(() => { mocks.switchAgentCanvasScope.mockReset(); + vi.mocked(hideSessionAuxPane).mockClear(); setActiveSession('session-a'); container = document.createElement('div'); document.body.appendChild(container); @@ -82,6 +87,24 @@ describe('AuxPane session canvas switching', () => { expect(mocks.switchAgentCanvasScope).toHaveBeenCalledWith('session-a'); }); + it('offers reversible fullscreen and hides the host without closing its tabs', () => { + const onToggleFullscreen = vi.fn(); + act(() => root.render()); + const maximize = container.querySelector('[aria-label="canvas.maximizePanel"]')!; + expect(maximize.getAttribute('aria-pressed')).toBe('false'); + act(() => maximize.click()); + expect(onToggleFullscreen).toHaveBeenCalledTimes(1); + + act(() => root.render()); + const restore = container.querySelector('[aria-label="canvas.restorePanel"]')!; + expect(restore.getAttribute('aria-pressed')).toBe('true'); + act(() => restore.click()); + expect(onToggleFullscreen).toHaveBeenCalledTimes(2); + act(() => container.querySelector('[aria-label="canvas.hidePanel"]')!.click()); + expect(hideSessionAuxPane).toHaveBeenCalledTimes(1); + expect(mocks.canvasStore.closeAllTabs).not.toHaveBeenCalled(); + }); + it('swaps canvas snapshots synchronously before the target session content can open', () => { act(() => { root.render(); diff --git a/src/web-ui/src/app/scenes/session/AuxPane.tsx b/src/web-ui/src/app/scenes/session/AuxPane.tsx index 442a495a95..930d0ab324 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPane.tsx @@ -6,10 +6,12 @@ */ import { forwardRef, useEffect, useRef, useImperativeHandle, useCallback, useSyncExternalStore } from 'react'; +import { Maximize2, Minimize2, SquareDashed } from 'lucide-react'; +import { Icon, IconButton, Tooltip } from '@openbitfun/ui'; import { ContentCanvas, useCanvasStore } from '../../components/panels/content-canvas'; import { usePanelTabCoordinator } from '../../components/panels/content-canvas/hooks/usePanelTabCoordinator'; import { TAB_EVENTS } from '../../components/panels/content-canvas/types'; -import { collapseSessionAuxPane, expandSessionAuxPane } from './sessionPanelLayout'; +import { collapseSessionAuxPane, expandSessionAuxPane, hideSessionAuxPane } from './sessionPanelLayout'; import { switchAgentCanvasScope } from '../../components/panels/content-canvas/stores'; import { useI18n } from '@/infrastructure/i18n'; import type { PanelContent as OldPanelContent } from '../../components/panels/base/types'; @@ -34,10 +36,12 @@ interface AuxPaneProps { workspacePath?: string; isSceneActive?: boolean; terminalResizeSuspended?: boolean; + isFullscreen?: boolean; + onToggleFullscreen?: () => void; } const AuxPane = forwardRef( - ({ workspacePath, isSceneActive = true, terminalResizeSuspended = false }, ref) => { + ({ workspacePath, isSceneActive = true, terminalResizeSuspended = false, isFullscreen = false, onToggleFullscreen }, ref) => { const { t } = useI18n('components'); const activeSessionId = useSyncExternalStore( flowChatStore.subscribe.bind(flowChatStore), @@ -55,7 +59,7 @@ const AuxPane = forwardRef( const secondaryGroup = useCanvasStore(state => state.secondaryGroup); const tertiaryGroup = useCanvasStore(state => state.tertiaryGroup); const canvasScopeKey = useCanvasStore(state => state.scopeKey); - const { expandPanel, collapsePanel } = usePanelTabCoordinator({ + const { expandPanel } = usePanelTabCoordinator({ visibleTabCount: [primaryGroup, secondaryGroup, tertiaryGroup] .reduce((count, group) => count + group.tabs.filter(tab => !tab.isHidden && isCanvasTabVisibleForSession(tab, activeSessionId), @@ -154,7 +158,36 @@ const AuxPane = forwardRef( mode="agent" isSceneActive={isSceneActive} onReveal={expandPanel} - onCollapsePanel={collapsePanel} + onCollapsePanel={hideSessionAuxPane} + toolbarActions={( + <> + {onToggleFullscreen && ( + + } + onClick={event => { + event.stopPropagation(); + onToggleFullscreen(); + }} + /> + + )} + + } + onClick={event => { + event.stopPropagation(); + hideSessionAuxPane(); + }} + /> + + + )} onInteraction={handleInteraction} onBeforeClose={handleBeforeClose} terminalResizeSuspended={terminalResizeSuspended} diff --git a/src/web-ui/src/app/scenes/session/SessionScene.scss b/src/web-ui/src/app/scenes/session/SessionScene.scss index b5088cd499..0e417c072a 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.scss +++ b/src/web-ui/src/app/scenes/session/SessionScene.scss @@ -1,7 +1,4 @@ -/** - * SessionScene styles — ChatPane + Resizer + AuxPane layout. - * Resizer styles moved here from WorkspaceShell.scss. - */ +/** Session content hosts; horizontal pane geometry belongs to SplitView. */ .openbitfun-session-scene { display: flex; flex-direction: column; @@ -14,40 +11,14 @@ background: var(--openbitfun-color-surface-scene); color: var(--openbitfun-color-content-primary); - &--dragging { - user-select: none; - cursor: col-resize; - - * { pointer-events: none; } - - .openbitfun-pane-resizer, - .openbitfun-bottom-pane-resizer { pointer-events: auto; } - - .openbitfun-session-scene__chat-pane, - .openbitfun-session-scene__aux-pane, - .openbitfun-session-scene__bottom-terminal-pane { transition: none; } - - .openbitfun-dual-column__header, - .openbitfun-dual-column__header-two-row, - .openbitfun-dual-column__drop-indicator-content, - .openbitfun-dual-column__dropdown { - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - } - - .openbitfun-dual-column__header::after, - .openbitfun-dual-column__header-two-row::after { display: none !important; } - } - + &--dragging { user-select: none; } &--dragging-bottom { cursor: row-resize; + .openbitfun-session-scene__main-row { pointer-events: none; } } } - .openbitfun-session-scene__main-row { - display: flex; - flex-direction: row; flex: 1 1 auto; min-width: 0; min-height: 0; @@ -55,234 +26,18 @@ overflow: hidden; } - -.openbitfun-session-scene__chat-pane { - flex: 1 1 auto; - min-width: 400px; - height: 100%; - overflow: hidden; - position: relative; - box-sizing: border-box; - - &--dragging { - transition: none; - * { transition: none !important; animation: none !important; } - } -} - - +.openbitfun-session-scene__chat-pane, .openbitfun-session-scene__aux-pane { display: flex; - flex-shrink: 0; + flex: 1; + min-width: 0; + min-height: 0; height: 100%; overflow: hidden; position: relative; box-sizing: border-box; - max-width: 1200px; - - transition: width var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - opacity var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - - &--collapsed { - min-width: 0; - width: 0 !important; - pointer-events: none; - opacity: 0; - } - - &--no-animation { - transition: none !important; - * { transition: none !important; animation: none !important; } - } - - &--dragging { - transition: none; - * { transition: none !important; animation: none !important; } - - .openbitfun-dual-column__header, - .openbitfun-dual-column__header-two-row, - .openbitfun-dual-column__drop-indicator-content, - .openbitfun-dual-column__dropdown { - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - } - .openbitfun-dual-column__header::after, - .openbitfun-dual-column__header-two-row::after { display: none !important; } - } - - &--editor-mode { - flex: 1 1 auto; - min-width: 300px; - max-width: none; - } - - // Near-collapse warning - &--near-collapse { - position: relative; - - &::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - to right, - color-mix(in srgb, var(--openbitfun-color-accent-default) 25%, transparent) 0%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 12%, transparent) 50%, - transparent 100% - ); - pointer-events: none; - z-index: 100; - animation: pane-collapse-pulse 0.8s ease-in-out infinite; - } - - &::after { - content: var(--panel-collapse-hint-right); - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - color: color-mix(in srgb, var(--openbitfun-color-accent-default) 90%, transparent); - font-size: var(--openbitfun-type-flow-control-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - pointer-events: none; - z-index: 101; - white-space: nowrap; - text-shadow: 0 1px 2px color-mix(in srgb, var(--openbitfun-color-content-on-light) 30%, transparent); - animation: pane-collapse-hint 0.3s ease-out; - } - } } - -.openbitfun-pane-resizer { - width: 1px; - flex-shrink: 0; - position: relative; - cursor: col-resize; - user-select: none; - z-index: var(--openbitfun-layer-sticky); - background: var(--openbitfun-color-border-subtle); - - &::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -6px; - right: -6px; - background: transparent; - } - - // Explicit property list (no `transition: all`): only the collapse width - // and cheap paint properties animate; hover feedback uses transform below. - transition: width var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - opacity var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - background-color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - - &--collapsed { - width: 0; - opacity: 0; - pointer-events: none; - cursor: default; - background: transparent; - - &::before { display: none; } - } - - &__line { - position: absolute; - left: 50%; - top: 50%; - // Final size is fixed; hover/drag feedback scales via transform so no - // layout work happens while the pointer sweeps across the divider. - transform: translate(-50%, -50%) scaleY(0.72); - width: 2px; - height: 50px; - background: transparent; - border-radius: var(--openbitfun-radius-sm); - transition: opacity var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - background-color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - box-shadow var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard), - transform var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - opacity: 0; - pointer-events: none; - } - - &__handle { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - opacity: 0; - transition: opacity var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - pointer-events: none; - } - - &__icon { - color: color-mix(in srgb, var(--openbitfun-color-content-muted) 60%, transparent); - transition: color var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard); - } - - &:hover, - &--hovering { - .openbitfun-pane-resizer__line { - background: linear-gradient(180deg, - transparent 0%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent) 20%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 80%, transparent) 50%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent) 80%, - transparent 100% - ); - transform: translate(-50%, -50%) scaleY(1); - opacity: 1; - } - .openbitfun-pane-resizer__handle { opacity: 1; } - .openbitfun-pane-resizer__icon { color: color-mix(in srgb, var(--openbitfun-color-accent-default) 90%, transparent); } - } - - &--dragging { - .openbitfun-pane-resizer__line { - background: linear-gradient(180deg, - transparent 0%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 60%, transparent) 20%, - var(--openbitfun-color-accent-default) 50%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 60%, transparent) 80%, - transparent 100% - ); - box-shadow: 0 0 12px color-mix(in srgb, var(--openbitfun-color-accent-default) 50%, transparent); - transform: translate(-50%, -50%) scaleY(1.2); - opacity: 1; - } - .openbitfun-pane-resizer__handle { opacity: 1; } - .openbitfun-pane-resizer__icon { color: var(--openbitfun-color-accent-default); } - } - - &:focus { - outline: none; - .openbitfun-pane-resizer__line { - background: linear-gradient(180deg, - transparent 0%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 50%, transparent) 20%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 90%, transparent) 50%, - color-mix(in srgb, var(--openbitfun-color-accent-default) 50%, transparent) 80%, - transparent 100% - ); - box-shadow: 0 0 8px color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); - transform: translate(-50%, -50%) scaleY(1.1); - opacity: 1; - } - .openbitfun-pane-resizer__handle { opacity: 1; } - } - - &:focus-visible { - .openbitfun-pane-resizer__line { - outline: 2px solid var(--openbitfun-color-focus-ring); - outline-offset: 2px; - } - } -} - - .openbitfun-session-scene__bottom-terminal-pane { display: flex; flex: 0 0 auto; @@ -437,66 +192,24 @@ } -@keyframes pane-collapse-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } -} - -@keyframes pane-collapse-hint { - 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } - 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); } -} - - @media (prefers-reduced-motion: reduce) { - .openbitfun-session-scene, - .openbitfun-session-scene__chat-pane, - .openbitfun-session-scene__aux-pane, .openbitfun-session-scene__bottom-terminal-pane, - .openbitfun-pane-resizer, .openbitfun-bottom-pane-resizer, - .openbitfun-pane-resizer__line, .openbitfun-bottom-pane-resizer__line, - .openbitfun-pane-resizer__handle, .openbitfun-bottom-pane-resizer__handle, - .openbitfun-pane-resizer__icon, - .openbitfun-bottom-pane-resizer__icon { - transition: none; - } - - .openbitfun-session-scene__aux-pane--near-collapse { - &::before, &::after { animation: none; } - } + .openbitfun-bottom-pane-resizer__icon { transition: none; } } - @media (hover: none) and (pointer: coarse) { - .openbitfun-pane-resizer { - width: 8px; - - &::before { left: -10px; right: -10px; } - &__line { width: 2px; opacity: 1; } - &__handle { opacity: 0.8; } - } - .openbitfun-bottom-pane-resizer { height: 8px; - &::before { top: -10px; bottom: -10px; } &__line { height: 2px; opacity: 1; } &__handle { opacity: 0.8; } } } - @media (prefers-contrast: high) { - .openbitfun-pane-resizer { - &__line { background: currentColor; opacity: 1; } - &:hover &__line, - &--hovering &__line, - &--dragging &__line { background: currentColor; outline: 2px solid currentColor; } - } - .openbitfun-bottom-pane-resizer { &__line { background: currentColor; opacity: 1; } &:hover &__line, diff --git a/src/web-ui/src/app/scenes/session/SessionScene.tsx b/src/web-ui/src/app/scenes/session/SessionScene.tsx index fe9b1a72f3..3361fa46dd 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.tsx +++ b/src/web-ui/src/app/scenes/session/SessionScene.tsx @@ -1,47 +1,22 @@ -import { GripHorizontal as LucideGripHorizontal, GripVertical as LucideGripVertical } from 'lucide-react'; -/** - * SessionScene — Session scene layout. - * - * Layout (left to right): - * ChatPane (flex:1, FlowChat conversation) - * PaneResizer (draggable divider) - * AuxPane (variable width, ContentCanvas tabs) - * - * Resizer logic moved here from WorkspaceShell. - */ - +/** Session-owned composition over the shared, controlled SplitView. */ +import { ArrowLeftRight, GripHorizontal } from 'lucide-react'; import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Icon, IconButton, SplitView, Tooltip } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; import { useApp } from '../../hooks/useApp'; import ChatPane from './ChatPane'; -import AuxPane, { type AuxPaneRef } from './AuxPane'; +import AuxPane from './AuxPane'; import BottomTerminalPane from './BottomTerminalPane'; +import { collapseSessionBottomTerminalPane, expandSessionBottomTerminalPane } from './sessionPanelLayout'; +import { useSessionPaneLayout } from './sessionPaneLayoutStore'; import { - collapseSessionBottomTerminalPane, - expandSessionAuxPane, - expandSessionBottomTerminalPane, -} from './sessionPanelLayout'; -import { - getCachedTerminalPanelPosition, - onTerminalPanelPositionChange, - refreshTerminalPanelPosition, + getCachedTerminalPanelPosition, onTerminalPanelPositionChange, refreshTerminalPanelPosition, } from '@/tools/terminal/services/terminalPanelPreferenceService'; import type { TerminalPanelPosition } from '@/infrastructure/config/types'; - import { - BOTTOM_TERMINAL_PANEL_CONFIG, - RIGHT_PANEL_CONFIG, - PANEL_COMMON_CONFIG, - STORAGE_KEYS, - PanelDisplayMode, - getPanelDisplayMode, - getModeWidth, - getSnappedWidth, - getNextMode, - savePanelWidth, - loadPanelWidth, + BOTTOM_TERMINAL_PANEL_CONFIG, RIGHT_PANEL_CONFIG, PANEL_COMMON_CONFIG, STORAGE_KEYS, + getPanelDisplayMode, getModeWidth, getSnappedWidth, getNextMode, savePanelWidth, loadPanelWidth, } from '../../layout/panelConfig'; - import './SessionScene.scss'; const TERMINAL_PANEL_RESIZE_SUSPEND_FALLBACK_MS = 360; @@ -52,204 +27,80 @@ interface SessionSceneProps { isActive?: boolean; } -const SessionScene: React.FC = ({ - workspacePath, - isEntering = false, - isActive = true, -}) => { - const { t } = useTranslation('flow-chat'); - const { - state, - updateRightPanelWidth, - toggleRightPanel, - updateBottomTerminalPanelHeight, - } = useApp(); - const auxPaneRef = useRef(null); - - const [isDraggingRight, setIsDraggingRight] = useState(false); +const SessionScene: React.FC = ({ workspacePath, isEntering = false, isActive = true }) => { + const { t } = useI18n('flow-chat'); + const { t: tPane } = useI18n('components'); + const { state, updateBottomTerminalPanelHeight } = useApp(); + const pane = useSessionPaneLayout(); + const [isDraggingContent, setIsDraggingContent] = useState(false); + const [isContentSettling, setIsContentSettling] = useState(false); const [isDraggingBottom, setIsDraggingBottom] = useState(false); - const [isHovering, setIsHovering] = useState(false); const [isHoveringBottom, setIsHoveringBottom] = useState(false); - const [isRightPanelTransitioning, setIsRightPanelTransitioning] = useState(false); const [isBottomTerminalPanelTransitioning, setIsBottomTerminalPanelTransitioning] = useState(false); - const [terminalPanelPosition, setTerminalPanelPosition] = useState(() => - getCachedTerminalPanelPosition(), - ); - - const [, setLastRightWidth] = useState(() => - loadPanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, RIGHT_PANEL_CONFIG.COMFORTABLE_DEFAULT) - ); - + const [terminalPanelPosition, setTerminalPanelPosition] = useState(getCachedTerminalPanelPosition); const containerRef = useRef(null); - const resizerRef = useRef(null); - const auxPaneElementRef = useRef(null); const bottomTerminalPaneElementRef = useRef(null); const animationFrameRef = useRef(null); - const rightPanelTransitionTimerRef = useRef(null); const bottomPanelTransitionTimerRef = useRef(null); - const previousRightTransitionKeyRef = useRef(null); const previousBottomTransitionKeyRef = useRef(null); - - const currentRightWidth = state.layout.rightPanelWidth || RIGHT_PANEL_CONFIG.COMFORTABLE_DEFAULT; + const bottomDragCleanupRef = useRef<(() => void) | null>(null); const currentBottomHeight = state.layout.bottomTerminalPanelHeight || BOTTOM_TERMINAL_PANEL_CONFIG.COMFORTABLE_DEFAULT; const isTerminalDockedBottom = terminalPanelPosition === 'bottom'; - const isDragging = isDraggingRight || isDraggingBottom; + const isContentOnly = pane.mode === 'content-only'; + const isBottomHidden = isContentOnly || state.layout.bottomTerminalPanelCollapsed; + const isDragging = isDraggingContent || isDraggingBottom; + const bottomTerminalPanelMode = isBottomHidden ? 'collapsed' + : getPanelDisplayMode(currentBottomHeight, BOTTOM_TERMINAL_PANEL_CONFIG); - const stopRightPanelTransition = useCallback(() => { - if (rightPanelTransitionTimerRef.current !== null) { - window.clearTimeout(rightPanelTransitionTimerRef.current); - rightPanelTransitionTimerRef.current = null; - } - setIsRightPanelTransitioning(false); + useEffect(() => { + const stop = onTerminalPanelPositionChange(setTerminalPanelPosition); + void refreshTerminalPanelPosition(); + return stop; }, []); - const startRightPanelTransition = useCallback(() => { - if (rightPanelTransitionTimerRef.current !== null) { - window.clearTimeout(rightPanelTransitionTimerRef.current); - } - setIsRightPanelTransitioning(true); - rightPanelTransitionTimerRef.current = window.setTimeout( - stopRightPanelTransition, - TERMINAL_PANEL_RESIZE_SUSPEND_FALLBACK_MS, - ); - }, [stopRightPanelTransition]); + // Heavy content measures only the settled layout, not intermediate drag sizes. + useLayoutEffect(() => { + setIsContentSettling(true); + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => setIsContentSettling(false)); + }); + return () => cancelAnimationFrame(frame); + }, [pane.mode, pane.contentSide, pane.preferredRightPaneWidth]); + + useLayoutEffect(() => { + const element = bottomTerminalPaneElementRef.current; + if (element) element.inert = isBottomHidden || !isActive; + }, [isBottomHidden, isActive, isTerminalDockedBottom]); const stopBottomPanelTransition = useCallback(() => { - if (bottomPanelTransitionTimerRef.current !== null) { - window.clearTimeout(bottomPanelTransitionTimerRef.current); - bottomPanelTransitionTimerRef.current = null; - } + if (bottomPanelTransitionTimerRef.current !== null) window.clearTimeout(bottomPanelTransitionTimerRef.current); + bottomPanelTransitionTimerRef.current = null; setIsBottomTerminalPanelTransitioning(false); }, []); const startBottomPanelTransition = useCallback(() => { - if (bottomPanelTransitionTimerRef.current !== null) { - window.clearTimeout(bottomPanelTransitionTimerRef.current); - } + if (bottomPanelTransitionTimerRef.current !== null) window.clearTimeout(bottomPanelTransitionTimerRef.current); setIsBottomTerminalPanelTransitioning(true); - bottomPanelTransitionTimerRef.current = window.setTimeout( - stopBottomPanelTransition, - TERMINAL_PANEL_RESIZE_SUSPEND_FALLBACK_MS, - ); + bottomPanelTransitionTimerRef.current = window.setTimeout(stopBottomPanelTransition, TERMINAL_PANEL_RESIZE_SUSPEND_FALLBACK_MS); }, [stopBottomPanelTransition]); - const rightPanelMode: PanelDisplayMode = useMemo(() => { - if (state.layout.rightPanelCollapsed) return 'collapsed'; - return getPanelDisplayMode(currentRightWidth, RIGHT_PANEL_CONFIG); - }, [state.layout.rightPanelCollapsed, currentRightWidth]); - - const bottomTerminalPanelMode: PanelDisplayMode = useMemo(() => { - if (state.layout.bottomTerminalPanelCollapsed) return 'collapsed'; - return getPanelDisplayMode(currentBottomHeight, BOTTOM_TERMINAL_PANEL_CONFIG); - }, [state.layout.bottomTerminalPanelCollapsed, currentBottomHeight]); - - useEffect(() => { - const unsubscribe = onTerminalPanelPositionChange(setTerminalPanelPosition); - void refreshTerminalPanelPosition(); - return unsubscribe; - }, []); - - // Keep right panel visible when chat is hidden - useEffect(() => { - if (state.layout.chatCollapsed && state.layout.rightPanelCollapsed) { - expandSessionAuxPane(); - } - }, [state.layout.chatCollapsed, state.layout.rightPanelCollapsed]); - - const calculateValidRightWidth = useCallback((newWidth: number): number => { - if (!containerRef.current) return newWidth; - const containerWidth = containerRef.current.offsetWidth; - // When the container hasn't been laid out yet (e.g. window just restored from - // minimize), offsetWidth may be 0. Bail early to avoid clamping to a tiny value. - if (containerWidth <= 0) return newWidth; - // NavPanel (240px) is outside SessionScene — only account for resizer + min chat width - const reserved = PANEL_COMMON_CONFIG.RESIZER_WIDTH + PANEL_COMMON_CONFIG.MIN_CENTER_WIDTH; - const dynamicMax = containerWidth - reserved; - const maxWidth = Math.min(RIGHT_PANEL_CONFIG.MAX_WIDTH, dynamicMax); - return Math.min(maxWidth, Math.max(RIGHT_PANEL_CONFIG.COMPACT_WIDTH, newWidth)); + const calculateValidBottomHeight = useCallback((height: number): number => { + const containerHeight = containerRef.current?.offsetHeight ?? 0; + if (containerHeight <= 0) return height; + const max = Math.max(0, Math.min(BOTTOM_TERMINAL_PANEL_CONFIG.MAX_WIDTH, containerHeight - PANEL_COMMON_CONFIG.RESIZER_WIDTH - 220)); + return Math.min(max, Math.max(Math.min(BOTTOM_TERMINAL_PANEL_CONFIG.COMPACT_WIDTH, max), height)); }, []); - const calculateValidBottomHeight = useCallback((newHeight: number): number => { - if (!containerRef.current) return newHeight; - const containerHeight = containerRef.current.offsetHeight; - if (containerHeight <= 0) return newHeight; - const reserved = PANEL_COMMON_CONFIG.RESIZER_WIDTH + 220; - const dynamicMax = containerHeight - reserved; - const maxHeight = Math.min(BOTTOM_TERMINAL_PANEL_CONFIG.MAX_WIDTH, dynamicMax); - return Math.min(maxHeight, Math.max(BOTTOM_TERMINAL_PANEL_CONFIG.COMPACT_WIDTH, newHeight)); - }, []); - - const saveAndUpdateRightWidth = useCallback((width: number) => { - updateRightPanelWidth(width); - setLastRightWidth(width); - savePanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, width); - }, [updateRightPanelWidth]); - const saveAndUpdateBottomHeight = useCallback((height: number) => { updateBottomTerminalPanelHeight(height); savePanelWidth(STORAGE_KEYS.BOTTOM_TERMINAL_PANEL_LAST_HEIGHT, height); }, [updateBottomTerminalPanelHeight]); - const handleDoubleClick = useCallback(() => { - const nextMode = getNextMode(rightPanelMode); - const targetWidth = getModeWidth(nextMode, RIGHT_PANEL_CONFIG); - saveAndUpdateRightWidth(calculateValidRightWidth(targetWidth)); - }, [rightPanelMode, calculateValidRightWidth, saveAndUpdateRightWidth]); - const handleBottomDoubleClick = useCallback(() => { - const nextMode = getNextMode(bottomTerminalPanelMode); - const targetHeight = getModeWidth(nextMode, BOTTOM_TERMINAL_PANEL_CONFIG); - saveAndUpdateBottomHeight(calculateValidBottomHeight(targetHeight)); + const target = getModeWidth(getNextMode(bottomTerminalPanelMode), BOTTOM_TERMINAL_PANEL_CONFIG); + saveAndUpdateBottomHeight(calculateValidBottomHeight(target)); }, [bottomTerminalPanelMode, calculateValidBottomHeight, saveAndUpdateBottomHeight]); - const handleMouseDownResizer = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - if (!containerRef.current) return; - - const startX = e.clientX; - const startWidth = currentRightWidth; - let lastValidWidth = startWidth; - - setIsDraggingRight(true); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - - const onMove = (ev: MouseEvent) => { - if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = requestAnimationFrame(() => { - const valid = calculateValidRightWidth(startWidth + (startX - ev.clientX)); - lastValidWidth = valid; - if (auxPaneElementRef.current && !state.layout.chatCollapsed) { - auxPaneElementRef.current.style.width = `${valid}px`; - } else { - updateRightPanelWidth(valid); - } - animationFrameRef.current = null; - }); - }; - - const onUp = () => { - if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current); - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - - const snapped = getSnappedWidth(lastValidWidth, RIGHT_PANEL_CONFIG, false); - if (snapped !== lastValidWidth) { - saveAndUpdateRightWidth(snapped); - } else { - updateRightPanelWidth(lastValidWidth); - setLastRightWidth(lastValidWidth); - savePanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, lastValidWidth); - } - requestAnimationFrame(() => requestAnimationFrame(() => setIsDraggingRight(false))); - }; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }, [currentRightWidth, calculateValidRightWidth, updateRightPanelWidth, saveAndUpdateRightWidth, state.layout.chatCollapsed]); - const handleMouseDownBottomResizer = useCallback((e: React.MouseEvent) => { e.preventDefault(); if (!containerRef.current) return; @@ -257,32 +108,36 @@ const SessionScene: React.FC = ({ const startY = e.clientY; const startHeight = currentBottomHeight; let lastValidHeight = startHeight; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; setIsDraggingBottom(true); document.body.style.cursor = 'row-resize'; document.body.style.userSelect = 'none'; const onMove = (ev: MouseEvent) => { + lastValidHeight = calculateValidBottomHeight(startHeight + (startY - ev.clientY)); if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = requestAnimationFrame(() => { - const valid = calculateValidBottomHeight(startHeight + (startY - ev.clientY)); - lastValidHeight = valid; if (bottomTerminalPaneElementRef.current) { - bottomTerminalPaneElementRef.current.style.height = `${valid}px`; - } else { - updateBottomTerminalPanelHeight(valid); + bottomTerminalPaneElementRef.current.style.height = `${lastValidHeight}px`; } animationFrameRef.current = null; }); }; - const onUp = () => { + const cleanup = () => { if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - + bottomDragCleanupRef.current = null; + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + }; + function onUp(event: MouseEvent) { + lastValidHeight = calculateValidBottomHeight(startHeight + (startY - event.clientY)); + cleanup(); const snapped = getSnappedWidth(lastValidHeight, BOTTOM_TERMINAL_PANEL_CONFIG, false); if (snapped !== lastValidHeight) { saveAndUpdateBottomHeight(snapped); @@ -290,345 +145,135 @@ const SessionScene: React.FC = ({ updateBottomTerminalPanelHeight(lastValidHeight); savePanelWidth(STORAGE_KEYS.BOTTOM_TERMINAL_PANEL_LAST_HEIGHT, lastValidHeight); } - requestAnimationFrame(() => requestAnimationFrame(() => setIsDraggingBottom(false))); - }; + animationFrameRef.current = requestAnimationFrame(() => { + animationFrameRef.current = requestAnimationFrame(() => { + animationFrameRef.current = null; + setIsDraggingBottom(false); + }); + }); + } + bottomDragCleanupRef.current = cleanup; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, [calculateValidBottomHeight, currentBottomHeight, saveAndUpdateBottomHeight, updateBottomTerminalPanelHeight]); - // No-animation expansion - const [isAuxPaneExpandingImmediate, setIsAuxPaneExpandingImmediate] = useState(false); - - useEffect(() => { - const handler = (event: CustomEvent) => { - if (event.detail?.noAnimation && state.layout.rightPanelCollapsed) { - setIsAuxPaneExpandingImmediate(true); - setTimeout(() => setIsAuxPaneExpandingImmediate(false), 0); - } - }; - window.addEventListener('expand-right-panel-immediate', handler as EventListener); - return () => window.removeEventListener('expand-right-panel-immediate', handler as EventListener); - }, [state.layout.rightPanelCollapsed]); const expandBottomTerminalPanel = useCallback(() => { - const saved = loadPanelWidth( - STORAGE_KEYS.BOTTOM_TERMINAL_PANEL_LAST_HEIGHT, - BOTTOM_TERMINAL_PANEL_CONFIG.COMFORTABLE_DEFAULT, - ); + const saved = loadPanelWidth(STORAGE_KEYS.BOTTOM_TERMINAL_PANEL_LAST_HEIGHT, BOTTOM_TERMINAL_PANEL_CONFIG.COMFORTABLE_DEFAULT); expandSessionBottomTerminalPane(calculateValidBottomHeight(saved)); }, [calculateValidBottomHeight]); - // Responsive resize — also validate on mount to clamp widths restored from - // localStorage that may exceed the current (non-maximized) window size. useEffect(() => { + if (!isTerminalDockedBottom) return; const validate = () => { - const valid = calculateValidRightWidth(currentRightWidth); - if (valid !== currentRightWidth) updateRightPanelWidth(valid); - if (isTerminalDockedBottom) { - const validBottom = calculateValidBottomHeight(currentBottomHeight); - if (validBottom !== currentBottomHeight) updateBottomTerminalPanelHeight(validBottom); - } + const valid = calculateValidBottomHeight(currentBottomHeight); + if (valid !== currentBottomHeight) updateBottomTerminalPanelHeight(valid); }; - const rafId = requestAnimationFrame(validate); + const frame = requestAnimationFrame(validate); window.addEventListener('resize', validate); - return () => { - cancelAnimationFrame(rafId); - window.removeEventListener('resize', validate); - }; - }, [ - currentRightWidth, - currentBottomHeight, - calculateValidRightWidth, - calculateValidBottomHeight, - isTerminalDockedBottom, - updateRightPanelWidth, - updateBottomTerminalPanelHeight, - ]); - - // Restore right panel width when window regains visibility (e.g. after minimize → restore). - // This acts as a safety net in case any layout recalculation during the restore - // cycle lost the user's manual width adjustment. - const prevVisibleRef = useRef(true); - useEffect(() => { - const handleVisibility = () => { - const nowVisible = document.visibilityState === 'visible'; - if (nowVisible && !prevVisibleRef.current) { - const saved = loadPanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, currentRightWidth); - if (saved !== currentRightWidth && !state.layout.rightPanelCollapsed) { - updateRightPanelWidth(saved); - } - } - prevVisibleRef.current = nowVisible; - }; - document.addEventListener('visibilitychange', handleVisibility); - return () => document.removeEventListener('visibilitychange', handleVisibility); - }, [currentRightWidth, updateRightPanelWidth, state.layout.rightPanelCollapsed]); + return () => { cancelAnimationFrame(frame); window.removeEventListener('resize', validate); }; + }, [isTerminalDockedBottom, calculateValidBottomHeight, currentBottomHeight, updateBottomTerminalPanelHeight]); - // Cleanup animation frames useEffect(() => () => { + bottomDragCleanupRef.current?.(); if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current); - if (rightPanelTransitionTimerRef.current !== null) { - window.clearTimeout(rightPanelTransitionTimerRef.current); - } - if (bottomPanelTransitionTimerRef.current !== null) { - window.clearTimeout(bottomPanelTransitionTimerRef.current); - } + if (bottomPanelTransitionTimerRef.current !== null) window.clearTimeout(bottomPanelTransitionTimerRef.current); }, []); - const isRightAsMain = state.layout.chatCollapsed; - const isChatHidden = state.layout.centerPanelCollapsed || isRightAsMain; - - // Terminal resize is suspended for the whole CSS transition. xterm.js reflows - // its buffer on every resize, so fitting through intermediate panel widths can - // permanently damage scrollback before the panel reaches its final size. useLayoutEffect(() => { - const transitionKey = [ - state.layout.rightPanelCollapsed ? 'collapsed' : 'open', - currentRightWidth, - isRightAsMain ? 'main' : 'side', - ].join(':'); - - if (previousRightTransitionKeyRef.current === null) { - previousRightTransitionKeyRef.current = transitionKey; - return; - } - - if (previousRightTransitionKeyRef.current === transitionKey) { - return; - } - - previousRightTransitionKeyRef.current = transitionKey; - - if (isDraggingRight || isAuxPaneExpandingImmediate) { - return; - } - - startRightPanelTransition(); - }, [ - currentRightWidth, - isAuxPaneExpandingImmediate, - isDraggingRight, - isRightAsMain, - startRightPanelTransition, - state.layout.rightPanelCollapsed, - ]); - - // Bottom docking uses the same suspension as the right panel. This keeps the - // compact final height usable while avoiding transient animation heights. - useLayoutEffect(() => { - const transitionKey = [ - state.layout.bottomTerminalPanelCollapsed ? 'collapsed' : 'open', - currentBottomHeight, - isTerminalDockedBottom ? 'bottom' : 'right', - ].join(':'); - - if (previousBottomTransitionKeyRef.current === null) { - previousBottomTransitionKeyRef.current = transitionKey; - return; - } - - if (previousBottomTransitionKeyRef.current === transitionKey) { - return; - } - - previousBottomTransitionKeyRef.current = transitionKey; - - if (isDraggingBottom || !isTerminalDockedBottom) { - return; - } - - startBottomPanelTransition(); - }, [ - currentBottomHeight, - isDraggingBottom, - isTerminalDockedBottom, - startBottomPanelTransition, - state.layout.bottomTerminalPanelCollapsed, - ]); - - const handleRightPanelTransitionEnd = useCallback((event: React.TransitionEvent) => { - if (event.currentTarget !== event.target) return; - if (event.propertyName !== 'width') return; - stopRightPanelTransition(); - }, [stopRightPanelTransition]); - - const handleBottomPanelTransitionEnd = useCallback((event: React.TransitionEvent) => { - if (event.currentTarget !== event.target) return; - if (event.propertyName !== 'height') return; - stopBottomPanelTransition(); - }, [stopBottomPanelTransition]); + const key = `${isBottomHidden}:${currentBottomHeight}:${isTerminalDockedBottom}`; + const previous = previousBottomTransitionKeyRef.current; + previousBottomTransitionKeyRef.current = key; + if (previous !== null && key !== previous && !isDraggingBottom && isTerminalDockedBottom) startBottomPanelTransition(); + }, [isBottomHidden, currentBottomHeight, isTerminalDockedBottom, isDraggingBottom, startBottomPanelTransition]); const panelModeLabels = useMemo(() => ({ - collapsed: t('layout.panelMode.collapsed'), - compact: t('layout.panelMode.compact'), - comfortable: t('layout.panelMode.comfortable'), - expanded: t('layout.panelMode.expanded'), + collapsed: t('layout.panelMode.collapsed'), compact: t('layout.panelMode.compact'), + comfortable: t('layout.panelMode.comfortable'), expanded: t('layout.panelMode.expanded'), }), [t]); - const panelCollapseHintStyles = useMemo(() => { - const q = (v: string) => `"${v.replace(/"/g, '\\"')}"`; - return { - '--panel-collapse-hint-right': q(t('layout.panelCollapseHintRight')), - } as React.CSSProperties & Record<'--panel-collapse-hint-right', string>; - }, [t]); - return ( -
- {/* ChatPane — FlowChat conversation */} - {!isChatHidden && ( -
- -
- )} - - {/* Resizer — always rendered (when chat visible) for slide animation */} - {!isChatHidden && ( -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - tabIndex={state.layout.rightPanelCollapsed ? -1 : 0} - role="separator" - aria-orientation="vertical" - aria-label={t('layout.resizer.rightAriaLabel')} - aria-valuenow={currentRightWidth} - aria-valuemin={RIGHT_PANEL_CONFIG.COMPACT_WIDTH} - aria-valuemax={RIGHT_PANEL_CONFIG.MAX_WIDTH} - title={t('layout.resizer.title', { mode: panelModeLabels[rightPanelMode] })} - data-testid="session-right-pane-resizer" - > -
-
-
- )} - - {/* AuxPane — ContentCanvas */} + } + secondary={ +
+ +
+ } + /> +
+ {isTerminalDockedBottom && <>
setIsHoveringBottom(true)} onMouseLeave={() => setIsHoveringBottom(false)} + tabIndex={isBottomHidden ? -1 : 0} role="separator" aria-orientation="horizontal" aria-hidden={isBottomHidden} + aria-label={t('layout.resizer.terminalBottomAriaLabel')} aria-valuenow={currentBottomHeight} + aria-valuemin={BOTTOM_TERMINAL_PANEL_CONFIG.COMPACT_WIDTH} aria-valuemax={BOTTOM_TERMINAL_PANEL_CONFIG.MAX_WIDTH} + title={t('layout.resizer.title', { mode: panelModeLabels[bottomTerminalPanelMode] })} + > +
+
+
+
{ + if (event.currentTarget === event.target && event.propertyName === 'height') stopBottomPanelTransition(); }} - data-mode={rightPanelMode} - data-testid="session-aux-pane" - data-openbitfun-scene="session" - data-openbitfun-part="auxiliary" - onTransitionEnd={handleRightPanelTransitionEnd} > - +
-
- - {isTerminalDockedBottom ? ( - <> -
setIsHoveringBottom(true)} - onMouseLeave={() => setIsHoveringBottom(false)} - tabIndex={state.layout.bottomTerminalPanelCollapsed ? -1 : 0} - role="separator" - aria-orientation="horizontal" - aria-label={t('layout.resizer.terminalBottomAriaLabel')} - aria-valuenow={currentBottomHeight} - aria-valuemin={BOTTOM_TERMINAL_PANEL_CONFIG.COMPACT_WIDTH} - aria-valuemax={BOTTOM_TERMINAL_PANEL_CONFIG.MAX_WIDTH} - title={t('layout.resizer.title', { mode: panelModeLabels[bottomTerminalPanelMode] })} - > -
-
-
-
- -
- -
- - ) : null} + }
); }; diff --git a/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts index 1a5c2b9490..56b8a11c66 100644 --- a/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts +++ b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts @@ -5,6 +5,7 @@ import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import type { Session } from '@/flow_chat/types/flow-chat'; import { appManager } from '../../services/AppManager'; import { clearSessionAuxPaneMemory, startSessionAuxPaneMemory } from './sessionAuxPaneMemory'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; function session(sessionId: string): Session { return { @@ -34,6 +35,7 @@ describe('session aux pane memory', () => { let stop: (() => void) | undefined; beforeEach(() => { + activateSurface('local'); clearSessionAuxPaneMemory(); appManager.updateLayout({ chatCollapsed: false, rightPanelCollapsed: true }); seed(['a', 'b']); @@ -42,6 +44,7 @@ describe('session aux pane memory', () => { afterEach(() => { stop?.(); stop = undefined; + activateSurface('local'); }); it('restores a remembered session and uses the collapsed default for an unseen one', () => { @@ -72,17 +75,36 @@ describe('session aux pane memory', () => { expect(collapsed()).toBe(true); }); - it('leaves editor mode, where this pane is the main surface, untouched', () => { + it('restores fullscreen only when returning to the session that requested it', () => { stop = startSessionAuxPaneMemory(); flowChatStore.switchSession('a'); appManager.updateLayout({ chatCollapsed: true, rightPanelCollapsed: false }); flowChatStore.switchSession('b'); + expect(collapsed()).toBe(true); + expect(appManager.getState().layout.chatCollapsed).toBe(false); + flowChatStore.switchSession('a'); expect(collapsed()).toBe(false); expect(appManager.getState().layout.chatCollapsed).toBe(true); }); + it('isolates identical session ids on different device surfaces', () => { + stop = startSessionAuxPaneMemory(); + flowChatStore.switchSession('a'); + appManager.updateLayout({ chatCollapsed: true, rightPanelCollapsed: false }); + activateSurface('pane-test-peer'); + seed(['a']); + flowChatStore.switchSession('a'); + expect(collapsed()).toBe(true); + setCollapsed(false); + activateSurface('local'); + expect(appManager.getState().layout.chatCollapsed).toBe(true); + activateSurface('pane-test-peer'); + expect(appManager.getState().layout.chatCollapsed).toBe(false); + expect(collapsed()).toBe(false); + }); + it('does not attribute an active session when there is none', () => { stop = startSessionAuxPaneMemory(); diff --git a/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts index 682222b157..4801ee3eda 100644 --- a/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts +++ b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts @@ -1,54 +1,7 @@ -/** - * Session-scoped memory for the session scene right pane (AuxPane). - * - * The layout itself stays global (`appManager.layout.rightPanelCollapsed`); this - * module is the only place that maps the active session to the state it was - * left with, so switching sessions restores the target session instead of - * inheriting the state of the session that was active before. - */ - -import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; -import { appManager } from '../../services/AppManager'; -import { collapseSessionAuxPane, expandSessionAuxPane } from './sessionPanelLayout'; - -/** Sessions that were never left before fall back to the collapsed default. */ -const UNSEEN_SESSION_COLLAPSED = true; - -/** In-memory only: a fresh start uses the app default for every session. */ -const collapsedBySession = new Map(); +/** Compatibility exports; the session pane layout store owns all view modes. */ +export { startSessionPaneLayoutSync as startSessionAuxPaneMemory } from './sessionPaneLayoutSync'; +import { sessionPaneLayoutStore } from './sessionPaneLayoutStore'; export function clearSessionAuxPaneMemory(): void { - collapsedBySession.clear(); -} - -/** - * Remembers the pane state of the session being left and applies the state of - * the session being entered. Returns the unsubscribe function for the shell. - */ -export function startSessionAuxPaneMemory(): () => void { - let previousSessionId = flowChatStore.getState().activeSessionId; - - return flowChatStore.subscribe(state => { - const nextSessionId = state.activeSessionId; - if (nextSessionId === previousSessionId) return; - - const leftSessionId = previousSessionId; - previousSessionId = nextSessionId; - - if (leftSessionId) { - collapsedBySession.set( - leftSessionId, - appManager.getState().layout.rightPanelCollapsed, - ); - } - if (!nextSessionId) return; - - // Both writers are idempotent and keep editor mode, where this pane is the - // main content surface, untouched. - if (collapsedBySession.get(nextSessionId) ?? UNSEEN_SESSION_COLLAPSED) { - collapseSessionAuxPane(); - } else { - expandSessionAuxPane(); - } - }); + sessionPaneLayoutStore.getState().clearSessionMemory(); } diff --git a/src/web-ui/src/app/scenes/session/sessionPaneLayoutStore.test.ts b/src/web-ui/src/app/scenes/session/sessionPaneLayoutStore.test.ts new file mode 100644 index 0000000000..d85fdce4b1 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/sessionPaneLayoutStore.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { STORAGE_KEYS } from '../../layout/panelConfig'; +import { createSessionPaneLayoutStore, projectLegacySessionPaneLayout, selectSessionPaneMode } from './sessionPaneLayoutStore'; + +function preferences(values: Record = {}) { + const data = new Map(Object.entries(values)); + return { getItem: (key: string) => data.get(key) ?? null, setItem: vi.fn((key: string, value: string) => { data.set(key, value); }) }; +} + +describe('session pane layout', () => { + it('swaps content without moving the divider, including hide and fullscreen round trips', () => { + const storage = preferences({ [STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH]: '640' }); + const store = createSessionPaneLayoutStore(storage); + const pane = store.getState(); + pane.activate('local', 'a'); + pane.showContent(); + pane.swapPanes(); + expect(store.getState().contentSide).toBe('left'); + expect(store.getState().preferredRightPaneWidth).toBe(640); + pane.maximizeContent(); + pane.showContent(); // Revealing another tab must not exit fullscreen. + expect(selectSessionPaneMode(store.getState())).toBe('content-only'); + pane.restoreSplit(); + pane.hideContent(); + expect(selectSessionPaneMode(store.getState())).toBe('chat-only'); + pane.showContent(); + expect(store.getState()).toMatchObject({ contentSide: 'left', preferredRightPaneWidth: 640 }); + pane.swapPanes(); + expect(store.getState()).toMatchObject({ contentSide: 'right', preferredRightPaneWidth: 640 }); + expect(storage.setItem.mock.calls.some(([key]) => key === STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH)).toBe(false); + }); + + it('remembers mode per session and device while sharing only placement and width preferences', () => { + const storage = preferences(); + const store = createSessionPaneLayoutStore(storage); + const pane = store.getState(); + pane.activate('local', 'a'); + pane.showContent(); + pane.swapPanes(); + pane.resizeRightPane(720); + pane.maximizeContent(); + pane.activate('peer', 'a'); + expect(selectSessionPaneMode(store.getState())).toBe('chat-only'); + pane.showContent(); + pane.activate('local', 'b'); + expect(selectSessionPaneMode(store.getState())).toBe('chat-only'); + pane.activate('local', 'a'); + expect(selectSessionPaneMode(store.getState())).toBe('content-only'); + pane.activate('peer', 'a'); + expect(selectSessionPaneMode(store.getState())).toBe('split'); + const reopened = createSessionPaneLayoutStore(storage).getState(); + expect(reopened).toMatchObject({ contentSide: 'left', preferredRightPaneWidth: 720 }); + expect(selectSessionPaneMode(reopened)).toBe('chat-only'); + }); + + it('normalizes contradictory legacy flags in one update and retains a readable legacy shape', () => { + const store = createSessionPaneLayoutStore(preferences()); + const changes = vi.fn(); + store.subscribe(changes); + store.getState().applyLegacyLayout({ chatCollapsed: true, rightPanelCollapsed: false, rightPanelWidth: 640 }); + expect(changes).toHaveBeenCalledTimes(1); + expect(projectLegacySessionPaneLayout(store.getState())).toEqual({ + chatCollapsed: true, centerPanelCollapsed: false, rightPanelCollapsed: false, rightPanelWidth: 640, + }); + store.getState().applyLegacyLayout({ rightPanelCollapsed: true, centerPanelCollapsed: true }); + expect(selectSessionPaneMode(store.getState())).toBe('chat-only'); + const snapshot = projectLegacySessionPaneLayout(store.getState()); + const copy = createSessionPaneLayoutStore(preferences()); + copy.getState().applyLegacyLayout(snapshot); + expect(projectLegacySessionPaneLayout(copy.getState())).toEqual(snapshot); + }); + + it('does not hide the main content surface when its last tab closes in fullscreen', () => { + const store = createSessionPaneLayoutStore(preferences()); + store.getState().maximizeContent(); + store.getState().collapseEmptyContent(); + expect(selectSessionPaneMode(store.getState())).toBe('content-only'); + store.getState().hideContent(); + expect(projectLegacySessionPaneLayout(store.getState()).chatCollapsed).toBe(false); + store.getState().showContent(); + store.getState().collapseEmptyContent(); + expect(selectSessionPaneMode(store.getState())).toBe('chat-only'); + }); + + it('reads legacy width without rewriting it and leaves invalid preferences intact', () => { + const storage = preferences({ [STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH]: 'invalid' }); + const store = createSessionPaneLayoutStore(storage); + const initial = store.getState().preferredRightPaneWidth; + expect(initial).toBeGreaterThan(0); + store.getState().resizeRightPane(Number.NaN); + store.getState().resizeRightPane(-1); + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH)).toBe('invalid'); + store.getState().resizeRightPane(640); + store.getState().resizeRightPane(640); + expect(storage.setItem).toHaveBeenCalledTimes(1); + }); + + it('continues to support all layout actions when preference storage is unavailable', () => { + const store = createSessionPaneLayoutStore({ getItem: () => { throw Error('unavailable'); }, setItem: () => { throw Error('unavailable'); } }); + store.getState().showContent(); + store.getState().swapPanes(); + store.getState().resizeRightPane(620); + expect(store.getState()).toMatchObject({ contentSide: 'left', preferredRightPaneWidth: 620 }); + }); +}); diff --git a/src/web-ui/src/app/scenes/session/sessionPaneLayoutStore.ts b/src/web-ui/src/app/scenes/session/sessionPaneLayoutStore.ts new file mode 100644 index 0000000000..3695d00f64 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/sessionPaneLayoutStore.ts @@ -0,0 +1,150 @@ +import { createStore } from 'zustand/vanilla'; +import { useStore } from 'zustand'; +import { surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; +import { RIGHT_PANEL_CONFIG, STORAGE_KEYS } from '../../layout/panelConfig'; + +export type SessionPaneMode = 'chat-only' | 'split' | 'content-only'; +export type ContentPaneSide = 'left' | 'right'; +type PreferenceStorage = Pick; + +export interface LegacySessionPaneLayout { + chatCollapsed: boolean; + centerPanelCollapsed: boolean; + rightPanelCollapsed: boolean; + rightPanelWidth: number; +} + +interface SessionPaneLayoutState { + activeKey: string; + modes: ReadonlyMap; + contentSide: ContentPaneSide; + /** A physical slot preference, independent of which content occupies it. */ + preferredRightPaneWidth: number; + activate: (surfaceId: string, sessionId: string | null) => void; + showContent: () => void; + hideContent: () => void; + collapseEmptyContent: () => void; + maximizeContent: () => void; + restoreSplit: () => void; + toggleContent: () => void; + toggleMaximized: () => void; + swapPanes: () => void; + resizeRightPane: (width: number) => void; + applyLegacyLayout: (layout: Partial) => void; + clearSessionMemory: () => void; +} + +const CONTENT_SIDE_KEY = 'openbitfun:sessionContentSide'; +const scopeKey = (surfaceId: string, sessionId: string | null) => surfaceScopedKey(surfaceId, sessionId); + +export const selectSessionPaneMode = (state: SessionPaneLayoutState): SessionPaneMode => ( + state.modes.get(state.activeKey) ?? 'chat-only' +); + +export function projectLegacySessionPaneLayout(state: SessionPaneLayoutState): LegacySessionPaneLayout { + const mode = selectSessionPaneMode(state); + return { + chatCollapsed: mode === 'content-only', + centerPanelCollapsed: false, + rightPanelCollapsed: mode === 'chat-only', + rightPanelWidth: state.preferredRightPaneWidth, + }; +} + +function localPreferenceStorage(): PreferenceStorage | undefined { + try { return typeof window === 'undefined' ? undefined : window.localStorage; } + catch { return undefined; } +} + +/** One owner for pane modes and local layout preferences; no runtime or document state. */ +export function createSessionPaneLayoutStore(storage = localPreferenceStorage()) { + const read = (key: string) => { + try { return storage?.getItem(key); } catch { return null; } + }; + const write = (key: string, value: string) => { + try { storage?.setItem(key, value); } catch { /* Layout remains usable without storage. */ } + }; + const savedWidth = Number(read(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH)); + const defaultWidth = typeof window !== 'undefined' + ? Math.max(540, Math.min(800, Math.floor(window.innerWidth * 0.35))) + : RIGHT_PANEL_CONFIG.COMFORTABLE_DEFAULT; + + return createStore((set, get) => { + const setMode = (mode: SessionPaneMode) => set(state => { + if (selectSessionPaneMode(state) === mode) return state; + const modes = new Map(state.modes); + modes.set(state.activeKey, mode); + return { modes }; + }); + const normalizeWidth = (width: number) => Math.min( + RIGHT_PANEL_CONFIG.MAX_WIDTH, Math.max(RIGHT_PANEL_CONFIG.COMPACT_WIDTH, width), + ); + return { + activeKey: scopeKey('local', null), + modes: new Map(), + contentSide: read(CONTENT_SIDE_KEY) === 'left' ? 'left' : 'right', + preferredRightPaneWidth: Number.isFinite(savedWidth) && savedWidth > 0 + ? normalizeWidth(savedWidth) : defaultWidth, + activate: (surfaceId, sessionId) => { + const activeKey = scopeKey(surfaceId, sessionId); + if (get().activeKey !== activeKey) set({ activeKey }); + }, + showContent: () => { + if (selectSessionPaneMode(get()) === 'chat-only') setMode('split'); + }, + hideContent: () => setMode('chat-only'), + collapseEmptyContent: () => { + if (selectSessionPaneMode(get()) === 'split') setMode('chat-only'); + }, + maximizeContent: () => setMode('content-only'), + restoreSplit: () => setMode('split'), + toggleContent: () => setMode(selectSessionPaneMode(get()) === 'chat-only' ? 'split' : 'chat-only'), + toggleMaximized: () => setMode(selectSessionPaneMode(get()) === 'content-only' ? 'split' : 'content-only'), + swapPanes: () => { + if (selectSessionPaneMode(get()) !== 'split') return; + const contentSide = get().contentSide === 'right' ? 'left' : 'right'; + write(CONTENT_SIDE_KEY, contentSide); + set({ contentSide }); + }, + resizeRightPane: width => { + if (!Number.isFinite(width) || width <= 0) return; + const preferredRightPaneWidth = normalizeWidth(width); + if (get().preferredRightPaneWidth === preferredRightPaneWidth) return; + write(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, String(preferredRightPaneWidth)); + set({ preferredRightPaneWidth }); + }, + applyLegacyLayout: layout => { + const state = get(); + let mode = selectSessionPaneMode(state); + if (layout.rightPanelCollapsed === true) mode = 'chat-only'; + else if (layout.chatCollapsed === true || layout.centerPanelCollapsed === true) mode = 'content-only'; + else { + if ((layout.chatCollapsed === false || layout.centerPanelCollapsed === false) && mode === 'content-only') mode = 'split'; + if (layout.rightPanelCollapsed === false && mode === 'chat-only') mode = 'split'; + } + const width = layout.rightPanelWidth; + const preferredRightPaneWidth = typeof width === 'number' && Number.isFinite(width) && width > 0 + ? normalizeWidth(width) : state.preferredRightPaneWidth; + if (mode === selectSessionPaneMode(state) && preferredRightPaneWidth === state.preferredRightPaneWidth) return; + const modes = new Map(state.modes); + modes.set(state.activeKey, mode); + if (preferredRightPaneWidth !== state.preferredRightPaneWidth) { + write(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, String(preferredRightPaneWidth)); + } + set({ modes, preferredRightPaneWidth }); + }, + clearSessionMemory: () => set({ activeKey: scopeKey('local', null), modes: new Map() }), + }; + }); +} + +export const sessionPaneLayoutStore = createSessionPaneLayoutStore(); +export type SessionPaneLayoutStore = ReturnType; + +export function useSessionPaneLayout() { + const mode = useStore(sessionPaneLayoutStore, selectSessionPaneMode); + const contentSide = useStore(sessionPaneLayoutStore, state => state.contentSide); + const preferredRightPaneWidth = useStore(sessionPaneLayoutStore, state => state.preferredRightPaneWidth); + const { showContent, hideContent, toggleContent, toggleMaximized, swapPanes, resizeRightPane } = sessionPaneLayoutStore.getState(); + return { mode, contentSide, preferredRightPaneWidth, showContent, hideContent, toggleContent, toggleMaximized, swapPanes, resizeRightPane }; +} diff --git a/src/web-ui/src/app/scenes/session/sessionPaneLayoutSync.ts b/src/web-ui/src/app/scenes/session/sessionPaneLayoutSync.ts new file mode 100644 index 0000000000..4c90c35ec0 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/sessionPaneLayoutSync.ts @@ -0,0 +1,14 @@ +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { getActiveSurfaceId, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { sessionPaneLayoutStore } from './sessionPaneLayoutStore'; + +/** Activate the view identity after the session/device owners commit their selection. */ +export function startSessionPaneLayoutSync(): () => void { + const sync = () => sessionPaneLayoutStore.getState().activate( + getActiveSurfaceId(), flowChatStore.getState().activeSessionId, + ); + sync(); + const stopSessions = flowChatStore.subscribe(sync); + const stopSurfaces = onSurfaceActivated(sync); + return () => { stopSessions(); stopSurfaces(); }; +} diff --git a/src/web-ui/src/app/scenes/session/sessionPanelLayout.ts b/src/web-ui/src/app/scenes/session/sessionPanelLayout.ts index 80b7f53564..859196f28e 100644 --- a/src/web-ui/src/app/scenes/session/sessionPanelLayout.ts +++ b/src/web-ui/src/app/scenes/session/sessionPanelLayout.ts @@ -1,29 +1,23 @@ import { appManager } from '../../services/AppManager'; -import { loadPanelWidth, RIGHT_PANEL_CONFIG, STORAGE_KEYS } from '../../layout/panelConfig'; +import { selectSessionPaneMode, sessionPaneLayoutStore } from './sessionPaneLayoutStore'; /** Explicit, synchronous layout writes remain safe when open requests repeat. */ export function expandSessionAuxPane(): void { - if (!appManager.getState().layout.rightPanelCollapsed) return; - - window.dispatchEvent(new CustomEvent('expand-right-panel-immediate', { - detail: { noAnimation: true }, - })); - appManager.updateLayout({ - rightPanelWidth: loadPanelWidth( - STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, - RIGHT_PANEL_CONFIG.COMFORTABLE_DEFAULT, - ), - rightPanelCollapsed: false, - }); + sessionPaneLayoutStore.getState().showContent(); } export function collapseSessionAuxPane(): void { - // In editor mode this pane is the main content surface. - if (appManager.getState().layout.chatCollapsed) return; - appManager.updateLayout({ rightPanelCollapsed: true }); + sessionPaneLayoutStore.getState().collapseEmptyContent(); +} + +/** An explicit hide also restores chat when the pane occupies the content area. */ +export function hideSessionAuxPane(): void { + sessionPaneLayoutStore.getState().hideContent(); } export function expandSessionBottomTerminalPane(height: number): void { + const panes = sessionPaneLayoutStore.getState(); + if (selectSessionPaneMode(panes) === 'content-only') panes.restoreSplit(); appManager.updateLayout({ bottomTerminalPanelHeight: height, bottomTerminalPanelCollapsed: false, diff --git a/src/web-ui/src/app/services/AppManager.test.ts b/src/web-ui/src/app/services/AppManager.test.ts index 53e768b0db..50e5f15910 100644 --- a/src/web-ui/src/app/services/AppManager.test.ts +++ b/src/web-ui/src/app/services/AppManager.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AppManager } from './AppManager'; +import { createSessionPaneLayoutStore, selectSessionPaneMode } from '../scenes/session/sessionPaneLayoutStore'; describe('AppManager layout updates', () => { it('does not emit layout changes when the requested layout is already current', () => { @@ -34,4 +35,24 @@ describe('AppManager layout updates', () => { }, }); }); + + it('projects pane state and normalizes legacy writes without emitting intermediate flags', () => { + const panes = createSessionPaneLayoutStore(); + const manager = new AppManager(panes); + const listener = vi.fn(); + manager.addEventListener(listener); + manager.updateLayout({ chatCollapsed: true, rightPanelWidth: 650 }); + expect(selectSessionPaneMode(panes.getState())).toBe('content-only'); + expect(listener).toHaveBeenCalledExactlyOnceWith({ type: 'layout:changed', payload: { + chatCollapsed: true, centerPanelCollapsed: false, rightPanelCollapsed: false, rightPanelWidth: 650, + } }); + listener.mockClear(); + panes.getState().hideContent(); + expect(manager.getState().layout).toMatchObject({ chatCollapsed: false, rightPanelCollapsed: true, rightPanelWidth: 650 }); + expect(listener).toHaveBeenCalledTimes(1); + manager.destroy(); + listener.mockClear(); + panes.getState().showContent(); + expect(listener).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/app/services/AppManager.ts b/src/web-ui/src/app/services/AppManager.ts index 21c0c569ec..1f7bb3b3f8 100644 --- a/src/web-ui/src/app/services/AppManager.ts +++ b/src/web-ui/src/app/services/AppManager.ts @@ -19,27 +19,41 @@ import { globalEventBus } from '../../infrastructure/event-bus'; import { createLogger } from '@/shared/utils/logger'; import { i18nService } from '@/infrastructure/i18n'; import { loadPanelWidth, savePanelWidth, STORAGE_KEYS } from '../layout/panelConfig'; +import { + createSessionPaneLayoutStore, + projectLegacySessionPaneLayout, + sessionPaneLayoutStore, + type LegacySessionPaneLayout, + type SessionPaneLayoutStore, +} from '../scenes/session/sessionPaneLayoutStore'; const log = createLogger('AppManager'); +type ShellLayout = Omit; +type ShellState = Omit & { layout: ShellLayout }; export class AppManager implements IAppManager { - private state: AppState; + private state: ShellState; + private readonly paneLayout: SessionPaneLayoutStore; + private readonly unsubscribePaneLayout: () => void; + private updatingLayout = false; private listeners = new Set<(event: AppEvent) => void>(); /** Coalesce rapid layout/state updates into one event per animation frame (reduces main-thread churn). */ private pendingStateNotifyRaf: number | null = null; - constructor() { + constructor(paneLayout = createSessionPaneLayoutStore()) { + this.paneLayout = paneLayout; // Clear legacy panel state data (run once) this.clearPersistedPanelState(); // Initialize state + const { chatCollapsed: _chat, centerPanelCollapsed: _center, rightPanelCollapsed: _content, + rightPanelWidth: _width, ...shellDefaults } = DEFAULT_LAYOUT_STATE; this.state = { layout: { - ...DEFAULT_LAYOUT_STATE, + ...shellDefaults, leftPanelWidth: typeof window !== 'undefined' && window.innerWidth > 0 ? Math.min(300, Math.floor(window.innerWidth * 0.15)) // Left 15%, max 300px : 280, - rightPanelWidth: loadPanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, DEFAULT_LAYOUT_STATE.rightPanelWidth), bottomTerminalPanelHeight: loadPanelWidth( STORAGE_KEYS.BOTTOM_TERMINAL_PANEL_LAST_HEIGHT, DEFAULT_LAYOUT_STATE.bottomTerminalPanelHeight @@ -53,6 +67,11 @@ export class AppManager implements IAppManager { isLoading: false, error: null }; + this.unsubscribePaneLayout = this.paneLayout.subscribe(() => { + if (this.updatingLayout) return; + this.notifyStateChange(); + this.emitEvent({ type: 'layout:changed', payload: projectLegacySessionPaneLayout(this.paneLayout.getState()) }); + }); // Set up event listeners this.setupEventListeners(); @@ -60,36 +79,41 @@ export class AppManager implements IAppManager { // State management getState(): AppState { - return { ...this.state }; + return { + ...this.state, + layout: { ...this.state.layout, ...projectLegacySessionPaneLayout(this.paneLayout.getState()) }, + }; } - private updateState(updates: Partial): void { + private updateState(updates: Partial): void { this.state = { ...this.state, ...updates }; this.notifyStateChange(); } updateLayout(layout: Partial): void { - const hasLayoutChange = Object.entries(layout).some(([key, value]) => ( - this.state.layout[key as keyof LayoutState] !== value - )); - if (!hasLayoutChange) { - return; - } - - if (typeof layout.rightPanelWidth === 'number') { - savePanelWidth(STORAGE_KEYS.RIGHT_PANEL_LAST_WIDTH, layout.rightPanelWidth); + const previous = this.getState().layout; + const { chatCollapsed, centerPanelCollapsed, rightPanelCollapsed, rightPanelWidth, ...shellLayout } = layout; + this.updatingLayout = true; + try { + this.paneLayout.getState().applyLegacyLayout({ chatCollapsed, centerPanelCollapsed, rightPanelCollapsed, rightPanelWidth }); + this.state = { ...this.state, layout: { ...this.state.layout, ...shellLayout } }; + } finally { + this.updatingLayout = false; } + const next = this.getState().layout; + if (!Object.keys(next).some(key => previous[key as keyof LayoutState] !== next[key as keyof LayoutState])) return; if (typeof layout.bottomTerminalPanelHeight === 'number') { savePanelWidth(STORAGE_KEYS.BOTTOM_TERMINAL_PANEL_LAST_HEIGHT, layout.bottomTerminalPanelHeight); } - const newLayout = { ...this.state.layout, ...layout }; - this.state = { ...this.state, layout: newLayout }; this.notifyStateChange(); - + const paneProjection = projectLegacySessionPaneLayout(this.paneLayout.getState()); + const paneChanged = Object.keys(paneProjection).some(key => ( + previous[key as keyof LegacySessionPaneLayout] !== next[key as keyof LegacySessionPaneLayout] + )); this.emitEvent({ type: 'layout:changed', - payload: layout + payload: paneChanged ? { ...layout, ...paneProjection } : layout }); } @@ -278,14 +302,11 @@ export class AppManager implements IAppManager { const newTab: TabInfo = { ...tab, id: tabId }; const updatedTabs = [...this.state.layout.rightPanelTabs, newTab]; - const newLayout = { - ...this.state.layout, + this.updateLayout({ rightPanelTabs: updatedTabs, rightPanelActiveTabId: tabId, rightPanelCollapsed: false // Auto-expand right panel - }; - - this.updateState({ layout: newLayout }); + }); this.emitEvent({ type: 'tab:opened', @@ -303,14 +324,11 @@ export class AppManager implements IAppManager { newActiveTabId = updatedTabs.length > 0 ? updatedTabs[updatedTabs.length - 1].id : null; } - const newLayout = { - ...this.state.layout, + this.updateLayout({ rightPanelTabs: updatedTabs, rightPanelActiveTabId: newActiveTabId, rightPanelCollapsed: updatedTabs.length === 0 // Collapse when no tabs remain - }; - - this.updateState({ layout: newLayout }); + }); this.emitEvent({ type: 'tab:closed', @@ -324,13 +342,10 @@ export class AppManager implements IAppManager { throw new Error(`Tab not found: ${tabId}`); } - const newLayout = { - ...this.state.layout, + this.updateLayout({ rightPanelActiveTabId: tabId, rightPanelCollapsed: false - }; - - this.updateState({ layout: newLayout }); + }); this.emitEvent({ type: 'tab:selected', @@ -367,7 +382,7 @@ export class AppManager implements IAppManager { private notifyStateChange(): void { if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - globalEventBus.emit('app:state:changed', this.state); + globalEventBus.emit('app:state:changed', this.getState()); return; } if (this.pendingStateNotifyRaf != null) { @@ -375,7 +390,7 @@ export class AppManager implements IAppManager { } this.pendingStateNotifyRaf = window.requestAnimationFrame(() => { this.pendingStateNotifyRaf = null; - globalEventBus.emit('app:state:changed', this.state); + globalEventBus.emit('app:state:changed', this.getState()); }); } @@ -440,9 +455,10 @@ export class AppManager implements IAppManager { // Cleanup resources destroy(): void { + this.unsubscribePaneLayout(); this.listeners.clear(); } } // Default application manager instance -export const appManager = new AppManager(); +export const appManager = new AppManager(sessionPaneLayoutStore); diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index 7fec6e9a2c..69122ed1fa 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -5,7 +5,7 @@ import { getActiveSurfaceId, isSurfaceChangedError } from '@/infrastructure/peer import { createLogger } from '@/shared/utils/logger'; import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; import { isSessionSceneId, type SessionSceneTarget } from '../components/SceneBar/types'; -import { startSessionAuxPaneMemory } from '../scenes/session/sessionAuxPaneMemory'; +import { startSessionPaneLayoutSync } from '../scenes/session/sessionPaneLayoutSync'; import { resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; const log = createLogger('SessionSceneLifecycle'); @@ -16,7 +16,7 @@ const log = createLogger('SessionSceneLifecycle'); */ export function startSessionSceneLifecycle(): () => void { const stopProjectionSync = startAutoSync(); - const stopAuxPaneMemory = startSessionAuxPaneMemory(); + const stopPaneLayoutSync = startSessionPaneLayoutSync(); const current = () => { const session = flowChatStore.getActiveSession(); return session ? resolveSessionSceneTarget( @@ -119,7 +119,7 @@ export function startSessionSceneLifecycle(): () => void { unsubscribeScenes(); unsubscribeWorkspaces(); stopNavigation(); - stopAuxPaneMemory(); + stopPaneLayoutSync(); stopProjectionSync(); }; } diff --git a/src/web-ui/src/flow_chat/_item-rhythm.scss b/src/web-ui/src/flow_chat/_item-rhythm.scss index ab0e60860b..e8951e6b17 100644 --- a/src/web-ui/src/flow_chat/_item-rhythm.scss +++ b/src/web-ui/src/flow_chat/_item-rhythm.scss @@ -1,26 +1,36 @@ // Composition owns inter-item space. Leaf views own only their content padding. // A model round retains its trailing gap across virtual rows; enclosed content // drops it so the enclosing section does not count the last gap twice. -@mixin apply($enclosed: false) { - > :is(.flow-text-block, .flow-thinking-item, .flowchat-flow-item, - .subagent-projection-text--truncated) { - margin: 0 0 var(--openbitfun-control-flow-chat-flow-item-gap) 0; +@mixin spacing($enclosed: false) { + // The desktop composition owns one paragraph gap for every presentation. + // Include the stack identity so shared compact/prominent-card rules cannot + // replace it, including the public last-child reset on a virtual member row. + &:is([data-flow-item-stack], .explore-region__content, .flow-group-content-segment):not([data-agent-card-grid]) + > :is(.flow-text-block, .flow-thinking-item, .flowchat-flow-item, + .subagent-projection-text--truncated):not([hidden]):not([data-thinking-attachment='side']) { + margin: 0 0 var(--openbitfun-control-flow-chat-paragraph-gap) 0; + + // A renderer with no content must not leave an empty paragraph behind. + &:has(> .flow-tool-card-wrapper:empty) { + margin-bottom: 0; + } + + @if $enclosed { + &:is(:last-child, :has(+ .flowchat-capsule-row-break:last-child)) { + margin-bottom: 0; + } + } } +} + +@mixin apply($enclosed: false) { + @include spacing($enclosed); // Side-docked reasoning occupies the next item's gutter, not a transcript row. > .flow-thinking-item[data-thinking-attachment='side'] { margin-bottom: 0; } - // Defensive fallback for a card renderer that returns no content. Known - // hidden items are removed before composition, including their wrappers. - > .flowchat-flow-item:has(> .flow-tool-card-wrapper:empty) { - margin-bottom: 0; - } - - // Public FlowItemRhythm.css owns compact adjacency, including side reasoning. - // The composition marks its root with data-flow-item-stack. - // Compact geometry is independent of concurrency; serial calls keep a row. > .flowchat-flow-item:has(> .flow-tool-card-wrapper[data-tool-capsule='true']) { inline-size: 100%; @@ -47,11 +57,4 @@ max-inline-size: calc(100% - var(--openbitfun-space-2)); margin-inline-end: var(--openbitfun-space-2); } - - @if $enclosed { - > :is(.flow-text-block, .flow-thinking-item, .flowchat-flow-item, - .subagent-projection-text--truncated):is(:last-child, :has(+ .flowchat-capsule-row-break:last-child)) { - margin-bottom: 0; - } - } } diff --git a/src/web-ui/src/flow_chat/components/CodePreview.tsx b/src/web-ui/src/flow_chat/components/CodePreview.tsx index a0329db64e..20b2630818 100644 --- a/src/web-ui/src/flow_chat/components/CodePreview.tsx +++ b/src/web-ui/src/flow_chat/components/CodePreview.tsx @@ -18,6 +18,7 @@ import { getLoadedPrismSyntaxHighlighter, loadPrismSyntaxHighlighter } from '@/s import { buildCodePreviewPrismStyle } from './codePreviewPrismTheme'; import { ScrollArea, type ScrollAreaEdgeFade, type ScrollbarVisibility } from '@openbitfun/ui'; import './CodePreview.scss'; +import { WindowedCodeContent } from './WindowedCodeContent'; export interface CodePreviewProps { /** Code content */ @@ -257,7 +258,11 @@ export const CodePreview: React.FC = memo(({ style={containerStyle} tabIndex={0} > - {SyntaxHighlighter ? ( + {!isStreaming && (displayContent.length > 8000 || countNewlines(displayContent) > 120) ? ( + + ) : SyntaxHighlighter ? ( = React.memo(({ data-openbitfun-attention={config.attention} data-openbitfun-presentation={config.presentation} > + = React.memo(({ onConfirm={handleConfirm} onReject={handleReject} /> + {interruptionNote && !cardHandlesInterruptionNote && (
{ - if (lines.length <= perSide * 2) return { text: lines.join('\n'), kept: lines.length, dropped: 0 }; - const head = lines.slice(0, perSide); - const tail = lines.slice(lines.length - perSide); - return { - text: [...head, '', `... truncated ${lines.length - perSide * 2} lines ...`, '', ...tail].join('\n'), - kept: perSide * 2, - dropped: lines.length - perSide * 2, - }; - }; - - const o = slice(origLines); - const m = slice(modLines); - return { - originalContent: o.text, - modifiedContent: m.text, - truncated: true, - omittedLines: o.dropped + m.dropped, - }; -} +const diffCache = new FlowChatResourceCache(8 * 1024 * 1024, 64); export interface InlineDiffPreviewProps { /** Original content. */ @@ -119,20 +68,6 @@ export interface InlineDiffPreviewProps { * Tokenize a full content string with prismjs, return per-line token arrays. * Falls back to plain-text lines when the language grammar is not registered. */ -function tokenizeContent(content: string, language: string): LineTokens[] { - if (!content) return []; - const grammar = Prism.languages[language]; - if (!grammar) { - // Graceful fallback: split by lines, no highlighting - return content.split('\n').map(line => [line]); - } - try { - const tokens = Prism.tokenize(content, grammar); - return splitTokensByNewlines(tokens); - } catch { - return content.split('\n').map(line => [line]); - } -} /** * Render a single Prism token as a React element. @@ -267,8 +202,13 @@ export const InlineDiffPreview: React.FC = memo(({ // Compute diff line list (fast, O(ND)) const diffLineList = useMemo(() => { try { + const key = JSON.stringify([truncated.originalContent, truncated.modifiedContent, contextLines]); + const cached = diffCache.get(key); + if (cached) return cached; const rawDiff = computeLineDiff(truncated.originalContent, truncated.modifiedContent); - return applyContextCollapsing(rawDiff, contextLines); + const lines = applyContextCollapsing(rawDiff, contextLines); + diffCache.set(key, lines, key.length * 2 + lines.reduce((bytes, line) => bytes + line.content.length * 2 + 80, 0)); + return lines; } catch (error) { log.error('Diff computation failed', error); return [{ type: 'context-separator' as const, content: 'Diff computation failed; file may be too large.' }]; @@ -285,14 +225,8 @@ export const InlineDiffPreview: React.FC = memo(({ String(Math.max(line.originalLineNumber ?? 0, line.modifiedLineNumber ?? 0)).length)), [diffLineList]); // Tokenize each content once — O(content_length), not O(lines²) - const originalLineTokens = useMemo( - () => tokenizeContent(truncated.originalContent, detectedLanguage), - [truncated.originalContent, detectedLanguage], - ); - const modifiedLineTokens = useMemo( - () => tokenizeContent(truncated.modifiedContent, detectedLanguage), - [truncated.modifiedContent, detectedLanguage], - ); + const originalLineTokens = useHighlightedLines(truncated.originalContent, detectedLanguage); + const modifiedLineTokens = useHighlightedLines(truncated.modifiedContent, detectedLanguage); // Line number → token array lookup helpers const getTokensForLine = useCallback( @@ -405,7 +339,7 @@ export const InlineDiffPreview: React.FC = memo(({ > {truncated.truncated && (
- Content too large; showing first and last portions ({truncated.omittedLines} lines omitted). + Content too large; showing first and last portions{truncated.omittedLines === null ? '.' : ` (${truncated.omittedLines} lines omitted).`}
)} ({ ready: false })); +vi.mock('../timeline/highlightResources', () => ({ useHighlightedLines: (content: string) => content.split('\n').map(line => + highlights.ready ? [{ type: 'keyword', content: line }] : [line]) })); + +it('keeps complete native text and a live range while token decorations change', () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host); + const content = `${Array.from({ length: 2000 }, (_, index) => `const line${index} = ${index};`).join('\n')}\n`; + const render = () => act(() => root.render( {}} />)); + try { + render(); + const source = host.querySelector('[data-code-source]')!; + expect(source.childNodes).toHaveLength(1); + expect(source.textContent).toBe(content); + expect(host.querySelectorAll('[data-code-line]').length).toBeLessThan(60); + const range = document.createRange(); range.selectNodeContents(source); + const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); + act(() => document.dispatchEvent(new Event('selectionchange'))); + expect(selection.toString()).toBe(content); + const text = source.firstChild; + highlights.ready = true; render(); + expect(source.firstChild).toBe(text); + expect(selection.toString()).toBe(content); + expect(host.querySelector('.code-preview__decorations')?.getAttribute('aria-hidden')).toBe('true'); + } finally { + window.getSelection()?.removeAllRanges(); act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); + } +}); diff --git a/src/web-ui/src/flow_chat/components/WindowedCodeContent.tsx b/src/web-ui/src/flow_chat/components/WindowedCodeContent.tsx new file mode 100644 index 0000000000..dae2363b62 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/WindowedCodeContent.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useHighlightedLines } from '../timeline/highlightResources'; +import { getInlineDiffTokenStyle } from './inlineDiffPrismTheme'; +import type { PrismToken } from './inlineDiffTokens'; + +function tokenNode(token: PrismToken, key: number): React.ReactNode { + if (typeof token === 'string') return token; + return {Array.isArray(token.content) + ? token.content.map((child, index) => tokenNode(child, index)) + : typeof token.content === 'string' ? token.content : tokenNode(token.content, 0)}; +} + +/** Keep one native text node for selection/copy/accessibility. Only the expensive + * syntax-token and line-number decorations are virtualized in the existing pane. */ +export function WindowedCodeContent({ content, language, scrollerRef, startingLineNumber, showLineNumbers, highlightedLine, onLineClick }: { + content: string; language: string; scrollerRef: RefObject; + startingLineNumber: number; showLineNumbers: boolean; highlightedLine: number | null; + onLineClick: (line: number) => void; +}) { + const lines = useHighlightedLines(content, language); + const plain = useMemo(() => content.split('\n'), [content]); + const [lineHeight, setLineHeight] = useState(22); + const rootRef = useRef(null); + const sourceRef = useRef(null); + const [selected, setSelected] = useState(false); + const virtualizer = useVirtualizer({ count: plain.length, getScrollElement: () => scrollerRef.current, + estimateSize: () => lineHeight, overscan: 5 }); + useEffect(() => { + const update = () => { + const selection = window.getSelection(); + setSelected(Boolean(sourceRef.current && selection?.rangeCount && !selection.isCollapsed + && selection.getRangeAt(0).intersectsNode(sourceRef.current))); + }; + document.addEventListener('selectionchange', update); + return () => document.removeEventListener('selectionchange', update); + }, []); + useLayoutEffect(() => { + const source = sourceRef.current; + if (!source) return; + const measure = () => { + const next = Number.parseFloat(getComputedStyle(source).lineHeight); + if (Number.isFinite(next) && next > 0) setLineHeight(next); + }; + const observer = new ResizeObserver(measure); + observer.observe(source); measure(); + return () => observer.disconnect(); + }, [scrollerRef]); + return
 {
+      if (!window.getSelection()?.isCollapsed) return;
+      const index = Math.floor((event.clientY - event.currentTarget.getBoundingClientRect().top) / lineHeight);
+      if (index >= 0 && index < plain.length) onLineClick(startingLineNumber + index);
+    }}>
+    {content}
+    
+  
; +} diff --git a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx index 41108ad8de..1dc3fb5992 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx @@ -182,7 +182,7 @@ describe('BtwVirtualSessionList', () => { }); followRef.current = true; render(items.slice(0, 20)); - const oldRowNodes: Array = []; + const renderedWindows: string[] = []; const deliveredSizes = new WeakMap>(); for (let frame = 0; frame < 40; frame++) { act(() => { @@ -209,15 +209,22 @@ describe('BtwVirtualSessionList', () => { frames.clear(); pending.forEach(callback => callback(frame * 16)); }); - if (frame >= 10) oldRowNodes.push(host.querySelector('[data-virtual-index="9"]')); + if (frame >= 10) renderedWindows.push([...host.querySelectorAll('[data-virtual-index]')] + .map(node => node.getAttribute('data-virtual-index')).join(',')); } - const identities = new Set(oldRowNodes.filter(node => node !== null)); + const window = host.firstElementChild as HTMLElement; + const measuredTotal = Number.parseFloat(window.style.paddingTop || '0') + + Number.parseFloat(window.style.paddingBottom || '0') + + [...host.querySelectorAll('[data-virtual-index]')].reduce((sum, row) => sum + row.offsetHeight, 0); if (contained) { - expect(oldRowNodes).not.toContain(null); - expect(identities.size).toBe(1); + expect(new Set(renderedWindows).size).toBe(1); + expect(host.querySelector('[data-virtual-index="19"]')).not.toBeNull(); + expect(scroller.scrollHeight - 15).toBe(measuredTotal); } else { - expect(oldRowNodes).toContain(null); - expect(identities.size).toBeGreaterThan(10); + // Adaptive overscan need not reproduce the old fixed-window oscillation, + // but escaped margins still violate the geometry used for tail placement. + expect(scroller.scrollHeight - 15 - measuredTotal).toBe(window.children.length * 8); + expect(scroller.scrollHeight - 15).toBeGreaterThan(measuredTotal); } }); }); diff --git a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx index a29351633e..1f23a7b2d9 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState, type RefObject, type MutableRefObject } from 'react'; +import { useEmbeddedTimelineFollow } from '../../timeline/useEmbeddedTimelineFollow'; +import { Fragment, useEffect, useRef, useState, type RefObject, type MutableRefObject } from 'react'; import type { VirtualItem } from '../../store/modernFlowChatStore'; import { VirtualItemRenderer } from '../modern/VirtualItemRenderer'; import { useFlowChatVirtualizer } from '../modern/useFlowChatVirtualizer'; @@ -13,6 +14,13 @@ import { FLOWCHAT_FOCUS_ITEM_EVENT, type FlowChatFocusItemRequest } from '../../ import { findExcerptTextRoot, highlightLocatedExcerpt } from '../../selection/locateConversationExcerpt'; import { resolveExcerptRange } from '../../selection/flowChatSelection'; import { resolveFlowChatFocusTarget } from '../modern/flowChatFocusTarget'; +import { useFlowChatContext } from '../modern/FlowChatContext'; +import { useConversationTimeline } from '../../timeline/useConversationTimeline'; +import { FlowChatReaderProvider } from '../../timeline/readerState'; +import { useTimelineInteraction } from '../../timeline/useTimelineInteraction'; +import { TimelineMutationBoundary } from '../../timeline/TimelineMutationBoundary'; +import { findTimelineBlockIndex } from '../../timeline/document'; +import { revealContainedRange } from '@openbitfun/flow-chat-presentation/scroll'; interface BtwVirtualSessionListProps { items: VirtualItem[]; @@ -28,9 +36,11 @@ interface BtwVirtualSessionListProps { /** The embedded transcript shares row placement, not the primary session shell. */ export function BtwVirtualSessionList({ - items, scrollerRef, headerRef, followRef, viewportOwner, + items: sourceItems, scrollerRef, headerRef, followRef, viewportOwner, exploreGroupStates, isHistorical, viewState, onExpandGroup, }: BtwVirtualSessionListProps) { + const { sessionId } = useFlowChatContext(); + const { items, reader } = useConversationTimeline(sourceItems, sessionId, 'side'); const windowRef = useRef(null); const [width, setWidth] = useState(0); useEffect(() => { @@ -42,7 +52,14 @@ export function BtwVirtualSessionList({ updateWidth(); return () => observer.disconnect(); }, [scrollerRef]); + const cancelAimRef = useRef<() => void>(() => {}); + const pinnedKeys = useTimelineInteraction(scrollerRef, reader, () => followRef.current, undefined, () => { + followRef.current = false; + if (viewState) viewState.followTail = false; + viewportOwner.release('follow-output'); cancelAimRef.current(); + }); const virtualizer = useFlowChatVirtualizer({ + pinnedKeys, items, scrollerRef, headerRef, @@ -59,15 +76,19 @@ export function BtwVirtualSessionList({ writeViewport: viewportOwner.write, shiftViewport: viewportOwner.shift, }); + cancelAimRef.current = virtualizer.cancelAim; useBtwPanelViewport(viewState, items, scrollerRef, windowRef, virtualizer, viewportOwner); - const navigationRef = useRef({ items, virtualizer, onExpandGroup }); - navigationRef.current = { items, virtualizer, onExpandGroup }; + const navigationRef = useRef({ items, sourceItems, virtualizer, onExpandGroup }); + navigationRef.current = { items, sourceItems, virtualizer, onExpandGroup }; useEffect(() => { const scroller = scrollerRef.current; if (!scroller) return; let generation = 0; let frame = 0; - const cancel = () => { generation++; cancelAnimationFrame(frame); navigationRef.current.virtualizer.cancelAim(); }; + const cancel = () => { + generation++; cancelAnimationFrame(frame); navigationRef.current.virtualizer.cancelAim(); + reader.set('navigation:thinking', ''); + }; const unsubscribe = globalEventBus.on(FLOWCHAT_FOCUS_ITEM_EVENT, request => { if (!request.embedded || !request.excerpt || request.sessionId !== scroller.dataset.flowchatSelectionRoot) return; cancel(); @@ -76,16 +97,20 @@ export function BtwVirtualSessionList({ if (request.surfaceEpoch !== scope.epoch) return; const excerpt = request.excerpt; const fragment = excerpt.fragments[0]; + reader.set('navigation:thinking', fragment.flowItemId ?? ''); const startedAt = performance.now(); - let materialized = false; + let materialized = -1; + let expandedGroup: string | undefined; followRef.current = false; if (viewState) viewState.followTail = false; const aim = () => { if (generation !== ownGeneration || !scope.isCurrent()) return; const current = navigationRef.current; const source = findExcerptTextRoot(excerpt); - const range = source && resolveExcerptRange(source, fragment); + const ready = source && !source.closest('[data-markdown-pending]') && !source.querySelector('[data-markdown-pending]'); + const range = ready && resolveExcerptRange(source, fragment); if (range) { + revealContainedRange(range, scroller); const bounds = scroller.getBoundingClientRect(); const rect = range.getBoundingClientRect(); current.virtualizer.cancelAim(); @@ -96,14 +121,22 @@ export function BtwVirtualSessionList({ highlightLocatedExcerpt(excerpt); return; } - if (!materialized) { - const target = resolveFlowChatFocusTarget(request, current.items); - const index = fragment.flowItemId ? target.resolvedVirtualIndex + { + const target = resolveFlowChatFocusTarget(request, current.sourceItems); + const index = fragment.flowItemId ? findTimelineBlockIndex(current.items, target.resolvedVirtualIndex ?? -1, fragment.flowItemId) : current.items.findIndex(item => item.type === 'user-message' && item.turnId === fragment.turnId); if (index === undefined || index < 0) { request.onUnavailable?.(); return; } - if (target.expandExploreGroupId) current.onExpandGroup?.(target.expandExploreGroupId); - current.virtualizer.scrollItemIntoView(index, { align: 'center', owner: 'one-shot-navigation' }); - materialized = true; + if (target.expandExploreGroupId && expandedGroup !== target.expandExploreGroupId) { + expandedGroup = target.expandExploreGroupId; + reader.set(`group:${expandedGroup}:query`, ''); + reader.set(`group:${expandedGroup}:tool`, 'all'); + reader.set(`group:${expandedGroup}:status`, 'all'); + current.onExpandGroup?.(target.expandExploreGroupId); + } + if (materialized !== index) { + current.virtualizer.scrollItemIntoView(index, { align: 'center', owner: 'one-shot-navigation' }); + materialized = index; + } } if (performance.now() - startedAt >= 2000) { request.onUnavailable?.(); return; } frame = requestAnimationFrame(aim); @@ -118,47 +151,33 @@ export function BtwVirtualSessionList({ cancel(); unsubscribe(); scroller.removeEventListener('wheel', cancel); scroller.removeEventListener('touchmove', cancel); scroller.removeEventListener('pointerdown', cancel); scroller.removeEventListener('keydown', cancel); }; - }, [scrollerRef, followRef, viewState]); + }, [scrollerRef, followRef, viewState, reader]); - // Estimated offscreen rows change the scroll range as they mount. Follow - // those measurements as well as streamed data, but recheck user intent in - // the frame itself so a queued update cannot undo an upward gesture. - useEffect(() => { - const scroller = scrollerRef.current; - const window = windowRef.current; - if (!scroller || !window) return; - let frame: number | undefined; - const follow = () => { - if (frame !== undefined) cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { - frame = undefined; - if (!followRef.current || scroller.clientHeight === 0) return; - viewportOwner.write({ owner: 'follow-output', topPx: scroller.scrollHeight, holdForMs: 0 }); - }); - }; - const observer = new ResizeObserver(follow); - observer.observe(window); - observer.observe(scroller); - follow(); - return () => { - observer.disconnect(); - if (frame !== undefined) cancelAnimationFrame(frame); - }; - }, [items, virtualizer.rows, scrollerRef, followRef, viewportOwner]); + useEmbeddedTimelineFollow({ scrollerRef, contentRef: windowRef, followRef, viewportOwner, + revision: items, cancelAim: virtualizer.cancelAim }); return ( + + !followRef.current && viewportOwner.canShift()} shift={viewportOwner.shift}>
- {virtualizer.rows.map(row => ( + {virtualizer.rows.map((row, rowIndex) => ( + + {rowIndex > 0 && row.startPx > virtualizer.rows[rowIndex - 1].endPx && + + + ); } diff --git a/src/web-ui/src/flow_chat/components/inlineDiffTruncation.test.ts b/src/web-ui/src/flow_chat/components/inlineDiffTruncation.test.ts new file mode 100644 index 0000000000..669dba6ab6 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/inlineDiffTruncation.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'vitest'; +import { truncateForDiff } from './inlineDiffTruncation'; + +it('bounds minified inputs while retaining both ends without claiming a false line count', () => { + const text = `HEAD${'x'.repeat(100_000)}TAIL`; + const preview = truncateForDiff(text, text); + expect(preview.originalContent.startsWith('HEAD')).toBe(true); + expect(preview.originalContent.endsWith('TAIL')).toBe(true); + expect(preview.originalContent.length + preview.modifiedContent.length).toBeLessThanOrEqual(50_000); + expect(preview.omittedLines).toBeNull(); +}); + +it('preserves exact small sources and reports whole-line omissions accurately', () => { + expect(truncateForDiff('a\n', 'b\n')).toMatchObject({ originalContent: 'a\n', modifiedContent: 'b\n', truncated: false }); + const text = Array.from({ length: 1000 }, (_, index) => String(index)).join('\n'); + expect(truncateForDiff(text, text)).toMatchObject({ truncated: true, omittedLines: 1500 }); +}); diff --git a/src/web-ui/src/flow_chat/components/inlineDiffTruncation.ts b/src/web-ui/src/flow_chat/components/inlineDiffTruncation.ts new file mode 100644 index 0000000000..b1fe60ba3b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/inlineDiffTruncation.ts @@ -0,0 +1,26 @@ +const MAX_LINES = 500; +const MAX_CHARS = 50_000; + +/** Bounded preview only; the owning card retains full source for the editor. */ +export function truncateForDiff(original: string, modified: string) { + const originalLines = original ? original.split('\n') : []; + const modifiedLines = modified ? modified.split('\n') : []; + if (originalLines.length + modifiedLines.length <= MAX_LINES && original.length + modified.length <= MAX_CHARS) { + return { originalContent: original, modifiedContent: modified, truncated: false, omittedLines: 0 as number | null }; + } + const slice = (lines: string[]) => { + const halfLines = MAX_LINES / 4; + const dropped = Math.max(0, lines.length - 2 * halfLines); + const text = dropped ? [...lines.slice(0, halfLines), '', `... truncated ${dropped} lines ...`, '', ...lines.slice(-halfLines)].join('\n') + : lines.join('\n'); + if (text.length <= MAX_CHARS / 2) return { text, dropped, partial: false }; + const marker = '\n…\n'; + const halfChars = Math.floor((MAX_CHARS / 2 - marker.length) / 2); + // Retain both ends even for a single minified line; never silently discard + // the tail a line-only budget deliberately retained. + return { text: text.slice(0, halfChars) + marker + text.slice(-halfChars), dropped, partial: true }; + }; + const a = slice(originalLines), b = slice(modifiedLines); + return { originalContent: a.text, modifiedContent: b.text, truncated: true, + omittedLines: a.partial || b.partial ? null : a.dropped + b.dropped }; +} diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index 9a146dfd61..dd9cfb56e7 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -1,3 +1,32 @@ +@use '../../item-rhythm' as itemRhythm; + +// Desktop collection boundaries use the same rhythm as body paragraphs. +.explore-region--collapsible { + margin-bottom: var(--openbitfun-control-flow-chat-paragraph-gap); +} + +// Whole groups and deferred segments retain the same spacing as flat members. +// Only a complete group's final member hands its trailing gap to the group. +.explore-region--collapsible:not(.flowchat-timeline-members) { + .explore-region__content { + @include itemRhythm.spacing($enclosed: true); + padding-block-start: var(--openbitfun-control-flow-chat-paragraph-gap); + } + + .flow-group-content-segment { + @include itemRhythm.spacing; + + &:last-child { + @include itemRhythm.spacing($enclosed: true); + } + } +} + +// The exposed page edges need space only while the file group is collected. +.explore-region[data-file-revisions='true'][data-expanded='true'] > [data-openbitfun-part='header'] { + padding-block-end: 0; +} + // ==================== Cross-ModelRound collapse ==================== // Reduce spacing for explore-only model rounds. diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts index 71c734443b..41d69b72e8 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts @@ -68,12 +68,12 @@ describe('FlowChat transcript rhythm', () => { expect(isAmbientToolRunContinuationAfter(control, read)).toBe(false); }); - it('spaces collapsed ambient tool runs like body paragraphs', () => { + it('spaces desktop content and tool runs like body paragraphs', () => { const toolStyles = readSource('../../_item-rhythm.scss'); const sharedRhythm = readSource('../../../../../../design-system/packages/ui/src/flow-chat/conversation/FlowItemRhythm.css'); expect(toolStyles).toContain( - 'margin: 0 0 var(--openbitfun-control-flow-chat-flow-item-gap) 0;', + 'margin: 0 0 var(--openbitfun-control-flow-chat-paragraph-gap) 0;', ); expect(toolStyles).not.toContain( 'margin: 0 0 var(--openbitfun-control-flow-chat-card-gap) 0;', @@ -193,7 +193,7 @@ describe('FlowChat transcript rhythm', () => { // The fallback also covers a lazy card returning no content. Known hidden // cards are filtered before composition using the leaf's visibility rule. expect(toolStyles).toMatch( - /> \.flowchat-flow-item:has\(> \.flow-tool-card-wrapper:empty\) \{\s*margin-bottom: 0;\s*\}/, + /&:has\(> \.flow-tool-card-wrapper:empty\) \{\s*margin-bottom: 0;\s*\}/, ); for (const hidingCard of [ '../../tool-cards/ReadFileDisplay.tsx', diff --git a/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx index 31f043f7f1..6f1e31121a 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx @@ -5,7 +5,7 @@ import { FlowGroup, type FlowGroupProps } from '@openbitfun/ui/flow-chat'; import { Icon } from '@openbitfun/ui'; -import React, { useMemo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useMemo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useRef } from 'react'; import { DeferredContent } from '@openbitfun/flow-chat-presentation/deferred-content'; import { useI18n } from '@/infrastructure/i18n'; @@ -30,12 +30,14 @@ import { useFlowChatContext, useFlowChatVolatileContext } from './FlowChatContex import { FileEditGroupView } from './FileEditGroupView'; import { buildFlowGroupRenderSegments, estimateFlowGroupItemsHeight } from './flowGroupRenderSegments'; import './ExploreRegion.scss'; +import { useFlowChatReaderValue } from '../../timeline/readerState'; export interface FlowGroupRendererProps { data: FlowGroupData; turnId: string; expandedThinkingItemIds?: readonly string[]; placement?: 'standalone' | 'inline'; + timelineExpanded?: boolean; } interface FlowGroupView { @@ -74,6 +76,7 @@ export const FlowGroupRenderer: React.FC = React.memo(({ turnId, expandedThinkingItemIds, placement, + timelineExpanded, }) => { const { t, formatNumber } = useI18n('flow-chat'); const category = getFlowGroupCategory(data); @@ -111,9 +114,9 @@ export const FlowGroupRenderer: React.FC = React.memo(({ const toolItemCount = useMemo(() => allItems.filter(item => item.type === 'tool').length, [allItems]); const filePath = category === 'file-edit' ? fileEditTarget(allItems.find(item => item.type === 'tool')) : undefined; const fileStatus = useMemo(() => fileEditGroupStatus(category === 'file-edit' ? allItems : []), [category, allItems]); - const [query, setQuery] = useState(''); - const [toolFilter, setToolFilter] = useState('all'); - const [statusFilter, setStatusFilter] = useState('all'); + const [query, setQuery] = useFlowChatReaderValue(`group:${groupId}:query`, ''); + const [toolFilter, setToolFilter] = useFlowChatReaderValue(`group:${groupId}:tool`, 'all'); + const [statusFilter, setStatusFilter] = useFlowChatReaderValue(`group:${groupId}:status`, 'all'); const deferredQuery = useDeferredValue(query); const toolLabels = useMemo(() => flowGroupToolLabels(t), [t]); const toolCounts = useMemo(() => flowGroupToolCounts(allItems), [allItems]); @@ -137,9 +140,9 @@ export const FlowGroupRenderer: React.FC = React.memo(({ && expandedToolCapsules?.has(toolCapsuleStateKey(sessionId, turnId, item.id))); const hasPendingPermission = allItems.some(item => item.type === 'tool' && pendingPermissionToolCallIds?.has((item as FlowToolItem).toolCall.id)); - const isExpanded = hasOpenCapsule || hasPendingPermission || defaultExpanded + const isExpanded = timelineExpanded ?? (hasPendingPermission || (disclosureChoice !== false && hasOpenCapsule) || defaultExpanded || (disclosureChoice === undefined && (fileStatus.failed > 0 || fileStatus.stopped > 0)) - || (disclosureChoice === undefined && revealGate.isAnyRevealing); + || (disclosureChoice === undefined && revealGate.isAnyRevealing)); // Once sealed work is complete, the parent owns the single closing motion. // Keep revealed thinking at its current height while the reveal gate drains. const retainForGroupCollapse = disclosureChoice !== true && !hasOpenCapsule && !hasPendingPermission @@ -150,8 +153,8 @@ export const FlowGroupRenderer: React.FC = React.memo(({ previousExpanded.current = isExpanded; }, [dispatchToolCardToggle, isExpanded]); useEffect(() => { - if (hasOpenCapsule && disclosureChoice !== true) onExpandGroup?.(groupId); - }, [disclosureChoice, groupId, hasOpenCapsule, onExpandGroup]); + if (timelineExpanded === undefined && hasOpenCapsule && disclosureChoice === undefined) onExpandGroup?.(groupId); + }, [disclosureChoice, groupId, hasOpenCapsule, onExpandGroup, timelineExpanded]); const summaryPresentation = useMemo( () => policy.summarize(allItems, t, formatNumber), [policy, allItems, t, formatNumber], @@ -164,7 +167,7 @@ export const FlowGroupRenderer: React.FC = React.memo(({ const resetFilters = useCallback(() => { setQuery(''); setToolFilter('all'); setStatusFilter('all'); - }, []); + }, [setQuery, setStatusFilter, setToolFilter]); const revealItem = useCallback((itemId: string) => { if (browse.visibleIds.has(itemId)) return false; resetFilters(); @@ -213,6 +216,7 @@ export const FlowGroupRenderer: React.FC = React.memo(({ onReset: resetFilters, }, }; + if (timelineExpanded !== undefined) return view.render({ ...groupProps, externalContent: true }, { items: allItems, sessionId }); const renderItem = (item: FlowItem, idx: number) => ( = React.memo(({ interface FlowGroupItemRendererProps { item: FlowItem; turnId: string; + roundId?: string; + withinGroup?: boolean; isLastItem?: boolean; forceThinkingExpanded?: boolean; revealStreamingContent?: boolean; @@ -268,7 +274,7 @@ interface FlowGroupItemRendererProps { Item: FlowGroupView['Item']; } -const FlowGroupItemRenderer = React.memo(({ item, turnId, isLastItem, forceThinkingExpanded, revealStreamingContent, retainForGroupCollapse, hidden, capsuleRow, Item }) => { +export const FlowGroupItemRenderer = React.memo(({ item, turnId, roundId, withinGroup = true, isLastItem, forceThinkingExpanded, revealStreamingContent, retainForGroupCollapse, hidden, capsuleRow, Item }) => { const { onToolConfirm, onToolReject, @@ -307,13 +313,16 @@ const FlowGroupItemRenderer = React.memo(({ item, tu
- {/* - Resident tail reservation of roughly one viewport. Its height tracks the - viewport and nothing else — it must never react to a measured content - change, or it becomes the compensation scheme this replaced. - */} + {/* Flex consumes/restores the latest Turn's trailing space without per-token writes. */}
scroller.getBoundingClientRect().left + scroller.clientWidth; + return isFlowChatScrollbarPress(event, scroller); } function isElementVisibleInScroller(element: HTMLElement, scroller: HTMLElement): boolean { @@ -400,12 +404,15 @@ const VirtualMessageListSession = forwardRef getNextVisibleVirtualItemIndexes(virtualItems), [virtualItems]); + const sourceItems = items ?? canonicalVirtualItems; const { exploreGroupStates, pendingPermissionToolCallIds } = useFlowChatVolatileContext(); const activeSession = useActiveSession(); const activeSessionState = useActiveSessionState(); const activeSessionId = activeSession?.sessionId ?? null; + const { items: virtualItems, reader } = useConversationTimeline(sourceItems, activeSessionId ?? undefined, 'main'); + const timelineItemsRef = useRef(virtualItems); + timelineItemsRef.current = virtualItems; + const nextVisibleItemIndexes = useMemo(() => getNextVisibleVirtualItemIndexes(virtualItems), [virtualItems]); /** * The newest Turn the session has, which is what "a new Turn" means. * @@ -421,7 +428,6 @@ const VirtualMessageListSession = forwardRef(null); if (viewportIdRef.current === null) { nextViewportInstanceId += 1; @@ -444,6 +450,8 @@ const VirtualMessageListSession = forwardRef(null); + const extentElementRef = useRef(null); + const [submissionKey, setSubmissionKey] = useState(null); const [scrollerElement, setScrollerElement] = useState(null); const [viewportHeightPx, setViewportHeightPx] = useState(0); const [viewportWidthPx, setViewportWidthPx] = useState(0); @@ -459,6 +467,11 @@ const VirtualMessageListSession = forwardRef ({ + wheel: new FlowChatScrollIntent(), touch: new FlowChatTouchIntent(), scrollbar: new FlowChatScrollIntent(), + }), []); const viewportSnapshotFrameRef = useRef(null); const lastSnapshotRestoreErrorPxRef = useRef(Number.POSITIVE_INFINITY); const onViewportSnapshotRef = useRef(onViewportSnapshot); @@ -494,7 +507,13 @@ const VirtualMessageListSession = forwardRef boolean>(() => false); + const isExpandingSearchSourceRef = useRef(false); + const readerInteractionRef = useRef<(kind: TimelineReaderInteraction) => void>(() => {}); + const interactionKeys = useTimelineInteraction(scrollerElementRef, reader, () => viewportOwner.currentOwner() === 'follow-output', undefined, kind => readerInteractionRef.current(kind)); + const pinnedKeys = useMemo(() => submissionKey + ? new Set([...interactionKeys, submissionKey]) : interactionKeys, [interactionKeys, submissionKey]); const virtualizer = useFlowChatVirtualizer({ + pinnedKeys, items: virtualItems, startAtTailOnMount: presentationMode !== 'history-window' && !shouldRestoreInitialSnapshot, reconcileOpeningMeasurement: () => reconcileOpeningMeasurementRef.current(), @@ -549,7 +568,6 @@ const VirtualMessageListSession = forwardRef { tailSpacerPxRef.current = tailSpacerPx; }, [tailSpacerPx]); - const getTailSpacerPx = useCallback(() => tailSpacerPxRef.current, []); /* * The paging diagnostics need a few session facts, but `activeSession` is a @@ -573,7 +591,6 @@ const VirtualMessageListSession = forwardRef { - const scroller = scrollerElementRef.current; - if (scroller) { - viewportOwner.write({ - owner: 'follow-output', - topPx: readContentEndScrollTop(scroller), - behavior, - }); - return; - } - scrollToContentEndThroughVirtualizer(behavior === 'smooth' ? 'smooth' : 'auto'); - }, [readContentEndScrollTop, scrollToContentEndThroughVirtualizer, viewportOwner]); - - const revealNewTurnTail = useCallback((turnId: string) => { - const targetIndex = virtualItems.findIndex(item => ( - item.turnId === turnId && item.type === 'user-message' - )); - if (targetIndex < 0) return false; - const scroller = scrollerElementRef.current; - if (!scroller) return false; - - // This placement is intentionally one-shot. Streaming growth then consumes - // the resident blank without a re-aim moving historical content upward. - virtualizer.measureRenderedItems(); - return viewportOwner.write({ - topPx: Math.max(0, scroller.scrollHeight - scroller.clientHeight), - behavior: 'auto', - owner: 'follow-output', - }); - }, [viewportOwner, virtualItems, virtualizer]); - - // Turn navigation still needs the rendered top offset independently of the - // new-Turn reveal, which targets the physical bottom only once. + const userMessageIndexes = useMemo(() => new Map(userMessageItems.map(({ item, index }) => [item.turnId, index])), [userMessageItems]); const resolveTurnTopScrollTop = useCallback((turnId: string) => { const scroller = scrollerElementRef.current; const element = getRenderedUserMessageElement(turnId); if (!scroller || !element) return null; - return Math.max( - 0, + return turnTopScrollTop( scroller.scrollTop + element.getBoundingClientRect().top - - scroller.getBoundingClientRect().top - - FLOWCHAT_TURN_TOP_GAP_PX, + - scroller.getBoundingClientRect().top, ); }, [getRenderedUserMessageElement]); + const tailTurnId = presentationMode === 'tail' ? virtualItems.at(-1)?.turnId : undefined; + const rebaseLeadingAnchorRef = useRef<() => void>(() => {}); + const { capture: captureLeadingExtent, refresh: refreshLeadingExtent, snapshot: snapshotLeadingExtent } = useFlowChatLeadingExtent({ + scope: JSON.stringify([activeSessionId, presentationMode, tailTurnId]), + items: virtualItems, virtualizer, scrollerRef: scrollerElementRef, extentRef: extentElementRef, + onAnchorRebased: () => rebaseLeadingAnchorRef.current(), + }); + const readLayoutTarget = useCallback(() => { + const scroller = scrollerElementRef.current; + const extent = extentElementRef.current; + if (!scroller || !extent) return 0; + const index = tailTurnId === undefined ? undefined : userMessageIndexes.get(tailTurnId); + const bounds = index === undefined ? null : virtualizer.getItemBounds(index); + // This is a property of the current Turn, not a consumable send receipt. + // CSS consumes/restores trailing flex space as cards grow/fold. Keeping the + // minimum in place prevents an intermediate browser clamp on collapse. + // Cached virtual bounds keep it current while its user row is unmounted, + // including prepends; a mounted row supplies exact normal-flow geometry. + const top = tailTurnId !== undefined && bounds + ? resolveTurnTopScrollTop(tailTurnId) ?? turnTopScrollTop(bounds.startPx) + : null; + refreshLeadingExtent(top); + // Reading only changes layout, never ownership or scroll position. Follow, + // jump and the native scrollbar share this actual reachable endpoint. + return readContentEndScrollTop(scroller); + }, [readContentEndScrollTop, refreshLeadingExtent, resolveTurnTopScrollTop, tailTurnId, userMessageIndexes, virtualizer]); + + const publishFollowOffset = useCallback((offset: number) => { + virtualizer.syncViewportOffset(offset); + // Opening estimates are still settling; do not turn those estimates into + // a persistent leading edge before the viewport has been revealed. + if (!isOpeningViewport()) captureLeadingExtent(offset); + }, [captureLeadingExtent, isOpeningViewport, virtualizer]); + useLayoutEffect(() => { + if (isOpenViewportSettled && scrollerElementRef.current) { + captureLeadingExtent(scrollerElementRef.current.scrollTop, true); + } + // Only on reveal. Layout changes resolve the existing anchor, never replace it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpenViewportSettled]); + + const placeSubmittedMessage = useCallback((intent: SubmittedMessageScrollIntent): number | null => { + const item = virtualItems.find(candidate => candidate.turnId === intent.turnId && candidate.type === 'user-message'); + const turn = activeSessionRef.current?.dialogTurns.find(candidate => candidate.id === intent.turnId); + if (!item || turn?.userMessage.id !== intent.messageId) return null; + const scroller = scrollerElementRef.current; + if (!scroller || !extentElementRef.current) return null; + if (!getRenderedUserMessageElement(intent.turnId)) { + // Materialize just this stable row before measuring it. Do not traverse + // history or mount a whole Turn to get a user-message anchor. + setSubmissionKey(getVirtualItemStableKey(item)); + return null; + } + virtualizer.cancelAim(); + virtualizer.measureRenderedItems(); + const top = resolveTurnTopScrollTop(intent.turnId); + if (top === null) return null; + extentElementRef.current.style.minHeight = `${top + scroller.clientHeight}px`; + if (!viewportOwner.write({ owner: 'follow-output', topPx: top })) return null; + virtualizer.syncViewportOffset(scroller.scrollTop); + captureLeadingExtent(scroller.scrollTop, true); + setSubmissionKey(null); + return scroller.scrollTop; + }, [captureLeadingExtent, getRenderedUserMessageElement, resolveTurnTopScrollTop, viewportOwner, virtualItems, virtualizer]); + const outputTurns = useMemo(() => new Set(virtualItems.filter(item => item.type !== 'user-message').map(item => item.turnId)), [virtualItems]); + const { isFollowingOutput, enterFollowOutput, @@ -666,19 +715,19 @@ const VirtualMessageListSession = forwardRef isViewportSuspendedRef.current, scrollerRef: scrollerElementRef, - getTailSpacerPx, - scrollToContentEnd, - revealNewTurnTail, + readLayoutTarget, + placeSubmittedMessage, + hasRenderedOutput: turnId => outputTurns.has(turnId), + cancelPendingPlacement: () => setSubmissionKey(null), + cancelNavigation: virtualizer.cancelAim, isOpeningViewport, - onOpeningOffset: virtualizer.syncViewportOffset, + onViewportOffset: publishFollowOffset, viewportOwner, viewportId, }); @@ -719,6 +768,7 @@ const VirtualMessageListSession = forwardRef { + const notifyUserScrollIntent = useCallback((direction?: SessionHistoryWindowDirection, interaction?: TimelineReaderInteraction) => { + const scroller = scrollerElementRef.current; + if (scroller && !isViewportSuspendedRef.current) captureLeadingExtent(scroller.scrollTop, true); if (direction) historyPager.readerIntent(direction); /* * The reader outranks everything, and the claim is what makes that true of @@ -1006,7 +1058,8 @@ const VirtualMessageListSession = forwardRef { + if (isExpandingSearchSourceRef.current) return; + traceViewportRepeating(`reader|interaction|${viewportId}|${kind}`, { + location: 'viewport.readerInteraction', message: 'Reader activated transcript content', + data: () => ({ kind, viewportId }), + }); + notifyUserScrollIntent(undefined, kind); + }; + const captureViewportSnapshot = useCallback((): FlowChatViewportSnapshot | null => { const scroller = scrollerElementRef.current; if (!scroller || !activeSessionId || !isUsableFlowChatViewportRect({ @@ -1534,6 +1597,7 @@ const VirtualMessageListSession = forwardRef { if (!scrollerElement) return; + const { wheel: wheelIntent, touch: touchIntent, scrollbar: scrollbarIntent } = nativeScrollIntent; const handleNativeScroll = () => { if (isViewportSuspendedRef.current) return; const position = viewportOwner.readReaderScrollPosition(); @@ -1545,8 +1609,10 @@ const VirtualMessageListSession = forwardRef { - notifyUserScrollIntent(event.deltaY < 0 ? 'before' : event.deltaY > 0 ? 'after' : undefined); + const direction = wheelIntent.wheel(event, scrollerElement.clientHeight); + if (direction) notifyUserScrollIntent(direction); }; - let touchY: number | null = null; - const handleTouchStart = (event: TouchEvent) => { touchY = event.touches?.[0]?.clientY ?? null; }; + const handleTouchStart = (event: TouchEvent) => { touchIntent.start(event); }; const handleTouchMove = (event: TouchEvent) => { - const y = event.touches?.[0]?.clientY ?? null; - const delta = y !== null && touchY !== null ? touchY - y : 0; - touchY = y; - notifyUserScrollIntent(delta < 0 ? 'before' : delta > 0 ? 'after' : undefined); + const direction = touchIntent.move(event); + if (direction) notifyUserScrollIntent(direction); }; + const handleTouchEnd = () => { touchIntent.end(); }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof Element && event.target.closest('input, textarea, select, [contenteditable="true"]')) return; - if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) { - const before = ['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey); - notifyUserScrollIntent(before ? 'before' : 'after'); - } + const direction = flowChatKeyScrollDirection(event); + if (direction) notifyUserScrollIntent(direction); }; /* * Arming rather than releasing outright: `scrollbar-gutter: stable` keeps @@ -1599,14 +1664,19 @@ const VirtualMessageListSession = forwardRef { isScrollbarPressRef.current = isScrollbarPress(event, scrollerElement); + scrollbarIntent.reset(); + if (isScrollbarPressRef.current) readerScrollPositionRef.current = viewportOwner.readReaderScrollPosition(); }; const handlePointerRelease = () => { isScrollbarPressRef.current = false; + scrollbarIntent.reset(); }; scrollerElement.addEventListener('scroll', handleNativeScroll, { passive: true }); scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); scrollerElement.addEventListener('touchstart', handleTouchStart, { passive: true }); scrollerElement.addEventListener('touchmove', handleTouchMove, { passive: true }); + scrollerElement.addEventListener('touchend', handleTouchEnd, { passive: true }); + scrollerElement.addEventListener('touchcancel', handleTouchEnd, { passive: true }); scrollerElement.addEventListener('keydown', handleKeyDown); scrollerElement.addEventListener('pointerdown', handlePointerDown, { passive: true }); // On the window: a drag can be released anywhere, including outside it. @@ -1617,14 +1687,18 @@ const VirtualMessageListSession = forwardRef= 0) virtualizer.scrollItemIntoView(blockIndex, { align: 'center', owner: 'one-shot-navigation' }); + return false; + } exitFollowOutput('scroll-to-index'); setNavigatedTurn( @@ -1996,7 +2076,7 @@ const VirtualMessageListSession = forwardRef { + const scrollToIndex = useCallback((sourceIndex: number) => { + const index = findTimelineBlockIndex(virtualItems, sourceIndex); if (index < 0 || index >= virtualItems.length) return; exitFollowOutput('scroll-to-index'); setNavigatedTurn(virtualItems[index].turnId); @@ -2089,16 +2170,22 @@ const VirtualMessageListSession = forwardRef { searchNavigationRequestIdRef.current += 1; + reader.set('navigation:thinking', ''); virtualizer.cancelAim(); - }, [virtualizer]); + }, [reader, virtualizer]); + + useEffect(() => () => reader.set('navigation:thinking', ''), [reader]); const scrollToSearchMatch = useCallback((target: FlowChatTextNavigationTarget) => { clearSearchMatch(); + if (target.flowItemId && target.expandableIds?.includes(target.flowItemId)) { + reader.set('navigation:thinking', target.flowItemId); + } exitFollowOutput('scroll-to-index'); - setNavigatedTurn(virtualItems[target.virtualItemIndex]?.turnId ?? null); + setNavigatedTurn(sourceItems[target.virtualItemIndex]?.turnId ?? null); const requestId = searchNavigationRequestIdRef.current; let attempts = 0; - let materializing = false; + let materializing: number | null = null; const traceSkipped = (reason: string) => traceViewport({ location: 'searchNavigation.skipped', message: 'search navigation kept the viewport at its reading position', @@ -2117,7 +2204,7 @@ const VirtualMessageListSession = forwardRef { if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) requestAnimationFrame(resolve); else { - if (materializing) virtualizer.cancelAim(); + if (materializing !== null) virtualizer.cancelAim(); traceSkipped(reason); target.onUnavailable?.(); } @@ -2127,16 +2214,26 @@ const VirtualMessageListSession = forwardRef('.virtual-item-wrapper'), - ).find(element => Number(element.dataset.virtualIndex) === target.virtualItemIndex); + ).find(element => Number(element.dataset.virtualIndex) === blockIndex); if (!wrapper) { // Coarse item alignment is only for materializing an unmounted row. // A mounted hit can be resolved before any painted placement. - if (!materializing) { - materializing = true; + if (blockIndex >= 0 && materializing !== blockIndex) { + materializing = blockIndex; viewportAnchor.reanchorAfterNavigation(); - virtualizer.scrollItemIntoView(target.virtualItemIndex, { + virtualizer.scrollItemIntoView(blockIndex, { align: 'center', owner: 'one-shot-navigation', holdForMs: ONE_SHOT_NAVIGATION_HOLD_MS, @@ -2152,11 +2249,13 @@ const VirtualMessageListSession = forwardRef( - expandable.hasAttribute('data-flow-group') - ? '[data-openbitfun-part="header"][role="button"]' - : '[data-testid="chat-thinking-toggle"]', - )?.click(); + if (expandable.hasAttribute('data-flow-group')) { + // Compatibility groups still own a local disclosure. This scoped + // activation belongs to navigation, not a new reader gesture. + isExpandingSearchSourceRef.current = true; + try { expandable.querySelector('[data-openbitfun-part="header"][role="button"]')?.click(); } + finally { isExpandingSearchSourceRef.current = false; } + } retry('source-not-expanded'); return; } @@ -2187,7 +2286,7 @@ const VirtualMessageListSession = forwardRef= readableTop && rangeRect.bottom <= readableBottom) { - if (materializing) virtualizer.cancelAim(); + if (materializing !== null) virtualizer.cancelAim(); traceSkipped('already-readable'); return; } @@ -2215,7 +2314,7 @@ const VirtualMessageListSession = forwardRef { const opening = isOpeningViewport(); @@ -2434,7 +2533,7 @@ const VirtualMessageListSession = forwardRef readerInteractionRef.current('selection'), clearSearchMatch, scrollToPhysicalBottom, scrollToTurnEnd, @@ -2450,7 +2549,6 @@ const VirtualMessageListSession = forwardRef + !isViewportSuspendedRef.current && !isViewportOwnedElsewhere()} shift={viewportOwner.shift} + snapshotAnchor={snapshotLeadingExtent} + onRepaired={viewportAnchor.captureAnchor}> +
- {virtualizer.rows.map(row => { + {virtualizer.rows.map((row, rowIndex) => { const item = virtualItems[row.index]; const nextItem = virtualItems[nextVisibleItemIndexes[row.index]]; return ( + + {rowIndex > 0 && row.startPx > virtualizer.rows[rowIndex - 1].endPx && + @@ -2587,6 +2695,7 @@ const VirtualMessageListSession = forwardRef +
+
+
); }); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatLeadingExtent.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatLeadingExtent.test.ts new file mode 100644 index 0000000000..d171011d11 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatLeadingExtent.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { leadingExtentFloor, type LeadingExtentAnchor } from './flowChatLeadingExtent'; + +describe('leading-edge collapse geometry', () => { + const anchor: LeadingExtentAnchor = { key: 'card', offsetPx: 24, heightPx: 900 }; + it('holds the visible header while everything after a closing card moves upward', () => { + const startPx = 2400; + const offsets = [900, 700, 450, 200, 40].map(height => { + const { floorPx } = leadingExtentFloor({ anchor, startPx, endPx: startPx + height, usesGroupHeader: false }); + return { header: startPx - floorPx, successor: startPx + height - floorPx }; + }); + expect(offsets.map(position => position.header)).toEqual([24, 24, 24, 24, 24]); + expect(offsets.map(position => position.successor)).toEqual([924, 724, 474, 224, 64]); + }); + it('does not bring a partly visible closing card down just because it fits the reading area', () => { + const interior = { ...anchor, offsetPx: -200 }; + expect(leadingExtentFloor({ anchor: interior, startPx: 2400, endPx: 2640, usesGroupHeader: false })) + .toEqual({ floorPx: 2600, offsetPx: -200 }); + // Only an entirely vanished reading position falls back to the compact top. + expect(leadingExtentFloor({ anchor: interior, startPx: 2400, endPx: 2440, usesGroupHeader: false })) + .toEqual({ floorPx: 2392, offsetPx: 8 }); + }); + it.each([[36, 36], [-500, 8]])('replaces a removed member with its group header at %i px', (before, after) => { + expect(leadingExtentFloor({ anchor: { ...anchor, groupHeaderOffsetPx: before }, + startPx: 1800, endPx: 1840, usesGroupHeader: true })) + .toEqual({ floorPx: 1800 - after, offsetPx: after }); + }); + it('follows semantic position changes above the anchor without accumulating removed heights', () => { + for (const startPx of [2400, 3400, 1600, 2400]) { + const { floorPx } = leadingExtentFloor({ anchor, startPx, endPx: startPx + 40, usesGroupHeader: false }); + expect(startPx - floorPx).toBe(24); + } + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatLeadingExtent.ts b/src/web-ui/src/flow_chat/components/modern/flowChatLeadingExtent.ts new file mode 100644 index 0000000000..ba9d299972 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatLeadingExtent.ts @@ -0,0 +1,35 @@ +import { FLOWCHAT_TURN_TOP_GAP_PX } from './flowChatTailFollow'; + +export interface LeadingExtentAnchor { + key: string; + offsetPx: number; + heightPx: number; + groupHeaderKey?: string; + groupHeaderOffsetPx?: number; +} + +/** + * Preserve the leading edge, not the shrinking trailing edge. This is one + * semantic anchor, never a sum of heights removed by previous collapses. + */ +export function leadingExtentFloor(input: { + anchor: LeadingExtentAnchor; + startPx: number; + endPx: number; + usesGroupHeader: boolean; +}): { floorPx: number; offsetPx: number } { + const { anchor, startPx, endPx, usesGroupHeader } = input; + const height = endPx - startPx; + // Do not move a still-visible shrinking card down the screen. Only recover + // its top when the part the reader was looking at has disappeared entirely. + const compactedAbove = height < anchor.heightPx - 0.5 + && height + anchor.offsetPx <= FLOWCHAT_TURN_TOP_GAP_PX + && anchor.offsetPx < FLOWCHAT_TURN_TOP_GAP_PX; + const offsetPx = usesGroupHeader + ? Math.max(FLOWCHAT_TURN_TOP_GAP_PX, anchor.groupHeaderOffsetPx ?? FLOWCHAT_TURN_TOP_GAP_PX) + : compactedAbove ? FLOWCHAT_TURN_TOP_GAP_PX : anchor.offsetPx; + return { + floorPx: Math.max(0, startPx - offsetPx), + offsetPx, + }; +} diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatScrollIntent.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatScrollIntent.test.ts new file mode 100644 index 0000000000..b74d4fab43 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatScrollIntent.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { FlowChatScrollIntent, FlowChatTouchIntent, flowChatKeyScrollDirection, isFlowChatScrollbarPress } from './flowChatScrollIntent'; + +describe('desktop scroll intent, independent of passive layout and device noise', () => { + it.each([ + {}, { deltaX: 40 }, { deltaX: 40, deltaY: -2 }, { deltaY: 100, ctrlKey: true }, + { deltaY: 100, shiftKey: true }, + ])('ignores non-vertical or non-scroll wheel input %j', init => { + expect(new FlowChatScrollIntent().wheel(new WheelEvent('wheel', init), 800)).toBeUndefined(); + }); + it('ignores a consumed wheel event', () => { + const event = new WheelEvent('wheel', { deltaY: -100, cancelable: true }); event.preventDefault(); + expect(new FlowChatScrollIntent().wheel(event, 800)).toBeUndefined(); + }); + it.each([0, 1, 2])('normalizes pixel, line and page wheel modes (%i)', deltaMode => { + const tracker = new FlowChatScrollIntent(); + expect(tracker.travel(0, Number.NaN, 0)).toBeUndefined(); + expect(tracker.wheel(new WheelEvent('wheel', { deltaY: deltaMode ? -1 : -10, deltaMode }), 800)).toBe('before'); + }); + it('accumulates fractional travel while cancelling oscillation and stale input', () => { + const tracker = new FlowChatScrollIntent(); + for (let i = 0; i < 50; i++) expect(tracker.travel(0, i % 2 ? -0.75 : 0.75, i * 10)).toBeUndefined(); + tracker.reset(); + for (let i = 0; i < 7; i++) expect(tracker.travel(0, -0.5, 1000 + i * 10)).toBeUndefined(); + expect(tracker.travel(0, -0.5, 1070)).toBe('before'); + for (let i = 0; i < 20; i++) expect(tracker.travel(0, 1, 2000 + i * 250)).toBeUndefined(); + expect(tracker.travel(0, 80, 9000)).toBe('after'); + }); + it('does not accumulate the small vertical component of horizontal gestures', () => { + const tracker = new FlowChatScrollIntent(); + for (let i = 0; i < 30; i++) expect(tracker.travel(1, 0.1, i * 10)).toBeUndefined(); + expect(tracker.travel(0, -20, 400)).toBe('before'); + }); + + const touch = (y: number, x = 0, id = 1) => ({ clientX: x, clientY: y, identifier: id }) as Touch; + const touchEvent = (touches: Touch[]) => new TouchEvent('touchmove', { touches }); + it('requires a single-finger vertical drag, not an unstarted move or contact jitter', () => { + const tracker = new FlowChatTouchIntent(); + expect(tracker.move(touchEvent([touch(100)]))).toBeUndefined(); + tracker.start(touchEvent([touch(100)])); + for (let i = 0; i < 30; i++) expect(tracker.move(touchEvent([touch(i % 2 ? 100 : 101)]))).toBeUndefined(); + expect(tracker.move(touchEvent([touch(85)]))).toBe('after'); + tracker.end(); + expect(tracker.move(touchEvent([touch(50)]))).toBeUndefined(); + }); + it('cancels multi-touch and finger replacement without inventing travel', () => { + const tracker = new FlowChatTouchIntent(); + tracker.start(touchEvent([touch(100)])); + expect(tracker.move(touchEvent([touch(50), touch(80, 0, 2)]))).toBeUndefined(); + expect(tracker.move(touchEvent([touch(30)]))).toBeUndefined(); + tracker.start(touchEvent([touch(100)])); + expect(tracker.move(touchEvent([touch(50, 0, 2)]))).toBeUndefined(); + }); + + function keyOn(target: HTMLElement, key: string, init: KeyboardEventInit = {}) { + let direction: ReturnType; + target.addEventListener('keydown', event => { direction = flowChatKeyScrollDirection(event); }, { once: true }); + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })); + return direction; + } + it.each(['input', 'select', 'textarea'])('does not reinterpret %s navigation as transcript scrolling', tag => { + const element = document.createElement(tag); + for (const key of ['ArrowUp', 'ArrowDown', 'Home', 'End', ' ']) expect(keyOn(element, key)).toBeUndefined(); + }); + it('keeps empty/plaintext contenteditable and ARIA widget keys with their owner', () => { + const editor = document.createElement('div'); editor.setAttribute('contenteditable', 'plaintext-only'); + expect(keyOn(editor, 'Home')).toBeUndefined(); + editor.setAttribute('contenteditable', ''); expect(keyOn(editor, 'End')).toBeUndefined(); + const list = document.createElement('div'); list.setAttribute('role', 'listbox'); + const option = document.createElement('div'); list.append(option); + expect(keyOn(option, 'ArrowUp')).toBeUndefined(); + }); + it('distinguishes button activation and composition from real transcript navigation', () => { + const button = document.createElement('button'); + expect(keyOn(button, ' ')).toBeUndefined(); + expect(keyOn(button, 'ArrowUp')).toBe('before'); + const body = document.createElement('div'); + expect(keyOn(body, ' ', { isComposing: true })).toBeUndefined(); + expect(keyOn(body, ' ')).toBe('after'); + expect(keyOn(body, ' ', { shiftKey: true })).toBe('before'); + expect(keyOn(body, 'Home', { ctrlKey: true })).toBe('before'); + }); + it('arms only the actual primary-button scrollbar gutter, not content or an overflowing child', () => { + const scroller = document.createElement('div'), child = document.createElement('div'); scroller.append(child); + Object.defineProperties(scroller, { clientWidth: { value: 600 }, clientLeft: { value: 0 } }); + scroller.getBoundingClientRect = () => new DOMRect(10, 20, 616, 800); + const press = (target: HTMLElement, clientX: number, button = 0, clientY = 200) => { + let result = false; + scroller.addEventListener('pointerdown', event => { result = isFlowChatScrollbarPress(event, scroller); }, { once: true }); + target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX, clientY, button })); + return result; + }; + expect(press(scroller, 615)).toBe(true); + expect(press(scroller, 610)).toBe(false); + expect(press(child, 615)).toBe(false); + expect(press(scroller, 615, 2)).toBe(false); + expect(press(scroller, 630)).toBe(false); + expect(press(scroller, 615, 0, 900)).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatScrollIntent.ts b/src/web-ui/src/flow_chat/components/modern/flowChatScrollIntent.ts new file mode 100644 index 0000000000..a095d006fc --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatScrollIntent.ts @@ -0,0 +1,71 @@ +export type FlowChatScrollDirection = 'before' | 'after'; + +// CSS pixels, not event counts: high-resolution devices can emit a deliberate +// gesture as many fractions. Signed travel cancels jitter instead of adding it. +const WHEEL_INTENT_PX = 4; +const TOUCH_INTENT_PX = 8; +const GESTURE_GAP_MS = 200; + +export class FlowChatScrollIntent { + private x = 0; + private y = 0; + private at = -Infinity; + + constructor(private readonly threshold = WHEEL_INTENT_PX) {} + + reset() { this.x = 0; this.y = 0; this.at = -Infinity; } + + travel(x: number, y: number, now: number): FlowChatScrollDirection | undefined { + if (!Number.isFinite(x) || !Number.isFinite(y)) { this.reset(); return; } + if (now - this.at > GESTURE_GAP_MS) this.reset(); + this.at = now; this.x += x; this.y += y; + if (Math.max(Math.abs(this.x), Math.abs(this.y)) < this.threshold) return; + const direction = Math.abs(this.y) > Math.abs(this.x) ? this.y < 0 ? 'before' : 'after' : undefined; + this.reset(); + return direction; + } + + wheel(event: WheelEvent, viewportHeight: number): FlowChatScrollDirection | undefined { + // Ctrl-wheel is zoom; Shift-wheel commonly maps a vertical wheel to a + // horizontal code scroller. Neither asks to browse this transcript. + if (event.defaultPrevented || event.ctrlKey || event.shiftKey) { this.reset(); return; } + const unit = event.deltaMode === 2 ? viewportHeight : event.deltaMode === 1 ? 16 : 1; + return this.travel(event.deltaX * unit, event.deltaY * unit, event.timeStamp); + } +} + +export class FlowChatTouchIntent { + private point: { id: number; x: number; y: number } | null = null; + private travel = new FlowChatScrollIntent(TOUCH_INTENT_PX); + + start(event: TouchEvent) { + this.end(); + const touch = event.touches.length === 1 ? event.touches[0] : null; + if (touch) this.point = { id: touch.identifier, x: touch.clientX, y: touch.clientY }; + } + end() { this.point = null; this.travel.reset(); } + move(event: TouchEvent): FlowChatScrollDirection | undefined { + const touch = event.touches.length === 1 ? event.touches[0] : null; + const previous = this.point; + if (event.defaultPrevented || !touch || !previous || previous.id !== touch.identifier) { this.end(); return; } + this.point = { id: touch.identifier, x: touch.clientX, y: touch.clientY }; + return this.travel.travel(previous.x - touch.clientX, previous.y - touch.clientY, event.timeStamp); + } +} + +export function flowChatKeyScrollDirection(event: KeyboardEvent): FlowChatScrollDirection | undefined { + if (event.defaultPrevented || event.isComposing) return; + const target = event.target instanceof Element ? event.target : null; + if (target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="menu"], [role="slider"], [role="spinbutton"], [role="tablist"], [role="radiogroup"]')) return; + if (event.key === ' ' && target?.closest('button, [role="button"], [role="checkbox"], [role="radio"], [role="switch"], summary')) return; + if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) return 'before'; + if (['ArrowDown', 'PageDown', 'End', ' '].includes(event.key)) return 'after'; +} + +export function isFlowChatScrollbarPress(event: PointerEvent, scroller: HTMLElement): boolean { + if (event.button !== 0 || event.target !== scroller) return false; + const rect = scroller.getBoundingClientRect(); + const contentRight = rect.left + scroller.clientLeft + scroller.clientWidth; + return event.clientX > contentRight && event.clientX <= rect.right + && event.clientY >= rect.top && event.clientY <= rect.bottom; +} diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts index 9d86815eb8..9590d317b8 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts @@ -11,6 +11,13 @@ import { } from './flowChatSearchDom'; describe('FlowChat search DOM navigation', () => { + it('waits for lazy reasoning to replace its provisional source before locating a hit', () => { + const root = document.createElement('div'); + root.innerHTML = '
needle
'; + expect(getFlowChatSearchTextRoot(root, 'thought')).toBeNull(); + root.querySelector('.thinking-markdown')!.innerHTML = '

needle

'; + expect(getFlowChatSearchTextRoot(root, 'thought')).toBe(root.querySelector('.thinking-markdown')); + }); it('finds a query split across Markdown text nodes', () => { const root = document.createElement('div'); root.innerHTML = '

Before keyword after

'; diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts index 4fb19811f9..750a1ee779 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts @@ -168,7 +168,10 @@ export function getFlowChatSearchTextRoot( const thinkingItem = findElementWithDataValue(wrapper, 'data-tool-card-id', flowItemId); const thinkingText = thinkingItem?.querySelector('.thinking-markdown'); - if (thinkingText) { + // The lazy reasoning renderer initially paints plain source. Its line + // positions differ from the parsed Markdown, so navigation must wait for + // that source to materialize before revealing an inner scroll range. + if (thinkingText && !thinkingText.querySelector('[data-markdown-pending="true"]')) { return thinkingText; } diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.test.ts index 26a4237d5a..d95372cad1 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.test.ts @@ -1,389 +1,62 @@ import { describe, expect, it } from 'vitest'; import { - contentEndScrollTop, - FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS, - FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX, - isTailBlankMeasurable, - isViewportAtTail, - nextTailFollowState, - resolveAnimatedJumpBehavior, - resolveTailDepartureCrossing, - shouldResumeFollowAfterDeparture, - tailHoldMaxGapPx, - tailSpacerPxForViewport, + contentEndScrollTop, readingLinePxForViewport, tailSpacerPxForViewport, + turnTopScrollTop, easeFollowOffset, isViewportAtTail, resolveAnimatedJumpBehavior, turnTopAlignmentEntersReservedBlank, - type TailFollowState, } from './flowChatTailFollow'; -const VIEWPORT = 800; -const BOTTOM_INSET = 168; -const SPACER = tailSpacerPxForViewport(VIEWPORT, BOTTOM_INSET); -const MAX_GAP = tailHoldMaxGapPx(VIEWPORT, SPACER); -const THRESHOLD = FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX; - -function holding(target: number): TailFollowState { - return { target }; -} - -describe('tailSpacerPxForViewport', () => { - it('caps the footer and spacer at three quarters of the viewport', () => { - const spacer = tailSpacerPxForViewport(VIEWPORT, BOTTOM_INSET); - expect(BOTTOM_INSET + spacer).toBe(Math.round(VIEWPORT * 0.75)); - expect(VIEWPORT - BOTTOM_INSET - spacer).toBe(200); - }); - - it('lets an expanded footer consume the reservation without making spacer negative', () => { - expect(tailSpacerPxForViewport(VIEWPORT, VIEWPORT - 80)).toBe(0); - }); - - it('stays well under a full viewport, so the scroll range does not end in blank', () => { - expect(tailSpacerPxForViewport(VIEWPORT, BOTTOM_INSET)).toBeLessThan(VIEWPORT); - }); - - it('reserves nothing before the scroller has been measured', () => { - expect(tailSpacerPxForViewport(0, BOTTOM_INSET)).toBe(0); - }); - - it('never lets the hold gap exceed the physical spacer', () => { - expect(tailHoldMaxGapPx(VIEWPORT, 120)).toBe(120); - expect(tailHoldMaxGapPx(VIEWPORT, SPACER)).toBe(SPACER); - }); -}); - -describe('turnTopAlignmentEntersReservedBlank', () => { - it('leaves a Turn with content below it top-aligned', () => { - expect(turnTopAlignmentEntersReservedBlank({ - turnTopScrollTop: 400, - contentEndScrollTop: 3000, - })).toBe(false); - }); - - it('clamps a Turn whose top lies past the end of real content', () => { - // Everything below it is the reserved blank, which follow-output holds for - // output that is arriving. Nothing arrives under a navigated Turn. - expect(turnTopAlignmentEntersReservedBlank({ - turnTopScrollTop: 3200, - contentEndScrollTop: 3000, - })).toBe(true); - }); - - it('asks nothing about which Turn it is', () => { - // The last Turn of a long transcript top-aligns like any other; short and - // long is the result of the comparison, not an input to it. - expect(turnTopAlignmentEntersReservedBlank({ - turnTopScrollTop: 3000 - VIEWPORT, - contentEndScrollTop: 3000, - })).toBe(false); - }); - - it('treats the content end itself as still on the transcript', () => { - expect(turnTopAlignmentEntersReservedBlank({ - turnTopScrollTop: 3000, - contentEndScrollTop: 3000, - })).toBe(false); - }); -}); - -describe('contentEndScrollTop', () => { - it('excludes the resident tail spacer from the tail target', () => { - expect(contentEndScrollTop({ - scrollHeight: 5000 + SPACER, - clientHeight: VIEWPORT, - tailSpacerPx: SPACER, - })).toBe(5000 - VIEWPORT); - }); - - it('clamps to the top when the transcript is shorter than the viewport', () => { - expect(contentEndScrollTop({ - scrollHeight: 300 + SPACER, - clientHeight: VIEWPORT, - tailSpacerPx: SPACER, - })).toBe(0); - }); -}); - -describe('nextTailFollowState hold-tail', () => { - it('follows content growth', () => { - const next = nextTailFollowState(holding(4000), { - desiredScrollTop: 4200, - maxGapPx: MAX_GAP, - }); - expect(next).toEqual({ target: 4200 }); - }); - - it('holds its offset when a collapse fits the tolerated gap', () => { - // A card above the live output collapses by 300px: the tail rises, but the - // viewport must not move or earlier content would visually drop by 300px. - const next = nextTailFollowState(holding(4000), { - desiredScrollTop: 3700, - maxGapPx: MAX_GAP, - }); - expect(next.target).toBe(4000); - }); - - it('gives ground only past the tolerated gap, and only by the excess', () => { - const next = nextTailFollowState(holding(4000), { - desiredScrollTop: 1000, - maxGapPx: MAX_GAP, - }); - expect(next.target).toBe(1000 + MAX_GAP); - }); - - it('never drops below the content-end target', () => { - const next = nextTailFollowState(holding(100), { - desiredScrollTop: 900, - maxGapPx: MAX_GAP, - }); - expect(next.target).toBe(900); - }); -}); - -describe('isViewportAtTail', () => { - const contentEnd = 4000; - - it('counts the content end itself', () => { - expect(isViewportAtTail({ - scrollTop: contentEnd, - contentEndScrollTop: contentEnd, - followTargetScrollTop: contentEnd, - thresholdPx: THRESHOLD, - })).toBe(true); - }); - - it('counts a reveal position owned by follow-output', () => { - expect(isViewportAtTail({ - scrollTop: 5000, - contentEndScrollTop: contentEnd, - followTargetScrollTop: 5000, - thresholdPx: THRESHOLD, - })).toBe(true); - }); - - it('excludes a viewport parked in the reserved blank', () => { - // The one-sided test this replaced reported "at the bottom" here, which hid - // the jump-to-latest affordance on a screen with nothing on it. - expect(isViewportAtTail({ - scrollTop: contentEnd + SPACER, - contentEndScrollTop: contentEnd, - followTargetScrollTop: contentEnd, - thresholdPx: THRESHOLD, - })).toBe(false); - }); - - it('excludes a viewport scrolled up into the transcript', () => { - expect(isViewportAtTail({ - scrollTop: 100, - contentEndScrollTop: contentEnd, - followTargetScrollTop: contentEnd, - thresholdPx: THRESHOLD, - })).toBe(false); - }); -}); - -describe('resolveAnimatedJumpBehavior', () => { - const budget = VIEWPORT * FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS; - - it('animates a jump the reader can follow', () => { - expect(resolveAnimatedJumpBehavior({ - fromPx: 4000, - targetPx: 4000 + VIEWPORT, - clientHeight: VIEWPORT, - })).toBe('smooth'); - }); - - it('animates a nearby follow target coming back into place', () => { - expect(resolveAnimatedJumpBehavior({ - fromPx: 12_000, - targetPx: 12_180, - clientHeight: VIEWPORT, - })).toBe('smooth'); - }); - - it('lands a jump from the head of a long transcript outright', () => { - /* - * The measurement the budget comes from: a jump issued for 8717px animated - * 5480 of them inside the yield and was finished by the follow loop in one - * 3290px write. Reading it as an animation is the mistake — what the reader - * saw was two thirds of a scroll and then a jump. - */ - expect(resolveAnimatedJumpBehavior({ - fromPx: 0, - targetPx: 8717, - clientHeight: VIEWPORT, - })).toBe('auto'); - }); - - it('measures the distance in viewports, not pixels', () => { - // The same travel, on a display tall enough to still show where it went. - const travelPx = VIEWPORT * FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS + 400; - expect(resolveAnimatedJumpBehavior({ - fromPx: 0, - targetPx: travelPx, - clientHeight: VIEWPORT, - })).toBe('auto'); - expect(resolveAnimatedJumpBehavior({ - fromPx: 0, - targetPx: travelPx, - clientHeight: travelPx, - })).toBe('smooth'); - }); - - it('takes the budget itself as near enough', () => { - expect(resolveAnimatedJumpBehavior({ - fromPx: 0, - targetPx: budget, - clientHeight: VIEWPORT, - })).toBe('smooth'); - expect(resolveAnimatedJumpBehavior({ - fromPx: 0, - targetPx: budget + 1, - clientHeight: VIEWPORT, - })).toBe('auto'); - }); - - it('judges the distance travelled, whichever way it goes', () => { - // A follow target can be above the viewport. Distance is absolute either way. - expect(resolveAnimatedJumpBehavior({ - fromPx: 9000, - targetPx: 9000 - budget - 1, - clientHeight: VIEWPORT, - })).toBe('auto'); - }); - - it('does not animate against a scroller that has not been measured', () => { - // No budget to scale and nothing on screen to follow. Every other reading - // of a zero height makes this a *short* jump, which is the wrong one. - expect(resolveAnimatedJumpBehavior({ - fromPx: 0, - targetPx: 0, - clientHeight: 0, - })).toBe('auto'); - }); -}); - -describe('resolveTailDepartureCrossing', () => { - it('keeps watching while the blank is still on screen', () => { - expect(resolveTailDepartureCrossing({ - blankPx: 180, - contentDeltaPx: 40, - scrollDeltaPx: 0, - })).toBe('watching'); - }); - - it('reads content rising to meet a stationary reader as the tail catching up', () => { - // The case the resume acts on: the reader has not moved, and the empty - // space they were left looking at has just been filled. - expect(resolveTailDepartureCrossing({ - blankPx: -12, - contentDeltaPx: 190, - scrollDeltaPx: 0, - })).toBe('content-caught-up'); - }); - - it('reads a reader climbing out past a still content end as reading history', () => { - expect(resolveTailDepartureCrossing({ - blankPx: -240, - contentDeltaPx: 0, - scrollDeltaPx: -420, - })).toBe('reader-left-blank'); - }); - - it('gives a crossing where both moved to whichever moved further', () => { - // Streaming does not stop because the reader scrolled, so both sides move - // between samples and the tie-break decides. Callers record the two deltas - // beside the verdict so this line can be revisited from the trail. - expect(resolveTailDepartureCrossing({ - blankPx: -5, - contentDeltaPx: 120, - scrollDeltaPx: -40, - })).toBe('content-caught-up'); - expect(resolveTailDepartureCrossing({ - blankPx: -5, - contentDeltaPx: 40, - scrollDeltaPx: -120, - })).toBe('reader-left-blank'); - }); - - it('counts the blank as gone the moment content reaches the bottom edge', () => { - // `blankPx` is `scrollTop - contentEnd`, so zero is the content end exactly - // on the viewport's bottom edge — no blank, and the departure is over. - expect(resolveTailDepartureCrossing({ - blankPx: 0, - contentDeltaPx: 30, - scrollDeltaPx: 0, - })).toBe('content-caught-up'); - expect(resolveTailDepartureCrossing({ - blankPx: 0.5, - contentDeltaPx: 30, - scrollDeltaPx: 0, - })).toBe('watching'); - }); - - it('does not read a shrinking transcript as the reader leaving', () => { - // A tool card collapsing moves the content end *down*, which cannot end a - // departure — and if it somehow coincides with a crossing, a negative - // content delta must not out-vote the reader. - expect(resolveTailDepartureCrossing({ - blankPx: -30, - contentDeltaPx: -200, - scrollDeltaPx: -60, - })).toBe('reader-left-blank'); - }); -}); - -describe('isTailBlankMeasurable', () => { - it('accepts a blank the reserved spacer can account for', () => { - expect(isTailBlankMeasurable({ blankPx: SPACER, tailSpacerPx: SPACER })).toBe(true); - expect(isTailBlankMeasurable({ blankPx: -400, tailSpacerPx: SPACER })).toBe(true); - }); - - it('refuses a blank larger than the whole spacer, which cannot have been seen', () => { - /* - * `scrollTop` is clamped to `scrollHeight - clientHeight`, so a settled - * viewport is never more than the spacer past the content end. Measured on - * a session that paged its whole history on the first scroll up: the - * prepend shifted the viewport 14252px to hold the reader's Turn still, - * against a content end still reading 989 — a blank of 6239px in a - * transcript reserving a few hundred. - */ - expect(isTailBlankMeasurable({ blankPx: 6239, tailSpacerPx: SPACER })).toBe(false); - }); -}); - -describe('shouldResumeFollowAfterDeparture', () => { - it('takes the viewport back when output caught up with a reader who had stopped', () => { - expect(shouldResumeFollowAfterDeparture({ - crossing: 'content-caught-up', - gestureLive: false, - })).toBe(true); - }); - - it('leaves a reader who is still scrolling alone, whatever the geometry says', () => { - /* - * Measured, and the reason the gesture is an input at all: 320ms into a - * live gesture the reader had climbed 200px while content grew 237, so the - * tie-break called the crossing for the content — correctly — and acting on - * it would have taken the viewport back mid-scroll. - */ - expect(shouldResumeFollowAfterDeparture({ - crossing: 'content-caught-up', - gestureLive: true, - })).toBe(false); - }); - - it('never resumes for a reader who climbed out past the content end', () => { - // Reading history is the case the whole departure exists to tell apart, and - // a lapsed gesture claim does not make it something else. - expect(shouldResumeFollowAfterDeparture({ - crossing: 'reader-left-blank', - gestureLive: false, - })).toBe(false); - }); - - it('decides nothing while the departure is still open', () => { - expect(shouldResumeFollowAfterDeparture({ - crossing: 'watching', - gestureLive: false, - })).toBe(false); +describe('desktop reading geometry', () => { + it.each([[800, 160], [500, 100], [1100, 240]])('uses one physical endpoint at %i px with %i px of composer clearance', (height, footer) => { + const line = readingLinePxForViewport(height, footer); + const tail = tailSpacerPxForViewport(height, footer); + const contentEnd = 3000; + const physicalEnd = contentEndScrollTop({ scrollHeight: contentEnd + footer + tail, clientHeight: height }); + expect(contentEnd - physicalEnd).toBeCloseTo(line, 6); + expect(line).toBe(8 + (height - footer - 8) * 0.618); + expect(isViewportAtTail({ scrollTop: physicalEnd, contentEndScrollTop: physicalEnd, followTargetScrollTop: physicalEnd, thresholdPx: 2 })).toBe(true); + }); + it('keeps the latest Turn top reachable before and after a tall card collapses', () => { + const height = 800, footer = 160, turnStart = 2008; + const floor = turnTopScrollTop(turnStart); + const line = readingLinePxForViewport(height, footer); + for (const turnHeight of [40, 200, 700, 1200, 120, 300, 900, 80]) { + // CSS min-height supplies the floor; natural content plus the ordinary + // tail supplies the other bound. Neither discards the Turn's identity. + const scrollHeight = Math.max(floor + height, + turnStart + turnHeight + footer + tailSpacerPxForViewport(height, footer)); + const end = contentEndScrollTop({ scrollHeight, clientHeight: height }); + if (turnHeight <= line - 8) expect(turnStart - end).toBe(8); + else expect(turnStart + turnHeight - end).toBeCloseTo(line, 6); + } + expect(turnTopScrollTop(8)).toBe(0); + expect(turnTopScrollTop(0)).toBe(0); + }); + it('never produces negative spacer or scroll ranges for small windows', () => { + expect(tailSpacerPxForViewport(0, 160)).toBe(0); + expect(tailSpacerPxForViewport(100, 160)).toBe(0); + expect(contentEndScrollTop({ scrollHeight: 100, clientHeight: 800 })).toBe(0); + }); + it('clamps ordinary turn navigation only beyond the physical end', () => { + expect(turnTopAlignmentEntersReservedBlank({ turnTopScrollTop: 100, contentEndScrollTop: 200 })).toBe(false); + expect(turnTopAlignmentEntersReservedBlank({ turnTopScrollTop: 250, contentEndScrollTop: 200 })).toBe(true); + }); +}); + +describe('desktop follow motion', () => { + it('has the same time response at 60 and 120 Hz without overshoot', () => { + const advance = (hz: number) => { + let offset = 0; + for (let i = 0; i < hz / 10; i++) offset = easeFollowOffset(offset, 1000, 1000 / hz); + return offset; + }; + expect(advance(60)).toBeCloseTo(advance(120), 8); + expect(advance(60)).toBeLessThan(1000); + expect(easeFollowOffset(1000, 500, 16)).toBeGreaterThan(500); + }); + it('settles subpixels and skips untrackable long navigation', () => { + expect(easeFollowOffset(499.4, 500, 16)).toBe(500); + expect(resolveAnimatedJumpBehavior({ fromPx: 0, targetPx: 1000, clientHeight: 500 })).toBe('smooth'); + expect(resolveAnimatedJumpBehavior({ fromPx: 0, targetPx: 5000, clientHeight: 500 })).toBe('auto'); + expect(resolveAnimatedJumpBehavior({ fromPx: 0, targetPx: 1, clientHeight: 0 })).toBe('auto'); }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts b/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts index fba393a727..f4c6c1c9c3 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts @@ -1,334 +1,52 @@ -/** - * FlowChat tail-follow geometry. - * - * The transcript keeps a resident tail spacer below the real content, sized - * from the viewport and the current input-stack inset. The spacer is static: it - * never reacts to a measured content change, so it is a reservation rather than - * a compensation. - * - * The spacer alone does not stabilise the viewport. It only removes the - * browser's forced `scrollTop` clamp when content shrinks; the follow target - * below is what then chooses to stay where it is instead of dragging earlier - * content down. Both halves are required. - */ - -/** Blank tail tolerated below the live output, as a share of the viewport. */ -export const FLOWCHAT_TAIL_HOLD_GAP_RATIO = 0.6; - -/** Maximum combined height of the input footer and resident tail spacer. */ -export const FLOWCHAT_TAIL_RESERVATION_RATIO = 0.75; - -/** - * Distance from an owned offset still treated as being on it. - * - * One constant deliberately serves both "the viewport is at the end" and "the - * viewport has come to rest too far below the end". They are the two sides of - * the same band; separate tolerances would leave a seam where the jump-to-latest - * affordance is shown but nothing snaps back, or the reverse. - */ -export const FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX = 50; - -export interface TailFollowState { - /** Scroll offset the viewport currently owns. */ - target: number; -} - -export interface TailFollowGeometry { - scrollHeight: number; - clientHeight: number; - tailSpacerPx: number; -} - -export interface TailFollowInput { - /** Offset placing the end of real content at the viewport bottom. */ - desiredScrollTop: number; - /** Largest blank tail the hold rule accepts before it gives ground. */ - maxGapPx: number; -} - -/** - * Breathing gap held above a Turn whose user message is aligned to the viewport - * top. - * - * The first Turn gets this for free: `.message-list-header` sits above it in - * the scroll content, so a transcript resting at `scrollTop: 0` already shows - * the gap. Every other Turn is reached by a scroll that lands its message flat - * on the top edge, which read as a different alignment rather than the same one - * — so top-aligning aims at `top - this` instead, and the header renders at - * exactly this height so the two cannot drift apart. - */ +/** Desktop reading geometry. The physical end and the follow target are one offset. */ export const FLOWCHAT_TURN_TOP_GAP_PX = 8; +export const FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX = 2; +export const FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS = 3; +export const FLOWCHAT_READING_LINE_RATIO = 0.618; -/** - * Height of the resident tail spacer. - * - * A new Turn is revealed by scrolling to the physical bottom once, then - * allowing streamed output to consume this blank without further writes. The - * footer and spacer together are capped at three quarters of the viewport, so - * the reveal always leaves at least one quarter of the transcript visible. - * `bottomInsetPx` is layout input only; no content measurement feeds this size. - */ -export function tailSpacerPxForViewport( - clientHeight: number, - bottomInsetPx: number, -): number { - return Math.max( - 0, - Math.round(clientHeight * FLOWCHAT_TAIL_RESERVATION_RATIO - bottomInsetPx), - ); -} - -/** - * Scroll offset that places the end of real content at the viewport bottom. - * The tail spacer sits below that point and is deliberately excluded. - */ -export function contentEndScrollTop(geometry: TailFollowGeometry): number { - return Math.max( - 0, - geometry.scrollHeight - geometry.tailSpacerPx - geometry.clientHeight, - ); -} - -export function tailHoldMaxGapPx(clientHeight: number, tailSpacerPx: number): number { - return Math.max( - 0, - Math.min(Math.round(clientHeight * FLOWCHAT_TAIL_HOLD_GAP_RATIO), tailSpacerPx), - ); +/** Slightly below center, within the readable area above composer clearance. */ +export function readingLinePxForViewport(clientHeight: number, bottomInsetPx: number): number { + const top = Math.min(FLOWCHAT_TURN_TOP_GAP_PX, clientHeight); + return top + Math.max(0, clientHeight - bottomInsetPx - top) * FLOWCHAT_READING_LINE_RATIO; } -export interface TurnTopAlignmentInput { - /** Offset that would place the Turn's user message at the viewport top. */ - turnTopScrollTop: number; - contentEndScrollTop: number; +/** The footer already provides part of the blank below the reading line. */ +export function tailSpacerPxForViewport(clientHeight: number, bottomInsetPx: number): number { + return Math.max(0, clientHeight - readingLinePxForViewport(clientHeight, bottomInsetPx) - bottomInsetPx); } -/** - * Whether top-aligning a Turn would park the viewport in the reserved blank. - * - * The blank belongs to follow-output's new-Turn reveal, and nothing is on its - * way under a Turn the user navigated to - * — so a navigation that lands there shows a screen of nothing, and the snap - * back then reclaims it as a second, visible movement. - * - * A clamp at the content end is the whole rule. There is no "is this the last - * Turn" test and no measurement of what lies below it: a Turn with a viewport - * of content under it has its top above the content end already, so this - * returns false and the alignment stands. Before the resident spacer the - * browser did exactly this for free, by clamping at the end of the scroll - * range; reserving the blank is what removed the clamp. - */ -export function turnTopAlignmentEntersReservedBlank( - input: TurnTopAlignmentInput, -): boolean { - return input.turnTopScrollTop > input.contentEndScrollTop; +export function contentEndScrollTop(geometry: { + scrollHeight: number; clientHeight: number; +}): number { + return Math.max(0, geometry.scrollHeight - geometry.clientHeight); } -/** - * Resolve the next follow target. - * - * `hold-tail` never moves backwards for free. It keeps its previous offset when - * content shrinks, which is what leaves earlier content visually still, and - * only gives ground once the blank below the live output exceeds `maxGapPx`. - */ -export function nextTailFollowState( - previous: TailFollowState, - input: TailFollowInput, -): TailFollowState { - return { - target: Math.max( - input.desiredScrollTop, - Math.min(previous.target, input.desiredScrollTop + input.maxGapPx), - ), - }; +/** Keep the latest Turn top reachable even after a tall card folds away. */ +export function turnTopScrollTop(turnStartPx: number): number { + return Math.max(0, turnStartPx - FLOWCHAT_TURN_TOP_GAP_PX); } -export type TailDepartureCrossing = - /** The blank is still on screen; nothing has been crossed. */ - | 'watching' - /** Output grew until the blank was gone under a reader who stayed put. */ - | 'content-caught-up' - /** The reader scrolled up past the end of content. Reading history. */ - | 'reader-left-blank'; - -/** - * Where a reader stands relative to the reserved blank, and who moved them - * there. - * - * Losing the follow permanently is right only for a reader who left the live - * region, and `scrollTop > contentEnd` says they have not. The reserved blank - * is up to `tailHoldMaxGapPx` under `hold-tail`, while a new-Turn reveal starts - * at the physical bottom. A small scroll up can therefore leave the reader - * looking at empty space below the newest output — nothing hidden from them yet - * — until output grows past the bottom edge and they silently stop seeing it. - * - * The reveal position sits inside the blank after a new Turn arrives, but - * this watch deliberately uses the real content end: it only observes output - * catching up with a reader, never a user's resting position. - * - * `watching` is a *position*, not a pending outcome: the blank is on screen - * right now. It says nothing about whether the reader has ever left it, and the - * caller is the one that remembers — the transition from `watching` to either - * verdict is the event, and a reader who has been above the content end for - * minutes reports `reader-left-blank` on every sample without anything having - * happened. - * - * The two ways out are told apart by which side moved. Content rising to meet a - * stationary reader is the case `shouldResumeFollowAfterDeparture` acts on; a - * reader climbing out past a stationary content end is the case it must leave - * alone. When both moved, the larger one is the cause — and callers record the - * raw deltas beside the verdict, so the tie-break can be revisited from the - * trail. - */ -export function resolveTailDepartureCrossing(input: { - /** `scrollTop - contentEndScrollTop` now. Positive while blank is on screen. */ - blankPx: number; - /** How far the content end rose since the previous sample. */ - contentDeltaPx: number; - /** How far the reader moved the viewport since the previous sample. */ - scrollDeltaPx: number; -}): TailDepartureCrossing { - if (input.blankPx > 0) { - return 'watching'; - } - return input.contentDeltaPx >= Math.abs(input.scrollDeltaPx) - ? 'content-caught-up' - : 'reader-left-blank'; -} - -/** - * Whether a departure that has just ended hands the viewport back to follow. - * - * `content-caught-up` is the case the whole rule exists for, but it is not - * sufficient on its own. Streaming does not stop because the reader scrolled, - * so a reader still working the wheel can cross the same line by scrolling into - * output that is rising to meet them — and resuming there takes the viewport - * out of their hands while their hand is still on it. - * - * Measured over one session's twenty departures: nine left blank on screen, and - * exactly two of those ended `content-caught-up`. One was a reader who had - * stopped 1.6s earlier and was overtaken by output, which is the case to act - * on. The other was 320ms into a live gesture, with the reader climbing 200px - * while content grew 237 — the tie-break called it for the content, correctly, - * and acting on it would have yanked the viewport back mid-scroll. The register - * separates the two cleanly, because a wheel claim lapses - * `USER_DRIVEN_SCROLL_WINDOW_MS` after the last notch and notches arrive faster - * than that. - * - * A refusal here defers the crossing rather than settling it. The caller keeps - * its "blank was visible" latch, so the same transition is judged again on the - * next sample: a reader who carries on climbing out-moves the content and is - * let go by a verdict that never needed the veto, and one who has stopped is - * followed as soon as the claim lapses. Nothing has to be given up to stay off - * a live gesture — only postponed by one sample. - */ -export function shouldResumeFollowAfterDeparture(input: { - crossing: TailDepartureCrossing; - /** Whether the reader's own claim on the viewport is still live. */ - gestureLive: boolean; +export function turnTopAlignmentEntersReservedBlank(input: { + turnTopScrollTop: number; contentEndScrollTop: number; }): boolean { - return input.crossing === 'content-caught-up' && !input.gestureLive; + return input.turnTopScrollTop > input.contentEndScrollTop; } -/** - * Whether the blank a sample just measured can be real. - * - * `scrollTop` is clamped to `scrollHeight - clientHeight`, and the spacer is - * everything below the end of real content, so no settled viewport can be more - * than `tailSpacerPx` past it. A larger reading means the two halves were read - * from different moments — the transcript has been restructured and the browser - * has not clamped `scrollTop` to the new range yet, or something wrote the - * offset to compensate for content that has not been measured. - * - * Measured on a session that paged its whole history on the first scroll up: - * the prepend shifted the viewport 14252px to hold the reader's Turn still, - * against a content end that was still 989 — a blank of 6239px in a transcript - * whose spacer reserves a few hundred. Read as a reader sitting deep in the - * blank, which is the one state the follow rule is watching for, so the next - * sample would have handed the viewport to follow and pulled them out of the - * history they had just asked for. - */ -export function isTailBlankMeasurable(input: { - blankPx: number; - tailSpacerPx: number; +export function isViewportAtTail(input: { + scrollTop: number; contentEndScrollTop: number; followTargetScrollTop: number; thresholdPx: number; }): boolean { - return input.blankPx <= input.tailSpacerPx; + return Math.abs(input.scrollTop - input.contentEndScrollTop) <= input.thresholdPx; } -export interface ViewportAtTailInput { - scrollTop: number; - contentEndScrollTop: number; - /** Upper bound of the band; the content end when nothing owns the viewport. */ - followTargetScrollTop: number; - thresholdPx: number; -} - -/** - * Whether the viewport counts as being at the end of the transcript. - * - * The band runs from the content end down to whatever the follow rule owns, so - * a new-Turn reveal and a held collapse gap both sit inside it — neither is a - * reason to offer a jump to the latest output. Past the lower bound is reserved - * blank, which is. - */ -export function isViewportAtTail(input: ViewportAtTailInput): boolean { - return input.scrollTop >= input.contentEndScrollTop - input.thresholdPx - && input.scrollTop <= input.followTargetScrollTop + input.thresholdPx; -} - -/** - * Viewports a jump to the latest output may animate across. - * - * Counted in viewports rather than pixels because the question is whether the - * reader can follow the movement, and what they can follow is a share of what - * they can see: the same 2000px is two and a half screens on a laptop and most - * of one on a tall display. - * - * Three is about the largest number the animation reliably finishes. The frame - * loop yields to a smooth scroll for a bounded time and takes the viewport back - * afterwards wherever it has got to — measured, a jump issued for 8717px - * animated 5480 of them and was finished by the loop in a single 3290px write. - * That is 38% of the distance delivered as a hard jump at the end of an - * animation, which is worse than either half on its own. The same measurement - * puts the sustained rate near 4570px/s, so three 800px viewports travel in - * roughly half the budget. - * - * `followOutput.animatedScrollEnded` is where this number is checked: a - * `backstop` reason means an animation ran out its yield without arriving, and - * this is then too high. - */ -export const FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS = 3; - -export interface AnimatedJumpInput { - /** Where the viewport is now. */ - fromPx: number; - /** Where the jump would leave it. */ - targetPx: number; - clientHeight: number; +export function resolveAnimatedJumpBehavior(input: { + fromPx: number; targetPx: number; clientHeight: number; +}): 'smooth' | 'auto' { + return input.clientHeight > 0 && Math.abs(input.targetPx - input.fromPx) + <= input.clientHeight * FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS ? 'smooth' : 'auto'; } -/** - * How a jump to the latest output should travel. - * - * An animation earns its cost only while the reader can read it. Its whole job - * is spatial continuity — showing which way and how far the viewport went — and - * past a few screens the transcript in between goes by faster than anyone can - * track, leaving a wait where the answer was. So a far jump lands instantly, - * the way every other navigation in the transcript already does, and a near one - * animates. - * - * Distance also costs more than it looks. Animating across N screens of a - * virtualized transcript renders and measures every item passed while the - * animation runs, and heights are estimates until they are measured — so the - * content end moves under an animation aimed at where it used to be, and the - * follow loop corrects that afterwards as a second, visible movement. A jump - * inside a few viewports passes items that are already rendered and measured. - */ -export function resolveAnimatedJumpBehavior(input: AnimatedJumpInput): 'smooth' | 'auto' { - // An unmeasured scroller is not a short distance, it is no distance at all: - // there is nothing to scale the budget by and nothing on screen to follow. - if (input.clientHeight <= 0) return 'auto'; - return Math.abs(input.targetPx - input.fromPx) - <= input.clientHeight * FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS - ? 'smooth' - : 'auto'; +/** Time-based easing, independent of display refresh rate. No overshoot. */ +export function easeFollowOffset(from: number, target: number, elapsedMs: number): number { + if (Math.abs(target - from) <= 1) return target; + return from + (target - from) * (1 - Math.exp(-Math.max(0, elapsedMs) / 65)); } diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatViewportAnchor.ts b/src/web-ui/src/flow_chat/components/modern/flowChatViewportAnchor.ts index 72e8a6baf3..d8f9ce600e 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatViewportAnchor.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatViewportAnchor.ts @@ -19,6 +19,7 @@ * what the reader is looking for when they scroll back through history. */ const TURN_ANCHOR_SELECTOR = '.virtual-item-wrapper[data-item-type="user-message"]'; +const BLOCK_ANCHOR_SELECTOR = '.virtual-item-wrapper[data-virtual-item-key]'; /** * Where the user is reading, expressed as a Turn rather than an offset. @@ -29,11 +30,13 @@ const TURN_ANCHOR_SELECTOR = '.virtual-item-wrapper[data-item-type="user-message */ export interface ViewportAnchor { turnId: string; + itemKey?: string; offsetFromScrollerTop: number; } export interface ViewportAnchorCandidate { turnId: string | null; + itemKey?: string; offsetFromScrollerTop: number; bottomOffsetFromScrollerTop: number; } @@ -148,6 +151,7 @@ export function selectViewportAnchor( if (!candidate?.turnId) return null; return { turnId: candidate.turnId, + ...(candidate.itemKey ? { itemKey: candidate.itemKey } : {}), offsetFromScrollerTop: candidate.offsetFromScrollerTop, }; } @@ -204,11 +208,12 @@ export function shouldCaptureViewportAnchorOnScroll( export function readViewportAnchorCandidates(scroller: HTMLElement): ViewportAnchorCandidate[] { const scrollerTop = scroller.getBoundingClientRect().top; return Array.from( - scroller.querySelectorAll(TURN_ANCHOR_SELECTOR), + scroller.querySelectorAll(`${BLOCK_ANCHOR_SELECTOR}, ${TURN_ANCHOR_SELECTOR}`), ).map(element => { const rect = element.getBoundingClientRect(); return { turnId: element.dataset.turnId ?? null, + itemKey: element.dataset.virtualItemKey, offsetFromScrollerTop: rect.top - scrollerTop, bottomOffsetFromScrollerTop: rect.bottom - scrollerTop, }; @@ -218,10 +223,11 @@ export function readViewportAnchorCandidates(scroller: HTMLElement): ViewportAnc export function findRenderedTurnAnchorElement( scroller: HTMLElement | null, turnId: string, + itemKey?: string, ): HTMLElement | null { return Array.from( - scroller?.querySelectorAll(TURN_ANCHOR_SELECTOR) ?? [], - ).find(element => element.dataset.turnId === turnId) ?? null; + scroller?.querySelectorAll(itemKey ? BLOCK_ANCHOR_SELECTOR : TURN_ANCHOR_SELECTOR) ?? [], + ).find(element => itemKey ? element.dataset.virtualItemKey === itemKey : element.dataset.turnId === turnId) ?? null; } export function readTurnAnchorOffsetPx(scroller: HTMLElement, element: HTMLElement): number { diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts index 42f0ac1fb5..3b359d653e 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemMemo.ts @@ -2,6 +2,7 @@ import type { ModelRound, FlowToolItem, TokenUsage } from '../../types/flow-chat import type { ModelRoundItemGroup } from './modelRoundItemGrouping'; export interface ModelRoundItemProps { + blockPart?: 'content' | 'header' | 'footer'; round: ModelRound; projectedGroups?: ModelRoundItemGroup[]; turnId: string; @@ -40,6 +41,7 @@ export function areModelRoundItemPropsEqual(prev: ModelRoundItemProps, next: Mod // In complete state, compare items array reference to detect tool state changes. return ( + prev.blockPart === next.blockPart && prev.round.id === next.round.id && prev.round.renderHints?.continuedAfterInterruption === next.round.renderHints?.continuedAfterInterruption && prev.round.renderHints?.disableExploreGrouping === next.round.renderHints?.disableExploreGrouping && diff --git a/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.test.tsx b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.test.tsx index 2cbd117442..c04bdbcac7 100644 --- a/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.test.tsx @@ -9,7 +9,7 @@ import type { FlowToolItem } from '../../types/flow-chat'; import { getModelRoundExploreGroups, isExploreGroupExpanded } from './modelRoundItemGrouping'; import { resolveFlowChatFocusTarget } from './flowChatFocusTarget'; -it('retains open capsule state when rounds regroup, and clears it only on explicit collection', () => { +it('retains child disclosure through regrouping and explicit parent collection', () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; const container = document.createElement('div'); const root = createRoot(container); @@ -31,14 +31,14 @@ it('retains open capsule state when rounds regroup, and clears it only on explic expect(state.exploreGroupStates.get('group')).toBe(true); act(() => state.onToolCapsuleExpandedChange(key, true)); act(() => state.onCollapseGroup('group')); - expect(state.expandedToolCapsules.has(key)).toBe(false); + expect(state.expandedToolCapsules.has(key)).toBe(true); expect(state.exploreGroupStates.get('group')).toBe(false); } finally { act(() => root.unmount()); } }); -it('retains any merged source expansion and explicitly closes all source states and member details', () => { +it('explicitly closes merged group sources while retaining member choices', () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; const root = createRoot(document.createElement('div')); const key = toolCapsuleStateKey('session', 'turn', 'read'); @@ -54,7 +54,7 @@ it('retains any merged source expansion and explicitly closes all source states expect(isExploreGroupExpanded((item as Extract).data, state.exploreGroupStates)).toBe(true); act(() => state.onCollapseGroup('owner')); expect([...state.exploreGroupStates.values()]).toEqual([false, false, false]); - expect(state.expandedToolCapsules.has(key)).toBe(false); + expect(state.expandedToolCapsules.has(key)).toBe(true); act(() => state.onExpandGroup('previous-inline')); expect(isExploreGroupExpanded((item as Extract).data, state.exploreGroupStates)).toBe(true); act(() => state.onExploreGroupToggle('owner')); @@ -97,7 +97,7 @@ it('expands and collects mixed-round groups using the same identities as item na expect(state.exploreGroupStates.get(groupId)).toBe(true); act(() => state.onCollapseGroup(groupId)); expect(state.exploreGroupStates.get(groupId)).toBe(false); - expect(state.expandedToolCapsules.has(key)).toBe(false); + expect(state.expandedToolCapsules.has(key)).toBe(true); expect(state.expandedToolCapsules.has(otherKey)).toBe(true); } finally { act(() => root.unmount()); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx index 7784570f41..9882c36208 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx @@ -1,1602 +1,246 @@ // @vitest-environment jsdom - import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { flushSync } from 'react-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { tailSpacerPxForViewport } from './flowChatTailFollow'; -import { - TAIL_EASE_ALPHA, - TAIL_EASE_LINE_PX, - TAIL_EASE_SNAP_ABOVE_PX, -} from '../../utils/flowChatTailEase'; -import { SMOOTH_SCROLL_STALL_MS, useFlowChatFollowOutput } from './useFlowChatFollowOutput'; -import { - useFlowChatViewportOwner, - type FlowChatViewportOwnerApi, -} from './useFlowChatViewportOwner'; -import { USER_DRIVEN_SCROLL_WINDOW_MS } from './flowChatViewportAnchor'; +import { activateSurface, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { registerSubmittedMessageScrollIntent } from '../../services/submittedMessageScrollIntent'; +import { FlowChatReaderState } from '../../timeline/readerState'; +import { useTimelineInteraction } from '../../timeline/useTimelineInteraction'; +import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; +import { useFlowChatViewportOwner, type FlowChatViewportOwnerApi } from './useFlowChatViewportOwner'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; - type Controller = ReturnType; -const VIEWPORT = 500; -/** Input-stack footer the harness renders below the transcript. */ -const BOTTOM_INSET = 100; -/** The spacer the component would render for a given viewport. */ -const spacerFor = (clientHeight: number) => tailSpacerPxForViewport(clientHeight, BOTTOM_INSET); -const TAIL_SPACER = spacerFor(VIEWPORT); -/** The 60% hold gap is capped by the smaller physical spacer. */ -const MAX_GAP = TAIL_SPACER; - -function setScrollerMetrics( - scroller: HTMLElement, - metrics: { scrollHeight: number; clientHeight: number; scrollTop: number }, -) { - Object.defineProperties(scroller, { - scrollHeight: { configurable: true, value: metrics.scrollHeight }, - clientHeight: { configurable: true, value: metrics.clientHeight }, - scrollTop: { configurable: true, writable: true, value: metrics.scrollTop }, - }); -} - -/** - * `turn-N` is the Nth Turn of the session, so the ledger holds N of them. - * - * A Turn arriving grows the ledger, which is what every test walking `turn-1` - * to `turn-2` means. A rollback is the one case where the identity moves and - * the ledger does not grow, and those tests state the count themselves. - */ -const turnCountFor = (turnId: string) => Number(turnId.replace('turn-', '')) || 1; - -interface HarnessProps { - latestTurnId: string; - /** Turns in the session ledger. Defaults to what `latestTurnId` implies. */ - dialogTurnCount?: number; - isStreaming?: boolean; - scroller: HTMLElement; - scrollToContentEnd?: (behavior: ScrollBehavior) => void; - revealNewTurnTail?: (turnId: string) => boolean; - isOpeningViewport?: boolean; - onOpeningOffset?: (actualOffsetPx: number) => void; - isViewportActive?: boolean; - isViewportSuspended?: boolean; - startAtTailOnMount?: boolean; - onController: (controller: Controller) => void; - /** The register the hook writes through, for a test that has to hold it. */ - onViewportOwner?: (owner: FlowChatViewportOwnerApi) => void; -} - -function Harness({ - latestTurnId, - dialogTurnCount = turnCountFor(latestTurnId), - isStreaming = true, - scroller, - scrollToContentEnd = () => {}, - revealNewTurnTail = () => false, - isOpeningViewport = false, - onOpeningOffset, - isViewportActive = true, - isViewportSuspended = false, - startAtTailOnMount = true, - onController, - onViewportOwner, -}: HarnessProps) { - const scrollerRef = React.useRef(scroller); - // The real register, not a stand-in: what this hook is allowed to write is - // now part of its behaviour, so a permissive fake would test the wrong thing. - const viewportOwner = useFlowChatViewportOwner(scrollerRef); - onViewportOwner?.(viewportOwner); - const controller = useFlowChatFollowOutput({ - activeSessionId: 'session-1', - latestTurnId, - dialogTurnCount, - virtualItemCount: 2, - isStreaming, - isViewportActive, - isViewportSuspended: () => isViewportSuspended, - startAtTailOnMount, - scrollerRef, - // Sized from live layout, exactly as the component's state does. - getTailSpacerPx: () => tailSpacerPxForViewport(scroller.clientHeight, BOTTOM_INSET), - scrollToContentEnd, - revealNewTurnTail, - isOpeningViewport: () => isOpeningViewport, - onOpeningOffset, - viewportOwner, - }); - onController(controller); - return
; -} - -describe('useFlowChatFollowOutput', () => { - let container: HTMLDivElement; - let root: Root; - let scroller: HTMLDivElement; - let controller: Controller | null; - let frames: FrameRequestCallback[]; - let scrollTo: ReturnType; - - function runNextFrame() { - const frame = frames.shift(); - expect(frame).toBeDefined(); - act(() => frame?.(16)); - } - - beforeEach(() => { - container = document.createElement('div'); - scroller = document.createElement('div'); - document.body.append(container, scroller); - // jsdom has no layout engine, so the animated scroll is a no-op here and - // the tests place the viewport where the animation would have left it. - scrollTo = vi.fn(); - scroller.scrollTo = scrollTo as unknown as HTMLElement['scrollTo']; - root = createRoot(container); - controller = null; - frames = []; - vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { - frames.push(callback); - return frames.length; - })); - vi.stubGlobal('cancelAnimationFrame', vi.fn()); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - scroller.remove(); - vi.unstubAllGlobals(); - }); - - it('reveals a newly submitted Turn at the physical bottom once', () => { - const revealNewTurnTail = vi.fn(() => { - scroller.scrollTop = 100; - return true; - }); - const scrollToContentEnd = vi.fn(); - const props = { - scroller, - scrollToContentEnd, - revealNewTurnTail, - onController: (next: Controller) => { controller = next; }, - }; - - act(() => { - root.render(); - }); - // Mount settles on the content end; only the *new Turn* must not. - expect(scrollToContentEnd).toHaveBeenCalledWith('auto'); - scrollToContentEnd.mockClear(); - - act(() => { - root.render(); - }); - - expect(revealNewTurnTail).toHaveBeenCalledWith('turn-2'); - expect(scrollToContentEnd).not.toHaveBeenCalled(); - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('does not read a rolled-back Turn as a newly arrived one', () => { - /* - * Measured as a report: send a message, watch it pin, roll it back, and the - * Turn *before* it takes the viewport top. `latestTurnId` is right — it is - * the ledger's last Turn — but the detector asked whether the identity had - * changed, and a truncation changes it without anything having arrived. - */ - const revealNewTurnTail = vi.fn(() => { - scroller.scrollTop = 100; - return true; - }); - const props = { - scroller, - revealNewTurnTail, - onController: (next: Controller) => { controller = next; }, - }; - - act(() => { - root.render(); - }); - revealNewTurnTail.mockClear(); - - // The rollback removes turn-2, so the ledger's last Turn is turn-1 again. - act(() => { - root.render( - , - ); - }); - - expect(revealNewTurnTail).not.toHaveBeenCalled(); - }); - - it('returns to the end of the transcript when a rollback takes the Turn it was following', () => { - const scrollToContentEnd = vi.fn(); - const props = { - scroller, - scrollToContentEnd, - revealNewTurnTail: () => true, - onController: (next: Controller) => { controller = next; }, - }; - - act(() => { - root.render(); - }); - scrollToContentEnd.mockClear(); - - act(() => { - root.render( - , - ); - }); - // Nothing on its own: a shorter ledger is not a reason to move, because - // hydration and window merges write it too. - expect(scrollToContentEnd).not.toHaveBeenCalled(); - - // The rollback says so itself, and the answer is the tail. - act(() => { controller?.handleTurnsRolledBack(); }); - expect(scrollToContentEnd).toHaveBeenCalledWith('auto'); - }); - - it('settles a rollback on the new tail even after the reader took the viewport', () => { - /* - * Gating this on ownership made it dead code in the case it was written - * for. Reaching a Turn far enough up to want it gone means scrolling, and - * scrolling is what hands the viewport back to the reader — so the answer - * never ran, the anchor held the reader's Turn at its offset from the - * viewport top, and an 8-Turn session rolled back at Turn 7 came to rest on - * Turns 2..6 with the new last Turn's answer below the fold. - * - * Taking it is safe because a rollback at Turn N - * removes N and everything after it, and N was on screen. The new tail is - * within a Turn of where the reader already is. - */ - const scrollToContentEnd = vi.fn(); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - act(() => { controller?.handleUserScrollIntent(); }); - expect(controller?.isFollowingOutput).toBe(false); - scrollToContentEnd.mockClear(); - - act(() => { controller?.handleTurnsRolledBack(); }); - expect(scrollToContentEnd).toHaveBeenCalledWith('auto'); - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('settles on the content end when a session opens without streaming', () => { - const scrollToContentEnd = vi.fn(); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - - expect(scrollToContentEnd).toHaveBeenCalledWith('auto'); - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('reuses cached geometry in continuous streaming frames', () => { - let scrollHeightPx = 1500 + TAIL_SPACER; - let scrollHeightReads = 0; - Object.defineProperties(scroller, { - scrollHeight: { - configurable: true, - get: () => { - scrollHeightReads += 1; - return scrollHeightPx; - }, +describe('desktop follow ownership and lifecycle (supplied geometry, not visual evidence)', () => { + let root: Root, host: HTMLDivElement, scroller: HTMLDivElement, controller: Controller, owner: FlowChatViewportOwnerApi; + let frames: Map, frameId: number, now: number; + let normalTarget: number, floor: number | null, sentTop: number, output: boolean, renderable: boolean; + let props: { session: string; active: boolean; suspended: boolean; opening: boolean; count: number; startAtTail: boolean; streaming: boolean }; + let reader: FlowChatReaderState; + const placements: string[] = []; + const readbacks: number[] = []; + function Harness() { + const ref = React.useRef(scroller); + owner = useFlowChatViewportOwner(ref); + controller = useFlowChatFollowOutput({ + activeSessionId: props.session, virtualItemCount: props.count, + isViewportActive: props.active, isViewportSuspended: () => props.suspended, + isStreaming: props.streaming, startAtTailOnMount: props.startAtTail, + scrollerRef: ref, viewportOwner: owner, viewportId: 1, + isOpeningViewport: () => props.opening, onViewportOffset: value => readbacks.push(value), + readLayoutTarget: () => Math.max(normalTarget, floor ?? 0), + placeSubmittedMessage: intent => { + if (!renderable) return null; + floor = sentTop; + if (!owner.write({ owner: 'follow-output', topPx: sentTop })) return null; + placements.push(intent.turnId); + return sentTop; }, - clientHeight: { configurable: true, value: VIEWPORT }, - scrollTop: { configurable: true, writable: true, value: 0 }, - }); - const scrollToContentEnd = vi.fn(() => { - scroller.scrollTop = 1000; - }); - - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - const readsAfterInitialPlacement = scrollHeightReads; - - // Production streaming frames reuse the target cached by the last - // content-change refresh. - scrollHeightPx = 1800 + TAIL_SPACER; - runNextFrame(); - expect(scrollHeightReads).toBe(readsAfterInitialPlacement); - expect(scroller.scrollTop).toBe(1000); - - // The resize/content-change signal is the intentionally retained low-rate - // geometry refresh and updates the cached target once. - const beforeRefresh = scrollHeightReads; - act(() => controller?.scheduleFollowToLatest()); - expect(scrollHeightReads).toBeGreaterThan(beforeRefresh); - expect(scroller.scrollTop).toBe(1300); - const afterRefresh = scrollHeightReads; - runNextFrame(); - expect(scrollHeightReads).toBe(afterRefresh); - }); - - it('tracks the content end exactly while the transcript is still opening', () => { - // The virtualizer compensates a history prepend by writing scrollTop before the - // prepended heights reach the DOM. While opening, the transcript is hidden - // and we are authoritative — accommodating that write would be invisible - // now and permanent once paging stops. - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - expect(scroller.scrollTop).toBe(1000); - - scroller.scrollTop = 1000 + 380; - runNextFrame(); - expect(scroller.scrollTop).toBe(1000); - }); - - describe('opening offset publication', () => { - const mountOpening = (props: Partial = {}) => { - act(() => root.render( { controller = next; }} - {...props} - />)); - }; - - beforeEach(() => { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, clientHeight: VIEWPORT, scrollTop: 0, - }); - }); - - it('publishes the clamped readback in the follow frame without a scroll event', () => { - let actualTop = 0; - Object.defineProperty(scroller, 'scrollTop', { - configurable: true, - get: () => actualTop, - set: (value: number) => { actualTop = Math.min(value, 900); }, - }); - const onOpeningOffset = vi.fn(); - mountOpening({ onOpeningOffset }); - expect(onOpeningOffset).toHaveBeenCalledWith(900); - onOpeningOffset.mockClear(); - actualTop = 0; - runNextFrame(); - expect(onOpeningOffset).toHaveBeenCalledExactlyOnceWith(900); - expect(scroller.scrollTop).toBe(900); - }); - - it('publishes an already-reached target and uses the latest callback', () => { - scroller.scrollTop = 1000; - const previous = vi.fn(); - const current = vi.fn(); - mountOpening({ onOpeningOffset: previous }); - previous.mockClear(); - mountOpening({ onOpeningOffset: current }); - current.mockClear(); - runNextFrame(); - expect(previous).not.toHaveBeenCalled(); - expect(current).toHaveBeenCalledExactlyOnceWith(1000); - }); - - it.each([0, 1000])('does not publish under a higher-priority owner at offset %s', offset => { - scroller.scrollTop = offset; - const onOpeningOffset = vi.fn(); - let owner: FlowChatViewportOwnerApi; - mountOpening({ onOpeningOffset, onViewportOwner: next => { owner = next; } }); - act(() => owner.claim('one-shot-navigation', { holdForMs: 1000 })); - expect(owner!.currentOwner()).toBe('one-shot-navigation'); - scroller.scrollTop = offset; - onOpeningOffset.mockClear(); - act(() => controller?.scheduleFollowToLatest()); - expect(scroller.scrollTop).toBe(offset); - expect(onOpeningOffset).not.toHaveBeenCalled(); - }); - - it.each([ - { isOpeningViewport: false }, - { isViewportActive: false }, - { isViewportSuspended: true }, - { startAtTailOnMount: false }, - ])('does not publish outside active opening follow: %j', boundary => { - const onOpeningOffset = vi.fn(); - mountOpening({ onOpeningOffset, ...boundary }); - act(() => controller?.scheduleFollowToLatest()); - expect(onOpeningOffset).not.toHaveBeenCalled(); - }); - - it('stops publishing when the reader takes over', () => { - const onOpeningOffset = vi.fn(); - mountOpening({ onOpeningOffset }); - runNextFrame(); - onOpeningOffset.mockClear(); - act(() => controller?.handleUserScrollIntent()); - scroller.scrollTop = 100; - act(() => controller?.scheduleFollowToLatest()); - expect(scroller.scrollTop).toBe(100); - expect(onOpeningOffset).not.toHaveBeenCalled(); - }); - }); - - it('does not strand the viewport inside the tail spacer after opening', () => { - // Regression: the gap tolerance is a streaming allowance. Applied to a - // foreign forward move it parked the content end mid-viewport forever, - // because nothing pulls the target back down once paging stops. - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); + hasRenderedOutput: () => output, + cancelPendingPlacement: () => {}, cancelNavigation: () => {}, }); - runNextFrame(); - - scroller.scrollTop = 1000 + 380; - runNextFrame(); - expect(scroller.scrollTop).toBe(1000); - expect(1000).toBeLessThan(1000 + MAX_GAP); - }); - - it('defers the reveal when the new Turn is not in the live-tail projection yet', () => { - const scrollToContentEnd = vi.fn(); - const revealNewTurnTail = vi.fn(() => false); - const props = { - scroller, - scrollToContentEnd, - revealNewTurnTail, - onController: (next: Controller) => { controller = next; }, - }; - - act(() => { - root.render(); - }); - scrollToContentEnd.mockClear(); - act(() => { - root.render(); - }); - - expect(revealNewTurnTail).toHaveBeenCalledWith('turn-2'); - expect(scrollToContentEnd).not.toHaveBeenCalled(); - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('reveals a new Turn at physical bottom once and lets growth consume the blank', () => { - setScrollerMetrics(scroller, { - scrollHeight: 1200 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 700, - }); - const revealNewTurnTail = vi.fn(() => { - scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; - return true; - }); - const props = { - scroller, - revealNewTurnTail, - onController: (next: Controller) => { controller = next; }, - }; - - act(() => { - root.render(); - }); - act(() => { - root.render(); - }); - - expect(revealNewTurnTail).toHaveBeenCalledTimes(1); - expect(scroller.scrollTop).toBe(700 + TAIL_SPACER); - - setScrollerMetrics(scroller, { - scrollHeight: 1300 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 700 + TAIL_SPACER, - }); - act(() => controller?.scheduleFollowToLatest()); - expect(scroller.scrollTop).toBe(700 + TAIL_SPACER); - }); - - it('starts following without a snap when streamed output consumes the reveal blank', () => { - setScrollerMetrics(scroller, { - scrollHeight: 1200 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 700, - }); - const revealNewTurnTail = () => { - scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; - return true; - }; - const props = { - scroller, - revealNewTurnTail, - onController: (next: Controller) => { controller = next; }, - }; - - act(() => { - root.render(); - }); - act(() => { - root.render(); - }); - - const revealPosition = 700 + TAIL_SPACER; - setScrollerMetrics(scroller, { - scrollHeight: 1200 + TAIL_SPACER + TAIL_SPACER + 10, - clientHeight: VIEWPORT, - scrollTop: revealPosition, - }); - act(() => controller?.scheduleFollowToLatest()); - expect(scroller.scrollTop).toBe(revealPosition); - - // The first queued frame may be the cancelled opening settle. The next one - // is ordinary following and advances from the unchanged reveal position. - runNextFrame(); - runNextFrame(); - expect(scroller.scrollTop).toBeGreaterThan(revealPosition); - }); - - it('follows content growth against the content end, not the tail spacer', () => { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - expect(controller?.isFollowingOutput).toBe(true); - - setScrollerMetrics(scroller, { - scrollHeight: 1800 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => controller?.scheduleFollowToLatest()); - runNextFrame(); - - expect(scroller.scrollTop).toBe(1300); - }); - - it('holds its offset when a collapse shrinks content under the viewport', () => { - // The regression this whole mechanism exists for: a tool card collapsing - // above the live output must not drag earlier content down. - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - expect(scroller.scrollTop).toBe(1000); - - setScrollerMetrics(scroller, { - scrollHeight: 1200 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => controller?.scheduleFollowToLatest()); - runNextFrame(); - - expect(scroller.scrollTop).toBe(1000 - (300 - MAX_GAP)); - }); - - it('gives ground only past the tolerated gap after a very large collapse', () => { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - - setScrollerMetrics(scroller, { - scrollHeight: 700 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => controller?.scheduleFollowToLatest()); - runNextFrame(); - - expect(scroller.scrollTop).toBe(200 + MAX_GAP); - }); - - it('does not force the content end when a resize re-asserts follow', () => { - const scrollToContentEnd = vi.fn(); - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - scrollToContentEnd.mockClear(); - - setScrollerMetrics(scroller, { - scrollHeight: 1200 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => controller?.scheduleFollowToLatest()); - - expect(scrollToContentEnd).not.toHaveBeenCalled(); - expect(scroller.scrollTop).toBe(1000 - (300 - MAX_GAP)); - }); - - it('settles the held blank once streaming stops', () => { - const scrollToContentEnd = vi.fn(); - const props = { - scroller, - scrollToContentEnd, - onController: (next: Controller) => { controller = next; }, - }; - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render(); - }); - runNextFrame(); - - setScrollerMetrics(scroller, { - scrollHeight: 1200 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => controller?.scheduleFollowToLatest()); - runNextFrame(); - scrollToContentEnd.mockClear(); - - act(() => { - root.render(); - }); - - expect(scrollToContentEnd).toHaveBeenCalledWith('smooth'); - }); - - it('lets explicit user scroll intent exit follow immediately', () => { - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - act(() => controller?.enterFollowOutput('jump-to-latest')); - expect(controller?.isFollowingOutput).toBe(true); - - act(() => controller?.handleUserScrollIntent()); - expect(controller?.isFollowingOutput).toBe(false); - expect(cancelAnimationFrame).toHaveBeenCalled(); - }); - - /** Mount on a transcript of `contentEndPx`, then jump to latest from the top. */ - function jumpToLatestAcross(contentEndPx: number) { - const scrollToContentEnd = vi.fn(); - setScrollerMetrics(scroller, { - scrollHeight: contentEndPx + VIEWPORT + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - expect(scroller.scrollTop).toBe(contentEndPx); - scroller.scrollTop = 0; - scrollToContentEnd.mockClear(); - act(() => controller?.enterFollowOutput('jump-to-latest')); - return scrollToContentEnd; + useTimelineInteraction(ref, reader, controller.isFollowingOutputNow, undefined, + () => controller.exitFollowOutput('reader-interaction')); + return null; } - - it('uses smooth behavior only for an explicit jump to latest', () => { - // Well inside `FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS`, so the distance is not - // what this is testing. - expect(jumpToLatestAcross(VIEWPORT)).toHaveBeenCalledWith('smooth'); - }); - - it('lands a near jump immediately when reduced motion is requested', () => { - vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))); - - expect(jumpToLatestAcross(VIEWPORT)).toHaveBeenCalledWith('auto'); - }); - - it('lands a jump too far to follow rather than animate part of it', () => { - /* - * The animation cannot finish this and the frame loop takes the viewport - * back wherever it has got to — measured, a jump issued for 8717px animated - * 5480 of them and was finished in one 3290px write. The reader sees two - * thirds of a scroll and then a jump, which is worse than either. - * - * It would not be worth watching even if it did finish: three screens on, - * the transcript in between is going past faster than anyone can read it, - * so the continuity an animation exists to show is not there to see. - */ - expect(jumpToLatestAcross(VIEWPORT * 10)).toHaveBeenCalledWith('auto'); - }); - - it('yields the frame loop to its own animated scroll instead of overwriting it', () => { - // The loop assigns scrollTop outright, which cancels an in-flight smooth - // scroll on the very next frame: `jump-to-latest` asked for an animation - // and got a jump. - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - expect(scroller.scrollTop).toBe(1000); - - scroller.scrollTop = 0; - act(() => controller?.enterFollowOutput('jump-to-latest')); - runNextFrame(); - - expect(scroller.scrollTop).toBe(0); - }); - - describe('standing down for its own animated scroll', () => { - /** - * A clock the test moves by hand. - * - * The stand-down ends on a *duration* without travel, so a test that runs - * its frames in microseconds of real time proves nothing about it either - * way. Every frame below states how much time it took. - */ - let nowMs = 0; - let nowSpy: ReturnType; - - beforeEach(() => { - nowMs = 1_000; - nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => nowMs); - }); - - afterEach(() => { - nowSpy.mockRestore(); - }); - - /* - * A viewport tall enough that the jump below is one the follow rule agrees - * to animate at all: only a jump inside - * `FLOWCHAT_ANIMATED_JUMP_MAX_VIEWPORTS` is animated, and everything in - * this block is about what happens *during* an animation, so it has to be - * given one. The distance is left where it was so the frame counts below - * still mean what their comments say. - */ - const TALL_VIEWPORT = 1_600; - const CONTENT_END = 4_500; - - /** - * Mounts a transcript whose content ends at `CONTENT_END`, settles there, - * and then issues an animated jump to latest from the top. - * - * jsdom does not animate, so the animation is played by hand below. That is - * the point of these tests: what ends the stand-down is the viewport having - * stopped moving, and only a test that moves it can tell that apart from a - * budget running out. - */ - function jumpToLatestFromTheTop() { - setScrollerMetrics(scroller, { - scrollHeight: CONTENT_END + TALL_VIEWPORT + spacerFor(TALL_VIEWPORT), - clientHeight: TALL_VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - expect(scroller.scrollTop).toBe(CONTENT_END); - scroller.scrollTop = 0; - act(() => controller?.enterFollowOutput('jump-to-latest')); - } - - /** One frame, `forMs` after the last, in which the animation moved `byPx`. */ - function animateFrame(byPx: number, forMs = 5) { - nowMs += forMs; - scroller.scrollTop += byPx; - runNextFrame(); - } - - it('stays out of the way for as long as the animation is still travelling', () => { - // The stand-down used to be 45 frames, which is 0.75s at 60Hz and 0.52s - // on a busy 200Hz display — measured, a jump issued for 8717px animated - // 5480 of them and was finished by the loop in one 3290px write. - jumpToLatestFromTheTop(); - - for (let frame = 0; frame < 60; frame += 1) { - animateFrame(50); - expect(scroller.scrollTop).toBe(50 * (frame + 1)); - } - }); - - it('sits through the frames a smooth scroll takes to visibly start', () => { - /* - * The regression this pair of constants exists for. A programmatic smooth - * scroll eases in — measured on WebView2, 2px in its first 50ms against - * 9734px to travel — and with scroll offsets quantised to 0.8px the early - * frames genuinely do not move. Counting two still *frames* took the - * viewport back 21ms after asking for the animation, and the reader saw - * an instant jump. - */ - jumpToLatestFromTheTop(); - - for (let frame = 0; frame < 20; frame += 1) { - animateFrame(0); - } - expect(scroller.scrollTop).toBe(0); - - // 100ms in, still inside the window, and the animation finally shows. - animateFrame(0.8); - expect(scroller.scrollTop).toBe(0.8); - }); - - it('resumes once the animation stops moving the viewport', () => { - jumpToLatestFromTheTop(); - animateFrame(50); - - // Still inside the window a stalling animation is given. - animateFrame(0, SMOOTH_SCROLL_STALL_MS - 10); - expect(scroller.scrollTop).toBe(50); - - animateFrame(0, 20); - expect(scroller.scrollTop).toBe(CONTENT_END); - }); - - it('takes the viewport back on the backstop when the animation never ends', () => { - jumpToLatestFromTheTop(); - // Travelling the whole time, so nothing but the backstop can end this. - for (let frame = 0; frame < 12; frame += 1) { - animateFrame(1, 100); - } - - expect(scroller.scrollTop).toBe(CONTENT_END); - }); - }); - - describe('easing the follow across the frames it has', () => { - /** Mounts a streaming transcript resting exactly on its content end. */ - function followFromContentEnd() { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - } - - /** Grows real content by `byPx`, which is what moves the follow target. */ - function growContentBy(byPx: number) { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + byPx + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: scroller.scrollTop, - }); - act(() => controller?.scheduleFollowToLatest()); - } - - it('spends a line of growth over the frames that were empty', () => { - // Markdown reflows a line at a time, so an outright write puts all 24px - // on one frame out of seven and none on the rest. - followFromContentEnd(); - growContentBy(TAIL_EASE_LINE_PX); - - expect(scroller.scrollTop).toBeCloseTo(1000 + TAIL_EASE_LINE_PX * TAIL_EASE_ALPHA, 5); - expect(scroller.scrollTop - 1000).toBeLessThan(TAIL_EASE_LINE_PX); - }); - - it('keeps stepping until it has landed on the target', () => { - followFromContentEnd(); - growContentBy(TAIL_EASE_LINE_PX); - - let previousPx = scroller.scrollTop; - for (let frame = 0; frame < 20; frame += 1) { - runNextFrame(); - // Every step is under a line: that is the bar the ease has to clear to - // be worth having, since a line is what it set out to replace. - expect(scroller.scrollTop - previousPx).toBeLessThan(TAIL_EASE_LINE_PX); - previousPx = scroller.scrollTop; - } - - // Inside the loop's own `BOTTOM_EPSILON_PX`, where it stops writing at - // all — the ease converges on the target rather than stalling short of - // it or ringing around it. - expect(1000 + TAIL_EASE_LINE_PX - scroller.scrollTop).toBeLessThan(2); - expect(scroller.scrollTop).toBeLessThanOrEqual(1000 + TAIL_EASE_LINE_PX); - }); - - it('jumps when an ease would open with a bigger step than the jump it replaces', () => { - // A burst arriving further behind than four lines: a quarter of that is - // already worse than having simply gone the whole way. - followFromContentEnd(); - growContentBy(TAIL_EASE_SNAP_ABOVE_PX + 100); - - expect(scroller.scrollTop).toBe(1000 + TAIL_EASE_SNAP_ABOVE_PX + 100); - }); - - it('does not ease while the transcript is still opening', () => { - // The reveal waits for the viewport to reach the content end, and while - // it waits nothing is painted — so an ease there is travel nobody sees, - // holding open the one phase whose whole point is to be over. - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - growContentBy(TAIL_EASE_LINE_PX); - - expect(scroller.scrollTop).toBe(1000 + TAIL_EASE_LINE_PX); - }); - - it('goes the whole way at once when content shrinks under the viewport', () => { - // Easing down through a shrink reads as the transcript being clawed - // backwards, which is worse than the jump it would replace. - followFromContentEnd(); - setScrollerMetrics(scroller, { - scrollHeight: 700 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => controller?.scheduleFollowToLatest()); - - // Past the tolerated gap, so the hold rule gives ground — in one step. - expect(scroller.scrollTop).toBe(200 + MAX_GAP); - }); - }); - - describe('new Turn reveal ownership', () => { - function revealLatestTurn() { - const scrollToContentEnd = vi.fn(); - setScrollerMetrics(scroller, { - scrollHeight: 1200 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 700, - }); - const props = { - scroller, - scrollToContentEnd, - revealNewTurnTail: () => { - scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; - return true; - }, - onController: (next: Controller) => { controller = next; }, - }; - act(() => { - root.render(); - }); - scrollToContentEnd.mockClear(); - act(() => { - root.render(); - }); - return scrollToContentEnd; - } - - it('ignores a delayed jump-to-latest while the one-shot reveal is active', () => { - const scrollToContentEnd = revealLatestTurn(); - const revealPosition = scroller.scrollTop; - - act(() => controller?.enterFollowOutput('jump-to-latest')); - - expect(scrollToContentEnd).not.toHaveBeenCalled(); - expect(scroller.scrollTop).toBe(revealPosition); - }); - - it('releases reveal ownership on user intent', () => { - revealLatestTurn(); - - act(() => controller?.handleUserScrollIntent()); - - expect(controller?.isFollowingOutput).toBe(false); - }); - }); - - describe('user-controlled reserved blank', () => { - it('keeps a reader in the blank after their scroll ends', () => { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - act(() => controller?.handleUserScrollIntent()); - scrollTo.mockClear(); - - scroller.scrollTop = 1000 + TAIL_SPACER; - act(() => controller?.handleScroll()); - - expect(scroller.scrollTop).toBe(1000 + TAIL_SPACER); - expect(scrollTo).not.toHaveBeenCalled(); - expect(controller?.isFollowingOutput).toBe(false); - }); - }); - - describe('anchoring the viewport bottom across a resize', () => { - /** Mounts at the content end, then hands the viewport to the user. */ - function restAtContentEnd() { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + spacerFor(VIEWPORT), - clientHeight: VIEWPORT, - scrollTop: 0, - }); - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - runNextFrame(); - expect(scroller.scrollTop).toBe(1000); - act(() => controller?.handleUserScrollIntent()); - } - - /** Content and footer stay at 1500; only the viewport, and so the spacer, change. */ - function resizeViewportTo(clientHeight: number, scrollTop: number) { - setScrollerMetrics(scroller, { - scrollHeight: 1500 + spacerFor(clientHeight), - clientHeight, - scrollTop, - }); - } - - it('keeps content against the viewport bottom when the viewport grows', () => { - // A viewport 200px taller lowers the content end by 200px. Without the - // clamp the spacer removed, the resting viewport is left that far into - // the blank. - restAtContentEnd(); - resizeViewportTo(VIEWPORT + 200, 1000); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 200, - wasAtTail: true, - })); - - expect(scroller.scrollTop).toBe(800); - }); - - it('keeps content against the viewport bottom when the viewport shrinks', () => { - // The opposite direction: the content end moves down past the viewport, - // cutting off the bottom of what was on screen. - restAtContentEnd(); - resizeViewportTo(VIEWPORT - 200, 1000); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: -200, - wasAtTail: true, - })); - - expect(scroller.scrollTop).toBe(1200); - }); - - it('anchors the bottom for a viewport reading history too', () => { - // The rule is not about the tail. A plain scroller preserves scrollTop - // and reveals or swallows content at the bottom edge; for a transcript - // the bottom edge is the one worth holding still. - restAtContentEnd(); - resizeViewportTo(VIEWPORT + 200, 600); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 200, - wasAtTail: false, - })); - - expect(scroller.scrollTop).toBe(400); - }); - - it('does not drag a viewport reading history to the tail', () => { - restAtContentEnd(); - resizeViewportTo(VIEWPORT - 200, 600); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: -200, - wasAtTail: false, - })); - - // Bottom-anchored, and nowhere near the content end at 1200. - expect(scroller.scrollTop).toBe(800); - }); - - it('corrects instantly, since the content does not appear to move', () => { - restAtContentEnd(); - scrollTo.mockClear(); - resizeViewportTo(VIEWPORT + 200, 1000); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 200, - wasAtTail: true, - })); - - expect(scrollTo).not.toHaveBeenCalled(); - }); - - it('does not hand the viewport back to follow', () => { - // A gesture ending in the blank means "take me to the end"; a layout - // change means nothing at all. - restAtContentEnd(); - resizeViewportTo(VIEWPORT + 200, 1000); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 200, - wasAtTail: true, - })); - - expect(controller?.isFollowingOutput).toBe(false); - }); - - it('follows the content end while a reflow keeps moving it', () => { - // A width change reflows every item, and arithmetic cannot track where - // the bottom line went. The end of the transcript is the one position - // that can be recomputed, so a viewport resting on it is put back on it — - // repeatedly, because the virtualizer re-estimates over several passes. - restAtContentEnd(); - - // Narrower: the same text wraps into 300px more content. - setScrollerMetrics(scroller, { - scrollHeight: 1800 + spacerFor(VIEWPORT), - clientHeight: VIEWPORT, - scrollTop: 1000, - }); - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 0, - wasAtTail: true, - })); - expect(scroller.scrollTop).toBe(1300); - - // Re-measurement adds another 200px above the viewport. - setScrollerMetrics(scroller, { - scrollHeight: 2000 + spacerFor(VIEWPORT), - clientHeight: VIEWPORT, - scrollTop: 1300, - }); - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 0, - wasAtTail: true, - })); - expect(scroller.scrollTop).toBe(1500); - }); - - it('leaves a reflow alone for a viewport that was not at the end', () => { - restAtContentEnd(); - setScrollerMetrics(scroller, { - scrollHeight: 1800 + spacerFor(VIEWPORT), - clientHeight: VIEWPORT, - scrollTop: 200, - }); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 0, - wasAtTail: false, - })); - - expect(scroller.scrollTop).toBe(200); - }); - - it('does not pull a user-controlled blank position back to the tail', () => { - restAtContentEnd(); - setScrollerMetrics(scroller, { - scrollHeight: 1800 + spacerFor(VIEWPORT), - clientHeight: VIEWPORT, - scrollTop: 1600, - }); - - act(() => controller?.handleViewportResize({ - viewportHeightDeltaPx: 0, - wasAtTail: false, - })); - - expect(scroller.scrollTop).toBe(1600); - expect(controller?.isFollowingOutput).toBe(false); - }); - }); - - describe('ownership across renders', () => { - /* - * Ownership is a ref because the loop reads it between renders, and it was - * *also* assigned from the `isFollowingOutput` state on every render. Those - * two writers disagree for exactly as long as a state update is queued, and - * React is free to render in that window: an update scheduled from a - * passive effect sits at a lower priority than a synchronous render, which - * then renders the value from before it and put the ref back to `false`. - * - * Measured on session open, which is where this always happens — the entry - * comes from a mount effect: `followOutput.enter` recorded, then the very - * first frame of the loop stood down with `not-following` and its settle - * budget untouched, and no `followOutput.exit` anywhere in the trail - * because nothing had exited. The register still held `follow-output`, so - * the prepend compensation for a history window that arrived 90ms later was - * left to a writer that was no longer running, and the transcript stayed at - * offset 0 — the top of the window, eight Turns above the tail. - */ - it('survives a render that has not seen the entry yet', () => { - act(() => { - root.render( - { controller = next; }} - />, - ); - }); - act(() => controller?.handleUserScrollIntent()); - expect(controller?.isFollowingOutputNow()).toBe(false); - - // Outside `act`, so the state update this schedules is still queued. - controller?.enterFollowOutput('jump-to-latest'); - expect(controller?.isFollowingOutputNow()).toBe(true); - - // A synchronous render, which does not carry the queued update. - flushSync(() => { - root.render( - { controller = next; }} - />, - ); - }); - - expect(controller?.isFollowingOutputNow()).toBe(true); - }); + const render = () => act(() => root.render()); + const signal = () => act(() => controller.scheduleFollowToLatest()); + const submit = (turn = 'turn-2') => act(() => registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), props.session, turn, `user-${turn}`)); + function tick(ms = 16) { + now += ms; + const callbacks = [...frames.values()]; frames.clear(); + act(() => callbacks.forEach(fn => fn(now))); + } + function settle() { for (let i = 0; i < 100 && frames.size; i++) tick(); } + function depart(direction: 'before' | 'after' = 'before') { + act(() => { owner.claim('user-gesture', { holdForMs: 200 }); controller.handleUserScrollIntent(direction); }); + } + beforeEach(() => { + activateSurface('local'); + host = document.createElement('div'); scroller = document.createElement('div'); + document.body.append(host, scroller); root = createRoot(host); + normalTarget = 1000; floor = null; sentTop = 1400; output = false; renderable = true; + props = { session: 'session', active: true, suspended: false, opening: false, count: 2, startAtTail: true, streaming: true }; + reader = new FlowChatReaderState(); + frames = new Map(); frameId = 0; now = 0; placements.length = 0; readbacks.length = 0; + Object.defineProperty(scroller, 'clientHeight', { configurable: true, get: () => props.suspended ? 0 : 800 }); + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => Math.max(normalTarget, floor ?? 0) + 800 }); + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { frames.set(++frameId, fn); return frameId; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.spyOn(performance, 'now').mockImplementation(() => now); + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} }))); }); - - /* - * Scrolling up gives the follow away, and the reserved blank is what makes - * that too blunt on its own: a reader can leave the tail by a hundred pixels - * and still be looking at empty space below the newest output, having missed - * nothing. The departure watches those readers until the blank is gone, and - * which side closed it decides who keeps the viewport. - */ - describe('resuming a follow that output caught up with', () => { - const CONTENT_END = 1_000; - let viewportOwner: FlowChatViewportOwnerApi | null = null; - - /** Move the end of real content, leaving the viewport where it is. */ - function setContentEnd(contentEndPx: number) { - Object.defineProperty(scroller, 'scrollHeight', { - configurable: true, - value: contentEndPx + VIEWPORT + TAIL_SPACER, - }); - } - - /** - * Mount a following transcript, then have the reader take the viewport from - * `blankPx` into the reserved blank — `0` being the end of real content, on - * the viewport's bottom edge. Returns the one-shot scroll, so a test can - * assert nothing reached for it. - */ - function departWithBlank(blankPx: number) { - const scrollToContentEnd = vi.fn(); - setScrollerMetrics(scroller, { - scrollHeight: CONTENT_END + VIEWPORT + TAIL_SPACER, - clientHeight: VIEWPORT, - scrollTop: CONTENT_END, - }); - act(() => { - root.render( - { controller = next; }} - onViewportOwner={next => { viewportOwner = next; }} - />, - ); - }); - expect(controller?.isFollowingOutput).toBe(true); - scrollToContentEnd.mockClear(); - frames.length = 0; - - scroller.scrollTop = CONTENT_END + blankPx; - act(() => controller?.handleUserScrollIntent()); - expect(controller?.isFollowingOutput).toBe(false); - return scrollToContentEnd; - } - - it('takes the viewport back when output fills the blank under a stationary reader', () => { - departWithBlank(100); - - // The resize observer's signal, which is the only one that arrives when - // the content moves and the reader does not. - setContentEnd(CONTENT_END + 150); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('resumes where the reader is instead of scrolling them to the end', () => { - const scrollToContentEnd = departWithBlank(100); - setContentEnd(CONTENT_END + 150); - act(() => controller?.scheduleFollowToLatest()); - - // Nothing to travel that the follow loop is not already about to cover: - // the blank closing *is* the two offsets meeting. What is left is the one - // sample of growth that closed it, and the loop eases that away — a - // quarter of the 50px on the first frame it runs, which is the one the - // resume's own re-render asks for. - expect(scrollToContentEnd).not.toHaveBeenCalled(); - expect(scroller.scrollTop).toBe(CONTENT_END + 100 + 50 * TAIL_EASE_ALPHA); - }); - - it('leaves the viewport with a reader whose hand is still on it', () => { - /* - * Measured: 320ms into a live gesture, a reader had climbed 200px while - * content grew 237, so the crossing was attributed to the content — which - * is correct, and acting on it would still have been wrong. - */ - departWithBlank(400); - viewportOwner?.claim('user-gesture', { holdForMs: USER_DRIVEN_SCROLL_WINDOW_MS }); - - scroller.scrollTop = CONTENT_END + 200; - setContentEnd(CONTENT_END + 237); - act(() => controller?.handleScroll()); - - expect(controller?.isFollowingOutput).toBe(false); - }); - - it('leaves a reader who climbed out past the content end reading history', () => { - departWithBlank(100); - - scroller.scrollTop = CONTENT_END - 300; - act(() => controller?.handleScroll()); - // Output arriving afterwards must not fetch them back down. - setContentEnd(CONTENT_END + 150); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(false); - }); - - it('leaves a reader who was never in the blank where they are', () => { - // Nothing crossed: they were above the end of content when they took the - // viewport and they are still above it. Only a transition is a question. - departWithBlank(0); - - setContentEnd(CONTENT_END + 150); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(false); - }); - - it('follows a reader who left the blank and came back down into it', () => { - /* - * The case a per-departure watch got wrong, and the reason the watch runs - * for as long as the reader holds the viewport. Measured as a report: - * scroll up until the blank is gone, scroll back down until it shows - * again, wait — and nothing happened, because the one crossing the watch - * was given had been spent on the way up. - */ - departWithBlank(400); - - // Up and out of the blank entirely. - scroller.scrollTop = CONTENT_END - 200; - act(() => controller?.handleScroll()); - expect(controller?.isFollowingOutput).toBe(false); - - // Back down until the blank is on screen again, and stop. - scroller.scrollTop = CONTENT_END + 250; - act(() => controller?.handleScroll()); - expect(controller?.isFollowingOutput).toBe(false); - - setContentEnd(CONTENT_END + 250); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('watches a reader who took the viewport from a follow that was already gone', () => { - // Every wheel notch exits, and only the first finds follow to give up. - // Gating the watch on that notch left the rest of the gesture unwatched. - departWithBlank(0); - act(() => controller?.handleUserScrollIntent()); - - scroller.scrollTop = CONTENT_END + 250; - act(() => controller?.handleScroll()); - setContentEnd(CONTENT_END + 250); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('reads nothing into a viewport a history prepend has just shifted', () => { - /* - * Measured: a session opened on three Turns, one scroll up paged the whole - * seven, and the prepend shifted the viewport 14252px to hold the reader's - * Turn still — before those heights were in the scroll range, so the blank - * between the two read as 6239px against a spacer of a few hundred. Taken - * at face value that is a reader sitting deep in the blank, which is the - * one state this rule acts on, and the next sample would have pulled them - * out of the history they had just asked for. - */ - departWithBlank(0); - - // The compensation: the viewport moves, the content end has not caught up. - scroller.scrollTop = CONTENT_END + TAIL_SPACER + 5_000; - act(() => controller?.handleScroll()); - // ...and now it has. Nothing crossed, because nothing was ever measured. - setContentEnd(CONTENT_END + TAIL_SPACER + 5_000); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(false); - }); - - it('judges a vetoed crossing again rather than spending it', () => { - /* - * The veto is about the reader's hand being on the wheel, not about the - * crossing being wrong. Deferring costs one sample; settling it would cost - * the whole episode. - */ - departWithBlank(400); - viewportOwner?.claim('user-gesture', { holdForMs: USER_DRIVEN_SCROLL_WINDOW_MS }); - - setContentEnd(CONTENT_END + 400); - act(() => controller?.scheduleFollowToLatest()); - expect(controller?.isFollowingOutput).toBe(false); - - // The claim lapses, the reader has not moved, and output is still coming. - viewportOwner?.release('user-gesture'); - setContentEnd(CONTENT_END + 420); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(true); - }); - - it('lets a reader who kept climbing through a vetoed crossing go', () => { - departWithBlank(400); - viewportOwner?.claim('user-gesture', { holdForMs: USER_DRIVEN_SCROLL_WINDOW_MS }); - - setContentEnd(CONTENT_END + 400); - act(() => controller?.scheduleFollowToLatest()); - expect(controller?.isFollowingOutput).toBe(false); - - // Still climbing, and now out-moving the content: the deferred crossing - // resolves against them, and the latch is spent for good. - scroller.scrollTop = CONTENT_END + 100; - setContentEnd(CONTENT_END + 410); - act(() => controller?.handleScroll()); - viewportOwner?.release('user-gesture'); - setContentEnd(CONTENT_END + 430); - act(() => controller?.scheduleFollowToLatest()); - - expect(controller?.isFollowingOutput).toBe(false); - }); - - it('keeps watching while the blank is only partly filled', () => { - departWithBlank(400); - - setContentEnd(CONTENT_END + 150); - act(() => controller?.scheduleFollowToLatest()); - expect(controller?.isFollowingOutput).toBe(false); - - setContentEnd(CONTENT_END + 400); - act(() => controller?.scheduleFollowToLatest()); - expect(controller?.isFollowingOutput).toBe(true); - }); + afterEach(() => { + act(() => root.unmount()); host.remove(); scroller.remove(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); activateSurface('local'); + }); + + it('opens at the one physical bottom and sleeps once settled even while streaming', () => { + props.opening = true; render(); settle(); + expect(scroller.scrollTop).toBe(1000); expect(readbacks).toContain(1000); + expect(frames.size).toBe(0); expect(controller.isFollowingOutputNow()).toBe(true); + normalTarget = 1250; signal(); expect(scroller.scrollTop).toBe(1250); + }); + it('places a submitted user message once, waits at top until output crosses the reading line, then eases monotonically', () => { + render(); submit(); expect(scroller.scrollTop).toBe(1400); + output = true; normalTarget = 1350; signal(); settle(); + expect(scroller.scrollTop).toBe(1400); expect(placements).toEqual(['turn-2']); + normalTarget = 1500; signal(); expect(scroller.scrollTop).toBe(1400); + tick(); expect(scroller.scrollTop).toBeGreaterThan(1400); expect(scroller.scrollTop).toBeLessThan(1500); + let previous = scroller.scrollTop; + while (frames.size) { tick(); expect(scroller.scrollTop).toBeGreaterThanOrEqual(previous); previous = scroller.scrollTop; } + expect(scroller.scrollTop).toBeCloseTo(1500, 0); expect(floor).toBe(1400); + }); + it('does not let a tall user message start following before assistant output exists', () => { + render(); submit(); normalTarget = 2100; signal(); settle(); expect(scroller.scrollTop).toBe(1400); + output = true; signal(); settle(); expect(scroller.scrollTop).toBe(2100); + }); + it('publishes live follow travel before native scroll delivery, not only during opening', () => { + render(); readbacks.length = 0; + normalTarget = 1400; signal(); tick(); + expect(readbacks).toEqual([scroller.scrollTop]); + expect(readbacks[0]).toBeGreaterThan(1000); + settle(); expect(readbacks.at(-1)).toBe(1400); + depart(); readbacks.length = 0; normalTarget = 1800; signal(); settle(); + expect(readbacks).toEqual([]); + }); + it('sleeps at the reachable physical endpoint when layout clamps before the resize notification', () => { + render(); normalTarget = 1600; signal(); tick(); + // Model native clamping independently from the last cached layout target. + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1700 }); + scroller.scrollTop = 900; + tick(); expect(scroller.scrollTop).toBe(900); expect(frames.size).toBe(0); + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 2000 }); + normalTarget = 1200; signal(); settle(); + expect(scroller.scrollTop).toBe(1200); expect(frames.size).toBe(0); + }); + it.each(['complete', 'stop', 'error'])('keeps a short reply top-aligned on %s and explicit bottom navigation', () => { + render(); submit(); output = true; normalTarget = 1200; props.streaming = false; render(); settle(); + expect(scroller.scrollTop).toBe(1400); expect(floor).toBe(1400); + act(() => controller.enterFollowOutput('jump-to-latest')); settle(); + expect(scroller.scrollTop).toBe(1400); expect(floor).toBe(1400); + }); + it('does not treat replay, hydration or ledger growth as a submission', () => { + render(); depart(); scroller.scrollTop = 500; + props.count = 20; normalTarget = 3000; render(); settle(); + expect(placements).toEqual([]); expect(scroller.scrollTop).toBe(500); expect(controller.isFollowingOutputNow()).toBe(false); + }); + it('waits for the exact submitted row to materialize without a timeout or moving history first', () => { + render(); renderable = false; submit(); now += 2000; render(); expect(scroller.scrollTop).toBe(1000); + renderable = true; signal(); expect(scroller.scrollTop).toBe(1400); expect(placements).toEqual(['turn-2']); + }); + it('cancels a pending placement on manual input', () => { + render(); renderable = false; submit(); depart(); renderable = true; signal(); settle(); + expect(placements).toEqual([]); expect(controller.isFollowingOutputNow()).toBe(false); + }); + it('a gesture interrupts animation immediately, and later output cannot regain control', () => { + render(); normalTarget = 1400; signal(); tick(); depart(); + scroller.scrollTop = 600; normalTarget = 2600; signal(); settle(); now += 10000; signal(); + expect(scroller.scrollTop).toBe(600); expect(frames.size).toBe(0); + }); + it('selection or disclosure releases follow until a fresh explicit action', () => { + render(); act(() => controller.exitFollowOutput('reader-interaction')); + normalTarget = 1300; signal(); settle(); expect(scroller.scrollTop).toBe(1000); + act(() => controller.enterFollowOutput('jump-to-latest')); settle(); expect(scroller.scrollTop).toBe(1300); + }); + it.each([false, true])('preserves follow intent through an answer and subsequent output (reader took over: %s)', reading => { + const row = document.createElement('div'); + row.className = 'virtual-item-wrapper'; row.dataset.virtualItemKey = 'question'; + row.dataset.flowchatInteraction = 'response'; + const answer = document.createElement('input'); answer.type = 'radio'; + row.append(answer); scroller.append(row); + render(); submit(); output = true; signal(); settle(); + if (reading) { depart(); scroller.scrollTop = 900; } + act(() => { answer.focus(); answer.click(); }); + expect(controller.isFollowingOutputNow()).toBe(!reading); + act(() => { row.replaceChildren(document.createTextNode('Answered')); delete row.dataset.flowchatInteraction; }); + normalTarget = 1300; signal(); settle(); + expect(scroller.scrollTop).toBe(reading ? 900 : 1400); + // Text and new card rows arrive later. Completion/growth does not repair + // lost intent, and may not override a genuine reader either. + props.count += 3; normalTarget = 2200; render(); signal(); settle(); + expect(scroller.scrollTop).toBe(reading ? 900 : 2200); + expect(controller.isFollowingOutputNow()).toBe(!reading); + }); + it('resumes only on deliberate downward arrival at bottom, not content catching a reader', () => { + render(); depart(); scroller.scrollTop = 600; normalTarget = 600; signal(); + act(() => controller.handleScroll()); expect(controller.isFollowingOutputNow()).toBe(false); + normalTarget = 1000; depart('after'); scroller.scrollTop = 1000; + act(() => controller.handleScroll()); expect(controller.isFollowingOutputNow()).toBe(true); + now += 250; normalTarget = 1100; signal(); settle(); expect(scroller.scrollTop).toBe(1100); + }); + it('downward input at the physical edge resumes even without a scroll event', () => { + render(); depart(); depart('after'); expect(controller.isFollowingOutputNow()).toBe(true); + }); + it('does not invent a reverse jump on completion or restart', () => { + render(); normalTarget = 1200; signal(); props.streaming = false; render(); settle(); + expect(scroller.scrollTop).toBe(1200); props.streaming = true; render(); expect(scroller.scrollTop).toBe(1200); + }); + it('returns to the Turn top after a tall card folds, then waits for output to fill again', () => { + render(); submit(); output = true; normalTarget = 1700; signal(); settle(); + normalTarget = 900; signal(); settle(); expect(scroller.scrollTop).toBe(1400); + normalTarget = 1300; signal(); settle(); expect(scroller.scrollTop).toBe(1400); + normalTarget = 1600; signal(); tick(); + expect(scroller.scrollTop).toBeGreaterThan(1400); expect(scroller.scrollTop).toBeLessThan(1600); + settle(); expect(scroller.scrollTop).toBe(1600); expect(floor).toBe(1400); + }); + it('manual collection folding keeps reader ownership even when short content reaches the physical end', () => { + render(); submit(); output = true; normalTarget = 1700; signal(); settle(); + act(() => controller.exitFollowOutput('reader-interaction')); + normalTarget = 900; scroller.scrollTop = 1400; // Browser clamps to the persistent Turn floor. + signal(); act(() => controller.handleScroll()); settle(); + expect(controller.isFollowingOutputNow()).toBe(false); + normalTarget = 1800; signal(); settle(); expect(scroller.scrollTop).toBe(1400); + act(() => controller.enterFollowOutput('jump-to-latest')); settle(); expect(scroller.scrollTop).toBe(1800); + }); + it('deliberate bottom arrival on a short Turn preserves its top alignment', () => { + render(); submit(); output = true; normalTarget = 1000; signal(); + depart(); scroller.scrollTop = 1200; + depart('after'); scroller.scrollTop = 1400; + act(() => controller.handleScroll()); now += 250; settle(); + expect(controller.isFollowingOutputNow()).toBe(true); expect(scroller.scrollTop).toBe(1400); + normalTarget = 1300; signal(); settle(); expect(scroller.scrollTop).toBe(1400); + }); + it('leaves history navigation and restored history snapshots under reader control', () => { + props.startAtTail = false; scroller.scrollTop = 400; render(); signal(); settle(); + expect(scroller.scrollTop).toBe(400); expect(controller.isFollowingOutputNow()).toBe(false); + submit(); expect(scroller.scrollTop).toBe(1400); + }); + it('initializes after empty hydration but cannot reacquire after subsequent reader interaction', () => { + props.count = 0; render(); props.count = 2; render(); expect(scroller.scrollTop).toBe(1000); + depart(); scroller.scrollTop = 500; props.count = 0; render(); props.count = 2; render(); + expect(scroller.scrollTop).toBe(500); + }); + it('keeps reader top position during viewport resizing', () => { + render(); depart(); scroller.scrollTop = 500; normalTarget = 1500; + act(() => controller.handleViewportResize({ viewportHeightDeltaPx: -200, wasAtTail: false })); + expect(scroller.scrollTop).toBe(500); + }); + it('suspends without discarding ownership, resumes from usable geometry', () => { + render(); props.suspended = true; normalTarget = 1400; render(); settle(); expect(scroller.scrollTop).toBe(1000); + props.suspended = false; render(); settle(); expect(scroller.scrollTop).toBe(1400); + }); + it('does not write into an inactive viewport', () => { + props.active = false; render(); expect(scroller.scrollTop).toBe(0); + props.active = true; render(); expect(scroller.scrollTop).toBe(1000); + }); + it('cancels a deferred send across session or device changes', () => { + render(); renderable = false; submit(); props.session = 'other'; render(); renderable = true; signal(); + expect(placements).toEqual([]); + renderable = false; submit(); act(() => activateSurface('peer')); renderable = true; signal(); + expect(placements).toEqual([]); expect(controller.isFollowingOutputNow()).toBe(false); + }); + it('rollbacks are explicit tail navigation, never new-message placement', () => { + render(); depart(); normalTarget = 400; + act(() => controller.handleTurnsRolledBack()); settle(); expect(scroller.scrollTop).toBe(400); expect(placements).toEqual([]); + }); + it('finishes already pending follow travel after completion without an extra target', () => { + render(); normalTarget = 1060; signal(); tick(); props.streaming = false; render(); settle(); + expect(scroller.scrollTop).toBe(1060); expect(frames.size).toBe(0); + }); + it('honors reduced motion', () => { + vi.mocked(window.matchMedia).mockReturnValue({ matches: true } as MediaQueryList); + render(); normalTarget = 1200; signal(); tick(); expect(scroller.scrollTop).toBe(1200); }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index b05bd6468e..0084d92551 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -1,1421 +1,281 @@ -import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; +import { traceViewportRepeating } from '@/infrastructure/diagnostics/flowChatViewportDiagnostics'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { - isViewportDiagnosticsEnabled, - roundViewportPx, - traceViewport, - traceViewportRepeating, -} from '@/infrastructure/diagnostics/flowChatViewportDiagnostics'; -import { - isTailFollowDiagnosticsEnabled, - noteTailFollowStep, -} from '@/infrastructure/diagnostics/flowChatTailFollowDiagnostics'; -import { - nextEasedScrollTopPx, - shouldEaseTailFollow, -} from '../../utils/flowChatTailEase'; + claimSubmittedMessageScrollIntent, finishSubmittedMessageScrollIntent, + subscribeSubmittedMessageScrollIntent, type SubmittedMessageScrollIntent, +} from '../../services/submittedMessageScrollIntent'; import { getMotionAwareScrollBehavior } from '../../utils/motionPreference'; import type { FlowChatViewportOwnerApi } from './useFlowChatViewportOwner'; -import { - contentEndScrollTop, - FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX, - isTailBlankMeasurable, - nextTailFollowState, - resolveAnimatedJumpBehavior, - resolveTailDepartureCrossing, - shouldResumeFollowAfterDeparture, - tailHoldMaxGapPx, - type TailFollowState, -} from './flowChatTailFollow'; - -export type FollowOutputEnterReason = - | 'jump-to-latest' - | 'new-turn' - | 'session-open' - | 'streaming-resumed' - | 'tail-caught-up' - | 'turns-rolled-back'; -export type FollowOutputExitReason = - | 'session-changed' - | 'user-scroll' - | 'scroll-to-turn' - | 'scroll-to-index'; - -/** - * Why a watch over a reader who owns the viewport ended. - * - * These are follow taking it back, a reader replacing a reveal, or the - * transcript going away. A *crossing* is deliberately not on this list: the - * reader can climb out of the reserved blank and scroll back down into it any - * number of times before either happens, and each is another chance for output - * to catch up with them. - */ -type TailWatchOutcome = - /** Something handed the viewport back — a new Turn, a jump, a snap, this. */ - | 'followed-again' - | 'navigated' - | 'reader-took-over' - | 'session-changed' - | 'unmounted'; +import { easeFollowOffset, resolveAnimatedJumpBehavior } from './flowChatTailFollow'; -type TailWatchOrigin = 'new-turn-reveal' | 'user-departure'; +export type FollowOutputEnterReason = 'jump-to-latest' | 'session-open' | 'streaming-resumed' | 'turns-rolled-back' | 'reader-at-bottom'; +export type FollowOutputExitReason = 'session-changed' | 'user-scroll' | 'reader-interaction' | 'scroll-to-turn' | 'scroll-to-index'; +export interface ViewportResizeInput { viewportHeightDeltaPx: number; wasAtTail: boolean } -interface UseFlowChatFollowOutputOptions { +interface Options { activeSessionId?: string; - latestTurnId: string | null; - /** - * Turns in the session ledger, so that an arrival can be told from a - * truncation. `latestTurnId` alone cannot: a rollback moves it backwards to a - * Turn that has been there all along, which is a change and not an arrival. - */ - dialogTurnCount: number; virtualItemCount: number; isStreaming: boolean; isViewportActive: boolean; - /** Restored history presentations must not start by pinning the tail. */ startAtTailOnMount?: boolean; - /** The native host has temporarily withdrawn the scroller from layout. */ isViewportSuspended?: () => boolean; scrollerRef: RefObject; - /** Height of the resident tail spacer currently rendered below the content. */ - getTailSpacerPx: () => number; - /** One-shot scroll placing the end of real content at the viewport bottom. */ - scrollToContentEnd: (behavior: ScrollBehavior) => void; - /** Reveal a rendered new Turn by placing the viewport at the physical bottom once. */ - revealNewTurnTail: (turnId: string) => boolean; - /** True while the transcript is still hidden for the opening reveal. */ + /** Refresh layout geometry once per layout signal; frames reuse this result. */ + readLayoutTarget: () => number; + /** Returns the measured top placement, or null while materializing the row. */ + placeSubmittedMessage: (intent: SubmittedMessageScrollIntent) => number | null; + hasRenderedOutput: (turnId: string) => boolean; + cancelPendingPlacement: () => void; + cancelNavigation: () => void; isOpeningViewport: () => boolean; - /** Immediate position readback after an opening follow correction. */ - onOpeningOffset?: (actualOffsetPx: number) => void; - /** - * Who is moving the viewport. Every write below goes through it, so that - * nothing else has to carry a private opinion about when this hook is busy. - */ + /** Publish accepted writes before the virtualizer selects its next window. */ + onViewportOffset?: (offset: number) => void; viewportOwner: FlowChatViewportOwnerApi; - /** - * Which transcript this hook belongs to, for the trail only. - * - * The list is keyed on the session, so every switch is a fresh instance with - * a fresh scroller — and two of them can overlap, or a second pane can hold - * one of its own. Read on its own, `followOutput.enter` followed by a - * boundary ask that passes the "is follow following" gate reads as an exit - * that left no trace; with this it reads as two instances, which is a - * different fault entirely. - */ viewportId?: number; } -export interface ViewportResizeInput { - /** Change in the scroller's client height; `0` on a reflow-only callback. */ - viewportHeightDeltaPx: number; - /** - * Whether the viewport was resting at the end of the transcript *before* the - * resize. Afterwards that is unknowable — a viewport on the content end and - * one parked deliberately above it are the same geometry. - */ - wasAtTail: boolean; -} - -interface UseFlowChatFollowOutputResult { - isFollowingOutput: boolean; - enterFollowOutput: (reason: FollowOutputEnterReason) => void; - exitFollowOutput: (reason: FollowOutputExitReason) => void; - scheduleFollowToLatest: () => void; - /** Whether follow-output owns the viewport now, not one render ago. */ - isFollowingOutputNow: () => boolean; - /** - * Whether the frame loop is actively moving the viewport right now. - * - * Not the same question as ownership, which outlives the loop on purpose so - * that streaming can resume following after the settle budget runs out. - */ - isFollowCorrectingViewport: () => boolean; - handleUserScrollIntent: () => void; - /** Turns were rolled back out of the session; end on the new tail. */ - handleTurnsRolledBack: () => void; - handleScroll: () => void; - /** - * The viewport was resized; keep whatever was on the bottom edge there. - */ - handleViewportResize: (input: ViewportResizeInput) => void; - /** Offset the follow rule owns, or `null` when it does not own the viewport. */ - getFollowTargetScrollTop: () => number | null; -} - -const BOTTOM_EPSILON_PX = 2; - -/** - * How long a programmatic smooth scroll of ours may own the viewport before the - * follow loop resumes writing. - * - * The loop assigns `scrollTop` outright, which cancels an in-flight smooth - * scroll on the very next frame — every `'smooth'` request in this hook was in - * practice a jump until it yielded. - * - * A backstop rather than the actual terminator: the yield normally ends when - * the animation stops moving the viewport, and this only covers an animation - * that never finishes at all. It was a *frame* count and could not be — 45 - * frames is 0.75s at 60Hz and 0.52s on a busy 200Hz display, so the duration a - * caller was buying depended on the machine. Measured: a jump to latest issued - * for 8717px animated 5480 of them and was finished by the loop in one 3290px - * write, 38% short. - */ -const SMOOTH_SCROLL_YIELD_MS = 1_200; +type Phase = 'reading' | 'pending-send' | 'awaiting-output' | 'following'; +const EPSILON = 1; -/** - * How long the viewport may sit still before our animated scroll counts as over. - * - * A duration and not a frame count, for the same reason the backstop above is: - * this was two frames, and two frames is 33ms at 60Hz and 10ms at 200Hz, where - * a smooth scroll has not visibly started. Measured on WebView2: a jump issued - * for 9734px was taken back 21ms after it was asked for, having animated - * nothing at all, and the reader saw an instant jump where the whole point was - * an animation. - * - * The number comes from the shape of the curve rather than from the platform's - * startup latency, which is the shorter of the two. A programmatic smooth - * scroll eases in: measured, 2px in its first 50ms against 9734px to travel. - * With scroll offsets quantised to 0.8px on that display, the early frames - * genuinely do not move, and visible increments arrive up to ~40ms apart. This - * is that, doubled. - * - * What it costs is the follow resuming this late after an animation that ends - * somewhere other than its target — which only happens while streaming moves - * the target underneath it, and is then a dozen pixels the ease absorbs. - * Arriving ends the yield immediately and does not wait for this. - */ -export const SMOOTH_SCROLL_STALL_MS = 120; - -/** - * Frames the follow target keeps being re-asserted after a non-streaming entry, - * refreshed whenever it actually moves. - * - * A session opens against an unsettled transcript: item heights are still - * estimates and `isPartial` sessions page older Turns in, so the end of content - * can travel thousands of pixels after the first alignment. The browser used to - * absorb that for free — a bottom-aligned scroll was clamped at `scrollHeight - - * clientHeight`, so any target at or past the end snapped onto it. The resident - * tail spacer removes that clamp, so the settle has to be explicit. - */ -const SETTLE_FRAMES = 90; - -export function useFlowChatFollowOutput({ - activeSessionId, - latestTurnId, - dialogTurnCount, - virtualItemCount, - isStreaming, - isViewportActive, - startAtTailOnMount = true, - isViewportSuspended = () => false, - scrollerRef, - getTailSpacerPx, - scrollToContentEnd, - revealNewTurnTail, - isOpeningViewport, - onOpeningOffset, - viewportOwner, - viewportId = 0, -}: UseFlowChatFollowOutputOptions): UseFlowChatFollowOutputResult { +/** One interruptible writer. Layout measures targets; RAF only spends their travel. */ +export function useFlowChatFollowOutput(options: Options) { + const current = useRef(options); + current.current = options; const [isFollowingOutput, setIsFollowingOutput] = useState(false); - const isFollowingOutputRef = useRef(false); - const isStreamingRef = useRef(isStreaming); - const isViewportActiveRef = useRef(isViewportActive); - const latestTurnIdRef = useRef(latestTurnId); - const isViewportSuspendedRef = useRef(isViewportSuspended); - isViewportSuspendedRef.current = isViewportSuspended; - const onOpeningOffsetRef = useRef(onOpeningOffset); - onOpeningOffsetRef.current = onOpeningOffset; - const followFrameRef = useRef(null); - const previousSessionIdRef = useRef(activeSessionId); - const previousLatestTurnIdRef = useRef(latestTurnId); - const previousDialogTurnCountRef = useRef(dialogTurnCount); - const hasMountedRef = useRef(false); - const wasStreamingRef = useRef(isStreaming); - - const followStateRef = useRef({ target: 0 }); - const followPhaseRef = useRef<'idle' | 'revealing-tail' | 'following-tail'>('idle'); - /** - * A Turn that arrived while it was not in the transcript on screen. - * - * Submitting from inside a history window is the case: the session gains the - * Turn a beat before the presentation is restored to the live tail, so the - * one moment the arrival is *detectable* is not a moment it can be answered. - * The answer is deferred rather than dropped — kept until the Turn can - * actually be revealed, which is what the reader is waiting to see. - */ - const pendingNewTurnIdRef = useRef(null); - const settleFramesRef = useRef(0); - /** When the yield to our own animated scroll lapses, if it has not ended. */ - const smoothScrollUntilMsRef = useRef(0); - /** Where the viewport was on the previous frame of that yield. */ - const smoothScrollLastTopRef = useRef(0); - /** When that yield last saw the viewport move, which is what ends it. */ - const smoothScrollLastMoveAtMsRef = useRef(0); - /** Where the animation started, so the trace can say how far it got. */ - const smoothScrollFromPxRef = useRef(0); - /** Last content-end geometry read, reused by continuous follow frames. */ - const lastScrollHeightPxRef = useRef(0); - - /* - * Deliberately *not* mirrored from `isFollowingOutput` here. - * - * Every other ref on this line is a prop, and a prop is whatever the render - * that assigned it was given — consistent by construction. Ownership is not: - * it is written imperatively by `enterFollowOutput` and `exitFollowOutput`, - * and the state is only how the rest of the component hears about it. Between - * the two, React is free to render the value from *before* the update — an - * update scheduled from a passive effect sits at a lower priority than a - * synchronous render, which then renders without it — and the assignment took - * ownership away from a writer that had just taken it, with nothing anywhere - * saying so. - * - * Measured on session open, which is the entry that comes from a mount - * effect and so hits this every time: `followOutput.enter` recorded at 31976, - * the first frame of the loop stood down 345ms later with `not-following` and - * its 90-frame budget untouched, and no `followOutput.exit` in the trail - * because nothing exited. The register still held `follow-output`, so the - * 22301px prepend that landed in between was left to a writer that was no - * longer running — `followTargetPx: null` while `heldBy: follow-output` — and - * the session was revealed at offset 0 of a window starting eight Turns above - * the tail. - */ - isStreamingRef.current = isStreaming; - isViewportActiveRef.current = isViewportActive; - latestTurnIdRef.current = latestTurnId; - - const stopFollowFrame = useCallback(() => { - if (followFrameRef.current !== null) { - cancelAnimationFrame(followFrameRef.current); - followFrameRef.current = null; - } + const following = useRef(false); + const phase = useRef('reading'); + const pending = useRef(null); + const submittedTurn = useRef(null); + const target = useRef(0); + const frame = useRef(null); + const lastFrameAt = useRef(null); + const downward = useRef<{ position: number } | null>(null); + const initialized = useRef(false); + const session = useRef(options.activeSessionId); + const run = useRef<(now: number) => void>(() => {}); + const refresh = useRef<() => void>(() => {}); + + const stop = useCallback(() => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = null; + lastFrameAt.current = null; + }, []); + const available = useCallback(() => { + const o = current.current; + return o.isViewportActive && !o.isViewportSuspended?.() && !document.hidden + && Boolean(o.scrollerRef.current?.clientHeight); + }, []); + const wake = useCallback(() => { + if (frame.current === null && following.current && available()) { + frame.current = requestAnimationFrame(now => run.current(now)); + } + }, [available]); + const discardPending = useCallback(() => { + if (pending.current) finishSubmittedMessageScrollIntent(pending.current); + const o = current.current; + const unbound = session.current ? claimSubmittedMessageScrollIntent(session.current, o.viewportId ?? 0, true) : null; + if (unbound) finishSubmittedMessageScrollIntent(unbound); + pending.current = null; + current.current.cancelPendingPlacement(); }, []); - - /** - * Whether the frame loop is actively moving the viewport. - * - * Ownership is the other question and outlives this one: the loop stops once - * the settle budget runs out, and follow keeps the viewport so that streaming - * can resume. Reading ownership as "something is correcting this" hands the - * viewport to a loop that is not running — measured, a drag came to rest - * 813px into the reserved blank with `isFollowingOutput` true, the loop - * asleep, and nothing to bring it back. - */ - const isFollowCorrectingViewport = useCallback(() => ( - isFollowingOutputRef.current && followFrameRef.current !== null - ), []); - - const readContentEndScrollTop = useCallback((scroller: HTMLElement) => { - const scrollHeightPx = scroller.scrollHeight; - lastScrollHeightPxRef.current = scrollHeightPx; - return contentEndScrollTop({ - scrollHeight: scrollHeightPx, - clientHeight: scroller.clientHeight, - tailSpacerPx: getTailSpacerPx(), - }); - }, [getTailSpacerPx]); - - /** - * The state the follow rule would hold for the current geometry, ignoring any - * offset it was holding. Used to resolve explicit follow targets and to - * resume on the live content end. - */ - const resolveFollowState = useCallback((scroller: HTMLElement): TailFollowState => { - const desired = readContentEndScrollTop(scroller); - return { target: desired }; - }, [readContentEndScrollTop]); - - const resolveFollowTargetScrollTop = useCallback((scroller: HTMLElement) => ( - resolveFollowState(scroller).target - ), [resolveFollowState]); - - /* - * --------------------------------------------------------------------------- - * A viewport in the reader's hands, watched for output catching up with them. - * - * The same crossing has two origins. A new Turn reveal deliberately starts in - * the resident blank while follow-output still owns the viewport; a user - * departure starts after a gesture releases it. In both cases output reaching - * the fixed viewport bottom is the event that may start ordinary tail follow. - * - * "Still looking at the blank" is `scrollTop > contentEnd`, because - * `contentEnd` is by definition the offset that puts the end of real content - * on the viewport's bottom edge. This watch only handles output catching up - * with a stationary reader; it never turns a user's resting position into a - * follow target. - * - * The watch therefore runs for as long as the reader holds the viewport, not - * for one crossing. `blankWasVisible` is the whole state it keeps between - * samples, and the rule is the obvious one: the blank was on screen and now it - * is not. Scoping it to a single crossing was tried and is wrong — a reader - * who climbs out of the blank, reads for a while and scrolls back down to sit - * in it again is in exactly the position the rule exists for, and had already - * spent the one crossing it was given. - * - * `content-caught-up` is what hands the viewport back, subject to - * `shouldResumeFollowAfterDeparture`; `reader-left-blank` is a reader who - * really did go and read history, and only clears the latch. Both raw deltas - * and the gesture claim are traced at each crossing rather than only the - * verdict, so the tie-break can still be revisited from a trail. - * --------------------------------------------------------------------------- - */ - const tailWatchRef = useRef<{ - origin: TailWatchOrigin; - openedAtMs: number; - /** Blank on screen when the watch opened. */ - exitBlankPx: number; - exitScrollTopPx: number; - exitContentEndPx: number; - exitStreaming: boolean; - /** Previous sample, so a crossing can be attributed to whatever moved. */ - lastScrollTopPx: number; - lastContentEndPx: number; - /** Whether the reserved blank was on screen as of the previous sample. */ - blankWasVisible: boolean; - samples: number; - crossings: number; - } | null>(null); - - const closeTailWatch = useCallback(( - outcome: TailWatchOutcome, - extra?: Record, - ) => { - const watch = tailWatchRef.current; - if (!watch) return; - tailWatchRef.current = null; - const scroller = scrollerRef.current; - const scrollTopPx = scroller?.scrollTop ?? watch.lastScrollTopPx; - const contentEndPx = scroller - ? readContentEndScrollTop(scroller) - : watch.lastContentEndPx; - traceViewport({ - location: 'followOutput.tailWatchEnded', - message: outcome === 'reader-took-over' - ? 'the reader replaced the passive new Turn reveal' - : 'the tail watch ended', - data: () => ({ - outcome, - origin: watch.origin, - viewportId, - forMs: Math.round(performance.now() - watch.openedAtMs), - samples: watch.samples, - crossings: watch.crossings, - blankAtExitPx: roundViewportPx(watch.exitBlankPx), - blankNowPx: roundViewportPx(scrollTopPx - contentEndPx), - // The two movements, over the whole life of the watch. - contentGrewPx: roundViewportPx(contentEndPx - watch.exitContentEndPx), - readerMovedPx: roundViewportPx(scrollTopPx - watch.exitScrollTopPx), - streamingAtExit: watch.exitStreaming, - streamingNow: isStreamingRef.current, - ...(extra ?? {}), - }), - }); - }, [readContentEndScrollTop, scrollerRef, viewportId]); - /* - * Held by identity for the unmount cleanup below. - * - * That cleanup must run when the transcript goes away and at no other time, - * and depending on the callback would instead run it whenever the callback is - * rebuilt — which is whenever `getTailSpacerPx` changes identity, and so - * potentially on every render of whoever owns this hook. A watch lives for as - * long as the reader keeps the viewport and spans many renders by - * construction, so an effect that tears down with the callback closes every - * one of them a frame after it opens. - */ - const closeTailWatchRef = useRef(closeTailWatch); - closeTailWatchRef.current = closeTailWatch; - - /** - * Start watching a viewport the reader has taken. - * - * Opened whether or not blank is on screen at the time. Where the reader was - * standing when they took it settles nothing — they can scroll down into the - * blank at any point afterwards, and the whole question is where they are when - * output next reaches the bottom edge. - */ - const openTailWatch = useCallback((origin: TailWatchOrigin) => { - const scroller = scrollerRef.current; - if (!scroller) return; - const contentEndPx = readContentEndScrollTop(scroller); - const blankPx = scroller.scrollTop - contentEndPx; - traceViewport({ - location: 'followOutput.tailWatch', - message: origin === 'new-turn-reveal' - ? 'the new Turn is revealed in the blank and watched for output catching up' - : 'the reader has the viewport and is watched for output catching up', - data: () => ({ - origin, - viewportId, - blankPx: roundViewportPx(blankPx), - blankVisible: blankPx > 0, - scrollTopPx: roundViewportPx(scroller.scrollTop), - contentEndPx: roundViewportPx(contentEndPx), - clientHeightPx: scroller.clientHeight, - isStreaming: isStreamingRef.current, - }), - }); - tailWatchRef.current = { - origin, - openedAtMs: performance.now(), - exitBlankPx: blankPx, - exitScrollTopPx: scroller.scrollTop, - exitContentEndPx: contentEndPx, - exitStreaming: isStreamingRef.current, - lastScrollTopPx: scroller.scrollTop, - lastContentEndPx: contentEndPx, - blankWasVisible: blankPx > 0, - samples: 0, - crossings: 0, - }; - }, [readContentEndScrollTop, scrollerRef, viewportId]); - - /** - * Stand the frame loop down for an animated scroll this hook is about to - * issue. Taken from the viewport as it is now, so the first frame of the - * yield can tell travel from a stall. - */ - const beginSmoothScrollYield = useCallback(() => { - const nowMs = performance.now(); - smoothScrollUntilMsRef.current = nowMs + SMOOTH_SCROLL_YIELD_MS; - smoothScrollLastMoveAtMsRef.current = nowMs; - smoothScrollLastTopRef.current = scrollerRef.current?.scrollTop ?? 0; - smoothScrollFromPxRef.current = smoothScrollLastTopRef.current; - }, [scrollerRef]); - - /** - * Take the viewport back, and say why. - * - * Every way this ends looks the same from outside — the follow simply starts - * writing again — and they are not the same fact. `stalled` after no travel - * at all is an animation that never ran, which is what a reader reports as - * the jump to latest having lost its animation; the same reason after most - * of the distance is the ordinary end of one. - */ - const endSmoothScrollYield = useCallback(( - reason: 'arrived' | 'stalled' | 'backstop' | 'superseded', - ) => { - if (smoothScrollUntilMsRef.current !== 0 && reason !== 'superseded') { - const travelledPx = (scrollerRef.current?.scrollTop ?? 0) - smoothScrollFromPxRef.current; - traceViewportRepeating(`smoothYield|${reason}|${Math.abs(travelledPx) < 1}`, { - location: 'followOutput.animatedScrollEnded', - message: 'the frame loop took the viewport back from its own animation', - travelPx: travelledPx, - data: () => ({ - reason, - travelledPx: roundViewportPx(travelledPx), - fromPx: roundViewportPx(smoothScrollFromPxRef.current), - scrollTopPx: roundViewportPx(scrollerRef.current?.scrollTop ?? 0), - }), - }); - } - smoothScrollUntilMsRef.current = 0; - }, [scrollerRef]); - - /** - * Issue the one-shot content-end scroll, yielding the frame loop to it when - * it is animated. Without the yield the next frame overwrites `scrollTop` and - * the animation never plays. - */ - const runContentEndScroll = useCallback((behavior: ScrollBehavior) => { - const resolvedBehavior = getMotionAwareScrollBehavior( - behavior === 'smooth' ? 'smooth' : 'auto', - ); - if (resolvedBehavior === 'smooth') { - beginSmoothScrollYield(); - } else { - // An instant scroll of ours replaces whatever was travelling. - endSmoothScrollYield('superseded'); - } - scrollToContentEnd(resolvedBehavior); - }, [beginSmoothScrollYield, endSmoothScrollYield, scrollToContentEnd]); - - /** - * Decide how a jump to latest travels, and record the decision. - * - * Traced because the two outcomes are indistinguishable afterwards — an - * animation that was never issued and one the loop cut short both end as a - * viewport that arrived without moving through anything — and they call for - * opposite fixes. This line says which one a reader is describing. - */ - const resolveJumpBehavior = useCallback((scroller: HTMLElement, targetPx: number) => { - const distanceBehavior = resolveAnimatedJumpBehavior({ - fromPx: scroller.scrollTop, - targetPx, - clientHeight: scroller.clientHeight, - }); - const behavior = getMotionAwareScrollBehavior(distanceBehavior); - const distancePx = Math.abs(targetPx - scroller.scrollTop); - traceViewportRepeating(`follow|jumpBehavior|${behavior}`, { - location: 'followOutput.jumpBehavior', - message: behavior === 'smooth' - ? 'the jump to latest is near enough to animate' - : distanceBehavior === 'smooth' - ? 'the jump to latest lands outright because reduced motion is enabled' - : 'the jump to latest is too far to animate, so it lands outright', - travelPx: targetPx - scroller.scrollTop, - data: () => ({ - behavior, - distanceBehavior, - viewportId, - distancePx: roundViewportPx(distancePx), - viewports: scroller.clientHeight > 0 - ? Math.round((distancePx / scroller.clientHeight) * 10) / 10 - : null, - clientHeightPx: scroller.clientHeight, - }), - }); - return behavior; - }, [viewportId]); - - /** Move the viewport to whatever the follow state currently owns. */ - const applyFollowTarget = useCallback((options: { - refreshContentEnd?: boolean; - } = {}) => { - const scroller = scrollerRef.current; - if (!scroller || isViewportSuspendedRef.current()) { - return; - } - - const remembered = followStateRef.current; - const opening = isOpeningViewport(); - // Streaming frames reuse geometry refreshed by ResizeObserver and explicit - // follow scheduling. This keeps the RAF loop from forcing a synchronous - // scrollHeight/style recalculation on every frame. - const refreshContentEnd = options.refreshContentEnd !== false || opening; - const desired = refreshContentEnd - ? readContentEndScrollTop(scroller) - : remembered.target; - /* - * While the transcript is opening it is still hidden, so nothing is gained - * by remembering an earlier offset: drop the memory and track the content - * end exactly. The virtualizer writes `scrollTop` too during this window — it - * compensates a history prepend from the item index before the prepended - * heights reach the DOM — and any accommodation of that is both invisible - * and, once paging stops, permanent. The gap tolerance is a *streaming* - * allowance: blank below the live output is acceptable only because more - * output is about to fill it. - */ - const previous: TailFollowState = opening - ? { target: desired } - : remembered; - const next = nextTailFollowState(previous, { - desiredScrollTop: desired, - maxGapPx: tailHoldMaxGapPx(scroller.clientHeight, getTailSpacerPx()), - }); - followStateRef.current = next; - // Content is still moving, so keep the settle window open. - if (Math.abs(next.target - previous.target) > BOTTOM_EPSILON_PX) { - settleFramesRef.current = SETTLE_FRAMES; - } - - let actualOffsetPx = scroller.scrollTop; - let writeGranted = true; - const onTarget = Math.abs(next.target - actualOffsetPx) <= BOTTOM_EPSILON_PX; - /* - * What the loop decided this frame, coalesced by the decision. - * - * A frame that writes nothing is the interesting one: it means the follow - * rule believes the viewport is already where it belongs, and the reader is - * looking at something else. Measured on a reopened session, that is the - * whole fault — a history window arrived above a viewport at 0 during the - * opening reveal, and whether the loop then aimed at the new content end or - * was still reading an unmeasured 0 could not be told apart from outside. - */ - if (isViewportDiagnosticsEnabled()) { - // Guarded rather than left to the coalescer, which is the rule for - // anything on this path: it runs on every frame of every follow, and even - // the key would be a string built sixty times a second for a switch that - // is off. - traceViewportRepeating( - `follow|frame|${onTarget}|${isOpeningViewport()}`, - { - location: 'followOutput.frame', - message: onTarget - ? 'the viewport is already on the offset the follow rule owns' - : 'the follow rule is moving the viewport to the offset it owns', - travelPx: next.target - scroller.scrollTop, - data: () => ({ - viewportId, - onTarget, - phase: followPhaseRef.current, - isOpening: opening, - desiredPx: roundViewportPx(desired), - targetPx: roundViewportPx(next.target), - scrollTopPx: roundViewportPx(scroller.scrollTop), - scrollRangePx: roundViewportPx(lastScrollHeightPxRef.current), - settleFrames: settleFramesRef.current, - smoothYieldActive: smoothScrollUntilMsRef.current !== 0, - }), - }, - ); - } - if (smoothScrollUntilMsRef.current !== 0) { - /* - * An animated scroll of ours is in flight and heading for this same - * target. Track the state, but leave the writing to it. - * - * It ends when the viewport stops moving for `SMOOTH_SCROLL_STALL_MS`, - * which is the animation's own answer rather than a guess at how long it - * takes: the browser scales the duration with the distance, and a jump to - * latest from the top of a transcript takes longer than anything else - * this issues. Arriving ends it too, and sooner. - */ - const nowMs = performance.now(); - if (scroller.scrollTop !== smoothScrollLastTopRef.current) { - smoothScrollLastTopRef.current = scroller.scrollTop; - smoothScrollLastMoveAtMsRef.current = nowMs; - } - if (onTarget) { - endSmoothScrollYield('arrived'); - } else if (nowMs >= smoothScrollUntilMsRef.current) { - endSmoothScrollYield('backstop'); - } else if (nowMs - smoothScrollLastMoveAtMsRef.current >= SMOOTH_SCROLL_STALL_MS) { - endSmoothScrollYield('stalled'); - } else { - return; - } - /* - * The animation is over, and the target has moved on under it — it aims - * at the offset it was issued for, and content arrives while it travels. - * Falling through rather than returning is what covers that growth on - * this frame instead of the next one. - */ - } - - if (!onTarget) { - const fromPx = scroller.scrollTop; - /* - * Eased across the frames the follow already has, rather than written in - * one step. - * - * The target moves when Markdown reflows, which is a whole line at a - * time with nothing in between, so an outright write spends a line of - * travel on one frame out of seven. This spends the same distance over - * all seven. It buys latency, not speed: under steady growth the offset - * settles where its per-frame catch-up equals the growth, so the step - * converges on the content's growth per frame whatever the fraction is. - * - * Only the *write* is eased. `followStateRef` still holds the true - * target, so the settle budget and the at-tail band both - * keep reading the offset the follow rule owns rather than how far - * behind its own ease is riding. - * - * Not while the transcript is opening. There the target is authoritative - * and the transcript is hidden, so an ease would only make the reveal - * wait for travel nobody can see — and the reveal is watching for the - * viewport to reach the content end. - */ - const step = !opening && shouldEaseTailFollow({ - scrollHeightPx: lastScrollHeightPxRef.current, - clientHeightPx: scroller.clientHeight, - }) - ? nextEasedScrollTopPx(fromPx, next.target) - : { offsetPx: next.target, outcome: 'snapped' as const }; - writeGranted = viewportOwner.write({ owner: 'follow-output', topPx: step.offsetPx }); - /* - * Read back rather than taken from the step. The register can refuse - * this write outright, and a refused follow moves nothing — believing - * the step there would report a follow that is being outranked as the - * smoothest one in the session, and would book the frame below forever - * over travel that never happens. - */ - actualOffsetPx = scroller.scrollTop; - const movedPx = actualOffsetPx - fromPx; - /* - * An ease in flight is a reason to run again, and the only one it has - * once the target stops moving: the budget is refreshed by the *target* - * travelling, so a correction arriving on the last frame of a settle - * would otherwise be abandoned partway. Bounded by the ease itself, - * which halves what is left every frame and is inside `BOTTOM_EPSILON_PX` - * within a handful of them. - */ - if (step.outcome === 'eased' && movedPx !== 0) { - settleFramesRef.current = Math.max(settleFramesRef.current, 1); - } - if (isTailFollowDiagnosticsEnabled()) { - noteTailFollowStep('list', { - stepPx: movedPx, - lagPx: next.target - fromPx, - innerScroll: true, - snapped: step.outcome === 'snapped', - }); - } - } - // Native scroll delivery can lag a frame behind this write. Publish the - // DOM readback through the existing virtualizer observer channel so range - // selection catches up in the same task without a synchronous flush. - if (writeGranted && opening && isFollowingOutputRef.current - && followPhaseRef.current === 'following-tail' && isViewportActiveRef.current - && !document.hidden && viewportOwner.currentOwner() === 'follow-output') { - onOpeningOffsetRef.current?.(actualOffsetPx); - } - }, [ - endSmoothScrollYield, - getTailSpacerPx, - isOpeningViewport, - readContentEndScrollTop, - scrollerRef, - viewportId, - viewportOwner, - ]); - - const runFollowFrame = useCallback(() => { - followFrameRef.current = null; - /* - * Why the loop is not running, which the trail could not say. - * - * "The session opened on the wrong Turn" has looked identical from outside - * whether follow was refused the viewport, was never following, or simply - * ran out of budget — all three are a viewport that stops where it was left - * and a log with nothing in it after `followOutput.enter`. Measured on a - * reopened session: `enter` at session-open, a history window prepended - * 22317px above 136ms later, and not one write for the next half second. - */ - const standDownReason = !isFollowingOutputRef.current - ? 'not-following' - : followPhaseRef.current !== 'following-tail' - ? 'revealing-tail' - : !isViewportActiveRef.current - ? 'viewport-inactive' - : isViewportSuspendedRef.current() - ? 'viewport-suspended' - : document.hidden - ? 'document-hidden' - : (!isStreamingRef.current && settleFramesRef.current <= 0) - // Streaming holds the loop open indefinitely; anything else runs - // only until the transcript stops moving. - ? 'settle-exhausted' - : null; - if (standDownReason !== null) { - traceViewportRepeating(`follow|standDown|${standDownReason}`, { - location: 'followOutput.frameStoodDown', - message: 'the follow loop stopped running', - data: () => ({ - reason: standDownReason, - viewportId, - settleFrames: settleFramesRef.current, - isStreaming: isStreamingRef.current, - isOpening: isOpeningViewport(), - followTargetPx: roundViewportPx(followStateRef.current.target), - scrollTopPx: roundViewportPx(scrollerRef.current?.scrollTop ?? 0), - }), - }); - return; - } - const streaming = isStreamingRef.current; - if (!streaming) { - settleFramesRef.current -= 1; - } - - applyFollowTarget({ refreshContentEnd: !streaming }); - followFrameRef.current = requestAnimationFrame(runFollowFrame); - }, [applyFollowTarget, isOpeningViewport, scrollerRef, viewportId]); - - const startFollowFrame = useCallback(() => { - if ( - followFrameRef.current === null && - isFollowingOutputRef.current && - followPhaseRef.current === 'following-tail' && - !isViewportSuspendedRef.current() && - (isStreamingRef.current || settleFramesRef.current > 0) - ) { - followFrameRef.current = requestAnimationFrame(runFollowFrame); - } - }, [runFollowFrame]); - - const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => { - if (!isViewportActiveRef.current) { - /* - * The one way this hook can decline to take the viewport and leave no - * trace at all — and it leaves the transcript with no continuous writer - * for the rest of its life, because nothing asks again until a Turn - * arrives or the session changes. A second pane holding an inactive copy - * of the same transcript looks exactly like this from the trail. - */ - traceViewportRepeating(`follow|enterDeclined|${reason}`, { - location: 'followOutput.enterDeclined', - message: 'follow-output was asked for a viewport that is not active', - data: () => ({ - reason, - viewportId, - scrollTopPx: roundViewportPx(scrollerRef.current?.scrollTop ?? 0), - }), - }); - return; - } - - if (reason === 'jump-to-latest' && followPhaseRef.current === 'revealing-tail') { - traceViewportRepeating('follow|jumpIgnored|revealing-tail', { - location: 'followOutput.jumpIgnored', - message: 'jump to latest was already satisfied by the new Turn reveal', - data: () => ({ viewportId }), - }); - return; - } - - /* A new Turn may arrive before the live-tail projection contains it. The - * one detectable arrival is retained until its one-shot reveal can run. */ - const revealTurnId = reason === 'new-turn' ? latestTurnIdRef.current : null; - const revealedNewTurn = revealTurnId !== null && revealNewTurnTail(revealTurnId); - if (reason === 'new-turn') { - pendingNewTurnIdRef.current = revealedNewTurn ? null : revealTurnId; - if (!revealedNewTurn) { - /* - * The viewport is deliberately left exactly as it was, so the only - * evidence that a submission was answered at all is this line. A - * deferral with no `followOutput.enter` after it is the reader's - * "nothing happened when I sent a message". - */ - traceViewportRepeating('follow|deferred-new-turn', { - location: 'followOutput.deferNewTurn', - message: 'new Turn is not in the transcript on screen yet, so the reveal waits', - data: () => ({ turnId: revealTurnId }), - }); - return; - } - } - - isFollowingOutputRef.current = true; - setIsFollowingOutput(true); - // Whatever this entry is, follow has the viewport, so there is nothing left - // to watch for. The crossing route comes through here too, having already - // traced why. - closeTailWatch('followed-again', { enterReason: reason }); - settleFramesRef.current = SETTLE_FRAMES; - traceViewportRepeating(`follow|enter|${reason}`, { - location: 'followOutput.enter', - message: 'follow-output took the viewport', - data: () => ({ - reason, - viewportId, - phase: revealedNewTurn ? 'revealing-tail' : 'following-tail', - isStreaming: isStreamingRef.current, - scrollTopPx: roundViewportPx(scrollerRef.current?.scrollTop ?? 0), - }), - }); - /* - * Ownership is taken here rather than at each write, because following is - * continuous: between two frames of the loop the viewport is still ours, - * and a correction slipping into that gap is the thing this prevents. A - * refused claim is not a reason to stop following — the loop keeps its - * state and simply writes nothing until whoever outranks it is done. - */ - viewportOwner.claim('follow-output'); - - const scroller = scrollerRef.current; - const contentEnd = scroller ? readContentEndScrollTop(scroller) : 0; - - if (revealedNewTurn && scroller && scroller.scrollTop > contentEnd) { - followPhaseRef.current = 'revealing-tail'; - endSmoothScrollYield('superseded'); - followStateRef.current = { target: scroller.scrollTop }; - stopFollowFrame(); - openTailWatch('new-turn-reveal'); - return; - } - - followPhaseRef.current = 'following-tail'; - followStateRef.current = { target: contentEnd }; - { - /* - * Output that caught up with a stationary reader is already at the end — - * the blank between them closing is what raised this — so the whole - * distance left is one sample of growth, and the frame loop's ease covers - * it on the next few frames. A one-shot scroll would spend that as a snap - * the reader can see, for a correction they cannot. - */ - if (reason !== 'tail-caught-up') { - /* - * Only a jump to latest is ever a candidate for an animation, and only - * a near one. Every other entry reason is the transcript resuming a - * follow it already owned, where an animation would be a movement the - * reader did not ask for. - */ - runContentEndScroll( - reason === 'jump-to-latest' && scroller - ? resolveJumpBehavior(scroller, contentEnd) - : 'auto', - ); - } - } - - startFollowFrame(); - }, [ - viewportOwner, - closeTailWatch, - endSmoothScrollYield, - openTailWatch, - readContentEndScrollTop, - revealNewTurnTail, - resolveJumpBehavior, - runContentEndScroll, - scrollerRef, - startFollowFrame, - stopFollowFrame, - viewportId, - ]); - - /** Release the viewport. A user gesture replaces any reveal watch with a reader watch. */ const exitFollowOutput = useCallback((reason: FollowOutputExitReason) => { - /* - * Traced whether or not there was anything to give up. An exit that finds - * follow already released is the answer to "who ended the follow this - * session opened with" being nobody — and gating the line on ownership is - * what made that case indistinguishable from the exit never being reached. - */ - traceViewportRepeating(`follow|exit|${reason}|${isFollowingOutputRef.current}`, { - location: 'followOutput.exit', - message: isFollowingOutputRef.current - ? 'follow-output gave the viewport up' - : 'follow-output was released while it held nothing', - data: () => ({ - reason, - viewportId, - wasFollowing: isFollowingOutputRef.current, - phase: followPhaseRef.current, - scrollTopPx: roundViewportPx(scrollerRef.current?.scrollTop ?? 0), - }), - }); - isFollowingOutputRef.current = false; - followPhaseRef.current = 'idle'; + const o = current.current; + following.current = false; + phase.current = 'reading'; + downward.current = null; + discardPending(); setIsFollowingOutput(false); - endSmoothScrollYield('superseded'); - viewportOwner.release('follow-output'); - stopFollowFrame(); - - /* - * A watch is open exactly while the reader holds the viewport, which is what - * every branch below maintains. `wasFollowing` is deliberately not the gate: - * this runs on every wheel notch rather than once per gesture, so gating on - * it would open a watch on the notch that took the viewport and nowhere - * else, and any exit that found follow already released — a reader scrolling - * on from a navigation, say — would be watched by nothing. - * - * Re-opening is what must not happen instead. The offsets a crossing is - * judged against are the previous sample's, and re-baselining them halfway - * through the gesture being judged is how a reader climbing steadily reads - * as one standing still. - */ - if (reason === 'session-changed') { - closeTailWatch('session-changed'); - return; - } - if (reason === 'scroll-to-turn' || reason === 'scroll-to-index') { - // A navigation puts the reader somewhere they did not travel to, so the - // watch starts again from there rather than carrying offsets across it. - closeTailWatch('navigated', { navigationReason: reason }); - } - if (reason === 'user-scroll' && tailWatchRef.current?.origin === 'new-turn-reveal') { - closeTailWatch('reader-took-over'); - } - if (tailWatchRef.current === null) { - openTailWatch('user-departure'); - } - }, [ - closeTailWatch, - endSmoothScrollYield, - openTailWatch, - scrollerRef, - stopFollowFrame, - viewportId, - viewportOwner, - ]); - - /** - * One sample of an open watch, from wherever the geometry can change. - * - * Costs a null check whenever follow owns the viewport, which is when nothing - * is being watched. Otherwise it reads `scrollHeight` — the same measurement - * the follow loop takes every frame while it *does* own the viewport, so this - * is that measurement continuing across the gap where it does not. - * - * Defined below `enterFollowOutput` because a crossing can hand the viewport - * back, which is the whole point of watching. - */ - const sampleTailWatch = useCallback(() => { - const watch = tailWatchRef.current; - if (!watch) return; - const scroller = scrollerRef.current; - if (!scroller || isViewportSuspendedRef.current()) return; - - const scrollTopPx = scroller.scrollTop; - const contentEndPx = readContentEndScrollTop(scroller); - const blankPx = scrollTopPx - contentEndPx; - const contentDeltaPx = contentEndPx - watch.lastContentEndPx; - const scrollDeltaPx = scrollTopPx - watch.lastScrollTopPx; - watch.lastScrollTopPx = scrollTopPx; - watch.lastContentEndPx = contentEndPx; - watch.samples += 1; - - /* - * Geometry read from two different moments, so neither the latch nor a - * verdict can be taken from it. A history prepend is the case: it shifts the - * viewport by the height it inserted, before that height has been measured - * into the scroll range, and the blank between the two reads as thousands of - * pixels. Left alone rather than cleared — a restructure says nothing about - * where the reader was standing before it, and the next honest sample is a - * frame away. - */ - if (!isTailBlankMeasurable({ blankPx, tailSpacerPx: getTailSpacerPx() })) return; - - const crossing = resolveTailDepartureCrossing({ - blankPx, - contentDeltaPx, - scrollDeltaPx, + stop(); + o.viewportOwner.release('follow-output'); + traceViewportRepeating(`follow|exit|${reason}`, { + location: 'followOutput.exit', message: 'The reader owns the viewport', + data: () => ({ reason, viewportId: o.viewportId }), }); - if (crossing === 'watching') { - watch.blankWasVisible = true; + }, [discardPending, stop]); + + run.current = now => { + frame.current = null; + if (!following.current || !available()) { lastFrameAt.current = null; return; } + const o = current.current; + const scroller = o.scrollerRef.current!; + if (phase.current === 'pending-send' || phase.current === 'awaiting-output') { + lastFrameAt.current = null; return; } - /* - * The blank is gone, but it was already gone last time: the reader is above - * the end of content and staying there, which is reading history and is not - * a crossing of anything. Only the transition is a question. - */ - if (!watch.blankWasVisible) return; + const from = scroller.scrollTop; + // The browser may already have clamped a shrinking layout before its + // ResizeObserver is delivered. Never animate toward an unreachable cache + // entry, or leave an idle RAF chasing it until the next token arrives. + const reachableTarget = Math.min(target.current, Math.max(0, scroller.scrollHeight - scroller.clientHeight)); + const elapsed = lastFrameAt.current === null ? 16 : Math.min(64, now - lastFrameAt.current); + lastFrameAt.current = now; + const opening = o.isOpeningViewport(); + const candidate = opening || getMotionAwareScrollBehavior('smooth') === 'auto' + ? reachableTarget : easeFollowOffset(from, reachableTarget, elapsed); + const next = Math.abs(candidate - reachableTarget) <= EPSILON ? reachableTarget : candidate; + if (Math.abs(reachableTarget - from) > EPSILON) { + // Refusal leaves the target intact. A short live gesture can outlast the + // reader's deliberate arrival at bottom; it still outranks every write. + if (o.viewportOwner.write({ owner: 'follow-output', topPx: next })) o.onViewportOffset?.(scroller.scrollTop); + } else if (from !== reachableTarget) { + if (o.viewportOwner.write({ owner: 'follow-output', topPx: reachableTarget })) o.onViewportOffset?.(scroller.scrollTop); + } + if (Math.abs(scroller.scrollTop - reachableTarget) > EPSILON) wake(); + else lastFrameAt.current = null; + }; - // Whether the reader's hand is still on it. The claim lapses on its own, - // so this separates a crossing during a gesture from one after it. - const gestureLive = viewportOwner.currentOwner() === 'user-gesture'; - const resumed = shouldResumeFollowAfterDeparture({ crossing, gestureLive }); - watch.crossings += 1; - /* - * A crossing the gesture vetoed keeps the latch, so the next sample judges - * the same transition again: the reader either keeps climbing, and is let go - * by a verdict that no longer needs the veto, or stops, and is followed once - * the claim lapses. Every other verdict is final for this crossing — coming - * back is what the reader has to do to raise the question again. - */ - if (!(crossing === 'content-caught-up' && gestureLive)) { - watch.blankWasVisible = false; + const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => { + if (!available()) return; + const o = current.current; + discardPending(); + downward.current = null; + submittedTurn.current = null; + o.cancelNavigation(); + o.viewportOwner.release('one-shot-navigation'); + if (reason !== 'reader-at-bottom') o.viewportOwner.release('user-gesture'); + o.viewportOwner.claim('follow-output'); + following.current = true; + phase.current = 'following'; + setIsFollowingOutput(true); + target.current = o.readLayoutTarget(); + const scroller = o.scrollerRef.current!; + const animate = reason === 'jump-to-latest' && resolveAnimatedJumpBehavior({ + fromPx: scroller.scrollTop, targetPx: target.current, clientHeight: scroller.clientHeight, + }) === 'smooth'; + if (!animate && reason !== 'reader-at-bottom') { + if (o.viewportOwner.write({ owner: 'follow-output', topPx: target.current })) o.onViewportOffset?.(scroller.scrollTop); } - traceViewport({ - location: 'followOutput.tailCrossing', - message: resumed - ? 'output caught up with the reader, so follow takes the viewport back' - : 'the blank closed under the reader, and follow leaves it alone', - data: () => ({ - crossing, - origin: watch.origin, - resumed, - gestureLive, - viewportId, - blankPx: roundViewportPx(blankPx), - contentDeltaPx: roundViewportPx(contentDeltaPx), - scrollDeltaPx: roundViewportPx(scrollDeltaPx), - scrollTopPx: roundViewportPx(scrollTopPx), - contentEndPx: roundViewportPx(contentEndPx), - crossingIndex: watch.crossings, - intoWatchMs: Math.round(performance.now() - watch.openedAtMs), - }), + traceViewportRepeating(`follow|enter|${reason}`, { + location: 'followOutput.enter', message: 'Following the desktop reading line', + data: () => ({ reason, viewportId: o.viewportId, targetPx: target.current }), }); - if (resumed) { - enterFollowOutput('tail-caught-up'); - } - }, [ - enterFollowOutput, - getTailSpacerPx, - readContentEndScrollTop, - scrollerRef, - viewportId, - viewportOwner, - ]); + wake(); + }, [available, discardPending, wake]); + + const trySubmission = useCallback(() => { + const o = current.current; + if (!o.activeSessionId || !available()) return false; + const intent = claimSubmittedMessageScrollIntent(o.activeSessionId, o.viewportId ?? 0); + if (intent && intent !== pending.current) { + if (pending.current) finishSubmittedMessageScrollIntent(pending.current); + pending.current = intent; + phase.current = 'pending-send'; + stop(); + o.cancelNavigation(); + // Submission is a new explicit placement, including from a history view. + o.viewportOwner.release('one-shot-navigation'); + o.viewportOwner.release('user-gesture'); + o.viewportOwner.claim('follow-output'); + following.current = true; + setIsFollowingOutput(true); + } + if (!pending.current) return false; + if (!pending.current.scope.isCurrent()) { exitFollowOutput('session-changed'); return false; } + const placed = o.placeSubmittedMessage(pending.current); + if (placed === null) return true; + submittedTurn.current = pending.current.turnId; + finishSubmittedMessageScrollIntent(pending.current); + pending.current = null; + phase.current = 'awaiting-output'; + target.current = placed; + initialized.current = true; + return true; + }, [available, exitFollowOutput, stop]); - /** - * Re-assert ownership after a layout change. This deliberately does not force - * the viewport to the content end: a tool-card collapse resizes the content - * too, and the hold rule is what keeps that from dragging earlier content - * down. - */ const scheduleFollowToLatest = useCallback(() => { - if (isViewportSuspendedRef.current()) return; - if ( - isFollowingOutputRef.current - && isViewportActiveRef.current - && followPhaseRef.current === 'revealing-tail' - ) { - // The reveal is intentionally passive: streamed growth consumes the - // visible blank while scrollTop stays fixed. Only the crossing is sampled. - sampleTailWatch(); - return; - } - if (!isFollowingOutputRef.current || !isViewportActiveRef.current) { - /* - * This is the transcript's content-change signal — the resize observer - * calls it before paint on every content box change — and for a viewport - * nobody is following it is the one place a blank closing under a - * stationary reader can be seen at all. Without it the departure would - * only ever be sampled when the reader moved, which is the case it is not - * watching for. - */ - sampleTailWatch(); - return; - } - settleFramesRef.current = SETTLE_FRAMES; - applyFollowTarget({ - refreshContentEnd: true, + if (!available()) return; + if (trySubmission()) return; + const o = current.current; + const next = o.readLayoutTarget(); + if (!following.current) return; // Growth can never take control from a reader. + if (phase.current === 'awaiting-output') { + if (!submittedTurn.current || !o.hasRenderedOutput(submittedTurn.current)) return; + if (next <= target.current + EPSILON) return; + phase.current = 'following'; + } + target.current = next; + traceViewportRepeating(`follow|layout|${o.viewportId}|${phase.current}`, { + location: 'followOutput.layout', message: 'Refreshed the physical follow endpoint', + data: () => ({ viewportId: o.viewportId, phase: phase.current, targetPx: next, + scrollTopPx: o.scrollerRef.current?.scrollTop, itemCount: o.virtualItemCount }), }); - startFollowFrame(); - }, [applyFollowTarget, sampleTailWatch, startFollowFrame]); - - const handleUserScrollIntent = useCallback(() => { - exitFollowOutput('user-scroll'); - }, [exitFollowOutput]); - - /** - * Turns were rolled back out of the session. - * - * The ledger cannot say this on its own — a shorter `dialogTurns` is also - * what a window re-cut and a hydration merge look like — so the rollback - * announces it, the same way a submission does. The transcript now ends - * somewhere else, so the answer is an ordinary content-end placement. - * - * This takes the viewport whether or not follow owned it. A rollback at - * Turn N removes N and everything after it, and the reader had N on screen — - * they clicked its own button. So the new tail is always within a Turn of - * where they already are, and there is no history below them to be pulled out - * of. Gating this on ownership instead made it dead code in the case it was - * written for: reaching a Turn far enough up to want it gone means scrolling, - * and scrolling is exactly what hands the viewport back to the reader. - * - * Without it the viewport anchor answers instead, and answers the wrong - * question — it holds the reader's Turn at its offset from the viewport top, - * so an 8-Turn session rolled back at Turn 7 came to rest showing Turns 2..6 - * with the new last Turn's answer below the fold. - */ - const handleTurnsRolledBack = useCallback(() => { - pendingNewTurnIdRef.current = null; - enterFollowOutput('turns-rolled-back'); - }, [enterFollowOutput]); - - const handleScroll = useCallback(() => { - if (isViewportSuspendedRef.current()) return; - // Scroll events describe the resulting viewport position, but do not prove user intent. - // Layout growth and virtualizer remeasurement can emit them while output follow still owns - // the viewport. Explicit wheel, touch, and keyboard handlers release that ownership instead. - // - // The other half of the departure sampler. Without it the remembered offset - // goes stale whenever the reader moves and the content does not, and the - // next crossing is attributed to the wrong one — which here would mean - // handing the viewport back to a reader who is still climbing out of it. - sampleTailWatch(); - }, [sampleTailWatch]); - - const getFollowTargetScrollTop = useCallback(() => ( - isFollowingOutputRef.current ? followStateRef.current.target : null - ), []); - - /** - * Whether follow-output owns the viewport *now*. - * - * The `isFollowingOutput` state answers the same question one render later, - * which is a different answer inside an event handler that just released it. - * A reader's gesture releases ownership and then asks whether the boundary - * they are on is worth paging: mirroring the state into a ref at render time - * made that ask see the ownership it had itself just ended, and refuse. - */ - const isFollowingOutputNow = useCallback(() => isFollowingOutputRef.current, []); - - /** - * The viewport was resized. Keep whatever was on the bottom edge on the - * bottom edge. - * - * A plain scroller preserves `scrollTop`, which anchors the *top* edge: the - * bottom is where content gets revealed or swallowed. For a transcript that - * is backwards — the interesting end is the bottom — so this anchors there - * instead. Follow output already behaves this way for a viewport it owns; - * this is the same rule for one it does not. - * - * The two halves are not equally capable, and the difference is worth - * knowing: - * - * - **A height change moves no content.** Preserving `scrollTop + - * clientHeight` is therefore exact and needs no judgement about what the - * user was doing. It also preserves the distance to the content end, so a - * viewport that was at the end stays at the end for free. Growing the - * viewport is additionally a restoration: the browser used to clamp a - * bottom-anchored viewport at `scrollHeight - clientHeight`, and the - * resident spacer removed that clamp. - * - **A width change reflows the transcript.** Where the line that was on the - * bottom edge went is a DOM question, and by the time a resize is observed - * the reflow has already happened — answering it would mean sampling an - * element anchor on the scroll path. The end of the transcript is the one - * position that can be recomputed from geometry, so that case is handled - * and the general one is not. - * - * Never animated. A height change moves the viewport by exactly the height - * that was added or removed, so nothing appears to move at all; the rest is a - * correction the user is already watching happen under their cursor. - * Ownership does not change either: a gesture ending in the blank expresses - * an intent to be at the end, a layout change expresses nothing. - */ - const handleViewportResize = useCallback((input: ViewportResizeInput) => { - const scroller = scrollerRef.current; - if (!scroller || isViewportSuspendedRef.current() || isFollowingOutputRef.current) { - // Follow re-asserts its own target through `scheduleFollowToLatest`. - return; + if (o.isOpeningViewport()) { + if (o.viewportOwner.write({ owner: 'follow-output', topPx: next })) o.onViewportOffset?.(o.scrollerRef.current!.scrollTop); } + wake(); + }, [available, trySubmission, wake]); + refresh.current = scheduleFollowToLatest; - traceViewportRepeating(`resize|${input.wasAtTail}|${input.viewportHeightDeltaPx !== 0}`, { - location: 'followOutput.viewportResize', - message: 'the scroller box changed under a viewport nobody was following', - travelPx: input.viewportHeightDeltaPx, - data: () => ({ - viewportHeightDeltaPx: roundViewportPx(input.viewportHeightDeltaPx), - wasAtTail: input.wasAtTail, - scrollTopPx: roundViewportPx(scroller.scrollTop), - clientHeightPx: scroller.clientHeight, - }), - }); - - if (input.viewportHeightDeltaPx !== 0) { - viewportOwner.write({ - owner: 'layout-correction', - topPx: Math.max(0, scroller.scrollTop - input.viewportHeightDeltaPx), - }); - } - - const followTarget = resolveFollowTargetScrollTop(scroller); - if ( - input.wasAtTail && - followTarget - scroller.scrollTop > FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX - ) { - viewportOwner.write({ owner: 'layout-correction', topPx: followTarget }); - return; - } - - }, [resolveFollowTargetScrollTop, scrollerRef, viewportOwner]); - - useEffect(() => { - if (!hasMountedRef.current) { - hasMountedRef.current = true; - if (virtualItemCount > 0 && startAtTailOnMount) { - enterFollowOutput(isStreaming ? 'streaming-resumed' : 'session-open'); + const handleScroll = useCallback(() => { + const o = current.current; + const demand = downward.current; + const scroller = o.scrollerRef.current; + if (!demand || !scroller || following.current || !available()) return; + const position = o.viewportOwner.readReaderScrollPosition(); + // A passive layout/scroll event is not downward reader travel. + if (position < demand.position - 0.5) { downward.current = null; return; } + if (position > demand.position + 0.5 + && Math.abs(scroller.scrollTop - o.readLayoutTarget()) <= 2) { + enterFollowOutput('reader-at-bottom'); + } + }, [available, enterFollowOutput]); + const handleUserScrollIntent = useCallback((direction?: 'before' | 'after', reason: 'user-scroll' | 'reader-interaction' = 'user-scroll') => { + exitFollowOutput(reason); + const o = current.current; + if (direction === 'after') { + downward.current = { position: o.viewportOwner.readReaderScrollPosition() }; + const scroller = o.scrollerRef.current; + if (scroller && Math.abs(scroller.scrollTop - o.readLayoutTarget()) <= 2) { + enterFollowOutput('reader-at-bottom'); } - return; } + }, [enterFollowOutput, exitFollowOutput]); - if (previousSessionIdRef.current !== activeSessionId) { - previousSessionIdRef.current = activeSessionId; - previousLatestTurnIdRef.current = latestTurnId; - previousDialogTurnCountRef.current = dialogTurnCount; + useLayoutEffect(() => { + if (session.current !== options.activeSessionId) { exitFollowOutput('session-changed'); - // A Turn waiting to be shown belongs to the session that gained it. - pendingNewTurnIdRef.current = null; - if (virtualItemCount > 0 && startAtTailOnMount) { - enterFollowOutput(isStreaming ? 'streaming-resumed' : 'session-open'); - } - return; - } - - /* - * An arrival, not a change. `latestTurnId` is the ledger's last Turn and it - * is the right identity — but a rollback truncates the ledger, which moves - * that identity *backwards* onto a Turn that has been there all along. Read - * as an arrival it revealed the survivor as though it were new, which is the - * reader's "I undid my message and it jumped to the one before it". - * - * The ledger growing is what separates the two. Nothing else that rewrites - * `dialogTurns` — a history page merging in above, a window re-cut, a - * hydration — moves the last Turn, so requiring growth costs nothing and - * excludes every truncation. - */ - const previousDialogTurnCount = previousDialogTurnCountRef.current; - previousDialogTurnCountRef.current = dialogTurnCount; - const isNewTurn = Boolean( - latestTurnId - && latestTurnId !== previousLatestTurnIdRef.current - && dialogTurnCount > previousDialogTurnCount, - ); - previousLatestTurnIdRef.current = latestTurnId; - if (virtualItemCount === 0) { - return; - } - if (isNewTurn) { - enterFollowOutput('new-turn'); - return; - } - /* - * The transcript changed without a new Turn, which is the moment a deferred - * one can become revealable — the presentation being restored to the live - * tail is exactly that. A retry that still cannot reveal it leaves the - * viewport alone and stays pending. - */ - if (pendingNewTurnIdRef.current === latestTurnId && latestTurnId !== null) { - enterFollowOutput('new-turn'); - } - }, [ - activeSessionId, - dialogTurnCount, - enterFollowOutput, - exitFollowOutput, - isStreaming, - startAtTailOnMount, - latestTurnId, - virtualItemCount, - ]); - + session.current = options.activeSessionId; + initialized.current = false; + } + if (!options.isViewportActive) { discardPending(); stop(); return; } + if (trySubmission()) return; + if (!initialized.current && options.virtualItemCount > 0 && available()) { + initialized.current = true; + if (options.startAtTailOnMount !== false) enterFollowOutput('session-open'); + } else scheduleFollowToLatest(); + }); + useEffect(() => subscribeSubmittedMessageScrollIntent(() => refresh.current()), []); useEffect(() => { - if (!isViewportActive) { - stopFollowFrame(); - return; - } - if (isFollowingOutput && isStreaming) { - scheduleFollowToLatest(); - } - }, [isFollowingOutput, isStreaming, isViewportActive, scheduleFollowToLatest, stopFollowFrame]); - - // Settle any blank the hold rule accumulated once output stops arriving. - // A short new-Turn reveal keeps its blank and its fixed viewport position. - useEffect(() => { - const wasStreaming = wasStreamingRef.current; - wasStreamingRef.current = isStreaming; - if (wasStreaming === isStreaming || isStreaming) { - return; - } - if ( - !isFollowingOutputRef.current - || followPhaseRef.current !== 'following-tail' - ) { - return; - } - - const scroller = scrollerRef.current; - if (!scroller) { - return; - } - const contentEnd = readContentEndScrollTop(scroller); - if (followStateRef.current.target - contentEnd > BOTTOM_EPSILON_PX) { - followStateRef.current = { target: contentEnd }; - runContentEndScroll('smooth'); - } - }, [isStreaming, readContentEndScrollTop, runContentEndScroll, scrollerRef]); - + const scope = getActiveSurfaceScope(); + const cancel = () => exitFollowOutput('session-changed'); + scope.signal.addEventListener('abort', cancel, { once: true }); + return () => { scope.signal.removeEventListener('abort', cancel); }; + }, [exitFollowOutput]); useEffect(() => { - const handleVisibilityChange = () => { - if (!document.hidden) { - scheduleFollowToLatest(); - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => document.removeEventListener('visibilitychange', handleVisibilityChange); - }, [scheduleFollowToLatest]); - - useEffect(() => stopFollowFrame, [stopFollowFrame]); - // A watch the transcript outlived is not an outcome, and leaving it open - // would drop it from the trail entirely. - useEffect(() => () => closeTailWatchRef.current('unmounted'), []); - + const visible = () => { if (document.hidden) stop(); else refresh.current(); }; + document.addEventListener('visibilitychange', visible); + return () => document.removeEventListener('visibilitychange', visible); + }, [stop]); + useEffect(() => () => { + stop(); + if (pending.current) finishSubmittedMessageScrollIntent(pending.current); + const o = current.current; + const intent = session.current ? claimSubmittedMessageScrollIntent(session.current, o.viewportId ?? 0, true) : null; + if (intent) finishSubmittedMessageScrollIntent(intent); + current.current.viewportOwner.release('follow-output'); + }, [stop]); + + // Completion changes no policy or target. The final rendered layout wakes + // the same follower, including typewriter frames delivered after completion. return { - isFollowingOutput, - enterFollowOutput, - exitFollowOutput, - scheduleFollowToLatest, - isFollowingOutputNow, - isFollowCorrectingViewport, - handleUserScrollIntent, - handleTurnsRolledBack, - handleScroll, - handleViewportResize, - getFollowTargetScrollTop, + isFollowingOutput, enterFollowOutput, exitFollowOutput, scheduleFollowToLatest, + isFollowingOutputNow: useCallback(() => following.current, []), + isFollowCorrectingViewport: useCallback(() => following.current, []), + getFollowTargetScrollTop: useCallback(() => following.current ? target.current : null, []), + handleUserScrollIntent, handleScroll, + handleTurnsRolledBack: useCallback(() => enterFollowOutput('turns-rolled-back'), [enterFollowOutput]), + // Reading keeps its top anchor; following recomputes the reading line. + handleViewportResize: useCallback((_input: ViewportResizeInput) => scheduleFollowToLatest(), [scheduleFollowToLatest]), }; } diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.test.tsx new file mode 100644 index 0000000000..9467cec78b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { useFlowChatLeadingExtent } from './useFlowChatLeadingExtent'; +import { getVirtualItemStableKey } from './virtualItemIdentity'; +import { readingLinePxForViewport } from './flowChatTailFollow'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +// Supplied measurements test extent/identity contracts, not rendered visuals. +const item = (id: string, kind = 'content') => ({ type: 'model-round', turnId: 'turn', data: { id }, + timeline: { key: id, kind, ...(kind.startsWith('group-') ? { group: { groupId: 'group' } } : {}) }, +}) as unknown as VirtualItem; +const cleanups: Array<() => void> = []; +afterEach(() => { cleanups.splice(0).forEach(cleanup => cleanup()); }); + +function setup() { + const host = document.createElement('div'); + const scroller = document.createElement('div'); + const extent = document.createElement('div'); + document.body.append(host, scroller); scroller.append(extent); + const root = createRoot(host); + let contentEnd = 3300; + const height = 800, line = readingLinePxForViewport(height, 160); + Object.defineProperties(scroller, { + clientHeight: { get: () => height }, + scrollHeight: { get: () => Math.max(contentEnd + height - line, Number.parseFloat(extent.style.minHeight || '0')) }, + }); + let items = [item('header', 'group-header'), item('member', 'group-members'), item('after')]; + let bounds = [{ startPx: 2000, endPx: 2040 }, { startPx: 2040, endPx: 3240 }, { startPx: 3240, endPx: 3300 }]; + let api: ReturnType; + let rebases = 0; + function Test({ scope }: { scope: string }) { + api = useFlowChatLeadingExtent({ scope, items, virtualizer: { getItemBounds: index => bounds[index] ?? null }, + scrollerRef: { current: scroller }, extentRef: { current: extent }, onAnchorRebased: () => { rebases++; } }); + return null; + } + const render = (scope = 'session:turn') => act(() => root.render()); + render(); + cleanups.push(() => { act(() => root.unmount()); host.remove(); scroller.remove(); }); + return { + get api() { return api!; }, extent, scroller, line, get rebases() { return rebases; }, render, + layout(nextItems: VirtualItem[], nextBounds: typeof bounds, end: number) { + items = nextItems; bounds = nextBounds; contentEnd = end; render(); + }, + }; +} + +describe('desktop leading extent lifecycle', () => { + it('reserves the reader position before shrink and lets regrowth consume it', () => { + const view = setup(); + view.api.refresh(1000); + view.scroller.scrollTop = 1980; + view.api.capture(1980); + expect(view.extent.style.minHeight).toBe('2780px'); + view.layout([item('header', 'group-header'), item('after')], + [{ startPx: 2000, endPx: 2040 }, { startPx: 2040, endPx: 2100 }], 2100); + // Before any resize callback, the range already prevents native clamp. + expect(view.scroller.scrollHeight - view.scroller.clientHeight).toBe(1980); + expect(view.api.snapshot()).toEqual({ key: getVirtualItemStableKey(item('header', 'group-header')), offset: 20 }); + view.api.refresh(1000); + expect(view.scroller.scrollHeight - 800).toBe(1980); + for (const end of [2200, 2300, 2500, 3000]) { + view.layout([item('header', 'group-header'), item('after')], + [{ startPx: 2000, endPx: 2040 }, { startPx: 2040, endPx: end }], end); + view.api.refresh(1000); + expect(view.scroller.scrollHeight - 800).toBeCloseTo(Math.max(1980, end - view.line)); + } + }); + it('uses the surviving header instead of pinning the following block to its former low position', () => { + const view = setup(); + view.api.refresh(1000); view.api.capture(2800); + view.layout([item('header', 'group-header'), item('after')], + [{ startPx: 2000, endPx: 2040 }, { startPx: 2040, endPx: 2100 }], 2100); + expect(view.api.snapshot()).toEqual({ key: 'block:header', offset: 8 }); + view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2792px'); + expect(view.rebases).toBe(1); + view.api.refresh(1000); + expect(view.rebases).toBe(1); + }); + it('releases the prior position on reader travel and on new turn/session/history scope', () => { + const view = setup(); + view.api.refresh(1000); view.api.capture(2800); view.api.capture(2200); + expect(view.extent.style.minHeight).toBe('3000px'); + view.render('session:new-turn'); view.api.refresh(1200); + expect(view.api.snapshot()).toBeNull(); + expect(view.extent.style.minHeight).toBe('2000px'); + view.render('history'); view.api.refresh(null); + expect(view.extent.style.minHeight).toBe(''); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.ts new file mode 100644 index 0000000000..ed23c3af8a --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.ts @@ -0,0 +1,131 @@ +import { useCallback, useMemo, useRef, type RefObject } from 'react'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import type { FlowChatVirtualizer } from './useFlowChatVirtualizer'; +import { getVirtualItemStableKey } from './virtualItemIdentity'; +import { leadingExtentFloor, type LeadingExtentAnchor } from './flowChatLeadingExtent'; +import { FLOWCHAT_TURN_TOP_GAP_PX } from './flowChatTailFollow'; + +type Virtualizer = Pick; + +/** Layout only. The follow/reader owners remain the only viewport writers. */ +export function useFlowChatLeadingExtent(options: { + scope: string; + items: readonly VirtualItem[]; + virtualizer: Virtualizer; + scrollerRef: RefObject; + extentRef: RefObject; + onAnchorRebased: () => void; +}) { + const { items, virtualizer, scrollerRef, extentRef, scope } = options; + const state = useRef<{ scope: string; anchor: LeadingExtentAnchor | null; turnFloor: number }>({ scope, anchor: null, turnFloor: 0 }); + const onAnchorRebased = useRef(options.onAnchorRebased); + onAnchorRebased.current = options.onAnchorRebased; + const indexes = useMemo(() => new Map(items.map((item, index) => [getVirtualItemStableKey(item), index])), [items]); + const groupHeaders = useMemo(() => { + const headers = new Map(); + items.forEach((item, index) => { + if (item.timeline?.kind === 'group-header') headers.set(item.timeline.group!.groupId, index); + }); + return headers; + }, [items]); + const current = useCallback(() => { + if (state.current.scope !== scope) state.current = { scope, anchor: null, turnFloor: 0 }; + return state.current; + }, [scope]); + const install = useCallback((floor: number | null) => { + const extent = extentRef.current; + const scroller = scrollerRef.current; + if (!extent || !scroller?.clientHeight) return; + const value = floor === null ? '' : `${floor + scroller.clientHeight}px`; + if (extent.style.minHeight !== value) extent.style.minHeight = value; + }, [extentRef, scrollerRef]); + const mountedBounds = useCallback((key: string) => { + const scroller = scrollerRef.current; + if (!scroller) return null; + const row = [...scroller.querySelectorAll('.virtual-item-wrapper[data-virtual-item-key]')] + .find(element => element.dataset.virtualItemKey === key); + if (!row) return null; + const rect = row.getBoundingClientRect(); + const startPx = scroller.scrollTop + rect.top - scroller.getBoundingClientRect().top; + return { startPx, endPx: startPx + rect.height }; + }, [scrollerRef]); + + const capture = useCallback((offset: number, exact = false) => { + const scroller = scrollerRef.current; + if (!scroller?.clientHeight || !items.length) return; + const s = current(); + // Frames use cached measurements only: no DOM traversal or row remeasure. + let low = 0, high = items.length; + while (low < high) { + const middle = (low + high) >>> 1; + const bounds = virtualizer.getItemBounds(middle); + if (!bounds || bounds.endPx <= offset) low = middle + 1; + else high = middle; + } + let index = low; + if (exact) { + const top = scroller.getBoundingClientRect().top; + const row = [...scroller.querySelectorAll('.virtual-item-wrapper[data-virtual-item-key]')] + .find(element => { + const rect = element.getBoundingClientRect(); + return rect.bottom > top && rect.top < top + scroller.clientHeight; + }); + if (row) index = indexes.get(row.dataset.virtualItemKey!) ?? index; + } + const item = items[index]; + if (!item) return; + const key = getVirtualItemStableKey(item); + const bounds = (exact ? mountedBounds(key) : null) ?? virtualizer.getItemBounds(index); + if (!bounds || bounds.startPx >= offset + scroller.clientHeight) return; + const groupIndex = item.timeline?.group ? groupHeaders.get(item.timeline.group.groupId) : undefined; + const groupHeaderKey = groupIndex === undefined ? undefined : getVirtualItemStableKey(items[groupIndex]); + const groupBounds = groupIndex === undefined ? null + : (exact && groupHeaderKey ? mountedBounds(groupHeaderKey) : null) ?? virtualizer.getItemBounds(groupIndex); + s.anchor = { key, offsetPx: bounds.startPx - offset, heightPx: bounds.endPx - bounds.startPx, + groupHeaderKey, groupHeaderOffsetPx: groupBounds ? groupBounds.startPx - offset : undefined }; + // Install before any later shrink, so native clamping cannot paint a frame + // at the shortened tail before a ResizeObserver could repair it. + install(Math.max(s.turnFloor, offset)); + }, [current, groupHeaders, indexes, install, items, mountedBounds, scrollerRef, virtualizer]); + + const refresh = useCallback((turnFloor: number | null) => { + const s = current(); + s.turnFloor = turnFloor ?? 0; + const anchor = s.anchor; + let floor = turnFloor; + if (anchor) { + const primaryIndex = indexes.get(anchor.key); + const index = primaryIndex ?? (anchor.groupHeaderKey ? indexes.get(anchor.groupHeaderKey) : undefined); + if (index === undefined) s.anchor = null; + else { + const key = getVirtualItemStableKey(items[index]); + const bounds = mountedBounds(key) ?? virtualizer.getItemBounds(index); + if (bounds) { + const resolved = leadingExtentFloor({ anchor, ...bounds, usesGroupHeader: primaryIndex === undefined }); + floor = Math.max(floor ?? 0, resolved.floorPx); + if (key !== anchor.key || resolved.offsetPx !== anchor.offsetPx) { + s.anchor = { key, offsetPx: resolved.offsetPx, heightPx: bounds.endPx - bounds.startPx }; + // A removed member/vanished card interior must not leave an older + // reader anchor restoring the successor's former lower position. + onAnchorRebased.current(); + } + } + } + } + install(floor); + }, [current, indexes, install, items, mountedBounds, virtualizer]); + + // Structural transactions use the same leading edge. Choosing a surviving + // successor instead would cancel the upward movement of content after a fold. + const snapshot = useCallback(() => { + const anchor = current().anchor; + if (!anchor) return null; + if (indexes.has(anchor.key)) return { key: anchor.key, offset: anchor.offsetPx }; + if (anchor.groupHeaderKey && indexes.has(anchor.groupHeaderKey)) return { + key: anchor.groupHeaderKey, + offset: Math.max(FLOWCHAT_TURN_TOP_GAP_PX, anchor.groupHeaderOffsetPx ?? FLOWCHAT_TURN_TOP_GAP_PX), + }; + return null; + }, [current, indexes]); + return { capture, refresh, snapshot }; +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts index 4ac47b5b36..6a8257eac1 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts @@ -515,7 +515,7 @@ export function useFlowChatViewportAnchor({ }); return 'stood-down'; } - const element = findRenderedTurnAnchorElement(scroller, anchor.turnId); + const element = findRenderedTurnAnchorElement(scroller, anchor.turnId, anchor.itemKey); /* * The anchored Turn can be absent from the rendered window, and one frame * of absence is not the same fact as the reader having left it behind. @@ -730,7 +730,7 @@ export function useFlowChatViewportAnchor({ const scroller = scrollerRef.current; if (source === 'resize' && scroller) { const anchor = anchorRef.current; - const element = anchor ? findRenderedTurnAnchorElement(scroller, anchor.turnId) : null; + const element = anchor ? findRenderedTurnAnchorElement(scroller, anchor.turnId, anchor.itemKey) : null; const geometry = JSON.stringify([ scroller.scrollHeight, scroller.clientWidth, scroller.clientHeight, anchor?.turnId, element ? readTurnAnchorOffsetPx(scroller, element) + scroller.scrollTop : null, diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx index 874f8ef51b..19b5d8af98 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx @@ -139,14 +139,15 @@ describe('initial virtual window with the real virtualizer', () => { shortOverscan = true; render(34, true); reconcileEnabled = true; - expect(windows.at(-1)![0]).toBe(27); + const initialFirst = windows.at(-1)![0]; + expect(initialFirst).toBeGreaterThan(0); const scroller = host.querySelector('[data-scroller]')!; act(() => { latestApi.scrollToOffset(scroller.scrollHeight - 500, { owner: 'follow-output' }); latestApi.syncViewportOffset(scroller.scrollTop); }); const first = windows.at(-1)![0]; - expect(first).toBeLessThan(27); + expect(first).toBeLessThan(initialFirst); expect(windows.at(-1)!.at(-1)).toBe(33); const row = host.querySelector(`[data-virtual-index="${first}"]`); const commits = windows.length; @@ -178,6 +179,27 @@ describe('initial virtual window with the real virtualizer', () => { expect(windows.at(-1)![0]).toBe(0); }); + it('keeps the live window aligned when native layout clamps before its scroll event', () => { + render(34, true); + const scroller = host.querySelector('[data-scroller]')!; + act(() => { + latestApi.scrollToOffset(scroller.scrollHeight - 500, { owner: 'follow-output' }); + latestApi.syncViewportOffset(scroller.scrollTop); + }); + // A collection above the output shrinks. The browser clamps now, while + // native scroll/scroll-end delivery still describes the previous range. + shortOverscan = true; + act(() => { + scroller.scrollTop = Math.max(0, scroller.scrollHeight - 500); + latestApi.measureRenderedItems(); + }); + const measuredWindow = [...windows.at(-1)!]; + const mountedRows = [...host.querySelectorAll('[data-virtual-index]')]; + act(() => scroller.dispatchEvent(new Event('scroll'))); + expect(windows.at(-1)).toEqual(measuredWindow); + expect([...host.querySelectorAll('[data-virtual-index]')]).toEqual(mountedRows); + }); + it('keeps the measured reading window stable before the compensation scroll event arrives', () => { shortOverscan = true; render(34, true); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx index 7c88f12494..1adecb9890 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx @@ -34,9 +34,10 @@ interface HarnessProps { items: Item[]; onApi: (api: FlowChatVirtualizer) => void; isViewportSuspended?: () => boolean; + widthPx?: number; } -function Harness({ scroller, header, items, onApi, isViewportSuspended }: HarnessProps) { +function Harness({ scroller, header, items, onApi, isViewportSuspended, widthPx }: HarnessProps) { const scrollerRef = React.useRef(scroller); const headerRef = React.useRef(header); onApi(useFlowChatVirtualizer({ @@ -46,6 +47,7 @@ function Harness({ scroller, header, items, onApi, isViewportSuspended }: Harnes getItemKey: (item: Item) => item.key, estimateItemHeightPx: (item: Item) => item.estimatedHeightPx ?? ESTIMATE_PX, getKnownItemHeightPx: (item: Item) => item.knownHeightPx, + estimateContext: { availableWidthPx: widthPx }, isViewportSuspended, scrollPaddingStartPx: 0, writeViewport: () => true, @@ -148,6 +150,33 @@ describe('useFlowChatVirtualizer measurement', () => { ]); }); + it('never measures nested code or subagent viewport rows as transcript rows', () => { + renderRow(0, REAL_PX); + const nested = document.createElement('div'); + nested.setAttribute('data-flowchat-virtual-viewport', ''); + const line = document.createElement('div'); + line.setAttribute('data-virtual-index', '0'); + Object.defineProperty(line, 'offsetHeight', { value: 9999 }); + nested.appendChild(line); scroller.appendChild(nested); + expect(measureAndRead([0])).toEqual([{ startPx: 0, endPx: REAL_PX }]); + }); + + it('retains unchanged mounted heights when scrollbar width invalidates the size cache', () => { + [0, 1, 2].forEach(index => renderRow(index, REAL_PX)); + measureAndRead([2]); + + // Opening a collection introduces a scrollbar. Compact rows keep the same + // border box, so ResizeObserver will not send another height change. + for (const widthPx of [540, 525, 540]) { + act(() => root.render( + { api = next; }} />, + )); + expect(api.getItemBounds(2)).toEqual({ startPx: REAL_PX * 2, endPx: REAL_PX * 3 }); + } + }); + it('replaces a stale measured size when a stable row is collected and restores its estimate when it returns', () => { renderRow(1, 300); measureAndRead([2]); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts index 29447cffa7..6520d9e5cf 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -29,6 +29,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ import { observeElementOffset as observeTanStackElementOffset, observeElementRect as observeTanStackElementRect, + measureElement as measureTanStackElement, useVirtualizer, type Rect, type Virtualizer, @@ -45,8 +46,16 @@ import { type VirtualItemHeightEstimateContext, } from './virtualMessageListLayout'; -/** Item-count overscan. Roughly two Turns either side of the viewport. */ -const FLOW_CHAT_OVERSCAN_ITEMS = 6; +/** A nearby pixel window, bounded in leaves so dense groups mount incrementally. */ +export function timelineOverscanRange(start: number, end: number, count: number, viewportPx: number, + heightAt: (index: number) => number): number[] { + let first = start, last = end; + const budget = Math.max(200, viewportPx * 0.6); + let above = 0, below = 0; + while (first > 0 && start - first < 16 && (above < budget || start - first < 2)) above += Math.max(1, heightAt(--first)); + while (last < count - 1 && last - end < 16 && (below < budget || last - end < 2)) below += Math.max(1, heightAt(++last)); + return Array.from({ length: Math.max(0, last - first + 1) }, (_, index) => first + index); +} /** * How long the library goes on re-aiming after an aim, mirrored from it. @@ -146,6 +155,7 @@ interface MeasuringVirtualizer { } export interface UseFlowChatVirtualizerOptions { + pinnedKeys?: ReadonlySet; items: readonly T[]; scrollerRef: RefObject; /** @@ -329,6 +339,7 @@ export function virtualWindowPaddingPx( export function useFlowChatVirtualizer({ items, + pinnedKeys, scrollerRef, headerRef, getItemKey, @@ -345,13 +356,21 @@ export function useFlowChatVirtualizer({ }: UseFlowChatVirtualizerOptions): FlowChatVirtualizer { const itemsRef = useRef(items); itemsRef.current = items; + useLayoutEffect(() => { + const scroller = scrollerRef.current; + if (scroller && !scroller.hasAttribute('data-flowchat-virtual-viewport')) scroller.setAttribute('data-flowchat-virtual-viewport', ''); + }); const initialTailRef = useRef(startAtTailOnMount); const hasInitialItemsRef = useRef(items.length > 0); if (items.length > 0) hasInitialItemsRef.current = true; const reconcileOpeningMeasurementRef = useRef(reconcileOpeningMeasurement); reconcileOpeningMeasurementRef.current = reconcileOpeningMeasurement; const pendingMeasurementRef = useRef(false); + const resettingMeasurementsRef = useRef(false); const pendingMeasurementShiftRef = useRef(false); + // Keep the estimated initial tail seed until a real placement or gesture has + // established the viewport. Later measurements must use that live position. + const viewportOffsetEstablishedRef = useRef(false); const publishMeasuredOffsetRef = useRef<((actualOffsetPx?: number) => void) | null>(null); const syncViewportOffset = useCallback((actualOffsetPx: number) => { if (Number.isFinite(actualOffsetPx)) publishMeasuredOffsetRef.current?.(actualOffsetPx); @@ -419,14 +438,19 @@ export function useFlowChatVirtualizer({ return item === undefined ? 0 : estimateItemHeightRef.current(item, estimateContextRef.current); }, []); + const itemKeyRevision = useMemo(() => items.map(getItemKey).join('\u0000'), [items, getItemKey]); const resolveItemKey = useCallback((index: number) => { // Recreate the callback when estimate inputs change. TanStack uses the // callback identity to invalidate its derived measurement positions while // retaining DOM-measured sizes in its key cache. void estimateContextRevision; + void itemKeyRevision; const item = itemsRef.current[index]; return item === undefined ? index : getItemKeyRef.current(item); - }, [estimateContextRevision]); + }, [estimateContextRevision, itemKeyRevision]); + + const attachingRowsRef = useRef(new Set()); + const measurementBatchRef = useRef | null>(null); const virtualizer = useVirtualizer({ // A live-tail open previously mounted rows 0..13 before moving to 22..33; @@ -447,11 +471,13 @@ export function useFlowChatVirtualizer({ observeElementRect: observeFlowChatViewportRect, observeElementOffset: (instance, callback) => { let synchronized = false; - const publish = (actualOffsetPx?: number) => { - const scroller = instance.scrollElement; - if (!scroller || scroller !== scrollerRef.current || isViewportSuspendedRef.current()) return; - const actualOffset = actualOffsetPx ?? scroller.scrollTop; + viewportOffsetEstablishedRef.current = false; + const publish = (actualOffsetPx?: number) => { + const scroller = instance.scrollElement; + if (!scroller || scroller !== scrollerRef.current || isViewportSuspendedRef.current()) return; + const actualOffset = actualOffsetPx ?? scroller.scrollTop; synchronized = true; + viewportOffsetEstablishedRef.current = true; // false avoids a nested flushSync while React is attaching measured rows. if (instance.scrollOffset !== actualOffset) callback(actualOffset, false); }; @@ -460,7 +486,10 @@ export function useFlowChatVirtualizer({ // TanStack's debounced scroll-end callback captures the last native-event // offset. It must not undo a newer synchronous measurement reconciliation. if (synchronized && !isScrolling && instance.scrollElement) offset = instance.scrollElement.scrollTop; - if (isScrolling) synchronized = false; + if (isScrolling) { + synchronized = false; + viewportOffsetEstablishedRef.current = true; + } callback(offset, isScrolling); }); return () => { @@ -483,13 +512,26 @@ export function useFlowChatVirtualizer({ // Ordinary reading also shifts the real viewport when measured rows above // it shrink. Publish that readback after the size cache updates, before // selecting a window from the old offset and unmounting those same rows. - if (reconciled || shifted) publishMeasuredOffsetRef.current?.(); + // A physical range shrink can also clamp follow without a registered + // shift. Every measured window uses the actual offset, including live + // follow after opening; a delayed native event must not select old rows. + if (reconciled || shifted || viewportOffsetEstablishedRef.current) publishMeasuredOffsetRef.current?.(); }, estimateSize, + measureElement: (element, entry, instance) => measurementBatchRef.current?.get(element) + ?? measureTanStackElement(element, entry, instance), getItemKey: resolveItemKey, // Items carry their own index attribute already; measuring reads it back. indexAttribute: 'data-virtual-index', - overscan: FLOW_CHAT_OVERSCAN_ITEMS, + overscan: 0, + rangeExtractor: range => { + const indexes = new Set(timelineOverscanRange(range.startIndex, range.endIndex, range.count, + scrollerRef.current?.clientHeight ?? 0, estimateSize)); + if (pinnedKeys?.size) itemsRef.current.forEach((item, index) => { + if (pinnedKeys.has(getItemKeyRef.current(item))) indexes.add(index); + }); + return [...indexes].sort((a, b) => a - b); + }, scrollMargin: contentStartPx, scrollPaddingStart: scrollPaddingStartPx, /* @@ -511,6 +553,7 @@ export function useFlowChatVirtualizer({ // An instance field rather than an option, so it is assigned here — before // any measurement callback can reach `resizeItem`. virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta) => { + if (resettingMeasurementsRef.current) return false; pendingMeasurementRef.current = true; const scroller = scrollerRef.current; if (!scroller) return false; @@ -621,7 +664,21 @@ export function useFlowChatVirtualizer({ contentStartPx, ); - const measureRowElement = virtualizer.measureElement; + // Ref attachment interleaves with descendant layout effects. Reading each + // row here used to force a separate style/layout pass for every new card. + const measureRowElement = useCallback((element: HTMLElement | null) => { + if (element) attachingRowsRef.current.add(element); + }, []); + useLayoutEffect(() => { + const attached = [...attachingRowsRef.current].filter(element => element.isConnected); + attachingRowsRef.current.clear(); + virtualizer.measureElement(null); + if (!attached.length || isViewportSuspendedRef.current()) return; + // Read before any viewport shift or size-cache notification can write. + measurementBatchRef.current = new Map(attached.map(element => [element, element.offsetHeight])); + try { for (const element of attached) virtualizer.measureElement(element); } + finally { measurementBatchRef.current = null; } + }); const rowsRef = useRef(rows); rowsRef.current = rows; @@ -659,16 +716,55 @@ export function useFlowChatVirtualizer({ const scroller = scrollerRef.current; if (!scroller || isViewportSuspendedRef.current()) return; const elements = scroller.querySelectorAll('[data-virtual-index]'); + const measurements: Array<[number, number]> = []; for (const element of elements) { + if (element.closest('[data-flowchat-virtual-viewport]') !== scroller) continue; const index = Number(element.getAttribute('data-virtual-index')); if (!Number.isInteger(index)) continue; - virtualizer.resizeItem( - index, - virtualizer.options.measureElement(element, undefined, virtualizer), - ); + measurements.push([index, virtualizer.options.measureElement(element, undefined, virtualizer)]); } + // measure() clears keyed sizes but leaves the previous derived positions. + // Rebuild those before resizeItem compares heights: otherwise unchanged + // compact rows look like zero deltas, never repopulate the empty size cache, + // and fall back to estimates until another resize or scroll arrives. + (virtualizer as unknown as MeasuringVirtualizer).getMeasurements(); + for (const [index, height] of measurements) virtualizer.resizeItem(index, height); }, [scrollerRef, virtualizer]); + const [fontRevision, setFontRevision] = useState(0); + useEffect(() => { + const changed = () => setFontRevision(value => value + 1); + document.fonts?.addEventListener('loadingdone', changed); + const observer = new MutationObserver(changed); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'class', 'data-openbitfun-appearance-revision'] }); + return () => { observer.disconnect(); document.fonts?.removeEventListener('loadingdone', changed); }; + }, []); + const layoutSignature = `${estimateContext?.availableWidthPx ?? ''}:${fontRevision}`; + const measuredLayoutRef = useRef(layoutSignature); + const previousKeysRef = useRef(new Set()); + const retiredMeasurementsRef = useRef(0); + useLayoutEffect(() => { + const keys = new Set(items.map(getItemKeyRef.current)); + for (const key of previousKeysRef.current) if (!keys.has(key)) retiredMeasurementsRef.current++; + previousKeysRef.current = keys; + if (measuredLayoutRef.current === layoutSignature && retiredMeasurementsRef.current < 2048) return; + if (isViewportSuspendedRef.current()) return; + const scroller = scrollerRef.current; + const before = (virtualizer as unknown as MeasuringVirtualizer).getMeasurements(); + const anchorIndex = before.findIndex(row => row.end > (scroller?.scrollTop ?? 0)); + const anchorStart = before[anchorIndex]?.start; + resettingMeasurementsRef.current = true; + try { virtualizer.measure(); measureRenderedItems(); } + finally { resettingMeasurementsRef.current = false; } + const after = (virtualizer as unknown as MeasuringVirtualizer).getMeasurements()[anchorIndex]?.start; + if (after !== undefined && anchorStart !== undefined && Math.abs(after - anchorStart) > 0.5) { + shiftViewport(after - anchorStart); + publishMeasuredOffsetRef.current?.(); + } + measuredLayoutRef.current = layoutSignature; + retiredMeasurementsRef.current = 0; + }, [itemKeyRevision, layoutSignature, items, measureRenderedItems, scrollerRef, shiftViewport, virtualizer]); + const getItemBounds = useCallback((index: number): FlowChatItemBounds | null => { const measurement = (virtualizer as unknown as MeasuringVirtualizer) .getMeasurements()[index]; diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowGroupState.ts b/src/web-ui/src/flow_chat/components/modern/useFlowGroupState.ts index 43ec0d3abf..0bfb5d4c24 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowGroupState.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowGroupState.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { FlowGroupReceiveFeedback } from '@openbitfun/ui/flow-chat'; import type { VirtualItem } from '../../types/flow-chat-projection'; -import { toolCapsuleStateKey } from '../../tool-cards/toolCapsuleModel'; import { indexFlowGroups } from '../../grouping/selectors'; import { getFlowGroupStateIds, isFlowGroupExpanded } from '../../grouping/types'; import { captureFlowGroupFeedbackSnapshot, collectFlowGroupReceiveFeedback } from '../../grouping/receiveFeedback'; @@ -58,11 +57,8 @@ export function useFlowGroupState( const onCollapseGroup = useCallback((groupId: string) => { const location = indexRef.current.byId.get(groupId); const ids = location ? getFlowGroupStateIds(location.group) : [groupId]; - if (location) setExpandedToolCapsules(previous => { - const next = new Set(previous); - location.group.allItems.forEach(member => next.delete(toolCapsuleStateKey(sessionIdRef.current, location.turnId, member.id))); - return next; - }); + // A parent fold changes only the parent. Child disclosure is a reader + // choice and is restored when the collection is opened again. setGroupStates(previous => { const next = new Map(previous); ids.forEach(id => next.set(id, false)); diff --git a/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts b/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts index ab8f437da9..6efcf16378 100644 --- a/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts +++ b/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts @@ -13,6 +13,7 @@ import type { ToolGroupData, VirtualItem } from '../../store/modernFlowChatStore import { getEffectiveToolName } from '../../utils/toolInvocationIdentity'; import { buildInlineFlowGroupData, getModelRoundActiveItems, getProjectedModelRoundGroups, hasModelRoundLeadingControls } from '../../grouping/roundGroups'; import { isFlowGroupExpanded } from '../../grouping/types'; +import { estimateAgentCardColumns } from '../../timeline/agentCardLayout'; export interface VirtualItemHeightEstimateContext { /** Width available to the reading column, excluding the scrollbar. */ @@ -31,6 +32,7 @@ export interface VirtualItemHeightEstimate { /** Collected rounds keep their virtual identity but have no visible row content. */ export function getKnownVirtualItemHeightPx(item: VirtualItem): number | undefined { + if (item.timeline) return undefined; if (item.type !== 'model-round' || item.projectedGroups?.length !== 0 || (item.isLastRound && item.isTurnComplete) || hasModelRoundLeadingControls(item.data) @@ -279,6 +281,23 @@ export function estimateVirtualItemHeight( item: VirtualItem, context: VirtualItemHeightEstimateContext = {}, ): VirtualItemHeightEstimate { + if (item.timeline && item.type === 'model-round') { + if (item.timeline.layout === 'agent-cards') { + const thoughts = item.data.items.filter(member => member.type === 'thinking'); + const cardCount = item.data.items.length - thoughts.length; + const rows = Math.ceil(cardCount / estimateAgentCardColumns(context.availableWidthPx)); + // Identity cards keep two fixed content lines in every execution state. + // Sum visual rows, not individual cards; DOM owns approvals/wrapped notes. + return { heightPx: rows * (80 + 12) + + thoughts.reduce((height, thought) => height + estimateFlowItemHeight(thought, context).heightPx, 0), + confidence: 'medium', kind: 'timeline-agent-cards' }; + } + const { kind, expanded, group } = item.timeline; + const heightPx = kind === 'group-header' ? (group?.category === 'file-edit' ? 52 : expanded ? 48 : 36) + : kind === 'round-header' ? 48 : kind === 'round-footer' ? 56 + : item.data.items.reduce((height, member) => height + estimateFlowItemHeight(member as AnyFlowItem, context).heightPx, 0); + return { heightPx: Math.max(1, heightPx), confidence: 'medium', kind: `timeline-${kind}` }; + } switch (item.type) { case 'user-message': case 'user-steering-message': diff --git a/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts b/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts index 6db62a09c3..4e38058b09 100644 --- a/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts +++ b/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts @@ -2,6 +2,7 @@ import type { VirtualItem } from '../../store/modernFlowChatStore'; /** Stable identity shared by React, the virtualizer and viewport snapshots. */ export function getVirtualItemStableKey(item: VirtualItem): string { + if (item.timeline) return `block:${item.timeline.key}`; switch (item.type) { case 'user-message': case 'user-steering-message': diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss index a2ffbd6831..8ddef143dc 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss @@ -35,6 +35,11 @@ } } +.subagent-projection-run { + @include itemRhythm.apply; + display: flow-root; +} + .subagent-projection-text__hint { display: flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx index 5184a5f45a..de67754e39 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx @@ -66,6 +66,14 @@ describe('SubagentProjectionView', () => { let root: Root; beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(500); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(500); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement('div'); document.body.appendChild(container); @@ -91,6 +99,8 @@ describe('SubagentProjectionView', () => { root.unmount(); }); container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('hydrates a metadata-only historical subagent session when its detail panel is mounted', async () => { diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx index 9100d01e24..869dcf58cc 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx @@ -1,3 +1,12 @@ +import { useFlowChatVirtualizer } from '../modern/useFlowChatVirtualizer'; +import { useFlowChatViewportOwner } from '../modern/useFlowChatViewportOwner'; +import { estimateFlowItemHeight } from '../modern/virtualItemHeightEstimators'; +import { FlowChatReaderProvider, useFlowChatReaderScope, useFlowChatReaderValue } from '../../timeline/readerState'; +import { useEmbeddedTimelineFollow } from '../../timeline/useEmbeddedTimelineFollow'; +import { useTimelineInteraction } from '../../timeline/useTimelineInteraction'; +import { TimelineMutationBoundary } from '../../timeline/TimelineMutationBoundary'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import type { AnyFlowItem } from '../../types/flow-chat'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@openbitfun/ui'; import { useTranslation } from 'react-i18next'; @@ -31,7 +40,7 @@ interface SubagentProjectionViewProps { const SUBAGENT_TEXT_TRUNCATE_LINES = 50; const SubagentProjectionTextBlock = React.memo<{ textItem: FlowTextItem; className?: string }>(({ textItem, className = '' }) => { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useFlowChatReaderValue(`text:${textItem.id}:expanded`, false); const { t } = useTranslation('flow-chat'); const content = typeof textItem.content === 'string' @@ -144,7 +153,10 @@ export const SubagentProjectionView: React.FC = ({ liveItemsMode = 'last-round', }) => { const containerRef = useRef(null); - const userScrolledUpRef = useRef(false); + const followRef = useRef(true); + const contentRef = useRef(null); + const headerRef = useRef(null); + const viewportOwner = useFlowChatViewportOwner(containerRef); const lastScrollTopRef = useRef(0); const [projectionState, setProjectionState] = useState(() => { if (!parentToolIds || parentToolIds.size === 0) { @@ -275,12 +287,13 @@ export const SubagentProjectionView: React.FC = ({ if (currentScrollTop < lastScrollTopRef.current && maxScrollTop > 0) { if (lastScrollTopRef.current - currentScrollTop > 20) { - userScrolledUpRef.current = true; + followRef.current = false; + viewportOwner.release('follow-output'); } } if (maxScrollTop > 0 && maxScrollTop - currentScrollTop < 30) { - userScrolledUpRef.current = false; + followRef.current = true; } lastScrollTopRef.current = currentScrollTop; @@ -288,38 +301,44 @@ export const SubagentProjectionView: React.FC = ({ container.addEventListener('scroll', handleScroll, { passive: true }); return () => container.removeEventListener('scroll', handleScroll); - }, [shouldRenderProjection]); - - const scrollSignal = useMemo(() => { - return items.map((item) => { - const itemAny = item as any; - const contentLength = typeof itemAny.content === 'string' ? itemAny.content.length : 0; - const paramsLength = itemAny.partialParams ? JSON.stringify(itemAny.partialParams).length : 0; - return `${item.id}:${item.status}:${contentLength}:${paramsLength}`; - }).join('|'); - }, [items]); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const rafId = requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (!userScrolledUpRef.current) { - container.scrollTop = container.scrollHeight; - lastScrollTopRef.current = container.scrollTop; - } - }); - }); - - return () => cancelAnimationFrame(rafId); - }, [shouldRenderProjection, scrollSignal]); + }, [shouldRenderProjection, viewportOwner]); + + // Chunk the recorded sequence before hiding failed/empty items. Visibility + // changes must not reparent surviving cards (or destroy their focus/selection). + // Never cut a thought from the following item used by its side annotation. + const runs = useMemo(() => { + const result: FlowItem[][] = []; + let run: FlowItem[] = []; + for (const item of liveItems) { + run.push(item); + if (run.length >= 8 && item.type !== 'thinking') { result.push(run); run = []; } + } + if (run.length) result.push(run); + return result; + }, [liveItems]); + const reader = useFlowChatReaderScope(`${getActiveSurfaceScope().epoch}:subagent:${resolvedSubagentSessionId}:${parentTaskToolId}`); + const cancelAimRef = useRef<() => void>(() => {}); + const pinnedKeys = useTimelineInteraction(containerRef, reader, () => followRef.current, '.subagent-projection-run[data-virtual-item-key]', () => { + followRef.current = false; viewportOwner.release('follow-output'); cancelAimRef.current(); + }); + const virtualizer = useFlowChatVirtualizer({ + pinnedKeys, + items: runs, scrollerRef: containerRef, headerRef, + getItemKey: run => run[0].id, + estimateItemHeightPx: run => run.reduce((height, item) => height + (isFlowItemVisible(item) ? estimateFlowItemHeight(item as AnyFlowItem).heightPx : 0), 0), + scrollPaddingStartPx: 0, startAtTailOnMount: true, + writeViewport: viewportOwner.write, shiftViewport: viewportOwner.shift, + }); + cancelAimRef.current = virtualizer.cancelAim; + useEmbeddedTimelineFollow({ scrollerRef: containerRef, contentRef, followRef, viewportOwner, + revision: shouldRenderProjection ? items : null, cancelAim: virtualizer.cancelAim }); if (!shouldRenderProjection) { return null; } return ( +
= ({ className="subagent-projection-container subagent-projection-container--expanded" data-parent-tool-id={parentTaskToolId} > -
- {items.map(item => renderProjectedItem( + run[0].id)} scrollerRef={containerRef} + canRepair={() => !followRef.current && viewportOwner.canShift()} shift={viewportOwner.shift} + rowSelector=".subagent-projection-run"> +
+ {virtualizer.rows.map((row, index) => + {index > 0 && row.startPx > virtualizer.rows[index - 1].endPx && +
+ ); }; diff --git a/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.test.tsx b/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.test.tsx index b54f0ab24d..f2374f1514 100644 --- a/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.test.tsx +++ b/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.test.tsx @@ -44,6 +44,7 @@ describe('useComposerDefaultFocus', () => { }); afterEach(() => { + document.getSelection()?.removeAllRanges(); act(() => root.unmount()); container.remove(); document.body.replaceChildren(); @@ -114,6 +115,23 @@ describe('useComposerDefaultFocus', () => { expect(document.activeElement).toBe(composer); }); + it('preserves transcript selection on window restore, but still accepts explicit typing', () => { + const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true }); + composer.blur(); + const transcript = document.createElement('div'); + transcript.setAttribute('data-flowchat-selection-root', 'session-a'); + transcript.textContent = 'Recorded response selected by the reader'; + document.body.append(transcript); + const range = document.createRange(); range.selectNodeContents(transcript); + document.getSelection()!.addRange(range); + const focus = vi.spyOn(composer, 'focus'); + act(() => window.dispatchEvent(new FocusEvent('focus'))); + expect(focus).not.toHaveBeenCalled(); + expect(document.getSelection()?.toString()).toBe(transcript.textContent); + pressKey('a'); + expect(document.activeElement).toBe(composer); + }); + it('does not refocus the composer when it already owns focus', () => { const composer = renderProbe({ sessionId: 'session-a', isSceneActive: true }); const focusSpy = vi.spyOn(composer, 'focus'); diff --git a/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.ts b/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.ts index 5e167e02fa..8a59e118be 100644 --- a/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.ts +++ b/src/web-ui/src/flow_chat/hooks/useComposerDefaultFocus.ts @@ -47,6 +47,14 @@ function hasBlockingModal(): boolean { return document.querySelector('[role="dialog"][aria-modal="true"]') !== null; } +function hasTranscriptSelection(): boolean { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed || !selection.anchorNode?.isConnected) return false; + const node = selection.anchorNode; + const element = node instanceof Element ? node : node.parentElement; + return Boolean(element?.closest('[data-flowchat-selection-root]')); +} + function isTextEntryKey(event: KeyboardEvent): boolean { if (event.defaultPrevented || event.metaKey) { return false; @@ -100,6 +108,7 @@ export function useComposerDefaultFocus({ || document.activeElement === editor || hasVisibleTextInputFocus() || hasBlockingModal() + || hasTranscriptSelection() ) { return; } diff --git a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx index 5a0c021712..09230141c7 100644 --- a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx +++ b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx @@ -27,7 +27,7 @@ function Transcript() { ; } -describe('selection annotation dialog lifecycle', () => { +describe('selection toolbar and annotation dialog lifecycle', () => { let container: HTMLDivElement; let root: Root; const frames = new Map(); @@ -40,12 +40,18 @@ describe('selection annotation dialog lifecycle', () => { pending.forEach(callback => callback(0)); }); const dialog = () => document.querySelector('[role="dialog"][data-state="open"]')!; + const toolbar = () => document.querySelector('.conversation-excerpt__popover'); + const originalRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects'); + const resizeCallbacks = new Set<() => void>(); + let textTop = 200; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; vi.useFakeTimers(); activateSurface('local'); frames.clear(); + resizeCallbacks.clear(); + textTop = 200; requests.length = 0; vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); @@ -66,7 +72,127 @@ describe('selection annotation dialog lifecycle', () => { container.remove(); document.querySelectorAll('[data-openbitfun-overlay-host]').forEach(host => host.remove()); vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); + if (originalRects) Object.defineProperty(Range.prototype, 'getClientRects', originalRects); + else Reflect.deleteProperty(Range.prototype, 'getClientRects'); + }); + + // Geometry is supplied only for lifecycle assertions; this is not visual acceptance. + function selectForToolbar(dragging = false) { + vi.stubGlobal('ResizeObserver', class { + constructor(private readonly callback: () => void) { resizeCallbacks.add(callback); } + observe() {} + disconnect() { resizeCallbacks.delete(this.callback); } + }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + return this.classList.contains('conversation-excerpt__popover') + ? new DOMRect(0, 0, 160, 30) : new DOMRect(100, 80, 600, 440); + }); + Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: function (this: Range) { + if (this.startContainer.nodeType !== Node.TEXT_NODE) return []; + return [new DOMRect(180 + this.startOffset * 10, textTop, (this.endOffset - this.startOffset) * 10, 20)]; + } }); + const target = container.querySelector('[data-flow-item-id]')!; + if (dragging) act(() => target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))); + act(() => { + window.getSelection()!.setBaseAndExtent(target.firstChild!, 0, target.firstChild!, target.textContent!.length); + document.dispatchEvent(new Event('selectionchange')); + }); + flushFrames(); + return target; + } + + it('waits for the selecting gesture to end and keeps one position for unchanged selection events', () => { + const target = selectForToolbar(true); + expect(toolbar()).toBeNull(); + act(() => target.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, button: 0 }))); + flushFrames(); + const popup = toolbar()!; + expect(popup.style.visibility).toBe('visible'); + const position = popup.style.cssText; + act(() => document.dispatchEvent(new Event('selectionchange'))); + flushFrames(); + expect(toolbar()).toBe(popup); + expect(popup.style.cssText).toBe(position); + }); + + it.each([120, 680])('completes a drag released in a gutter at x=%i even when that region stops bubbling', (clientX) => { + selectForToolbar(true); + const gutter = document.createElement('div'); + container.querySelector('[data-flowchat-selection-root]')!.append(gutter); + gutter.addEventListener('pointerup', event => event.stopPropagation()); + act(() => gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, button: 0, clientX, clientY: 210 }))); + flushFrames(); + expect(toolbar()?.style.visibility).toBe('visible'); + expect(window.getSelection()?.toString()).toBe('Selected source text'); + gutter.remove(); + }); + + it.each(['left', 'right'])('accepts a native selection ending on the %s whitespace wrapper', (side) => { + const target = selectForToolbar(true); + const scope = container.querySelector('[data-flowchat-selection-root]')!; + const gutter = document.createElement('div'); + if (side === 'left') scope.prepend(gutter); else scope.append(gutter); + act(() => { + window.getSelection()!.setBaseAndExtent(target.firstChild!, side === 'left' ? target.textContent!.length : 0, gutter, 0); + document.dispatchEvent(new Event('selectionchange')); + gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, button: 0 })); + }); + flushFrames(); + expect(toolbar()?.style.visibility).toBe('visible'); + expect(window.getSelection()?.toString()).toBe('Selected source text'); + gutter.remove(); + }); + + it('ignores other panes scrolling and does not reopen a scrolled-away selection on unrelated pointerup', () => { + selectForToolbar(); + const unrelated = document.createElement('div'); + document.body.append(unrelated); + const popup = toolbar(); + expect(popup).not.toBeNull(); + act(() => unrelated.dispatchEvent(new Event('scroll'))); + expect(toolbar()).toBe(popup); + act(() => container.querySelector('[data-flowchat-selection-root]')!.dispatchEvent(new Event('scroll'))); + expect(toolbar()).toBeNull(); + act(() => { + unrelated.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + document.dispatchEvent(new Event('selectionchange')); + }); + flushFrames(); + expect(toolbar()).toBeNull(); + expect(window.getSelection()?.toString()).toBe('Selected source text'); + unrelated.remove(); + }); + + it('retires the toolbar when its text moves instead of moving an action under the pointer', () => { + selectForToolbar(); + expect(toolbar()).not.toBeNull(); + textTop += 40; + act(() => resizeCallbacks.forEach(callback => callback())); + flushFrames(); + expect(toolbar()).toBeNull(); + }); + + it('does not treat a cancelled selection gesture as a completed selection', () => { + const target = selectForToolbar(true); + act(() => target.dispatchEvent(new MouseEvent('pointercancel', { bubbles: true }))); + act(() => document.dispatchEvent(new Event('selectionchange'))); + flushFrames(); + expect(toolbar()).toBeNull(); + }); + + it('lets keyboard users focus and dismiss the toolbar without reviving it on the same selection', () => { + selectForToolbar(); + const first = toolbar()!.querySelector('button'); + act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))); + expect(document.activeElement).toBe(first); + act(() => first!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))); + expect(toolbar()).toBeNull(); + expect(document.activeElement).toBe(container.querySelector('[data-flowchat-selection-root]')); + act(() => document.dispatchEvent(new Event('selectionchange'))); + flushFrames(); + expect(toolbar()).toBeNull(); }); async function openAnnotation() { diff --git a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx index f6ce0d70da..75666f4e5d 100644 --- a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx +++ b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx @@ -8,11 +8,14 @@ import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import { isTauriRuntime } from '@/infrastructure/runtime'; -import { computeFixedPopoverPositionInViewport } from '@/shared/utils/fixedPopoverViewport'; +import type { FixedPopoverPlacement } from '@/shared/utils/fixedPopoverViewport'; import { flowChatStore } from '../store/FlowChatStore'; import { resolveSessionDriverId } from '../session-drivers/resolve'; import { isAcpFlowSession } from '../utils/acpSession'; import { captureFlowChatSelection, type CapturedFlowChatSelection } from './flowChatSelection'; +import { + computeSelectionBarPosition, measureSelectionBarGeometry, sameFlowChatSelection, sameSelectionBarAnchor, +} from './flowChatSelectionPosition'; import { requestExcerptAction } from './excerptActions'; import { ConversationExcerptEditor } from './ConversationExcerptEditor'; import './ConversationExcerpt.scss'; @@ -38,6 +41,7 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti const [editing, setEditing] = useState(false); const [comment, setComment] = useState(''); const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + const lastCapturedRef = useRef(null); const editingRef = useRef(editing); editingRef.current = editing; const intentRef = useRef(onSelectionIntent); @@ -53,7 +57,7 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti if (!editingRef.current && popupRef.current?.contains(focused ?? null)) { rootRef.current?.focus({ preventScroll: true }); } - setSelection(null); setEditing(false); setComment(''); + setSelection(null); setPosition(null); setEditing(false); setComment(''); }, [rootRef]); const scope = getActiveSurfaceScope(); const sourceSession = sessionId ? flowChatStore.getState().sessions.get(sessionId) : undefined; @@ -67,11 +71,14 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti useEffect(() => { clear(); + lastCapturedRef.current = null; const root = rootRef.current; if (!active || !sessionId || !root) return; const owner = root.ownerDocument; let frame = 0; let pressed = false; + let startedInRoot = false; + let changedDuringPress = false; let claimedSelection = false; const readSelection = () => { const session = flowChatStore.getState().sessions.get(sessionId); @@ -86,13 +93,16 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti frame = 0; if (pressed || editingRef.current || useContextMenuStore.getState().visible || popupRef.current?.contains(owner.activeElement)) return; + if (sameFlowChatSelection(lastCapturedRef.current, owner.getSelection())) return; const next = readSelection(); + lastCapturedRef.current = next; setSelection(next); setPosition(null); }; const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(capture); }; const selectionChanged = () => { if (editingRef.current) return; + if (pressed) changedDuringPress = true; const nativeSelection = owner.getSelection(); if (!claimedSelection && nativeSelection && !nativeSelection.isCollapsed && root.contains(nativeSelection.anchorNode) && !popupRef.current?.contains(owner.activeElement) @@ -103,11 +113,30 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti schedule(); }; const pointerDown = (event: PointerEvent) => { - if (popupRef.current?.contains(event.target as Node)) return; + if (editingRef.current || popupRef.current?.contains(event.target as Node)) return; pressed = event.button === 0; + startedInRoot = pressed && root.contains(event.target as Node); + changedDuringPress = false; claimedSelection = false; + if (startedInRoot) clear(); + }; + const pointerUp = () => { + const captureGesture = pressed && startedInRoot; + pressed = false; + startedInRoot = false; + if (!captureGesture || editingRef.current) return; + // A deliberate reselection can have the same endpoints as the previous quote. + if (changedDuringPress) lastCapturedRef.current = null; + schedule(); + }; + const pointerCancel = () => { + const cancelGesture = pressed && startedInRoot; + pressed = false; startedInRoot = false; + if (!cancelGesture || editingRef.current) return; + cancelAnimationFrame(frame); frame = 0; + lastCapturedRef.current = readSelection(); + clear(); }; - const pointerUp = () => { pressed = false; if (!editingRef.current) schedule(); }; const keyDown = (event: KeyboardEvent) => { if (event.isComposing || editingRef.current) return; const next = readSelection(); @@ -126,9 +155,20 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti } }; const closeOnScroll = (event: Event) => { - if (!editingRef.current && !popupRef.current?.contains(event.target as Node)) clear(); + if (editingRef.current) return; + const target = event.target; + const captured = lastCapturedRef.current; + if (target === owner || (target instanceof Element + && (target.contains(root) || (captured && (target.contains(captured.focusNode) || target.contains(captured.anchorNode)))))) { + cancelAnimationFrame(frame); frame = 0; + clear(); + } + }; + const closeOnResize = () => { + if (editingRef.current) return; + cancelAnimationFrame(frame); frame = 0; + clear(); }; - const closeOnResize = () => { if (!editingRef.current) clear(); }; contextMenuRegistry.register({ id: menuId, name: t('selection.actions'), priority: 110, matcher: context => root.contains(context.targetElement) && !!readSelection(), @@ -154,11 +194,15 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti }); owner.addEventListener('selectionchange', selectionChanged); owner.addEventListener('pointerdown', pointerDown); - owner.addEventListener('pointerup', pointerUp); - owner.addEventListener('pointercancel', pointerUp); + // A text-selection drag still belongs to this transcript when released in + // its gutters or over another element that stops bubbling pointer events. + owner.addEventListener('pointerup', pointerUp, true); + owner.addEventListener('pointercancel', pointerCancel, true); owner.addEventListener('keydown', keyDown, true); owner.addEventListener('scroll', closeOnScroll, true); owner.defaultView?.addEventListener('resize', closeOnResize); + owner.defaultView?.visualViewport?.addEventListener('resize', closeOnResize); + owner.defaultView?.visualViewport?.addEventListener('scroll', closeOnResize); root.dataset.flowchatExcerptReady = sessionId; return () => { delete root.dataset.flowchatExcerptReady; @@ -166,30 +210,49 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti contextMenuRegistry.unregister(menuId); owner.removeEventListener('selectionchange', selectionChanged); owner.removeEventListener('pointerdown', pointerDown); - owner.removeEventListener('pointerup', pointerUp); - owner.removeEventListener('pointercancel', pointerUp); + owner.removeEventListener('pointerup', pointerUp, true); + owner.removeEventListener('pointercancel', pointerCancel, true); owner.removeEventListener('keydown', keyDown, true); owner.removeEventListener('scroll', closeOnScroll, true); owner.defaultView?.removeEventListener('resize', closeOnResize); + owner.defaultView?.visualViewport?.removeEventListener('resize', closeOnResize); + owner.defaultView?.visualViewport?.removeEventListener('scroll', closeOnResize); }; }, [active, sessionId, scope, rootRef, clear, beginEditing, t, parentSessionId, canAsk, child, menuId]); useLayoutEffect(() => { - if (!selection || editing || !popupRef.current) return; + const root = rootRef.current; + if (!selection || editing || !popupRef.current || !root) return; const popup = popupRef.current; + const initial = measureSelectionBarGeometry(root, selection); + if (!initial) { clear(); return; } + const selectedText = selection.range.toString(); + let placement: FixedPopoverPlacement | undefined; + let frame = 0; const update = () => { - if (!selection.range.startContainer.isConnected) return clear(); - const rects = selection.range.getClientRects(); - const anchor = rects[rects.length - 1] ?? selection.range.getBoundingClientRect(); - const rect = popup.getBoundingClientRect(); - setPosition(computeFixedPopoverPositionInViewport(anchor, rect.width, rect.height, - { width: window.innerWidth, height: window.innerHeight }, { preferredPlacement: 'top', gap: 8 })); + frame = 0; + const geometry = measureSelectionBarGeometry(root, selection); + if (!geometry || !sameSelectionBarAnchor(initial, geometry) || selection.range.toString() !== selectedText) return clear(); + const next = computeSelectionBarPosition(geometry, popup.getBoundingClientRect(), placement); + if (!next) return clear(); + placement = next.placement; + setPosition(previous => previous?.left === next.left && previous.top === next.top ? previous : next); }; + const schedule = () => { if (!frame) frame = requestAnimationFrame(update); }; update(); - const observer = new ResizeObserver(update); + const observer = new ResizeObserver(schedule); observer.observe(popup); - return () => observer.disconnect(); - }, [selection, editing, clear]); + for (const node of [selection.anchorNode, selection.focusNode]) { + for (let element = node instanceof Element ? node : node.parentElement; element; element = element.parentElement) { + observer.observe(element); + if (element === root) break; + } + } + const mutation = new MutationObserver(schedule); + mutation.observe(root, { childList: true, characterData: true, subtree: true, attributes: true, + attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-openbitfun-viewport-inset-bottom'] }); + return () => { cancelAnimationFrame(frame); observer.disconnect(); mutation.disconnect(); }; + }, [selection, editing, clear, rootRef]); useEffect(() => { if (!editing || !selection) return; @@ -233,7 +296,7 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti {t(child ? 'selection.askHere' : 'selection.askSide')} - , getAppearanceOverlayHost(), + , getAppearanceOverlayHost(), undefined, { ownerRef: rootRef }, )} new DOMRect(left, top, width, height); +const size = { width: 160, height: 30 }; +const geometry = (x = 380, top = 200): SelectionBarGeometry => ({ + anchor: { x, top, bottom: top + 20, preferredPlacement: 'top' }, + bounds: rect(100, 100, 600, 400), + textRects: [rect(180, top, 200, 20)], +}); + +describe('selection toolbar placement policy', () => { + it('centres on the focus endpoint above a single line', () => { + expect(computeSelectionBarPosition(geometry(), size)).toEqual({ left: 300, top: 162, placement: 'top' }); + }); + + it('shifts only as far as required to stay within the transcript pane', () => { + expect(computeSelectionBarPosition(geometry(105), size)?.left).toBe(108); + expect(computeSelectionBarPosition(geometry(695), size)?.left).toBe(532); + }); + + it('keeps a valid selection actionable when its horizontal endpoint extends past the pane', () => { + expect(computeSelectionBarPosition(geometry(80), size)).toEqual({ left: 108, top: 162, placement: 'top' }); + expect(computeSelectionBarPosition(geometry(740), size)).toEqual({ left: 532, top: 162, placement: 'top' }); + }); + + it('flips when opening at an edge, then keeps the chosen side', () => { + const edge = geometry(380, 110); + expect(computeSelectionBarPosition(edge, size)).toEqual({ left: 300, top: 138, placement: 'bottom' }); + expect(computeSelectionBarPosition(edge, size, 'top')).toBeNull(); + }); + + it('does not flip into the selected lines when the endpoint is near the bottom', () => { + const multiline = geometry(380, 450); + multiline.anchor.preferredPlacement = 'bottom'; + multiline.textRects = [rect(180, 400, 420, 45), rect(180, 450, 200, 20)]; + expect(computeSelectionBarPosition(multiline, size)).toBeNull(); + }); + + it('rejects vertically clipped endpoints and panes too narrow for the toolbar', () => { + expect(computeSelectionBarPosition(geometry(380, 90), size)).toBeNull(); + expect(computeSelectionBarPosition({ ...geometry(), bounds: rect(300, 100, 170, 400) }, size)).toBeNull(); + }); +}); + +// Supplied DOM geometry verifies the selection/positioning contract, not rendered appearance. +describe('native selection endpoint geometry', () => { + const originalRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects'); + let root: HTMLDivElement; + let scroller: HTMLElement; + let source: HTMLElement; + let first: Text; + let last: Text; + const textGeometry = new Map(); + const elementGeometry = new Map(); + + beforeEach(() => { + root = document.createElement('div'); + root.dataset.flowchatSelectionRoot = 'session'; + root.innerHTML = '
' + + '

first bold

last

'; + document.body.append(root); + scroller = root.firstElementChild as HTMLElement; + source = root.querySelector('[data-flow-item-id]')!; + first = source.querySelector('p')!.firstChild as Text; + last = source.querySelectorAll('p')[1].firstChild as Text; + textGeometry.set(first, rect(180, 180, 60, 20)); + textGeometry.set(source.querySelector('strong')!.firstChild!, rect(240, 180, 40, 20)); + textGeometry.set(last, rect(180, 220, 40, 20)); + elementGeometry.set(root, rect(0, 0, 900, 600)); + elementGeometry.set(scroller, rect(100, 100, 600, 400)); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + return elementGeometry.get(this) ?? rect(0, 0, 0, 0); + }); + Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: function (this: Range) { + const bounds = textGeometry.get(this.startContainer); + if (!bounds || this.startContainer !== this.endContainer) return []; + const width = bounds.width / this.startContainer.textContent!.length; + return [rect(bounds.left + this.startOffset * width, bounds.top, (this.endOffset - this.startOffset) * width, bounds.height)]; + } }); + }); + + afterEach(() => { + window.getSelection()?.removeAllRanges(); + root.remove(); textGeometry.clear(); elementGeometry.clear(); + vi.restoreAllMocks(); + if (originalRects) Object.defineProperty(Range.prototype, 'getClientRects', originalRects); + else Reflect.deleteProperty(Range.prototype, 'getClientRects'); + }); + + function capture(backward = false) { + const native = window.getSelection()!; + native.setBaseAndExtent(backward ? last : first, backward ? 4 : 2, backward ? first : last, backward ? 2 : 4); + return captureFlowChatSelection(root, native, { sessionId: 'session', surfaceId: 'local', sessionName: 'Session' })!; + } + + it('places a forward multiline selection below its actual endpoint and excludes the input inset', () => { + const measured = measureSelectionBarGeometry(root, capture())!; + expect(measured.anchor).toEqual({ x: 220, top: 220, bottom: 240, preferredPlacement: 'bottom' }); + expect(measured.bounds).toEqual({ left: 100, right: 700, top: 100, bottom: 400 }); + expect(computeSelectionBarPosition(measured, size)).toEqual({ left: 140, top: 248, placement: 'bottom' }); + }); + + it('puts reverse selection above the focus at the start, keeping document order only for quoting', () => { + const forward = capture(); + const backward = capture(true); + expect(backward.range.toString()).toBe(forward.range.toString()); + expect(sameFlowChatSelection(forward, backward)).toBe(false); + expect(sameFlowChatSelection(backward, window.getSelection())).toBe(true); + expect(measureSelectionBarGeometry(root, backward)?.anchor) + .toEqual({ x: 200, top: 180, bottom: 200, preferredPlacement: 'top' }); + }); + + it('uses selected text runs when an element-offset endpoint has no caret rectangle', () => { + const native = window.getSelection()!; + const range = document.createRange(); + range.selectNodeContents(source); + native.removeAllRanges(); native.addRange(range); + const snapshot = captureFlowChatSelection(root, native, { sessionId: 'session', surfaceId: 'local', sessionName: 'Session' })!; + expect(measureSelectionBarGeometry(root, snapshot)?.anchor.x).toBe(220); + }); + + it('rejects an endpoint hidden by nested clipping or the overlaid input', () => { + const snapshot = capture(); + const paragraph = last.parentElement!; + paragraph.style.overflowY = 'hidden'; + elementGeometry.set(paragraph, rect(100, 250, 500, 100)); + expect(measureSelectionBarGeometry(root, snapshot)).toBeNull(); + paragraph.style.overflowY = ''; + scroller.dataset.openbitfunViewportInsetBottom = '280'; + expect(measureSelectionBarGeometry(root, snapshot)).toBeNull(); + }); + + it('anchors to the visible part of a selected line when the focus extends beyond its left edge', () => { + const snapshot = capture(true); + textGeometry.set(first, rect(20, 180, 120, 20)); + const measured = measureSelectionBarGeometry(root, snapshot)!; + expect(measured?.anchor).toEqual({ x: 100, top: 180, bottom: 200, preferredPlacement: 'top' }); + expect(computeSelectionBarPosition(measured, size)).toEqual({ left: 108, top: 142, placement: 'top' }); + }); + + it('also keeps a selection actionable when its focus extends beyond the right edge', () => { + textGeometry.set(last, rect(680, 220, 120, 20)); + const measured = measureSelectionBarGeometry(root, capture())!; + expect(measured?.anchor.x).toBe(700); + expect(computeSelectionBarPosition(measured, size)).toEqual({ left: 532, top: 248, placement: 'bottom' }); + }); + + it('still rejects a selected endpoint line that is entirely clipped horizontally', () => { + textGeometry.set(first, rect(0, 180, 60, 20)); + textGeometry.set(source.querySelector('strong')!.firstChild!, rect(60, 180, 40, 20)); + expect(measureSelectionBarGeometry(root, capture(true))).toBeNull(); + }); + + it('falls back to the embedded transcript root without using another pane', () => { + scroller.removeAttribute('data-flowchat-scroller'); + const measured = measureSelectionBarGeometry(root, capture())!; + expect(measured.bounds).toEqual({ left: 0, right: 900, top: 0, bottom: 600 }); + }); +}); diff --git a/src/web-ui/src/flow_chat/selection/flowChatSelectionPosition.ts b/src/web-ui/src/flow_chat/selection/flowChatSelectionPosition.ts new file mode 100644 index 0000000000..daf80f2c6c --- /dev/null +++ b/src/web-ui/src/flow_chat/selection/flowChatSelectionPosition.ts @@ -0,0 +1,143 @@ +import { DEFAULT_POPOVER_VIEWPORT_PADDING, type FixedPopoverPlacement } from '@/shared/utils/fixedPopoverViewport'; +import { selectionElement, sourceTextNodes, type CapturedFlowChatSelection } from './flowChatSelection'; + +type Rect = Pick; +type SelectionEndpoints = Pick; +export interface SelectionBarAnchor { + x: number; + top: number; + bottom: number; + preferredPlacement: FixedPopoverPlacement; +} +export interface SelectionBarGeometry { + anchor: SelectionBarAnchor; + bounds: Rect; + textRects: readonly Rect[]; +} +export interface SelectionBarPosition { + left: number; + top: number; + placement: FixedPopoverPlacement; +} + +const GAP = 8; +const overlaps = (a: Rect, b: Rect) => a.left < b.right && a.right > b.left + && a.top < b.bottom && a.bottom > b.top; +const sameLine = (a: Rect, b: Rect) => a.top < b.bottom && a.bottom > b.top; + +export function sameFlowChatSelection(a: SelectionEndpoints | null, b: SelectionEndpoints | null): boolean { + return !!a && !!b && a.anchorNode === b.anchorNode && a.anchorOffset === b.anchorOffset + && a.focusNode === b.focusNode && a.focusOffset === b.focusOffset; +} + +/** A toolbar may flip once when opening, but never cover the quote or detach to a viewport edge. */ +export function computeSelectionBarPosition({ anchor, bounds, textRects }: SelectionBarGeometry, + size: { width: number; height: number }, lockedPlacement?: FixedPopoverPlacement): SelectionBarPosition | null { + const padding = DEFAULT_POPOVER_VIEWPORT_PADDING; + if (size.width <= 0 || size.height <= 0 || size.width > bounds.right - bounds.left - padding * 2 + || anchor.top < bounds.top || anchor.bottom > bounds.bottom) return null; + const left = Math.max(bounds.left + padding, + Math.min(anchor.x - size.width / 2, bounds.right - padding - size.width)); + const placements = lockedPlacement ? [lockedPlacement] + : [anchor.preferredPlacement, anchor.preferredPlacement === 'top' ? 'bottom' : 'top'] as const; + for (const placement of placements) { + const top = placement === 'top' ? anchor.top - GAP - size.height : anchor.bottom + GAP; + const rect = { left, top, right: left + size.width, bottom: top + size.height }; + if (top >= bounds.top + padding && rect.bottom <= bounds.bottom - padding + && !textRects.some(text => overlaps(rect, text))) return { left, top, placement }; + } + return null; +} + +/** Text runs exclude the full-width element boxes returned by Range.getClientRects(). */ +function selectedTextRects(range: Range): DOMRect[] { + const element = selectionElement(range.commonAncestorContainer); + if (!(element instanceof HTMLElement)) return []; + return sourceTextNodes(element).flatMap(node => { + if (!range.intersectsNode(node)) return []; + const text = node.ownerDocument.createRange(); + text.selectNodeContents(node); + if (range.compareBoundaryPoints(Range.START_TO_START, text) > 0) { + text.setStart(range.startContainer, range.startOffset); + } + if (range.compareBoundaryPoints(Range.END_TO_END, text) < 0) { + text.setEnd(range.endContainer, range.endOffset); + } + return [...text.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0); + }); +} + +/** The primary transcript declares its input occlusion; embedded transcripts use their own root. */ +function getSelectionViewport(root: HTMLElement, node: Node): HTMLElement { + const scroller = selectionElement(node)?.closest('[data-flowchat-scroller]'); + return scroller && root.contains(scroller) ? scroller : root; +} + +function clipToAncestors(bounds: Rect, element: Element | null, view: Window, stop?: Element): Rect | null { + const visible = { ...bounds }; + for (let parent = element; parent && parent !== stop; parent = parent.parentElement) { + const style = view.getComputedStyle(parent); + if (style.visibility === 'hidden' || style.display === 'none') return null; + const rect = parent.getBoundingClientRect(); + if (/auto|scroll|hidden|clip/.test(style.overflowX)) { + visible.left = Math.max(visible.left, rect.left); + visible.right = Math.min(visible.right, rect.right); + } + if (/auto|scroll|hidden|clip/.test(style.overflowY)) { + visible.top = Math.max(visible.top, rect.top); + visible.bottom = Math.min(visible.bottom, rect.bottom); + } + } + return visible.right > visible.left && visible.bottom > visible.top ? visible : null; +} + +export function measureSelectionBarGeometry(root: HTMLElement, selection: CapturedFlowChatSelection): SelectionBarGeometry | null { + const { range, focusNode, focusOffset, anchorNode } = selection; + const view = root.ownerDocument.defaultView; + if (!view || !root.contains(focusNode) || !root.contains(anchorNode)) return null; + const textRects = selectedTextRects(range); + if (!textRects.length) return null; + + // Selection keeps gesture direction; Range always normalizes it to document order. + const caret = root.ownerDocument.createRange(); + const focusLength = focusNode.nodeType === Node.TEXT_NODE ? focusNode.textContent!.length : focusNode.childNodes.length; + if (focusOffset > focusLength) return null; + caret.setStart(focusNode, focusOffset); + caret.collapse(true); + const backward = caret.compareBoundaryPoints(Range.START_TO_START, range) === 0; + const endpoint = backward ? textRects[0] : textRects[textRects.length - 1]; + // At a soft wrap the caret may report two lines. Keep the line containing selected text. + const caretRect = [...caret.getClientRects()].find(rect => rect.height > 0 && sameLine(rect, endpoint)); + const rtl = view.getComputedStyle(selectionElement(focusNode)!).direction === 'rtl'; + const x = caretRect?.left ?? (backward !== rtl ? endpoint.left : endpoint.right); + const line = textRects.filter(rect => sameLine(rect, endpoint)); + const top = Math.min(...line.map(rect => rect.top)); + const bottom = Math.max(...line.map(rect => rect.bottom)); + const multiline = textRects.some(rect => !sameLine(rect, endpoint)); + const anchor: SelectionBarAnchor = { x, top, bottom, preferredPlacement: multiline && !backward ? 'bottom' : 'top' }; + + const viewport = getSelectionViewport(root, focusNode); + const pane = viewport.getBoundingClientRect(); + const visual = view.visualViewport; + const inset = Math.max(0, Number(viewport.dataset.openbitfunViewportInsetBottom) || 0); + const bounds = clipToAncestors({ + left: Math.max(pane.left, visual?.offsetLeft ?? 0), + right: Math.min(pane.right, (visual?.offsetLeft ?? 0) + (visual?.width ?? view.innerWidth)), + top: Math.max(pane.top, visual?.offsetTop ?? 0), + bottom: Math.min(pane.bottom - inset, (visual?.offsetTop ?? 0) + (visual?.height ?? view.innerHeight)), + }, viewport, view); + // The selected line owns visibility, not the caret's horizontal position: a + // drag may finish in either reading-column gutter while its text stays visible. + const visible = bounds && clipToAncestors(bounds, selectionElement(focusNode), view, viewport); + if (!bounds || !visible || top < visible.top || bottom > visible.bottom + || !line.some(rect => rect.left < visible.right && rect.right > visible.left)) return null; + anchor.x = Math.max(visible.left, Math.min(x, visible.right)); + return { anchor, bounds, textRects }; +} + +/** Layout changes retire a transient toolbar instead of moving the target under the pointer. */ +export function sameSelectionBarAnchor(a: SelectionBarGeometry, b: SelectionBarGeometry): boolean { + return a.anchor.x === b.anchor.x && a.anchor.top === b.anchor.top && a.anchor.bottom === b.anchor.bottom + && a.bounds.left === b.bounds.left && a.bounds.right === b.bounds.right + && a.bounds.top === b.bounds.top && a.bounds.bottom === b.bounds.bottom; +} diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.test.ts b/src/web-ui/src/flow_chat/services/EventBatcher.test.ts index fbb21982c4..e522b3b251 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.test.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.test.ts @@ -208,6 +208,20 @@ describe('EventBatcher dual latency', () => { batcher.destroy(); }); + it('commits accumulated facts when a minimized window stops delivering frames', async () => { + const onFlush = vi.fn(); + const batcher = new EventBatcher({ onFlush }); + batcher.add('text', 'a', 'accumulate', (a, b) => a + b, { maxLatencyMs: TEXT_CHUNK_MAX_LATENCY_MS }); + await vi.advanceTimersByTimeAsync(TEXT_CHUNK_MAX_LATENCY_MS); + batcher.add('text', 'b', 'accumulate', (a, b) => a + b, { maxLatencyMs: TEXT_CHUNK_MAX_LATENCY_MS }); + await vi.advanceTimersByTimeAsync(TEXT_CHUNK_MAX_LATENCY_MS); + expect(onFlush).toHaveBeenCalledExactlyOnceWith([{ key: 'text', payload: 'ab' }]); + await drainAnimationFrames(); + expect(onFlush).toHaveBeenCalledTimes(1); + batcher.destroy(); + expect(vi.getTimerCount()).toBe(0); + }); + it('reschedules earlier when a text event arrives after a tool event', async () => { const onFlush = vi.fn(); const batcher = new EventBatcher({ onFlush }); diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 57a5c9e697..12dcc07763 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -114,6 +114,7 @@ export class EventBatcher { private scheduled = false; private onFlush: (events: Array<{ key: string; payload: any }>) => void; private frameId: number | null = null; + private frameDeadlineId: ReturnType | null = null; private lastUpdateTime = 0; private timeoutId: ReturnType | null = null; @@ -171,6 +172,10 @@ export class EventBatcher { } private cancelScheduledFlush(): void { + if (this.frameDeadlineId !== null) { + clearTimeout(this.frameDeadlineId); + this.frameDeadlineId = null; + } if (this.frameId !== null) { cancelAnimationFrame(this.frameId); this.frameId = null; @@ -187,27 +192,17 @@ export class EventBatcher { this.scheduled = true; this.scheduledDelayMs = delayMs; - if (delayMs <= 0) { - this.frameId = requestAnimationFrame(() => { - this.flush(); - this.scheduled = false; - this.frameId = null; - this.scheduledDelayMs = null; - this.lastUpdateTime = nowMs(); - }); - return; - } - - this.timeoutId = setTimeout(() => { + const flushAtDeadline = () => this.flushNow(); + const requestFrame = () => { this.timeoutId = null; - this.frameId = requestAnimationFrame(() => { - this.flush(); - this.scheduled = false; - this.frameId = null; - this.scheduledDelayMs = null; - this.lastUpdateTime = nowMs(); - }); - }, delayMs); + // Facts must keep advancing when WebView suspends animation frames. + // Also covers a window minimized after the frame was already requested. + if (typeof document !== 'undefined' && document.hidden) { flushAtDeadline(); return; } + this.frameId = requestAnimationFrame(flushAtDeadline); + this.frameDeadlineId = setTimeout(flushAtDeadline, TEXT_CHUNK_MAX_LATENCY_MS); + }; + if (delayMs <= 0) requestFrame(); + else this.timeoutId = setTimeout(requestFrame, delayMs); } private scheduleFlush(): void { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index d7dae09f46..c2de4f0e90 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -4,6 +4,7 @@ */ import { projectUserQuestionTiming } from '../../utils/userQuestionTiming'; +import { bindSubmittedMessageScrollIntent } from '../submittedMessageScrollIntent'; import { FlowChatStore, mergeModelRoundAttemptDiagnostics } from '../../store/FlowChatStore'; import { initializeAcpPlanState } from '../acpPlanState'; import { isSessionTurnRetired } from '../../store/sessionMutationStore'; @@ -1940,6 +1941,7 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { storageTurnIndex: typeof turnIndex === 'number' ? turnIndex : undefined, backendTurnIndex: typeof turnIndex === 'number' ? turnIndex : undefined, }; + bindSubmittedMessageScrollIntent(sessionId, turnId, newTurn.userMessage.id); const replacedTempTurn = tempTurnId ? store.replaceOptimisticDialogTurn(sessionId, tempTurnId, newTurn) : false; diff --git a/src/web-ui/src/flow_chat/services/hostDialogQueue.ts b/src/web-ui/src/flow_chat/services/hostDialogQueue.ts index b95eda805c..5dd7960345 100644 --- a/src/web-ui/src/flow_chat/services/hostDialogQueue.ts +++ b/src/web-ui/src/flow_chat/services/hostDialogQueue.ts @@ -7,6 +7,7 @@ import { FlowChatStore } from '../store/FlowChatStore'; import { isAcpFlowSession } from '../utils/acpSession'; import { resolveSessionDriverId } from '../session-drivers/resolve'; import { translateAgentIdentityFields } from '../../../../shared/agent-harness/wire'; +import { registerSubmittedMessageScrollIntent, finishSubmittedMessageScrollIntent } from './submittedMessageScrollIntent'; const clients = new Map(); function currentAccount(): string { @@ -30,11 +31,23 @@ export function hostDialogQueue(sessionId: string): HostDialogQueue { client = new HostDialogQueue(owner, sessionId, async request => { scope.assertCurrent('send queue operation'); if (currentAccount() !== account) throw new Error('Queue account changed'); - const result = await api.invoke( - 'manage_dialog_queue', { request: translateAgentIdentityFields(request, 'legacy') }); - scope.assertCurrent('apply queue operation'); - if (currentAccount() !== account) throw new Error('Queue account changed'); - return result; + // The queue has now selected the actual idempotent Turn id. Register + // before invoking, since DialogTurnStarted can beat the RPC receipt. + const intent = request.action === 'submit' + ? registerSubmittedMessageScrollIntent(scope, sessionId, request.message.turnId, null) : undefined; + try { + const result = await api.invoke( + 'manage_dialog_queue', { request: translateAgentIdentityFields(request, 'legacy') }); + scope.assertCurrent('apply queue operation'); + if (currentAccount() !== account) throw new Error('Queue account changed'); + // Parking/steering work is not a new transcript placement. Later queue + // drain or outbox replay must not resurrect this old send gesture. + if (intent && result.receipt?.status !== 'started') finishSubmittedMessageScrollIntent(intent); + return result; + } catch (error) { + if (intent) finishSubmittedMessageScrollIntent(intent); + throw error; + } }); clients.set(key, client); } diff --git a/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.test.ts b/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.test.ts new file mode 100644 index 0000000000..c5471a763a --- /dev/null +++ b/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { bindSubmittedMessageScrollIntent, claimSubmittedMessageScrollIntent, finishSubmittedMessageScrollIntent, registerSubmittedMessageScrollIntent, subscribeSubmittedMessageScrollIntent } from './submittedMessageScrollIntent'; + +afterEach(() => { activateSurface('local'); vi.useRealTimers(); }); +describe('submitted viewport intent', () => { + it('binds host queue identity before the RPC receipt, without treating unrelated replay as a submission', () => { + registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'session', 'host-turn', null); + expect(claimSubmittedMessageScrollIntent('session', 1)).toBeNull(); + bindSubmittedMessageScrollIntent('session', 'remote-turn', 'remote-message'); + expect(claimSubmittedMessageScrollIntent('session', 1)).toBeNull(); + bindSubmittedMessageScrollIntent('session', 'host-turn', 'projected-message'); + expect(claimSubmittedMessageScrollIntent('session', 1)?.messageId).toBe('projected-message'); + }); + it('can cancel before host projection so delayed queue drain cannot steal the viewport', () => { + registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'session', 'queued', null); + finishSubmittedMessageScrollIntent(claimSubmittedMessageScrollIntent('session', 1, true)!); + bindSubmittedMessageScrollIntent('session', 'queued', 'late-message'); + expect(claimSubmittedMessageScrollIntent('session', 1)).toBeNull(); + }); + it('outlives arrival motion, scopes exact identities and has one viewport consumer', () => { + vi.useFakeTimers(); + const scope = getActiveSurfaceScope(); + registerSubmittedMessageScrollIntent(scope, 'session', 'turn', 'message'); + vi.advanceTimersByTime(10000); + expect(claimSubmittedMessageScrollIntent('other-session', 1)).toBeNull(); + const intent = claimSubmittedMessageScrollIntent('session', 1)!; + expect(intent).toMatchObject({ sessionId: 'session', turnId: 'turn', messageId: 'message', viewportId: 1 }); + expect(claimSubmittedMessageScrollIntent('session', 2)).toBeNull(); + finishSubmittedMessageScrollIntent(intent); + expect(claimSubmittedMessageScrollIntent('session', 1)).toBeNull(); + }); + it('invalidates on device activation, including a later return to the same device', () => { + const old = getActiveSurfaceScope(); + registerSubmittedMessageScrollIntent(old, 'session', 'turn', 'message'); + activateSurface('peer'); activateSurface('local'); + registerSubmittedMessageScrollIntent(old, 'session', 'turn', 'message'); + expect(claimSubmittedMessageScrollIntent('session', 1)).toBeNull(); + }); + it('new submissions replace obsolete placement without stale cleanup removing the new intent', () => { + const changed = vi.fn(); const unsubscribe = subscribeSubmittedMessageScrollIntent(changed); + registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'session', 'first', 'm1'); + const first = claimSubmittedMessageScrollIntent('session', 1)!; + registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'session', 'second', 'm2'); + finishSubmittedMessageScrollIntent(first); + expect(claimSubmittedMessageScrollIntent('session', 1)?.turnId).toBe('second'); + expect(changed).toHaveBeenCalledTimes(2); unsubscribe(); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.ts b/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.ts new file mode 100644 index 0000000000..2fa8de2dfc --- /dev/null +++ b/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.ts @@ -0,0 +1,62 @@ +import { getActiveSurfaceScope, type SurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; + +export interface SubmittedMessageScrollIntent { + readonly scope: SurfaceScope; + readonly sessionId: string; + readonly turnId: string; + messageId: string | null; + viewportId?: number; +} + +// Separate from the 300ms arrival animation. A history projection may take +// longer to reveal the submitted Turn. Only explicit local submission writes +// here; replay, hydration, and other controllers never manufacture intent. +const pending = new Map(); +const listeners = new Set<() => void>(); +const cleanups = new WeakMap void>(); +const keyFor = (scope: SurfaceScope, sessionId: string) => scope.key('submit-scroll', scope.epoch, sessionId); + +export function registerSubmittedMessageScrollIntent( + scope: SurfaceScope, sessionId: string, turnId: string, messageId: string | null, +): SubmittedMessageScrollIntent | undefined { + if (!scope.isCurrent()) return undefined; + const key = keyFor(scope, sessionId); + const intent: SubmittedMessageScrollIntent = { scope, sessionId, turnId, messageId }; + const previous = pending.get(key); + if (previous) finishSubmittedMessageScrollIntent(previous); + pending.set(key, intent); + scope.signal.addEventListener('abort', discard, { once: true }); + function discard() { finishSubmittedMessageScrollIntent(intent); } + cleanups.set(intent, () => scope.signal.removeEventListener('abort', discard)); + listeners.forEach(listener => listener()); + return intent; +} + +export function claimSubmittedMessageScrollIntent(sessionId: string, viewportId: number, includeUnbound = false) { + const intent = pending.get(keyFor(getActiveSurfaceScope(), sessionId)); + if (!intent?.scope.isCurrent() || (intent.viewportId !== undefined && intent.viewportId !== viewportId)) return null; + if (!includeUnbound && intent.messageId === null) return null; + intent.viewportId = viewportId; + return intent; +} + +/** A host Turn can arrive before its submit RPC returns. Bind only a Turn this + * device explicitly submitted, before publishing its user row to the store. */ +export function bindSubmittedMessageScrollIntent(sessionId: string, turnId: string, messageId: string): void { + const intent = pending.get(keyFor(getActiveSurfaceScope(), sessionId)); + if (!intent?.scope.isCurrent() || intent.turnId !== turnId || intent.messageId !== null) return; + intent.messageId = messageId; + listeners.forEach(listener => listener()); +} + +export function finishSubmittedMessageScrollIntent(intent: SubmittedMessageScrollIntent): void { + const key = keyFor(intent.scope, intent.sessionId); + if (pending.get(key) === intent) pending.delete(key); + cleanups.get(intent)?.(); + cleanups.delete(intent); +} + +export function subscribeSubmittedMessageScrollIntent(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} diff --git a/src/web-ui/src/flow_chat/session-drivers/shared.ts b/src/web-ui/src/flow_chat/session-drivers/shared.ts index a3aea8fcea..16ebf2f5d8 100644 --- a/src/web-ui/src/flow_chat/session-drivers/shared.ts +++ b/src/web-ui/src/flow_chat/session-drivers/shared.ts @@ -7,6 +7,7 @@ import type { FlowChatContext } from '../services/flow-chat-manager/types'; import type { SurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { DialogTurn } from '../types/flow-chat'; import { registerSubmittedMessage } from '../services/submittedMessagePresentation'; +import { registerSubmittedMessageScrollIntent } from '../services/submittedMessageScrollIntent'; /** Register send feedback before the synchronous optimistic projection can render. */ export function addSubmittedDialogTurn( @@ -18,6 +19,7 @@ export function addSubmittedDialogTurn( const session = context.flowChatStore.getState().sessions.get(sessionId); if (session && !session.dialogTurns.some(existing => existing.id === turn.id)) { registerSubmittedMessage(scope, sessionId, turn.id, turn.userMessage.id); + registerSubmittedMessageScrollIntent(scope, sessionId, turn.id, turn.userMessage.id); } context.flowChatStore.addDialogTurn(sessionId, turn); } diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 435fdb5ecd..53f3cc6408 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -168,8 +168,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { if ( cachedItems && cachedItems.hasNewerDialogTurn === hasNewerDialogTurn && - cachedItems.absoluteTurnIndex === absoluteTurnIndex && - isStableTurnProjection(turn) + cachedItems.absoluteTurnIndex === absoluteTurnIndex ) { items.push(...cachedItems.items); return; diff --git a/src/web-ui/src/flow_chat/timeline/TimelineContentBlock.scss b/src/web-ui/src/flow_chat/timeline/TimelineContentBlock.scss new file mode 100644 index 0000000000..08fb0af7df --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/TimelineContentBlock.scss @@ -0,0 +1,34 @@ +// Desktop composition owns the row, while the public cards retain their anatomy. +.virtual-item-wrapper > :is(.flowchat-timeline-content, .flowchat-timeline-members) + > .flowchat-agent-card-grid[data-flow-item-stack][data-agent-card-grid] { + display: grid; + // Tracks retain the same card width when a side pane opens or the window + // narrows. Only a pane narrower than one card can constrain that width. + grid-template-columns: repeat(auto-fill, min(100%, calc( + (var(--openbitfun-control-flow-chat-transcript-max-width) + - 2 * var(--openbitfun-control-flow-chat-content-padding-inline) + - 2 * var(--openbitfun-control-flow-chat-paragraph-gap)) / 3 + ))); + justify-content: start; + gap: var(--openbitfun-control-flow-chat-paragraph-gap); + margin-block-end: var(--openbitfun-control-flow-chat-paragraph-gap); + + // Full-width thinking keeps its original DOM and successor relationship. + > .flow-thinking-item { grid-column: 1 / -1; } + > :is(.flow-thinking-item, .flowchat-flow-item) { + min-inline-size: 0; + inline-size: 100%; + margin: 0; + } + + // The fixed grid cell supplies the width, including the paragraph-gap tokens. + // Avoid applying the standalone card-gap-based cap a second time. + > .flowchat-flow-item [data-openbitfun-tool-card='agent-control'] { + max-inline-size: 100%; + } +} + +.virtual-item-wrapper[data-turn-boundary-after='true'] + > .flowchat-timeline-content > .flowchat-agent-card-grid { + margin-block-end: 0; +} diff --git a/src/web-ui/src/flow_chat/timeline/TimelineContentBlock.tsx b/src/web-ui/src/flow_chat/timeline/TimelineContentBlock.tsx new file mode 100644 index 0000000000..5b588d18bd --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/TimelineContentBlock.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useMemo } from 'react'; +import type { VirtualItem } from '../types/flow-chat-projection'; +import { useI18n } from '@/infrastructure/i18n'; +import { getFlowGroupCategory } from '../grouping/types'; +import { FileEditGroupContext } from '../grouping/FileEditGroupContext'; +import { getConcurrentCapsuleRows } from '../tool-cards/toolCapsuleLayout'; +import { FlowGroupItemRenderer } from '../components/modern/FlowGroupRenderer'; +import { useCreateTypewriterRevealGate } from '../hooks/typewriterRevealGateContext'; +import { TypewriterRevealGateProvider } from '../hooks/TypewriterRevealGate'; +import { useFlowChatReaderStore, useTimelineReveal } from './readerState'; +import { useFlowChatContext } from '../components/modern/FlowChatContext'; +import { getModelRoundItemClassName } from '../components/modern/modelRoundItemClassName'; +import './TimelineContentBlock.scss'; + +const GroupMember: React.FC> = props =>
; + +/** The same keyed renderer owns a leaf before, during and after collection. */ +export function TimelineContentBlock({ item }: { item: Extract }) { + const { t, formatNumber } = useI18n('flow-chat'); + const block = item.timeline!; + const group = block.group; + const groupId = group?.groupId; + const { activeSessionOverride } = useFlowChatContext(); + const reader = useFlowChatReaderStore(); + const gate = useCreateTypewriterRevealGate(); + useTimelineReveal(item.turnId, block.key, gate.isAnyRevealing); + useEffect(() => { + if (gate.isAnyRevealing && groupId) return reader?.holdGroup(groupId, `reveal:${block.key}`); + }, [gate.isAnyRevealing, reader, groupId, block.key]); + const parallel = useMemo(() => getConcurrentCapsuleRows(item.data.items), [item.data.items]); + const revisions = useMemo(() => { + if (!group || getFlowGroupCategory(group) !== 'file-edit') return undefined; + return { + revisionLabels: new Map(item.data.items.filter(member => member.type === 'tool') + .map((member, index) => [member.id, t('fileEditGroup.revision', { count: formatNumber((block.memberOrdinal ?? 0) + index + 1) })])), + // Each visible card owns its on-demand diff stats. The header separately + // reads the same snapshot service; no invisible member card is mounted. + diffStats: new Map(), + }; + }, [group, item.data.items, block.memberOrdinal, t, formatNumber]); + return + +
+
+ {item.data.items.map((member, index) => )} +
+
+
+
; +} diff --git a/src/web-ui/src/flow_chat/timeline/TimelineMutationBoundary.tsx b/src/web-ui/src/flow_chat/timeline/TimelineMutationBoundary.tsx new file mode 100644 index 0000000000..a37bce262e --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/TimelineMutationBoundary.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +interface Props { + itemKeys: readonly string[]; + scrollerRef: React.RefObject; + canRepair: () => boolean; + shift: (delta: number) => boolean; + onRepaired?: () => void; + /** The viewport may retain a leading block or its surviving group header. */ + snapshotAnchor?: () => { key: string; offset: number } | null; + children: React.ReactNode; + rowSelector?: string; +} +interface Snapshot { key: string; offset: number; scrollTop: number } + +/** Layout transactions preserve a surviving reading block before the next paint. */ +export class TimelineMutationBoundary extends React.Component { + getSnapshotBeforeUpdate(previous: Props): Snapshot | null { + const { itemKeys, scrollerRef, canRepair } = this.props; + const scroller = scrollerRef.current; + if (!scroller || !scroller.clientHeight || !canRepair() || previous.itemKeys === itemKeys) return null; + // Existing history-prepend compensation remains the sole owner of prepends. + if (itemKeys[0] !== previous.itemKeys[0]) return null; + if (itemKeys.length === previous.itemKeys.length && itemKeys.every((key, index) => key === previous.itemKeys[index])) return null; + const leading = this.props.snapshotAnchor?.(); + if (leading) return { ...leading, scrollTop: scroller.scrollTop }; + const surviving = new Set(itemKeys); + const top = scroller.getBoundingClientRect().top; + const row = [...scroller.querySelectorAll(this.props.rowSelector ?? '.virtual-item-wrapper')].find(element => { + const rect = element.getBoundingClientRect(); + return rect.bottom > top && rect.top < top + scroller.clientHeight && surviving.has(element.dataset.virtualItemKey!); + }); + return row ? { key: row.dataset.virtualItemKey!, offset: row.getBoundingClientRect().top - top, scrollTop: scroller.scrollTop } : null; + } + componentDidUpdate(_previous: Props, _state: object, snapshot: Snapshot | null) { + const { scrollerRef, canRepair, shift, onRepaired } = this.props; + const scroller = scrollerRef.current; + if (!snapshot || !scroller || !canRepair()) return; + const row = [...scroller.querySelectorAll(this.props.rowSelector ?? '.virtual-item-wrapper')] + .find(element => element.dataset.virtualItemKey === snapshot.key); + if (!row) return; + const delta = row.getBoundingClientRect().top - scroller.getBoundingClientRect().top - snapshot.offset; + // Measure the remaining error, not total height change. Measurements may + // already have repaired some or all of it during child layout effects. + if (Math.abs(delta) > 0.5 && shift(delta)) onRepaired?.(); + } + render() { return this.props.children; } +} diff --git a/src/web-ui/src/flow_chat/timeline/agentCardLayout.ts b/src/web-ui/src/flow_chat/timeline/agentCardLayout.ts new file mode 100644 index 0000000000..99f34c6e40 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/agentCardLayout.ts @@ -0,0 +1,33 @@ +import type { FlowItem, FlowToolItem } from '../types/flow-chat'; +import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; +import { readInteractionInput, readToolRecord, toolString } from '../tool-cards/toolInteractionModel'; + +// Six is divisible by every supported column count (1, 2, 3). Batch boundaries +// therefore never leave a hole mid-row, and resizing never reparents a card. +export const AGENT_CARD_BATCH_SIZE = 6; + +/** Launch identity cards share layout; send/wait/interrupt actions keep their rows. */ +export function isAgentLaunchCard(item: FlowItem): boolean { + if (item.type !== 'tool') return false; + const tool = projectEffectiveToolItem(item as FlowToolItem); + if (tool.toolName === 'AgentSpawn') return true; + if (tool.toolName !== 'Task' && tool.toolName !== 'LaunchReviewAgent') return false; + const action = (toolString(readInteractionInput(tool).action) + || toolString(readToolRecord(tool.toolResult?.result).action)).toLowerCase(); + return action !== 'send_input' && action !== 'cancel'; +} + +/** A leading thought remains a sibling of its first successor for handoff. */ +export function isAgentCardRun(items: readonly FlowItem[]): boolean { + return items.some(isAgentLaunchCard) + && items.every(item => item.type === 'thinking' || isAgentLaunchCard(item)); +} + +// Estimates use the default public transcript geometry. CSS and DOM measurement +// remain authoritative for Appearance overrides and each viewport's rail/insets. +export function estimateAgentCardColumns(availableWidthPx = 900): number { + const contentWidth = Math.max(0, Math.min(900, availableWidthPx) - 24); + const gap = 12; + const cardWidth = (900 - 24 - 2 * gap) / 3; + return Math.max(1, Math.min(3, Math.floor((contentWidth + gap) / (cardWidth + gap)))); +} diff --git a/src/web-ui/src/flow_chat/timeline/contentScheduler.ts b/src/web-ui/src/flow_chat/timeline/contentScheduler.ts new file mode 100644 index 0000000000..2917084171 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/contentScheduler.ts @@ -0,0 +1,43 @@ +type Priority = 'interaction' | 'visible' | 'background'; +interface Work { run: () => void; cancelled: boolean; priority: Priority } +const priorities: Priority[] = ['interaction', 'visible', 'background']; + +/** One cooperative queue for all desktop panes. A task must itself be bounded. */ +export class FlowChatContentScheduler { + private queue: Work[] = []; + private scheduled = false; + private channel: MessageChannel | undefined; + + schedule(run: () => void, priority: Priority = 'visible'): () => void { + const work = { run, priority, cancelled: false }; + this.queue.push(work); + this.request(); + return () => { work.cancelled = true; }; + } + private request() { + if (this.scheduled) return; + this.scheduled = true; + if (typeof MessageChannel === 'undefined') { setTimeout(() => this.flush(), 0); return; } + if (!this.channel) { + this.channel = new MessageChannel(); + this.channel.port1.onmessage = () => this.flush(); + } + this.channel.port2.postMessage(null); + } + private flush() { + this.scheduled = false; + const start = performance.now(); + // Alternate priorities each task turn, so streaming cannot starve queued + // user requests or background disposal in another pane. + for (const priority of priorities) { + const index = this.queue.findIndex(work => !work.cancelled && work.priority === priority); + if (index < 0) continue; + const [work] = this.queue.splice(index, 1); + work.run(); + if (performance.now() - start >= 4) break; + } + this.queue = this.queue.filter(work => !work.cancelled); + if (this.queue.length) this.request(); + } +} +export const flowChatContentScheduler = new FlowChatContentScheduler(); diff --git a/src/web-ui/src/flow_chat/timeline/document.test.ts b/src/web-ui/src/flow_chat/timeline/document.test.ts new file mode 100644 index 0000000000..a7871e6360 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/document.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { ConversationDocumentProjection, conversationContentRuns, findTimelineBlockIndex } from './document'; +import { FlowChatReaderState } from './readerState'; +import type { FlowItem, FlowToolItem } from '../types/flow-chat'; +import type { VirtualItem } from '../types/flow-chat-projection'; +import { estimateVirtualItemHeight } from '../components/modern/virtualItemHeightEstimators'; + +const tool = (id: string, status: FlowToolItem['status'] = 'completed'): FlowToolItem => ({ + id, type: 'tool', toolName: 'Read', timestamp: 1, status, + toolCall: { id, input: { file_path: `/src/${id}.ts` } }, toolResult: { success: true, result: {} }, +}); +const source = (items: FlowItem[]): Extract => ({ + type: 'model-round', turnId: 'turn', isLastRound: true, isTurnComplete: true, + data: { id: 'round', index: 0, startTime: 1, status: 'completed', isStreaming: false, isComplete: true, items }, +}); +const options = () => ({ reader: new FlowChatReaderState(), toolLabels: new Map() }); +const agent = (id: string, toolName = 'AgentSpawn', status: FlowToolItem['status'] = 'completed'): FlowToolItem => ({ + ...tool(id, status), toolName, toolCall: { id, input: { description: `Inspect ${id}` } }, +}); + +describe('desktop conversation document', () => { + it('packs adjacent launch cards independently of execution status or timing', () => { + const projection = new ConversationDocumentProjection(); + const launches = [agent('a', 'Task', 'running'), agent('b', 'AgentSpawn', 'cancelled'), agent('c', 'LaunchReviewAgent', 'error')]; + const input = source(launches); + const before = JSON.stringify(input); + const rows = projection.project([input], options()); + expect(rows[0].timeline?.layout).toBe('agent-cards'); + expect(rows[0].timeline?.memberIds).toEqual(['a', 'b', 'c']); + for (const id of ['a', 'b', 'c']) expect(findTimelineBlockIndex(rows, 0, id)).toBe(0); + expect(JSON.stringify(input)).toBe(before); + }); + + it('keeps launch batches bounded and earlier blocks stable while new agents arrive', () => { + const projection = new ConversationDocumentProjection(); + const opts = options(); + const launches = Array.from({ length: 600 }, (_, index) => agent(`agent-${index}`)); + const first = projection.project([source(launches.slice(0, 7))], opts); + const next = projection.project([source(launches.slice(0, 8))], opts); + expect(next[0]).toBe(first[0]); + expect(next[1].timeline?.key).toBe(first[1].timeline?.key); + expect(next[1].timeline?.memberIds).toEqual(['agent-6', 'agent-7']); + const large = projection.project([source(launches)], opts); + const batches = large.filter(row => row.timeline?.layout === 'agent-cards'); + expect(batches).toHaveLength(100); + expect(batches.every(row => row.timeline!.memberIds.length === 6)).toBe(true); + expect(large[findTimelineBlockIndex(large, 0, 'agent-599')].timeline?.memberIds).toContain('agent-599'); + }); + + it('preserves thinking successors and stops card rows at narrative and interaction boundaries', () => { + const thought = { id: 'thought', type: 'thinking', content: 'Planning', status: 'completed', isStreaming: false } as FlowItem; + const text = { id: 'text', type: 'text', content: 'Next task', status: 'completed', isStreaming: false } as FlowItem; + const send = { ...agent('send', 'Task'), toolCall: { id: 'send', input: { action: 'send_input' } } }; + const cancel = { ...agent('cancel', 'Task'), toolResult: { success: true, result: { action: 'cancel' } } }; + const deferred = { ...agent('deferred'), toolName: 'CallDeferredTool', + toolCall: { id: 'deferred', input: { tool_name: 'AgentSpawn', args: {} } } }; + expect(conversationContentRuns([thought, agent('a'), deferred, text, agent('b'), send, cancel, agent('c'), thought, agent('d')]) + .map(run => run.map(item => item.id))).toEqual([ + ['thought', 'a', 'deferred'], ['text'], ['b'], ['send'], ['cancel'], ['c'], ['thought', 'd'], + ]); + }); + + it('estimates visual grid rows instead of summing the heights of every card', () => { + const projection = new ConversationDocumentProjection(); + const opts = options(); + const one = projection.project([source([agent('a')])], opts)[0]; + const three = projection.project([source([agent('a'), agent('b'), agent('c')])], opts)[0]; + expect(estimateVirtualItemHeight(three, { availableWidthPx: 900 }).heightPx) + .toBe(estimateVirtualItemHeight(one, { availableWidthPx: 900 }).heightPx); + expect(estimateVirtualItemHeight(three, { availableWidthPx: 640 }).heightPx).toBe(184); + expect(estimateVirtualItemHeight(three, { availableWidthPx: 400 }).heightPx).toBe(276); + }); + + it('projects a large group into bounded independent members without duplicating payload trees', () => { + const projection = new ConversationDocumentProjection(); + const input = source(Array.from({ length: 1000 }, (_, index) => tool(`read-${index}`))); + const before = JSON.stringify(input); + const opts = options(); + const collapsed = projection.project([input], opts); + const header = collapsed.find(item => item.timeline?.kind === 'group-header')!; + expect(collapsed.some(item => item.timeline?.kind === 'group-members')).toBe(false); + expect(findTimelineBlockIndex(collapsed, 0, 'read-900')).toBe(collapsed.indexOf(header)); + const open = projection.project([input], { ...opts, groupStates: new Map([[header.timeline!.group!.groupId, true]]) }); + const members = open.filter(item => item.timeline?.kind === 'group-members'); + expect(members).toHaveLength(1000); + expect(members.every(item => item.timeline!.memberIds.length === 1 && item.timeline!.group!.allItems.length === 0)).toBe(true); + expect(open[findTimelineBlockIndex(open, 0, 'read-900')].timeline?.memberIds).toEqual(['read-900']); + expect(JSON.stringify(input)).toBe(before); + }); + + it('keeps unchanged leaves stable while another member receives output', () => { + const projection = new ConversationDocumentProjection(); + const a = tool('a'), b = tool('b', 'running'); + const opts = options(); + const first = projection.project([source([a, b])], opts); + const next = projection.project([source([a, { ...b, partialParams: { file_path: '/b' } }])], opts); + expect(next.find(item => item.timeline?.memberIds[0] === 'a')) + .toBe(first.find(item => item.timeline?.memberIds[0] === 'a')); + expect(next.find(item => item.timeline?.memberIds[0] === 'b')) + .not.toBe(first.find(item => item.timeline?.memberIds[0] === 'b')); + }); + + it('honors explicit folding without destroying child choices and always exposes permission requests', () => { + const projection = new ConversationDocumentProjection(); + const opts = options(); + const input = source([tool('a'), tool('b')]); + const header = projection.project([input], opts).find(item => item.timeline?.group)!; + const id = header.timeline!.group!.groupId; + opts.reader.set('card:b:details', true); + const release = opts.reader.holdGroup(id, 'selection'); + expect(projection.project([input], opts).some(item => item.timeline?.kind === 'group-members')).toBe(true); + const folded = { ...opts, groupStates: new Map([[id, false]]) }; + expect(projection.project([input], folded).some(item => item.timeline?.kind === 'group-members')).toBe(false); + expect(opts.reader.get('card:b:details', false)).toBe(true); + expect(projection.project([input], { ...folded, pendingPermissionToolCallIds: new Set(['b']) }) + .some(item => item.timeline?.memberIds.includes('b'))).toBe(true); + release(); + }); + + it('applies remembered filters before materialization and supports legacy group sources', () => { + const projection = new ConversationDocumentProjection(); + const opts = options(); + const legacy: VirtualItem = { type: 'explore-group', turnId: 'turn', data: { + groupId: 'legacy', allItems: [tool('a'), { ...tool('b', 'error'), toolName: 'ExecCommand' }], rounds: [], + stats: { readCount: 2, searchCount: 0, commandCount: 0 }, + isGroupStreaming: false, isLastGroupInTurn: true, wasCutByCritical: false, + } }; + opts.reader.set('group:legacy:status', 'attention'); + const rows = projection.project([legacy], { ...opts, groupStates: new Map([['legacy', true]]) }); + expect(rows[0].timeline?.expanded).toBe(true); + expect(rows.flatMap(row => row.timeline?.memberIds ?? [])).toEqual(['b']); + }); +}); diff --git a/src/web-ui/src/flow_chat/timeline/document.ts b/src/web-ui/src/flow_chat/timeline/document.ts new file mode 100644 index 0000000000..c045836c96 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/document.ts @@ -0,0 +1,200 @@ +import type { AnyFlowItem, FlowItem, FlowToolItem, ModelRound } from '../types/flow-chat'; +import type { VirtualItem, TimelineBlock } from '../types/flow-chat-projection'; +import { buildInlineToolGroupData, getProjectedModelRoundGroups, hasModelRoundLeadingControls } from '../grouping/roundGroups'; +import { getFlowGroupDisclosureChoice, isFlowGroupExpanded, type FlowGroupData } from '../grouping/types'; +import { filterFlowGroupItems, type FlowGroupStatusFilter } from '../grouping/browse'; +import { isFlowItemVisible } from '../utils/flowItemVisibility'; +import { toolCapsuleStateKey } from '../tool-cards/toolCapsuleModel'; +import type { FlowChatReaderState } from './readerState'; +import { getVirtualItemStableKey } from '../components/modern/virtualItemIdentity'; +import { getConcurrentCapsuleRows } from '../tool-cards/toolCapsuleLayout'; +import { AGENT_CARD_BATCH_SIZE, isAgentCardRun, isAgentLaunchCard } from './agentCardLayout'; + +export interface TimelineProjectionOptions { + reader: FlowChatReaderState; + sessionId?: string; + groupStates?: ReadonlyMap; + expandedToolCapsules?: ReadonlySet; + pendingPermissionToolCallIds?: ReadonlySet; + toolLabels: ReadonlyMap; +} + +/** A thought and its successor stay together for the existing annotation handoff. */ +export function conversationContentRuns(items: readonly FlowItem[]): FlowItem[][] { + const runs: FlowItem[][] = []; + let pending: FlowItem[] = []; + let agentCount = 0; + const flush = () => { if (pending.length) runs.push(pending); pending = []; agentCount = 0; }; + const parallel = getConcurrentCapsuleRows(items); + for (const item of items) { + const agent = isAgentLaunchCard(item); + // A new thought or any other visible activity is a narrative boundary. + if (agentCount && !agent) flush(); + pending.push(item); + if (agent) { + if (++agentCount === AGENT_CARD_BATCH_SIZE) flush(); + } else if (item.type !== 'thinking' && (parallel.get(item.id) !== 'member' || pending.length >= 4)) flush(); + } + flush(); + return runs; +} + +function readerExpanded(group: FlowGroupData, turnId: string, options: TimelineProjectionOptions): boolean { + const choice = getFlowGroupDisclosureChoice(group, options.groupStates); + const permission = group.allItems.some(item => item.type === 'tool' + && (item.status === 'pending_confirmation' || options.pendingPermissionToolCallIds?.has((item as FlowToolItem).toolCall.id))); + if (permission) return true; + if (choice !== undefined) return choice; + return isFlowGroupExpanded(group, options.groupStates) + || options.reader.isGroupHeld(group.groupId) + || group.allItems.some(item => options.expandedToolCapsules?.has(toolCapsuleStateKey(options.sessionId, turnId, item.id))); +} + +type RoundItem = Extract; +const emptyItems: FlowItem[] = []; + +/** + * Execution rounds remain immutable source facts. Only this desktop projection + * chooses render/measurement units. Reconciliation preserves unchanged leaves + * even when a different leaf in the same live round receives another token. + */ +export class ConversationDocumentProjection { + private previous = new Map(); + private sourceCache = new WeakMap(); + private next = new Map(); + private labels?: ReadonlyMap; + + project(sources: readonly VirtualItem[], options: TimelineProjectionOptions): VirtualItem[] { + if (this.labels !== options.toolLabels) { + this.labels = options.toolLabels; + this.sourceCache = new WeakMap(); + } + this.next = new Map(); + const result: VirtualItem[] = []; + const viewRevision = `${options.reader.getProjectionRevision()}:${[...(options.groupStates ?? [])].map(([id, value]) => `${id}:${value}`).join('|')}:${[...(options.expandedToolCapsules ?? [])].join('|')}:${[...(options.pendingPermissionToolCallIds ?? [])].join('|')}`; + sources.forEach((original, sourceIndex) => { + // Legacy whole-round groups obey the same residency contract. This is a + // render adapter only; neither their recorded rounds nor ids are changed. + const source: VirtualItem = original.type === 'explore-group' ? { + type: 'model-round', turnId: original.turnId, isLastRound: false, isTurnComplete: false, + data: { id: original.data.groupId, index: 0, startTime: 0, items: [], + status: original.data.isGroupStreaming ? 'streaming' : 'completed', + isComplete: !original.data.isGroupStreaming, isStreaming: original.data.isGroupStreaming }, + projectedGroups: [{ type: original.data.category ?? 'explore', items: original.data.allItems, + isLast: original.data.isLastGroupInTurn, projection: original.data }], + } as VirtualItem : original; + if (source.type !== 'model-round') { + const key = getVirtualItemStableKey(source); + const cached = this.sourceCache.get(source); + const row = cached?.revision === String(sourceIndex) ? cached.items[0] : { + ...source, timeline: { key, kind: 'content' as const, sourceIndex, memberIds: [] }, + }; + this.sourceCache.set(source, { revision: String(sourceIndex), items: [row] }); + this.next.set(key, row); + result.push(row); + return; + } + // Source identity is already shared by completed immutable Turns. Reader + // changes only invalidate their projection, never their payloads. + const revision = `${sourceIndex}:${viewRevision}`; + const cached = this.sourceCache.get(original); + if (cached?.revision === revision) { + for (const row of cached.items) this.next.set(row.timeline!.key, row); + result.push(...cached.items); + return; + } + const rows: VirtualItem[] = []; + const add = (kind: TimelineBlock['kind'], id: string, members: FlowItem[], extra: Partial = {}) => { + const key = `${source.turnId}:${id}`; + const previous = this.previous.get(key); + const chrome = kind === 'round-header' || kind === 'round-footer'; + const data: ModelRound = chrome ? source.data : { + ...source.data, items: members as AnyFlowItem[], attempts: undefined, historyRounds: undefined, + renderHints: undefined, + isStreaming: members.some(member => member.status === 'streaming' || member.status === 'running' + || ('isStreaming' in member && member.isStreaming === true)), + }; + const row: RoundItem = { + ...source, data, + projectedGroups: members.map(item => ({ type: 'critical', item })), + isLastRound: (kind === 'round-footer' || (kind === 'content' && extra.last === true)) && source.isLastRound, + canvasArtifactItems: kind === 'round-footer' ? source.canvasArtifactItems : undefined, + timeline: { key, kind, sourceIndex, memberIds: members.map(item => item.id), + layout: isAgentCardRun(members) ? 'agent-cards' : undefined, ...extra }, + }; + const reused = previous?.type === 'model-round' && previous.timeline?.kind === kind + && previous.timeline.sourceIndex === sourceIndex + && (kind === 'group-members' + ? previous.timeline.group?.groupId === extra.group?.groupId + && previous.timeline.group?.phase === extra.group?.phase + && previous.timeline.group?.isLastGroupInTurn === extra.group?.isLastGroupInTurn + && previous.timeline.memberOrdinal === extra.memberOrdinal + : previous.timeline.group === extra.group) + && previous.timeline.expanded === extra.expanded + && previous.timeline.layout === row.timeline!.layout + && previous.timeline.first === extra.first && previous.timeline.last === extra.last + && previous.timeline.revealThinkingIds?.join('|') === extra.revealThinkingIds?.join('|') + && previous.isLastRound === row.isLastRound && previous.isTurnComplete === row.isTurnComplete + && previous.layoutHints?.expandedThinkingItemIds.join('|') === row.layoutHints?.expandedThinkingItemIds.join('|') + && (chrome ? previous.data === data && previous.canvasArtifactItems === row.canvasArtifactItems + : previous.data.items.length === members.length && previous.data.items.every((item, index) => item === members[index])) + ? previous : row; + this.next.set(key, reused); + rows.push(reused); + }; + if (hasModelRoundLeadingControls(source.data)) add('round-header', `${source.data.id}:history`, emptyItems); + const groups = getProjectedModelRoundGroups(source); + let critical: FlowItem[] = []; + const flush = (last = false) => { + const runs = conversationContentRuns(critical); + runs.forEach((run, index) => add('content', run[0].id, run, { last: last && index === runs.length - 1 })); + critical = []; + }; + groups.forEach(group => { + if (group.type === 'critical') { if (isFlowItemVisible(group.item)) critical.push(group.item); return; } + flush(); + const data = buildInlineToolGroupData(source.data.id, group, source.isLastRound && group.isLast); + const expanded = readerExpanded(data, source.turnId, options); + add('group-header', `group:${data.groupId}`, emptyItems, { group: data, expanded }); + if (!expanded) return; + const prefix = `group:${data.groupId}:`; + const filter = { + query: options.reader.get(`${prefix}query`, ''), + tool: options.reader.get(`${prefix}tool`, 'all'), + status: options.reader.get(`${prefix}status`, 'all') as FlowGroupStatusFilter, + }; + const all = data.allItems.filter(isFlowItemVisible); + const browse = filterFlowGroupItems(all, filter, options.toolLabels, options.pendingPermissionToolCallIds); + const runs = conversationContentRuns(all.filter(item => browse.visibleIds.has(item.id))); + // Member blocks hold only group metadata, never the full source tree. + const memberGroup = { ...data, rounds: [], allItems: [] }; + const ordinals = new Map(all.filter(member => member.type === 'tool').map((member, index) => [member.id, index])); + runs.forEach((run, index) => add('group-members', run[0].id, run, + { group: memberGroup, expanded, first: index === 0, last: index === runs.length - 1, + memberOrdinal: ordinals.get(run.find(member => member.type === 'tool')?.id ?? ''), + revealThinkingIds: run.filter(member => browse.matchingThinkingIds.has(member.id)).map(member => member.id) })); + }); + flush(true); + if ((source.isLastRound && source.isTurnComplete) || source.canvasArtifactItems?.length) { + add('round-footer', `${source.data.id}:footer`, emptyItems); + } + this.sourceCache.set(original, { revision, items: rows }); + result.push(...rows); + }); + this.previous = this.next; + return result; + } +} + +/** Source/search indexes never leak into the virtualizer's block index space. */ +export function findTimelineBlockIndex(items: readonly VirtualItem[], sourceIndex: number, memberId?: string): number { + if (memberId) { + const member = items.findIndex(item => item.timeline?.memberIds.includes(memberId) + || (item.type === 'model-round' && item.canvasArtifactItems?.some(candidate => candidate.id === memberId))); + if (member >= 0) return member; + const group = items.findIndex(item => item.timeline?.kind === 'group-header' + && item.timeline.group?.allItems.some(candidate => candidate.id === memberId)); + if (group >= 0) return group; + } + return items.findIndex((item, index) => (item.timeline?.sourceIndex ?? index) === sourceIndex); +} diff --git a/src/web-ui/src/flow_chat/timeline/highlight.worker.ts b/src/web-ui/src/flow_chat/timeline/highlight.worker.ts new file mode 100644 index 0000000000..fb141718e2 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/highlight.worker.ts @@ -0,0 +1,20 @@ +import Prism from 'prismjs'; +import 'prismjs/components/prism-typescript'; +import 'prismjs/components/prism-jsx'; +import 'prismjs/components/prism-tsx'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-rust'; +import 'prismjs/components/prism-bash'; +import { splitTokensByNewlines } from '../components/inlineDiffTokens'; + +self.onmessage = (event: MessageEvent<{ id: number; content: string; language: string }>) => { + const { id, content, language } = event.data; + try { + const grammar = Prism.languages[language]; + const lines = grammar ? splitTokensByNewlines(Prism.tokenize(content, grammar)) : content.split('\n').map(line => [line]); + self.postMessage({ id, lines }); + } catch { + self.postMessage({ id, lines: content.split('\n').map(line => [line]) }); + } +}; diff --git a/src/web-ui/src/flow_chat/timeline/highlightResources.ts b/src/web-ui/src/flow_chat/timeline/highlightResources.ts new file mode 100644 index 0000000000..f80b99961f --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/highlightResources.ts @@ -0,0 +1,87 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { LineTokens } from '../components/inlineDiffTokens'; +import { FlowChatResourceCache } from './resourceCache'; +import { flowChatContentScheduler } from './contentScheduler'; + +const cache = new FlowChatResourceCache(12 * 1024 * 1024, 96); +let worker: Worker | undefined; +let sequence = 0; +let idle: ReturnType | undefined; +const pending = new Map void; fallback: () => LineTokens[] }>(); +let activeId: number | undefined; + +function failWorker() { + worker?.terminate(); worker = undefined; activeId = undefined; + const requests = [...pending.values()]; + pending.clear(); + for (const request of requests) request.resolve(request.fallback()); + scheduleDisposal(); +} + +function pump() { + if (activeId !== undefined || !worker) return; + const next = pending.entries().next().value; + if (!next) { scheduleDisposal(); return; } + activeId = next[0]; + try { + worker.postMessage({ id: next[0], content: next[1].content, language: next[1].language }); + } catch { failWorker(); } +} + +function scheduleDisposal() { + clearTimeout(idle); + if (!pending.size) idle = setTimeout(() => { worker?.terminate(); worker = undefined; }, 30_000); +} +function tokenize(content: string, language: string, resolve: (lines: LineTokens[]) => void): () => void { + clearTimeout(idle); + const fallback = () => content.split('\n').map(line => [line]); + if (typeof Worker === 'undefined') { resolve(fallback()); return () => {}; } + const id = ++sequence; + try { + if (!worker) { + worker = new Worker(new URL('./highlight.worker.ts', import.meta.url), { type: 'module' }); + worker.onmessage = (event: MessageEvent<{ id: number; lines: LineTokens[] }>) => { + if (activeId !== event.data.id) return; + activeId = undefined; + const request = pending.get(event.data.id); + pending.delete(event.data.id); + request?.resolve(event.data.lines); + pump(); + }; + worker.onerror = failWorker; + } + pending.set(id, { content, language, resolve, fallback }); + pump(); + } catch { pending.delete(id); resolve(fallback()); scheduleDisposal(); } + return () => { + pending.delete(id); + // Only one payload is posted at a time. Cancelled queued work never enters + // the worker; cancel the active computation when no reader needs any work. + if (!pending.size && activeId !== undefined) { + worker?.terminate(); worker = undefined; activeId = undefined; + } + scheduleDisposal(); + }; +} + +/** Exact plain text is immediate. Highlighting never blocks a layout or a gesture. */ +export function useHighlightedLines(content: string, language: string): LineTokens[] { + const key = `${language}\u0000${content}`; + const [result, setResult] = useState<{ key: string; lines: LineTokens[] }>(); + const cached = cache.get(key); + useEffect(() => { + if (cache.has(key)) return; + let active = true; + let cancelWorker: (() => void) | undefined; + const cancelSchedule = flowChatContentScheduler.schedule(() => { + cancelWorker = tokenize(content, language, lines => { + if (!active) return; + cache.set(key, lines, key.length * 2 + content.length * 8 + lines.length * 80); + setResult({ key, lines }); + }); + }); + return () => { active = false; cancelSchedule(); cancelWorker?.(); }; + }, [content, language, key]); + const plain = useMemo(() => content.split('\n').map(line => [line]), [content]); + return cached ?? (result?.key === key ? result.lines : plain); +} diff --git a/src/web-ui/src/flow_chat/timeline/operationSummaryResource.test.ts b/src/web-ui/src/flow_chat/timeline/operationSummaryResource.test.ts new file mode 100644 index 0000000000..d5d810d3ee --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/operationSummaryResource.test.ts @@ -0,0 +1,32 @@ +import { expect, it, vi } from 'vitest'; +import { loadOperationSummary } from './operationSummaryResource'; + +const api = vi.hoisted(() => ({ getOperationSummary: vi.fn() })); +vi.mock('../../infrastructure/api', () => ({ snapshotAPI: api })); + +it('bounds IO, drops obsolete queued readers, and retains work needed by another pane', async () => { + const complete = new Map void>(); + api.getOperationSummary.mockImplementation((_session: string, operation: string) => new Promise(resolve => complete.set(operation, resolve))); + const active = Array.from({ length: 4 }, (_, index) => loadOperationSummary('s', `active-${index}`, 1, () => true)); + let firstReader = true; + const stale = loadOperationSummary('s', 'stale', 1, () => firstReader); + const shared = loadOperationSummary('s', 'shared', 1, () => firstReader); + const secondPane = loadOperationSummary('s', 'shared', 1, () => true); + expect(secondPane).toBe(shared); + firstReader = false; + await vi.waitFor(() => expect(api.getOperationSummary).toHaveBeenCalledTimes(4)); + for (let index = 0; index < 4; index++) complete.get(`active-${index}`)!(null); + await vi.waitFor(() => expect(api.getOperationSummary).toHaveBeenCalledTimes(5)); + expect(api.getOperationSummary).not.toHaveBeenCalledWith('s', 'stale'); + complete.get('shared')!({ linesAdded: 5, linesRemoved: 2 }); + await Promise.all(active); + expect(await stale).toBeNull(); + expect(await secondPane).toEqual({ additions: 5, deletions: 2 }); + expect(await loadOperationSummary('s', 'shared', 1, () => true)).toEqual({ additions: 5, deletions: 2 }); + expect(api.getOperationSummary).toHaveBeenCalledTimes(5); + // A missing snapshot can arrive later; its earlier null is not a cache entry. + const retry = loadOperationSummary('s', 'active-0', 1, () => true); + await vi.waitFor(() => expect(api.getOperationSummary).toHaveBeenCalledTimes(6)); + complete.get('active-0')!({ linesAdded: 1, linesRemoved: 0 }); + expect(await retry).toEqual({ additions: 1, deletions: 0 }); +}); diff --git a/src/web-ui/src/flow_chat/timeline/operationSummaryResource.ts b/src/web-ui/src/flow_chat/timeline/operationSummaryResource.ts new file mode 100644 index 0000000000..2dec982bc2 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/operationSummaryResource.ts @@ -0,0 +1,39 @@ +import type { FileOperationDiffStats } from '../tool-cards/fileOperationDiffStats'; +import { FlowChatResourceCache } from './resourceCache'; + +const cache = new FlowChatResourceCache(256 * 128, 256); +const pending = new Map; readers: Set<() => boolean> }>(); +const waiting: Array<() => void> = []; +let running = 0; +let api: Promise | undefined; + +/** One bounded IO lane across headers and cards in all desktop panes. */ +export function loadOperationSummary(session: string, operation: string, epoch: number, isCurrent: () => boolean) { + if (!isCurrent()) return Promise.resolve(null); + const key = JSON.stringify([epoch, session, operation]); + if (cache.has(key)) return Promise.resolve(cache.get(key)!); + const existing = pending.get(key); + if (existing) { existing.readers.add(isCurrent); return existing.promise; } + const readers = new Set([isCurrent]); + const needed = () => [...readers].some(current => current()); + const promise = new Promise((resolve, reject) => { + const run = async () => { + running++; + try { + if (!needed()) { resolve(null); return; } + const { snapshotAPI } = await (api ??= import('../../infrastructure/api')); + if (!needed()) { resolve(null); return; } + const summary = await snapshotAPI.getOperationSummary(session, operation); + const result = summary ? { additions: Number(summary.linesAdded ?? 0), deletions: Number(summary.linesRemoved ?? 0) } : null; + // A snapshot may not have been persisted yet. Missing summaries must + // remain retryable when the card re-enters the viewport. + if (needed() && result) cache.set(key, result, key.length * 2 + 32); + resolve(result); + } catch (error) { reject(error); } + finally { running--; pending.delete(key); waiting.shift()?.(); } + }; + if (running < 4) void run(); else waiting.push(run); + }); + pending.set(key, { promise, readers }); + return promise; +} diff --git a/src/web-ui/src/flow_chat/timeline/readerLifecycle.test.tsx b/src/web-ui/src/flow_chat/timeline/readerLifecycle.test.tsx new file mode 100644 index 0000000000..2d5af3387b --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/readerLifecycle.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { TimelineContentBlock } from './TimelineContentBlock'; +import { FlowChatReaderProvider, FlowChatReaderState, useFlowChatReaderValue } from './readerState'; +import type { VirtualItem } from '../types/flow-chat-projection'; + +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key, formatNumber: String }) })); +vi.mock('../components/modern/FlowChatContext', () => ({ useFlowChatContext: () => ({}) })); +vi.mock('../components/modern/FlowGroupRenderer', () => ({ FlowGroupItemRenderer: ({ item }: { item: { id: string } }) => { + const [draft, setDraft] = useFlowChatReaderValue(`draft:${item.id}`, ''); + return setDraft(event.target.value)} />; +} })); + +it('retains an active card node and native selection when the same source enters or leaves a group', () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host), reader = new FlowChatReaderState(); + const item = { type: 'model-round', turnId: 't', isLastRound: true, isTurnComplete: false, + data: { id: 'r', index: 0, items: [{ id: 'a', type: 'tool', toolName: 'Read', status: 'running', toolCall: { id: 'a', input: {} } }], isStreaming: true }, + timeline: { key: 't:a', kind: 'content', memberIds: ['a'], sourceIndex: 0 } } as Extract; + const render = (next: typeof item) => act(() => root.render()); + try { + reader.set('draft:a', 'keep this draft'); render(item); + const input = host.querySelector('input')!; input.focus(); input.setSelectionRange(2, 7); + render({ ...item, timeline: { ...item.timeline!, kind: 'group-members', first: true, last: true, + group: { groupId: 'g', category: 'explore', allItems: [], rounds: [], isGroupStreaming: true, isLastGroupInTurn: true, stats: { readCount: 1, searchCount: 0, commandCount: 0 } } } }); + expect(host.querySelector('input')).toBe(input); + expect(document.activeElement).toBe(input); + expect([input.selectionStart, input.selectionEnd]).toEqual([2, 7]); + render(item); expect(host.querySelector('input')).toBe(input); + act(() => root.render(null)); render(item); + expect(host.querySelector('input')?.value).toBe('keep this draft'); + } finally { act(() => root.unmount()); host.remove(); } +}); + +it('releases only the final reveal/interaction lease before automatic collection', () => { + const reader = new FlowChatReaderState(); + const releaseA = reader.holdGroup('g', 'selection'), releaseB = reader.holdGroup('g', 'reveal'); + releaseA(); expect(reader.isGroupHeld('g')).toBe(true); + releaseB(); expect(reader.isGroupHeld('g')).toBe(false); + const finishA = reader.reportReveal('turn', 'a'), finishB = reader.reportReveal('turn', 'b'); + finishA(); expect(reader.get('reveal:turn', false)).toBe(true); + finishB(); expect(reader.get('reveal:turn', true)).toBe(false); +}); diff --git a/src/web-ui/src/flow_chat/timeline/readerState.tsx b/src/web-ui/src/flow_chat/timeline/readerState.tsx new file mode 100644 index 0000000000..e62f0edc20 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/readerState.tsx @@ -0,0 +1,133 @@ +import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type Dispatch, type ReactNode, type SetStateAction } from 'react'; + +type ReaderValue = boolean | number | string; +type Listener = () => void; + +/** Small view facts only. Never put payloads, React nodes, DOM or requests here. */ +export class FlowChatReaderState { + private values = new Map(); + private listeners = new Map>(); + private projectionListeners = new Set(); + private revision = 0; + private groupHolds = new Map>(); + private reveals = new Map>(); + + reportReveal(turn: string, block: string) { + const owners = this.reveals.get(turn) ?? new Set(); + owners.add(block); + this.reveals.set(turn, owners); + this.set(`reveal:${turn}`, true); + return () => { + owners.delete(block); + if (!owners.size) { this.reveals.delete(turn); this.set(`reveal:${turn}`, false); } + }; + } + + isGroupHeld(id: string) { return Boolean(this.groupHolds.get(id)?.size); } + holdGroup(id: string, owner: string): () => void { + const owners = this.groupHolds.get(id) ?? new Set(); + owners.add(owner); + this.groupHolds.set(id, owners); + this.notifyProjection(); + return () => { + if (!owners.delete(owner)) return; + if (!owners.size) this.groupHolds.delete(id); + this.notifyProjection(); + }; + } + private notifyProjection() { + this.revision++; + this.projectionListeners.forEach(listener => listener()); + } + + get(key: string, fallback: T): T { + return (this.values.get(key) ?? fallback) as T; + } + has(key: string) { return this.values.has(key); } + set(key: string, value: T): void { + if (this.values.get(key) === value) return; + this.values.set(key, value); + this.listeners.get(key)?.forEach(listener => listener()); + if (key.startsWith('group:')) { + this.notifyProjection(); + } + } + subscribe = (key: string, listener: Listener) => { + const listeners = this.listeners.get(key) ?? new Set(); + listeners.add(listener); + this.listeners.set(key, listeners); + return () => { + listeners.delete(listener); + if (!listeners.size) this.listeners.delete(key); + }; + }; + subscribeProjection = (listener: Listener) => { + this.projectionListeners.add(listener); + return () => { this.projectionListeners.delete(listener); }; + }; + getProjectionRevision = () => this.revision; +} + +// Session switching preserves choices without retaining the session. Active views +// hold their own store; the bounded registry only owns inactive view snapshots. +const views = new Map(); +const MAX_REMEMBERED_VIEWS = 24; +export function getFlowChatReaderState(scope: string): FlowChatReaderState { + const store = views.get(scope) ?? new FlowChatReaderState(); + views.delete(scope); + views.set(scope, store); + while (views.size > MAX_REMEMBERED_VIEWS) views.delete(views.keys().next().value!); + return store; +} + +const ReaderContext = createContext(null); +export const useFlowChatReaderStore = () => useContext(ReaderContext); +const ItemContext = createContext(''); +export function FlowChatReaderProvider({ store, children }: { store: FlowChatReaderState; children: ReactNode }) { + return {children}; +} +export function FlowChatCardReaderProvider({ itemKey, children }: { itemKey: string; children: ReactNode }) { + return {children}; +} + +/** Reader choices survive viewport recycling; execution/completion never resets them. */ +export function useFlowChatReaderValue(key: string, initial: T): [T, Dispatch>] { + const context = useContext(ReaderContext); + const [fallback] = useState(() => new FlowChatReaderState()); + const store = context ?? fallback; + const seed = useRef({ store, key, initial }); + if (seed.current.key !== key || seed.current.store !== store) seed.current = { store, key, initial }; + const initialValue = seed.current.initial; + useLayoutEffect(() => { + if (!store.has(key)) store.set(key, initialValue); + }, [store, key, initialValue]); + const subscribe = useCallback((listener: Listener) => store.subscribe(key, listener), [store, key]); + const read = useCallback(() => store.get(key, initialValue), [store, key, initialValue]); + const value = useSyncExternalStore(subscribe, read, read); + const set = useCallback>>(next => { + store.set(key, typeof next === 'function' ? next(store.get(key, initialValue)) : next); + }, [store, key, initialValue]); + return [value, set]; +} + +export function useToolCardValue(slot: string, initial: T) { + const item = useContext(ItemContext); + return useFlowChatReaderValue(`card:${item}:${slot}`, initial); +} + +export function useToolCardDisclosure(slot = 'details', initial = false) { + return useToolCardValue(slot, initial); +} + +export function useFlowChatReaderScope(scope: string) { + return useMemo(() => getFlowChatReaderState(scope), [scope]); +} + +/** Footer and group lifecycles observe the same visual completion across rows. */ +export function useTimelineReveal(turn: string, block: string, revealing: boolean) { + const reader = useFlowChatReaderStore(); + useLayoutEffect(() => { + if (revealing) return reader?.reportReveal(turn, block); + }, [reader, turn, block, revealing]); + return useFlowChatReaderValue(`reveal:${turn}`, false)[0]; +} diff --git a/src/web-ui/src/flow_chat/timeline/resourceCache.ts b/src/web-ui/src/flow_chat/timeline/resourceCache.ts new file mode 100644 index 0000000000..1e20fec946 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/resourceCache.ts @@ -0,0 +1 @@ +export { BoundedResourceCache as FlowChatResourceCache } from '@/shared/utils/BoundedResourceCache'; diff --git a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts new file mode 100644 index 0000000000..87f2a5209e --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts @@ -0,0 +1,44 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; +import { prewarmMarkdownParse } from '@/infrastructure/markdown/markdownParseCache'; +import { useI18n } from '@/infrastructure/i18n'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { flowGroupToolLabels } from '../grouping/browse'; +import { useFlowChatVolatileContext } from '../components/modern/FlowChatContext'; +import type { VirtualItem } from '../types/flow-chat-projection'; +import { ConversationDocumentProjection } from './document'; +import { useFlowChatReaderScope } from './readerState'; + +export function useConversationTimeline(sources: VirtualItem[], sessionId: string | undefined, viewport: string) { + const { t } = useI18n('flow-chat'); + const surface = getActiveSurfaceScope(); + const reader = useFlowChatReaderScope(`${surface.epoch}:${viewport}:${sessionId ?? ''}`); + const revision = useSyncExternalStore(reader.subscribeProjection, reader.getProjectionRevision, reader.getProjectionRevision); + const { groupStates, exploreGroupStates, expandedToolCapsules, pendingPermissionToolCallIds } = useFlowChatVolatileContext(); + const projectionRef = useRef<{ reader: typeof reader; value: ConversationDocumentProjection } | null>(null); + if (projectionRef.current?.reader !== reader) { + projectionRef.current = { reader, value: new ConversationDocumentProjection() }; + } + const projection = projectionRef.current.value; + const labels = useMemo(() => flowGroupToolLabels(t), [t]); + useEffect(() => { + const texts = new Set(); + for (const source of sources) { + const members = source.type === 'explore-group' ? source.data.allItems + : source.type === 'model-round' ? source.projectedGroups?.flatMap(group => group.type === 'critical' ? [group.item] + : group.projection?.allItems ?? group.items) ?? source.data.items : []; + for (const member of members) if (member.type === 'text' && !('isStreaming' in member && member.isStreaming) + && 'content' in member && typeof member.content === 'string' && member.content.length >= 2000) texts.add(member.content); + } + const cancel = [...texts].map(prewarmMarkdownParse); + return () => cancel.forEach(stop => stop()); + }, [sources]); + const items = useMemo(() => { + // Reader mutations can change a projection while the source array is stable. + void revision; + return projection.project(sources, { + reader, sessionId, groupStates: groupStates ?? exploreGroupStates, + expandedToolCapsules, pendingPermissionToolCallIds, toolLabels: labels, + }); + }, [projection, sources, reader, sessionId, revision, groupStates, exploreGroupStates, expandedToolCapsules, pendingPermissionToolCallIds, labels]); + return { items, reader }; +} diff --git a/src/web-ui/src/flow_chat/timeline/useEmbeddedTimelineFollow.test.tsx b/src/web-ui/src/flow_chat/timeline/useEmbeddedTimelineFollow.test.tsx new file mode 100644 index 0000000000..d45e949750 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useEmbeddedTimelineFollow.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { useEmbeddedTimelineFollow } from './useEmbeddedTimelineFollow'; +import type { FlowChatViewportOwnerApi } from '../components/modern/useFlowChatViewportOwner'; + +it('hands a streaming embedded viewport to native text selection until the reader resumes', () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const frames = new Map(); + let next = 0, resize = () => {}; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { frames.set(++next, cb); return next; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.stubGlobal('ResizeObserver', class { constructor(cb: () => void) { resize = cb; } observe() {} disconnect() {} }); + const flush = () => act(() => { const pending = [...frames.values()]; frames.clear(); pending.forEach(cb => cb(0)); }); + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host), followRef = { current: true }, cancelAim = vi.fn(); + const owner = { write: vi.fn(), release: vi.fn() } as unknown as FlowChatViewportOwnerApi; + function View() { + const scrollerRef = useRef(null), contentRef = useRef(null); + useEmbeddedTimelineFollow({ scrollerRef, contentRef, followRef, viewportOwner: owner, revision: 0, cancelAim }); + return

A growing answer to select and read

; + } + try { + act(() => root.render()); + Object.defineProperty(host.firstChild!, 'clientHeight', { value: 400 }); + Object.defineProperty(host.firstChild!, 'scrollHeight', { value: 1200 }); + flush(); expect(owner.write).toHaveBeenCalledTimes(1); + const range = document.createRange(); range.selectNodeContents(host.querySelector('p')!); + const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); + act(() => document.dispatchEvent(new Event('selectionchange'))); + expect(followRef.current).toBe(false); expect(cancelAim).toHaveBeenCalled(); + expect(owner.release).toHaveBeenCalledWith('follow-output'); + resize(); flush(); expect(owner.write).toHaveBeenCalledTimes(1); + // A trailing scroll event must not undo the selection's takeover. + followRef.current = true; resize(); flush(); expect(owner.write).toHaveBeenCalledTimes(1); + followRef.current = false; selection.removeAllRanges(); + act(() => document.dispatchEvent(new Event('selectionchange'))); + resize(); flush(); expect(owner.write).toHaveBeenCalledTimes(1); + followRef.current = true; resize(); flush(); expect(owner.write).toHaveBeenCalledTimes(2); + } finally { act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); } +}); diff --git a/src/web-ui/src/flow_chat/timeline/useEmbeddedTimelineFollow.ts b/src/web-ui/src/flow_chat/timeline/useEmbeddedTimelineFollow.ts new file mode 100644 index 0000000000..2a89850a4a --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useEmbeddedTimelineFollow.ts @@ -0,0 +1,55 @@ +import { useEffect, type MutableRefObject, type RefObject } from 'react'; +import type { FlowChatViewportOwnerApi } from '../components/modern/useFlowChatViewportOwner'; + +/** Compact desktop panes use the same registered, gesture-cancellable tail writer. */ +export function useEmbeddedTimelineFollow({ scrollerRef, contentRef, followRef, viewportOwner, revision, cancelAim }: { + scrollerRef: RefObject; contentRef: RefObject; + followRef: MutableRefObject; viewportOwner: FlowChatViewportOwnerApi; + revision: unknown; cancelAim: () => void; +}) { + useEffect(() => { + const scroller = scrollerRef.current; + const content = contentRef.current; + if (!scroller || !content) return; + let frame = 0; + const hasSelection = () => { + const selection = window.getSelection(); + return Boolean(selection && !selection.isCollapsed && ( + scroller.contains(selection.anchorNode) || scroller.contains(selection.focusNode) + )); + }; + const pause = () => { + cancelAim(); followRef.current = false; viewportOwner.release('follow-output'); + }; + const selectionChanged = () => { if (hasSelection()) pause(); }; + const follow = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + if (followRef.current && !hasSelection() && scroller.clientHeight > 0 && !document.hidden) { + viewportOwner.write({ owner: 'follow-output', topPx: scroller.scrollHeight, holdForMs: 0 }); + } + }); + }; + const wheel = (event: WheelEvent) => { + cancelAim(); + if (event.deltaY < 0) pause(); + }; + const key = (event: KeyboardEvent) => { + if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) { + pause(); + } + }; + const observer = new ResizeObserver(follow); + observer.observe(content); observer.observe(scroller); + scroller.addEventListener('wheel', wheel, { passive: true }); + scroller.addEventListener('keydown', key); + document.addEventListener('visibilitychange', follow); + document.addEventListener('selectionchange', selectionChanged); + follow(); + return () => { + observer.disconnect(); cancelAnimationFrame(frame); + scroller.removeEventListener('wheel', wheel); scroller.removeEventListener('keydown', key); document.removeEventListener('visibilitychange', follow); + document.removeEventListener('selectionchange', selectionChanged); + }; + }, [scrollerRef, contentRef, followRef, viewportOwner, revision, cancelAim]); +} diff --git a/src/web-ui/src/flow_chat/timeline/useReaderScrollMemory.ts b/src/web-ui/src/flow_chat/timeline/useReaderScrollMemory.ts new file mode 100644 index 0000000000..acc1eb2ea9 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useReaderScrollMemory.ts @@ -0,0 +1,35 @@ +import { useLayoutEffect, type RefObject } from 'react'; +import { useFlowChatReaderStore } from './readerState'; + +/** Small inner-reader offsets survive card recycling. Never observes the transcript. */ +export function useReaderScrollMemory(rootRef: RefObject, itemId: string | null | undefined) { + const reader = useFlowChatReaderStore(); + useLayoutEffect(() => { + const root = rootRef.current; + if (!root || !reader || !itemId) return; + const restored = new WeakSet(); + const targets = () => [...root.querySelectorAll('[data-openbitfun-scrollbar-visibility], [data-testid="chat-thinking-content"]')]; + const keyFor = (element: HTMLElement, index: number) => `scroll:${itemId}:${element.dataset.openbitfunComponent}:${element.dataset.openbitfunPart}:${index}`; + const restore = () => targets().forEach((element, index) => { + if (restored.has(element)) return; + restored.add(element); + const key = keyFor(element, index); + if (reader.has(`${key}:y`)) element.scrollTop = reader.get(`${key}:y`, 0); + if (reader.has(`${key}:x`)) element.scrollLeft = reader.get(`${key}:x`, 0); + }); + const record = (event: Event) => { + const element = event.target; + if (!(element instanceof HTMLElement)) return; + const index = targets().indexOf(element); + if (index < 0) return; + const key = keyFor(element, index); + reader.set(`${key}:y`, element.scrollTop); + reader.set(`${key}:x`, element.scrollLeft); + }; + restore(); + root.addEventListener('scroll', record, true); + const observer = new MutationObserver(restore); + observer.observe(root, { childList: true, subtree: true }); + return () => { observer.disconnect(); root.removeEventListener('scroll', record, true); }; + }, [reader, rootRef, itemId]); +} diff --git a/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.test.tsx b/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.test.tsx new file mode 100644 index 0000000000..387eb905c4 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { FlowChatReaderState } from './readerState'; +import { useTimelineInteraction } from './useTimelineInteraction'; + +it('takes reading ownership before manual disclosure, leaving automatic state changes alone', () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host), reader = new FlowChatReaderState(), events: string[] = []; + const take = vi.fn(() => events.push('reader')); + function View() { + const scrollerRef = useRef(null); + useTimelineInteraction(scrollerRef, reader, () => true, undefined, take); + return
+ + +
; + } + try { + act(() => root.render()); + const button = host.querySelector('button')!; + button.setAttribute('aria-expanded', 'true'); expect(take).not.toHaveBeenCalled(); + act(() => button.querySelector('span')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(events).toEqual(['reader', 'toggle']); + act(() => button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))); + act(() => host.querySelector('input')!.click()); + expect(take).toHaveBeenCalledTimes(3); + act(() => host.querySelector('input')!.focus()); + expect(take).toHaveBeenCalledTimes(3); + act(() => host.querySelector('[data-outside]')!.click()); + expect(take).toHaveBeenCalledTimes(3); + act(() => host.querySelector('input')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }))); + expect(take).toHaveBeenCalledTimes(4); + act(() => host.querySelector('input')!.dispatchEvent(new Event('input', { bubbles: true }))); + expect(take).toHaveBeenCalledTimes(5); + } finally { act(() => root.unmount()); host.remove(); } +}); + +it('retains focused response controls without takeover, while disclosure and transcript selection still take over', () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host), reader = new FlowChatReaderState(), take = vi.fn(); + function View() { + const scrollerRef = useRef(null); + const pinned = useTimelineInteraction(scrollerRef, reader, () => true, undefined, take); + return
+
+

Question text

+ +
+
Custom answer
+ +
+
; + } + try { + act(() => root.render()); + const scroller = host.firstElementChild!; + Object.defineProperties(scroller, { clientHeight: { value: 800 }, clientWidth: { value: 600 } }); + const answer = host.querySelector('input')!; + act(() => answer.focus()); + expect(scroller.getAttribute('data-pinned')).toBe('question'); + act(() => { + answer.click(); + answer.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + answer.dispatchEvent(new Event('input', { bubbles: true })); + answer.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(take).not.toHaveBeenCalled(); + act(() => { + host.querySelector('[data-menu]')!.click(); + host.querySelector('[data-action]')!.click(); + }); + const select = (selector: string) => { + const selection = window.getSelection()!; + const range = document.createRange(); range.selectNodeContents(host.querySelector(selector)!); + selection.removeAllRanges(); selection.addRange(range); + document.dispatchEvent(new Event('selectionchange')); + }; + act(() => select('[data-whitespace]')); + act(() => select('[data-answer-editor]')); + expect(take).not.toHaveBeenCalled(); + act(() => host.querySelector('button')!.click()); + expect(take).toHaveBeenCalledTimes(1); + act(() => select('p')); + expect(take).toHaveBeenCalledTimes(2); + expect(take).toHaveBeenLastCalledWith('selection'); + } finally { + act(() => { window.getSelection()?.removeAllRanges(); root.unmount(); }); host.remove(); + } +}); diff --git a/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.ts b/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.ts new file mode 100644 index 0000000000..ff16595589 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.ts @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState, type RefObject } from 'react'; +import type { FlowChatReaderState } from './readerState'; + +const DISCLOSURE_SELECTOR = '[aria-expanded], summary, [data-openbitfun-expandable="true"]'; +const FIELD_SELECTOR = 'input, select, textarea, [contenteditable]:not([contenteditable="false"])'; +export type TimelineReaderInteraction = 'disclosure' | 'field' | 'selection'; + +/** Temporary interaction leases, separate from disclosure and execution state. */ +export function useTimelineInteraction( + scrollerRef: RefObject, reader: FlowChatReaderState, + isFollowing: () => boolean, + rowSelector = '.virtual-item-wrapper[data-virtual-item-key]', + onReaderInteraction?: (kind: TimelineReaderInteraction) => void, +) { + const [pinnedKeys, setPinnedKeys] = useState>(() => new Set()); + const followingRef = useRef(isFollowing); + followingRef.current = isFollowing; + const readerInteractionRef = useRef(onReaderInteraction); + readerInteractionRef.current = onReaderInteraction; + useEffect(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + const holds = new Map void>(); + let lastSelection: { anchor: Node | null; focus: Node | null; start: number; end: number } | null = null; + const activate = (event: Event) => { + if (event.defaultPrevented) return; + const target = event.target instanceof Element ? event.target : null; + // Stop at the actual control: an action inside an expandable card is + // not an activation of its ancestor's disclosure. + const control = target?.closest(`button, [role="button"], a[href], ${DISCLOSURE_SELECTOR}, ${FIELD_SELECTOR}`); + if (!control?.closest(rowSelector) || !scroller.contains(control)) return; + // Runtime responses continue the live conversation. They keep whichever + // intent the viewport already had; submitting must not reclaim a reader. + // Disclosure is still reading, even inside an active response card. + if (control.matches(FIELD_SELECTOR)) { + if (!control.closest('[data-flowchat-interaction="response"]')) readerInteractionRef.current?.('field'); + } else if (control.matches(DISCLOSURE_SELECTOR) + && (!control.hasAttribute('aria-haspopup') || control.getAttribute('aria-haspopup') === 'false')) { + readerInteractionRef.current?.('disclosure'); + } + }; + const keyActivation = (event: KeyboardEvent) => { + if (event.isComposing) return; + const field = event.target instanceof Element && event.target.closest(FIELD_SELECTOR); + if (event.key === 'Enter' || event.key === ' ' + || (field && !['Tab', 'Shift', 'Control', 'Alt', 'Meta', 'Escape'].includes(event.key))) activate(event); + }; + const update = () => { + if (!scroller.clientHeight || !scroller.clientWidth) return; + const pinned = new Set(); + const rowFor = (node: Node | null) => { + const element = node instanceof Element ? node : node?.parentElement; + const row = element?.closest(rowSelector); + if (row && scroller.contains(row)) pinned.add(row.dataset.virtualItemKey!); + }; + rowFor(document.activeElement); + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) { + rowFor(selection.anchorNode); rowFor(selection.focusNode); + const range = selection.rangeCount ? selection.getRangeAt(0) : undefined; + // Keep the selected interior as well as its endpoints. Retained rows + // stay in natural DOM order, so native multi-card copy remains exact. + if (range) scroller.querySelectorAll(rowSelector).forEach(row => { + if (range.intersectsNode(row)) rowFor(row); + }); + } + // Embedded applications keep their live instance after actual interaction. + // Mere visibility does not acquire an instance lease. + scroller.querySelectorAll('[data-flowchat-retain-instance="true"]').forEach(rowFor); + setPinnedKeys(previous => previous.size === pinned.size && [...pinned].every(key => previous.has(key)) ? previous : pinned); + const groups = new Set(); + const bounds = scroller.getBoundingClientRect(); + scroller.querySelectorAll('.virtual-item-wrapper[data-timeline-kind="group-members"]').forEach(row => { + const rect = row.getBoundingClientRect(); + if (pinned.has(row.dataset.virtualItemKey!) || (!followingRef.current() && rect.bottom > bounds.top && rect.top < bounds.bottom)) { + groups.add(row.dataset.timelineGroupId!); + } + }); + for (const [group, release] of holds) if (!groups.has(group)) { release(); holds.delete(group); } + for (const group of groups) if (!holds.has(group)) holds.set(group, reader.holdGroup(group, 'reader')); + }; + const selectionChanged = () => { + const selection = window.getSelection(); + const anchor = selection?.anchorNode; + const anchorElement = anchor instanceof Element ? anchor : anchor?.parentElement; + if (selection && !selection.isCollapsed && selection.toString().trim() + && anchor && scroller.contains(anchor) && !anchorElement?.closest(FIELD_SELECTOR)) { + if (!lastSelection || lastSelection.anchor !== anchor || lastSelection.focus !== selection.focusNode + || lastSelection.start !== selection.anchorOffset || lastSelection.end !== selection.focusOffset) { + readerInteractionRef.current?.('selection'); + } + lastSelection = { anchor, focus: selection.focusNode, start: selection.anchorOffset, end: selection.focusOffset }; + } else lastSelection = null; + update(); + }; + let frame = 0; + const schedule = () => { + cancelAnimationFrame(frame); + if (scroller.clientHeight && scroller.clientWidth) frame = requestAnimationFrame(update); + }; + scroller.addEventListener('scroll', schedule, { passive: true }); + // Capture before React changes geometry. Programmatic completion/reveal has + // no activation event and therefore keeps its existing follow ownership. + scroller.addEventListener('click', activate, true); + scroller.addEventListener('keydown', keyActivation, true); + scroller.addEventListener('input', activate, true); + scroller.addEventListener('change', activate, true); + // Autofocus, restoration and keyboard focus all need residency, but focus + // alone proves no request to stop following. Activation/editing does. + scroller.addEventListener('focusin', update); + scroller.addEventListener('focusout', schedule); + scroller.addEventListener('pointerup', update); + document.addEventListener('selectionchange', selectionChanged); + window.addEventListener('resize', schedule); + const mutations = new MutationObserver(schedule); + mutations.observe(scroller, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-flowchat-retain-instance'] }); + update(); + return () => { + cancelAnimationFrame(frame); window.removeEventListener('resize', schedule); mutations.disconnect(); + scroller.removeEventListener('scroll', schedule); scroller.removeEventListener('focusin', update); + scroller.removeEventListener('click', activate, true); scroller.removeEventListener('keydown', keyActivation, true); + scroller.removeEventListener('input', activate, true); scroller.removeEventListener('change', activate, true); + scroller.removeEventListener('focusout', schedule); scroller.removeEventListener('pointerup', update); + document.removeEventListener('selectionchange', selectionChanged); + holds.forEach(release => release()); + }; + }, [reader, scrollerRef, rowSelector]); + return pinnedKeys; +} diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx index 08ba171b71..955ccb26de 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx @@ -11,6 +11,8 @@ import { } from '@/infrastructure/peer-device/deviceSurface'; import { PeerDeviceContext } from '@/infrastructure/peer-device/peerDeviceContextState'; import { askUserQuestionDraftStore } from '../store/askUserQuestionDraftStore'; +import { FlowChatReaderState } from '../timeline/readerState'; +import { useTimelineInteraction } from '../timeline/useTimelineInteraction'; const sendFollowUp = vi.hoisted(() => vi.fn()); vi.mock('../services/FlowChatManager', () => ({ @@ -129,9 +131,16 @@ describe('AskUserQuestionCard', () => { { question: 'Anything else?', options: [{ label: 'Nothing else' }], multiSelect: false }, ], }; - const renderCard = () => root.render( - , - ); + const takeReading = vi.fn(); + const reader = new FlowChatReaderState(); + function Transcript() { + const ref = React.useRef(null); + useTimelineInteraction(ref, reader, () => true, undefined, takeReading); + return
+ +
; + } + const renderCard = () => root.render(); const next = () => container.querySelector('[data-openbitfun-part="next"] button')!; const back = () => container.querySelector('[data-openbitfun-part="back"] button')!; const choose = async (value: string) => { @@ -193,6 +202,9 @@ describe('AskUserQuestionCard', () => { ]; expect(answeredContent()).toEqual(expectedContent); expect(container.querySelector('input, button, progress, svg')).toBeNull(); + // Answering, question navigation and custom-answer autofocus continue the + // live conversation; they must not turn its viewport into a history reader. + expect(takeReading).not.toHaveBeenCalled(); act(() => root.render(null)); act(renderCard); expect(answeredContent()).toEqual(expectedContent); @@ -625,6 +637,66 @@ describe('AskUserQuestionCard', () => { expect(toolAPI.submitUserAnswers).not.toHaveBeenCalled(); }); + it.each(['cancelled', 'completed'] as const)('lets a cancelled %s call reopen every received question without allowing answers', (status) => { + const item = questionTool('running'); + item.toolCall.input.questions.push({ + header: 'Region', + question: 'Which region?', + multiSelect: true, + options: [{ label: 'Asia', description: 'Use the Asia region' }], + }); + act(() => root.render()); + const cancelledItem = { + ...item, + status, + isParamsStreaming: true, + partialParams: item.toolCall.input, + ...(status === 'completed' ? { + toolResult: { success: true, result: JSON.stringify({ status: 'cancelled' }) }, + } : {}), + }; + const renderCancelledCard = () => root.render( + , + ); + const expand = () => container.querySelector('[data-openbitfun-part="disclosure"]')!.click(); + + act(renderCancelledCard); + expect(container.querySelector('input')).toBeNull(); + expect(container.textContent).toContain('toolCards.default.cancelled'); + act(expand); + expect(Array.from(container.querySelectorAll('legend'), (legend) => legend.textContent)) + .toEqual(['Which database?', 'Which region?']); + expect(container.textContent).toContain('Use PostgreSQL'); + expect(container.textContent).toContain('Use the Asia region'); + expect(container.textContent).toContain('toolCards.default.cancelled'); + expect(container.querySelector('[data-openbitfun-part="submit"]')).toBeNull(); + expect(container.querySelector('[data-openbitfun-part="next"]')).toBeNull(); + container.querySelectorAll('input').forEach((input) => { + expect(input.disabled).toBe(true); + act(() => input.click()); + expect(input.checked).toBe(false); + }); + expect(toolAPI.startUserQuestionInteraction).not.toHaveBeenCalled(); + expect(toolAPI.submitUserAnswers).not.toHaveBeenCalled(); + expect(sendFollowUp).not.toHaveBeenCalled(); + expect(askUserQuestionDraftStore.getState().drafts).toEqual({}); + + act(() => container.querySelector('[data-openbitfun-part="collapse"]')!.click()); + expect(container.querySelector('input')).toBeNull(); + act(() => root.render(null)); + act(renderCancelledCard); + act(expand); + expect(container.querySelectorAll('fieldset')).toHaveLength(2); + }); + + it('keeps an empty cancelled call as a notice without a disclosure', () => { + const item = questionTool('cancelled'); + item.toolCall.input = {}; + act(() => root.render()); + expect(container.textContent).toContain('toolCards.default.cancelled'); + expect(container.querySelector('button, input')).toBeNull(); + }); + it('shows only the live question form beside an obsolete retry', async () => { const old = questionTool('cancelled'); old.interruptionReason = 'retry_superseded'; diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx index 354d365023..3b53abe5fb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx @@ -607,6 +607,7 @@ export const AskUserQuestionCard: React.FC = ({ : t('toolCards.askUser.waitingAnswer'); if (endedWithoutAnswer && !canFollowUp) { + const canReviewQuestions = cancelled && designQuestions.length > 0; const terminalLabel = timedOut ? t('toolCards.askUser.timeout') : cancelled @@ -617,10 +618,16 @@ export const AskUserQuestionCard: React.FC = ({ return ( ); } @@ -658,6 +665,7 @@ export const AskUserQuestionCard: React.FC = ({ answers={presentation.answers} aria-label={t('toolCards.askUser.questionsCount', { count: questions.length })} customAnswers={presentation.customAnswers} + data-flowchat-interaction={canAnswer ? 'response' : undefined} data-tool-card-id={toolId ?? ''} disabled={!canEditAnswers} defaultExpanded={!canFollowUp} diff --git a/src/web-ui/src/flow_chat/tool-cards/BuiltinToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/BuiltinToolCard.tsx index 8acfc0fa0d..0163787aa4 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BuiltinToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/BuiltinToolCard.tsx @@ -1,3 +1,4 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; import React, { Suspense, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { GoalToolCard, AgentRosterToolCard, SessionHistoryToolCard, ImageAnalysisToolCard, TimeToolCard, @@ -43,7 +44,7 @@ const views: Record = ({ toolItem, sessionId, onExpand, onOpenInEditor }) => { const { t, formatNumber } = useI18n('flow-chat'); - const [isExpanded, setExpanded] = useState(false); + const [isExpanded, setExpanded] = useToolCardDisclosure('isExpanded'); const [market, setMarket] = useState<'miniapps' | 'appearance'>(); const [marketOpen, setMarketOpen] = useState(false); const name = toolItem.toolName; diff --git a/src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.tsx index 90d40e4945..f430ea4e1f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CanvasToolCard.tsx @@ -1,3 +1,4 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; import React, { useCallback, useMemo, useState } from 'react'; import { OverflowText, Icon, IconButton } from '@openbitfun/ui'; import { AlertTriangle } from 'lucide-react'; @@ -81,7 +82,7 @@ const TERMINAL_STATUSES = new Set(['completed', 'error', 'cancelled', 'rejected' export const CanvasToolCard: React.FC = ({ toolItem, sessionId, onExpand }) => { const { t, formatNumber } = useI18n('flow-chat'); const { toolCall, toolResult, partialParams, isParamsStreaming } = toolItem; - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: toolItem.id ?? toolCall?.id, toolName: toolItem.toolName, diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx index 2725b4b32d..46ed5dbfbf 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx @@ -1,3 +1,4 @@ +import { useToolCardDisclosure, useToolCardValue } from '../timeline/readerState'; /** * CodeReview tool display component * Displays structured code review results with collapsible/expandable details @@ -236,9 +237,17 @@ export const CodeReviewToolCard: React.FC = React.memo(({ const { t } = useTranslation('flow-chat'); const { toolResult } = toolItem; const status = getToolCardStatus(toolItem); - const [isExpanded, setIsExpanded] = useState(false); - const [expandedRemediationIds, setExpandedRemediationIds] = useState>(new Set()); - const [reportSectionChoices, setReportSectionChoices] = useState>>({}); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); + const [remediationChoices, setRemediationChoices] = useToolCardValue('remediations', '[]'); + const [sectionChoices, setSectionChoices] = useToolCardValue('reportSections', '{}'); + const expandedRemediationIds = useMemo(() => new Set(JSON.parse(remediationChoices)), [remediationChoices]); + const reportSectionChoices = useMemo(() => JSON.parse(sectionChoices) as Partial>, [sectionChoices]); + const setExpandedRemediationIds = useCallback((update: (previous: Set) => Set) => { + setRemediationChoices(previous => JSON.stringify([...update(new Set(JSON.parse(previous)))])); + }, [setRemediationChoices]); + const setReportSectionChoices = useCallback((update: (previous: Partial>) => Partial>) => { + setSectionChoices(previous => JSON.stringify(update(JSON.parse(previous)))); + }, [setSectionChoices]); const toolId = toolItem.id ?? toolItem.toolCall?.id; const { cardRootRef, applyExpandedState, dispatchToolCardToggle } = useToolCardHeightContract({ toolId, @@ -366,7 +375,7 @@ export const CodeReviewToolCard: React.FC = React.memo(({ const toggleExpanded = useCallback(() => { applyExpandedState(isExpanded, !isExpanded, setIsExpanded); - }, [applyExpandedState, isExpanded]); + }, [applyExpandedState, isExpanded, setIsExpanded]); const handleCardClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -389,12 +398,12 @@ export const CodeReviewToolCard: React.FC = React.memo(({ } return next; }); - }, []); + }, [setExpandedRemediationIds]); const handleToggleReportSection = useCallback((sectionId: ReviewSectionId) => () => { dispatchToolCardToggle(); setReportSectionChoices(current => ({ ...current, [sectionId]: !expandedReportSectionIds.has(sectionId) })); - }, [dispatchToolCardToggle, expandedReportSectionIds]); + }, [dispatchToolCardToggle, expandedReportSectionIds, setReportSectionChoices]); // Listen for scroll-to events from the review action bar useEffect(() => { @@ -435,7 +444,7 @@ export const CodeReviewToolCard: React.FC = React.memo(({ return () => { globalEventBus.off(DEEP_REVIEW_SCROLL_TO_EVENT, handler); }; - }, [isExpanded]); + }, [isExpanded, setIsExpanded, setReportSectionChoices]); const renderContent = () => { if (status === 'cancelled') return t('toolCards.default.cancelled'); diff --git a/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx index d655539aa4..eff049f65c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import { useToolCardDisclosure } from '../timeline/readerState'; +import React, { useMemo } from 'react'; import { Button, Icon } from '@openbitfun/ui'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Camera, Clipboard, Keyboard, MousePointerClick } from 'lucide-react'; @@ -120,7 +121,7 @@ function actionIcon(action: string): React.ReactNode { export const ComputerUseToolCard: React.FC = ({ toolItem, onExpand }) => { const { t } = useTranslation('flow-chat'); const status = getToolCardStatus(toolItem); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolItem.toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, diff --git a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx index 53192d5a9a..1e126ef454 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx @@ -1,8 +1,9 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * Context compression display for Flow Chat. */ -import React, { useState } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import type { FlowToolItem } from '../types/flow-chat'; import { ContextCompressionToolCard } from '@openbitfun/ui/flow-chat'; @@ -36,7 +37,7 @@ export const ContextCompressionDisplay: React.FC compressionData }) => { const { t } = useTranslation('flow-chat'); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: toolItem?.id, toolName: 'ContextCompression', diff --git a/src/web-ui/src/flow_chat/tool-cards/ControlHubToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ControlHubToolCard.tsx index 0f240c227e..e2550820e2 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ControlHubToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ControlHubToolCard.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import { useToolCardDisclosure } from '../timeline/readerState'; +import React, { useMemo } from 'react'; import { ControlHubToolCard as ControlHubToolCardView, type ToolCardField, type ControlHubToolCardRecord } from '@openbitfun/ui/flow-chat'; import { useI18n } from '@/infrastructure/i18n'; import type { ToolCardProps } from '../types/flow-chat'; @@ -8,7 +9,7 @@ import { buildControlHubCardModel, formatRuntimeToolValue, runtimeToolNeedsConfi export const ControlHubToolCard: React.FC = ({ toolItem, onExpand }) => { const { t, formatNumber } = useI18n('flow-chat'); - const [isExpanded, setExpanded] = useState(false); + const [isExpanded, setExpanded] = useToolCardDisclosure('isExpanded'); const model = useMemo(() => buildControlHubCardModel(toolItem), [toolItem]); const { cardRootRef, applyExpandedState, dispatchToolCardToggle } = useToolCardHeightContract({ toolId: toolItem.id, toolName: 'ControlHub' }); const actions: Record = { diff --git a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx index 91664e48c6..e762bcdcbb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx @@ -1,3 +1,4 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * Plan display components. * @@ -114,7 +115,7 @@ export const PlanDisplay: React.FC = ({ const [hasSavedToProject, setHasSavedToProject] = useState(false); const [loadError, setLoadError] = useState(null); - const [isTodosExpanded, setIsTodosExpanded] = useState(false); + const [isTodosExpanded, setIsTodosExpanded] = useToolCardDisclosure('isTodosExpanded'); const toolCardId = cacheKey ?? planFilePath; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: toolCardId, @@ -413,7 +414,7 @@ Read the plan file before making changes and treat it as the source of truth. Do const handleToggleTodos = useCallback(() => { applyExpandedState(isTodosExpanded, !isTodosExpanded, setIsTodosExpanded); - }, [applyExpandedState, isTodosExpanded]); + }, [applyExpandedState, isTodosExpanded, setIsTodosExpanded]); const isLoading = status === 'preparing' || status === 'receiving' || status === 'streaming' || status === 'running'; const revealPlanTooltip = isRevealPlanDisabled diff --git a/src/web-ui/src/flow_chat/tool-cards/CronToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CronToolCard.tsx index 810ebb96eb..1f6fd1fed1 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CronToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CronToolCard.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import { useToolCardDisclosure } from '../timeline/readerState'; +import React, { useMemo } from 'react'; import { CronToolCard as CronToolCardView } from '@openbitfun/ui/flow-chat'; import { useI18n } from '@/infrastructure/i18n'; import type { CronSchedule } from '@/infrastructure/api'; @@ -114,7 +115,7 @@ export const CronToolCard: React.FC = React.memo(({ toolItem }) = const { t, formatDate } = useI18n('flow-chat'); const { t: tTodos } = useI18n('scenes/todos'); const { toolCall, toolResult } = toolItem; - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx index 5a72cd5af4..b8e6009775 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx @@ -1,9 +1,10 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * Default tool card component * Used for tool types without specific customization */ -import React, { useMemo, useState, useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '@openbitfun/ui'; import { isShellToolName } from '../grouping/activityClassification'; @@ -94,7 +95,7 @@ export const DefaultToolCard: React.FC = ({ const { t } = useTranslation('flow-chat'); const { toolCall, toolResult, requiresConfirmation, userConfirmed } = toolItem; const status = getToolCardStatus(toolItem); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState, dispatchToolCardToggle } = useToolCardHeightContract({ toolId, @@ -130,7 +131,7 @@ export const DefaultToolCard: React.FC = ({ applyExpandedState(isExpanded, nextExpanded, setIsExpanded, { onExpand, }); - }, [applyExpandedState, canExpand, isExpanded, onExpand]); + }, [applyExpandedState, canExpand, isExpanded, onExpand, setIsExpanded]); const resultSummary = hasResult && status === 'completed' ? getInlinePreview(toolResult?.result) : undefined; diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx index 0147bb618f..cc8b2ef7d9 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import { useToolCardDisclosure } from '../timeline/readerState'; import { useTranslation } from 'react-i18next'; import { ExecProcessPresentation, type ExecProcessCardModel } from '@openbitfun/flow-chat-presentation/exec'; import type { FlowToolItem, ToolCardDisplayContext } from '../types/flow-chat'; @@ -27,6 +28,7 @@ export const ExecProcessToolCardView: React.FC = ( displayContext, }) => { const { t } = useTranslation('flow-chat'); + const [expanded, setExpanded] = useToolCardDisclosure('output'); const { activeSessionOverride } = useFlowChatContext(); const isSubagentSurface = displayContext === 'subagent-projection' || activeSessionOverride?.sessionKind === 'subagent'; @@ -35,9 +37,10 @@ export const ExecProcessToolCardView: React.FC = ( toolName: toolItem.toolName, }); const onExpandedChange = useCallback((expanded: boolean) => { + setExpanded(expanded); dispatchToolCardToggle(); if (expanded) onExpand?.(); - }, [dispatchToolCardToggle, onExpand]); + }, [dispatchToolCardToggle, onExpand, setExpanded]); const { copied, copy } = useCopyTextAction({ getText: () => model.copyText, successMessage: t('toolCards.execProcess.primaryCopied'), @@ -47,6 +50,7 @@ export const ExecProcessToolCardView: React.FC = ( return ( = ({ const isFailed = status === 'error'; // Streaming stays in the summary until the reader asks for the live preview. - const [isContentExpanded, setIsContentExpanded] = useState(false); + const [isContentExpanded, setIsContentExpanded] = useToolCardDisclosure('isContentExpanded'); const hasInitializedCompletionEffectRef = useRef(false); const previousCompletionEndTimeRef = useRef(toolItem.endTime ?? null); @@ -325,7 +326,7 @@ const GenericFileOperationToolCard: React.FC = ({ nextExpanded, setIsContentExpanded, ); - }, [applyHeightContractExpandedState, isContentExpanded]); + }, [applyHeightContractExpandedState, isContentExpanded, setIsContentExpanded]); useEffect(() => { if (error) { diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx index 5a10e321c3..307992106a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useToolCardDisclosure } from '../timeline/readerState'; import { subscribeOverlayInteraction, Tooltip, Icon, IconButton } from '@openbitfun/ui'; import type { ToolCardProps } from '../types/flow-chat'; import { ProminentToolCard, ProminentToolCardSummary, ToolProcessingDots } from '@openbitfun/ui/flow-chat'; @@ -104,7 +105,19 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se status === 'preparing' || status === 'streaming' || status === 'running' || status === 'pending'; const isFailed = status === 'error' || (status === 'completed' && toolResult?.success === false); // Keep a live preview open if the tool fails; historical errors start compact. - const [isCardExpanded, setIsCardExpanded] = useState(!isFailed); + const [isCardExpanded, setIsCardExpanded] = useToolCardDisclosure('widget', !isFailed); + const [retainInstance, setRetainInstance] = useState(false); + useEffect(() => { + // Cross-origin iframe pointer events do not bubble. Its focus is observable + // on the host, which can acquire the same lease as an ordinary input. + const retainFocusedFrame = () => { + if (document.activeElement?.tagName === 'IFRAME' && cardRootRef.current?.contains(document.activeElement)) { + setRetainInstance(true); + } + }; + window.addEventListener('blur', retainFocusedFrame); + return () => window.removeEventListener('blur', retainFocusedFrame); + }, [cardRootRef]); const widgetId = resultData?.widget_id || toolCall?.id || toolItem.id; const isClickable = status === 'completed' && !isFailed && widgetCode.trim().length > 0; const hasRenderableWidget = widgetCode.trim().length > 0 && !isFailed; @@ -150,7 +163,7 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se } handleOpenPanel(); }, - [applyExpandedState, handleOpenPanel, isCardExpanded, isFailed], + [applyExpandedState, handleOpenPanel, isCardExpanded, isFailed, setIsCardExpanded], ); const handleWidgetEvent = useCallback((event: WidgetMessage) => { @@ -307,7 +320,9 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se return ( <> -
+
setRetainInstance(true)} onFocusCapture={() => setRetainInstance(true)} + data-tool-card-id={toolItem.id ?? toolCall?.id ?? ''} data-openbitfun-component="generative-widget-tool-card" data-openbitfun-part="root" data-openbitfun-state={isFailed ? 'failed' : undefined}> = React.memo(({ }) => { const { t } = useTranslation('flow-chat'); const { toolCall, toolResult, status } = toolItem; - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, @@ -92,7 +93,7 @@ export const GetFileDiffDisplay: React.FC = React.memo(({ const toggleExpanded = useCallback(() => { applyExpandedState(isExpanded, !isExpanded, setIsExpanded); - }, [applyExpandedState, isExpanded]); + }, [applyExpandedState, isExpanded, setIsExpanded]); const handleCardClick = useCallback(() => { if (status === 'error' || (hasDiffContent && status === 'completed')) { diff --git a/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx index 8d331c7f73..6d5894b1ff 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx @@ -1,8 +1,9 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * Tool card for GlobSearch file matching. */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { getToolCardStatus, getToolCardStatusDescription } from './toolCardStatus'; import type { ToolCardProps } from '../types/flow-chat'; @@ -17,7 +18,7 @@ export const GlobSearchDisplay: React.FC = ({ const { t } = useTranslation('flow-chat'); const { toolCall, toolResult } = toolItem; const status = getToolCardStatus(toolItem); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, @@ -98,7 +99,7 @@ export const GlobSearchDisplay: React.FC = ({ onExpand, }); } - }, [applyExpandedState, hasDetails, isExpanded, onExpand]); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, setIsExpanded]); if (!isToolCardVisible(toolItem)) { return null; diff --git a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx index 16ecb3346b..153d8ad28f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx @@ -1,8 +1,9 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * Tool card for GrepSearch text queries. */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { getToolCardStatus, getToolCardStatusDescription } from './toolCardStatus'; import type { ToolCardProps } from '../types/flow-chat'; @@ -17,7 +18,7 @@ export const GrepSearchDisplay: React.FC = ({ const { t } = useTranslation('flow-chat'); const { toolCall, toolResult } = toolItem; const status = getToolCardStatus(toolItem); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, @@ -83,7 +84,7 @@ export const GrepSearchDisplay: React.FC = ({ onExpand, }); } - }, [applyExpandedState, hasDetails, isExpanded, onExpand]); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, setIsExpanded]); if (!isToolCardVisible(toolItem)) { return null; diff --git a/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx index ce4de674df..35cb2331be 100644 --- a/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx @@ -1,8 +1,9 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * Display component for the LS tool. */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { getToolCardStatus, getToolCardStatusDescription } from './toolCardStatus'; import type { ToolCardProps } from '../types/flow-chat'; @@ -23,7 +24,7 @@ export const LSDisplay: React.FC = ({ const { t } = useTranslation('flow-chat'); const { toolCall, toolResult } = toolItem; const status = getToolCardStatus(toolItem); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, @@ -90,7 +91,7 @@ export const LSDisplay: React.FC = ({ onExpand, }); } - }, [applyExpandedState, hasDetails, isExpanded, onExpand]); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, setIsExpanded]); if (!isToolCardVisible(toolItem)) { return null; diff --git a/src/web-ui/src/flow_chat/tool-cards/ListModelsToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ListModelsToolCard.tsx index 5c98b6bff9..abab1e2fd6 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ListModelsToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ListModelsToolCard.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import { useToolCardDisclosure } from '../timeline/readerState'; +import React, { useMemo } from 'react'; import { ListModelsToolCard as ListModelsToolCardView } from '@openbitfun/ui/flow-chat'; import { useI18n } from '@/infrastructure/i18n'; import type { ToolCardProps } from '../types/flow-chat'; @@ -8,7 +9,7 @@ import { buildListModelsCardModel, formatRuntimeToolValue, runtimeToolNeedsConfi export const ListModelsToolCard: React.FC = ({ toolItem, onExpand }) => { const { t, formatNumber } = useI18n('flow-chat'); - const [isExpanded, setExpanded] = useState(false); + const [isExpanded, setExpanded] = useToolCardDisclosure('isExpanded'); const model = useMemo(() => buildListModelsCardModel(toolItem), [toolItem]); const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: toolItem.id, toolName: 'ListModels' }); const needsConfirmation = runtimeToolNeedsConfirmation(toolItem, model.status); diff --git a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx index 1b450f2775..bc67b4497a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx @@ -1,3 +1,4 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * Display component for MCP tools. * Supports MCP Apps: when tool result contains ui:// resource, renders interactive UI in sandboxed iframe. @@ -203,8 +204,8 @@ export const MCPToolDisplay: React.FC = ({ userConfirmed, isParamsStreaming, } = toolItem; - const [isExpanded, setIsExpanded] = useState(false); - const [isInputExpanded, setIsInputExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); + const [isInputExpanded, setIsInputExpanded] = useToolCardDisclosure('isInputExpanded'); // Tool-result images are inline content of this card, so the card owns their overlay. const [imagePreview, setImagePreview] = useState(null); const toolId = toolItem.id ?? toolCall?.id; @@ -662,12 +663,12 @@ export const MCPToolDisplay: React.FC = ({ setIsInputExpanded(false); } applyExpandedState(isExpanded, nextExpanded, setIsExpanded); - }, [applyExpandedState, isExpanded]); + }, [applyExpandedState, isExpanded, setIsExpanded, setIsInputExpanded]); const handleInputOpenChange = useCallback((open: boolean) => { setIsInputExpanded(open); dispatchToolCardToggle(); - }, [dispatchToolCardToggle]); + }, [dispatchToolCardToggle, setIsInputExpanded]); const getErrorMessage = () => { if (toolResult && 'error' in toolResult) { diff --git a/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx index b84c887e05..b0214661aa 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx @@ -1,7 +1,8 @@ +import { useToolCardDisclosure } from '../timeline/readerState'; /** * MiniAppToolDisplay — InitMiniApp result on the prominent FlowChat framework. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { OverflowText, IconButton, Icon } from '@openbitfun/ui'; import { useTranslation } from 'react-i18next'; @@ -15,7 +16,7 @@ export const InitMiniAppDisplay: React.FC = ({ toolItem }) => { const { t } = useTranslation('flow-chat'); const { status, toolResult, partialParams, isParamsStreaming, toolCall } = toolItem; const { openScene } = useSceneManager(); - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ @@ -48,7 +49,7 @@ export const InitMiniAppDisplay: React.FC = ({ toolItem }) => { const toggleExpanded = useCallback(() => { applyExpandedState(isExpanded, !isExpanded, setIsExpanded); - }, [applyExpandedState, isExpanded]); + }, [applyExpandedState, isExpanded, setIsExpanded]); const handleCardClick = useCallback( (e: React.MouseEvent) => { diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx index fdb2821b52..5382304087 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx @@ -10,6 +10,7 @@ import { ModelThinkingDisplay } from './ModelThinkingDisplay'; import { latestReasoningSummaryPreview } from '../utils/reasoningSummaryPresentation'; import { openThinkingPanel } from '../services/openThinkingPanel'; import { FlowChatContext } from '../components/modern/FlowChatContext'; +import { FlowChatReaderProvider, FlowChatReaderState } from '../timeline/readerState'; import { activateSurface, getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; vi.mock('../utils/reasoningSummaryPresentation', { spy: true }); @@ -99,6 +100,21 @@ describe('ModelThinkingDisplay reasoning summary', () => { vi.unstubAllGlobals(); }); + it('temporarily reveals a completed search source without opening the details panel', async () => { + const reader = new FlowChatReaderState(); + const item = { ...summaryItem('Find this recorded thought'), isStreaming: false, status: 'completed' as const }; + await act(async () => root.render( + + )); + expect(container.querySelector('[data-testid="chat-thinking-panel"]')?.getAttribute('data-expanded')).toBe('false'); + act(() => reader.set('navigation:thinking', item.id)); + expect(container.querySelector('[data-testid="chat-thinking-panel"]')?.getAttribute('data-expanded')).toBe('true'); + expect(container.querySelector('[data-testid="thinking-markdown"]')?.textContent).toBe(item.content); + expect(openThinkingPanel).not.toHaveBeenCalled(); + act(() => reader.set('navigation:thinking', '')); + expect(container.querySelector('[data-testid="chat-thinking-panel"]')?.getAttribute('data-expanded')).toBe('false'); + }); + it.each(['reasoning', undefined] as const)( 'skips summary processing for streaming reasoning (kind=%s)', async reasoningKind => { const item = { ...summaryItem('**Reasoning**\n\n'.repeat(7000)), reasoningKind }; @@ -459,12 +475,12 @@ describe('ModelThinkingDisplay side completion', () => {
{showPeer &&
-
+
-
+
}
; @@ -637,6 +653,7 @@ describe('ModelThinkingDisplay side completion', () => { let cardHeight = 44; vi.spyOn(successor, 'getBoundingClientRect').mockImplementation(() => ({ top: 100, height: cardHeight } as DOMRect)); vi.spyOn(header, 'getBoundingClientRect').mockImplementation(() => ({ top: 108, height: headerHeight } as DOMRect)); + act(() => successor.dispatchEvent(new MouseEvent('mouseenter'))); act(() => resizeCallbacks.get(header)!()); expect(panel.style.getPropertyValue('--_thinking-continuation-center')).toBe('26px'); @@ -684,6 +701,7 @@ describe('ModelThinkingDisplay side completion', () => { expect(container.querySelector(panelSelector)).toBe(panel); expect(container.querySelector(toggleSelector)).toBe(toggle); expect(panel.nextElementSibling).toBe(successor); + act(() => successor.dispatchEvent(new MouseEvent('mouseenter'))); expect(panel.style.getPropertyValue('--_thinking-continuation-center')).toBe('26px'); expect(resizeCallbacks.has(oldSuccessor)).toBe(false); expect(resizeCallbacks.has(header)).toBe(true); @@ -713,6 +731,7 @@ describe('ModelThinkingDisplay side completion', () => { const successor = container.querySelector('[data-thinking-continuation]')!; let blockHeight = 60; vi.spyOn(successor, 'getBoundingClientRect').mockImplementation(() => ({ top: 100, height: blockHeight } as DOMRect)); + act(() => successor.dispatchEvent(new MouseEvent('mouseenter'))); act(() => resizeCallbacks.get(successor)!()); expect(panel.style.getPropertyValue('--_thinking-continuation-center')).toBe(`${top - 100 + height / 2}px`); @@ -740,6 +759,7 @@ describe('ModelThinkingDisplay side completion', () => { const paragraph = successor.querySelector('p')!; const toggle = container.querySelector(toggleSelector); vi.spyOn(successor, 'getBoundingClientRect').mockReturnValue({ top: 100, height: 300 } as DOMRect); + act(() => successor.dispatchEvent(new MouseEvent('mouseenter'))); act(() => resizeCallbacks.get(successor)!()); expect(panel.style.getPropertyValue('--_thinking-continuation-center')).toBe('14px'); diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index 5dab4a986f..107fea879f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -6,6 +6,7 @@ */ import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useFlowChatReaderValue } from '../timeline/readerState'; import { ThinkingBlock } from '@openbitfun/ui/flow-chat'; import { useTranslation } from 'react-i18next'; import type { FlowThinkingItem } from '../types/flow-chat'; @@ -57,7 +58,9 @@ function resolveNextRoundContinuation(root: HTMLDivElement) { const sameTurnRound = Boolean(peer?.matches('.virtual-item-wrapper[data-item-type="model-round"]') && peer?.dataset.turnId === row.dataset.turnId); - const peerStack = sameTurnRound ? peer?.firstElementChild : null; + // The desktop timeline adds a block wrapper around the source stack. Keep + // legacy rounds and flattened leaves on the same continuation contract. + const peerStack = sameTurnRound ? peer?.querySelector('[data-flow-item-stack]') : null; const first = peerStack?.hasAttribute('data-flow-item-stack') ? peerStack.firstElementChild : null; const previous = root.previousElementSibling; const previousCard = previous instanceof HTMLElement && previous.hasAttribute('data-thinking-continuation') @@ -74,7 +77,7 @@ export const ModelThinkingDisplay: React.FC = ({ thinkingItem, isLastItem = true, forceExpanded = false, - revealStreamingContent = false, + revealStreamingContent: revealFromGroup = false, displayContext = 'default', retainForGroupCollapse = false, hidden, @@ -82,6 +85,10 @@ export const ModelThinkingDisplay: React.FC = ({ }) => { const { t } = useTranslation('flow-chat'); const { sessionId, workspaceId, workspacePath, remoteConnectionId } = useFlowChatContext(); + // Search temporarily reveals the recorded source in place. Activating the + // ordinary completed-thinking control opens its reader panel instead. + const [navigationThinking] = useFlowChatReaderValue('navigation:thinking', ''); + const revealStreamingContent = revealFromGroup || navigationThinking === thinkingItem.id; const { content, isStreaming, status } = thinkingItem; const isSummary = thinkingItem.reasoningKind === 'summary'; const isActive = isStreaming || status === 'streaming'; @@ -96,11 +103,11 @@ export const ModelThinkingDisplay: React.FC = ({ mounted: (isActive && !isSummary) || forceExpanded || revealStreamingContent, })); const retainClosingContent = retainedContent.identity === streamIdentity && retainedContent.mounted; - const [streamingChoice, setStreamingChoice] = useState<{ identity: string; expanded: boolean } | null>(null); + const [streamingChoice, setStreamingChoice] = useFlowChatReaderValue(`thinking:${streamIdentity}:live`, 'auto'); const [successor, setSuccessor] = useState<{ identity: string; ready: boolean; immediate: boolean; pending: boolean }>(); const coordinateContinuation = !revealStreamingContent && (!forceExpanded || isActive); const successorReady = coordinateContinuation && successor?.identity === streamIdentity && successor.ready; - const readerChoice = streamingChoice?.identity === streamIdentity ? streamingChoice.expanded : undefined; + const readerChoice = streamingChoice === 'auto' ? undefined : streamingChoice === 'open'; // Compact previews yield as soon as real output exists. A reader who opened // seven lines can finish its reveal; attention states always take precedence. const yieldToSuccessor = successorReady && (!(readerChoice ?? revealStreamingContent) || successor.immediate); @@ -122,6 +129,9 @@ export const ModelThinkingDisplay: React.FC = ({ toolName: 'thinking', }); const isVisuallyStreaming = !yieldToSuccessor && (isActive || isRevealing); + useLayoutEffect(() => { + if (!isVisuallyStreaming && streamingChoice !== 'auto') setStreamingChoice('auto'); + }, [isVisuallyStreaming, streamingChoice, setStreamingChoice]); const lastLiveIdentity = useRef(); useLayoutEffect(() => { if (isVisuallyStreaming) lastLiveIdentity.current = streamIdentity; @@ -140,12 +150,6 @@ export const ModelThinkingDisplay: React.FC = ({ previousExpanded.current = { identity: streamIdentity, expanded: isContentExpanded }; }, [dispatchToolCardToggle, isContentExpanded, streamIdentity]); - useLayoutEffect(() => { - if (streamingChoice && (streamingChoice.identity !== streamIdentity || !isVisuallyStreaming)) { - setStreamingChoice(null); - } - }, [isVisuallyStreaming, streamIdentity, streamingChoice]); - // Historical reasoning starts without inline Markdown. Retain a streamed or // forced body through its closing transition, then release it. useLayoutEffect(() => { @@ -183,13 +187,14 @@ export const ModelThinkingDisplay: React.FC = ({ // Compact output replaces its last typeset line without any scroll writer. // Only the expanded reading viewport follows the bounded inner tail. enabled: isContentExpanded && scrollViewportExpanded, active: isVisuallyStreaming, contentVersion: renderedContent, + followOnOpen: !revealStreamingContent, onStep: step => { if (isTailFollowDiagnosticsEnabled()) noteTailFollowStep('thinking', step); }, }); useLayoutEffect(() => { const previous = previousStreamViewport.current; - if (isVisuallyStreaming && streamingViewportExpanded + if (isVisuallyStreaming && streamingViewportExpanded && !revealStreamingContent && (!previous.expanded || !previous.active || previous.identity !== streamIdentity)) { resume(); } @@ -198,7 +203,7 @@ export const ModelThinkingDisplay: React.FC = ({ expanded: isVisuallyStreaming ? streamingViewportExpanded : previous.identity === streamIdentity && previous.expanded, }; - }, [isVisuallyStreaming, streamingViewportExpanded, streamIdentity, resume]); + }, [isVisuallyStreaming, streamingViewportExpanded, revealStreamingContent, streamIdentity, resume]); const contentLengthText = useMemo(() => { return t('toolCards.think.thinkingCharacters', { count: Array.from(content).length }); @@ -221,6 +226,7 @@ export const ModelThinkingDisplay: React.FC = ({ return (