diff --git a/design-system/AGENTS.md b/design-system/AGENTS.md index 49f3d6bb32..1ad64768dd 100644 --- a/design-system/AGENTS.md +++ b/design-system/AGENTS.md @@ -49,3 +49,10 @@ pnpm --dir design-system run build:ui && node --test design-system/packages/ui/t ``` Do not use browser automation or mock screenshots as visual proof. Design Lab is the manual authoring surface; source, build, HTTP, and package checks do not establish final visual fidelity. + +For SplitView geometry, drag cancellation, and cached resize bounds: + +```bash +pnpm --dir design-system run build:ui && node --test design-system/packages/ui/tests/split-view.test.mjs +pnpm --dir src/web-ui exec vitest run src/shared/ui/SplitView.test.tsx +``` diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.tsx b/design-system/packages/ui/src/components/SplitView/SplitView.tsx index 6b46012934..2b458922ab 100644 --- a/design-system/packages/ui/src/components/SplitView/SplitView.tsx +++ b/design-system/packages/ui/src/components/SplitView/SplitView.tsx @@ -84,12 +84,18 @@ export function SplitView({ const frameRef = useRef(null); const dragRef = useRef<{ pointerId: number; target: HTMLDivElement; x: number; size: number; latest: number; + /** Last size written to the CSS variable, so a sub-pixel move can skip the write. */ + painted: 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); + // The observer keeps these current without a layout read in pointer handlers. + // A queued frame must also use any bounds published since it was scheduled. + const boundsRef = useRef(bounds); + boundsRef.current = bounds; const resizeDirection = layout === 'overlay' && secondarySide === 'left' ? -1 : 1; const resolvedSize = Math.min(bounds.max, Math.max(bounds.min, rightSize)); const resolvedSizeRef = useRef(resolvedSize); @@ -112,6 +118,16 @@ export function SplitView({ return () => { observer?.disconnect(); window.removeEventListener('resize', measure); }; }, [mode, layout]); + // `--_split-view-right-size` is what the pane geometry actually reads, and React + // rewrites it whenever `resolvedSize` changes (a container/window resize during the + // drag re-renders with a new resolved size while the drag keeps writing the variable + // directly). Keep the sub-pixel guard anchored to the displayed value, otherwise a + // later move that rounds to the size the drag last wrote would be skipped and the + // pane would stay at the re-rendered width for the rest of the gesture. + useLayoutEffect(() => { + if (dragRef.current) dragRef.current.painted = resolvedSize; + }, [resolvedSize]); + const finishResize = useCallback((commit: boolean) => { const drag = dragRef.current; if (!drag) return; @@ -142,15 +158,21 @@ export function SplitView({ 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 currentBounds = boundsRef.current; const delta = (drag.x - event.clientX) * resizeDirection; 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`); + const current = dragRef.current; + if (!current) return; + const latestBounds = boundsRef.current; + current.latest = Math.min(latestBounds.max, Math.max(latestBounds.min, current.latest)); + // A sub-pixel move paints the same rounded width; keep the written value + // instead of dirtying the DOM style on every animation frame. + if (Math.round(current.latest) === Math.round(current.painted)) return; + current.painted = current.latest; + rootRef.current?.style.setProperty('--_split-view-right-size', `${current.latest}px`); }); }; @@ -196,6 +218,7 @@ export function SplitView({ dragRef.current = { pointerId: event.pointerId, target: event.currentTarget, x: event.clientX, size: resolvedSize, latest: resolvedSize, + painted: resolvedSize, cursor: document.body.style.cursor, userSelect: document.body.style.userSelect, }; event.currentTarget.setPointerCapture?.(event.pointerId); diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index c7752b32d4..a38dbc115e 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -176,6 +176,8 @@ interface ExtendedFlexiblePanelProps extends FlexiblePanelProps { isActive?: boolean; /** File no longer exists on disk (from editor); drives tab "deleted" label */ onFileMissingFromDiskChange?: (missing: boolean) => void; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; } const FlexiblePanel: React.FC = memo(({ @@ -189,6 +191,7 @@ const FlexiblePanel: React.FC = memo(({ isActive = true, onFileMissingFromDiskChange, terminalResizeSuspended = false, + resizeSuspended = false, }) => { const { t, formatDate } = useI18n('components'); const documentSession = useEditorDocument(); @@ -856,6 +859,7 @@ const FlexiblePanel: React.FC = memo(({ workspacePath={content.data?.workspacePath || workspacePath} viewKind={content.data?.viewKind} displayTitle={content.data?.displayTitle} + resizeSuspended={resizeSuspended} /> ); 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 b5f0fd3c75..8a657b9fd7 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 @@ -45,6 +45,8 @@ export interface ContentCanvasProps { externalToolbar?: boolean; /** Suspend terminal fit/PTY resize while the hosting panel is animating. */ terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; /** Whether this host exposes Mission Control. */ missionControlEnabled?: boolean; /** Host-provided content for the no-tabs state. */ @@ -63,6 +65,7 @@ export const ContentCanvas: React.FC = ({ toolbarActions, externalToolbar = false, terminalResizeSuspended = false, + resizeSuspended = false, missionControlEnabled = true, emptyState, }) => { @@ -225,6 +228,7 @@ export const ContentCanvas: React.FC = ({ externalToolbar={hasExternalToolbar} toolbarActions={toolbarActions} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} /> 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 9c1771998b..f5bf546383 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 @@ -21,6 +21,8 @@ export interface EditorAreaProps { toolbarActions?: React.ReactNode; externalToolbar?: boolean; terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; } export const EditorArea: React.FC = ({ @@ -35,6 +37,7 @@ export const EditorArea: React.FC = ({ toolbarActions, externalToolbar = false, terminalResizeSuspended = false, + resizeSuspended = false, }) => { const containerRef = useRef(null); const topRowRef = useRef(null); @@ -164,6 +167,7 @@ export const EditorArea: React.FC = ({ disablePopOut={disablePopOut} toolbarActions={toolbarActions === undefined || groupId === toolbarGroupId ? toolbarActions : null} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} /> ); 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 6e87e5e0f0..8b9a692052 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 @@ -89,6 +89,8 @@ export interface EditorGroupProps { toolbarActions?: React.ReactNode; externalToolbar?: boolean; terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; } export const EditorGroup: React.FC = ({ @@ -120,6 +122,7 @@ export const EditorGroup: React.FC = ({ toolbarActions, externalToolbar = false, terminalResizeSuspended = false, + resizeSuspended = false, }) => { const { t } = useTranslation('components'); const mode = useContext(CanvasStoreModeContext); @@ -308,6 +311,7 @@ export const EditorGroup: React.FC = ({ onInteraction={onInteraction} workspacePath={workspacePath} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} /> )) diff --git a/src/web-ui/src/app/scenes/session/AuxPane.tsx b/src/web-ui/src/app/scenes/session/AuxPane.tsx index b14dff18a1..c8447a3084 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPane.tsx @@ -37,12 +37,14 @@ interface AuxPaneProps { externalToolbar?: boolean; isSceneActive?: boolean; terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; isFullscreen?: boolean; onToggleFullscreen?: () => void; } const AuxPane = forwardRef( - ({ workspacePath, externalToolbar = false, isSceneActive = true, terminalResizeSuspended = false, isFullscreen = false, onToggleFullscreen }, ref) => { + ({ workspacePath, externalToolbar = false, isSceneActive = true, terminalResizeSuspended = false, resizeSuspended = false, isFullscreen = false, onToggleFullscreen }, ref) => { const { t } = useI18n('components'); const activeSessionId = useSyncExternalStore( flowChatStore.subscribe.bind(flowChatStore), @@ -177,6 +179,7 @@ const AuxPane = forwardRef( onInteraction={handleInteraction} onBeforeClose={handleBeforeClose} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} missionControlEnabled={false} emptyState={
({ - ModernFlowChatContainer: () =>
, + ModernFlowChatContainer: ({ resizeSuspended = false }: { resizeSuspended?: boolean }) => ( +
+ ), })); vi.mock('@/flow_chat/components/ChatInput', () => ({ ChatInput: ({ layoutResizeSuspended }: { layoutResizeSuspended: boolean }) => ( @@ -35,12 +37,16 @@ it('docks only the composer and forwards resize suspension without remounting th expect(dock.contains(composer)).toBe(true); expect(dock.contains(transcript)).toBe(false); composer.value = 'unsent'; - for (const isDragging of [true, false]) { - act(() => root.render()); + // Both the drag itself and the settle window two frames after it: the + // transcript stops measuring for either, the composer only for the drag. + for (const [isDragging, resizeSuspended] of [[true, true], [false, false], [false, true]] as const) { + act(() => root.render()); expect(container.querySelector('[data-testid="transcript"]')).toBe(transcript); expect(container.querySelector('[data-testid="composer"]')).toBe(composer); expect(composer.value).toBe('unsent'); expect(composer.dataset.resizeSuspended).toBe(String(isDragging)); + expect(transcript.dataset.resizeSuspended).toBe(String(resizeSuspended)); } } finally { act(() => root.unmount()); diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 8260ed892d..80491980b7 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -37,6 +37,8 @@ interface ChatPaneProps { workspacePath?: string; isDragging?: boolean; showChatInput?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; /** Whether the host-owned session right panel is open. */ isRightPanelOpen?: boolean; /** Toggle the host-owned session right panel. */ @@ -58,6 +60,7 @@ const ChatPaneInner: React.FC = ({ workspacePath, isDragging = false, showChatInput = false, + resizeSuspended = false, isRightPanelOpen = false, onToggleRightPanel, emptyState, @@ -134,6 +137,7 @@ const ChatPaneInner: React.FC = ({ = ({ workspacePath, isEntering = const { t } = useI18n('flow-chat'); const { state, updateBottomTerminalPanelHeight } = useApp(); const pane = useSessionPaneLayout(); + const workbenchResize = useWorkbenchResize(); const [isDraggingBottom, setIsDraggingBottom] = useState(false); const [isHoveringBottom, setIsHoveringBottom] = useState(false); const [isBottomTerminalPanelTransitioning, setIsBottomTerminalPanelTransitioning] = useState(false); @@ -42,7 +44,7 @@ const SessionScene: React.FC = ({ workspacePath, isEntering = const currentBottomHeight = state.layout.bottomTerminalPanelHeight || BOTTOM_TERMINAL_PANEL_CONFIG.COMFORTABLE_DEFAULT; const isTerminalDockedBottom = terminalPanelPosition === 'bottom'; const isBottomHidden = state.layout.bottomTerminalPanelCollapsed; - const isDragging = isDraggingBottom; + const isDragging = isDraggingBottom || workbenchResize.resizing; const bottomTerminalPanelMode = isBottomHidden ? 'collapsed' : getPanelDisplayMode(currentBottomHeight, BOTTOM_TERMINAL_PANEL_CONFIG); @@ -193,6 +195,7 @@ const SessionScene: React.FC = ({ workspacePath, isEntering = data-openbitfun-scene="session" data-openbitfun-part="chat">
diff --git a/src/web-ui/src/app/workbench/AGENTS.md b/src/web-ui/src/app/workbench/AGENTS.md index 4387eea30d..5ac83d5d63 100644 --- a/src/web-ui/src/app/workbench/AGENTS.md +++ b/src/web-ui/src/app/workbench/AGENTS.md @@ -24,3 +24,12 @@ pnpm --dir src/web-ui run test:run src/app/workbench/WorkbenchTabDrag.test.tsx s Use the parent guide's Session state checks when changing activation or lifetime, and the editor guide's focused suite when changing document behavior. Do not use mock-based visual verification or browser control for this module. + +For pane drag and settle measurement suspension: + +```bash +pnpm --dir src/web-ui exec vitest run src/shared/ui/SplitView.test.tsx src/app/workbench/WorkbenchLayout.test.tsx src/app/scenes/session/ChatPane.composer-layout.test.tsx src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx +``` + +These DOM fixtures cover split/overlay gestures, cancellation, resize bounds and +measurement recovery. They do not establish native frame timing or live remote behavior. diff --git a/src/web-ui/src/app/workbench/ContentResourceView.tsx b/src/web-ui/src/app/workbench/ContentResourceView.tsx index e4802015bb..d82a8ec344 100644 --- a/src/web-ui/src/app/workbench/ContentResourceView.tsx +++ b/src/web-ui/src/app/workbench/ContentResourceView.tsx @@ -10,8 +10,10 @@ import { useI18n } from '@/infrastructure/i18n'; import { useSceneStore } from '../stores/sceneStore'; import { getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; +import { useWorkbenchResize } from './workbenchResize'; export default function ContentResourceView({ resourceId, isActive }: { resourceId: string; isActive: boolean }) { + const { measurementSuspended } = useWorkbenchResize(); const focused = useSceneStore(state => state.activeTabId === `content:${resourceId}`); const resource = useContentResourceStore(state => state.resources[resourceId]); const update = useContentResourceStore(state => state.update); @@ -47,6 +49,7 @@ export default function ContentResourceView({ resourceId, isActive }: { resource { if (content) { // Buffers belong to EditorDocument. Keep file presentation updates out of the tab store's hot path. diff --git a/src/web-ui/src/app/workbench/README.md b/src/web-ui/src/app/workbench/README.md index a15c8cff3e..aae5adfb59 100644 --- a/src/web-ui/src/app/workbench/README.md +++ b/src/web-ui/src/app/workbench/README.md @@ -35,6 +35,12 @@ replacing the destination cancels the request instead of recreating its tab. Git opens and bottom-terminal opens keep their explicitly selected inline host. WorkbenchLayout owns one SplitView for the header and both content regions. +It also owns resize suspension: its context reaches session scenes and promoted +content views in either pane, and the canvas receives the same state. Transcripts +retain their last published width throughout a drag and the two-frame layout settle window; +composer layout pauses only during the drag. SplitView uses its observer-cached +bounds for pointer moves and queued paint frames, so a container resize updates +drag constraints without a synchronous layout read in the pointer handlers. SceneViewport renders a flat, keyed deck of SplitViewSurface hosts: moving a tab changes grid placement, never the React parent, iframe, document or mounted scene. SceneStore records optional pane membership (legacy tabs default to primary), diff --git a/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx b/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx index a1bee95715..7981fba164 100644 --- a/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx +++ b/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx @@ -22,8 +22,18 @@ vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => vi.mock('../hooks/useApp', () => ({ useApp: () => ({ state: { layout: { bottomTerminalPanelCollapsed: true } }, updateBottomTerminalPanelHeight: vi.fn() }), })); -vi.mock('../scenes/session/ChatPane', () => ({ default: () =>