From bbdb6557d9d76bf62518f945b171bd42fed078c0 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 10 Oct 2026 11:34:57 +0800 Subject: [PATCH] feat(ui): Add optional session panel overlay Keep side-by-side layout as the default and apply layout preferences immediately, with independently persisted split and overlay widths. Preserve mounted pane content and transcript dimensions in overlay mode. Resize and reposition the chat input to avoid the visible auxiliary pane, and defer input width measurement during divider dragging. Add localized appearance settings and focused layout regression tests. --- design-system/packages/ui/README.md | 25 +++++ .../components/SplitView/SplitView.meta.ts | 3 +- .../components/SplitView/SplitView.module.css | 55 +++++++++++ .../ui/src/components/SplitView/SplitView.tsx | 24 +++-- .../ui/src/components/SplitView/index.ts | 2 +- design-system/packages/ui/src/index.ts | 2 +- .../packages/ui/tests/split-view.test.mjs | 26 ++++++ src/web-ui/src/app/scenes/SceneViewport.scss | 1 + .../session/ChatPane.composer-layout.test.tsx | 49 ++++++++++ .../src/app/scenes/session/ChatPane.tsx | 22 +++-- .../session/SessionScene.overlay.test.tsx | 92 +++++++++++++++++++ .../src/app/scenes/session/SessionScene.tsx | 8 +- .../session/sessionPaneLayoutStore.test.ts | 37 +++++++- .../scenes/session/sessionPaneLayoutStore.ts | 30 +++++- .../AppearanceSettingsPage.test.tsx | 1 + .../application/AppearanceSettingsPage.tsx | 2 + .../SessionPanelSettingsSection.tsx | 25 +++++ .../app/scenes/settings/settingsRegistry.ts | 5 + .../src/flow_chat/components/ChatInput.tsx | 5 + .../design-system/SplitView.appearance.ts | 1 + .../locales/en-US/settings/appearance.json | 6 ++ .../locales/zh-CN/settings/appearance.json | 6 ++ .../locales/zh-TW/settings/appearance.json | 6 ++ src/web-ui/src/shared/ui/SplitView.test.tsx | 55 ++++++++++- 24 files changed, 459 insertions(+), 29 deletions(-) create mode 100644 design-system/packages/ui/tests/split-view.test.mjs create mode 100644 src/web-ui/src/app/scenes/session/ChatPane.composer-layout.test.tsx create mode 100644 src/web-ui/src/app/scenes/session/SessionScene.overlay.test.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/SessionPanelSettingsSection.tsx diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index c7eaa22c86..b4f7593d3c 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -18,6 +18,31 @@ export function Example() { The package owns component anatomy, behavior, accessibility, and stable variants. It does not own theme selection persistence, product state, routes, locale resources, or platform APIs. +## Pane layouts + +`SplitView` retains its primary and secondary content hosts across placement, +visibility and `layout="split" | "overlay"` changes. Split layout is the default: +`rightSize` controls the physical right slot. In overlay layout, `rightSize` +controls the secondary pane on `secondarySide`, while the primary pane fills the +container. `minLeftSize` reserves uncovered space in overlay layout; use zero +when the secondary pane may cover the full width. The resize handle follows the +secondary pane's inner edge and keeps keyboard and pointer directions aligned. + +This is a persistent, non-modal pane layout inside the component's isolated +stacking context. It does not add a backdrop, focus trap or outside-click +dismissal. Floating menus and dialogs within either pane continue to use the +document overlay coordinator. Owners control visibility with `mode` and persist +split and overlay sizes independently when those preferences differ. + +Place `SplitViewPrimaryDock` inside the primary content's positioned container +for an absolute input or toolbar that must avoid the secondary overlay. Its +width and placement follow the displayed pane size, including pointer drags and +container clamping, without resizing the primary pane. The dock passes pointer +events through; interactive children must enable pointer events. Keep +`minLeftSize` large enough for the dock's controls; on narrow containers this +reservation shrinks to half the available width. Dock contents stay mounted +when visibility or placement changes. + Floating dialog/sheet overlays, menu popovers, select/combobox popups and tooltips declare `data-openbitfun-native-webview-occlusion` on their rendered floating surface. Native hosts use its visible bounds to temporarily hide overlapping child views; diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.meta.ts b/design-system/packages/ui/src/components/SplitView/SplitView.meta.ts index efdfb88699..795046fa2d 100644 --- a/design-system/packages/ui/src/components/SplitView/SplitView.meta.ts +++ b/design-system/packages/ui/src/components/SplitView/SplitView.meta.ts @@ -9,6 +9,7 @@ export const splitViewMeta = { { name: 'primary', type: 'ReactNode' }, { name: 'secondary', type: 'ReactNode' }, { name: 'mode', type: 'split | primary | secondary', defaultValue: 'split' }, + { name: 'layout', type: 'split | overlay', defaultValue: 'split' }, { name: 'secondarySide', type: 'left | right', defaultValue: 'right' }, { name: 'rightSize', type: 'number' }, { name: 'onRightSizeChange', type: '(size: number) => void' }, @@ -16,5 +17,5 @@ export const splitViewMeta = { { 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'], + tokens: ['color.border.subtle', 'color.surface.raised', 'color.accent.default', 'color.focus.ring', 'space.1', 'space.2', 'space.3', 'radius.lg', 'shadow.overlay'], } 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 index ed54d698af..df043ec55b 100644 --- a/design-system/packages/ui/src/components/SplitView/SplitView.module.css +++ b/design-system/packages/ui/src/components/SplitView/SplitView.module.css @@ -1,5 +1,6 @@ @layer openbitfun.components { .root { + position: relative; 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"; @@ -17,6 +18,60 @@ .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"; } + /* Overlay is local pane geometry, retaining the same mounted content hosts. */ + .root[data-layout="overlay"]:not([data-mode="secondary"]) { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "primary"; + } + + .root[data-layout="overlay"] .primary { isolation: isolate; } + + .primaryDock { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + inline-size: 100%; + min-inline-size: 0; + pointer-events: none; + } + + .root[data-layout="overlay"][data-mode="split"] > .primary .primaryDock { + inline-size: max(0px, calc(100% - var(--_split-view-right-size) - var(--openbitfun-border-width-default))); + } + + .root[data-layout="overlay"][data-mode="split"][data-secondary-side="left"] > .primary .primaryDock { + inset-inline-start: calc(var(--_split-view-right-size) + var(--openbitfun-border-width-default)); + } + + .root[data-layout="overlay"][data-mode="split"] .secondary { + grid-area: auto; + position: absolute; + inset-block: 0; + inset-inline-end: 0; + inline-size: var(--_split-view-right-size); + isolation: isolate; + background: var(--openbitfun-color-surface-raised); + box-shadow: var(--openbitfun-shadow-overlay); + } + + .root[data-layout="overlay"][data-mode="split"] .divider { + grid-area: auto; + position: absolute; + inset-block: 0; + inset-inline-end: var(--_split-view-right-size); + inline-size: var(--openbitfun-border-width-default); + } + + .root[data-layout="overlay"][data-secondary-side="left"] .secondary { + inset-inline-start: 0; + inset-inline-end: auto; + } + + .root[data-layout="overlay"][data-secondary-side="left"] .divider { + inset-inline-start: var(--_split-view-right-size); + inset-inline-end: auto; + } + .pane { display: flex; flex-direction: column; diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.tsx b/design-system/packages/ui/src/components/SplitView/SplitView.tsx index 92b1f23c91..d1c4536b6f 100644 --- a/design-system/packages/ui/src/components/SplitView/SplitView.tsx +++ b/design-system/packages/ui/src/components/SplitView/SplitView.tsx @@ -7,12 +7,19 @@ import styles from './SplitView.module.css'; export type SplitViewMode = 'split' | 'primary' | 'secondary'; +/** Absolutely positioned primary content that stays clear of the secondary overlay. */ +export function SplitViewPrimaryDock({ className, children, ...props }: HTMLAttributes) { + return
{children}
; +} + export interface SplitViewProps extends Omit, 'children'> { primary: ReactNode; secondary: ReactNode; mode?: SplitViewMode; + layout?: 'split' | 'overlay'; secondarySide?: 'left' | 'right'; - /** Physical right slot width. Swapping content never moves the divider. */ + /** Physical right slot width in split layout; secondary width in overlay layout. */ rightSize: number; minLeftSize?: number; minRightSize?: number; @@ -34,7 +41,7 @@ function sizeBounds(width: number, dividerWidth: number, minLeft: number, minRig } export function SplitView({ - primary, secondary, mode = 'split', secondarySide = 'right', rightSize, + primary, secondary, mode = 'split', layout = 'split', secondarySide = 'right', rightSize, minLeftSize = 0, minRightSize = 0, maxRightSize = Number.MAX_SAFE_INTEGER, defaultRightSize = rightSize, onRightSizeChange, onResizeStateChange, dividerLabel, dividerActions, primaryPaneProps, secondaryPaneProps, @@ -59,6 +66,7 @@ export function SplitView({ callbacks.current = { onRightSizeChange, onResizeStateChange }; const bounds = width === null ? { min: minRightSize, max: maxRightSize } : sizeBounds(width, dividerWidth, minLeftSize, minRightSize, maxRightSize); + const resizeDirection = layout === 'overlay' && secondarySide === 'left' ? -1 : 1; const resolvedSize = Math.min(bounds.max, Math.max(bounds.min, rightSize)); const resolvedSizeRef = useRef(resolvedSize); resolvedSizeRef.current = resolvedSize; @@ -78,7 +86,7 @@ export function SplitView({ observer?.observe(root); window.addEventListener('resize', measure); return () => { observer?.disconnect(); window.removeEventListener('resize', measure); }; - }, [mode]); + }, [mode, layout]); const finishResize = useCallback((commit: boolean) => { const drag = dragRef.current; @@ -95,7 +103,7 @@ export function SplitView({ callbacks.current.onResizeStateChange?.(false); }, []); - useLayoutEffect(() => { finishResize(false); }, [mode, secondarySide, finishResize]); + useLayoutEffect(() => { finishResize(false); }, [mode, layout, secondarySide, finishResize]); useEffect(() => () => { finishResize(false); }, [finishResize]); useLayoutEffect(() => { @@ -111,7 +119,7 @@ export function SplitView({ const measuredWidth = rootRef.current?.getBoundingClientRect().width ?? 0; const currentBounds = measuredWidth > 0 ? sizeBounds(measuredWidth, dividerWidth, minLeftSize, minRightSize, maxRightSize) : bounds; - const delta = drag.x - event.clientX; + 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(() => { @@ -127,7 +135,7 @@ export function SplitView({ className={classNames(styles.root, className)} style={{ ...style, '--_split-view-right-size': `${resolvedSize}px` } as CSSProperties} data-openbitfun-component="split-view" data-openbitfun-part="root" - data-mode={mode} data-secondary-side={secondarySide} data-resizing={resizing || undefined} + data-mode={mode} data-layout={layout} data-secondary-side={secondarySide} data-resizing={resizing || undefined} onKeyDown={event => { onKeyDown?.(event); if (event.defaultPrevented || event.key !== 'F6' || event.altKey || event.ctrlKey || event.metaKey) return; @@ -149,7 +157,7 @@ export function SplitView({