improve(ui): declutter narrow session headers (#121935)

* improve(ui): declutter narrow session headers

* test: drop stale unit-fast helper expectations

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-11 01:19:25 -07:00
committed by GitHub
parent 1af6d3051d
commit 4841e4e6b9
7 changed files with 350 additions and 26 deletions
+2
View File
@@ -4591,6 +4591,8 @@ export const en: TranslationMap = {
renameAria: "Rename session {title}",
renameInputAria: "Session title",
renameInputPlaceholder: "Session title",
panels: "Panels",
layout: "Layout",
workspaceAria: "Workspace actions for {workspace}",
revealFinder: "Reveal in Finder",
revealFileExplorer: "Reveal in File Explorer",
+139 -21
View File
@@ -24,6 +24,7 @@ import {
resolveUiConfiguredMainKey,
} from "../../lib/sessions/session-key.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import { isActiveTask } from "../../lib/tasks/data.ts";
import { renderBoardViewSwitch } from "./board-session-surface.ts";
import { ChatPaneSessionMenu } from "./chat-pane-session-menu.ts";
import { readChatSessionActionAccess } from "./chat-session-action-access.ts";
@@ -31,7 +32,10 @@ import { renderBackgroundTasksToggle } from "./components/chat-background-tasks-
import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts";
import { isChatRunWorking } from "./components/chat-composer.ts";
import "./components/chat-header-session-menu.ts";
import type { HeaderMenuAction } from "./components/chat-header-session-menu.ts";
import type {
HeaderMenuAction,
HeaderMenuQuickAction,
} from "./components/chat-header-session-menu.ts";
import {
canRevealSessionWorkspace,
renderChatPaneHeader,
@@ -157,23 +161,119 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
session: row,
})
: {};
const desktopPanelAction = isDesktopPanelAvailable(this.context.gateway.snapshot)
const desktopPanelAvailable = isDesktopPanelAvailable(this.context.gateway.snapshot);
const openDesktopPanel = () =>
window.dispatchEvent(
new CustomEvent<DesktopPanelToggleDetail>(DESKTOP_PANEL_TOGGLE_EVENT, {
detail: { open: true },
}),
);
const desktopPanelAction = desktopPanelAvailable
? html`<openclaw-tooltip .content=${t("desktop.toggle")}>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-desktop-panel-toggle"
type="button"
aria-label=${t("desktop.toggle")}
@click=${() =>
window.dispatchEvent(
new CustomEvent<DesktopPanelToggleDetail>(DESKTOP_PANEL_TOGGLE_EVENT, {
detail: { open: true },
}),
)}
@click=${openDesktopPanel}
>
${icons.monitor}
</button>
</openclaw-tooltip>`
: nothing;
const discussion = this.resolveSessionDiscussionAction();
const sessionRailMode = this.selectedSessionRailMode(this.state?.sessionKey ?? "");
const toggleSessionRail = () => this.requestSessionRail("toggle");
const panelMenuActions: HeaderMenuQuickAction[] = [];
if (sessionWorkspace.onToggleTerminal) {
panelMenuActions.push({
id: "terminal",
label: t("terminal.toggle"),
icon: icons.terminal,
onActivate: sessionWorkspace.onToggleTerminal,
});
}
if (desktopPanelAvailable) {
panelMenuActions.push({
id: "desktop",
label: t("desktop.toggle"),
icon: icons.monitor,
onActivate: openDesktopPanel,
});
}
if (discussion) {
panelMenuActions.push({
id: "discussion",
label: discussion.label,
icon: icons.messageSquare,
active: discussion.active,
onActivate: discussion.onToggle,
});
}
if (sessionWorkspace.onOpenDiff) {
panelMenuActions.push({
id: "changes",
label: t("chat.sessionDiff.show"),
icon: icons.fileDiff,
disabledReason: sessionWorkspace.diffNotGit ? t("chat.sessionDiff.notGit") : undefined,
onActivate: sessionWorkspace.onOpenDiff,
});
}
if (backgroundTasks) {
panelMenuActions.push({
id: "background-tasks",
label: t(
backgroundTasks.collapsed ? "chat.backgroundTasks.show" : "chat.backgroundTasks.collapse",
),
icon: icons.listChecks,
active: !backgroundTasks.collapsed,
badge: backgroundTasks.tasks?.filter(isActiveTask).length ?? 0,
onActivate: backgroundTasks.onToggleCollapsed,
});
}
panelMenuActions.push({
id: "session-files",
label: t(
sessionWorkspace.collapsed
? "chat.workspaceFiles.showFiles"
: "chat.workspaceFiles.collapse",
),
icon: icons.fileText,
active: !sessionWorkspace.collapsed,
badge: sessionWorkspace.list?.files.filter((file) => file.kind === "modified").length ?? 0,
onActivate: sessionWorkspace.onToggleCollapsed,
});
panelMenuActions.push({
id: "session-companion",
label: t(sessionRailMode === "expanded" ? "chat.rail.collapse" : "chat.rail.show"),
icon: icons.spark,
active: sessionRailMode === "expanded",
onActivate: toggleSessionRail,
});
const layoutMenuActions: HeaderMenuQuickAction[] = [];
if (this.onOpenSplitView) {
layoutMenuActions.push({
id: "open-split-view",
label: t("chat.splitView.open"),
icon: icons.columns2,
onActivate: this.onOpenSplitView,
});
}
if (!this.narrow && this.onSplitDown) {
layoutMenuActions.push({
id: "split-down",
label: t("chat.splitView.splitDown"),
icon: icons.panelBottomOpen,
onActivate: () => this.onSplitDown?.(this.paneId),
});
}
if (!this.narrow && this.onSplitRight) {
layoutMenuActions.push({
id: "split-right",
label: t("chat.splitView.splitRight"),
icon: icons.panelRightOpen,
onActivate: () => this.onSplitRight?.(this.paneId),
});
}
return renderChatPaneHeader({
paneId: this.paneId,
narrow: this.narrow,
@@ -206,12 +306,12 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
this.catalogSession,
sessionWorkspace.onToggleTerminal,
)}${desktopPanelAction}`,
discussionAction: this.renderSessionDiscussionAction(),
discussionAction: this.renderSessionDiscussionAction(discussion),
diffAction: renderSessionDiffToggle(sessionWorkspace),
backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks),
sessionRailAction: renderSessionRailToggle({
mode: this.selectedSessionRailMode(this.state?.sessionKey ?? ""),
onToggle: () => this.requestSessionRail("toggle"),
mode: sessionRailMode,
onToggle: toggleSessionRail,
}),
workspaceAction: renderSessionWorkspaceToggle(sessionWorkspace),
presence:
@@ -298,7 +398,10 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
.onboarding=${this.onboarding}
.preferencesBrowserOnly=${this.context.runtimeConfig?.state.connected &&
this.context.runtimeConfig.canPatch === false}
.compact=${this.narrow}
.settings=${this.state.settings}
.panelActions=${panelMenuActions}
.layoutActions=${layoutMenuActions}
.actionDisabledReasons=${actionDisabledReasons}
.forkDisabled=${this.state.sessionsLoading || row.modelSelectionLocked === true}
.archiveAllowed=${archiveAllowed}
@@ -508,7 +611,11 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
return true;
}
protected renderSessionDiscussionAction() {
private resolveSessionDiscussionAction(): {
active: boolean;
label: string;
onToggle: () => void;
} | null {
const state = this.state;
const sessionKey = state?.sessionKey.trim() ?? "";
const known = sessionKey ? this.sessionDiscussionStates.get(sessionKey) : undefined;
@@ -520,26 +627,37 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu {
known === "none" ||
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.info") !== true
) {
return nothing;
return null;
}
if (!this.buildSessionDiscussionPanel(state, sessionKey)) {
return nothing;
return null;
}
const active = state.sidebarLayout.columns.some((column) =>
column.panels.some((panel) => panel.slot === "discussion"),
);
const label = t(active ? "chat.sessionDiscussion.hide" : "chat.sessionDiscussion.show");
return {
active,
label,
onToggle: () =>
active
? state.updateSidebarLayout(closeSlot(state.sidebarLayout, "discussion"))
: this.openSessionDiscussionSlot(),
};
}
protected renderSessionDiscussionAction(action = this.resolveSessionDiscussionAction()) {
if (!action) {
return nothing;
}
return html`
<openclaw-tooltip .content=${label}>
<openclaw-tooltip .content=${action.label}>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-session-discussion-toggle"
type="button"
aria-label=${label}
aria-pressed=${String(active)}
@click=${() =>
active
? state.updateSidebarLayout(closeSlot(state.sidebarLayout, "discussion"))
: this.openSessionDiscussionSlot()}
aria-label=${action.label}
aria-pressed=${String(action.active)}
@click=${action.onToggle}
>
${icons.messageSquare}
</button>
@@ -3,9 +3,10 @@
import { html, render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { UiSettings } from "../../../app/settings.ts";
import { icons } from "../../../components/icons.ts";
import type { SessionMenuActionKind } from "../../../components/session-menu.ts";
import "./chat-header-session-menu.ts";
import type { HeaderMenuAction } from "./chat-header-session-menu.ts";
import type { HeaderMenuAction, HeaderMenuQuickAction } from "./chat-header-session-menu.ts";
type HeaderMenuElement = HTMLElement & { updateComplete: Promise<boolean> };
type MenuItemElement = HTMLElement & { checked: boolean; disabled: boolean; submenuOpen?: boolean };
@@ -41,7 +42,10 @@ async function mountMenu(
archived?: boolean;
onboarding?: boolean;
preferencesBrowserOnly?: boolean;
compact?: boolean;
settings?: UiSettings;
panelActions?: HeaderMenuQuickAction[];
layoutActions?: HeaderMenuQuickAction[];
actionDisabledReasons?: Partial<Record<SessionMenuActionKind, string>>;
forkDisabled?: boolean;
archiveAllowed?: boolean;
@@ -61,7 +65,10 @@ async function mountMenu(
.archived=${options.archived ?? false}
.onboarding=${options.onboarding ?? false}
.preferencesBrowserOnly=${options.preferencesBrowserOnly ?? false}
.compact=${options.compact ?? false}
.settings=${options.settings ?? settings()}
.panelActions=${options.panelActions ?? []}
.layoutActions=${options.layoutActions ?? []}
.actionDisabledReasons=${options.actionDisabledReasons ?? {}}
.forkDisabled=${options.forkDisabled ?? false}
.archiveAllowed=${options.archiveAllowed ?? true}
@@ -165,6 +172,84 @@ describe("chat header session menu", () => {
]);
});
it("keeps panel and layout actions available from the session menu", async () => {
const showTasks = vi.fn();
const showChanges = vi.fn();
const splitRight = vi.fn();
const menu = await mountMenu({
panelActions: [
{
id: "background-tasks",
label: "Show background tasks",
icon: icons.listChecks,
active: false,
badge: 2,
onActivate: showTasks,
},
{
id: "changes",
label: "Show session changes",
icon: icons.fileDiff,
disabledReason: "This session's workspace is not a git checkout.",
onActivate: showChanges,
},
],
layoutActions: [
{
id: "split-right",
label: "Split right",
icon: icons.panelRightOpen,
onActivate: splitRight,
},
],
});
const panels = item(menu, "Panels");
const panelItems = Array.from(
panels.querySelectorAll<MenuItemElement>("wa-dropdown-item[slot='submenu']"),
);
expect(panelItems.map(itemLabel)).toEqual(["Show background tasks", "Show session changes"]);
expect(panelItems[0]?.checked).toBe(false);
expect(panelItems[0]?.querySelector('[slot="details"]')?.textContent?.trim()).toBe("2");
expect(panelItems[1]?.disabled).toBe(true);
expect(
Array.from(
item(menu, "Layout").querySelectorAll<MenuItemElement>("wa-dropdown-item[slot='submenu']"),
).map(itemLabel),
).toEqual(["Split right"]);
select(menu, "quick:panels:background-tasks");
select(menu, "quick:panels:changes");
select(menu, "quick:layout:split-right");
expect(showTasks).toHaveBeenCalledOnce();
expect(showChanges).not.toHaveBeenCalled();
expect(splitRight).toHaveBeenCalledOnce();
});
it("renders quick actions directly in the compact menu", async () => {
const showTasks = vi.fn();
const menu = await mountMenu({
compact: true,
panelActions: [
{
id: "background-tasks",
label: "Show background tasks",
icon: icons.listChecks,
badge: 2,
onActivate: showTasks,
},
],
});
expect(menu.querySelector(".session-menu__section-label")?.textContent?.trim()).toBe("Panels");
const action = item(menu, "Show background tasks");
expect(action.getAttribute("slot")).toBeNull();
expect(action.querySelector('[slot="details"]')?.textContent?.trim()).toBe("2");
select(menu, "quick:panels:background-tasks");
expect(showTasks).toHaveBeenCalledOnce();
});
it("pins and disables onboarding view preferences", async () => {
const onSettingsChange = vi.fn<(patch: Partial<UiSettings>) => void>();
const menu = await mountMenu({ onboarding: true, onSettingsChange });
@@ -1,4 +1,4 @@
import { html, nothing } from "lit";
import { html, nothing, type TemplateResult } from "lit";
import { property } from "lit/decorators.js";
import type { UiSettings } from "../../../app/settings.ts";
import { icons } from "../../../components/icons.ts";
@@ -16,6 +16,16 @@ export type HeaderMenuAction =
| { kind: "toggle-archived" }
| { kind: "delete" };
export type HeaderMenuQuickAction = {
id: string;
label: string;
icon: TemplateResult;
active?: boolean;
badge?: number;
disabledReason?: string;
onActivate: () => void;
};
const EMPTY_SETTINGS = {} as UiSettings;
class ChatHeaderSessionMenu extends OpenClawLightDomElement {
@@ -24,7 +34,10 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) archived = false;
@property({ attribute: false }) onboarding = false;
@property({ attribute: false }) preferencesBrowserOnly = false;
@property({ attribute: false }) compact = false;
@property({ attribute: false }) settings: UiSettings = EMPTY_SETTINGS;
@property({ attribute: false }) panelActions: HeaderMenuQuickAction[] = [];
@property({ attribute: false }) layoutActions: HeaderMenuQuickAction[] = [];
@property({ attribute: false }) actionDisabledReasons: Partial<
Record<SessionMenuActionKind, string>
> = {};
@@ -48,6 +61,15 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
if (!value) {
return;
}
if (value.startsWith("quick:")) {
const [, group, id] = value.split(":");
const actions = group === "panels" ? this.panelActions : this.layoutActions;
const action = actions.find((candidate) => candidate.id === id);
if (action && !action.disabledReason) {
action.onActivate();
}
return;
}
if (value.startsWith("view:")) {
event.preventDefault();
if (this.onboarding) {
@@ -94,6 +116,49 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
);
}
private renderQuickActions(group: "panels" | "layout", actions: HeaderMenuQuickAction[]) {
if (actions.length === 0) {
return nothing;
}
const label = t(group === "panels" ? "chat.sessionHeader.panels" : "chat.sessionHeader.layout");
const icon = group === "panels" ? icons.panelRightOpen : icons.columns2;
const items = actions.map((action) => {
const detail =
typeof action.badge === "number" && action.badge > 0
? html`<span slot="details" class="session-menu__sub">${action.badge}</span>`
: nothing;
return html`
<wa-dropdown-item
slot=${this.compact ? nothing : "submenu"}
class="session-menu__item"
value=${`quick:${group}:${action.id}`}
type=${action.active === undefined ? nothing : "checkbox"}
.checked=${action.active ?? false}
?disabled=${Boolean(action.disabledReason)}
title=${action.disabledReason ?? nothing}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${action.icon}</span>
<span class="session-menu__text">${action.label}</span>
${detail}
</wa-dropdown-item>
`;
});
if (this.compact) {
return html`
<div class="session-menu__section-label">${label}</div>
${items}
<div class="session-menu__separator" role="separator"></div>
`;
}
return html`
<wa-dropdown-item class="session-menu__item">
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icon}</span>
<span class="session-menu__text">${label}</span>
${items}
</wa-dropdown-item>
`;
}
private renderViewSubmenu() {
const showThinking = this.onboarding ? false : this.settings.chatShowThinking;
const showToolCalls = this.onboarding ? true : this.settings.chatShowToolCalls;
@@ -128,7 +193,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
const menuLabel = t("chat.sidebar.sessionMenu", { session: this.sessionLabel });
return html`
<wa-dropdown
class="session-menu chat-header-session-menu"
class=${`session-menu chat-header-session-menu${this.compact ? " chat-header-session-menu--compact" : ""}`}
placement="bottom-end"
aria-label=${menuLabel}
@keydown=${(event: KeyboardEvent) => activateMenuShortcut(this, event)}
@@ -156,6 +221,8 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
<div class="session-menu__separator" role="separator"></div>
`
: nothing}
${this.renderQuickActions("panels", this.panelActions)}
${this.renderQuickActions("layout", this.layoutActions)}
<wa-dropdown-item
class="session-menu__item"
value="rename"
@@ -253,6 +253,41 @@ describe("chat pane header", () => {
expect(actions?.querySelector(".chat-pane__close-pane")).not.toBeNull();
});
it("moves session panel shortcuts out of a narrow header while keeping shell actions", () => {
const { container } = mount({
narrow: true,
mergedChrome: true,
panelActions: html`<button data-action="terminal"></button>`,
discussionAction: html`<button data-action="discussion"></button>`,
diffAction: html`<button data-action="diff"></button>`,
backgroundTasksAction: html`<button data-action="tasks"></button>`,
workspaceAction: html`<button data-action="workspace"></button>`,
sessionRailAction: html`<button data-action="rail"></button>`,
sessionMenuAction: html`<button data-action="session-menu"></button>`,
});
expect(container.querySelector('[data-action="terminal"]')).toBeNull();
expect(container.querySelector('[data-action="discussion"]')).toBeNull();
expect(container.querySelector('[data-action="diff"]')).toBeNull();
expect(container.querySelector('[data-action="tasks"]')).toBeNull();
expect(container.querySelector('[data-action="workspace"]')).toBeNull();
expect(container.querySelector('[data-action="rail"]')).toBeNull();
expect(container.querySelector('[data-action="session-menu"]')).not.toBeNull();
expect(container.querySelector(".chat-pane__nav-toggle")).not.toBeNull();
expect(container.querySelector(".chat-pane__palette-open")).not.toBeNull();
});
it("keeps narrow catalog panel shortcuts visible without a session menu", () => {
const { container } = mount({
narrow: true,
catalog: true,
session: undefined,
panelActions: html`<button data-action="terminal"></button>`,
});
expect(container.querySelector('[data-action="terminal"]')).not.toBeNull();
});
it("renders an editable title and workspace chip", () => {
const { container, props } = mount();
const title = container.querySelector<HTMLButtonElement>(".chat-pane__session-title-button");
@@ -240,6 +240,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
: t("chat.sessionHeader.copyBranch");
const copied = props.copiedAction === "copy-path" || props.copiedAction === "copy-branch";
const drawerLabel = props.navDrawerOpen ? t("nav.collapse") : t("nav.expand");
const compactSessionActions = props.narrow && props.sessionMenuAction !== nothing;
return html`
<div class="chat-pane__header" @mousedown=${beginNativeWindowDrag}>
@@ -432,8 +433,8 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
: nothing}
${renderGatewayPicker(props)}
<div class="chat-pane__actions">
${props.panelActions} ${props.discussionAction}
${props.catalog
${compactSessionActions ? nothing : html`${props.panelActions} ${props.discussionAction}`}
${props.catalog || compactSessionActions
? nothing
: html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction}
${props.sessionRailAction}`}
+16
View File
@@ -517,6 +517,22 @@ openclaw-chat-pane {
}
}
.chat-header-session-menu--compact .session-menu__item {
min-height: 44px;
}
.chat-header-session-menu--compact .session-menu__section-label {
padding: 4px 8px 2px;
color: var(--muted);
font-size: var(--control-ui-text-xs);
font-weight: 650;
}
.chat-header-session-menu--compact::part(menu) {
max-height: calc(100dvh - 16px);
overflow-y: auto;
}
/* Plain web shell chrome overlays the first pane header. Expanded navigation
shows toggle + search; collapsed navigation adds new-thread between them. */
html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)