mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
c067802cda
commit
5afd4707c7
2 changed files with 180 additions and 79 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue