From 5afd4707c77eba03f5460ada3bac5766cbd2daa2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 11:58:31 +0100 Subject: [PATCH] fix(ui): split-view toolbar segments misalign with the panes they label (#102675) * fix(ui): align split-view toolbar segments with the pane edges they label * fix(ui): track pane gutter and horizontal overflow in the split toolbar * fix(ui): reset split toolbar scroll offset on mode flips and reserve docked terminal space * fix(ui): scroll clipped split panes into view when their toolbar segment gains focus --- ui/src/pages/chat/chat-page.ts | 199 +++++++++++++++++++----------- ui/src/styles/chat/split-view.css | 60 ++++++++- 2 files changed, 180 insertions(+), 79 deletions(-) diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index d21c2f371e4..421e0cb0c6f 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -50,6 +50,9 @@ export class ChatPage extends LitElement { @state() private layout: ChatSplitLayout | undefined; @state() private narrow = false; @state() private dropIndicator: DropIndicator | null = null; + // Horizontal scroll offset of .chat-split-view when its columns overflow; + // the fixed toolbar track mirrors it so segments stay over their panes. + @state() private splitScrollLeft = 0; private mediaQuery: MediaQueryList | null = null; private sessionsCleanup: (() => void) | null = null; @@ -106,6 +109,9 @@ export class ChatPage extends LitElement { private readonly handleViewportChange = (event: MediaQueryListEvent) => { this.narrow = event.matches; + // Mode flips remount .chat-split-view with scrollLeft 0 and no scroll + // event; drop the mirrored offset so the toolbar track is not left shifted. + this.splitScrollLeft = 0; if (event.matches) { this.clearDropIndicator(); } @@ -341,10 +347,29 @@ export class ChatPage extends LitElement { private readonly openSplitView = () => { const sessionKey = this.data?.sessionKey?.trim(); if (sessionKey) { + this.splitScrollLeft = 0; this.persistLayout(createSplitLayout(sessionKey)); } }; + private readonly handleSplitViewScroll = (event: Event) => { + const left = (event.currentTarget as HTMLElement).scrollLeft; + if (left !== this.splitScrollLeft) { + this.splitScrollLeft = left; + } + }; + + private readonly handleToolbarPaneFocus = (paneId: string) => { + this.handleFocusPane(paneId); + // Tabbing can land on a toolbar segment the clipped track has translated + // off-screen; scroll its pane into view so the scroll sync follows and the + // focused control becomes visible (no-op when already in view). + const pane = Array.from(this.querySelectorAll("openclaw-chat-pane")).find( + (element) => element.paneId === paneId, + ); + pane?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }; + private readonly handleSplitRight = (paneId: string) => { const layout = this.layout; const pane = layout ? findPane(layout, paneId)?.pane : null; @@ -398,90 +423,114 @@ export class ChatPage extends LitElement { `; } - private renderSplitToolbar(layout: ChatSplitLayout) { + private renderSplitToolbarPane(layout: ChatSplitLayout, pane: ChatSplitPane) { const sessions = this.context?.sessions?.state.result?.sessions ?? []; - const panes = this.narrow - ? [findPane(layout, layout.activePaneId)?.pane].filter( - (pane): pane is ChatSplitPane => pane != null, - ) - : panesOf(layout); + const active = pane.id === layout.activePaneId; + const currentSession = sessions.find((row) => row.key === pane.sessionKey); + const options = currentSession ? sessions : [{ key: pane.sessionKey }, ...sessions]; return html` -
- ${panes.map((pane) => { - const active = pane.id === layout.activePaneId; - const currentSession = sessions.find((row) => row.key === pane.sessionKey); - const options = currentSession ? sessions : [{ key: pane.sessionKey }, ...sessions]; - return html` -
this.handleFocusPane(pane.id)} - @focusin=${() => this.handleFocusPane(pane.id)} - > - -
- ${!this.narrow - ? html` - - - - - - - ` - : nothing} - + + `, + )} + + +
+ ${!this.narrow + ? html` + + + + + ` + : nothing} + + + +
+
+ `; + } + + private renderSplitToolbar(layout: ChatSplitLayout) { + if (this.narrow) { + const activePane = findPane(layout, layout.activePaneId)?.pane; + return html` +
+ ${activePane ? this.renderSplitToolbarPane(layout, activePane) : nothing} +
+ `; + } + // Mirror the split view's flex geometry (same column weights, 4px gaps at + // divider positions, same container bounds) so header segment edges land + // exactly on the pane edges below; the track follows the split view's + // horizontal scroll when columns overflow. See split-view.css. + return html` +
+
+ ${layout.columns.map( + (column, columnIndex) => html` + ${columnIndex > 0 ? html`
` : nothing} +
+ ${column.panes.map((pane) => this.renderSplitToolbarPane(layout, pane))}
-
- `; - })} + `, + )} +
`; } @@ -496,7 +545,7 @@ export class ChatPage extends LitElement { : nothing; } return html` -
+
${repeat( layout.columns, (column) => column.id, diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index 53e9f140cf0..4c491e8964e 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -119,15 +119,47 @@ openclaw-chat-pane { } } +/* Spans exactly the split view's box (nav edge -> viewport right, ignoring the + topbar's 12px padding) and mirrors its flex geometry below, so toolbar + segment edges land on the pane edges they label. overflow: clip keeps the + scroll-synced track from painting over the nav or viewport edge. */ .chat-split-toolbar { position: fixed; top: var(--safe-area-top); - right: calc(58px + var(--safe-area-right)); - left: calc(var(--shell-nav-width) + 12px + var(--safe-area-left)); + /* The docked terminal reshapes .content with margin-right; the pane edges + end there, so the fixed toolbar has to reserve the same space. */ + right: calc(var(--safe-area-right) + var(--oc-terminal-reserve-right, 0px)); + left: calc(var(--shell-nav-width) + var(--safe-area-left)); z-index: 41; display: flex; align-items: center; height: var(--shell-topbar-height); + overflow: clip; +} + +/* Same box constraints as .chat-split-view's content; chat-page translates it + by the split view's scrollLeft so segments track scrolled-away columns. */ +.chat-split-toolbar__track { + display: flex; + flex: 1 1 0; + align-items: center; + height: 100%; +} + +/* min-width mirrors .chat-split-view__column so both layers overflow at the + same point when many columns compete for space. */ +.chat-split-toolbar__column { + display: flex; + flex: 1 1 0; + align-items: center; + min-width: 320px; + height: 100%; +} + +/* Same width as the resizable-divider between columns; keeps segment + boundaries flush with the pane dividers below. */ +.chat-split-toolbar__gap { + flex: 0 0 4px; } .chat-split-toolbar__pane { @@ -140,6 +172,13 @@ openclaw-chat-pane { padding: 0 4px 0 10px; } +/* The rightmost segment ends at the viewport edge; give its controls the same + breathing room as the 10px lead-in padding. The shell's search action only + renders below 1101px, where the inset narrow toolbar applies instead. */ +.chat-split-toolbar__column:last-child .chat-split-toolbar__pane:last-child { + padding-right: 10px; +} + .chat-split-toolbar__pane + .chat-split-toolbar__pane { border-left: 1px solid color-mix(in srgb, var(--border) 74%, transparent); } @@ -206,12 +245,25 @@ openclaw-chat-pane { the rail's render breakpoint: the collapsed class stays on the workbench while the rail is display:none below 1121px. */ @media (min-width: 1121px) { - .chat-split-view__drop-container:has(.chat-workbench--workspace-collapsed) - .chat-open-split-view { + .chat-split-view__drop-container:has(.chat-workbench--workspace-collapsed) .chat-open-split-view { right: 8px; } } +/* 1101-1120px renders the full column toolbar, but the chat card keeps the + content gutter there (the zero-gutter workspace rule starts at 1121px); + match .content's 20px padding so segments stay on the pane edges. */ +@media (min-width: 1101px) and (max-width: 1120px) { + .chat-split-toolbar { + right: calc(20px + var(--safe-area-right) + var(--oc-terminal-reserve-right, 0px)); + left: calc(var(--shell-nav-width) + 20px + var(--safe-area-left)); + } +} + +/* At <=1100px the drawer toggle and search action own the topbar row's edges, + so the toolbar is inset to clear them instead of tracking pane edges. This + covers the JS narrow mode (<=1099px) plus the pinned 1100px boundary, where + the full column toolbar still renders (chat-flow e2e asserts both). */ @media (max-width: 1100px) { .chat-split-toolbar { right: calc(62px + var(--safe-area-right));