diff --git a/ui/src/components/session-owner-menu.ts b/ui/src/components/session-owner-menu.ts index 19ab3c1ff7c5..b4441e4de7c0 100644 --- a/ui/src/components/session-owner-menu.ts +++ b/ui/src/components/session-owner-menu.ts @@ -7,6 +7,13 @@ import { syncDropdownItemRadio } from "./web-awesome.ts"; type SessionOwnerAssignment = Pick; +type SessionOwnerMenuParams = { + ownerOptions: readonly SessionOwnerOption[]; + currentOwnerId: string | null; + disabled: boolean; + disabledReason?: string; +}; + export function sessionOwnerAssignmentFromMenuValue(value: string): SessionOwnerAssignment | null { if (!value.startsWith("assign-owner:")) { return null; @@ -16,13 +23,43 @@ export function sessionOwnerAssignmentFromMenuValue(value: string): SessionOwner return (type === "human" || type === "agent") && id ? { type, id } : null; } -export function renderSessionOwnerAssignmentMenu(params: { - ownerOptions: readonly SessionOwnerOption[]; - selfOwner: SessionOwnerOption | null; - currentOwnerId: string | null; - disabled: boolean; - disabledReason?: string; -}) { +export function renderSessionOwnerAssignmentOptions( + params: SessionOwnerMenuParams, + inline = false, +) { + const title = params.disabledReason ?? nothing; + return params.ownerOptions.map((owner) => { + const checked = owner.id === params.currentOwnerId; + return html` + syncDropdownItemRadio(element, checked))} + ?disabled=${params.disabled || checked} + title=${title} + > + + ${owner.label ?? owner.id} + ${checked + ? html`` + : nothing} + + `; + }); +} + +export function renderSessionOwnerAssignmentMenu( + params: SessionOwnerMenuParams & { + selfOwner: SessionOwnerOption | null; + }, +) { const title = params.disabledReason ?? nothing; return html` ${params.selfOwner @@ -44,31 +81,7 @@ export function renderSessionOwnerAssignmentMenu(params: { > ${t("sessionsView.assignTo")} - ${params.ownerOptions.map((owner) => { - const checked = owner.id === params.currentOwnerId; - return html` - syncDropdownItemRadio(element, checked))} - ?disabled=${params.disabled || checked} - title=${title} - > - - ${owner.label ?? owner.id} - ${checked - ? html`` - : nothing} - - `; - })} + ${renderSessionOwnerAssignmentOptions(params)} ` : nothing} `; diff --git a/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts index 0656e0b27f55..831c39d029ea 100644 --- a/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts +++ b/ui/src/e2e/new-session-page.cloud-startup.e2e.test.ts @@ -207,22 +207,22 @@ suite.define(() => { const app = document.querySelector("openclaw-app") as HTMLElement & { runtime?: { context: { - cloudStartup: { + placementStartup: { get: (sessionKey: string) => { error?: string; phase: string } | null; subscribe: (listener: () => void) => () => void; }; }; }; }; - const cloudStartup = app.runtime?.context.cloudStartup; - if (!cloudStartup) { - reject(new Error("cloud startup runtime unavailable")); + const placementStartup = app.runtime?.context.placementStartup; + if (!placementStartup) { + reject(new Error("session placement startup unavailable")); return; } let settled = false; const subscription: { stop?: () => void } = {}; const resolveFailed = () => { - const status = cloudStartup.get(key); + const status = placementStartup.get(key); if (settled || status?.phase !== "failed") { return; } @@ -230,7 +230,7 @@ suite.define(() => { subscription.stop?.(); resolve(status.error ?? ""); }; - subscription.stop = cloudStartup.subscribe(resolveFailed); + subscription.stop = placementStartup.subscribe(resolveFailed); if (settled) { subscription.stop(); } else { 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 9db6ff95c27c..fdce13785a30 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 @@ -273,10 +273,15 @@ describe("chat header session menu", () => { ]); }); - it("renders quick actions directly in the compact menu", async () => { + it("drills into compact menu groups without rendering side flyouts", async () => { const showTasks = vi.fn(); + const onSettingsChange = vi.fn<(patch: Partial) => void>(); + const onAction = vi.fn<(action: HeaderMenuAction) => void>(); + const ada = { type: "human", id: "profile-ada", label: "Ada" } as const; + const research = { type: "agent", id: "research:one", label: "Research" } as const; const menu = await mountMenu({ compact: true, + worktreePath: "/work/openclaw", panelActions: [ { id: "background-tasks", @@ -286,15 +291,71 @@ describe("chat header session menu", () => { onActivate: showTasks, }, ], + layoutActions: [ + { + id: "split-right", + label: "Split right", + icon: icons.panelRightOpen, + onActivate: vi.fn(), + }, + ], + ownerOptions: [ada, research], + selfOwner: ada, + currentOwnerId: research.id, + onSettingsChange, + onAction, }); - expect(menu.querySelector(".session-menu__section-label")?.textContent?.trim()).toBe("Panels"); + const rootLabels = Array.from( + menu.querySelectorAll(":scope > wa-dropdown > wa-dropdown-item"), + ).map(itemLabel); + expect(rootLabels).toEqual([ + "Open in", + "Panels", + "Layout", + "Rename…", + "Assign to…", + "View", + "Fork", + "Continue in terminal…", + "Archive session", + "Delete…", + ]); + expect(menu.querySelector("[slot='submenu']")).toBeNull(); + + select(menu, "compact:open-view"); + await menu.updateComplete; + expect( + Array.from( + menu.querySelectorAll(":scope > wa-dropdown > wa-dropdown-item"), + ).map(itemLabel), + ).toEqual(["Back", "Reasoning", "Tool calls", "Keep commentary"]); + expect(menu.querySelector("[slot='submenu']")).toBeNull(); + select(menu, "view:reasoning"); + expect(onSettingsChange).toHaveBeenCalledWith({ chatShowThinking: false }); + + select(menu, "compact:back"); + await menu.updateComplete; + select(menu, "compact:open-panels"); + await menu.updateComplete; 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(); + + select(menu, "compact:open-assign-owner"); + await menu.updateComplete; + expect( + Array.from( + menu.querySelectorAll(":scope > wa-dropdown > wa-dropdown-item"), + ).map(itemLabel), + ).toEqual(["Back", "Ada", "Research"]); + select(menu, "assign-owner:human:profile-ada"); + expect(onAction).toHaveBeenCalledWith({ + kind: "assign-owner", + owner: { type: "human", id: "profile-ada" }, + }); }); it("pins and disables onboarding view preferences", async () => { 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 09571e05881d..c191045f1748 100644 --- a/ui/src/pages/chat/components/chat-header-session-menu.ts +++ b/ui/src/pages/chat/components/chat-header-session-menu.ts @@ -1,11 +1,12 @@ import { html, nothing, type TemplateResult } from "lit"; -import { property } from "lit/decorators.js"; +import { property, state } from "lit/decorators.js"; import type { UiSettings } from "../../../app/settings.ts"; import { icons } from "../../../components/icons.ts"; import { activateMenuShortcut, menuShortcutHint } from "../../../components/menu-shortcuts.ts"; import type { SessionOwnerOption } from "../../../components/session-owner-chip.ts"; import { renderSessionOwnerAssignmentMenu, + renderSessionOwnerAssignmentOptions, sessionOwnerAssignmentFromMenuValue, } from "../../../components/session-owner-menu.ts"; import { t } from "../../../i18n/index.ts"; @@ -33,6 +34,17 @@ export type HeaderMenuQuickAction = { const EMPTY_SETTINGS = {} as UiSettings; +type CompactMenuView = "root" | "open-in" | "panels" | "layout" | "assign-owner" | "view"; + +const COMPACT_MENU_VIEW_BY_VALUE: Record = { + "compact:back": "root", + "compact:open-assign-owner": "assign-owner", + "compact:open-layout": "layout", + "compact:open-open-in": "open-in", + "compact:open-panels": "panels", + "compact:open-view": "view", +}; + class ChatHeaderSessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) sessionLabel = ""; @property({ attribute: false }) worktreePath: string | null = null; @@ -56,6 +68,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) onOpen: () => void = () => {}; @property({ attribute: false }) onSettingsChange: (patch: Partial) => void = () => {}; @property({ attribute: false }) onAction: (action: HeaderMenuAction) => void = () => {}; + @state() private compactView: CompactMenuView = "root"; private actionDisabled(kind: HeaderMenuActionKind, extra = false): boolean { return extra || Boolean(this.actionDisabledReasons[kind]); @@ -70,6 +83,15 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { if (!value) { return; } + const compactView = COMPACT_MENU_VIEW_BY_VALUE[value]; + if (compactView) { + event.preventDefault(); + this.compactView = compactView; + void this.updateComplete.then(() => { + this.querySelector("wa-dropdown-item:not([disabled])")?.focus(); + }); + return; + } if (value.startsWith("quick:")) { const [, group, id] = value.split(":"); const actions = group === "panels" ? this.panelActions : this.layoutActions; @@ -124,30 +146,33 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { } }; - private renderEditorSubmenu() { + private renderEditorSubmenu(inline = false) { return EDITOR_IDS.map( (editor) => html` - + ${EDITOR_LABELS[editor]} `, ); } - 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) => { + private renderQuickActionItems( + group: "panels" | "layout", + actions: HeaderMenuQuickAction[], + inline = false, + ) { + return actions.map((action) => { const detail = typeof action.badge === "number" && action.badge > 0 ? html`${action.badge}` : nothing; return html` `; }); + } + + private renderCompactNavigationItem( + view: Exclude, + label: string, + icon: TemplateResult, + disabled = false, + ) { + return html` + + + ${label} + + + `; + } + + 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; if (this.compact) { - return html` - - ${items} - - `; + return this.renderCompactNavigationItem(group, label, icon); } return html` ${label} - ${items} + ${this.renderQuickActionItems(group, actions)} `; } - private renderViewSubmenu() { + private renderViewSubmenu(inline = false) { const showThinking = this.onboarding ? false : this.settings.chatShowThinking; const showToolCalls = this.onboarding ? true : this.settings.chatShowToolCalls; const persistCommentary = this.settings.chatPersistCommentary !== false; const disabledTitle = this.onboarding ? t("chat.onboardingDisabled") : nothing; const item = (value: string, label: string, checked: boolean) => html` + ? html`
${t("quickSettings.personal.browserOnly")}
` : nothing} `; } + private compactOwnerOptions(): readonly SessionOwnerOption[] { + if (!this.selfOwner || this.ownerOptions.some((owner) => owner.id === this.selfOwner?.id)) { + return this.ownerOptions; + } + return [this.selfOwner, ...this.ownerOptions]; + } + + private renderCompactView() { + const back = html` + + + ${t("common.back")} + + + `; + const body = + this.compactView === "open-in" + ? this.renderEditorSubmenu(true) + : this.compactView === "panels" + ? this.renderQuickActionItems("panels", this.panelActions, true) + : this.compactView === "layout" + ? this.renderQuickActionItems("layout", this.layoutActions, true) + : this.compactView === "assign-owner" + ? renderSessionOwnerAssignmentOptions( + { + ownerOptions: this.compactOwnerOptions(), + currentOwnerId: this.currentOwnerId, + disabled: this.actionDisabled("assign-owner"), + disabledReason: this.actionDisabledReasons["assign-owner"], + }, + true, + ) + : this.renderViewSubmenu(true); + return html`${back}${body}`; + } + + private renderRootView() { + return html` + ${this.worktreePath + ? html` + ${this.compact + ? this.renderCompactNavigationItem( + "open-in", + t("sessionsView.openInEditorMenu"), + icons.externalLink, + ) + : html` + + ${t("sessionsView.openInEditorMenu")} + ${this.renderEditorSubmenu()} + `} + + ` + : nothing} + ${this.renderQuickActions("panels", this.panelActions)} + ${this.renderQuickActions("layout", this.layoutActions)} + + + ${t("sessionsView.renameSessionMenu")} + ${menuShortcutHint("r")} + + ${this.compact + ? this.compactOwnerOptions().length > 0 + ? this.renderCompactNavigationItem( + "assign-owner", + t("sessionsView.assignTo"), + icons.users, + this.actionDisabled("assign-owner"), + ) + : nothing + : renderSessionOwnerAssignmentMenu({ + ownerOptions: this.ownerOptions, + selfOwner: this.selfOwner, + currentOwnerId: this.currentOwnerId, + disabled: this.actionDisabled("assign-owner"), + disabledReason: this.actionDisabledReasons["assign-owner"], + })} + ${this.compact + ? this.renderCompactNavigationItem("view", t("chat.view.menu"), icons.eye) + : html` + + ${t("chat.view.menu")} + ${this.renderViewSubmenu()} + `} + + + ${t( + this.forkFromLastCompleted + ? "sessionsView.forkFromLastCompleted" + : "sessionsView.forkSession", + )} + ${menuShortcutHint("f")} + + + + ${t("chat.sessionHeader.continueInTerminal.action")} + + + + + ${this.archived + ? t("sessionsView.restoreSession") + : t("sessionsView.archiveSession")} + ${menuShortcutHint("a")} + + + + ${t("sessionsView.deleteSessionMenu")} + ${menuShortcutHint("d")} + + `; + } + + private readonly handleShow = () => { + this.compactView = "root"; + this.onOpen(); + }; + + private readonly handleAfterHide = () => { + this.compactView = "root"; + }; + override render() { const menuLabel = t("chat.sidebar.sessionMenu", { session: this.sessionLabel }); return html` @@ -213,7 +427,8 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { placement="bottom-end" aria-label=${menuLabel} @keydown=${(event: KeyboardEvent) => activateMenuShortcut(this, event)} - @wa-show=${this.onOpen} + @wa-show=${this.handleShow} + @wa-after-hide=${this.handleAfterHide} @wa-select=${this.handleSelect} > - ${this.worktreePath - ? html` - - - ${t("sessionsView.openInEditorMenu")} - ${this.renderEditorSubmenu()} - - - ` - : nothing} - ${this.renderQuickActions("panels", this.panelActions)} - ${this.renderQuickActions("layout", this.layoutActions)} - - - ${t("sessionsView.renameSessionMenu")} - ${menuShortcutHint("r")} - - ${renderSessionOwnerAssignmentMenu({ - ownerOptions: this.ownerOptions, - selfOwner: this.selfOwner, - currentOwnerId: this.currentOwnerId, - disabled: this.actionDisabled("assign-owner"), - disabledReason: this.actionDisabledReasons["assign-owner"], - })} - - - ${t("chat.view.menu")} - ${this.renderViewSubmenu()} - - - - ${t( - this.forkFromLastCompleted - ? "sessionsView.forkFromLastCompleted" - : "sessionsView.forkSession", - )} - ${menuShortcutHint("f")} - - - - ${t("chat.sessionHeader.continueInTerminal.action")} - - - - - ${this.archived - ? t("sessionsView.restoreSession") - : t("sessionsView.archiveSession")} - ${menuShortcutHint("a")} - - - - ${t("sessionsView.deleteSessionMenu")} - ${menuShortcutHint("d")} - + ${this.compact && this.compactView !== "root" + ? this.renderCompactView() + : this.renderRootView()} `; } diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index 5f36a6a4d695..bb7e2b1c2a1d 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -724,11 +724,15 @@ openclaw-chat-pane { 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 .session-menu__back { + z-index: 1; + position: sticky; + top: 0; + background: var(--wa-color-surface-raised); +} + +.chat-header-session-menu--compact .session-menu__chevron { + display: inline-flex; } .chat-header-session-menu--compact::part(menu) {