From 67d3914b773585bc50521888bf2a3c49fed47730 Mon Sep 17 00:00:00 2001 From: GCWing Date: Thu, 17 Sep 2026 11:14:18 +0800 Subject: [PATCH] fix(web-ui): correct window dragging and narrow chat layout --- src/web-ui/index.html | 2 +- .../SceneTopBar/SceneTopBar.test.tsx | 32 ++++++++----------- .../components/SceneTopBar/SceneTopBar.tsx | 20 +++++------- .../src/app/scenes/session/ChatPane.scss | 4 +++ .../src/flow_chat/_transcript-layout.scss | 27 ++++++++++++++++ .../src/flow_chat/components/ChatInput.scss | 25 +++++++++++++-- .../modern/FLOWCHAT_VIRTUALIZATION.md | 7 ++++ .../components/modern/FlowChatTurnRail.scss | 6 ++-- .../modern/ModernFlowChatContainer.scss | 3 ++ .../components/modern/RuntimeStatusSlot.scss | 5 ++- .../modern/VirtualItemRenderer.scss | 11 ++----- 11 files changed, 94 insertions(+), 48 deletions(-) diff --git a/src/web-ui/index.html b/src/web-ui/index.html index 71bc9dabd4..da54a5c1d8 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -368,7 +368,7 @@
-
+
{ expect(maximize).toHaveBeenCalledTimes(3); }); - it('keeps the single tab label draggable and updates the boundary when another tab opens or closes', async () => { - const toolbar = renderBar(); - const label = () => toolbar.querySelector('[role="tab"] [data-openbitfun-part="label"] span')!; - await mouseDown(label()); - doubleClick(label()); - expect(startDragging).toHaveBeenCalledOnce(); - expect(maximize).toHaveBeenCalledOnce(); - - renderBar(2); - await mouseDown(label()); - doubleClick(label()); - expect(startDragging).toHaveBeenCalledOnce(); - expect(maximize).toHaveBeenCalledOnce(); - - renderBar(1); - await mouseDown(label()); - doubleClick(label()); - expect(startDragging).toHaveBeenCalledTimes(2); - expect(maximize).toHaveBeenCalledTimes(2); + it('excludes the whole tab from window gestures as other tabs open and close', async () => { + for (const tabCount of [1, 2, 1]) { + const toolbar = renderBar(tabCount); + const tab = toolbar.querySelector('[role="tab"]')!; + const label = tab.querySelector('[data-openbitfun-part="label"] span')!; + const item = tab.closest('[data-openbitfun-part="item"]')!; + for (const target of [tab, label, item]) { + await mouseDown(target); + doubleClick(target); + } + expect(startDragging).not.toHaveBeenCalled(); + expect(maximize).not.toHaveBeenCalled(); + } }); it('leaves multi-tab labels and item padding to tab interaction', async () => { diff --git a/src/web-ui/src/app/components/SceneTopBar/SceneTopBar.tsx b/src/web-ui/src/app/components/SceneTopBar/SceneTopBar.tsx index 4d36b9ef68..a5feaea793 100644 --- a/src/web-ui/src/app/components/SceneTopBar/SceneTopBar.tsx +++ b/src/web-ui/src/app/components/SceneTopBar/SceneTopBar.tsx @@ -15,19 +15,17 @@ const INTERACTIVE_SELECTOR = function blocksWindowChromeInteraction( event: React.MouseEvent, - allowTabDragging: boolean, ): boolean { const target = event.target; if (event.defaultPrevented || !(target instanceof Element) || !event.currentTarget.contains(target)) { return true; } - // Tab count changes the tab hit target, never the surrounding window chrome. - if (!allowTabDragging && target.closest('[data-openbitfun-component="tab-group"] [data-openbitfun-part="item"]')) { + // The whole tab, including item padding, belongs to tab interaction. + if (target.closest('[data-openbitfun-component="tab-group"] [data-openbitfun-part="item"]')) { return true; } - const interactive = target.closest(INTERACTIVE_SELECTOR); - return interactive !== null && !(allowTabDragging && interactive.getAttribute('role') === 'tab'); + return target.closest(INTERACTIVE_SELECTOR) !== null; } interface SceneTopBarProps { @@ -45,25 +43,23 @@ const SceneTopBar: React.FC = ({ onClose, isMaximized = false, }) => { - const openTabCount = useSceneStore(state => state.openTabs.length); - const hasTabs = openTabCount > 0; - const isSingleTab = openTabCount <= 1; + const hasTabs = useSceneStore(state => state.openTabs.length > 0); const canDragWindow = supportsNativeWindowDragging(); const hasWindowControls = Boolean(onMinimize && onMaximize && onClose); const handleMouseDown = useCallback((event: React.MouseEvent) => { if (!canDragWindow || event.button !== 0 || event.detail > 1) return; - if (blocksWindowChromeInteraction(event, isSingleTab)) return; + if (blocksWindowChromeInteraction(event)) return; void startNativeWindowDragging().catch(error => { log.debug('startDragging failed', { error }); }); - }, [canDragWindow, isSingleTab]); + }, [canDragWindow]); const handleDoubleClick = useCallback((event: React.MouseEvent) => { - if (!canDragWindow || event.button !== 0 || blocksWindowChromeInteraction(event, isSingleTab)) return; + if (!canDragWindow || event.button !== 0 || blocksWindowChromeInteraction(event)) return; onMaximize?.(); - }, [canDragWindow, isSingleTab, onMaximize]); + }, [canDragWindow, onMaximize]); return ( [data-openbitfun-part='body'] > [data-openbitfun-part='surface'] { .openbitfun-chat-input__actions-left, .openbitfun-chat-input__actions-right { display: flex; @@ -545,6 +551,7 @@ } .openbitfun-model-selector__trigger { + min-width: 0; height: var(--openbitfun-control-chat-composer-control-height); padding: 0 var(--openbitfun-space-1) 0 var(--openbitfun-space-2); border: 0; @@ -555,7 +562,11 @@ } .openbitfun-model-selector__name { - max-width: 160px; + max-width: min(160px, 22cqi); + } + + .openbitfun-model-selector__trigger-reasoning { + max-width: min(72px, 12cqi); } .openbitfun-reasoning-preset-selector[data-openbitfun-presentation='label'] @@ -590,6 +601,14 @@ } &[data-openbitfun-layout='compact'] { + // Keep the editing track usable in smaller panes. The reasoning setting + // remains available in the model menu when its inline summary cannot fit. + @container chat-input (max-width: 360px) { + .openbitfun-model-selector__trigger-reasoning { + display: none; + } + } + .openbitfun-chat-input__actions-left, .openbitfun-chat-input__actions-right { height: 100%; diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 46c94b8de9..9cacb6eefe 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -170,6 +170,13 @@ measured message even when no valid timestamp is available. The timestamp and actions remain visible at rest, without requiring hover or keyboard focus. `_transcript-layout.scss` owns the reading-column inset shared by user-message shells, model rounds, Explore regions, and the runtime-status footer. +The messages viewport enables a shared outer column rule for virtual rows and +the runtime footer: reserve the turn rail's offset and hit area plus space-1 on +the leading side, and only space-2 on the trailing side. Columns remain centered +when there is room for the full 900px reading width. This rule is local to the +transcript; the composer and welcome surface keep their existing widths, and +embedded transcripts without a rail keep their own layout. The scroller remains +full width. The shared content-padding token defaults to 0.75rem on wide and narrow surfaces, keeping the reading column compact while leaving room for decoration and targets. The bubble extends into that gutter by its corner radius. Its horizontal border diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss index ca262c81dc..0f8939263f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss @@ -1,10 +1,12 @@ +@use '../../transcript-layout' as transcriptLayout; + .flowchat-turn-rail { position: absolute; - left: 4px; + left: transcriptLayout.$turn-rail-offset; top: 20%; bottom: 20%; z-index: 8; - width: 30px; + width: transcriptLayout.$turn-rail-width; display: flex; align-items: center; pointer-events: none; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss index 73f0e06aef..104cb2c8fc 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -1,6 +1,7 @@ /** * Modern FlowChat container styles. */ +@use '../../transcript-layout' as transcriptLayout; .modern-flowchat-container.flow-chat-typography { font-family:var(--openbitfun-type-body-sm-font-family); @@ -19,6 +20,8 @@ position: relative; // Positioning context for the permission panel and overlays. &__messages { + @include transcriptLayout.viewport; + // Header and messages stack vertically; messages take the remaining space. position: relative; flex: 1 1 auto; diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss index 18bfe48e20..df97e1dd8c 100644 --- a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss @@ -47,10 +47,9 @@ } &--footer &__content { + @include transcriptLayout.reading-column; @include transcriptLayout.content-column; - width: 100%; - max-width: 900px; - margin: 0 auto; + margin-block: 0; padding-block: 0; } } diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss index 2737d12fde..2d4d6070bc 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss @@ -4,12 +4,13 @@ */ @use '../../flat-markdown-surfaces' as flatMarkdownSurfaces; @use '../../markdown-typography' as markdownTypography; +@use '../../transcript-layout' as transcriptLayout; .virtual-item-wrapper { @include flatMarkdownSurfaces.apply; @include markdownTypography.apply; + @include transcriptLayout.reading-column; - width: 100%; /* * Single reading column shared by every item type (model rounds, explore * groups, user messages, notices). Matches ChatInput's 900px so headers, @@ -17,9 +18,7 @@ * transcript-layout owns the shared content inset; user-bubble decoration * extends into that gutter without shifting the reading column. */ - max-width: 900px; - margin: 0 auto; - box-sizing: border-box; + margin-block: 0; min-height: 1px; padding: 0; position: relative; @@ -86,10 +85,6 @@ margin-bottom: 0; } - @media (max-width: 768px) { - max-width: 100%; - } - /* * No mount animation survives inside a virtualized row. *