Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions design-system/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
31 changes: 27 additions & 4 deletions design-system/packages/ui/src/components/SplitView/SplitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,18 @@ export function SplitView({
const frameRef = useRef<number | null>(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);
Expand All @@ -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;
Expand Down Expand Up @@ -142,15 +158,21 @@ export function SplitView({
const updateDrag = (event: PointerEvent<HTMLDivElement>) => {
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`);
});
};

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtendedFlexiblePanelProps> = memo(({
Expand All @@ -189,6 +191,7 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
isActive = true,
onFileMissingFromDiskChange,
terminalResizeSuspended = false,
resizeSuspended = false,
}) => {
const { t, formatDate } = useI18n('components');
const documentSession = useEditorDocument();
Expand Down Expand Up @@ -856,6 +859,7 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
workspacePath={content.data?.workspacePath || workspacePath}
viewKind={content.data?.viewKind}
displayTitle={content.data?.displayTitle}
resizeSuspended={resizeSuspended}
/>
</React.Suspense>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -63,6 +65,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
toolbarActions,
externalToolbar = false,
terminalResizeSuspended = false,
resizeSuspended = false,
missionControlEnabled = true,
emptyState,
}) => {
Expand Down Expand Up @@ -225,6 +228,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
externalToolbar={hasExternalToolbar}
toolbarActions={toolbarActions}
terminalResizeSuspended={terminalResizeSuspended}
resizeSuspended={resizeSuspended}
/>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditorAreaProps> = ({
Expand All @@ -35,6 +37,7 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
toolbarActions,
externalToolbar = false,
terminalResizeSuspended = false,
resizeSuspended = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const topRowRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -164,6 +167,7 @@ export const EditorArea: React.FC<EditorAreaProps> = ({
disablePopOut={disablePopOut}
toolbarActions={toolbarActions === undefined || groupId === toolbarGroupId ? toolbarActions : null}
terminalResizeSuspended={terminalResizeSuspended}
resizeSuspended={resizeSuspended}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditorGroupProps> = ({
Expand Down Expand Up @@ -120,6 +122,7 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
toolbarActions,
externalToolbar = false,
terminalResizeSuspended = false,
resizeSuspended = false,
}) => {
const { t } = useTranslation('components');
const mode = useContext(CanvasStoreModeContext);
Expand Down Expand Up @@ -308,6 +311,7 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({
onInteraction={onInteraction}
workspacePath={workspacePath}
terminalResizeSuspended={terminalResizeSuspended}
resizeSuspended={resizeSuspended}
/>
</div>
))
Expand Down
5 changes: 4 additions & 1 deletion src/web-ui/src/app/scenes/session/AuxPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuxPaneRef, AuxPaneProps>(
({ 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),
Expand Down Expand Up @@ -177,6 +179,7 @@ const AuxPane = forwardRef<AuxPaneRef, AuxPaneProps>(
onInteraction={handleInteraction}
onBeforeClose={handleBeforeClose}
terminalResizeSuspended={terminalResizeSuspended}
resizeSuspended={resizeSuspended}
missionControlEnabled={false}
emptyState={
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { expect, it, vi } from 'vitest';
import ChatPane from './ChatPane';

vi.mock('@/flow_chat/components/modern/ModernFlowChatContainer', () => ({
ModernFlowChatContainer: () => <div data-testid="transcript" />,
ModernFlowChatContainer: ({ resizeSuspended = false }: { resizeSuspended?: boolean }) => (
<div data-testid="transcript" data-resize-suspended={String(resizeSuspended)} />
),
}));
vi.mock('@/flow_chat/components/ChatInput', () => ({
ChatInput: ({ layoutResizeSuspended }: { layoutResizeSuspended: boolean }) => (
Expand Down Expand Up @@ -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(<ChatPane width={0} isFullscreen={false} showChatInput isDragging={isDragging} />));
// 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(<ChatPane width={0} isFullscreen={false} showChatInput
isDragging={isDragging} resizeSuspended={resizeSuspended} />));
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());
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/app/scenes/session/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -58,6 +60,7 @@ const ChatPaneInner: React.FC<ChatPaneProps> = ({
workspacePath,
isDragging = false,
showChatInput = false,
resizeSuspended = false,
isRightPanelOpen = false,
onToggleRightPanel,
emptyState,
Expand Down Expand Up @@ -134,6 +137,7 @@ const ChatPaneInner: React.FC<ChatPaneProps> = ({
<FlowChatContainer
className="openbitfun-chat-pane__chat-container"
isViewportActive={isSceneActive}
resizeSuspended={resizeSuspended}
isRightPanelOpen={isRightPanelOpen}
onToggleRightPanel={onToggleRightPanel}
emptyState={emptyState}
Expand Down
5 changes: 4 additions & 1 deletion src/web-ui/src/app/scenes/session/SessionScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GripHorizontal } from 'lucide-react';
import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, useMemo } from 'react';
import { useI18n } from '@/infrastructure/i18n';
import { useApp } from '../../hooks/useApp';
import { useWorkbenchResize } from '../../workbench/workbenchResize';
import ChatPane from './ChatPane';
import BottomTerminalPane from './BottomTerminalPane';
import { collapseSessionBottomTerminalPane, expandSessionBottomTerminalPane } from './sessionPanelLayout';
Expand All @@ -29,6 +30,7 @@ const SessionScene: React.FC<SessionSceneProps> = ({ 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);
Expand All @@ -42,7 +44,7 @@ const SessionScene: React.FC<SessionSceneProps> = ({ 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);

Expand Down Expand Up @@ -193,6 +195,7 @@ const SessionScene: React.FC<SessionSceneProps> = ({ workspacePath, isEntering =
data-openbitfun-scene="session" data-openbitfun-part="chat">
<ChatPane width={0} isFullscreen={false} isSceneActive={isActive}
isDragging={isDragging} workspacePath={workspacePath} showChatInput
resizeSuspended={workbenchResize.measurementSuspended}
isRightPanelOpen={pane.mode !== 'chat-only'} onToggleRightPanel={pane.toggleContent} />
</div>
</div>
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/src/app/workbench/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions src/web-ui/src/app/workbench/ContentResourceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -47,6 +49,7 @@ export default function ContentResourceView({ resourceId, isActive }: { resource
<EditorDocumentContext.Provider value={document}>
<ResourceFileContext.Provider value={document}>
<FlexiblePanel content={resource.content} workspacePath={resource.scope.workspacePath} isActive={isActive}
resizeSuspended={measurementSuspended} terminalResizeSuspended={measurementSuspended}
onContentChange={content => {
if (content) {
// Buffers belong to EditorDocument. Keep file presentation updates out of the tab store's hot path.
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/src/app/workbench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading