diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 1d747da3db67..903401e42573 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -28,8 +28,10 @@ import "./theme-mode-toggle.ts"; import "./tooltip.ts"; import type { ThemeMode } from "../app/theme.ts"; import { t } from "../i18n/index.ts"; +import { editorOpenUrl } from "../lib/editor-links.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts"; import { formatRelativeTimestamp } from "../lib/format.ts"; +import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts"; import { channelDisplayLabel, @@ -72,7 +74,8 @@ import { getSafeLocalStorage } from "../local-storage.ts"; import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts"; import { icons, type IconName } from "./icons.ts"; import { lobsterPetSeed, resolveLobsterPetMode, resolveLobsterRunOutcome } from "./lobster-pet.ts"; -import type { SessionMenuAction } from "./session-menu.ts"; +import { fetchSessionMenuWork } from "./session-menu-work.ts"; +import type { SessionMenuAction, SessionMenuWork } from "./session-menu.ts"; type SidebarRecentSession = { key: string; @@ -90,6 +93,7 @@ type SidebarRecentSession = { channel?: string; channelSession?: boolean; workSession?: boolean; + worktreeId?: string; unread: boolean; }; @@ -198,6 +202,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { private context?: ApplicationContext; @state() private customizeMenuPosition: { x: number; y: number } | null = null; @state() private sessionMenu: SidebarSessionMenuState | null = null; + @state() private sessionMenuWork: SessionMenuWork | null = null; @state() private sessionGroupMenu: SidebarSessionGroupMenuState | null = null; @state() private draggingSessionKey: string | null = null; @state() private draggingSessionGroup: string | null = null; @@ -214,6 +219,9 @@ class AppSidebar extends OpenClawLightDomContentsElement { private readonly subscriptions = new SubscriptionsController(this); private customizeMenuTrigger: HTMLElement | null = null; private sessionMenuTrigger: HTMLElement | null = null; + // Guards the async work fetch: a menu reopened for another session must not + // adopt a stale response. + private sessionMenuWorkVersion = 0; private sessionGroupMenuTrigger: HTMLElement | null = null; private sessionSortMenuTrigger: HTMLElement | null = null; private sessionRowsByAgent: Record = {}; @@ -456,6 +464,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { channel: channelInfo.channel, channelSession: channelInfo.channelSession, workSession: Boolean(row.worktree || row.execNode), + worktreeId: row.worktree?.id, unread: row.unread === true, }; }; @@ -620,11 +629,43 @@ class AppSidebar extends OpenClawLightDomContentsElement { this.closeSessionSortMenu(); this.sessionMenuTrigger = trigger; this.sessionMenu = { session, x, y }; + this.loadSessionMenuWork(session); } private closeSessionMenu() { this.sessionMenuTrigger = null; this.sessionMenu = null; + this.sessionMenuWorkVersion += 1; + this.sessionMenuWork = null; + } + + private loadSessionMenuWork(session: SidebarRecentSession) { + const version = ++this.sessionMenuWorkVersion; + if (!session.worktreeId) { + this.sessionMenuWork = null; + return; + } + this.sessionMenuWork = { loading: true, pullRequestUrl: null, worktreePath: null }; + const context = this.context; + const client = context?.gateway.snapshot.client; + if (!context || !client) { + this.sessionMenuWork = { loading: false, pullRequestUrl: null, worktreePath: null }; + return; + } + const { selectedAgentId } = this.getSessionNavigationState(); + void fetchSessionMenuWork({ + client, + pullRequestsAvailable: + isGatewayMethodAdvertised(context.gateway.snapshot, "controlUi.sessionPullRequests") === + true, + sessionKey: session.key, + agentId: parseAgentSessionKey(session.key)?.agentId ?? selectedAgentId, + worktreeId: session.worktreeId, + }).then((work) => { + if (version === this.sessionMenuWorkVersion) { + this.sessionMenuWork = { loading: false, ...work }; + } + }); } private openSessionGroupMenu(group: string, x: number, y: number, trigger: HTMLElement | null) { @@ -1127,6 +1168,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { .archiveAllowed=${archiveAllowed} .groups=${this.knownSessionGroups()} .canOpenChat=${true} + .work=${this.sessionMenuWork} .workboard=${null} .onClose=${() => this.closeSessionMenu()} .onAction=${(action: SessionMenuAction) => { @@ -1134,6 +1176,13 @@ class AppSidebar extends OpenClawLightDomContentsElement { case "open-chat": this.selectSession(session.key); break; + case "open-pr": + window.open(action.url, "_blank", "noopener"); + break; + case "open-in": + // A custom-scheme window hands off to the OS without navigating this page. + window.open(editorOpenUrl(action.editor, action.path)); + break; case "toggle-pin": void this.patchSession(session, { pinned: !session.pinned }); break; diff --git a/ui/src/components/menu-shortcuts.ts b/ui/src/components/menu-shortcuts.ts index 864723424c4e..364907a814be 100644 --- a/ui/src/components/menu-shortcuts.ts +++ b/ui/src/components/menu-shortcuts.ts @@ -12,9 +12,10 @@ export function activateMenuShortcut(root: ParentNode, event: KeyboardEvent): bo return false; } const key = event.key.toLowerCase(); - // Letters only: keeps the querySelector below safe and leaves navigation - // keys (arrows, Tab, Enter) to native menu focus handling. - if (!/^[a-z]$/.test(key)) { + // Letters and digits only (digits number submenu entries): keeps the + // querySelector below safe and leaves navigation keys (arrows, Tab, Enter) + // to native menu focus handling. + if (!/^[a-z0-9]$/.test(key)) { return false; } const item = root.querySelector(`button[data-shortcut="${key}"]`); diff --git a/ui/src/components/session-menu-work.test.ts b/ui/src/components/session-menu-work.test.ts new file mode 100644 index 000000000000..5d04bad314fa --- /dev/null +++ b/ui/src/components/session-menu-work.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ControlUiSessionPullRequest } from "../../../src/gateway/control-ui-contract.js"; +import { fetchSessionMenuWork, pickSessionMenuPullRequestUrl } from "./session-menu-work.ts"; + +function pullRequest(overrides: Partial): ControlUiSessionPullRequest { + return { + number: 1, + owner: "openclaw", + repo: "openclaw", + branch: "feature/demo", + title: "Demo", + url: "https://github.com/openclaw/openclaw/pull/1", + state: "open", + ...overrides, + }; +} + +describe("pickSessionMenuPullRequestUrl", () => { + it("prefers active PRs over merged and closed ones", () => { + expect( + pickSessionMenuPullRequestUrl([ + pullRequest({ state: "closed", url: "https://example.test/closed" }), + pullRequest({ state: "merged", url: "https://example.test/merged" }), + pullRequest({ state: "draft", url: "https://example.test/draft" }), + ]), + ).toBe("https://example.test/draft"); + expect(pickSessionMenuPullRequestUrl([])).toBeNull(); + }); +}); + +describe("fetchSessionMenuWork", () => { + it("resolves the PR URL and worktree path in one pass", async () => { + const request = vi.fn((method: string) => { + if (method === "controlUi.sessionPullRequests") { + return Promise.resolve({ + pullRequests: [pullRequest({ url: "https://example.test/pr" })], + rateLimited: false, + }); + } + return Promise.resolve({ + worktrees: [ + { + id: "wt-1", + path: "/work/trees/demo", + removedAt: undefined, + }, + { + id: "wt-removed", + path: "/work/trees/stale", + removedAt: 123, + }, + ], + }); + }); + + await expect( + fetchSessionMenuWork({ + client: { request: request as never }, + pullRequestsAvailable: true, + sessionKey: "agent:main:demo", + agentId: "main", + worktreeId: "wt-1", + }), + ).resolves.toEqual({ + pullRequestUrl: "https://example.test/pr", + worktreePath: "/work/trees/demo", + }); + expect(request).toHaveBeenCalledWith("controlUi.sessionPullRequests", { + sessionKey: "agent:main:demo", + agentId: "main", + }); + }); + + it("returns nulls when the PR surface is absent, the worktree is removed, or requests fail", async () => { + const failing = vi.fn(() => Promise.reject(new Error("offline"))); + await expect( + fetchSessionMenuWork({ + client: { request: failing as never }, + pullRequestsAvailable: true, + sessionKey: "agent:main:demo", + worktreeId: "wt-1", + }), + ).resolves.toEqual({ pullRequestUrl: null, worktreePath: null }); + + const request = vi.fn(() => + Promise.resolve({ worktrees: [{ id: "wt-1", path: "/gone", removedAt: 5 }] }), + ); + await expect( + fetchSessionMenuWork({ + client: { request: request as never }, + pullRequestsAvailable: false, + sessionKey: "agent:main:demo", + worktreeId: "wt-1", + }), + ).resolves.toEqual({ pullRequestUrl: null, worktreePath: null }); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith("worktrees.list", {}); + }); +}); diff --git a/ui/src/components/session-menu-work.ts b/ui/src/components/session-menu-work.ts new file mode 100644 index 000000000000..ffbe0871ac1a --- /dev/null +++ b/ui/src/components/session-menu-work.ts @@ -0,0 +1,92 @@ +import type { WorktreeRecord } from "../../../packages/gateway-protocol/src/index.js"; +import type { + ControlUiSessionPullRequest, + ControlUiSessionPullRequests, +} from "../../../src/gateway/control-ui-contract.js"; + +// Shared by the app sidebar and the Sessions page: both hosts resolve the +// same worktree-session extras (PR link, checkout path) when opening the +// session context menu, after the menu is already visible. +export type SessionMenuWorkClient = { + request: (method: string, params?: unknown) => Promise; +}; + +export type SessionMenuWorkParams = { + client: SessionMenuWorkClient; + /** controlUi.sessionPullRequests is optional gateway surface; skip when absent. */ + pullRequestsAvailable: boolean; + sessionKey: string; + agentId?: string; + worktreeId?: string; +}; + +export type SessionMenuWorkResult = { + pullRequestUrl: string | null; + worktreePath: string | null; +}; + +// Menu offers a single Open PR action; prefer the PR a maintainer most +// likely wants: active first, merged history next, closed last. +const PR_STATE_ORDER: ReadonlyArray = [ + "open", + "draft", + "merged", + "closed", +]; + +export function pickSessionMenuPullRequestUrl( + pullRequests: readonly ControlUiSessionPullRequest[], +): string | null { + for (const state of PR_STATE_ORDER) { + const match = pullRequests.find((pullRequest) => pullRequest.state === state); + if (match) { + return match.url; + } + } + return null; +} + +async function loadPullRequestUrl(params: SessionMenuWorkParams): Promise { + if (!params.pullRequestsAvailable) { + return null; + } + try { + const result = await params.client.request( + "controlUi.sessionPullRequests", + { sessionKey: params.sessionKey, ...(params.agentId ? { agentId: params.agentId } : {}) }, + ); + return pickSessionMenuPullRequestUrl(result.pullRequests); + } catch { + // Optional affordance: a GitHub or gateway hiccup just leaves Open PR disabled. + return null; + } +} + +async function loadWorktreePath(params: SessionMenuWorkParams): Promise { + const worktreeId = params.worktreeId; + if (!worktreeId) { + return null; + } + try { + const result = await params.client.request<{ worktrees: WorktreeRecord[] }>( + "worktrees.list", + {}, + ); + const record = result.worktrees.find( + (candidate) => candidate.id === worktreeId && candidate.removedAt === undefined, + ); + return record?.path ?? null; + } catch { + return null; + } +} + +export async function fetchSessionMenuWork( + params: SessionMenuWorkParams, +): Promise { + const [pullRequestUrl, worktreePath] = await Promise.all([ + loadPullRequestUrl(params), + loadWorktreePath(params), + ]); + return { pullRequestUrl, worktreePath }; +} diff --git a/ui/src/components/session-menu.test.ts b/ui/src/components/session-menu.test.ts index 16d9d26fe6a4..dd90d7dc18e6 100644 --- a/ui/src/components/session-menu.test.ts +++ b/ui/src/components/session-menu.test.ts @@ -3,7 +3,7 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import "./session-menu.ts"; -import type { SessionMenuAction, SessionMenuData } from "./session-menu.ts"; +import type { SessionMenuAction, SessionMenuData, SessionMenuWork } from "./session-menu.ts"; type SessionMenuElement = HTMLElement & { updateComplete: Promise }; @@ -19,6 +19,7 @@ async function mountMenu( options: { session?: Partial; canOpenChat?: boolean; + work?: SessionMenuWork | null; workboard?: { captured: boolean; busy: boolean } | null; archiveAllowed?: boolean; groups?: readonly string[]; @@ -50,6 +51,7 @@ async function mountMenu( .archiveAllowed=${options.archiveAllowed ?? true} .groups=${options.groups ?? []} .canOpenChat=${options.canOpenChat ?? true} + .work=${options.work ?? null} .workboard=${options.workboard === undefined ? { captured: false, busy: false } : options.workboard} @@ -172,6 +174,102 @@ describe("session menu", () => { expect(menuItemLabels(menu)).not.toContain("Remove from group"); }); + it("numbers group submenu entries and dispatches them from digit keys", async () => { + const onAction = vi.fn<(action: SessionMenuAction) => void>(); + const menu = await mountMenu({ + session: { category: "Research" }, + groups: ["Research", "Projects"], + onAction, + }); + + const closedDigit = new KeyboardEvent("keydown", { key: "1", bubbles: true, cancelable: true }); + document.dispatchEvent(closedDigit); + expect(onAction).not.toHaveBeenCalled(); + + menuItem(menu, "Move to group").click(); + await menu.updateComplete; + + const submenu = menu.querySelector(".session-menu__submenu"); + if (!submenu) { + throw new Error("Expected group submenu"); + } + expect(menuItemLabels(submenu)).toEqual([ + "Research", + "Projects", + "Remove from group", + "New group…", + ]); + const shortcuts = Array.from(submenu.querySelectorAll('[role="menuitem"]')).map( + (item) => item.dataset.shortcut, + ); + expect(shortcuts).toEqual(["1", "2", "3", "4"]); + expect( + menuItem(submenu, "Projects").querySelector(".session-menu__shortcut")?.textContent, + ).toBe("2"); + + const keydown = new KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true }); + document.dispatchEvent(keydown); + expect(onAction).toHaveBeenCalledWith({ kind: "move-to-group", category: "Projects" }); + expect(keydown.defaultPrevented).toBe(true); + }); + + it("omits Open PR and Open in for sessions without a worktree", async () => { + const menu = await mountMenu(); + + expect(menuItemLabels(menu)).not.toContain("Open PR"); + expect(menuItemLabels(menu)).not.toContain("Open in"); + }); + + it("keeps Open PR and Open in disabled while the work context loads", async () => { + const menu = await mountMenu({ + work: { loading: true, pullRequestUrl: null, worktreePath: null }, + }); + + expect(menuItem(menu, "Open PR").disabled).toBe(true); + expect(menuItem(menu, "Open in").disabled).toBe(true); + }); + + it("dispatches open-pr with the resolved URL from click or the G shortcut", async () => { + const url = "https://github.com/openclaw/openclaw/pull/12345"; + const calls: SessionMenuAction[] = []; + const menu = await mountMenu({ + work: { loading: false, pullRequestUrl: url, worktreePath: null }, + onAction: (action) => calls.push(action), + }); + + const openPr = menuItem(menu, "Open PR"); + expect(openPr.disabled).toBe(false); + expect(openPr.querySelector(".session-menu__shortcut")?.textContent).toBe("G"); + expect(menuItem(menu, "Open in").disabled).toBe(true); + + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "g", bubbles: true, cancelable: true }), + ); + expect(calls).toEqual([{ kind: "open-pr", url }]); + }); + + it("opens the editor submenu and dispatches open-in with the worktree path", async () => { + const onAction = vi.fn<(action: SessionMenuAction) => void>(); + const menu = await mountMenu({ + work: { loading: false, pullRequestUrl: null, worktreePath: "/work/trees/demo" }, + onAction, + }); + + expect(menuItem(menu, "Open PR").disabled).toBe(true); + menuItem(menu, "Open in").click(); + await menu.updateComplete; + + expect(menuItemLabels(menu)).toEqual( + expect.arrayContaining(["Cursor", "VS Code", "Windsurf", "Zed"]), + ); + menuItem(menu, "VS Code").click(); + expect(onAction).toHaveBeenCalledWith({ + kind: "open-in", + editor: "vscode", + path: "/work/trees/demo", + }); + }); + it("renders shortcut hints and dispatches actions from bare letter keys", async () => { const calls: string[] = []; const menu = await mountMenu({ diff --git a/ui/src/components/session-menu.ts b/ui/src/components/session-menu.ts index 3622c0a89d5f..1dad8bba6c15 100644 --- a/ui/src/components/session-menu.ts +++ b/ui/src/components/session-menu.ts @@ -1,6 +1,7 @@ import { html, nothing, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import { t } from "../i18n/index.ts"; +import { EDITOR_IDS, EDITOR_LABELS, type EditorId } from "../lib/editor-links.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { icons } from "./icons.ts"; import { activateMenuShortcut, menuShortcutHint } from "./menu-shortcuts.ts"; @@ -14,8 +15,21 @@ export type SessionMenuData = { category: string | null; }; +/** + * Worktree-session extras resolved lazily by the menu host after open; null + * hides the block entirely (plain chat sessions), loading keeps the items + * rendered-but-disabled so the menu layout never shifts under the pointer. + */ +export type SessionMenuWork = { + loading: boolean; + pullRequestUrl: string | null; + worktreePath: string | null; +}; + export type SessionMenuAction = | { kind: "open-chat" } + | { kind: "open-pr"; url: string } + | { kind: "open-in"; editor: EditorId; path: string } | { kind: "toggle-pin" } | { kind: "toggle-unread" } | { kind: "rename" } @@ -47,16 +61,31 @@ class SessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) archiveAllowed = false; @property({ attribute: false }) groups: readonly string[] = []; @property({ attribute: false }) canOpenChat = false; + @property({ attribute: false }) work: SessionMenuWork | null = null; @property({ attribute: false }) workboard: { captured: boolean; busy: boolean } | null = null; @property({ attribute: false }) onAction: (action: SessionMenuAction) => void = () => {}; @property({ attribute: false }) onClose: () => void = () => {}; - @state() private submenuOpen = false; + @state() private openSubmenu: "editor" | "group" | null = null; override connectedCallback() { super.connectedCallback(); document.addEventListener("pointerdown", this.handleDocumentPointerDown, true); document.addEventListener("keydown", this.handleDocumentKeydown, true); + // Sidebar-hosted menus live inside the nav stacking context (z-index 10), + // which paints below the sidebar resizer divider (z-index 20); promoting + // the menu to the popover top layer keeps app chrome from bleeding + // through it (same pattern as openclaw-native-link-menu). + this.setAttribute("popover", "manual"); + if (typeof this.showPopover === "function") { + try { + this.showPopover(); + return; + } catch { + // Fall through to in-flow rendering when the top-layer API is unavailable. + } + } + this.removeAttribute("popover"); } override disconnectedCallback() { @@ -69,7 +98,7 @@ class SessionMenu extends OpenClawLightDomElement { if (changed.has("session")) { const previous = changed.get("session") as SessionMenuData | undefined; if (previous?.key !== this.session.key) { - this.submenuOpen = false; + this.openSubmenu = null; } } } @@ -107,6 +136,144 @@ class SessionMenu extends OpenClawLightDomElement { this.onAction(action); } + private renderWorkItems(submenuLeft: boolean) { + const work = this.work; + if (!work) { + return nothing; + } + const pullRequestUrl = work.pullRequestUrl; + const worktreePath = work.worktreePath; + return html` + +
{ + if (worktreePath) { + this.openSubmenu = "editor"; + } + }} + @pointerleave=${() => { + this.openSubmenu = null; + }} + > + + ${this.openSubmenu === "editor" && worktreePath + ? this.renderEditorSubmenu(worktreePath, submenuLeft) + : nothing} +
+ + `; + } + + private renderEditorSubmenu(path: string, submenuLeft: boolean) { + return html` + + `; + } + + private renderGroupSubmenu(submenuLeft: boolean) { + const session = this.session; + // Entries are numbered like the digits users see: existing groups first, + // then the ungroup entry, then New group…; entries past 9 stay unnumbered + // rather than reusing digits. + let nextDigit = 1; + const takeDigit = () => (nextDigit <= 9 ? String(nextDigit++) : null); + const entry = (label: string, checked: boolean, action: SessionMenuAction) => { + const digit = takeDigit(); + return html` + + `; + }; + return html` + + `; + } + override render() { const menuWidth = 240; const menuMaxHeight = 460; @@ -138,6 +305,7 @@ class SessionMenu extends OpenClawLightDomElement { ` : nothing} + ${this.renderWorkItems(submenuLeft)} - ${this.submenuOpen - ? html` - - ` - : nothing} + ${this.openSubmenu === "group" ? this.renderGroupSubmenu(submenuLeft) : nothing} `, )} @@ -747,7 +732,7 @@ class ChatDetailPanel extends OpenClawLightDomElement { } }; - private readonly openInEditor = (editor: "cursor" | "vscode" | "windsurf" | "zed") => { + private readonly openInEditor = (editor: EditorId) => { const content = this.visibleContent; if (content?.kind !== "file") { return; diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index 2541da28b949..d3a0a0658af5 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -12,8 +12,11 @@ import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { hasOperatorWriteAccess } from "../../app/operator-access.ts"; import "../../components/session-menu.ts"; -import type { SessionMenuAction } from "../../components/session-menu.ts"; +import { fetchSessionMenuWork } from "../../components/session-menu-work.ts"; +import type { SessionMenuAction, SessionMenuWork } from "../../components/session-menu.ts"; import { t } from "../../i18n/index.ts"; +import { editorOpenUrl } from "../../lib/editor-links.ts"; +import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts"; import { normalizeSessionsGroupBy, type SessionsGroupBy } from "../../lib/sessions/grouping.ts"; import { @@ -87,6 +90,7 @@ class SessionsPage extends OpenClawLightDomElement { @state() private pageSize = 25; @state() private selectedKeys = new Set(); @state() private sessionMenu: { key: string; x: number; y: number } | null = null; + @state() private sessionMenuWork: SessionMenuWork | null = null; @state() private expandedSessionKey: string | null = null; // Route deep-link target (?session=...); unlike expandedSessionKey it also // narrows sessionListOptions so the linked session is guaranteed to load. @@ -112,6 +116,9 @@ class SessionsPage extends OpenClawLightDomElement { private gatewayClient: GatewayBrowserClient | null = null; private gatewayConnected = false; private sessionMenuTrigger: HTMLElement | null = null; + // Guards the async work fetch: a menu reopened for another session must not + // adopt a stale response. + private sessionMenuWorkVersion = 0; private hasBoundGatewaySource = false; private sessionsSource?: ApplicationContext["sessions"]; private hasBoundSessionsSource = false; @@ -235,8 +242,7 @@ class SessionsPage extends OpenClawLightDomElement { this.checkpointLoadingKey = null; this.checkpointBusyKey = null; this.sessionMutationPending = false; - this.sessionMenu = null; - this.sessionMenuTrigger = null; + this.closeSessionMenu(); } private resetProviderState() { @@ -895,12 +901,48 @@ class SessionsPage extends OpenClawLightDomElement { trigger: HTMLElement | null, ) { if (this.sessionMenu?.key === row.key && trigger) { - this.sessionMenu = null; - this.sessionMenuTrigger = null; + this.closeSessionMenu(); return; } this.sessionMenu = { key: row.key, ...position }; this.sessionMenuTrigger = trigger; + this.loadSessionMenuWork(row); + } + + private closeSessionMenu() { + this.sessionMenu = null; + this.sessionMenuTrigger = null; + this.sessionMenuWorkVersion += 1; + this.sessionMenuWork = null; + } + + private loadSessionMenuWork(row: GatewaySessionRow) { + const version = ++this.sessionMenuWorkVersion; + if (!row.worktree) { + this.sessionMenuWork = null; + return; + } + this.sessionMenuWork = { loading: true, pullRequestUrl: null, worktreePath: null }; + const scope = this.captureRequestScope(); + if (!scope) { + this.sessionMenuWork = { loading: false, pullRequestUrl: null, worktreePath: null }; + return; + } + void fetchSessionMenuWork({ + client: scope.client, + pullRequestsAvailable: + isGatewayMethodAdvertised( + scope.context.gateway.snapshot, + "controlUi.sessionPullRequests", + ) === true, + sessionKey: row.key, + agentId: this.sessionAgentId(row.key, scope.context), + worktreeId: row.worktree.id, + }).then((work) => { + if (version === this.sessionMenuWorkVersion) { + this.sessionMenuWork = { loading: false, ...work }; + } + }); } private renderSessionMenu() { @@ -945,21 +987,26 @@ class SessionsPage extends OpenClawLightDomElement { .archiveAllowed=${archiveAllowed} .groups=${this.knownCategories()} .canOpenChat=${row.kind !== "global"} + .work=${this.sessionMenuWork} .workboard=${canCapture && row.kind !== "global" ? { captured: capturedSessionKeys.has(row.key), busy: [...workboardState.capturingSessionKeys][0] === row.key, } : null} - .onClose=${() => { - this.sessionMenu = null; - this.sessionMenuTrigger = null; - }} + .onClose=${() => this.closeSessionMenu()} .onAction=${(action: SessionMenuAction) => { switch (action.kind) { case "open-chat": context.navigate("chat", { search: searchForSession(row.key), hash: "" }); break; + case "open-pr": + window.open(action.url, "_blank", "noopener"); + break; + case "open-in": + // A custom-scheme window hands off to the OS without navigating this page. + window.open(editorOpenUrl(action.editor, action.path)); + break; case "toggle-pin": void this.patchSession(row.key, { pinned: row.pinned !== true }); break; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 6a453f443c9d..3ad4778e58c0 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -3938,7 +3938,7 @@ td.data-table-key-col { border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); border-radius: var(--radius-lg); background: var(--bg-elevated); - box-shadow: 0 18px 40px color-mix(in srgb, black 26%, transparent); + box-shadow: var(--shadow-lg); } .agent-select__option { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index d4b7194bd422..415f7a807a32 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1322,7 +1322,7 @@ html.openclaw-native-macos .shell-nav-expand { border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); border-radius: var(--radius-lg); background: var(--bg-elevated); - box-shadow: 0 18px 40px color-mix(in srgb, black 26%, transparent); + box-shadow: var(--shadow-lg); } .sidebar-customize-menu__title { @@ -1394,10 +1394,11 @@ html.openclaw-native-macos .shell-nav-expand { border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); border-radius: var(--radius-lg); background: var(--bg-elevated); - box-shadow: 0 18px 40px color-mix(in srgb, black 26%, transparent); + box-shadow: var(--shadow-lg); } -openclaw-native-link-menu[popover] { +openclaw-native-link-menu[popover], +openclaw-session-menu[popover] { width: 0; height: 0; margin: 0; @@ -1416,7 +1417,7 @@ openclaw-native-link-menu[popover] { border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); border-radius: var(--radius-lg); background: var(--bg-elevated); - box-shadow: 0 18px 40px color-mix(in srgb, black 26%, transparent); + box-shadow: var(--shadow-lg); } .sidebar-session-sort-menu__title {