diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 712d2fe18ab0..65effff06005 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 366c91af1329..cbe3a227d153 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -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(DESKTOP_PANEL_TOGGLE_EVENT, { + detail: { open: true }, + }), + ); + const desktopPanelAction = desktopPanelAvailable ? html` ` : 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` - + diff --git a/ui/src/pages/chat/components/chat-header-session-menu.test.ts b/ui/src/pages/chat/components/chat-header-session-menu.test.ts index 7d415537959a..48134d24117b 100644 --- a/ui/src/pages/chat/components/chat-header-session-menu.test.ts +++ b/ui/src/pages/chat/components/chat-header-session-menu.test.ts @@ -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 }; 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>; 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("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("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) => void>(); const menu = await mountMenu({ onboarding: true, onSettingsChange }); diff --git a/ui/src/pages/chat/components/chat-header-session-menu.ts b/ui/src/pages/chat/components/chat-header-session-menu.ts index 199e647c0374..84f3e163be6b 100644 --- a/ui/src/pages/chat/components/chat-header-session-menu.ts +++ b/ui/src/pages/chat/components/chat-header-session-menu.ts @@ -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 > = {}; @@ -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`${action.badge}` + : nothing; + return html` + + + ${action.label} + ${detail} + + `; + }); + if (this.compact) { + return html` + + ${items} + + `; + } + return html` + + + ${label} + ${items} + + `; + } + 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` activateMenuShortcut(this, event)} @@ -156,6 +221,8 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { ` : nothing} + ${this.renderQuickActions("panels", this.panelActions)} + ${this.renderQuickActions("layout", this.layoutActions)} { 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``, + discussionAction: html``, + diffAction: html``, + backgroundTasksAction: html``, + workspaceAction: html``, + sessionRailAction: html``, + sessionMenuAction: html``, + }); + + 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``, + }); + + expect(container.querySelector('[data-action="terminal"]')).not.toBeNull(); + }); + it("renders an editable title and workspace chip", () => { const { container, props } = mount(); const title = container.querySelector(".chat-pane__session-title-button"); diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index fee057b88c45..fb22680f29ed 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -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`
@@ -432,8 +433,8 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { : nothing} ${renderGatewayPicker(props)}
- ${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}`} diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index a30fc1418525..d79f596103b5 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -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)