From 06b36abc2d8a8567df3044740ae4df3b6d1772a6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 25 Jul 2026 23:44:04 -0700 Subject: [PATCH 1/2] fix(flow-chat): center session header titles --- .../src/app/components/SceneBar/SceneBar.scss | 12 ++++-- .../components/modern/FlowChatHeader.scss | 9 ++-- .../components/modern/FlowChatHeader.test.tsx | 38 +++++++++++++++++ .../components/modern/FlowChatHeader.tsx | 42 +++++++++++++++++-- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index 959bc0408e..9bfc4e1db4 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -140,12 +140,16 @@ $_tab-v-margin: 6px; // symmetric top/bottom gap inside SceneBar gap: $size-gap-2; min-width: 0; overflow: hidden; - padding: 0 $size-gap-3; - // Reserve right space so close button doesn't overlap text - padding-right: calc(#{$size-gap-3} + 20px); + // Keep the content group centered in the full tab. Closable tabs reserve + // the close-button footprint on both sides so the right-side control does + // not shift the visible title group away from the geometric center. + padding: 0 calc(#{$size-gap-3} + 20px); } - // pinned only affects auto-eviction; all tabs have close button so no special padding needed + // Pinned tabs have no close button, so they need no mirrored reservation. + &--pinned &__content { + padding-inline: $size-gap-3; + } &__icon { flex-shrink: 0; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index b44d636dd9..6a8a42b66e 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -581,7 +581,9 @@ // ==================== Center message ==================== &__message { - flex: 1; + position: absolute; + left: calc(var(--flowchat-header-side-width, 0px) + #{$size-gap-5}); + right: calc(var(--flowchat-header-side-width, 0px) + #{$size-gap-5}); min-width: 0; padding: 0 $size-gap-3; display: flex; @@ -630,14 +632,14 @@ // ==================== Actions ==================== &__actions { + position: relative; + z-index: 2; display: flex; align-items: center; gap: $size-gap-1; flex-shrink: 0; &--left { - position: relative; - z-index: 2; margin-right: $size-gap-2; overflow: visible; } @@ -650,4 +652,3 @@ } } } - diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index 36b625eb22..88ffa262b4 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -90,6 +90,44 @@ describe('FlowChatHeader', () => { root.unmount(); }); container.remove(); + vi.restoreAllMocks(); + }); + + it('reserves the larger action group width on both sides of the centered title', () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement, + ) { + const width = this.classList.contains('flowchat-header__actions--left') + ? 32 + : this.classList.contains('flowchat-header__actions') + ? 196 + : 0; + + return { + x: 0, + y: 0, + width, + height: 36, + top: 0, + right: width, + bottom: 36, + left: 0, + toJSON: () => ({}), + }; + }); + + act(() => { + root.render(); + }); + + expect(container.querySelector('.flowchat-header')).toBeNull(); + + act(() => { + root.render(); + }); + + const header = container.querySelector('.flowchat-header'); + expect(header?.style.getPropertyValue('--flowchat-header-side-width')).toBe('196px'); }); it('closes the turn list as soon as a different turn selection is accepted', () => { diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 97e4913a02..a0a4a4009e 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -4,7 +4,7 @@ * Height matches side panel headers (40px). */ -import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Activity, Bot, ChevronDown, ChevronUp, GitPullRequest, Keyboard, List, MoreHorizontal, Search, Square, Terminal, X } from 'lucide-react'; import { Tooltip, IconButton, Input } from '@/component-library'; import { useTranslation } from 'react-i18next'; @@ -140,6 +140,9 @@ export const FlowChatHeader: React.FC = ({ const [openBackgroundSubagentMenuId, setOpenBackgroundSubagentMenuId] = useState(null); const [openBackgroundCommandMenuId, setOpenBackgroundCommandMenuId] = useState(null); const [isSearchOpen, setIsSearchOpen] = useState(false); + const headerRef = useRef(null); + const leftActionsRef = useRef(null); + const rightActionsRef = useRef(null); const turnListRef = useRef(null); const backgroundActivityPanelRef = useRef(null); const activeTurnItemRef = useRef(null); @@ -263,6 +266,34 @@ export const FlowChatHeader: React.FC = ({ }; }, [currentTurn, displayTurns.length, isTurnListOpen]); + useLayoutEffect(() => { + const header = headerRef.current; + const leftActions = leftActionsRef.current; + const rightActions = rightActionsRef.current; + if (!header || !leftActions || !rightActions) return; + + const updateSideWidth = () => { + const sideWidth = Math.ceil(Math.max( + leftActions.getBoundingClientRect().width, + rightActions.getBoundingClientRect().width, + )); + header.style.setProperty('--flowchat-header-side-width', `${sideWidth}px`); + }; + + updateSideWidth(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateSideWidth); + return () => window.removeEventListener('resize', updateSideWidth); + } + + const observer = new ResizeObserver(updateSideWidth); + observer.observe(leftActions); + observer.observe(rightActions); + + return () => observer.disconnect(); + }, [isSearchOpen, totalTurns, visible]); + const handleOpenSearch = useCallback(() => { setIsSearchOpen(true); }, []); @@ -533,8 +564,11 @@ export const FlowChatHeader: React.FC = ({ } return ( -
-
+
+
@@ -563,7 +597,7 @@ export const FlowChatHeader: React.FC = ({
-
+
Date: Sat, 25 Jul 2026 23:50:05 -0700 Subject: [PATCH 2/2] fix(flow-chat): satisfy header CSS variable contract --- .../src/flow_chat/components/modern/FlowChatHeader.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index 6a8a42b66e..de9aff99b6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -582,8 +582,8 @@ // ==================== Center message ==================== &__message { position: absolute; - left: calc(var(--flowchat-header-side-width, 0px) + #{$size-gap-5}); - right: calc(var(--flowchat-header-side-width, 0px) + #{$size-gap-5}); + left: calc(var(--flowchat-header-side-width) + #{$size-gap-5}); + right: calc(var(--flowchat-header-side-width) + #{$size-gap-5}); min-width: 0; padding: 0 $size-gap-3; display: flex;