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
This commit is contained in:
Peter Steinberger 2026-07-09 11:58:31 +01:00 committed by GitHub
parent c067802cda
commit 5afd4707c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 180 additions and 79 deletions

View file

@ -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<ChatPaneElement>("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`
<div class="chat-split-toolbar">
${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`
<div
class="chat-split-toolbar__pane ${active ? "chat-split-toolbar__pane--active" : ""}"
@pointerdown=${() => this.handleFocusPane(pane.id)}
@focusin=${() => this.handleFocusPane(pane.id)}
>
<label class="chat-pane__session-label">
<span class="agent-chat__sr-only">${t("chat.splitView.sessionSelect")}</span>
<select
class="chat-pane__session-select"
data-session-key=${pane.sessionKey}
aria-label=${t("chat.splitView.sessionSelect")}
.value=${pane.sessionKey}
@change=${(event: Event) => {
const nextSessionKey = (event.target as HTMLSelectElement).value;
if (nextSessionKey && nextSessionKey !== pane.sessionKey) {
this.handlePaneSessionChange(pane.id, nextSessionKey);
}
}}
>
${options.map(
(row) => html`
<option value=${row.key}>
${resolveSessionDisplayName(
row.key,
sessions.find((session) => session.key === row.key),
)}
</option>
`,
<div
class="chat-split-toolbar__pane ${active ? "chat-split-toolbar__pane--active" : ""}"
@pointerdown=${() => this.handleFocusPane(pane.id)}
@focusin=${() => this.handleToolbarPaneFocus(pane.id)}
>
<label class="chat-pane__session-label">
<span class="agent-chat__sr-only">${t("chat.splitView.sessionSelect")}</span>
<select
class="chat-pane__session-select"
data-session-key=${pane.sessionKey}
aria-label=${t("chat.splitView.sessionSelect")}
.value=${pane.sessionKey}
@change=${(event: Event) => {
const nextSessionKey = (event.target as HTMLSelectElement).value;
if (nextSessionKey && nextSessionKey !== pane.sessionKey) {
this.handlePaneSessionChange(pane.id, nextSessionKey);
}
}}
>
${options.map(
(row) => html`
<option value=${row.key}>
${resolveSessionDisplayName(
row.key,
sessions.find((session) => session.key === row.key),
)}
</select>
</label>
<div class="chat-pane__actions">
${!this.narrow
? html`
<openclaw-tooltip .content=${t("chat.splitView.splitDown")}>
<button
class="btn btn--ghost btn--icon"
type="button"
aria-label=${t("chat.splitView.splitDown")}
@click=${() => this.handleSplitDown(pane.id)}
>
${icons.panelBottomOpen}
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("chat.splitView.splitRight")}>
<button
class="btn btn--ghost btn--icon"
type="button"
aria-label=${t("chat.splitView.splitRight")}
@click=${() => this.handleSplitRight(pane.id)}
>
${icons.panelRightOpen}
</button>
</openclaw-tooltip>
`
: nothing}
<openclaw-tooltip .content=${t("chat.splitView.closePane")}>
</option>
`,
)}
</select>
</label>
<div class="chat-pane__actions">
${!this.narrow
? html`
<openclaw-tooltip .content=${t("chat.splitView.splitDown")}>
<button
class="btn btn--ghost btn--icon"
type="button"
aria-label=${t("chat.splitView.closePane")}
@click=${() => this.handleClosePane(pane.id)}
aria-label=${t("chat.splitView.splitDown")}
@click=${() => this.handleSplitDown(pane.id)}
>
${icons.x}
${icons.panelBottomOpen}
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("chat.splitView.splitRight")}>
<button
class="btn btn--ghost btn--icon"
type="button"
aria-label=${t("chat.splitView.splitRight")}
@click=${() => this.handleSplitRight(pane.id)}
>
${icons.panelRightOpen}
</button>
</openclaw-tooltip>
`
: nothing}
<openclaw-tooltip .content=${t("chat.splitView.closePane")}>
<button
class="btn btn--ghost btn--icon"
type="button"
aria-label=${t("chat.splitView.closePane")}
@click=${() => this.handleClosePane(pane.id)}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
</div>
`;
}
private renderSplitToolbar(layout: ChatSplitLayout) {
if (this.narrow) {
const activePane = findPane(layout, layout.activePaneId)?.pane;
return html`
<div class="chat-split-toolbar">
${activePane ? this.renderSplitToolbarPane(layout, activePane) : nothing}
</div>
`;
}
// 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`
<div class="chat-split-toolbar">
<div
class="chat-split-toolbar__track"
style=${this.splitScrollLeft ? `transform: translateX(${-this.splitScrollLeft}px)` : ""}
>
${layout.columns.map(
(column, columnIndex) => html`
${columnIndex > 0 ? html`<div class="chat-split-toolbar__gap"></div>` : nothing}
<div
class="chat-split-toolbar__column"
style="flex: ${layout.columnWeights[columnIndex]} 1 0"
>
${column.panes.map((pane) => this.renderSplitToolbarPane(layout, pane))}
</div>
</div>
`;
})}
`,
)}
</div>
</div>
`;
}
@ -496,7 +545,7 @@ export class ChatPage extends LitElement {
: nothing;
}
return html`
<div class="chat-split-view">
<div class="chat-split-view" @scroll=${this.handleSplitViewScroll}>
${repeat(
layout.columns,
(column) => column.id,

View file

@ -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));