From 2644dfd651721c7f00cc52c1365c5c988829c869 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 06:49:31 -0700 Subject: [PATCH 001/124] perf(agents): avoid duplicate tool catalog fingerprinting (#127188) Co-authored-by: Amp --- src/agents/tool-search-catalog.ts | 32 +++++++++++----- src/agents/tool-search.test.ts | 64 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/agents/tool-search-catalog.ts b/src/agents/tool-search-catalog.ts index d461c960dfff..21cf57482ea7 100644 --- a/src/agents/tool-search-catalog.ts +++ b/src/agents/tool-search-catalog.ts @@ -274,6 +274,7 @@ function registerToolSearchCatalog(params: { catalogRef: ToolSearchCatalogRef; entries: ToolSearchCatalogEntry[]; append?: boolean; + fingerprint?: string; }): ToolSearchCatalogSession { const prior = params.append ? params.catalogRef.current : undefined; const byId = new Map((prior?.entries ?? []).map((entry) => [entry.id, entry])); @@ -289,7 +290,13 @@ function registerToolSearchCatalog(params: { describeCount: prior?.describeCount ?? 0, callCount: prior?.callCount ?? 0, }; - catalogFingerprints.set(next, catalogEntriesFingerprint(next.entries)); + // The supplied fingerprint describes the input entries. Duplicate IDs are + // last-write-wins, so recompute when registration changed the entry set. + const fingerprint = + params.fingerprint !== undefined && next.entries.length === params.entries.length + ? params.fingerprint + : catalogEntriesFingerprint(next.entries); + catalogFingerprints.set(next, fingerprint); params.catalogRef.current = next; params.catalogRef.onChange?.(); return next; @@ -437,8 +444,15 @@ export function applyToolCatalogCompaction( } visible.push(tool); } - const incomingFingerprint = catalogEntriesFingerprint(catalog); + // Hook-wrapped entries carry run context and have fresh executable identities, so + // their snapshots cannot be reused and would only retain the completed run. + const hasHookBoundEntry = catalog.some((entry) => + isToolWrappedWithBeforeToolCallHook(entry.tool as AnyAgentTool), + ); + const reusableKey = hasHookBoundEntry ? undefined : reusableCatalogKey(params); const existingCatalog = catalogRef.current; + const incomingFingerprint = + existingCatalog || reusableKey ? catalogEntriesFingerprint(catalog) : undefined; if (existingCatalog && catalogFingerprints.get(existingCatalog) === incomingFingerprint) { return { tools: visible, @@ -449,14 +463,8 @@ export function applyToolCatalogCompaction( }; } - // Hook-wrapped entries carry run context and have fresh executable identities, so - // their snapshots cannot be reused and would only retain the completed run. - const hasHookBoundEntry = catalog.some((entry) => - isToolWrappedWithBeforeToolCallHook(entry.tool as AnyAgentTool), - ); - const reusableKey = hasHookBoundEntry ? undefined : reusableCatalogKey(params); const reusableSnapshot = reusableKey ? reusableCatalogSnapshots.get(reusableKey) : undefined; - if (reusableSnapshot?.fingerprint === incomingFingerprint) { + if (reusableSnapshot && reusableSnapshot.fingerprint === incomingFingerprint) { restoreToolSearchCatalog({ catalogRef, entries: reusableSnapshot.entries, @@ -475,7 +483,11 @@ export function applyToolCatalogCompaction( }; } - const registered = registerToolSearchCatalog({ catalogRef, entries: catalog }); + const registered = registerToolSearchCatalog({ + catalogRef, + entries: catalog, + fingerprint: incomingFingerprint, + }); rememberReusableCatalog(reusableKey, registered); return { tools: visible, diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index 5949bbf9d821..1ebc8eb7d9ac 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -3677,6 +3677,70 @@ describe("Tool Search", () => { expect(testing.getReusableCatalogSnapshotCountForTest()).toBe(snapshotsBefore); }); + it("serializes a fresh hook-bound catalog schema only once", () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const config = { tools: { toolSearch: true } } as never; + const catalogRef = createToolSearchCatalogRef(); + const target = pluginTool("fake_hook_bound_schema", "Hook-bound schema probe"); + let schemaTraversalCount = 0; + target.parameters = new Proxy( + { type: "object", properties: { value: { type: "string" } } }, + { + ownKeys: (schema) => { + schemaTraversalCount += 1; + return Reflect.ownKeys(schema); + }, + }, + ); + + const result = applyToolSearchCatalog({ + tools: [codeTool, target], + config, + sessionId: "session-hook-bound-schema", + runId: "run-hook-bound-schema", + catalogRef, + toolHookContext: { + agentId: "agent-main", + sessionId: "session-hook-bound-schema", + sessionKey: "agent:main:main", + runId: "run-hook-bound-schema", + }, + }); + + expect(result.catalogRegistered).toBe(true); + expect(catalogRef.current?.entries.map((entry) => entry.name)).toEqual([ + "fake_hook_bound_schema", + ]); + expect(schemaTraversalCount).toBe(1); + }); + + it("preserves last-wins replacement when duplicate catalog ids reorder", () => { + const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); + const config = { tools: { toolSearch: true } } as never; + const catalogRef = createToolSearchCatalogRef(); + const first = fakeTool("fake_duplicate_id", "First executable"); + const second = fakeTool("fake_duplicate_id", "Second executable"); + const params = { + config, + sessionId: "session-duplicate-id-order", + catalogRef, + }; + + applyToolSearchCatalog({ ...params, tools: [codeTool, first, second] }); + expect(catalogRef.current?.entries.map((entry) => entry.description)).toEqual([ + "Second executable", + ]); + + const reordered = applyToolSearchCatalog({ + ...params, + tools: [codeTool, second, first], + }); + expect(reordered.catalogReused).toBe(false); + expect(catalogRef.current?.entries.map((entry) => entry.description)).toEqual([ + "First executable", + ]); + }); + it("does not reuse when a same-named tool uses a different executable", () => { const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode"); const original = pluginTool("fake_exec_swap", "Stable description"); From ebdb6115ddc0ff1a11c6628429439bfc96710b3c Mon Sep 17 00:00:00 2001 From: ClawSweeper Date: Fri, 21 Aug 2026 06:54:41 -0700 Subject: [PATCH 002/124] feat(ui): compact mobile chat header (#126788) * feat(ui): compact mobile chat header * refactor(ui): move mobile status into session menu * fix(ui): close mobile status menu before details * test(ui): follow compact header actions Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- config/assertion-safety-baseline.txt | 2 +- ui/src/app/app-shell-view.ts | 5 +- ui/src/app/device-scope-upgrade.runtime.ts | 27 +++++- ui/src/app/device-scope-upgrade.ts | 2 + ui/src/app/navigation-surface.ts | 9 +- ui/src/e2e/chat-header-axis.e2e.test.ts | 4 +- ui/src/e2e/chat-rail-columns.e2e.test.ts | 3 +- ui/src/e2e/chat-side-panel.test-support.ts | 11 ++- ui/src/e2e/device-scope-upgrade.e2e.test.ts | 97 ++++++++++++++++++- .../e2e/native-nav-sidebar-toggle.e2e.test.ts | 9 +- .../plugin-bundled-view-recovery.e2e.test.ts | 2 +- ui/src/e2e/update-confirmation.e2e.test.ts | 25 ++++- ui/src/pages/chat/chat-pane-header.ts | 86 ++++++++++++++++ .../chat-header-session-menu.test.ts | 32 ++++++ .../components/chat-header-session-menu.ts | 67 ++++++++++++- .../chat/components/chat-pane-header.test.ts | 8 +- .../pages/chat/components/chat-pane-header.ts | 7 +- ui/src/styles/chat/split-view.css | 37 +++++++ 18 files changed, 395 insertions(+), 38 deletions(-) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 3d4b8c5c2a4e..915e4fe0cddf 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -4171,7 +4171,7 @@ ui/src/pages/chat/components/chat-composer-slash-menu.ts 1 ui/src/pages/chat/components/chat-composer-view.ts 1 ui/src/pages/chat/components/chat-composer.ts 5 ui/src/pages/chat/components/chat-effort-picker.ts 6 -ui/src/pages/chat/components/chat-header-session-menu.ts 3 +ui/src/pages/chat/components/chat-header-session-menu.ts 2 ui/src/pages/chat/components/chat-message-attachment-availability.ts 1 ui/src/pages/chat/components/chat-message-bubble.ts 2 ui/src/pages/chat/components/chat-message-confirmation.ts 2 diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index bdfa28951861..b5098bbaa408 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -65,6 +65,7 @@ const SCOPE_UPGRADE_BANNER_ELEMENT = { function renderScopeUpgradeBanner( host: ShellViewHost, snapshot: ApplicationContext["gateway"]["snapshot"], + compact: boolean, ) { const state = readScopeUpgradeAvailability(snapshot); if ( @@ -78,6 +79,7 @@ function renderScopeUpgradeBanner( return html``; } @@ -615,10 +617,11 @@ export function renderApplicationShell(host: ShellViewHost) { : ""} ${activeRoute === "workboard" ? "content--workboard" : ""}" .tabIndex=${-1} > - ${renderScopeUpgradeBanner(host, gatewaySnapshot)} + ${renderScopeUpgradeBanner(host, gatewaySnapshot, mergedChatChrome)} ${renderFloatingUpdateCard({ navigationSurfaceHidden, onboarding, + compact: mergedChatChrome, updateAvailable: overlaySnapshot.updateAvailable, updateSchedule: overlaySnapshot.updateSchedule, heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId, diff --git a/ui/src/app/device-scope-upgrade.runtime.ts b/ui/src/app/device-scope-upgrade.runtime.ts index f776db0f99f6..528f0fbce51c 100644 --- a/ui/src/app/device-scope-upgrade.runtime.ts +++ b/ui/src/app/device-scope-upgrade.runtime.ts @@ -9,6 +9,7 @@ import { dismissScopeUpgradeBanner, hasDismissedScopeUpgradeBanner, readScopeUpgradeAvailability, + SCOPE_UPGRADE_DETAILS_EVENT, type ScopeUpgradeState, } from "./device-scope-upgrade.ts"; import type { ApplicationGatewaySnapshot } from "./gateway.ts"; @@ -136,12 +137,25 @@ export class ScopeUpgradeController { type ScopeUpgradeBannerProps = { snapshot: ApplicationGatewaySnapshot; + compact: boolean; }; class ScopeUpgradeBanner extends OpenClawLightDomContentsElement { @property({ attribute: false }) props?: ScopeUpgradeBannerProps; private controller?: ScopeUpgradeController; private expanded = !hasDismissedScopeUpgradeBanner(); + private compactExpanded = false; + + private readonly showDetails = () => { + this.expanded = true; + this.compactExpanded = true; + this.requestUpdate(); + }; + + override connectedCallback(): void { + super.connectedCallback(); + window.addEventListener(SCOPE_UPGRADE_DETAILS_EVENT, this.showDetails); + } protected override updated(): void { const snapshot = this.props?.snapshot; @@ -157,6 +171,7 @@ class ScopeUpgradeBanner extends OpenClawLightDomContentsElement { } override disconnectedCallback(): void { + window.removeEventListener(SCOPE_UPGRADE_DETAILS_EVENT, this.showDetails); this.controller?.dispose(); this.controller = undefined; super.disconnectedCallback(); @@ -177,6 +192,10 @@ class ScopeUpgradeBanner extends OpenClawLightDomContentsElement { if (!this.expanded && state.phase === "guidance") { return nothing; } + const compactAvailable = props.compact && !this.compactExpanded; + if (compactAvailable && state.phase === "available") { + return nothing; + } if (!this.expanded && state.phase === "available") { return html`
`; } @@ -247,6 +263,7 @@ class ScopeUpgradeBanner extends OpenClawLightDomContentsElement { @click=${() => { dismissScopeUpgradeBanner(); this.expanded = false; + this.compactExpanded = false; this.requestUpdate(); }} > diff --git a/ui/src/app/device-scope-upgrade.ts b/ui/src/app/device-scope-upgrade.ts index bee239a76b7c..46ef32bc8078 100644 --- a/ui/src/app/device-scope-upgrade.ts +++ b/ui/src/app/device-scope-upgrade.ts @@ -4,6 +4,8 @@ import { hasOperatorAdminAccess } from "./operator-access.ts"; const SCOPE_UPGRADE_BANNER_DISMISSED_KEY = "openclaw.control.scopeUpgradeBannerDismissed.v1"; +export const SCOPE_UPGRADE_DETAILS_EVENT = "openclaw:scope-upgrade-details"; + export function hasDismissedScopeUpgradeBanner(): boolean { try { return globalThis.localStorage?.getItem(SCOPE_UPGRADE_BANNER_DISMISSED_KEY) === "1"; diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts index 3915e3ceb8a8..7af2a779f13e 100644 --- a/ui/src/app/navigation-surface.ts +++ b/ui/src/app/navigation-surface.ts @@ -17,6 +17,7 @@ export function navigationSurfaceIsHidden(params: { export function renderFloatingUpdateCard(params: { navigationSurfaceHidden: boolean; onboarding: boolean; + compact?: boolean; updateAvailable: ApplicationContext["overlays"]["snapshot"]["updateAvailable"]; updateSchedule?: ApplicationContext["overlays"]["snapshot"]["updateSchedule"]; heldUpdateCampaignId?: string | null; @@ -35,10 +36,10 @@ export function renderFloatingUpdateCard(params: { }) { // A stale client must always have a visible refresh action, including during // onboarding, even though update-available actions stay hidden there. - const showAttention = params.navigationSurfaceHidden && !params.onboarding; - const showUpdateCard = params.onboarding - ? params.refreshRequired - : params.navigationSurfaceHidden; + const showAttention = params.navigationSurfaceHidden && !params.onboarding && !params.compact; + const showUpdateCard = + !params.compact && + (params.onboarding ? params.refreshRequired : params.navigationSurfaceHidden); if (!showAttention && !showUpdateCard) { return nothing; } diff --git a/ui/src/e2e/chat-header-axis.e2e.test.ts b/ui/src/e2e/chat-header-axis.e2e.test.ts index caf112ae158b..b71fe3527d4b 100644 --- a/ui/src/e2e/chat-header-axis.e2e.test.ts +++ b/ui/src/e2e/chat-header-axis.e2e.test.ts @@ -66,14 +66,14 @@ suite.define(() => { nav: centerY(".chat-pane__nav-toggle svg"), projectIcon: centerY(".workspace-icon"), projectText: centerY(".chat-pane__workspace-chip span"), - search: centerY(".chat-pane__palette-open svg"), + menu: centerY(".chat-header-session-menu__trigger svg"), separator: centerY(".chat-pane__crumb-sep"), parentText: centerY(".chat-pane__parent-session-text"), sessionText: centerY(".chat-pane__session-title-text"), }; }); - expect(Math.abs(centers.search - centers.nav), JSON.stringify(centers)).toBeLessThanOrEqual( + expect(Math.abs(centers.menu - centers.nav), JSON.stringify(centers)).toBeLessThanOrEqual( 0.1, ); for (const center of [ diff --git a/ui/src/e2e/chat-rail-columns.e2e.test.ts b/ui/src/e2e/chat-rail-columns.e2e.test.ts index b44551ef55a7..cc73fbae7777 100644 --- a/ui/src/e2e/chat-rail-columns.e2e.test.ts +++ b/ui/src/e2e/chat-rail-columns.e2e.test.ts @@ -902,8 +902,7 @@ suite.define(() => { const gateway = await installMockGateway(page, scenario()); await page.goto(`${suite.server.baseUrl}chat`); await page.locator(".chat-group").first().waitFor(); - await page.locator(".chat-side-panel-toggle").click(); - await openFromEmpty(page, "Files"); + await activateChatHeaderPanelAction(page, "Show session files"); await openFromPlus(page, "Terminal"); await openFromPlus(page, "Side chat"); await selectTab(page, "Side chat"); diff --git a/ui/src/e2e/chat-side-panel.test-support.ts b/ui/src/e2e/chat-side-panel.test-support.ts index b0204b3c25ef..e2d3e1b509ee 100644 --- a/ui/src/e2e/chat-side-panel.test-support.ts +++ b/ui/src/e2e/chat-side-panel.test-support.ts @@ -26,11 +26,14 @@ export async function activateChatHeaderPanelAction(page: Page, label: string): .locator('wa-dropdown-item[value^="quick:panels:"]') .filter({ hasText: label }); if (!(await action.isVisible())) { - await menu - .locator(".session-menu__text") - .filter({ hasText: /^Panels$/ }) - .hover(); + const panels = menu.locator(".session-menu__text").filter({ hasText: /^Panels$/ }); + if ((await menu.locator("wa-dropdown.chat-header-session-menu--compact").count()) > 0) { + await panels.click(); + } else { + await panels.hover(); + } } + await action.waitFor({ state: "visible" }); const afterHide = menu.locator("wa-dropdown").evaluate( (dropdown) => new Promise((resolve) => { diff --git a/ui/src/e2e/device-scope-upgrade.e2e.test.ts b/ui/src/e2e/device-scope-upgrade.e2e.test.ts index c842f46b446e..a67abab1ab37 100644 --- a/ui/src/e2e/device-scope-upgrade.e2e.test.ts +++ b/ui/src/e2e/device-scope-upgrade.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdir } from "node:fs/promises"; +import { copyFile, mkdir, rm } from "node:fs/promises"; import path from "node:path"; import { chromium, type Browser, type BrowserContext, type Page, type Route } from "playwright"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; @@ -78,6 +78,39 @@ async function createContext(): Promise { return context; } +async function createProofContext( + viewport: { width: number; height: number }, + label: string, +): Promise<{ context: BrowserContext; page: Page; rawVideoDir: string | null }> { + const rawVideoDir = proofDir ? path.join(proofDir, "raw-video", label) : null; + if (rawVideoDir) { + await mkdir(rawVideoDir, { recursive: true }); + } + const context = await browser.newContext({ + locale: "en-US", + ...(rawVideoDir ? { recordVideo: { dir: rawVideoDir, size: viewport } } : {}), + serviceWorkers: "block", + viewport, + }); + openContexts.add(context); + return { context, page: await context.newPage(), rawVideoDir }; +} + +async function closeProofContext( + proof: { context: BrowserContext; page: Page; rawVideoDir: string | null }, + label: string, +): Promise { + const video = proof.page.video(); + openContexts.delete(proof.context); + await proof.context.close(); + if (proofDir && video) { + await copyFile(await video.path(), path.join(proofDir, `${label}.webm`)); + } + if (proof.rawVideoDir) { + await rm(proof.rawVideoDir, { force: true, recursive: true }); + } +} + async function waitForLayoutSettled(page: Page, selector: string): Promise { // content-visibility, grid transitions, and lazy styles can defer layout beyond // a fixed rAF pair. Measure the owning geometry until two frames agree. @@ -133,6 +166,68 @@ describeControlUiE2e("Control UI live device scope upgrade", () => { openContexts.clear(); }); + it("keeps mobile status inside the overflow menu while leaving desktop chrome unchanged", async () => { + const mobile = await createProofContext({ width: 390, height: 844 }, "mobile"); + try { + const gateway = await installMockGateway(mobile.page, { operatorScopes: LIMITED_SCOPES }); + await mobile.page.goto(`${server.baseUrl}chat`); + await gateway.emitGatewayEvent("update.available", { + updateAvailable: { + channel: "stable", + currentVersion: "1.0.0", + latestVersion: "2.0.0", + }, + }); + const header = mobile.page.locator(".chat-pane__header").first(); + const menu = mobile.page.locator(".chat-header-session-menu__trigger"); + await header.waitFor(); + await menu.waitFor(); + expect(await mobile.page.locator(".chat-pane__palette-open").count()).toBe(0); + expect(await mobile.page.locator(".chat-side-panel-toggle").count()).toBe(0); + expect(await mobile.page.locator(".scope-upgrade-chip").count()).toBe(0); + expect(await mobile.page.locator(".sidebar-attention--floating").count()).toBe(0); + expect(await mobile.page.locator(".sidebar-update-card--floating").count()).toBe(0); + expect(await mobile.page.locator(".chat-header-session-menu__status-dot").count()).toBe(1); + await captureProof(mobile.page, "mobile-compact-header.png"); + await mobile.page.waitForTimeout(500); + await menu.click(); + const status = mobile.page.getByText("Limited access", { exact: true }); + await status.waitFor(); + await mobile.page.getByText("Update available v2.0.0", { exact: true }).waitFor(); + await captureProof(mobile.page, "mobile-status-menu.png"); + await mobile.page.waitForTimeout(500); + await status.click(); + await mobile.page.getByText("This browser has limited access.", { exact: true }).waitFor(); + await mobile.page + .locator(".chat-header-session-menu--compact wa-dropdown-item") + .first() + .waitFor({ state: "hidden" }); + await captureProof(mobile.page, "mobile-access-details.png"); + await mobile.page.waitForTimeout(700); + await mobile.page.getByRole("button", { name: "Collapse limited access banner" }).click(); + await menu.waitFor(); + await mobile.page.waitForTimeout(500); + } finally { + await closeProofContext(mobile, "mobile-compact-header"); + } + + const desktop = await createProofContext({ width: 1280, height: 900 }, "desktop"); + try { + await installMockGateway(desktop.page, { operatorScopes: LIMITED_SCOPES }); + await desktop.page.goto(`${server.baseUrl}chat`); + await desktop.page.getByText("This browser has limited access.", { exact: true }).waitFor(); + await desktop.page.locator(".shell-chrome-controls__search").first().waitFor(); + await desktop.page.locator(".chat-side-panel-toggle").first().waitFor(); + await captureProof(desktop.page, "desktop-unchanged.png"); + await desktop.page.waitForTimeout(500); + await desktop.page.getByRole("button", { name: "Collapse limited access banner" }).click(); + await desktop.page.getByRole("button", { name: "Show limited access details" }).waitFor(); + await desktop.page.waitForTimeout(500); + } finally { + await closeProofContext(desktop, "desktop-unchanged"); + } + }); + it("requests admin explicitly, shows pending repair guidance, and reconnects approved", async () => { const context = await createContext(); const page = await context.newPage(); diff --git a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts index 3faed48e49d3..6afa7641603f 100644 --- a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts +++ b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts @@ -322,7 +322,7 @@ suite.define(() => { expect(metrics).toEqual({ bodyScrollTop: 0, htmlScrollTop: 0, rootScrollY: 0 }); }); - it("moves drawer and search controls into the narrow chat title bar", async () => { + it("keeps drawer and search reachable from the narrow chat title bar", async () => { const page = await openPage({ nativeNav: false, width: 900 }); const header = page.locator(".chat-pane__header").first(); await expect @@ -332,9 +332,10 @@ suite.define(() => { await expect .poll(() => header.getByRole("button", { name: "Expand sidebar" }).isVisible()) .toBe(true); - await expect - .poll(() => header.getByRole("button", { name: "Open command palette" }).isVisible()) - .toBe(true); + await expect.poll(() => header.locator(".chat-pane__palette-open").count()).toBe(0); + await header.locator(".chat-header-session-menu__trigger").click(); + await page.getByText("Open command palette", { exact: true }).click(); + await page.locator(".cmd-palette__input").waitFor({ state: "visible" }); }); it("keeps the mobile drawer modal, keyboard-contained, and focus-restoring", async () => { diff --git a/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts b/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts index 0c71ff5f1514..6f66ffceb239 100644 --- a/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts +++ b/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts @@ -7,7 +7,7 @@ import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; const artifactDir = path.resolve(".artifacts/control-ui-e2e/plugin-bundled-view-recovery"); -const bundledChunk = /\/assets\/[^/]+\.js(?:\?.*)?$/; +const bundledChunk = /\/assets\/logbook-view-[^/]+\.js(?:\?.*)?$/; const suite = createControlUiE2eSuite({ name: "Control UI bundled plugin lazy-view recovery", diff --git a/ui/src/e2e/update-confirmation.e2e.test.ts b/ui/src/e2e/update-confirmation.e2e.test.ts index 271f560a99ac..1e01b54c5fa7 100644 --- a/ui/src/e2e/update-confirmation.e2e.test.ts +++ b/ui/src/e2e/update-confirmation.e2e.test.ts @@ -27,22 +27,32 @@ function confirmationCopy(page: Page) { return page.locator("openclaw-modal-dialog"); } -async function openUpdateCard(page: Page, baseUrl: string) { +async function openUpdateCard(page: Page, baseUrl: string, compact = false) { const gateway = await installMockGateway(page, { methodResponses: { "update.run": UPDATE_RUN_RESPONSE }, }); expect((await page.goto(`${baseUrl}chat`))?.status()).toBe(200); await gateway.waitForRequest("chat.startup"); await gateway.emitGatewayEvent("update.available", { updateAvailable: UPDATE_AVAILABLE }); + if (compact) { + await page.locator(".chat-header-session-menu__trigger").click(); + const updateButton = page.getByText("Update available v2.0.0", { exact: true }); + await updateButton.waitFor({ timeout: 10_000 }); + return { compact, gateway, updateButton }; + } const updateButton = page.locator( '[data-attention-kind="updateAvailable"] .sidebar-attention__open:visible', ); await updateButton.waitFor({ timeout: 10_000 }); - return { gateway, updateButton }; + return { compact, gateway, updateButton }; } -async function openConfirmationFromAlert(page: Page, updateButton: Locator) { +async function openConfirmationFromAlert(page: Page, updateButton: Locator, compact = false) { await updateButton.click(); + if (compact) { + await page.getByRole("button", { name: "Update now", exact: true }).click(); + return; + } const action = page .locator(".custodian__alert-card") .getByRole("button", { name: "Update and restart", exact: true }); @@ -98,8 +108,13 @@ suite.define(() => { viewport: variant.viewport, }, async ({ page }) => { - const { gateway, updateButton } = await openUpdateCard(page, suite.server.baseUrl); - await openConfirmationFromAlert(page, updateButton); + const compact = variant.viewport.width < 600; + const { gateway, updateButton } = await openUpdateCard( + page, + suite.server.baseUrl, + compact, + ); + await openConfirmationFromAlert(page, updateButton, compact); await page.getByRole("dialog").waitFor(); expect( await confirmationCopy(page) diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index e022504b1b75..8f728e580e00 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -4,7 +4,17 @@ import { buildControlUiResourcePath } from "../../../../src/gateway/control-ui-r import type { GatewaySessionRow } from "../../api/types.ts"; import { isDesktopPanelAvailable } from "../../app/app-shell-chrome.ts"; import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts"; +import { + hasDismissedScopeUpgradeBanner, + readScopeUpgradeAvailability, + SCOPE_UPGRADE_DETAILS_EVENT, +} from "../../app/device-scope-upgrade.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; +import { + formatUpdateCampaignLabel, + formatUpdateTargetLabel, +} from "../../app/update-overlay-helpers.ts"; +import { COMMAND_PALETTE_OPEN_EVENT } from "../../components/command-palette-contract.ts"; import { icons } from "../../components/icons.ts"; import { sessionMenuReasons } from "../../components/session-menu-access.ts"; import { listAssignableSessionOwners } from "../../components/session-owner-chip.ts"; @@ -32,6 +42,7 @@ import type { HeaderMenuAction, HeaderMenuActionKind, HeaderMenuQuickAction, + HeaderMenuStatusAction, } from "./components/chat-header-session-menu.ts"; import { canRevealSessionWorkspace, @@ -68,6 +79,78 @@ export abstract class ChatPaneHeader extends ChatPaneDiscussion { }; } + private compactHeaderStatusActions(): HeaderMenuStatusAction[] { + if (!this.narrow) { + return []; + } + const actions: HeaderMenuStatusAction[] = []; + const scopeState = readScopeUpgradeAvailability(this.context.gateway.snapshot); + const scopeStatusVisible = + scopeState.phase !== "hidden" && + !(scopeState.phase === "guidance" && hasDismissedScopeUpgradeBanner()); + if (scopeStatusVisible) { + actions.push({ + id: "access", + label: t("connection.scopeUpgrade.status"), + icon: icons.shieldQuestion, + tone: "warn", + onActivate: () => window.dispatchEvent(new Event(SCOPE_UPGRADE_DETAILS_EVENT)), + }); + } + + const overlay = this.context.overlays.snapshot; + if (overlay.controlUiRefreshRequired) { + actions.push({ + id: "refresh", + label: `${t("chat.sidebar.serverUpdatedTitle")} · ${t( + "chat.sidebar.serverUpdatedRefresh", + )}`, + icon: icons.refresh, + tone: "info", + onActivate: () => globalThis.location.reload(), + }); + return actions; + } + + const campaignLabel = formatUpdateCampaignLabel(overlay.updateSchedule); + const targetLabel = formatUpdateTargetLabel(overlay.updateSchedule, overlay.updateAvailable); + const updateBusy = overlay.updateRunning || overlay.updateReconciliationPending; + const update = overlay.updateAvailable; + const target = overlay.updateSchedule?.target; + const updateAvailable = Boolean( + update && + !overlay.updateSchedule?.campaign && + !overlay.updateStatusBanner && + (update.latestVersion !== update.currentVersion || + (target?.kind === "git" && target.commitsBehind > 0)), + ); + const updateLabel = overlay.updateStatusBanner?.text + ? overlay.updateStatusBanner.text + : campaignLabel + ? targetLabel + ? t("updates.sidebar.campaignTarget", { status: campaignLabel, target: targetLabel }) + : campaignLabel + : updateBusy + ? t("updates.sidebar.updating") + : updateAvailable + ? t("updates.page.available", { target: targetLabel ?? update?.latestVersion ?? "" }) + : null; + if (updateLabel) { + actions.push({ + id: "update", + label: updateLabel, + icon: overlay.updateStatusBanner + ? icons.alertTriangle + : updateBusy + ? icons.refresh + : icons.download, + tone: overlay.updateStatusBanner?.tone ?? (updateBusy ? "info" : "warn"), + onActivate: () => this.context.navigate("updates"), + }); + } + return actions; + } + protected renderPaneHeader( sessionWorkspace: SessionWorkspaceProps, backgroundTasks: BackgroundTasksProps, @@ -468,6 +551,7 @@ export abstract class ChatPaneHeader extends ChatPaneDiscussion { .settings=${this.state.settings} .panelActions=${panelMenuActions} .layoutActions=${layoutMenuActions} + .statusActions=${this.compactHeaderStatusActions()} .ownerOptions=${ownerOptions} .selfOwner=${selfOwner} .currentOwnerId=${row.owner?.actor.id ?? null} @@ -479,6 +563,8 @@ export abstract class ChatPaneHeader extends ChatPaneDiscussion { .onOpen=${() => { void this.loadHeaderMenuData(row, agentWorkspace, workspaceGit); }} + .onOpenCommandPalette=${() => + window.dispatchEvent(new Event(COMMAND_PALETTE_OPEN_EVENT))} .onSettingsChange=${this.state.applySettings} .onAction=${(action: HeaderMenuAction) => this.handleHeaderSessionAction(action, row)} >` 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 fdce13785a30..04446780ba64 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 @@ -10,6 +10,7 @@ import type { HeaderMenuAction, HeaderMenuActionKind, HeaderMenuQuickAction, + HeaderMenuStatusAction, } from "./chat-header-session-menu.ts"; type HeaderMenuElement = HTMLElement & { updateComplete: Promise }; @@ -50,6 +51,7 @@ async function mountMenu( settings?: UiSettings; panelActions?: HeaderMenuQuickAction[]; layoutActions?: HeaderMenuQuickAction[]; + statusActions?: HeaderMenuStatusAction[]; ownerOptions?: SessionOwnerOption[]; selfOwner?: SessionOwnerOption | null; currentOwnerId?: string | null; @@ -59,6 +61,7 @@ async function mountMenu( archiveAllowed?: boolean; deleteAllowed?: boolean; onOpen?: () => void; + onOpenCommandPalette?: () => void; onSettingsChange?: (patch: Partial) => void; onAction?: (action: HeaderMenuAction) => void; } = {}, @@ -77,6 +80,7 @@ async function mountMenu( .settings=${options.settings ?? settings()} .panelActions=${options.panelActions ?? []} .layoutActions=${options.layoutActions ?? []} + .statusActions=${options.statusActions ?? []} .ownerOptions=${options.ownerOptions ?? []} .selfOwner=${options.selfOwner ?? null} .currentOwnerId=${options.currentOwnerId ?? null} @@ -86,6 +90,7 @@ async function mountMenu( .archiveAllowed=${options.archiveAllowed ?? true} .deleteAllowed=${options.deleteAllowed ?? true} .onOpen=${options.onOpen ?? (() => {})} + .onOpenCommandPalette=${options.onOpenCommandPalette ?? (() => {})} .onSettingsChange=${options.onSettingsChange ?? (() => {})} .onAction=${options.onAction ?? (() => {})} >`, @@ -275,6 +280,8 @@ describe("chat header session menu", () => { it("drills into compact menu groups without rendering side flyouts", async () => { const showTasks = vi.fn(); + const showAccess = vi.fn(); + const onOpenCommandPalette = 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; @@ -299,9 +306,19 @@ describe("chat header session menu", () => { onActivate: vi.fn(), }, ], + statusActions: [ + { + id: "access", + label: "Limited access", + icon: icons.shieldQuestion, + tone: "warn", + onActivate: showAccess, + }, + ], ownerOptions: [ada, research], selfOwner: ada, currentOwnerId: research.id, + onOpenCommandPalette, onSettingsChange, onAction, }); @@ -310,6 +327,8 @@ describe("chat header session menu", () => { menu.querySelectorAll(":scope > wa-dropdown > wa-dropdown-item"), ).map(itemLabel); expect(rootLabels).toEqual([ + "Open command palette", + "Limited access", "Open in", "Panels", "Layout", @@ -322,6 +341,19 @@ describe("chat header session menu", () => { "Delete…", ]); expect(menu.querySelector("[slot='submenu']")).toBeNull(); + expect( + menu.querySelector('.chat-header-session-menu__status-dot[data-tone="warn"]'), + ).not.toBeNull(); + + select(menu, "open-command-palette"); + expect(onOpenCommandPalette).toHaveBeenCalledOnce(); + const dropdown = menu.querySelector("wa-dropdown"); + if (dropdown) { + dropdown.open = true; + } + select(menu, "status:access"); + expect(showAccess).toHaveBeenCalledOnce(); + expect(dropdown?.open).toBe(false); select(menu, "compact:open-view"); await menu.updateComplete; 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 c191045f1748..7c809be28320 100644 --- a/ui/src/pages/chat/components/chat-header-session-menu.ts +++ b/ui/src/pages/chat/components/chat-header-session-menu.ts @@ -32,9 +32,20 @@ export type HeaderMenuQuickAction = { onActivate: () => void; }; +export type HeaderMenuStatusAction = { + id: string; + label: string; + icon: TemplateResult; + tone: "danger" | "warn" | "info"; + onActivate: () => void; +}; + const EMPTY_SETTINGS = {} as UiSettings; type CompactMenuView = "root" | "open-in" | "panels" | "layout" | "assign-owner" | "view"; +type MenuSelectEvent = CustomEvent<{ item: { value?: string } }> & { + currentTarget: HTMLElement & { open: boolean }; +}; const COMPACT_MENU_VIEW_BY_VALUE: Record = { "compact:back": "root", @@ -55,6 +66,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) settings: UiSettings = EMPTY_SETTINGS; @property({ attribute: false }) panelActions: HeaderMenuQuickAction[] = []; @property({ attribute: false }) layoutActions: HeaderMenuQuickAction[] = []; + @property({ attribute: false }) statusActions: HeaderMenuStatusAction[] = []; @property({ attribute: false }) ownerOptions: readonly SessionOwnerOption[] = []; @property({ attribute: false }) selfOwner: SessionOwnerOption | null = null; @property({ attribute: false }) currentOwnerId: string | null = null; @@ -66,6 +78,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) archiveAllowed = false; @property({ attribute: false }) deleteAllowed = false; @property({ attribute: false }) onOpen: () => void = () => {}; + @property({ attribute: false }) onOpenCommandPalette: () => void = () => {}; @property({ attribute: false }) onSettingsChange: (patch: Partial) => void = () => {}; @property({ attribute: false }) onAction: (action: HeaderMenuAction) => void = () => {}; @state() private compactView: CompactMenuView = "root"; @@ -78,7 +91,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { return this.actionDisabledReasons[kind] ?? nothing; } - private readonly handleSelect = (event: CustomEvent<{ item: { value?: string } }>) => { + private readonly handleSelect = (event: MenuSelectEvent) => { const value = event.detail.item.value; if (!value) { return; @@ -92,6 +105,20 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { }); return; } + if (value === "open-command-palette") { + this.onOpenCommandPalette(); + return; + } + if (value.startsWith("status:")) { + const action = this.statusActions.find( + (candidate) => candidate.id === value.slice("status:".length), + ); + if (action) { + event.currentTarget.open = false; + action.onActivate(); + } + return; + } if (value.startsWith("quick:")) { const [, group, id] = value.split(":"); const actions = group === "panels" ? this.panelActions : this.layoutActions; @@ -139,7 +166,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { ) { if (!this.actionDisabled(value, value === "fork" && this.forkDisabled)) { if (value === "continue-in-terminal") { - (event.currentTarget as HTMLElement & { open: boolean }).open = false; + event.currentTarget.open = false; } this.onAction({ kind: value }); } @@ -293,6 +320,31 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { private renderRootView() { return html` + ${this.compact + ? html` + + ${t("chat.openCommandPalette")} + + ` + : nothing} + ${this.compact && this.statusActions.length > 0 + ? html`${this.statusActions.map( + (action) => html` + + ${action.label} + `, + )} + ` + : nothing} ${this.worktreePath ? html` ${this.compact @@ -421,6 +473,10 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { override render() { const menuLabel = t("chat.sidebar.sessionMenu", { session: this.sessionLabel }); + const statusTone = + this.statusActions.find((action) => action.tone === "danger")?.tone ?? + this.statusActions.find((action) => action.tone === "warn")?.tone ?? + this.statusActions[0]?.tone; return html` ${icons.moreHorizontal} + ${this.compact && statusTone + ? html`` + : nothing} ${this.compact && this.compactView !== "root" ? this.renderCompactView() diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 067daea491dd..6acce36df692 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -305,7 +305,7 @@ describe("chat pane header", () => { expect(actions?.querySelector(".chat-pane__close-pane")).not.toBeNull(); }); - it("keeps persistent surface actions in a narrow header", () => { + it("moves narrow session actions into the compact menu", () => { const { container } = mount({ narrow: true, mergedChrome: true, @@ -316,9 +316,10 @@ describe("chat pane header", () => { workspaceAction: html``, sessionRailAction: html``, sessionMenuAction: html``, + onOpenSplitView: vi.fn(), }); - expect(container.querySelector('[data-action="persistent-surface"]')).not.toBeNull(); + expect(container.querySelector('[data-action="persistent-surface"]')).toBeNull(); expect(container.querySelector('[data-action="discussion"]')).toBeNull(); expect(container.querySelector('[data-action="diff"]')).toBeNull(); expect(container.querySelector('[data-action="tasks"]')).toBeNull(); @@ -326,7 +327,8 @@ describe("chat pane header", () => { 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(); + expect(container.querySelector(".chat-pane__palette-open")).toBeNull(); + expect(container.querySelector(".chat-open-split-view")).toBeNull(); }); it("keeps narrow catalog panel shortcuts visible without a session menu", () => { diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index 1f1844dd99cb..46a09e5230a5 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -536,12 +536,13 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { : nothing} ${renderGatewayPicker(props)}
- ${props.panelActions} ${compactSessionActions ? nothing : props.discussionAction} + ${compactSessionActions ? nothing : props.panelActions} + ${compactSessionActions ? nothing : props.discussionAction} ${props.catalog || compactSessionActions ? nothing : html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction} ${props.sessionRailAction}`} - ${props.onOpenSplitView + ${props.onOpenSplitView && !compactSessionActions ? html` +
${t("cron.stats.nextWake")} diff --git a/ui/src/pages/cron/view-auto-disabled.test.ts b/ui/src/pages/cron/view-auto-disabled.test.ts index 2686c3d2f1c0..7a273fda7ca3 100644 --- a/ui/src/pages/cron/view-auto-disabled.test.ts +++ b/ui/src/pages/cron/view-auto-disabled.test.ts @@ -19,9 +19,14 @@ it("labels an auto-disabled job distinctly from an operator pause", () => { const container = renderView({ jobs: [paused, autoDisabled] }); const rows = Array.from(container.querySelectorAll(".cron-table__row")); expect(rows[0]?.textContent).toContain("Paused"); + expect(rows[0]?.querySelector(".cron-table__state--paused")?.getAttribute("aria-label")).toBe( + "Paused", + ); const note = rows[1]?.querySelector("[data-test-id='cron-row-auto-disabled-job-auto']"); expect(note?.textContent?.trim()).toBe("Auto-disabled · 10 run failures"); expect(note?.getAttribute("title")).toBe("provider exploded"); - // Escalated failure keeps the error dot even though the job is disabled. - expect(rows[1]?.querySelector(".cron-table__dot--error")).not.toBeNull(); + // Escalated failure keeps a visible error marker even though the job is disabled. + expect(rows[1]?.querySelector(".cron-table__state--error")?.getAttribute("aria-label")).toBe( + "Auto-disabled · 10 run failures", + ); }); diff --git a/ui/src/pages/cron/view-description.test.ts b/ui/src/pages/cron/view-description.test.ts index e37943e22b05..e7fd1abd5461 100644 --- a/ui/src/pages/cron/view-description.test.ts +++ b/ui/src/pages/cron/view-description.test.ts @@ -26,7 +26,7 @@ describe("cron view saved descriptions", () => { ); expect(description).toBeInstanceOf(HTMLSpanElement); - expect(description?.textContent?.trim()).toBe("· Summarize overnight deployment activity"); + expect(description?.textContent?.trim()).toBe("Summarize overnight deployment activity"); expect(description?.title).toBe("Description: Summarize overnight deployment activity"); description?.click(); expect(onSelectJob).toHaveBeenCalledWith(job); diff --git a/ui/src/pages/cron/view-running-state.test.ts b/ui/src/pages/cron/view-running-state.test.ts index 467fea2a735d..992dba08b188 100644 --- a/ui/src/pages/cron/view-running-state.test.ts +++ b/ui/src/pages/cron/view-running-state.test.ts @@ -13,5 +13,8 @@ it("shows Running instead of a past-due next-run time while a run executes", () const container = renderView({ jobs: [running] }); const row = container.querySelector(".cron-table__row"); expect(row?.querySelector(".cron-table__running")?.textContent).toBe("Running"); + expect(row?.querySelector(".cron-table__state--running")?.getAttribute("aria-label")).toBe( + "Running", + ); expect(row?.textContent).not.toContain("ago"); }); diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index b79e2c425a72..fed8803ff32b 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -137,7 +137,9 @@ describe("cron view list pane", () => { expect(rows[0]?.textContent).toContain("Cron 0 9 * * *"); expect(rows[1]?.classList.contains("cron-table__row--paused")).toBe(true); expect(rows[1]?.textContent).toContain("Paused"); - expect(rows[2]?.querySelector(".cron-table__dot--error")).not.toBeNull(); + expect(rows[2]?.querySelector(".cron-table__state--error")?.getAttribute("aria-label")).toBe( + "Error", + ); expect(rows[2]?.querySelector(".cron-last-glyph--error")).not.toBeNull(); expect(rows[2]?.querySelector(".cron-table__last-run")?.getAttribute("aria-label")).toBe( "Error", diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index d9702f0587c1..5f3cb7b80067 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -436,7 +436,10 @@ export function renderCron(props: CronProps) { function renderAdminRequired(props: CronProps) { return props.canManage ? nothing - : html`
${t("cron.adminRequired")}
`; + : html`
+ + ${t("cron.adminRequired")} +
`; } // ── List view ── @@ -474,17 +477,23 @@ function renderListView(props: CronProps) { !hasAnyJobsFilters && props.canManage; const children = [ - renderSettingsSection({}, renderCronStats(props)), - renderAdminRequired(props), - props.status && !props.status.enabled - ? html` -
- ${t("cron.list.schedulerOff")} ${t("cron.runNotStarted.stopped")} -
- ` - : nothing, - props.error ? html`
${props.error}
` : nothing, - renderToolbar(props, hasAdvancedJobsFilters), + html` +
+
+ ${renderCronStats(props)} ${renderAdminRequired(props)} +
+ ${props.status && !props.status.enabled + ? html` +
+ ${t("cron.list.schedulerOff")} + ${t("cron.runNotStarted.stopped")} +
+ ` + : nothing} + ${props.error ? html`
${props.error}
` : nothing} + ${renderToolbar(props, hasAdvancedJobsFilters)} +
+ `, html`
- ${renderListTabs(props)} +
+ ${renderListTabs(props)} +
+ + ${props.canManage + ? html` + + ` + : nothing} +
+
${props.listTab === "tasks" ? html` - ${renderSegmented({ - value: props.jobsEnabledFilter, - options: ENABLED_TABS.map((tab) => ({ - value: tab.value, - label: t(tab.labelKey), - testId: `cron-tab-${tab.value}`, - })), - ariaLabel: t("cron.tabs.filterLabel"), - onChange: (value) => void props.onJobsFiltersChange({ cronJobsEnabledFilter: value }), - })} - `; } @@ -713,13 +727,13 @@ function renderJobsFilterPopover(props: CronProps, active: boolean) { function renderJobsTable(props: CronProps, hasAnyJobsFilters: boolean) { return html` -
+
${t("cron.jobs.name")} ${t("cron.jobs.schedule")} ${t("cron.jobs.nextRun")} ${t("cron.jobs.lastRun")} - + ${props.canManage ? html`` : nothing}
${props.jobs.length === 0 ? html` @@ -753,11 +767,11 @@ function renderJobRow(job: CronJob, props: CronProps) { const description = job.description?.trim(); const nextRunAtMs = job.state?.nextRunAtMs; const hasNextRun = typeof nextRunAtMs === "number" && Number.isFinite(nextRunAtMs); - const dotVariant = isCronJobActiveFailure(job) - ? "cron-table__dot--error" - : job.enabled - ? "cron-table__dot--active" - : ""; + const nextRun = isCronJobRunning(job) + ? html`${t("cron.runs.runStatusRunning")}` + : hasNextRun + ? formatRelativeTimestamp(nextRunAtMs) + : t("common.na"); return html`
- - ${job.name} - ${description - ? html` - · ${description} - ` - : nothing} - ${job.trigger ? renderTriggerIndicator() : nothing} - ${job.enabled ? nothing : renderDisabledNote(job)} + ${renderJobStateIndicator(job)} + + + ${job.name} + ${job.trigger ? renderTriggerIndicator() : nothing} + + ${description || !job.enabled + ? html` + + ${description + ? html` + ${description} + ` + : nothing} + ${description && !job.enabled + ? html`` + : nothing} + ${job.enabled ? nothing : renderDisabledNote(job)} + + ` + : nothing} + - ${formatCronSchedule(job)} - - ${isCronJobRunning(job) - ? html`${t("cron.runs.runStatusRunning")}` - : hasNextRun - ? formatRelativeTimestamp(nextRunAtMs) - : t("common.na")} - - ${renderLastRunCell(job)} - e.stopPropagation()} - @keydown=${(e: Event) => e.stopPropagation()} - > - ${props.canManage - ? html` + ${renderJobCell("cron-table__schedule", t("cron.jobs.schedule"), formatCronSchedule(job))} + ${renderJobCell("cron-table__next", t("cron.jobs.nextRun"), nextRun)} + ${renderJobCell("cron-table__last", t("cron.jobs.lastRun"), renderLastRunCell(job))} + ${props.canManage + ? html` + e.stopPropagation()} + @keydown=${(e: Event) => e.stopPropagation()} + >
`; } +function renderJobCell(className: string, label: string, value: unknown) { + return html` + ${label} + ${value} + `; +} + +function renderJobStateIndicator(job: CronJob) { + const autoDisabled = job.state?.autoDisabled; + const state = isCronJobRunning(job) + ? { + className: "cron-table__state--running", + iconName: "loader" as const, + label: t("cron.runs.runStatusRunning"), + } + : autoDisabled + ? { + className: "cron-table__state--error", + iconName: "lock" as const, + label: disabledNoteLabel(job), + } + : isCronJobActiveFailure(job) + ? { + className: "cron-table__state--error", + iconName: "alertTriangle" as const, + label: t("cron.runs.runStatusError"), + } + : !job.enabled + ? { + className: "cron-table__state--paused", + iconName: "pause" as const, + label: t("cron.list.paused"), + } + : { + className: "cron-table__state--active", + iconName: null, + label: t("cron.detail.active"), + }; + return html`${state.iconName + ? icon(state.iconName) + : html``}`; +} + function renderTriggerIndicator() { const label = t("cron.form.triggerConfigured"); return html`${t("cron.list.paused")}`; } - const label = t( - autoDisabled.reason === "schedule-errors" - ? "cron.list.autoDisabledScheduleErrors" - : "cron.list.autoDisabledRunFailures", - { count: String(autoDisabled.consecutiveErrors) }, - ); + const label = disabledNoteLabel(job); const lastError = job.state?.lastError?.trim(); return html``; } +function disabledNoteLabel(job: CronJob) { + const autoDisabled = job.state?.autoDisabled; + if (!autoDisabled) { + return t("cron.list.paused"); + } + return t( + autoDisabled.reason === "schedule-errors" + ? "cron.list.autoDisabledScheduleErrors" + : "cron.list.autoDisabledRunFailures", + { count: String(autoDisabled.consecutiveErrors) }, + ); +} + function renderLastRunCell(job: CronJob) { const status = resolveCronJobLastRunStatus(job); const lastRunAtMs = job.state?.lastRunAtMs; diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index c808cc0ac32b..ab0d00741063 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -15,81 +15,72 @@ overflow: visible; } -/* ── Stat grid (escape hatch inside one group) ── */ +/* ── Compact overview chrome ── */ + +.cron-page[data-panel-mode="overview"] .settings-page { + gap: var(--space-4); +} + +.cron-overview-header { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; +} + +.cron-overview-summary { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} .cron-stats { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-4); - padding: var(--space-3) var(--space-4); + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + color: var(--muted); + font-size: var(--control-ui-text-sm); } .cron-stat { display: flex; - flex-direction: column; - gap: var(--space-2); + align-items: baseline; + gap: var(--space-1); min-width: 0; } -/* Failing stat is a drill-down into run history filtered to errors. It sits - inside the stats group, so it is a bare button (no card chrome). */ +/* Failing is still a drill-down into run history, but reads as one fact in the + summary rather than a third competing card. */ .cron-stat--action { position: relative; - text-align: left; + display: inline-flex; + align-items: baseline; + gap: var(--space-1); font: inherit; color: inherit; cursor: var(--cursor-action); border: 0; background: transparent; - margin: calc(-1 * var(--space-2)); - padding: var(--space-2); - border-radius: var(--radius-md); - transition: background var(--duration-fast) var(--ease-out); + padding: 2px; + margin: -2px; + border-radius: var(--radius-sm); + transition: color var(--duration-fast) var(--ease-out); } .cron-stat--action:hover { - background: var(--bg-hover); -} - -.cron-stat__go { - position: absolute; - top: 50%; - right: var(--space-3); - transform: translateY(-50%); - display: inline-flex; - color: var(--muted); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-out); -} - -.cron-stat__go svg { - width: 14px; - height: 14px; - stroke: currentColor; - fill: none; -} - -.cron-stat--action:hover .cron-stat__go, -.cron-stat--action:focus-visible .cron-stat__go { - opacity: 1; + color: var(--text-strong); } .cron-stat__label { - color: var(--muted); - font-size: var(--control-ui-text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; + color: inherit; } .cron-stat__value { color: var(--text-strong); - font-size: 22px; + font-size: inherit; font-weight: 650; - line-height: 1.1; - min-height: 24px; - display: flex; - align-items: center; } .cron-stat__value--danger { @@ -97,13 +88,29 @@ } .cron-stat__value--time { - font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - display: block; - line-height: 24px; +} + +.cron-stat__separator { + color: var(--border-strong); +} + +.cron-admin-note { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-width: 0; + margin-left: auto; + color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.cron-admin-note svg { + width: 12px; + height: 12px; } /* Tab panel wrapping the tab-dependent sections; keeps the settings-page @@ -111,17 +118,30 @@ .cron-tab-panel { display: flex; flex-direction: column; - gap: var(--space-7); + gap: var(--space-4); min-width: 0; } -/* ── One-row list toolbar (view switch, filters, refresh + New automation) ── */ +/* ── List toolbar ── */ .cron-toolbar { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.cron-toolbar__primary, +.cron-toolbar__filters { display: flex; align-items: center; gap: var(--space-2); - flex-wrap: wrap; + width: 100%; + min-width: 0; +} + +.cron-toolbar__primary { + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); } /* Toolbar buttons share the settings control height with the adjacent @@ -134,9 +154,8 @@ .cron-search-box { position: relative; - flex: 1 1 220px; - min-width: 180px; - max-width: 420px; + flex: 1 1 auto; + min-width: 0; } .cron-search-box__icon { @@ -263,13 +282,20 @@ /* ── Jobs table (escape hatch inside the tasks group) ── */ +.cron-table { + --cron-table-columns: minmax(220px, 1.8fr) minmax(160px, 1.05fr) minmax(110px, 0.7fr) + minmax(130px, 0.8fr) 116px; +} + +.cron-table--read-only { + --cron-table-columns: minmax(220px, 1.8fr) minmax(160px, 1.05fr) minmax(110px, 0.7fr) + minmax(130px, 0.8fr); +} + .cron-table__head, .cron-table__row { display: grid; - grid-template-columns: minmax(180px, 1.6fr) minmax(150px, 1.1fr) minmax(110px, 0.8fr) minmax( - 140px, - 0.9fr - ) 116px; + grid-template-columns: var(--cron-table-columns); gap: var(--space-3); align-items: center; padding: 0 var(--space-4); @@ -309,7 +335,7 @@ .cron-table__name { display: flex; - align-items: center; + align-items: flex-start; gap: 10px; min-width: 0; color: var(--text-strong); @@ -317,33 +343,84 @@ font-weight: 600; } -.cron-table__dot { +.cron-table__state { flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-top: 2px; + color: var(--muted); +} + +.cron-table__state svg { + width: 13px; + height: 13px; + stroke: currentColor; + fill: none; +} + +.cron-table__state-dot { width: 8px; height: 8px; border-radius: 999px; - background: var(--muted); - opacity: 0.5; -} - -.cron-table__dot--active { background: var(--ok); - opacity: 1; } -.cron-table__dot--error { - background: var(--danger); - opacity: 1; +.cron-table__state--running { + color: var(--accent-2); +} + +.cron-table__state--running svg { + animation: cron-state-spin 1s linear infinite; +} + +.cron-table__state--error { + color: var(--danger); +} + +.cron-table__state--paused { + color: var(--muted); +} + +@keyframes cron-state-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .cron-table__state--running svg { + animation: none; + } +} + +.cron-table__name-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 auto; +} + +.cron-table__name-line, +.cron-table__name-meta { + display: flex; + align-items: center; + gap: var(--space-1); + min-width: 0; } .cron-table__name-text { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cron-table__description { - flex: 1 1 0; + flex: 0 1 auto; min-width: 0; overflow: hidden; color: var(--muted); @@ -358,8 +435,12 @@ font-size: var(--control-ui-text-xs); } +.cron-table__meta-separator { + color: var(--muted); +} + .cron-table__running { - color: var(--accent); + color: var(--accent-2); } .cron-table__auto-disabled { @@ -375,6 +456,17 @@ white-space: nowrap; } +.cron-table__cell-label { + display: none; +} + +.cron-table__cell-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .cron-table__last { display: flex; align-items: center; @@ -1107,41 +1199,97 @@ /* ── Responsive ── */ @media (max-width: 900px) { - /* Stack table rows into label-free cards; hide the head and secondary cells. */ + /* Keep every table fact, but label and arrange it as a compact card. */ .cron-table__head { display: none; } .cron-table__row { - grid-template-columns: minmax(0, 1fr) 116px; - row-gap: 4px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2) var(--space-3); + padding-top: var(--space-3); + padding-bottom: var(--space-3); } .cron-table__name { - grid-column: 1; + grid-column: 1 / -1; } - .cron-table__actions { - grid-column: 2; - grid-row: 1; + .cron-table__schedule { + grid-column: 1 / -1; } .cron-table__cell { - grid-column: 1; + display: flex; + align-items: baseline; + gap: var(--space-1); color: var(--muted); font-size: var(--control-ui-text-sm); + white-space: normal; + } + + .cron-table__cell-label { + display: inline; + flex: 0 0 auto; + color: var(--muted); + font-size: var(--control-ui-text-xs); + font-weight: 600; + } + + .cron-table__cell-label::after { + content: ":"; + } + + .cron-table__cell-value { + white-space: normal; + overflow-wrap: anywhere; + } + + .cron-table__actions { + grid-column: 1 / -1; + justify-content: flex-start; + padding-left: 24px; } } @media (max-width: 560px) { .cron-stats { - grid-template-columns: 1fr; + flex-wrap: wrap; + } + + .cron-overview-summary { + align-items: flex-start; + flex-direction: column; + gap: var(--space-2); + } + + .cron-admin-note { + margin-left: 0; + } + + .cron-toolbar__filters { + flex-wrap: wrap; } .cron-search-box { - max-width: none; flex-basis: 100%; } + + .cron-filter-popover__trigger { + margin-left: auto; + } + + .cron-new-task { + width: 32px; + padding: 0; + justify-content: center; + font-size: 0; + } + + .cron-new-task svg { + width: 14px; + height: 14px; + } } /* Web Awesome popup surfaces own positioning, dismissal, and focus. */ From ff83e3efe811d561aee0e8d7cd38047294b1cb58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Fri, 21 Aug 2026 17:22:57 +0300 Subject: [PATCH 005/124] test(extensions): close cached agent databases before removing fixture state dirs (#126352) Doctor migrations and auth-profile writes open per-agent and shared state databases under the fixture's temporary directory. Clearing the plugin state store or the runtime auth snapshots does not release those handles, so Windows fails the directory removal with EBUSY while Linux unlinks the open files and stays green. Close the cached databases before each removal, matching the ordering the zalouser and zalo fixtures already use. --- extensions/acpx/doctor-contract-api.test.ts | 1 + .../active-memory/doctor-contract-api.test.ts | 1 + .../alibaba/video-generation-provider.test.ts | 5 ++ extensions/codex/doctor-contract-api.test.ts | 61 +++++++++++-------- .../device-pair/doctor-contract-api.test.ts | 1 + .../memory-core/doctor-contract-api.test.ts | 1 + .../msteams/doctor-contract-api.test.ts | 1 + .../openai/video-generation-provider.test.ts | 5 ++ 8 files changed, 49 insertions(+), 27 deletions(-) diff --git a/extensions/acpx/doctor-contract-api.test.ts b/extensions/acpx/doctor-contract-api.test.ts index bfa404bddad5..ad9263fe7617 100644 --- a/extensions/acpx/doctor-contract-api.test.ts +++ b/extensions/acpx/doctor-contract-api.test.ts @@ -105,6 +105,7 @@ describe("acpx doctor state migration", () => { }); afterEach(async () => { + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/active-memory/doctor-contract-api.test.ts b/extensions/active-memory/doctor-contract-api.test.ts index b0716b7ef474..0fc3e640bde1 100644 --- a/extensions/active-memory/doctor-contract-api.test.ts +++ b/extensions/active-memory/doctor-contract-api.test.ts @@ -65,6 +65,7 @@ describe("active-memory doctor state migration", () => { afterEach(async () => { vi.useRealTimers(); + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/alibaba/video-generation-provider.test.ts b/extensions/alibaba/video-generation-provider.test.ts index 3a0dfbbba0b0..56e44a4717d4 100644 --- a/extensions/alibaba/video-generation-provider.test.ts +++ b/extensions/alibaba/video-generation-provider.test.ts @@ -22,6 +22,7 @@ import { mockSuccessfulDashscopeVideoTask, } from "openclaw/plugin-sdk/provider-test-contracts"; // Alibaba tests cover video generation provider plugin behavior. +import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { DASHSCOPE_WAN_VIDEO_MODELS, @@ -276,6 +277,10 @@ describe("alibaba video generation provider", () => { expect(alibabaVideoGenerationProvider.isConfigured?.({ cfg: {}, agentDir })).toBe(expected); } finally { clearRuntimeAuthProfileStoreSnapshots(); + // Saving the profile store opens the per-agent database under the temporary agent + // dir, and clearing the snapshots does not release it, so Windows fails the removal + // with EBUSY unless the cached handles are closed first. + closeOpenClawAgentDatabasesForTest(); await fs.rm(agentDir, { force: true, recursive: true }); } }); diff --git a/extensions/codex/doctor-contract-api.test.ts b/extensions/codex/doctor-contract-api.test.ts index f022791762ba..e1411564c755 100644 --- a/extensions/codex/doctor-contract-api.test.ts +++ b/extensions/codex/doctor-contract-api.test.ts @@ -11,6 +11,7 @@ import type { PluginDoctorStateMigrationContext, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, describe, expect, it } from "vitest"; import { legacyConfigRules, @@ -59,6 +60,16 @@ function openBindingStore(env: NodeJS.ProcessEnv) { }); } +async function removeCodexDoctorFixture(stateDir: string): Promise { + // Doctor migrations open per-agent databases and leave the shared state database open under + // the temporary state dir; both must be released before removal or Windows keeps the files + // locked and the removal fails with EBUSY. Agent close first: it releases leases through + // shared state, so the reverse order can reopen it. + closeOpenClawAgentDatabasesForTest(); + resetPluginStateStoreForTests(); + await fs.rm(stateDir, { recursive: true, force: true }); +} + async function createBindingMigrationFixture(options: { binding?: Record; legacySharedRoot?: boolean; @@ -272,7 +283,7 @@ describe("codex doctor contract", () => { }), ).toMatchObject({ agentHarnessId: "codex" }); } finally { - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); } }); @@ -357,7 +368,7 @@ describe("codex doctor contract", () => { fs.readFile(fixture.storePath, "utf8").then(JSON.parse), ).resolves.not.toHaveProperty("agent:main:session-1.agentHarnessId"); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each([ @@ -396,7 +407,7 @@ describe("codex doctor contract", () => { await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("migrates a shared-root binding to the configured system agent", async () => { @@ -448,7 +459,7 @@ describe("codex doctor contract", () => { ).toMatchObject({ agentHarnessId: "codex" }); await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined(); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("keeps an agent-scoped shared-root binding with its explicit owner", async () => { @@ -492,7 +503,7 @@ describe("codex doctor contract", () => { ).resolves.toMatchObject({ sessionId: "explicit-ops-owner" }); await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined(); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("bounds oversized legacy fingerprints before plugin-state import", async () => { @@ -560,7 +571,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("normalizes a partial raw conversation import before copying the session row", async () => { @@ -638,7 +649,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("normalizes retained raw conversation and session rows before comparison", async () => { @@ -710,7 +721,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("rejects an explicit session file locator outside the session directory", async () => { @@ -743,7 +754,7 @@ describe("codex doctor contract", () => { ).toBeUndefined(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("deduplicates session-store aliases before classifying binding ownership", async () => { @@ -795,7 +806,7 @@ describe("codex doctor contract", () => { expect(configuredIndex["agent:main:aliased-store"]).not.toHaveProperty("agentHarnessId"); expect(targetIndex["agent:main:aliased-store"]).not.toHaveProperty("agentHarnessId"); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("resolves relative session files from a symlinked store path", async () => { @@ -847,7 +858,7 @@ describe("codex doctor contract", () => { `${sessionKey}.agentHarnessId`, ); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each([ @@ -929,7 +940,7 @@ describe("codex doctor contract", () => { retired: true, }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }, ); @@ -986,7 +997,7 @@ describe("codex doctor contract", () => { retired: true, }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("does not resurrect a retired session generation from its legacy sidecar", async () => { @@ -1041,7 +1052,7 @@ describe("codex doctor contract", () => { fs.readFile(path.join(fixture.sessionsDir, "sessions.json"), "utf8").then(JSON.parse), ).resolves.not.toHaveProperty(`${sessionKey}.agentHarnessId`); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each(["active", "cleared"] as const)( @@ -1081,7 +1092,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }, ); @@ -1106,7 +1117,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).rejects.toThrow(); await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined(); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("retains a zero-owner sidecar when canonical plugin state is malformed", async () => { @@ -1135,7 +1146,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined(); await expect(store.lookup(bindingKey)).resolves.toEqual(malformed); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("retains mixed Codex and foreign ambiguous binding owners", async () => { @@ -1164,7 +1175,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("retains a sidecar owned by a foreign harness without importing plugin state", async () => { @@ -1188,7 +1199,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each([ @@ -1230,10 +1241,8 @@ describe("codex doctor contract", () => { await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await Promise.all([ - fs.rm(fixture.stateDir, { recursive: true, force: true }), - fs.rm(externalDir, { recursive: true, force: true }), - ]); + await removeCodexDoctorFixture(fixture.stateDir); + await fs.rm(externalDir, { recursive: true, force: true }); }); it("does not scan above stateDir or follow escaped external store locators", async () => { @@ -1284,10 +1293,8 @@ describe("codex doctor contract", () => { await expect(migration.detectLegacyState(params)).resolves.toBeNull(); - await Promise.all([ - fs.rm(outerDir, { recursive: true, force: true }), - fs.rm(outsideDir, { recursive: true, force: true }), - ]); + await removeCodexDoctorFixture(outerDir); + await fs.rm(outsideDir, { recursive: true, force: true }); }); it("renames old approval-routed destructive plugin policy values", () => { diff --git a/extensions/device-pair/doctor-contract-api.test.ts b/extensions/device-pair/doctor-contract-api.test.ts index 2b3a92dccfe0..919f5c9b0e5f 100644 --- a/extensions/device-pair/doctor-contract-api.test.ts +++ b/extensions/device-pair/doctor-contract-api.test.ts @@ -43,6 +43,7 @@ describe("device-pair doctor notify migration", () => { }); afterEach(async () => { + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/memory-core/doctor-contract-api.test.ts b/extensions/memory-core/doctor-contract-api.test.ts index 90d08a55a737..38e45c064bf6 100644 --- a/extensions/memory-core/doctor-contract-api.test.ts +++ b/extensions/memory-core/doctor-contract-api.test.ts @@ -488,6 +488,7 @@ describe("memory-core doctor dreaming migration", () => { afterEach(async () => { resetMemoryCoreDreamingStateForTests(); + resetPluginStateStoreForTests(); await fs.rm(rootDir, { recursive: true, force: true }); }); diff --git a/extensions/msteams/doctor-contract-api.test.ts b/extensions/msteams/doctor-contract-api.test.ts index 0c66fde14c69..9d19d44418eb 100644 --- a/extensions/msteams/doctor-contract-api.test.ts +++ b/extensions/msteams/doctor-contract-api.test.ts @@ -83,6 +83,7 @@ describe("msteams doctor state migration", () => { }); afterEach(async () => { + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/openai/video-generation-provider.test.ts b/extensions/openai/video-generation-provider.test.ts index d50989b5cbcc..a09f37cb051b 100644 --- a/extensions/openai/video-generation-provider.test.ts +++ b/extensions/openai/video-generation-provider.test.ts @@ -13,6 +13,7 @@ import { installProviderHttpMockCleanup, } from "openclaw/plugin-sdk/provider-http-test-mocks"; import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts"; +import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { beforeAll, describe, expect, it, vi } from "vitest"; const { @@ -171,6 +172,10 @@ describe("openai video generation provider", () => { } else { process.env.OPENAI_API_KEY = previousOpenAIKey; } + // Saving the profile store opens the per-agent database under the temporary agent + // dir, and clearing the snapshots does not release it, so Windows fails the removal + // with EBUSY unless the cached handles are closed first. + closeOpenClawAgentDatabasesForTest(); fs.rmSync(agentDir, { recursive: true, force: true }); } }); From 2cbfade48b617ef5dea7e8ef47737959d0918009 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 07:31:58 -0700 Subject: [PATCH 006/124] fix(ui): copy task results through clipboard fallback (#127181) Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b Co-authored-by: Amp --- ui/src/lib/clipboard.test.ts | 21 +++ ui/src/lib/clipboard.ts | 10 +- ui/src/pages/tasks/tasks-page.test.ts | 46 ++++++ ui/src/pages/tasks/tasks-page.ts | 27 +++- ui/src/pages/tasks/tasks.e2e.test.ts | 225 +++++++++++++++++++++++++- ui/src/pages/tasks/view.ts | 6 +- 6 files changed, 320 insertions(+), 15 deletions(-) diff --git a/ui/src/lib/clipboard.test.ts b/ui/src/lib/clipboard.test.ts index e3424ae2ff75..82bacd9b4c76 100644 --- a/ui/src/lib/clipboard.test.ts +++ b/ui/src/lib/clipboard.test.ts @@ -46,6 +46,27 @@ describe("copyToClipboard", () => { expect(document.querySelector("textarea")).toBeNull(); }); + it("skips fallback when the caller retires a rejected async write", async () => { + let rejectWrite: ((reason?: unknown) => void) | undefined; + const writeText = vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectWrite = reject; + }), + ); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const exec = mockExecCommand(true); + let current = true; + + const copy = copyToClipboard("hello", () => current); + current = false; + rejectWrite?.(new Error("denied")); + + expect(await copy).toBe(false); + expect(exec).not.toHaveBeenCalled(); + expect(document.querySelector("textarea")).toBeNull(); + }); + it("falls back to execCommand over plain HTTP where navigator.clipboard is undefined", async () => { vi.stubGlobal("navigator", {}); const exec = mockExecCommand(true); diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts index 57784c85c6c3..8e2a2b983979 100644 --- a/ui/src/lib/clipboard.ts +++ b/ui/src/lib/clipboard.ts @@ -5,7 +5,10 @@ // is undefined, so calling it throws synchronously rather than rejecting. Guard // the secure-context path and fall back to the legacy execCommand copy so the // copy buttons keep working over HTTP. Returns whether the copy succeeded. -export async function copyToClipboard(text: string): Promise { +export async function copyToClipboard( + text: string, + shouldFallback?: () => boolean, +): Promise { if (!text) { return false; } @@ -18,6 +21,11 @@ export async function copyToClipboard(text: string): Promise { // fall through to the execCommand path before giving up. } } + // A rejected async write can settle after newer caller-owned work. Let that + // owner retire this second transport attempt before it mutates the clipboard. + if (shouldFallback && !shouldFallback()) { + return false; + } return copyWithExecCommand(text); } diff --git a/ui/src/pages/tasks/tasks-page.test.ts b/ui/src/pages/tasks/tasks-page.test.ts index 3054dd085365..9d1f626df0a7 100644 --- a/ui/src/pages/tasks/tasks-page.test.ts +++ b/ui/src/pages/tasks/tasks-page.test.ts @@ -10,8 +10,10 @@ type TasksPageTestElement = HTMLElement & { context: ApplicationContext; tasks: TaskSummary[]; error: string | null; + copyResultError: string | null; cancellingTaskIds: Set; cancelTask: (taskId: string) => Promise; + copyTaskResult: (taskId: string) => Promise; recoverTask: (taskId: string, action: "retry" | "dismiss") => Promise; refreshTasks: () => Promise; }; @@ -162,6 +164,7 @@ function createContext( afterEach(() => { document.body.replaceChildren(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("TasksPage concurrent refresh events", () => { @@ -447,6 +450,49 @@ describe("TasksPage active pagination", () => { }); describe("TasksPage cancellation lifecycle", () => { + it("does not clear an unrelated task error when a result copy succeeds", async () => { + const blocked = createTask("task-copy-independent-error", "completed", { + deliveryStatus: "failed", + terminalOutcome: "blocked", + }); + const clipboardWrite = deferred(); + const writeText = vi.fn(() => clipboardWrite.promise); + const request = vi.fn((method: string) => { + if (method === "tasks.get") { + return Promise.resolve({ task: { ...blocked, result: "Retained result" } }); + } + if (method === "tasks.retry") { + return Promise.resolve({ + results: [ + { + taskId: blocked.taskId, + ok: false, + reason: "Independent recovery failed", + }, + ], + }); + } + return Promise.resolve({ tasks: [blocked] }); + }); + const source = createGateway({ request } as unknown as GatewayBrowserClient); + const page = document.createElement("openclaw-tasks-page") as TasksPageTestElement; + page.context = createContext(source.gateway); + document.body.append(page); + await waitForFast(() => expect(page.tasks).toHaveLength(1)); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + const copying = page.copyTaskResult(blocked.taskId); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith("Retained result")); + await page.recoverTask(blocked.taskId, "retry"); + expect(page.error).toBe("Independent recovery failed"); + + clipboardWrite.resolve(undefined); + await copying; + + expect(page.error).toBe("Independent recovery failed"); + expect(page.copyResultError).toBeNull(); + }); + it("lets a read-only operator copy a retained result without mutation controls", async () => { const retained = createTask("task-read-only-retained", "completed", { deliveryStatus: "failed", diff --git a/ui/src/pages/tasks/tasks-page.ts b/ui/src/pages/tasks/tasks-page.ts index 8b9ead6fd442..d60377df8f22 100644 --- a/ui/src/pages/tasks/tasks-page.ts +++ b/ui/src/pages/tasks/tasks-page.ts @@ -9,6 +9,7 @@ import { hasOperatorReadAccess, hasOperatorWriteAccess } from "../../app/operato import { renderAgentScopeControl } from "../../components/agent-scope-control.ts"; import { t } from "../../i18n/index.ts"; import { watchAgentScope } from "../../lib/agents/index.ts"; +import { copyToClipboard } from "../../lib/clipboard.ts"; import { formatUiError, formatUiExternalText } from "../../lib/format-error.ts"; import { findUiSessionRow, @@ -99,14 +100,17 @@ class TasksPage extends OpenClawLightDomElement { @state() private tasks: TaskSummary[] = []; @state() private error: string | null = null; + @state() private copyResultError: string | null = null; @state() private cancellingTaskIds = new Set(); private taskRefreshEvents: TaskRefreshEventBuffer | null = null; + private copyResultAttempt = 0; private readonly gateway = new GatewayPageController(this, { getGateway: () => this.context?.gateway, onIdentityChange: () => { this.tasks = []; this.error = null; + this.copyResultError = null; }, invalidateRequests: () => this.cancelGatewayWork(), onSnapshot: () => { @@ -238,6 +242,8 @@ class TasksPage extends OpenClawLightDomElement { ); override disconnectedCallback() { + this.copyResultAttempt += 1; + this.copyResultError = null; this.subscriptions.clear(); super.disconnectedCallback(); } @@ -245,6 +251,8 @@ class TasksPage extends OpenClawLightDomElement { private cancelGatewayWork() { // Reconnects may reuse the client object; the epoch keeps pre-disconnect // cancellation responses from mutating the replacement task snapshot. + this.copyResultAttempt += 1; + this.copyResultError = null; this.taskRefreshEvents = null; void this.listTask.run([null, null, null]); this.cancellingTaskIds = new Set(); @@ -258,6 +266,7 @@ class TasksPage extends OpenClawLightDomElement { } const scopeId = this.context.agentSelection.state.scopeId; this.error = null; + this.copyResultError = null; return this.listTask.run([gateway, client, scopeId]); } @@ -353,6 +362,7 @@ class TasksPage extends OpenClawLightDomElement { } private async copyTaskResult(taskId: string) { + const attempt = ++this.copyResultAttempt; const scope = this.gateway.capture(); const gateway = this.gateway.gateway; if (!scope || !gateway || this.context.gateway !== gateway) { @@ -360,18 +370,24 @@ class TasksPage extends OpenClawLightDomElement { } try { const detail = normalizeTasksGetResult(await scope.client.request("tasks.get", { taskId })); - if (!this.gateway.isCurrent(scope)) { + if (!this.gateway.isCurrent(scope) || attempt !== this.copyResultAttempt) { return; } const result = detail?.result ?? detail?.progressSummary; if (!result) { - this.error = t("tasksPage.recoveryFailed"); + this.copyResultError = t("tasksPage.recoveryFailed"); return; } - await navigator.clipboard.writeText(result); + const copied = await copyToClipboard( + result, + () => this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt, + ); + if (this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt) { + this.copyResultError = copied ? null : t("common.copyFailed"); + } } catch (error) { - if (this.gateway.isCurrent(scope)) { - this.error = formatUiError(error, t("tasksPage.recoveryFailed")); + if (this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt) { + this.copyResultError = formatUiError(error, t("tasksPage.recoveryFailed")); } } } @@ -413,6 +429,7 @@ class TasksPage extends OpenClawLightDomElement { canCancel: hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null), loading: this.listTask.status === TaskStatus.PENDING, error: this.error, + copyResultError: this.copyResultError, tasks: this.tasks, cancellingTaskIds: this.cancellingTaskIds, sessionRow: (sessionKey) => findUiSessionRow(this.context, sessionKey), diff --git a/ui/src/pages/tasks/tasks.e2e.test.ts b/ui/src/pages/tasks/tasks.e2e.test.ts index 2bf2917aac19..169e9467773f 100644 --- a/ui/src/pages/tasks/tasks.e2e.test.ts +++ b/ui/src/pages/tasks/tasks.e2e.test.ts @@ -81,6 +81,19 @@ const readOnlyRetainedTask = { }; const readOnlyRetainedResult = "Synthetic retained result copied by a read-only operator."; +const olderRetainedResult = "Older retained result from the first copy activation."; +const newestRetainedResult = "Newest retained result from the second copy activation."; + +type ClipboardFaultState = { + asyncWrites: string[]; + execSucceeds: boolean; + legacyWrites: string[]; + mode: "defer" | "missing" | "reject"; + pending: Array<{ + reject: (reason?: unknown) => void; + resolve: () => void; + }>; +}; const retryBlockedTask = { ...readOnlyRetainedTask, @@ -484,15 +497,50 @@ suite.define(() => { } }); - it("lets an operator.read-only user copy a retained result without mutations", async () => { + it("copies retained results through fallback and announces total failure", async () => { await mkdir(artifactDir, { recursive: true }); const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block", viewport: { width: 1440, height: 900 }, }); - await context.grantPermissions(["clipboard-read", "clipboard-write"], { - origin: new URL(suite.server.baseUrl).origin, + await context.addInitScript(() => { + const state: ClipboardFaultState = { + asyncWrites: [], + execSucceeds: true, + legacyWrites: [], + mode: "reject", + pending: [], + }; + Object.defineProperty(window, "tasksClipboardFault", { value: state }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + get: () => + state.mode === "missing" + ? undefined + : { + writeText(text: string) { + state.asyncWrites.push(text); + if (state.mode === "defer") { + return new Promise((resolve, reject) => { + state.pending.push({ reject, resolve }); + }); + } + return Promise.reject( + new DOMException("Clipboard access denied", "NotAllowedError"), + ); + }, + }, + }); + document.execCommand = (command: string) => { + if (command !== "copy") { + return false; + } + state.legacyWrites.push( + document.querySelector("textarea")?.value ?? "", + ); + return state.execSucceeds; + }; }); const page = await context.newPage(); try { @@ -512,7 +560,13 @@ suite.define(() => { ], }, "tasks.get": { - task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult }, + sequence: [ + { task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } }, + { task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } }, + { task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } }, + { task: { ...readOnlyRetainedTask, result: olderRetainedResult } }, + { task: { ...readOnlyRetainedTask, result: newestRetainedResult } }, + ], }, }, }); @@ -533,11 +587,166 @@ suite.define(() => { const copyButton = task.getByRole("button", { name: "Copy result" }); await copyButton.waitFor({ state: "visible" }); await copyButton.click(); - const getRequest = await gateway.waitForRequest("tasks.get"); - expect(getRequest.params).toEqual({ taskId: readOnlyRetainedTask.taskId }); await expect - .poll(() => page.evaluate(() => navigator.clipboard.readText())) - .toBe(readOnlyRetainedResult); + .poll(() => + page.evaluate( + () => + (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault, + ), + ) + .toMatchObject({ + asyncWrites: [readOnlyRetainedResult], + legacyWrites: [readOnlyRetainedResult], + }); + + await page.evaluate(() => { + ( + window as typeof window & { tasksClipboardFault: ClipboardFaultState } + ).tasksClipboardFault.mode = "missing"; + }); + await copyButton.click(); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault, + ), + ) + .toMatchObject({ + asyncWrites: [readOnlyRetainedResult], + legacyWrites: [readOnlyRetainedResult, readOnlyRetainedResult], + }); + + await page.evaluate(() => { + const state = (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault; + state.mode = "reject"; + state.execSucceeds = false; + }); + await copyButton.click(); + await expect.poll(() => page.getByRole("alert").textContent()).toBe("Copy failed"); + await page.screenshot({ path: path.join(artifactDir, "05-copy-failed.png") }); + expect( + await page.evaluate( + () => + (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault, + ), + ).toMatchObject({ + asyncWrites: [readOnlyRetainedResult, readOnlyRetainedResult], + legacyWrites: [readOnlyRetainedResult, readOnlyRetainedResult, readOnlyRetainedResult], + }); + + await page.evaluate(() => { + const state = (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault; + state.mode = "defer"; + state.execSucceeds = false; + }); + await copyButton.click(); + await copyButton.click(); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault.pending.length, + ), + ) + .toBe(2); + await page.evaluate(() => { + ( + window as typeof window & { tasksClipboardFault: ClipboardFaultState } + ).tasksClipboardFault.pending[1]?.reject( + new DOMException("Clipboard access denied", "NotAllowedError"), + ); + }); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault.legacyWrites.length, + ), + ) + .toBe(4); + await page.evaluate(() => { + ( + window as typeof window & { tasksClipboardFault: ClipboardFaultState } + ).tasksClipboardFault.pending[0]?.reject( + new DOMException("Clipboard access denied", "NotAllowedError"), + ); + }); + await page.evaluate( + () => + new Promise((resolve) => { + window.setTimeout(resolve, 0); + }), + ); + const currentAlert = page.getByRole("alert"); + expect(await currentAlert.count()).toBe(1); + expect(await currentAlert.textContent()).toBe("Copy failed"); + expect( + await page.evaluate( + () => + (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault.legacyWrites, + ), + ).toEqual([ + readOnlyRetainedResult, + readOnlyRetainedResult, + readOnlyRetainedResult, + newestRetainedResult, + ]); + + await gateway.deferNext("tasks.get", { taskId: readOnlyRetainedTask.taskId }); + await copyButton.click(); + await expect.poll(() => gateway.getRequests("tasks.get")).toHaveLength(6); + expect(await page.getByRole("alert").textContent()).toBe("Copy failed"); + const socketCount = await gateway.getSocketCount(); + await gateway.closeLatest(1012, "retire retained-result copy"); + await expect.poll(() => gateway.getSocketCount()).toBeGreaterThan(socketCount); + await gateway.resolveDeferred("tasks.get", { + task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult }, + }); + await page.evaluate( + () => + new Promise((resolve) => { + window.setTimeout(resolve, 0); + }), + ); + expect(await page.getByRole("alert").count()).toBe(0); + expect( + await page.evaluate( + () => + (window as typeof window & { tasksClipboardFault: ClipboardFaultState }) + .tasksClipboardFault, + ), + ).toMatchObject({ + asyncWrites: [ + readOnlyRetainedResult, + readOnlyRetainedResult, + olderRetainedResult, + newestRetainedResult, + ], + legacyWrites: [ + readOnlyRetainedResult, + readOnlyRetainedResult, + readOnlyRetainedResult, + newestRetainedResult, + ], + }); + + expect((await gateway.getRequests("tasks.get")).map((request) => request.params)).toEqual([ + { taskId: readOnlyRetainedTask.taskId }, + { taskId: readOnlyRetainedTask.taskId }, + { taskId: readOnlyRetainedTask.taskId }, + { taskId: readOnlyRetainedTask.taskId }, + { taskId: readOnlyRetainedTask.taskId }, + { taskId: readOnlyRetainedTask.taskId }, + ]); expect(await gateway.getRequests("tasks.retry")).toHaveLength(0); expect(await gateway.getRequests("tasks.dismiss")).toHaveLength(0); expect(await gateway.getRequests("tasks.cancel")).toHaveLength(0); diff --git a/ui/src/pages/tasks/view.ts b/ui/src/pages/tasks/view.ts index 2e5c01e91c3c..d649ae9d1ddb 100644 --- a/ui/src/pages/tasks/view.ts +++ b/ui/src/pages/tasks/view.ts @@ -29,6 +29,7 @@ type TasksProps = { canCancel: boolean; loading: boolean; error: string | null; + copyResultError: string | null; tasks: TaskSummary[]; cancellingTaskIds: ReadonlySet; sessionRow: (sessionKey: string) => GatewaySessionRow | undefined; @@ -255,7 +256,10 @@ export function renderTasks(props: TasksProps) { ${!props.connected ? html`
${t("tasksPage.disconnected")}
` : nothing} - ${props.error ? html`
${props.error}
` : nothing} + ${props.error ? html`` : nothing} + ${props.copyResultError + ? html`` + : nothing} ${renderSummaryStrip(props.tasks)} ${props.loading && props.tasks.length === 0 ? html`
${t("tasksPage.loading")}
` From 7d83ff7c8fffa7ffc232a16e3b4034443e174bf9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 07:37:16 -0700 Subject: [PATCH 007/124] improve(terminal): speed up fitting table rendering (#127221) * test(terminal): cover fitting table cell rendering Amp-Thread-ID: https://ampcode.com/threads/T-01a0242a-0302-708f-a777-ec7c0a5c9c60 * perf(terminal): skip wrapping work for fitting ASCII cells Amp-Thread-ID: https://ampcode.com/threads/T-01a021f5-984a-7628-a30c-491c166ff247 --------- Co-authored-by: Amp --- packages/terminal-core/src/table.test.ts | 15 +++++++++++++++ packages/terminal-core/src/table.ts | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/terminal-core/src/table.test.ts b/packages/terminal-core/src/table.test.ts index 2f44ecc55318..488208b72240 100644 --- a/packages/terminal-core/src/table.test.ts +++ b/packages/terminal-core/src/table.test.ts @@ -70,6 +70,21 @@ describe("renderTable", () => { vi.restoreAllMocks(); }); + it("renders fitting ASCII cells without grapheme segmentation", () => { + const segment = vi.spyOn(Intl.Segmenter.prototype, "segment"); + + const out = renderTable({ + border: "ascii", + columns: [{ key: "Name", header: "Name" }], + rows: [{ Name: "alpha" }, { Name: "beta" }], + }); + + expect(out).toBe( + ["+-------+", "| Name |", "+-------+", "| alpha |", "| beta |", "+-------+", ""].join("\n"), + ); + expect(segment).not.toHaveBeenCalled(); + }); + it.each(["$&", "$`", "$'", "$$"])( "displays a literal %s home path in terminal tables", (pattern) => { diff --git a/packages/terminal-core/src/table.ts b/packages/terminal-core/src/table.ts index 7ac971acccb7..1f0bd1112365 100644 --- a/packages/terminal-core/src/table.ts +++ b/packages/terminal-core/src/table.ts @@ -55,8 +55,9 @@ function padCell(text: string, width: number, align: Align): string { // A single grapheme wider than the cell (e.g. a width-2 CJK/emoji glyph in a // width-1 column) survives wrapLine intact, so clamp here to keep every cell // exactly `width` columns and preserve the border-alignment invariant. - const content = visibleWidth(text) > width ? truncateToVisibleWidth(text, width) : text; - const w = visibleWidth(content); + const textWidth = visibleWidth(text); + const content = textWidth > width ? truncateToVisibleWidth(text, width) : text; + const w = content === text ? textWidth : visibleWidth(content); if (w >= width) { return content; } @@ -366,6 +367,11 @@ function wrapLine(text: string, width: number): string[] { if (width <= 0) { return [text]; } + // Fitting edge-trimmed ASCII is one column per code unit and needs no ANSI/grapheme scan. + // Keep edge whitespace on the full path, where wrapping preserves its trimming semantics. + if (text.length <= width && /^[!-~](?:[ -~]*[!-~])?$/u.test(text)) { + return [text]; + } // ANSI-aware wrapping: never split inside ANSI SGR/OSC-8 sequences. // Table cells are padded and bordered per physical line, so wrapped lines From 03d4b8a9c3313138886c901addf9231b96c67371 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Fri, 21 Aug 2026 11:37:36 -0300 Subject: [PATCH 008/124] fix(ui): expose active split pane to assistive tech (#127213) --- ui/src/pages/chat/chat-page-pane-render.ts | 3 ++- ui/src/pages/chat/chat-page.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/chat/chat-page-pane-render.ts b/ui/src/pages/chat/chat-page-pane-render.ts index 479c5dfedba6..5316db743c36 100644 --- a/ui/src/pages/chat/chat-page-pane-render.ts +++ b/ui/src/pages/chat/chat-page-pane-render.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import type { ApplicationContext } from "../../app/context.ts"; import { nativeGatewaysCapability } from "../../app/native-gateways.runtime.ts"; @@ -55,6 +55,7 @@ export function renderChatPagePaneCell(options: ChatPagePaneRenderOptions) { class="chat-split-view__cell ${options.splitMode && options.active ? "chat-split-view__cell--active" : ""} ${options.narrow && !options.active ? "chat-split-view__cell--narrow-hidden" : ""}" + aria-current=${options.splitMode && options.active ? "true" : nothing} style="flex: ${options.weight} 1 0" @pointerdown=${() => options.onFocusPane(options.pane.id)} @focusin=${() => options.onFocusPane(options.pane.id)} diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index 639f887304e8..bd700437099d 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -654,9 +654,11 @@ describe("chat page split layout host", () => { await page.updateComplete; const panes = [...page.querySelectorAll("openclaw-chat-pane")]; + const cells = [...page.querySelectorAll(".chat-split-view__cell")]; const dividers = page.querySelectorAll("resizable-divider"); expect(panes.map((pane) => pane.paneId)).toEqual(["p1", "p2"]); expect(panes.map((pane) => pane.active)).toEqual([false, true]); + expect(cells.map((cell) => cell.getAttribute("aria-current"))).toEqual([null, "true"]); expect(dividers).toHaveLength(1); expect(itemAt(dividers, 0, "split divider").orientation).toBe("vertical"); expect( @@ -666,6 +668,15 @@ describe("chat page split layout host", () => { ).toBe(true); expect(panes.every((pane) => pane.onOpenSplitView === undefined)).toBe(true); expect(panes[0]?.chatMessagesBySession).toBe(panes[1]?.chatMessagesBySession); + + itemAt(cells, 0, "split cell").dispatchEvent(new Event("pointerdown")); + await page.updateComplete; + + expect( + [...page.querySelectorAll(".chat-split-view__cell")].map((cell) => + cell.getAttribute("aria-current"), + ), + ).toEqual(["true", null]); }); it("declares split panes, session switches, pane closes, and page disposal", async () => { From 7833e242cb25a5be7ee6485aafe975bc7e4eb6dd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 07:54:56 -0700 Subject: [PATCH 009/124] fix(ci): isolate performance candidate trust (#127160) --- .github/workflows/openclaw-performance.yml | 89 ++++++-- .../openclaw-performance-workflow.test.ts | 212 +++++++++++++++++- 2 files changed, 279 insertions(+), 22 deletions(-) diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index e3b85def4865..81aa0897a810 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -93,6 +93,8 @@ jobs: kova_ref: ${{ steps.resolve.outputs.kova_ref }} kova_config_contract: ${{ steps.resolve.outputs.kova_config_contract }} kova_ref_trusted_for_live: ${{ steps.resolve.outputs.kova_ref_trusted_for_live }} + secret_eligible: ${{ steps.candidate_trust.outputs.secret_eligible }} + cache_write_allowed: ${{ steps.candidate_trust.outputs.cache_write_allowed }} steps: - name: Checkout target metadata uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -177,6 +179,34 @@ jobs: echo "kova_ref_trusted_for_live=false" >> "$GITHUB_OUTPUT" fi + - name: Classify performance candidate trust + id: candidate_trust + env: + CANDIDATE_SHA: ${{ steps.resolve.outputs.tested_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + shell: bash + run: | + set -euo pipefail + + secret_eligible=false + cache_write_allowed=false + if [[ + "$GITHUB_EVENT_NAME" == "schedule" || + "$GITHUB_EVENT_NAME" == "workflow_dispatch" + ]] && [[ + "$GITHUB_REF" == "refs/heads/${DEFAULT_BRANCH}" && + "$CANDIDATE_SHA" == "$WORKFLOW_SHA" + ]]; then + secret_eligible=true + cache_write_allowed=true + fi + + { + echo "secret_eligible=$secret_eligible" + echo "cache_write_allowed=$cache_write_allowed" + } >> "$GITHUB_OUTPUT" + kova: name: ${{ matrix.title }} needs: resolve_target @@ -234,6 +264,7 @@ jobs: MATRIX_DEEP_PROFILE: ${{ matrix.deep_profile }} MATRIX_LIVE: ${{ matrix.live }} KOVA_REF_TRUSTED_FOR_LIVE: ${{ needs.resolve_target.outputs.kova_ref_trusted_for_live }} + SECRET_ELIGIBLE: ${{ needs.resolve_target.outputs.secret_eligible }} steps: - name: Decide lane id: lane @@ -250,6 +281,10 @@ jobs: run_lane=false reason="live_openai_candidate input is false" fi + if [[ "$LANE_ID" == "live-openai-candidate" && "$run_lane" == "true" && "$SECRET_ELIGIBLE" != "true" ]]; then + run_lane=false + reason="candidate is not eligible for live credentials" + fi if [[ "$LANE_ID" == "live-openai-candidate" && "$run_lane" == "true" && "$KOVA_REF_TRUSTED_FOR_LIVE" != "true" ]]; then echo "::error::The live OpenAI lane only executes a reviewed immutable Kova default. Omit kova_ref or update the pinned workflow defaults after review." exit 1 @@ -271,7 +306,7 @@ jobs: if: steps.lane.outputs.run == 'true' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ github.workflow_sha }} path: .artifacts/performance-workflow fetch-depth: 1 persist-credentials: false @@ -295,11 +330,35 @@ jobs: echo "Workflow SHA: ${GITHUB_SHA}" } >> "$GITHUB_STEP_SUMMARY" + - name: Stage trusted setup action graph + if: steps.lane.outputs.run == 'true' + shell: bash + run: &stage_trusted_performance_setup | + set -euo pipefail + github_dir="$GITHUB_WORKSPACE/.github" + actions_dir="$github_dir/actions" + trusted_action="$PERFORMANCE_HELPER_DIR/.github/actions/setup-pnpm-store-cache" + + test -f "$trusted_action/action.yml" + test -f "$trusted_action/ensure-node.sh" + if [[ -L "$github_dir" || ( -e "$github_dir" && ! -d "$github_dir" ) ]]; then + rm -rf -- "$github_dir" + fi + mkdir -p "$github_dir" + if [[ -L "$actions_dir" || ( -e "$actions_dir" && ! -d "$actions_dir" ) ]]; then + rm -rf -- "$actions_dir" + fi + mkdir -p "$actions_dir" + rm -rf -- "$actions_dir/setup-pnpm-store-cache" + cp -R -- "$trusted_action" "$actions_dir/setup-pnpm-store-cache" + cmp "$trusted_action/action.yml" "$actions_dir/setup-pnpm-store-cache/action.yml" + cmp "$trusted_action/ensure-node.sh" "$actions_dir/setup-pnpm-store-cache/ensure-node.sh" + - name: Set up Node environment if: steps.lane.outputs.run == 'true' - uses: ./.github/actions/setup-node-env + uses: ./.artifacts/performance-workflow/.github/actions/setup-node-env with: - cache-mode: restore + cache-mode: ${{ needs.resolve_target.outputs.cache_write_allowed == 'true' && 'restore' || 'off' }} install-bun: "false" - name: Prepare systemd user session @@ -448,7 +507,7 @@ jobs: echo "KOVA_LANE_REPEAT=$repeat" >> "$GITHUB_ENV" - name: Configure live OpenAI auth - if: ${{ steps.lane.outputs.run == 'true' && matrix.live == 'true' }} + if: ${{ steps.lane.outputs.run == 'true' && matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} @@ -466,8 +525,8 @@ jobs: id: kova if: steps.lane.outputs.run == 'true' env: - OPENAI_API_KEY: ${{ matrix.live == 'true' && secrets.OPENAI_API_KEY || '' }} - OPENAI_BASE_URL: ${{ matrix.live == 'true' && secrets.OPENAI_BASE_URL || '' }} + OPENAI_API_KEY: ${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_API_KEY || '' }} + OPENAI_BASE_URL: ${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_BASE_URL || '' }} shell: bash run: | set -euo pipefail @@ -646,7 +705,7 @@ jobs: - name: Checkout source performance helpers uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ github.workflow_sha }} path: .artifacts/performance-workflow fetch-depth: 1 persist-credentials: false @@ -667,10 +726,14 @@ jobs: echo "Workflow SHA: ${GITHUB_SHA}" } >> "$GITHUB_STEP_SUMMARY" + - name: Stage trusted source setup action graph + shell: bash + run: *stage_trusted_performance_setup + - name: Set up source performance environment - uses: ./.github/actions/setup-node-env + uses: ./.artifacts/performance-workflow/.github/actions/setup-node-env with: - cache-mode: restore + cache-mode: ${{ needs.resolve_target.outputs.cache_write_allowed == 'true' && 'restore' || 'off' }} install-bun: "false" - name: Fetch previous source performance baseline @@ -925,7 +988,7 @@ jobs: publish: name: Publish ${{ matrix.title }} report needs: [resolve_target, kova, source_performance] - if: ${{ always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' && needs.source_performance.result != 'cancelled' }} + if: ${{ always() && needs.resolve_target.outputs.secret_eligible == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' && needs.source_performance.result != 'cancelled' }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: @@ -966,7 +1029,7 @@ jobs: if: steps.lane.outputs.run == 'true' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ github.workflow_sha }} path: .artifacts/performance-publisher sparse-checkout: | scripts/lib/kova-report-publish-files.mjs @@ -1246,7 +1309,7 @@ jobs: - name: Create clawgrit reports app token id: clawgrit_app_token - if: ${{ steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} + if: ${{ needs.resolve_target.outputs.secret_eligible == 'true' && steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} continue-on-error: ${{ env.REPORT_PUBLISH_REQUIRED != 'true' }} uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -1257,7 +1320,7 @@ jobs: permission-contents: write - name: Publish to clawgrit reports - if: ${{ steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} + if: ${{ needs.resolve_target.outputs.secret_eligible == 'true' && steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} continue-on-error: ${{ env.REPORT_PUBLISH_REQUIRED != 'true' }} env: CLAWGRIT_REPORTS_APP_TOKEN: ${{ steps.clawgrit_app_token.outputs.token }} diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index cad75f99ea91..dc66077c4475 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -74,6 +74,46 @@ function kovaMatrixEntries(): Array> { return readWorkflow().jobs?.kova?.strategy?.matrix?.include ?? []; } +function runCandidateTrustClassification({ + candidateSha, + eventName, + ref, + workflowSha, +}: { + candidateSha: string; + eventName: "schedule" | "workflow_dispatch"; + ref: string; + workflowSha: string; +}) { + const step = findStep("Classify performance candidate trust", "resolve_target"); + const root = tempDirs.make("openclaw-performance-candidate-trust-"); + const output = join(root, "output"); + const result = spawnSync("bash", ["-c", step.run ?? ""], { + encoding: "utf8", + env: { + ...process.env, + CANDIDATE_SHA: candidateSha, + DEFAULT_BRANCH: "main", + GITHUB_EVENT_NAME: eventName, + GITHUB_OUTPUT: output, + GITHUB_REF: ref, + WORKFLOW_SHA: workflowSha, + }, + }); + const outputs = Object.fromEntries( + existsSync(output) + ? readFileSync(output, "utf8") + .trim() + .split("\n") + .map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }) + : [], + ); + return { outputs, result }; +} + describe("OpenClaw performance workflow", () => { it("uses an optional dispatch identifier to name parent-owned runs", () => { const workflow = readFileSync(WORKFLOW, "utf8"); @@ -195,13 +235,13 @@ describe("OpenClaw performance workflow", () => { decideLane.run?.indexOf('echo "run=$run_lane"') ?? -1, ); expect(configureLiveAuth.if).toBe( - "${{ steps.lane.outputs.run == 'true' && matrix.live == 'true' }}", + "${{ steps.lane.outputs.run == 'true' && matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' }}", ); expect(runKova.env?.OPENAI_API_KEY).toBe( - "${{ matrix.live == 'true' && secrets.OPENAI_API_KEY || '' }}", + "${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_API_KEY || '' }}", ); expect(runKova.env?.OPENAI_BASE_URL).toBe( - "${{ matrix.live == 'true' && secrets.OPENAI_BASE_URL || '' }}", + "${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_BASE_URL || '' }}", ); try { @@ -212,6 +252,7 @@ describe("OpenClaw performance workflow", () => { GITHUB_OUTPUT: output, KOVA_REF_TRUSTED_FOR_LIVE: "false", LANE_ID: "live-openai-candidate", + SECRET_ELIGIBLE: "true", }, }); expect(rejected.status).toBe(1); @@ -226,6 +267,7 @@ describe("OpenClaw performance workflow", () => { GITHUB_OUTPUT: output, KOVA_REF_TRUSTED_FOR_LIVE: "true", LANE_ID: "live-openai-candidate", + SECRET_ELIGIBLE: "true", }, }); expect(accepted.status).toBe(0); @@ -235,6 +277,157 @@ describe("OpenClaw performance workflow", () => { } }); + it("keeps arbitrary performance candidates secretless and cacheless", () => { + const workflow = readWorkflow(); + const trust = findStep("Classify performance candidate trust", "resolve_target"); + const decideLane = findStep("Decide lane"); + const kovaHarness = findStep("Checkout performance workflow helpers"); + const kovaStage = findStep("Stage trusted setup action graph"); + const kovaSetup = findStep("Set up Node environment"); + const sourceHarness = findStep("Checkout source performance helpers", "source_performance"); + const sourceStage = findStep("Stage trusted source setup action graph", "source_performance"); + const sourceSetup = findStep("Set up source performance environment", "source_performance"); + const publisherHarness = findStep("Checkout performance publisher helper", "publish"); + + expect(workflow.jobs?.resolve_target?.outputs).toMatchObject({ + secret_eligible: "${{ steps.candidate_trust.outputs.secret_eligible }}", + cache_write_allowed: "${{ steps.candidate_trust.outputs.cache_write_allowed }}", + }); + expect(trust.env).toMatchObject({ + CANDIDATE_SHA: "${{ steps.resolve.outputs.tested_sha }}", + DEFAULT_BRANCH: "${{ github.event.repository.default_branch }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", + }); + expect(trust.run).toContain("secret_eligible=false"); + expect(trust.run).toContain("cache_write_allowed=false"); + expect(trust.run).toContain('"$GITHUB_REF" == "refs/heads/${DEFAULT_BRANCH}"'); + expect(trust.run).toContain('"$CANDIDATE_SHA" == "$WORKFLOW_SHA"'); + expect(trust.run).toContain("secret_eligible=true"); + expect(trust.run).toContain("cache_write_allowed=true"); + + for (const harness of [kovaHarness, sourceHarness, publisherHarness]) { + expect(harness.with?.ref).toBe("${{ github.workflow_sha }}"); + expect(harness.with?.["persist-credentials"]).toBe(false); + } + for (const setup of [kovaSetup, sourceSetup]) { + expect(setup.uses).toBe("./.artifacts/performance-workflow/.github/actions/setup-node-env"); + expect(setup.with?.["cache-mode"]).toBe( + "${{ needs.resolve_target.outputs.cache_write_allowed == 'true' && 'restore' || 'off' }}", + ); + } + expect(kovaStage.run).toBe(sourceStage.run); + for (const stage of [kovaStage, sourceStage]) { + expect(stage.run).toContain( + 'trusted_action="$PERFORMANCE_HELPER_DIR/.github/actions/setup-pnpm-store-cache"', + ); + expect(stage.run).toContain('rm -rf -- "$actions_dir/setup-pnpm-store-cache"'); + expect(stage.run).toContain( + 'cp -R -- "$trusted_action" "$actions_dir/setup-pnpm-store-cache"', + ); + expect(stage.run).toContain( + 'cmp "$trusted_action/action.yml" "$actions_dir/setup-pnpm-store-cache/action.yml"', + ); + expect(stage.run).toContain( + 'cmp "$trusted_action/ensure-node.sh" "$actions_dir/setup-pnpm-store-cache/ensure-node.sh"', + ); + } + const kovaSteps = workflow.jobs?.kova?.steps ?? []; + const sourceSteps = workflow.jobs?.source_performance?.steps ?? []; + expect(kovaSteps.findIndex((step) => step.name === kovaStage.name)).toBeLessThan( + kovaSteps.findIndex((step) => step.name === kovaSetup.name), + ); + expect(sourceSteps.findIndex((step) => step.name === sourceStage.name)).toBeLessThan( + sourceSteps.findIndex((step) => step.name === sourceSetup.name), + ); + expect(decideLane.run).toContain( + 'if [[ "$LANE_ID" == "live-openai-candidate" && "$run_lane" == "true" && "$SECRET_ELIGIBLE" != "true" ]]; then', + ); + expect(decideLane.run).toContain('reason="candidate is not eligible for live credentials"'); + + const trustedSha = "a".repeat(40); + for (const eventName of ["schedule", "workflow_dispatch"] as const) { + const trusted = runCandidateTrustClassification({ + candidateSha: trustedSha, + eventName, + ref: "refs/heads/main", + workflowSha: trustedSha, + }); + expect(trusted.result.status, trusted.result.stderr).toBe(0); + expect(trusted.outputs).toEqual({ + secret_eligible: "true", + cache_write_allowed: "true", + }); + } + + for (const candidate of [ + { + candidateSha: "b".repeat(40), + eventName: "workflow_dispatch" as const, + ref: "refs/heads/main", + workflowSha: trustedSha, + }, + { + candidateSha: trustedSha, + eventName: "workflow_dispatch" as const, + ref: "refs/heads/release/2026.8.1", + workflowSha: trustedSha, + }, + ]) { + const untrusted = runCandidateTrustClassification(candidate); + expect(untrusted.result.status, untrusted.result.stderr).toBe(0); + expect(untrusted.outputs).toEqual({ + secret_eligible: "false", + cache_write_allowed: "false", + }); + } + }); + + it("replaces candidate-owned nested setup actions with the trusted workflow copy", () => { + const stages = [ + findStep("Stage trusted setup action graph"), + findStep("Stage trusted source setup action graph", "source_performance"), + ]; + + for (const stage of stages) { + const root = tempDirs.make("openclaw-performance-action-graph-"); + const workspace = join(root, "candidate"); + const helper = join(root, "workflow"); + const candidateAction = join(workspace, ".github/actions/setup-pnpm-store-cache/action.yml"); + const candidateEnsureNode = join( + workspace, + ".github/actions/setup-pnpm-store-cache/ensure-node.sh", + ); + const trustedAction = join(helper, ".github/actions/setup-pnpm-store-cache/action.yml"); + const trustedEnsureNode = join( + helper, + ".github/actions/setup-pnpm-store-cache/ensure-node.sh", + ); + mkdirSync(join(workspace, ".github/actions/setup-pnpm-store-cache"), { + recursive: true, + }); + mkdirSync(join(helper, ".github/actions/setup-pnpm-store-cache"), { + recursive: true, + }); + writeFileSync(candidateAction, "candidate action\n"); + writeFileSync(candidateEnsureNode, "candidate script\n"); + writeFileSync(trustedAction, "trusted action\n"); + writeFileSync(trustedEnsureNode, "trusted script\n"); + + const result = spawnSync("bash", ["-c", stage.run ?? ""], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_WORKSPACE: workspace, + PERFORMANCE_HELPER_DIR: helper, + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(candidateAction, "utf8")).toBe("trusted action\n"); + expect(readFileSync(candidateEnsureNode, "utf8")).toBe("trusted script\n"); + } + }); + it("pins the OCM release archive and checksum", () => { const workflow = readFileSync(WORKFLOW, "utf8"); const installRun = findStep("Install OCM and Kova").run ?? ""; @@ -425,7 +618,7 @@ describe("OpenClaw performance workflow", () => { expect(publisher?.needs).toEqual(["resolve_target", "kova", "source_performance"]); expect(publisher?.if).toBe( - "${{ always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' && needs.source_performance.result != 'cancelled' }}", + "${{ always() && needs.resolve_target.outputs.secret_eligible == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' && needs.source_performance.result != 'cancelled' }}", ); expect(publisher?.["runs-on"]).toBe("ubuntu-24.04"); expect(publisher?.permissions?.actions).toBe("read"); @@ -488,7 +681,7 @@ describe("OpenClaw performance workflow", () => { expect(appToken.id).toBe("clawgrit_app_token"); expect(appToken.if).toBe( - "${{ steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }}", + "${{ needs.resolve_target.outputs.secret_eligible == 'true' && steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }}", ); expect(appToken.uses).toBe( "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1", @@ -506,7 +699,7 @@ describe("OpenClaw performance workflow", () => { expect(workflowText.split(appTokenOutput)).toHaveLength(2); expect(workflowText.split("${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}")).toHaveLength(2); expect(publish.if).toBe( - "${{ steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }}", + "${{ needs.resolve_target.outputs.secret_eligible == 'true' && steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }}", ); expect(workflowText).not.toContain("CLAWGRIT_REPORTS_TOKEN"); expect(workflowText).not.toContain("secrets.GH_APP_PRIVATE_KEY"); @@ -622,7 +815,7 @@ describe("OpenClaw performance workflow", () => { "scripts/lib/kova-report-selector.mjs", ); expect(helper.with).toMatchObject({ - ref: "${{ github.sha }}", + ref: "${{ github.workflow_sha }}", path: ".artifacts/performance-publisher", "sparse-checkout": "scripts/lib/kova-report-publish-files.mjs\nscripts/lib/kova-report-selector.mjs\n", @@ -1110,16 +1303,17 @@ esac const runKova = findStep("Run Kova"); expect(configureAuth.if).toContain("matrix.live == 'true'"); + expect(configureAuth.if).toContain("needs.resolve_target.outputs.secret_eligible == 'true'"); expect(configureAuth.env?.OPENAI_API_KEY).toBe("${{ secrets.OPENAI_API_KEY }}"); expect(configureAuth.run).toContain('if [[ -z "${OPENAI_API_KEY:-}" ]]; then'); expect(configureAuth.run).toContain("cannot run without live evidence"); expect(configureAuth.run).toContain("exit 1"); expect(configureAuth.run).not.toContain("will be skipped"); expect(runKova.env?.OPENAI_API_KEY).toBe( - "${{ matrix.live == 'true' && secrets.OPENAI_API_KEY || '' }}", + "${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_API_KEY || '' }}", ); expect(runKova.env?.OPENAI_BASE_URL).toBe( - "${{ matrix.live == 'true' && secrets.OPENAI_BASE_URL || '' }}", + "${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_BASE_URL || '' }}", ); expect(runKova.run).not.toContain('echo "skipped=true" >> "$GITHUB_OUTPUT"'); }); From 15664db855f2163de26e01fab5880e437b65ba8b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 07:56:38 -0700 Subject: [PATCH 010/124] refactor(approvals): unify native client setup (#127189) --- src/infra/exec-approval-surface.test.ts | 54 +++++++++++++++++++++++++ src/infra/exec-approval-surface.ts | 51 +++++++++++------------ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index a0f78af97a94..10f37e3f855c 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -38,10 +38,14 @@ type ExecApprovalSurfaceModule = typeof import("./exec-approval-surface.js"); let resolveExecApprovalInitiatingSurfaceState: ExecApprovalSurfaceModule["resolveExecApprovalInitiatingSurfaceState"]; let resolveApprovalInitiatingSurfaceState: ExecApprovalSurfaceModule["resolveApprovalInitiatingSurfaceState"]; let supportsNativeExecApprovalClient: ExecApprovalSurfaceModule["supportsNativeExecApprovalClient"]; +let describeNativeExecApprovalClientSetup: ExecApprovalSurfaceModule["describeNativeExecApprovalClientSetup"]; +let describeNativePluginApprovalClientSetup: ExecApprovalSurfaceModule["describeNativePluginApprovalClientSetup"]; describe("resolveExecApprovalInitiatingSurfaceState", () => { beforeAll(async () => { ({ + describeNativeExecApprovalClientSetup, + describeNativePluginApprovalClientSetup, resolveApprovalInitiatingSurfaceState, resolveExecApprovalInitiatingSurfaceState, supportsNativeExecApprovalClient, @@ -318,4 +322,54 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { expect(supportsNativeExecApprovalClient("matrix")).toBe(true); }); + + it("routes normalized setup parameters to the selected approval hook", () => { + const describeExecApprovalSetup = vi.fn(() => "exec setup"); + const describePluginApprovalSetup = vi.fn(() => "plugin setup"); + getChannelPluginMock.mockReturnValue({ + meta: { label: "Telegram" }, + approvalCapability: { describeExecApprovalSetup, describePluginApprovalSetup }, + }); + + expect( + describeNativeExecApprovalClientSetup({ + channel: " Telegram ", + channelLabel: " Telegram Custom ", + accountId: " primary ", + }), + ).toBe("exec setup"); + expect(describeExecApprovalSetup).toHaveBeenCalledWith({ + channel: "telegram", + channelLabel: "Telegram Custom", + accountId: "primary", + }); + + expect(describeNativePluginApprovalClientSetup({ channel: " TELEGRAM " })).toBe("plugin setup"); + expect(describePluginApprovalSetup).toHaveBeenCalledWith({ + channel: "telegram", + channelLabel: "Telegram", + accountId: undefined, + }); + }); + + it.each([undefined, "web", "tui"])("suppresses setup guidance for %s", (channel) => { + expect(describeNativeExecApprovalClientSetup({ channel })).toBeNull(); + expect(describeNativePluginApprovalClientSetup({ channel })).toBeNull(); + expect(getChannelPluginMock).not.toHaveBeenCalled(); + }); + + it("does not fall back to the sibling approval hook", () => { + const describeExecApprovalSetup = vi.fn(() => "exec setup"); + const describePluginApprovalSetup = vi.fn(() => "plugin setup"); + getChannelPluginMock.mockImplementation((channel: string) => ({ + meta: { label: channel }, + approvalCapability: + channel === "telegram" ? { describePluginApprovalSetup } : { describeExecApprovalSetup }, + })); + + expect(describeNativeExecApprovalClientSetup({ channel: "telegram" })).toBeNull(); + expect(describePluginApprovalSetup).not.toHaveBeenCalled(); + expect(describeNativePluginApprovalClientSetup({ channel: "matrix" })).toBeNull(); + expect(describeExecApprovalSetup).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index 76a19260aa20..211e55eee819 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -112,44 +112,39 @@ export function listNativeExecApprovalClientLabels(params?: { .toSorted((a, b) => a.localeCompare(b)); } -/** Returns channel-specific setup guidance for native exec approvals, when available. */ -export function describeNativeExecApprovalClientSetup(params: { +type NativeApprovalClientSetupParams = { channel?: string | null; channelLabel?: string | null; accountId?: string | null; -}): string | null { +}; + +function describeNativeApprovalClientSetup( + params: NativeApprovalClientSetupParams, + approvalKind: ChannelApprovalKind, +): string | null { const channel = normalizeMessageChannel(params.channel); if (!channel || channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") { return null; } const channelLabel = normalizeOptionalString(params.channelLabel) ?? labelForChannel(channel); const accountId = normalizeOptionalString(params.accountId); - return ( - resolveChannelApprovalCapability(getChannelPlugin(channel))?.describeExecApprovalSetup?.({ - channel, - channelLabel, - accountId, - }) ?? null - ); + const capability = resolveChannelApprovalCapability(getChannelPlugin(channel)); + const setupParams = { channel, channelLabel, accountId }; + return approvalKind === "exec" + ? (capability?.describeExecApprovalSetup?.(setupParams) ?? null) + : (capability?.describePluginApprovalSetup?.(setupParams) ?? null); +} + +/** Returns channel-specific setup guidance for native exec approvals, when available. */ +export function describeNativeExecApprovalClientSetup( + params: NativeApprovalClientSetupParams, +): string | null { + return describeNativeApprovalClientSetup(params, "exec"); } /** Returns channel-specific setup guidance for native plugin approvals, when available. */ -export function describeNativePluginApprovalClientSetup(params: { - channel?: string | null; - channelLabel?: string | null; - accountId?: string | null; -}): string | null { - const channel = normalizeMessageChannel(params.channel); - if (!channel || channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") { - return null; - } - const channelLabel = normalizeOptionalString(params.channelLabel) ?? labelForChannel(channel); - const accountId = normalizeOptionalString(params.accountId); - return ( - resolveChannelApprovalCapability(getChannelPlugin(channel))?.describePluginApprovalSetup?.({ - channel, - channelLabel, - accountId, - }) ?? null - ); +export function describeNativePluginApprovalClientSetup( + params: NativeApprovalClientSetupParams, +): string | null { + return describeNativeApprovalClientSetup(params, "plugin"); } From 3ae7ba319461c00aba59e0d1b74df0ac0e21f45e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 08:13:49 -0700 Subject: [PATCH 011/124] fix(ui): copy Lobsterdex links through clipboard fallback (#127194) Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b Co-authored-by: Amp --- .../lobsterdex/lobsterdex-page.e2e.test.ts | 196 ++++++++++++++++++ ui/src/pages/lobsterdex/lobsterdex-page.ts | 31 ++- ui/src/pages/lobsterdex/view.ts | 15 +- 3 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 ui/src/pages/lobsterdex/lobsterdex-page.e2e.test.ts diff --git a/ui/src/pages/lobsterdex/lobsterdex-page.e2e.test.ts b/ui/src/pages/lobsterdex/lobsterdex-page.e2e.test.ts new file mode 100644 index 000000000000..92460e2a3160 --- /dev/null +++ b/ui/src/pages/lobsterdex/lobsterdex-page.e2e.test.ts @@ -0,0 +1,196 @@ +import { expect, it } from "vitest"; +import { createControlUiE2eSuite } from "../../e2e/control-ui-e2e-suite.test-support.ts"; +import { installMockGateway } from "../../test-helpers/control-ui-e2e.ts"; + +type ClipboardFaultState = { + asyncWrites: string[]; + execSucceeds: boolean; + legacyWrites: string[]; + mode: "defer" | "missing" | "reject"; + pendingRejects: Array<(reason?: unknown) => void>; +}; + +const suite = createControlUiE2eSuite({ + name: "Control UI Lobsterdex clipboard E2E", + startServerBeforeBrowser: true, + unavailableMessage: (executablePath) => + `Playwright Chromium is not available at ${executablePath}`, +}); + +suite.define(() => { + it("falls back, announces failure, and keeps feedback with the newest copy", async () => { + await suite.withPage( + { + hasTouch: true, + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 844, width: 390 }, + }, + async ({ page }) => { + await page.addInitScript(() => { + const state: ClipboardFaultState = { + asyncWrites: [], + execSucceeds: true, + legacyWrites: [], + mode: "reject", + pendingRejects: [], + }; + Object.defineProperty(window, "lobsterdexClipboardFault", { value: state }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + get: () => + state.mode === "missing" + ? undefined + : { + writeText(text: string) { + state.asyncWrites.push(text); + if (state.mode === "reject") { + return Promise.reject( + new DOMException("Clipboard access denied", "NotAllowedError"), + ); + } + return new Promise((_resolve, reject) => { + state.pendingRejects.push(reject); + }); + }, + }, + }); + document.execCommand = (command: string) => { + if (command !== "copy") { + return false; + } + state.legacyWrites.push( + document.querySelector("textarea")?.value ?? "", + ); + return state.execSucceeds; + }; + }); + const gateway = await installMockGateway(page); + const response = await page.goto(`${suite.server.baseUrl}settings/lobsterdex`); + expect(response?.status()).toBe(200); + + const pageRoot = page.locator("openclaw-lobsterdex-page"); + const copyButtons = pageRoot.getByRole("button", { name: "Copy link" }); + await expect.poll(() => copyButtons.count()).toBeGreaterThan(1); + const crimson = copyButtons.nth(0); + const blue = copyButtons.nth(1); + const crimsonUrl = `${new URL(suite.server.baseUrl).origin}/settings/lobsterdex#lobsterdex-crimson`; + const blueUrl = `${new URL(suite.server.baseUrl).origin}/settings/lobsterdex#lobsterdex-blue`; + const requestsBeforeCopy = (await gateway.getRequests()).length; + + await crimson.focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault, + ), + ) + .toMatchObject({ asyncWrites: [crimsonUrl], legacyWrites: [crimsonUrl] }); + + await page.evaluate(() => { + ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault.mode = "missing"; + }); + await blue.tap(); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault, + ), + ) + .toMatchObject({ asyncWrites: [crimsonUrl], legacyWrites: [crimsonUrl, blueUrl] }); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.mode = "reject"; + state.execSucceeds = false; + }); + await crimson.tap(); + await expect.poll(() => pageRoot.getByRole("alert").textContent()).toBe("Copy failed"); + await pageRoot.evaluate((element) => { + const parent = element.parentElement; + if (!parent) { + throw new Error("Lobsterdex page has no route host"); + } + element.remove(); + parent.append(element); + }); + await expect.poll(() => pageRoot.getByRole("alert").count()).toBe(0); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.mode = "defer"; + state.execSucceeds = true; + state.asyncWrites = []; + state.legacyWrites = []; + state.pendingRejects = []; + }); + await crimson.focus(); + await page.keyboard.press("Enter"); + await blue.tap(); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault.pendingRejects.length, + ), + ) + .toBe(2); + expect(await pageRoot.getByRole("alert").count()).toBe(0); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.pendingRejects[1]?.(new DOMException("Newer write rejected", "NotAllowedError")); + }); + await blue.locator('path[d="M20 6 9 17l-5-5"]').waitFor(); + expect(await crimson.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(0); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault.legacyWrites, + ), + ) + .toEqual([blueUrl]); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.pendingRejects[0]?.(new DOMException("Older write rejected", "NotAllowedError")); + }); + await page.evaluate( + () => + new Promise((resolve) => { + window.setTimeout(resolve, 0); + }), + ); + expect(await crimson.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(0); + expect(await blue.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(1); + expect( + await page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault.legacyWrites, + ), + ).toEqual([blueUrl]); + await expect.poll(() => blue.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(0); + expect((await gateway.getRequests()).length).toBe(requestsBeforeCopy); + }, + ); + }); +}); diff --git a/ui/src/pages/lobsterdex/lobsterdex-page.ts b/ui/src/pages/lobsterdex/lobsterdex-page.ts index 22798f196289..1ccef5c766c0 100644 --- a/ui/src/pages/lobsterdex/lobsterdex-page.ts +++ b/ui/src/pages/lobsterdex/lobsterdex-page.ts @@ -5,14 +5,18 @@ import { getLobsterdexEntries } from "../../components/lobster-dex.ts"; import type { LobsterPetPaletteId } from "../../components/lobster-pet-contract.ts"; import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { copyToClipboard } from "../../lib/clipboard.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; -import { renderLobsterdex } from "./view.ts"; +import { renderLobsterdex, type LobsterdexCopyFeedback } from "./view.ts"; class LobsterdexPage extends OpenClawLightDomElement { - @state() private copiedPaletteId: LobsterPetPaletteId | null = null; + @state() private copyFeedback: LobsterdexCopyFeedback | null = null; + private copyAttempt = 0; private copyResetTimer: number | null = null; override disconnectedCallback(): void { + this.copyAttempt += 1; + this.copyFeedback = null; if (this.copyResetTimer !== null) { window.clearTimeout(this.copyResetTimer); this.copyResetTimer = null; @@ -54,18 +58,23 @@ class LobsterdexPage extends OpenClawLightDomElement { } private readonly copyLink = async (paletteId: LobsterPetPaletteId): Promise => { - const url = `${location.origin}${location.pathname}#lobsterdex-${paletteId}`; - try { - await navigator.clipboard.writeText(url); - } catch { - return; - } - this.copiedPaletteId = paletteId; + const attempt = ++this.copyAttempt; + this.copyFeedback = null; if (this.copyResetTimer !== null) { window.clearTimeout(this.copyResetTimer); + this.copyResetTimer = null; } + const url = `${location.origin}${location.pathname}#lobsterdex-${paletteId}`; + const copied = await copyToClipboard( + url, + () => this.isConnected && attempt === this.copyAttempt, + ); + if (!this.isConnected || attempt !== this.copyAttempt) { + return; + } + this.copyFeedback = { paletteId, status: copied ? "copied" : "error" }; this.copyResetTimer = window.setTimeout(() => { - this.copiedPaletteId = null; + this.copyFeedback = null; this.copyResetTimer = null; }, 1_500); }; @@ -77,7 +86,7 @@ class LobsterdexPage extends OpenClawLightDomElement { ${renderSettingsWorkspace( renderLobsterdex(getLobsterdexEntries(), { - copiedPaletteId: this.copiedPaletteId, + copyFeedback: this.copyFeedback, onCopyLink: (paletteId) => void this.copyLink(paletteId), }), )} diff --git a/ui/src/pages/lobsterdex/view.ts b/ui/src/pages/lobsterdex/view.ts index 3b5dd4804c36..5208aca972b4 100644 --- a/ui/src/pages/lobsterdex/view.ts +++ b/ui/src/pages/lobsterdex/view.ts @@ -20,8 +20,13 @@ type LobsterdexViewEntry = { type LobsterdexViewEntries = ReadonlyMap; +export type LobsterdexCopyFeedback = { + paletteId: LobsterPetPaletteId; + status: "copied" | "error"; +}; + type LobsterdexViewProps = { - copiedPaletteId?: LobsterPetPaletteId | null; + copyFeedback?: LobsterdexCopyFeedback | null; onCopyLink?: (paletteId: LobsterPetPaletteId) => void; }; @@ -47,6 +52,9 @@ export function renderLobsterdex(entries: LobsterdexViewEntries, props: Lobsterd
${countLabel} + ${props.copyFeedback?.status === "error" + ? html`` + : nothing}
${LOBSTER_PET_PALETTES.map((palette) => { const look = canonicalLobsterLook(palette); @@ -78,7 +86,10 @@ export function renderLobsterdex(entries: LobsterdexViewEntries, props: Lobsterd @click=${() => props.onCopyLink?.(palette.id)} > ${props.copyFeedback?.status === "copied" && + props.copyFeedback.paletteId === palette.id + ? icons.check + : icons.link}
Date: Fri, 21 Aug 2026 15:59:17 +0100 Subject: [PATCH 012/124] fix: preserve Codex compaction activity --- CHANGELOG.md | 1 + .../app-server/context-compaction-activity.ts | 73 +++++++++++++++++++ .../codex/src/app-server/event-projector.ts | 8 ++ packages/agent-core/src/harness/messages.ts | 3 + packages/agent-core/src/types.ts | 2 + ui/src/pages/chat/chat-progress.ts | 20 +++++ ui/src/pages/chat/chat-thread-build.ts | 3 +- ui/src/pages/chat/chat-thread-grouping.ts | 4 + 8 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 extensions/codex/src/app-server/context-compaction-activity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cc4481d5e9..d3a1eff86560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Control UI Codex compaction history:** preserve successful native context compactions as durable, model-excluded activity inside completed work traces after the composer status clears or the session reloads. Fixes #127206. Thanks @shakkernerd. - **Control UI Codex steering:** preserve pre-steer commentary and tool activity in durable transcript order, keep it visible while active, and collapse it before the steering message after completion. Fixes #126938. Thanks @shakkernerd. - **Onboarding migration menu:** group Claude, Codex, Hermes, and plugin-provided imports under a single **Import from another agent** setup choice while preserving detected source hints, manual paths, and Back navigation before import begins. Fixes #126440. Thanks @shakkernerd. - **Onboarding provider hook loading:** scope selected-model hook fallback to the chosen provider so metadata-only setup providers do not load unrelated plugins before configuration completes. Fixes #126408. Thanks @shakkernerd. diff --git a/extensions/codex/src/app-server/context-compaction-activity.ts b/extensions/codex/src/app-server/context-compaction-activity.ts new file mode 100644 index 000000000000..8ce22be5a6ec --- /dev/null +++ b/extensions/codex/src/app-server/context-compaction-activity.ts @@ -0,0 +1,73 @@ +import { + embeddedAgentLog, + formatErrorMessage, + type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + appendSessionTranscriptMessageByIdentity, + publishSessionTranscriptUpdateByIdentity, +} from "openclaw/plugin-sdk/session-transcript-runtime"; + +const CONTEXT_COMPACTION_CUSTOM_TYPE = "openclaw.context-compaction"; + +export async function persistCodexContextCompactionActivity(params: { + run: EmbeddedRunAttemptParams; + threadId: string; + turnId: string; + itemId: string; + timestamp: number; +}): Promise { + const target = params.run.sessionTarget; + if (!target?.sessionId || !target.sessionKey || !target.storePath) { + return; + } + const activityId = `codex-context-compaction:${params.threadId}:${params.turnId}:${params.itemId}`; + const message = { + role: "custom" as const, + customType: CONTEXT_COMPACTION_CUSTOM_TYPE, + content: "Context compacted", + display: true, + excludeFromContext: true, + details: { + kind: "context_compaction", + backend: "codex-app-server", + threadId: params.threadId, + turnId: params.turnId, + itemId: params.itemId, + runId: params.run.runId, + }, + timestamp: params.timestamp, + idempotencyKey: activityId, + }; + try { + const appended = await appendSessionTranscriptMessageByIdentity({ + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + storePath: target.storePath, + config: params.run.config, + cwd: params.run.workspaceDir, + eventId: activityId, + message, + }); + if (!appended?.appended) { + return; + } + await publishSessionTranscriptUpdateByIdentity({ + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + storePath: target.storePath, + update: { + message: appended.message, + messageId: appended.messageId, + runId: params.run.runId, + }, + }); + } catch (error) { + embeddedAgentLog.warn("failed to persist codex context compaction activity", { + error: formatErrorMessage(error), + itemId: params.itemId, + }); + } +} diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index b4b7da7f862f..6fab913ef4b7 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -10,6 +10,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { AttemptFailureSource, EmbeddedRunAttemptResult } from "./attempt-terminal.js"; +import { persistCodexContextCompactionActivity } from "./context-compaction-activity.js"; import { CodexAssistantProjection } from "./event-projector-assistant.js"; import { CodexProjectionDiagnostics } from "./event-projector-diagnostics.js"; import { CodexEventProjection } from "./event-projector-events.js"; @@ -520,6 +521,13 @@ export class CodexAppServerEventProjector { channelId: this.params.messageChannel ?? this.params.messageProvider ?? undefined, }, }); + await persistCodexContextCompactionActivity({ + run: this.params, + threadId: this.threadId, + turnId: this.turnId, + itemId, + timestamp: this.nextTranscriptTimestamp(), + }); this.emitCompactionEnd(itemId, true); } this.toolProgressProjection.recordToolMeta(item); diff --git a/packages/agent-core/src/harness/messages.ts b/packages/agent-core/src/harness/messages.ts index eec60e55fab0..076b878d928e 100644 --- a/packages/agent-core/src/harness/messages.ts +++ b/packages/agent-core/src/harness/messages.ts @@ -143,6 +143,9 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { timestamp: message.timestamp, }; case "custom": { + if (message.excludeFromContext) { + return undefined; + } const content = typeof message.content === "string" ? [{ type: "text" as const, text: message.content }] diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index 45534bffa79c..6cbaac6fc8bc 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -427,6 +427,8 @@ export interface CustomMessage { content: string | (TextContent | ImageContent)[]; /** Whether UI surfaces should display this message. */ display: boolean; + /** Keep display-only application activity out of future model context. */ + excludeFromContext?: boolean; /** Optional application-specific metadata. */ details?: T; /** Millisecond timestamp for transcript ordering. */ diff --git a/ui/src/pages/chat/chat-progress.ts b/ui/src/pages/chat/chat-progress.ts index 7d136ee11ad4..868cafd27074 100644 --- a/ui/src/pages/chat/chat-progress.ts +++ b/ui/src/pages/chat/chat-progress.ts @@ -1,3 +1,4 @@ +import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { t } from "../../i18n/index.ts"; import type { ChatItem, ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { formatCompactTokenCount } from "../../lib/format.ts"; @@ -11,6 +12,25 @@ type WorkingProgressCache = WorkingProgress & { runId: string | null; }; +const CONTEXT_COMPACTION_CUSTOM_TYPE = "openclaw.context-compaction"; + +export function projectContextCompactionActivity(message: unknown): unknown { + const record = asRecord(message); + if (record?.role !== "custom" || record.customType !== CONTEXT_COMPACTION_CUSTOM_TYPE) { + return message; + } + const metadata = asRecord(record["__openclaw"]); + return { + ...record, + role: "assistant", + content: [{ type: "text", text: t("chat.composer.contextCompacted") }], + __openclaw: { + ...metadata, + runtimeActivityKind: "context_compaction", + }, + }; +} + const workingProgressBySession = new Map(); let anonymousWorkingProgressId = 0; diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index 9a2600691fc4..c8668e9f657f 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -24,6 +24,7 @@ import { buildCompactionDividerItem, buildResetDividerItem, clearWorkingProgress, + projectContextCompactionActivity, resolveWorkingProgress, shouldRenderQueuedSendInThread, } from "./chat-progress.ts"; @@ -214,7 +215,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array + asRecord(asRecord(message)?.["__openclaw"])?.runtimeActivityKind === "context_compaction", + ) && groupHasVisibleReplyContent(group) ); } From 7b5430a2d3937756435620eea88ea3cd3d19f53b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 21 Aug 2026 16:03:06 +0100 Subject: [PATCH 013/124] test: cover durable compaction activity --- .../context-compaction-activity.test.ts | 70 +++++++++++++++++++ .../harness/session/session-context.test.ts | 13 ++++ ui/src/pages/chat/chat-thread-grouping.ts | 15 ++-- ui/src/pages/chat/chat-thread.test.ts | 29 ++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 extensions/codex/src/app-server/context-compaction-activity.test.ts diff --git a/extensions/codex/src/app-server/context-compaction-activity.test.ts b/extensions/codex/src/app-server/context-compaction-activity.test.ts new file mode 100644 index 000000000000..49bb90d820e0 --- /dev/null +++ b/extensions/codex/src/app-server/context-compaction-activity.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { persistCodexContextCompactionActivity } from "./context-compaction-activity.js"; + +const appendMessage = vi.hoisted(() => vi.fn()); +const publishUpdate = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/session-transcript-runtime", () => ({ + appendSessionTranscriptMessageByIdentity: appendMessage, + publishSessionTranscriptUpdateByIdentity: publishUpdate, +})); + +beforeEach(() => { + appendMessage.mockReset(); + publishUpdate.mockReset(); +}); + +describe("persistCodexContextCompactionActivity", () => { + it("publishes one model-excluded activity and leaves replay deduplication to transcript identity", async () => { + appendMessage + .mockImplementationOnce(async (params: { message: unknown }) => ({ + appended: true, + message: params.message, + messageId: "activity-message", + })) + .mockResolvedValueOnce({ + appended: false, + message: {}, + messageId: "activity-message", + }); + const params = { + run: { + runId: "run-1", + workspaceDir: "/workspace", + sessionTarget: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:dashboard:session-1", + storePath: "/state/openclaw-agent.sqlite", + }, + }, + threadId: "thread-1", + turnId: "turn-1", + itemId: "compact-1", + timestamp: 123, + } as Parameters[0]; + + await persistCodexContextCompactionActivity(params); + await persistCodexContextCompactionActivity(params); + + expect(appendMessage).toHaveBeenCalledTimes(2); + expect(appendMessage.mock.calls[0]?.[0]).toMatchObject({ + eventId: "codex-context-compaction:thread-1:turn-1:compact-1", + message: { + role: "custom", + customType: "openclaw.context-compaction", + content: "Context compacted", + display: true, + excludeFromContext: true, + idempotencyKey: "codex-context-compaction:thread-1:turn-1:compact-1", + }, + }); + expect(publishUpdate).toHaveBeenCalledOnce(); + expect(publishUpdate.mock.calls[0]?.[0]).toMatchObject({ + update: { + messageId: "activity-message", + runId: "run-1", + }, + }); + }); +}); diff --git a/packages/agent-core/src/harness/session/session-context.test.ts b/packages/agent-core/src/harness/session/session-context.test.ts index 412f32837eaf..176cddee30b0 100644 --- a/packages/agent-core/src/harness/session/session-context.test.ts +++ b/packages/agent-core/src/harness/session/session-context.test.ts @@ -133,6 +133,19 @@ function toolResultEntry( } describe("buildSessionContext", () => { + it("keeps display-only custom activity out of model input", () => { + const activity = { + role: "custom" as const, + customType: "openclaw.context-compaction", + content: "Context compacted", + display: true, + excludeFromContext: true, + timestamp: Date.parse(timestamp), + }; + + expect(convertToLlm([activity])).toEqual([]); + }); + it("keeps private shell executions in history without projecting them into context", () => { const hiddenEntry = bashEntry("hidden", "initial", "private shell output", true); const visibleEntry = bashEntry("visible", "hidden", "visible shell output", false); diff --git a/ui/src/pages/chat/chat-thread-grouping.ts b/ui/src/pages/chat/chat-thread-grouping.ts index d1e4ecfa5ce7..64236e2a5ba4 100644 --- a/ui/src/pages/chat/chat-thread-grouping.ts +++ b/ui/src/pages/chat/chat-thread-grouping.ts @@ -86,12 +86,18 @@ export function groupMessages(items: ChatItem[]): Array currentGroup?.role === "assistant" && isKeyedAssistantStreamFallbackMessage(currentGroup.messages[0]?.message) !== isKeyedAssistantStreamFallbackMessage(item.message); + const splitsRuntimeActivity = + role === "assistant" && + currentGroup?.role === "assistant" && + isContextCompactionActivity(currentGroup.messages[0]?.message) !== + isContextCompactionActivity(item.message); if ( !currentGroup || startsProjectedTurn || currentGroup.role !== role || splitsAssistantCommentary || + splitsRuntimeActivity || (shouldSplitBySender && (currentGroup.senderLabel !== senderLabel || senderIdentityKey(currentGroup.sender) !== senderIdentityKey(sender))) @@ -542,14 +548,15 @@ export function assistantGroupCanOwnActiveRunStatus(group: MessageGroup): boolea return ( group.role.toLowerCase() === "assistant" && !assistantGroupIsForwardedBoundary(group) && - !group.messages.every( - ({ message }) => - asRecord(asRecord(message)?.["__openclaw"])?.runtimeActivityKind === "context_compaction", - ) && + !group.messages.every(({ message }) => isContextCompactionActivity(message)) && groupHasVisibleReplyContent(group) ); } +function isContextCompactionActivity(message: unknown): boolean { + return asRecord(asRecord(message)?.["__openclaw"])?.runtimeActivityKind === "context_compaction"; +} + // History carries no final-vs-commentary marker (commentary exists only as // live stream segments), so the last assistant group with visible content // stands in for the final reply. Turns whose last content is commentary diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 8c7154e12a7f..93919ac98e0f 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -757,6 +757,35 @@ describe("collapseCompletedTurnWork", () => { expect(requireGroup(items[2]).role).toBe("assistant"); }); + it("keeps durable context compaction inside completed work instead of treating it as the reply", () => { + const items = collapsedItems({ + messages: [ + userMessage("do it", 1_000), + { + role: "custom", + customType: "openclaw.context-compaction", + content: "Context compacted", + display: true, + excludeFromContext: true, + timestamp: 2_000, + }, + assistantMessage("All done.", 3_000), + ], + }); + + expect(items.map((item) => item.kind)).toEqual(["group", "work-group", "group"]); + const work = requireWorkGroup(items[1]); + expect(work.groups).toHaveLength(1); + expect(work.groups[0]?.messages[0]?.message).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "Context compacted" }], + __openclaw: { runtimeActivityKind: "context_compaction" }, + }); + expect(requireGroup(items[2]).messages[0]?.message).toMatchObject({ + content: "All done.", + }); + }); + it.each([ { role: "assistant", From f65b7e06eaa66b9ce5fe2b3ca3549fbb7834acaa Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 21 Aug 2026 16:08:26 +0100 Subject: [PATCH 014/124] fix: persist manual Codex compactions (#127230) --- extensions/codex/src/app-server/compact.ts | 22 +++++++++++++++++-- .../context-compaction-activity.test.ts | 16 ++++++-------- .../app-server/context-compaction-activity.ts | 15 ++++++++----- .../codex/src/app-server/event-projector.ts | 5 ++++- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 2309e3c06ea0..bd516c0b80be 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -32,6 +32,7 @@ import { type CodexAppServerLiveThreadOwnership, } from "./client-runtime.js"; import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js"; +import { persistCodexContextCompactionActivity } from "./context-compaction-activity.js"; import { readCodexThreadContextSnapshot } from "./event-projector-usage.js"; import { readCodexNotificationThreadId, @@ -70,7 +71,7 @@ type CodexAppServerCompactOptions = { }; type CodexNativeCompactionCompletion = - | { completed: true; tokensAfter?: number } + | { completed: true; turnId?: string; itemId?: string; tokensAfter?: number } | { completed: false; reason: string }; function watchCodexNativeCompactionCompletion(params: { @@ -111,7 +112,12 @@ function watchCodexNativeCompactionCompletion(params: { resolveCompletion(result); }; const complete = () => - finish({ completed: true, ...(tokensAfter !== undefined ? { tokensAfter } : {}) }); + finish({ + completed: true, + ...(compactionTurnId ? { turnId: compactionTurnId } : {}), + ...(compactionItemId ? { itemId: compactionItemId } : {}), + ...(tokensAfter !== undefined ? { tokensAfter } : {}), + }); const fail = (reason: string) => finish({ completed: false, reason }); const retireUnconfirmed = (reason: string) => { if (settled || retirementStarted) { @@ -742,6 +748,18 @@ async function compactCodexNativeThread( throw new Error(completion.reason); } tokensAfter = completion.tokensAfter; + if (completion.turnId && completion.itemId) { + await persistCodexContextCompactionActivity({ + sessionTarget: params.sessionTarget, + config: params.config, + cwd: params.workspaceDir, + runId: params.runId, + threadId: binding.threadId, + turnId: completion.turnId, + itemId: completion.itemId, + timestamp: Date.now(), + }); + } embeddedAgentLog.info("completed codex app-server compaction", { sessionId: params.sessionId, threadId: binding.threadId, diff --git a/extensions/codex/src/app-server/context-compaction-activity.test.ts b/extensions/codex/src/app-server/context-compaction-activity.test.ts index 49bb90d820e0..19e7a03d5c02 100644 --- a/extensions/codex/src/app-server/context-compaction-activity.test.ts +++ b/extensions/codex/src/app-server/context-compaction-activity.test.ts @@ -28,15 +28,13 @@ describe("persistCodexContextCompactionActivity", () => { messageId: "activity-message", }); const params = { - run: { - runId: "run-1", - workspaceDir: "/workspace", - sessionTarget: { - agentId: "main", - sessionId: "session-1", - sessionKey: "agent:main:dashboard:session-1", - storePath: "/state/openclaw-agent.sqlite", - }, + runId: "run-1", + cwd: "/workspace", + sessionTarget: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:dashboard:session-1", + storePath: "/state/openclaw-agent.sqlite", }, threadId: "thread-1", turnId: "turn-1", diff --git a/extensions/codex/src/app-server/context-compaction-activity.ts b/extensions/codex/src/app-server/context-compaction-activity.ts index 8ce22be5a6ec..7b55635809d6 100644 --- a/extensions/codex/src/app-server/context-compaction-activity.ts +++ b/extensions/codex/src/app-server/context-compaction-activity.ts @@ -11,13 +11,16 @@ import { const CONTEXT_COMPACTION_CUSTOM_TYPE = "openclaw.context-compaction"; export async function persistCodexContextCompactionActivity(params: { - run: EmbeddedRunAttemptParams; + sessionTarget?: EmbeddedRunAttemptParams["sessionTarget"]; + config?: EmbeddedRunAttemptParams["config"]; + cwd?: string; + runId?: string; threadId: string; turnId: string; itemId: string; timestamp: number; }): Promise { - const target = params.run.sessionTarget; + const target = params.sessionTarget; if (!target?.sessionId || !target.sessionKey || !target.storePath) { return; } @@ -34,7 +37,7 @@ export async function persistCodexContextCompactionActivity(params: { threadId: params.threadId, turnId: params.turnId, itemId: params.itemId, - runId: params.run.runId, + ...(params.runId ? { runId: params.runId } : {}), }, timestamp: params.timestamp, idempotencyKey: activityId, @@ -45,8 +48,8 @@ export async function persistCodexContextCompactionActivity(params: { sessionId: target.sessionId, sessionKey: target.sessionKey, storePath: target.storePath, - config: params.run.config, - cwd: params.run.workspaceDir, + config: params.config, + cwd: params.cwd, eventId: activityId, message, }); @@ -61,7 +64,7 @@ export async function persistCodexContextCompactionActivity(params: { update: { message: appended.message, messageId: appended.messageId, - runId: params.run.runId, + ...(params.runId ? { runId: params.runId } : {}), }, }); } catch (error) { diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 6fab913ef4b7..d1a81bd14571 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -522,7 +522,10 @@ export class CodexAppServerEventProjector { }, }); await persistCodexContextCompactionActivity({ - run: this.params, + sessionTarget: this.params.sessionTarget, + config: this.params.config, + cwd: this.params.workspaceDir, + runId: this.params.runId, threadId: this.threadId, turnId: this.turnId, itemId, From 0095168ae624810385869370979ffa467739b28c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 08:38:57 -0700 Subject: [PATCH 015/124] fix(cli): render Nodes validation failures (#127223) --- src/cli/nodes-cli.coverage.test.ts | 66 +++++++++++++++++ src/cli/nodes-cli/register.invoke.ts | 7 +- src/cli/nodes-cli/register.pairing.ts | 6 +- src/cli/nodes-cli/register.status.ts | 20 ++---- src/cli/program.nodes-basic.e2e.test.ts | 57 +++++++++++++++ test/cli-json-stdout.e2e.test.ts | 95 +++++++++++++++++++++++++ 6 files changed, 226 insertions(+), 25 deletions(-) diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index 04572edf487b..a8f5ce050f52 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -249,6 +249,72 @@ describe("nodes-cli coverage", () => { expect(callGateway.mock.calls.map(([call]) => call.method)).toEqual(["node.pair.reject"]); }); + it.each([ + { + label: "status with an invalid last-connected duration", + command: "status", + args: ["nodes", "status", "--last-connected", "not-a-duration"], + message: "Invalid --last-connected: Invalid duration", + }, + { + label: "list with an invalid last-connected duration", + command: "list", + args: ["nodes", "list", "--last-connected", "not-a-duration"], + message: "Invalid --last-connected: Invalid duration", + }, + { + label: "status with an empty last-connected duration", + command: "status", + args: ["nodes", "status", "--last-connected", ""], + message: "Invalid --last-connected", + }, + { + label: "list with a blank last-connected duration", + command: "list", + args: ["nodes", "list", "--last-connected", " "], + message: "Invalid --last-connected", + }, + { + label: "invoke with a blank node", + command: "invoke", + args: ["nodes", "invoke", "--node", " ", "--command", "canvas.eval"], + message: "--node and --command required", + }, + { + label: "invoke with a blank command", + command: "invoke", + args: ["nodes", "invoke", "--node", "mac-1", "--command", " "], + message: "--node and --command required", + }, + { + label: "rename with a blank name", + command: "rename", + args: ["nodes", "rename", "--node", "mac-1", "--name", " "], + message: "--name must not be empty", + }, + ])("reports $label once before calling the gateway", async ({ command, args, message }) => { + await expect(sharedProgram.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1"); + + expect(callGateway).not.toHaveBeenCalled(); + expect(runtimeErrors).toEqual([expect.stringContaining(`nodes ${command} failed: ${message}`)]); + expect(defaultRuntime.exit).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["invoke node", ["nodes", "invoke", "--command", "canvas.eval"]], + ["invoke command", ["nodes", "invoke", "--node", "mac-1"]], + ["rename name", ["nodes", "rename", "--node", "mac-1"]], + ])("preserves Commander validation for a missing %s", async (_label, args) => { + await withSuppressedStderr(async () => { + await expect(sharedProgram.parseAsync(args, { from: "user" })).rejects.toMatchObject({ + code: "commander.missingMandatoryOptionValue", + }); + }); + + expect(callGateway).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + }); + it("blocks system.run on nodes invoke", async () => { await expect( sharedProgram.parseAsync(["nodes", "invoke", "--node", "mac-1", "--command", "system.run"], { diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 0eddf77aec90..834244fa5493 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -6,7 +6,7 @@ import { import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; -import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; +import { runNodesCommand } from "./cli-utils.js"; import { callNodesGatewayCli, nodesCallOpts, @@ -41,10 +41,7 @@ export function registerNodesInvokeCommands(nodes: Command) { const nodeQuery = normalizeOptionalString(opts.node) ?? ""; const command = normalizeOptionalString(opts.command) ?? ""; if (!nodeQuery || !command) { - const { error } = getNodesTheme(); - defaultRuntime.error(error("--node and --command required")); - defaultRuntime.exit(1); - return; + throw new Error("--node and --command required"); } if (BLOCKED_NODE_INVOKE_COMMANDS.has(normalizeLowercaseStringOrEmpty(command))) { throw new Error( diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index aaea23ff46f4..ac5111ba86a8 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -230,15 +230,13 @@ export function registerNodesPairingCommands(nodes: Command) { .requiredOption("--name ", "New display name") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("rename", async () => { - const nodeId = await resolveCliNodeId(opts, normalizeOptionalString(opts.node) ?? ""); const name = normalizeOptionalString(opts.name) ?? ""; if (!name) { - defaultRuntime.error( + throw new Error( `--name must not be empty. Run ${formatCliCommand("openclaw nodes list")} to see paired nodes, then rerun with --name .`, ); - defaultRuntime.exit(1); - return; } + const nodeId = await resolveCliNodeId(opts, normalizeOptionalString(opts.node) ?? ""); const result = await callNodesGatewayCli("node.rename", opts, { nodeId, displayName: name, diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 00656fce3502..1f18927f0cb6 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -160,26 +160,14 @@ function formatPendingApprovalCommand(raw: unknown, opts: NodesRpcOpts): string return formatCliCommand(args.map(quoteCliArg).join(" ")); } -function parseSinceMs(raw: unknown, label: string): number | undefined { - if (raw === undefined || raw === null) { - return undefined; - } - const value = normalizeOptionalString(raw) ?? (typeof raw === "number" ? String(raw) : null); - if (value === null) { - defaultRuntime.error(`${label}: invalid duration value`); - defaultRuntime.exit(1); - return undefined; - } - if (!value) { +function parseSinceMs(raw: string | undefined, label: string): number | undefined { + if (raw === undefined) { return undefined; } try { - return parseDurationMs(value); + return parseDurationMs(raw); } catch (err) { - const message = formatErrorMessage(err); - defaultRuntime.error(`${label}: ${message}`); - defaultRuntime.exit(1); - return undefined; + throw new Error(`${label}: ${formatErrorMessage(err)}`, { cause: err }); } } diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index 6fffc3d41b8b..677ee4979eb5 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -384,6 +384,37 @@ describe("cli program (nodes basics)", () => { expect(output).not.toContain("Two"); }); + it.each([ + { command: "status", duration: "24h" }, + { command: "status", duration: "1h30m" }, + { command: "status", duration: "0" }, + { command: "status", duration: " 24H " }, + { command: "list", duration: "24h" }, + { command: "list", duration: "1h30m" }, + { command: "list", duration: "0" }, + { command: "list", duration: " 24H " }, + ])("preserves nodes $command --last-connected $duration", async ({ command, duration }) => { + const node = { + nodeId: "recent-node", + displayName: "Recent Node", + paired: true, + connected: true, + lastConnectedAtMs: Date.now() + 60_000, + }; + programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { + const { method } = (args[0] ?? {}) as { method?: string }; + return method === "node.pair.list" ? { pending: [], paired: [node] } : { nodes: [node] }; + }); + + await runProgram(["nodes", command, "--last-connected", duration, "--json"]); + + const result = writeJsonArgAt(0) as { + nodes?: Array<{ nodeId: string }>; + paired?: Array<{ nodeId: string }>; + }; + expect((result.nodes ?? result.paired)?.map(({ nodeId }) => nodeId)).toEqual(["recent-node"]); + }); + it.each([ { label: "paired node details", @@ -848,6 +879,32 @@ describe("cli program (nodes basics)", () => { expectGatewayRequest("node.pair.remove", { nodeId: "ios-node" }); }); + it("runs nodes rename and preserves the successful node.rename payload", async () => { + programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { + const { method } = (args[0] ?? {}) as { method?: string }; + return method === "node.list" + ? { nodes: [{ nodeId: "ios-node", displayName: "iOS Node", paired: true }] } + : { ok: true, nodeId: "ios-node", displayName: "Renamed Node" }; + }); + + await runProgram([ + "nodes", + "rename", + "--node", + "iOS Node", + "--name", + " Renamed Node ", + "--json", + ]); + + expectGatewayRequest("node.rename", { nodeId: "ios-node", displayName: "Renamed Node" }); + expect(writeJsonArgAt(0)).toEqual({ + ok: true, + nodeId: "ios-node", + displayName: "Renamed Node", + }); + }); + it("runs nodes invoke and calls node.invoke", async () => { mockGatewayWithIosNodeListAnd("node.invoke", { ok: true, diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 9459e2544813..504721adac6c 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -379,6 +379,101 @@ describe("cli json stdout contract", () => { ); }); + it.each([ + { + name: "status with an invalid duration in human mode", + args: ["nodes", "status", "--last-connected", "not-a-duration"], + message: "Invalid --last-connected: Invalid duration", + human: true, + }, + { + name: "status with JSON before its invalid duration", + args: ["nodes", "status", "--json", "--last-connected", "not-a-duration"], + message: "Invalid --last-connected: Invalid duration", + }, + { + name: "status with JSON after its invalid duration", + args: ["nodes", "status", "--last-connected", "not-a-duration", "--json"], + message: "Invalid --last-connected: Invalid duration", + }, + { + name: "list with an invalid duration in human mode", + args: ["nodes", "list", "--last-connected", "not-a-duration"], + message: "Invalid --last-connected: Invalid duration", + human: true, + }, + { + name: "list with JSON before its invalid duration", + args: ["nodes", "list", "--json", "--last-connected", "not-a-duration"], + message: "Invalid --last-connected: Invalid duration", + }, + { + name: "list with JSON after its invalid duration", + args: ["nodes", "list", "--last-connected", "not-a-duration", "--json"], + message: "Invalid --last-connected: Invalid duration", + }, + { + name: "invoke with an explicitly JSON blank node", + args: ["nodes", "invoke", "--node", " ", "--command", "canvas.eval", "--json"], + message: "--node and --command required", + }, + { + name: "invoke with an implicitly JSON blank node", + args: ["nodes", "invoke", "--node", " ", "--command", "canvas.eval"], + message: "--node and --command required", + }, + { + name: "invoke with an explicitly JSON blank command", + args: ["nodes", "invoke", "--node", "mac-1", "--command", " ", "--json"], + message: "--node and --command required", + }, + { + name: "invoke with an implicitly JSON blank command", + args: ["nodes", "invoke", "--node", "mac-1", "--command", " "], + message: "--node and --command required", + }, + { + name: "rename with a blank name", + args: ["nodes", "rename", "--node", "mac-1", "--name", " ", "--json"], + message: "--name must not be empty", + }, + ])("renders nodes $name through the shared validation owner", async (testCase) => { + await withTempHome( + async (tempHome) => { + const denyNetwork = Buffer.from( + `import net from "node:net"; + net.Socket.prototype.connect = function () { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); }; + globalThis.fetch = async () => { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };`, + ).toString("base64"); + const result = runBuiltCli(tempHome, testCase.args, { + NODE_OPTIONS: `--permission --allow-fs-read=* --import=data:text/javascript;base64,${denyNetwork}`, + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_NO_RESPAWN: "1", + OPENCLAW_LOG_LEVEL: "silent", + OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"), + OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"), + }); + + expect(result.status, result.stderr).toBe(1); + if ("human" in testCase && testCase.human) { + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`nodes ${testCase.args[1]} failed:`); + } else { + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining(testCase.message), + }, + }); + } + expect(result.stderr).toContain(testCase.message); + expect(result.stderr).not.toContain("AUTOQA_NETWORK_FORBIDDEN"); + }, + { prefix: "openclaw-nodes-json-failure-e2e-" }, + ); + }); + it.each([ { name: "search with a leaf JSON flag", From 5eb28a3b404c24006f94a811d4666d46af5e3b28 Mon Sep 17 00:00:00 2001 From: Yuval Dinodia <102706514+yetval@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:41:42 -0400 Subject: [PATCH 016/124] fix(outbound): retry queued deliveries when a channel adapter is unavailable (#126800) * fix(outbound): retry queued deliveries when a channel adapter is unavailable Delivery queue recovery classified "Outbound not configured for channel" as a permanent provider rejection, so a queued message and its spooled attachment were dead-lettered on the first attempt whenever the channel adapter was momentarily unresolvable. That string is a local adapter-resolution failure thrown by createChannelHandler, not a provider verdict, and the live send path already treats it as retryable. Remove the pattern so adapter unavailability consumes the normal retry budget. * fix(outbound): type adapter lookup failures --------- Co-authored-by: Altay --- src/infra/outbound/deliver-channel.ts | 5 ++- ...deliver.queue-adapter-availability.test.ts | 45 ++++++++++++++++++- src/infra/outbound/delivery-queue-recovery.ts | 1 - 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/infra/outbound/deliver-channel.ts b/src/infra/outbound/deliver-channel.ts index 0ca0b6449f8b..fba7a7388c6f 100644 --- a/src/infra/outbound/deliver-channel.ts +++ b/src/infra/outbound/deliver-channel.ts @@ -27,7 +27,7 @@ import type { DurableFinalDeliveryRequirements, OutboundDurableDeliverySupport, } from "./deliver-contracts.js"; -import type { OutboundDeliveryResult } from "./deliver-types.js"; +import { PlatformMessageNotDispatchedError, type OutboundDeliveryResult } from "./deliver-types.js"; import { attachOutboundDeliveryCommitHook, type OutboundDeliveryCommitHook, @@ -57,7 +57,8 @@ export async function createChannelHandler(params: ChannelHandlerParams): Promis return createPluginHandler({ ...params, outbound, message }); }); if (!handler) { - throw new Error(`Outbound not configured for channel: ${params.channel}`); + const message = `Outbound not configured for channel: ${params.channel}`; + throw new PlatformMessageNotDispatchedError(message, { cause: new Error(message) }); } return scopeChannelHandler(handler, pluginRegistry); } diff --git a/src/infra/outbound/deliver.queue-adapter-availability.test.ts b/src/infra/outbound/deliver.queue-adapter-availability.test.ts index 0fb905a2689a..2b0b489e7b27 100644 --- a/src/infra/outbound/deliver.queue-adapter-availability.test.ts +++ b/src/infra/outbound/deliver.queue-adapter-availability.test.ts @@ -8,13 +8,14 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../p import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { getDeliveryQueueEntryStatus } from "../delivery-queue-sqlite.js"; +import { PlatformMessageNotDispatchedError } from "./deliver-types.js"; import { boundedCronCompletionRetention, matrixOutboundForQueueTest, } from "./deliver.queue-integration.test-support.js"; import { OUTBOUND_DELIVERY_QUEUE_NAME } from "./delivery-queue-media-staging.js"; import { recoverPendingDeliveries, type DeliverFn } from "./delivery-queue-recovery.js"; -import { loadPendingDeliveries } from "./delivery-queue-storage.js"; +import { enqueueDelivery, loadPendingDeliveries } from "./delivery-queue-storage.js"; import { createRecoveryLog, installDeliveryQueueTmpDirHooks, @@ -132,6 +133,48 @@ describe("queued lazy outbound adapter availability", () => { ).toBe("completed"); }); + it("retains recovery custody when no outbound adapter can be resolved", async () => { + process.env.OPENCLAW_STATE_DIR = tmpDir; + setActivePluginRegistry(createEmptyPluginRegistry()); + const id = await enqueueDelivery( + { + channel: "missing-adapter-test", + to: "recipient", + payloads: [{ text: "retry after adapter resolution" }], + }, + tmpDir, + ); + let deliveryError: unknown; + const recoveryDeliver = vi.fn(async (params) => { + try { + return await deliverOutboundPayloads(params); + } catch (error) { + deliveryError = error; + throw error; + } + }); + + const result = await recoverPendingDeliveries({ + cfg: {} as OpenClawConfig, + deliver: recoveryDeliver, + log: createRecoveryLog(), + stateDir: tmpDir, + }); + + expect(result).toMatchObject({ failed: 1, recovered: 0, skippedMaxRetries: 0 }); + expect(recoveryDeliver).toHaveBeenCalledOnce(); + expect(deliveryError).toBeInstanceOf(PlatformMessageNotDispatchedError); + const pendingEntry = expectDefined( + (await loadPendingDeliveries(tmpDir))[0], + "retained adapter-miss delivery", + ); + expect(pendingEntry).toMatchObject({ id, retryCount: 1 }); + expect(pendingEntry.recoveryState).toBeUndefined(); + expect(pendingEntry.platformSendAttemptId).toBeUndefined(); + expect(pendingEntry.platformSendStartedAt).toBeUndefined(); + expect(getDeliveryQueueEntryStatus(OUTBOUND_DELIVERY_QUEUE_NAME, id, tmpDir)).toBe("pending"); + }); + it("does not replay a provider call that already crossed the ambiguous send boundary", async () => { process.env.OPENCLAW_STATE_DIR = tmpDir; setActivePluginRegistry( diff --git a/src/infra/outbound/delivery-queue-recovery.ts b/src/infra/outbound/delivery-queue-recovery.ts index ccbe174e18fc..928dbdcb2b1c 100644 --- a/src/infra/outbound/delivery-queue-recovery.ts +++ b/src/infra/outbound/delivery-queue-recovery.ts @@ -94,7 +94,6 @@ const PERMANENT_ERROR_PATTERNS: readonly RegExp[] = [ /forbidden: bot was kicked/i, /chat_id is empty/i, /recipient is not a valid/i, - /outbound not configured for channel/i, /ambiguous .* recipient/i, /User .* not in room/i, ]; From 9c64ed87e90d686677d6a9cfbef421457a4f268a Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 21 Aug 2026 08:50:34 -0700 Subject: [PATCH 017/124] test(agents): keep fallback E2E runtime coherent (#127079) --- src/agents/model-fallback.run-embedded.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 394b62b10a7d..6396b6d41b1f 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -46,6 +46,7 @@ const installRunEmbeddedMocks = () => { // exercises fallback orchestration without live model/provider calls. vi.doMock("../plugins/runtime.js", () => ({ getActivePluginRegistry: () => null, + getActivePluginRegistryWorkspaceDir: () => undefined, requireActivePluginRegistry: () => ({}), })); vi.doMock("./harness/runtime-plugin.js", () => ({ @@ -79,7 +80,6 @@ let runWithModelFallback: typeof import("./model-fallback-runner.js").runWithMod let runEmbeddedAgentEntry: typeof import("./embedded-agent-runner/run-entry.js").runEmbeddedAgentEntry; beforeAll(async () => { - vi.resetModules(); installRunEmbeddedMocks(); runEmbeddedAgent = wrapRunWithTestAdmission( (await import("./embedded-agent-runner/run.js")).runEmbeddedAgent, From 870f936851f9f7191586115a5a0132c2f4752d44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 08:52:12 -0700 Subject: [PATCH 018/124] perf(sessions): avoid full key tokenization (#127249) Amp-Thread-ID: https://ampcode.com/threads/T-01a02481-e149-77dd-a132-a2797e238ef8 Co-authored-by: Amp --- src/sessions/session-key-utils.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index 70600a8a1fb8..f09c3ab760e6 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -263,16 +263,16 @@ export function parseAgentSessionKey( if (!raw) { return null; } - const parts = raw.split(":"); - if (parts.length < 3 || !parts[1] || !parts[2]) { + if (!raw.startsWith("agent:")) { return null; } - if (parts[0] !== "agent") { + const agentIdEnd = raw.indexOf(":", "agent:".length); + if (agentIdEnd === -1) { return null; } - const agentId = normalizeOptionalString(parts[1]); - const rest = parts.slice(2).join(":"); - if (!agentId || !rest) { + const agentId = normalizeOptionalString(raw.slice("agent:".length, agentIdEnd)); + const rest = raw.slice(agentIdEnd + 1); + if (!agentId || !rest || rest.startsWith(":")) { return null; } return { agentId, rest }; From 407930cb6ac50d86bd349f8501c368df2470a294 Mon Sep 17 00:00:00 2001 From: WhatsSkiLL Date: Fri, 21 Aug 2026 17:54:47 +0200 Subject: [PATCH 019/124] feat(wear): add direct agent, session, and model pickers (#126863) * feat(wear): add searchable context pickers * fix(wear): simplify context picker rows * fix(wear): prioritize chat actions over context * fix(wear): keep chat follow above context controls * fix(wear): keep context labels localized * fix(wear): refine context picker layout and close * fix(wear): sync picker locale resources * fix(wear): negotiate picker search support --------- Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> --- apps/.i18n/native-source.json | 108 +++- apps/android/app/lint.xml | 8 + .../openclaw/app/wear/WearProxyController.kt | 33 +- .../app/wear/WearProxyControllerTest.kt | 63 +- .../ai/openclaw/wear/shared/WearProtocol.kt | 2 + .../openclaw/wear/shared/WearProtocolTest.kt | 2 + .../java/ai/openclaw/wear/MainActivity.kt | 50 +- .../ai/openclaw/wear/WearCompanionUiModels.kt | 37 ++ .../ai/openclaw/wear/WearGatewayRepository.kt | 17 +- .../main/java/ai/openclaw/wear/WearScreens.kt | 560 +++++++++++++----- .../java/ai/openclaw/wear/WearViewModel.kt | 143 ++++- .../wear/src/main/res/values/strings.xml | 13 +- .../java/ai/openclaw/wear/MainActivityTest.kt | 80 ++- .../wear/WearGatewayRepositoryTest.kt | 64 +- 14 files changed, 964 insertions(+), 216 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 23810ce0d2aa..d0790a611b60 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -961,6 +961,17 @@ } ] }, + { + "id": "native.android.adedee12882a5e48", + "source": "%1$s: %2$s", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.5b1f512f56214522", "source": "+${diff.added}", @@ -1433,6 +1444,17 @@ } ] }, + { + "id": "native.android.4e2d71e58e9b5d58", + "source": "Active on phone", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.ccd226a2c0e85242", "source": "Active task list is at its limit", @@ -3558,6 +3580,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -6479,6 +6505,28 @@ } ] }, + { + "id": "native.android.df6908aa0d65572b", + "source": "Find a model", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, + { + "id": "native.android.98780014fd21c179", + "source": "Find a session", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.e889efed8c081333", "source": "Find on ClawHub", @@ -8338,6 +8386,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -9344,17 +9396,6 @@ } ] }, - { - "id": "native.android.2e7778c3617afdb8", - "source": "Next %1$s", - "surface": "android", - "sites": [ - { - "kind": "resource-string", - "path": "apps/android/wear/src/main/res/values/strings.xml" - } - ] - }, { "id": "native.android.56de778dd64c4cba", "source": "Next Cycle", @@ -9697,6 +9738,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -10925,6 +10970,17 @@ } ] }, + { + "id": "native.android.86743582a5bdbdca", + "source": "Open on watch", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.bd4741b9b157a8b9", "source": "Open profile", @@ -12291,17 +12347,6 @@ } ] }, - { - "id": "native.android.ab710f2d490e510d", - "source": "Previous %1$s", - "surface": "android", - "sites": [ - { - "kind": "resource-string", - "path": "apps/android/wear/src/main/res/values/strings.xml" - } - ] - }, { "id": "native.android.f24b4fe4204360d9", "source": "Prioritizes connected Bluetooth microphones.", @@ -14411,6 +14456,10 @@ { "kind": "ui-named-argument", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -14469,6 +14518,17 @@ } ] }, + { + "id": "native.android.bcab8e57d5c3490d", + "source": "Search models", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.8263e0efaec65c14", "source": "Search proposals", @@ -14499,6 +14559,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, diff --git a/apps/android/app/lint.xml b/apps/android/app/lint.xml index e0fee5d8c7ca..be2c631b686f 100644 --- a/apps/android/app/lint.xml +++ b/apps/android/app/lint.xml @@ -8,6 +8,14 @@ + + + + + + + + diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt index dfd44ef55648..57091fc7d95a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt @@ -140,8 +140,14 @@ internal class WearProxyController( "capabilities", buildJsonArray { WearProxyCapability.entries - .filter { capability -> capability != WearProxyCapability.ModelControls || hasOperatorAdminScope() } - .forEach { capability -> add(JsonPrimitive(capability.wireValue)) } + .filter { capability -> + when (capability) { + WearProxyCapability.ModelControls, + WearProxyCapability.ModelCatalogSearch, + -> hasOperatorAdminScope() + else -> true + } + }.forEach { capability -> add(JsonPrimitive(capability.wireValue)) } }, ) activeAgentId()?.takeIf(String::isNotBlank)?.let { put("activeAgentId", it.takeCodePoints(MAX_AGENT_ID_CHARS)) } @@ -200,16 +206,24 @@ internal class WearProxyController( } private fun listModels(params: JsonObject): JsonObject { - params.requireOnly("selectedModelRef") + params.requireOnly("selectedModelRef", "query") + val query = params.optionalStringParam("query", MAX_SEARCH_QUERY_CHARS)?.trim().orEmpty() val selected = canonicalModelRef(params.optionalStringParam("selectedModelRef", MAX_MODEL_REF_CHARS)) ?: canonicalModelRef(selectedModelRef()) val availableModels = availableModels() - // The Watch picker moves one adjacent model at a time and reloads after each choice. + val matchingModels = + availableModels.filter { (ref, model) -> + query.isBlank() || model.name.contains(query, ignoreCase = true) || ref.contains(query, ignoreCase = true) + } + // Queries match the full catalog before the bounded transport response. + // Blank requests keep the selected model centered in the compact Watch list. // Centering keeps both directions reachable without exceeding the message cap. val selectedIndex = availableModels.indexOfFirst { (ref) -> ref == selected } val boundedModels = - if (availableModels.size <= MAX_MODEL_COUNT || selectedIndex < 0) { + if (query.isNotBlank()) { + matchingModels.take(MAX_MODEL_COUNT) + } else if (availableModels.size <= MAX_MODEL_COUNT || selectedIndex < 0) { availableModels.take(MAX_MODEL_COUNT) } else { val start = @@ -275,8 +289,10 @@ internal class WearProxyController( } private suspend fun listSessions(params: JsonObject): JsonObject { - params.requireOnly("limit", "selectedSessionKey") + params.requireOnly("limit", "offset", "search", "selectedSessionKey") val limit = params.intParam("limit", default = DEFAULT_SESSION_LIMIT, range = 1..MAX_SESSION_LIMIT) + val offset = params.optionalIntParam("offset", range = 0..MAX_SESSION_OFFSET) + val search = params.optionalStringParam("search", MAX_SEARCH_QUERY_CHARS)?.trim()?.takeIf(String::isNotEmpty) val selectedSessionKey = params.optionalStringParam("selectedSessionKey", MAX_SESSION_KEY_CHARS) val agentId = activeAgentId()?.trim()?.takeIf(String::isNotEmpty) val gatewayResult = @@ -284,6 +300,8 @@ internal class WearProxyController( "sessions.list", buildJsonObject { put("limit", limit) + offset?.let { put("offset", it) } + search?.let { put("search", it) } put("includeGlobal", false) put("includeUnknown", false) agentId?.let { put("agentId", it.takeCodePoints(MAX_AGENT_ID_CHARS)) } @@ -320,6 +338,7 @@ internal class WearProxyController( put("sessions", JsonArray(sessions)) agentId?.let { put("activeAgentId", it.takeCodePoints(MAX_AGENT_ID_CHARS)) } if (selectedSessionKey != null) put("selectedSessionValid", selectedSessionValid) + gatewayResult["nextOffset"].longPrimitiveOrNull()?.let { put("nextOffset", it) } gatewayResult["hasMore"].booleanPrimitiveOrNull()?.let { put("hasMore", it) } gatewayResult["totalCount"].longPrimitiveOrNull()?.let { put("totalCount", it) } } @@ -386,6 +405,8 @@ internal class WearProxyController( private companion object { const val DEFAULT_SESSION_LIMIT = 20 const val MAX_SESSION_LIMIT = 50 + const val MAX_SESSION_OFFSET = 100_000 + const val MAX_SEARCH_QUERY_CHARS = 200 const val DEFAULT_HISTORY_LIMIT = 20 const val MAX_HISTORY_LIMIT = 20 const val DEFAULT_HISTORY_CHARS = 2_000 diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt index 7d995ac604da..90501829f603 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt @@ -61,7 +61,7 @@ class WearProxyControllerTest { } @Test - fun statusAdvertisesModelControlsOnlyWithOperatorAdminScope() = + fun statusAdvertisesModelCapabilitiesOnlyWithOperatorAdminScope() = runTest { var hasOperatorAdminScope = false val controller = @@ -88,8 +88,10 @@ class WearProxyControllerTest { assertEquals( WearProxyCapability.entries - .filter { it != WearProxyCapability.ModelControls } - .map(WearProxyCapability::wireValue), + .filter { + it != WearProxyCapability.ModelControls && + it != WearProxyCapability.ModelCatalogSearch + }.map(WearProxyCapability::wireValue), limitedCapabilities, ) assertEquals( @@ -413,6 +415,43 @@ class WearProxyControllerTest { assertEquals("openai/gpt-59", refs.last()) } + @Test + fun modelSearchFiltersTheFullCatalogBeforeApplyingTheTransportCap() = + runTest { + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + models = { + (0 until 80).map { index -> + WearProxyModel(ref = "provider/model-$index", name = "Model $index") + } + }, + ) + + val listed = + controller.handle( + request( + WearRpcMethod.ModelsList, + buildJsonObject { put("query", "model-79") }, + ), + ) + val refs = + checkNotNull(listed.result) + .jsonObject + .getValue("models") + .jsonArray + .map { model -> + model.jsonObject + .getValue("ref") + .jsonPrimitive + .content + } + + assertEquals(listOf("provider/model-79"), refs) + } + @Test fun modelListWindowKeepsAdjacentModelsReachableAcrossTheCap() = runTest { @@ -613,7 +652,7 @@ class WearProxyControllerTest { requestedMethod = method requestedParams = params json.parseToJsonElement( - """{"sessions":[{"key":"agent:main","displayName":"Main","updatedAt":7,"modelProvider":"openai","model":"gpt-test","lastMessage":"hidden"}],"hasMore":true,"totalCount":9}""", + """{"sessions":[{"key":"agent:main","displayName":"Main","updatedAt":7,"modelProvider":"openai","model":"gpt-test","lastMessage":"hidden"}],"hasMore":true,"nextOffset":10,"totalCount":9}""", ) }, isGatewayConnected = { true }, @@ -625,14 +664,18 @@ class WearProxyControllerTest { controller.handle( request( WearRpcMethod.SessionsList, - buildJsonObject { put("limit", 5) }, + buildJsonObject { + put("limit", 5) + put("offset", 5) + put("search", "older") + }, ), ) assertEquals("sessions.list", requestedMethod) assertEquals( json - .parseToJsonElement("""{"limit":5,"includeGlobal":false,"includeUnknown":false,"agentId":"main"}""") + .parseToJsonElement("""{"limit":5,"offset":5,"search":"older","includeGlobal":false,"includeUnknown":false,"agentId":"main"}""") .jsonObject, requestedParams, ) @@ -654,6 +697,14 @@ class WearProxyControllerTest { .content .toBoolean(), ) + assertEquals( + 10, + result + .getValue("nextOffset") + .jsonPrimitive + .content + .toInt(), + ) } @Test diff --git a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt index 24a2f2ac7f42..ff54943cb18b 100644 --- a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt +++ b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt @@ -74,7 +74,9 @@ enum class WearProxyCapability( AgentControls(wireValue = "agent-controls"), GatewayControls(wireValue = "gateway-controls"), ModelControls(wireValue = "model-controls"), + ModelCatalogSearch(wireValue = "model-catalog-search"), SessionSelectionLookup(wireValue = "session-selection-lookup"), + SessionSearchPagination(wireValue = "session-search-pagination"), AgentPulse(wireValue = "agent-pulse"), AttemptScopedRealtimeAudio(wireValue = "attempt-scoped-realtime-audio"), ; diff --git a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt index 86242ca5d0c5..276b05e9958b 100644 --- a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt +++ b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt @@ -143,7 +143,9 @@ class WearProtocolTest { assertEquals("agent-controls", WearProxyCapability.AgentControls.wireValue) assertEquals("gateway-controls", WearProxyCapability.GatewayControls.wireValue) assertEquals("model-controls", WearProxyCapability.ModelControls.wireValue) + assertEquals("model-catalog-search", WearProxyCapability.ModelCatalogSearch.wireValue) assertEquals("session-selection-lookup", WearProxyCapability.SessionSelectionLookup.wireValue) + assertEquals("session-search-pagination", WearProxyCapability.SessionSearchPagination.wireValue) assertEquals("agent-pulse", WearProxyCapability.AgentPulse.wireValue) assertEquals( "attempt-scoped-realtime-audio", diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt index a456a8f4ffde..650f1030d994 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.runtime.Composable @@ -203,6 +204,9 @@ internal fun OpenClawWearApp( val messageLabel = stringResource(R.string.message) val messageTitle = stringResource(R.string.message_agent) val sendLabel = stringResource(R.string.send) + val sessionSearchTitle = stringResource(R.string.search_sessions_title) + val modelSearchTitle = stringResource(R.string.search_models_title) + val searchLabel = stringResource(R.string.search) fun submitMessage(rawMessage: String) { val message = rawMessage.trim() @@ -254,6 +258,42 @@ internal fun OpenClawWearApp( interaction = WearInteractionState.READY } } + val sessionSearchLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val query = + result.data + ?.let(RemoteInput::getResultsFromIntent) + ?.getCharSequence(REMOTE_INPUT_KEY) + ?.toString() + if (result.resultCode == Activity.RESULT_OK && !query.isNullOrBlank()) { + viewModel.searchSessions(query) + } + } + val modelSearchLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val query = + result.data + ?.let(RemoteInput::getResultsFromIntent) + ?.getCharSequence(REMOTE_INPUT_KEY) + ?.toString() + if (result.resultCode == Activity.RESULT_OK && !query.isNullOrBlank()) { + viewModel.searchModels(query) + } + } + + fun launchSearchInput( + title: String, + launcher: ActivityResultLauncher, + ) { + val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY).setLabel(searchLabel).build() + val intent = + RemoteInputIntentHelper.createActionRemoteInputIntent().also { inputIntent -> + RemoteInputIntentHelper.putRemoteInputsExtra(inputIntent, listOf(remoteInput)) + RemoteInputIntentHelper.putTitleExtra(inputIntent, title) + RemoteInputIntentHelper.putConfirmLabelExtra(inputIntent, searchLabel) + } + launcher.launch(intent) + } fun startRealtimeTalk() { speaker.stop() @@ -466,15 +506,23 @@ internal fun OpenClawWearApp( viewModel.selectAgent(agentId) }, onSelectSession = { sessionKey -> - state.sessions.firstOrNull { it.key == sessionKey }?.let { session -> + ( + state.sessionSearchResults.firstOrNull { it.key == sessionKey } + ?: state.sessions.firstOrNull { it.key == sessionKey } + )?.let { session -> leaveConversationContext() viewModel.openSession(session) } }, + onSearchSessions = { launchSearchInput(sessionSearchTitle, sessionSearchLauncher) }, + onLoadMoreSessionSearch = viewModel::loadMoreSessionSearch, + onClearSessionSearch = viewModel::clearSessionSearch, onSelectModel = { modelRef -> leaveConversationContext() viewModel.selectModel(modelRef) }, + onSearchModels = { launchSearchInput(modelSearchTitle, modelSearchLauncher) }, + onClearModelSearch = viewModel::clearModelSearch, onAgentPulseVisibilityChanged = viewModel::setAgentPulseVisible, onAgentPulseRefresh = viewModel::refreshAgentPulse, onRefresh = viewModel::refresh, diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt index 0b3b745363f5..7d46e5dca37a 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt @@ -34,6 +34,8 @@ internal data class WearSessionSummary( val title: String?, val updatedAtEpochMillis: Long?, val selected: Boolean, + val activeOnPhone: Boolean = false, + val openOnWatch: Boolean = false, ) internal data class WearModelSummary( @@ -49,9 +51,17 @@ internal data class WearConversationSnapshot( val agentControlsSupported: Boolean = false, val gatewayControlsSupported: Boolean = false, val activeSessionId: String? = null, + val phoneActiveSessionId: String? = null, val sessions: List = emptyList(), + val sessionSearchQuery: String? = null, + val sessionSearchResults: List = emptyList(), + val sessionSearchHasMore: Boolean = false, + val sessionSearchSupported: Boolean = false, val models: List = emptyList(), + val modelSearchQuery: String? = null, + val modelSearchResults: List = emptyList(), val modelControlsSupported: Boolean = false, + val modelSearchSupported: Boolean = false, val messages: List = emptyList(), val streamingAssistantText: String? = null, val pendingRunCount: Int = 0, @@ -103,6 +113,7 @@ internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? { agentControlsSupported = WearProxyCapability.AgentControls in proxyCapabilities, gatewayControlsSupported = WearProxyCapability.GatewayControls in proxyCapabilities, activeSessionId = selectedSession?.key, + phoneActiveSessionId = phoneActiveSessionKey, sessions = sessions.map { session -> WearSessionSummary( @@ -110,8 +121,24 @@ internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? { title = session.title, updatedAtEpochMillis = session.updatedAt, selected = session.key == selectedSession?.key, + activeOnPhone = session.key == phoneActiveSessionKey, + openOnWatch = session.key == selectedSession?.key, ) }, + sessionSearchQuery = sessionSearchQuery, + sessionSearchResults = + sessionSearchResults.map { session -> + WearSessionSummary( + id = session.key, + title = session.title, + updatedAtEpochMillis = session.updatedAt, + selected = session.key == selectedSession?.key, + activeOnPhone = session.key == phoneActiveSessionKey, + openOnWatch = session.key == selectedSession?.key, + ) + }, + sessionSearchHasMore = sessionSearchHasMore, + sessionSearchSupported = WearProxyCapability.SessionSearchPagination in proxyCapabilities, models = models.map { model -> WearModelSummary( @@ -121,6 +148,16 @@ internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? { ) }, modelControlsSupported = WearProxyCapability.ModelControls in proxyCapabilities, + modelSearchSupported = WearProxyCapability.ModelCatalogSearch in proxyCapabilities, + modelSearchQuery = modelSearchQuery, + modelSearchResults = + modelSearchResults.map { model -> + WearModelSummary( + ref = model.ref, + name = model.name, + selected = model.ref == selectedModelRef, + ) + }, messages = messages, streamingAssistantText = streamText, pendingRunCount = if (activeRunId != null) 1 else 0, diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt index d128a908c4e5..eca56f8cabf4 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt @@ -117,6 +117,8 @@ internal data class WearSessionList( val eventStreamId: String? = null, val activeAgentId: String? = null, val selectedSessionValid: Boolean = false, + val hasMore: Boolean = false, + val nextOffset: Int? = null, ) internal data class WearModel( @@ -286,6 +288,7 @@ internal class WearGatewayRepository( expectedNodeId: String, capabilities: Set, selectedModelRef: String? = null, + query: String? = null, ): WearModelList { capabilities.require(WearProxyCapability.ModelControls) val response = @@ -293,6 +296,9 @@ internal class WearGatewayRepository( WearRpcMethod.ModelsList, buildJsonObject { selectedModelRef?.let { put("selectedModelRef", it) } + if (WearProxyCapability.ModelCatalogSearch in capabilities) { + query?.takeIf(String::isNotBlank)?.let { put("query", it) } + } }, expectedNodeId, requirePreferredNode = true, @@ -370,13 +376,20 @@ internal class WearGatewayRepository( expectedNodeId: String? = null, selectedSessionKey: String? = null, capabilities: Set = emptySet(), + limit: Int = 30, + offset: Int? = null, + search: String? = null, ): WearSessionList { val response = requester .request( WearRpcMethod.SessionsList, buildJsonObject { - put("limit", 30) + put("limit", limit) + if (WearProxyCapability.SessionSearchPagination in capabilities) { + offset?.let { put("offset", it) } + search?.takeIf(String::isNotBlank)?.let { put("search", it) } + } if (WearProxyCapability.SessionSelectionLookup in capabilities) { selectedSessionKey?.takeIf(String::isNotBlank)?.let { put("selectedSessionKey", it) } } @@ -394,6 +407,8 @@ internal class WearGatewayRepository( phoneNodeId = response.sourceNodeId, activeAgentId = result.string("activeAgentId"), selectedSessionValid = result.boolean("selectedSessionValid") ?: false, + hasMore = result.boolean("hasMore") ?: false, + nextOffset = result.long("nextOffset")?.toInt(), ) } diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt index 851c5f3786b8..a37ccf9e1e8b 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt @@ -166,6 +166,11 @@ internal fun OpenClawWearScreens( onSelectAgent: (String) -> Unit, onSelectSession: (String) -> Unit, onSelectModel: (String) -> Unit, + onSearchSessions: () -> Unit = {}, + onLoadMoreSessionSearch: () -> Unit = {}, + onClearSessionSearch: () -> Unit = {}, + onSearchModels: () -> Unit = {}, + onClearModelSearch: () -> Unit = {}, onAgentPulseVisibilityChanged: (Boolean) -> Unit = {}, onAgentPulseRefresh: () -> Unit = {}, onRefresh: () -> Unit, @@ -299,6 +304,11 @@ internal fun OpenClawWearScreens( onSelectAgent = onSelectAgent, onSelectSession = onSelectSession, onSelectModel = onSelectModel, + onSearchSessions = onSearchSessions, + onLoadMoreSessionSearch = onLoadMoreSessionSearch, + onClearSessionSearch = onClearSessionSearch, + onSearchModels = onSearchModels, + onClearModelSearch = onClearModelSearch, onSpeakLatest = onSpeakLatest, onStopSpeaking = onStopSpeaking, ) @@ -372,6 +382,11 @@ private fun ChatPage( onSelectAgent: (String) -> Unit, onSelectSession: (String) -> Unit, onSelectModel: (String) -> Unit, + onSearchSessions: () -> Unit, + onLoadMoreSessionSearch: () -> Unit, + onClearSessionSearch: () -> Unit, + onSearchModels: () -> Unit, + onClearModelSearch: () -> Unit, onSpeakLatest: () -> Unit, onStopSpeaking: () -> Unit, ) { @@ -385,8 +400,6 @@ private fun ChatPage( visibleMessageCount = visibleMessages.size, hasStreaming = streamingText != null, canAbort = canAbort, - hasAssistant = hasAssistant, - hasFailure = snapshot.failure != null, ) val contentRevision = wearChatContentRevision( @@ -396,6 +409,25 @@ private fun ChatPage( latestAnchorIndex = latestAnchorIndex, ) var followState by remember(snapshot.activeSessionId) { mutableStateOf(WearThreadFollowState()) } + var contextPicker by remember { mutableStateOf(null) } + + fun clearContextPickerSearch() { + when (contextPicker) { + WearContextPicker.Session -> onClearSessionSearch() + WearContextPicker.Model -> onClearModelSearch() + else -> Unit + } + } + + fun finishContextPicker() { + clearContextPickerSearch() + contextPicker = null + } + + fun closeContextPicker() { + clearContextPickerSearch() + contextPicker = contextPicker?.let(::wearContextPickerAfterClose) + } LaunchedEffect(listState, snapshot.activeSessionId) { snapshotFlow { @@ -429,15 +461,6 @@ private fun ChatPage( pageLabel = stringResource(R.string.chat), listState = listState, ) { - item { - ConversationIdentity( - snapshot = snapshot, - actionBusy = actionBusy, - onSelectAgent = onSelectAgent, - onSelectSession = onSelectSession, - onSelectModel = onSelectModel, - ) - } item { ConversationStatus( interaction = interaction, @@ -445,28 +468,6 @@ private fun ChatPage( gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED, ) } - item { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ActionButton( - label = stringResource(R.string.talk), - enabled = inputEnabled && !actionBusy && !speaking, - onClick = onTalk, - modifier = Modifier.weight(1f), - ) - ActionButton( - label = stringResource(R.string.type), - enabled = inputEnabled && !actionBusy && !speaking, - onClick = onType, - modifier = Modifier.weight(1f), - ) - } - } if (canAbort) { item { SecondaryButton( @@ -492,6 +493,11 @@ private fun ChatPage( } } } + if (latestAnchorIndex >= 0) { + item(key = "chat-end") { + Spacer(modifier = Modifier.height(1.dp)) + } + } if (hasAssistant) { item { SecondaryButton( @@ -506,18 +512,42 @@ private fun ChatPage( ) } } + item { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ActionButton( + label = stringResource(R.string.talk), + enabled = inputEnabled && !actionBusy && !speaking, + onClick = onTalk, + modifier = Modifier.weight(1f), + ) + ActionButton( + label = stringResource(R.string.type), + enabled = inputEnabled && !actionBusy && !speaking, + onClick = onType, + modifier = Modifier.weight(1f), + ) + } + } + item { + ConversationContextPicker( + snapshot = snapshot, + actionBusy = actionBusy, + onOpenContextPicker = { contextPicker = WearContextPicker.Session }, + ) + } snapshot.failure?.let { failure -> item { InlineError(text = failureDetail(failure)) } } - if (latestAnchorIndex >= 0) { - item(key = "chat-end") { - Spacer(modifier = Modifier.height(1.dp)) - } - } } - if (followState.hasNewContent) { + if (followState.hasNewContent && contextPicker == null) { NewMessagesAction( modifier = Modifier @@ -532,6 +562,31 @@ private fun ChatPage( } } } + contextPicker?.let { picker -> + ContextPickerOverlay( + picker = picker, + snapshot = snapshot, + actionBusy = actionBusy, + onDismiss = ::closeContextPicker, + onOpenAgentPicker = { contextPicker = WearContextPicker.Agent }, + onOpenModelPicker = { contextPicker = WearContextPicker.Model }, + onSelectAgent = { agentId -> + onSelectAgent(agentId) + finishContextPicker() + }, + onSelectSession = { sessionId -> + onSelectSession(sessionId) + finishContextPicker() + }, + onSelectModel = { modelRef -> + onSelectModel(modelRef) + finishContextPicker() + }, + onSearchSessions = onSearchSessions, + onLoadMoreSessionSearch = onLoadMoreSessionSearch, + onSearchModels = onSearchModels, + ) + } } } @@ -1201,13 +1256,11 @@ internal fun wearChatLatestAnchorIndex( visibleMessageCount: Int, hasStreaming: Boolean, canAbort: Boolean, - hasAssistant: Boolean, - hasFailure: Boolean, ): Int { if (visibleMessageCount == 0 && !hasStreaming) return -1 return CHAT_FIXED_ITEM_COUNT + visibleMessageCount + - listOf(canAbort, hasStreaming, hasAssistant, hasFailure).count { it } + listOf(canAbort, hasStreaming).count { it } } internal fun wearThreadLatestAnchorIndex( @@ -1409,6 +1462,7 @@ private fun ControlsPage( onGatewayEnabledChange: (Boolean) -> Unit, ) { val gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED + WearPage(pageLabel = stringResource(R.string.controls)) { item { ConnectionPanel(snapshot = snapshot) @@ -1843,66 +1897,284 @@ private fun OpenClawHeader(pageLabel: String) { } @Composable -private fun ConversationIdentity( +private fun ConversationContextPicker( snapshot: WearConversationSnapshot, actionBusy: Boolean, + onOpenContextPicker: () -> Unit, +) { + val session = snapshot.sessions.firstOrNull(WearSessionSummary::selected) ?: snapshot.sessions.firstOrNull() + val agent = snapshot.agents.firstOrNull(WearAgentSummary::selected) ?: snapshot.agents.firstOrNull() + val model = snapshot.models.firstOrNull(WearModelSummary::selected) + val agentName = + listOfNotNull( + agent?.emoji?.takeIf(String::isNotBlank), + agent?.name ?: stringResource(R.string.agent), + ).joinToString(" ") + val modelName = model?.name ?: snapshot.selectedModelRef ?: stringResource(R.string.model) + ContextPickerOption( + title = + stringResource( + R.string.context_label_value, + stringResource(R.string.session), + session?.title ?: stringResource(R.string.current_session), + ), + detail = + stringResource( + R.string.context_label_value, + stringResource(R.string.agent), + agentName, + ), + status = + stringResource( + R.string.context_label_value, + stringResource(R.string.model), + modelName, + ), + selected = true, + enabled = !actionBusy, + onClick = onOpenContextPicker, + modifier = Modifier.padding(horizontal = 12.dp), + ) +} + +internal enum class WearContextPicker { + Agent, + Session, + Model, +} + +internal fun wearContextPickerAfterClose(picker: WearContextPicker): WearContextPicker? = + when (picker) { + WearContextPicker.Agent, WearContextPicker.Model -> WearContextPicker.Session + WearContextPicker.Session -> null + } + +@Composable +private fun ContextPickerOverlay( + picker: WearContextPicker, + snapshot: WearConversationSnapshot, + actionBusy: Boolean, + onDismiss: () -> Unit, + onOpenAgentPicker: () -> Unit, + onOpenModelPicker: () -> Unit, onSelectAgent: (String) -> Unit, onSelectSession: (String) -> Unit, onSelectModel: (String) -> Unit, + onSearchSessions: () -> Unit, + onLoadMoreSessionSearch: () -> Unit, + onSearchModels: () -> Unit, ) { - val agentIndex = snapshot.agents.indexOfFirst(WearAgentSummary::selected) - val sessionIndex = snapshot.sessions.indexOfFirst(WearSessionSummary::selected) - val modelIndex = snapshot.models.indexOfFirst(WearModelSummary::selected) - val agent = snapshot.agents.getOrNull(agentIndex) ?: snapshot.agents.firstOrNull() - val session = snapshot.sessions.getOrNull(sessionIndex) ?: snapshot.sessions.firstOrNull() - val model = snapshot.models.getOrNull(modelIndex) - Panel { - ContextPickerRow( - label = stringResource(R.string.agent), - value = - listOfNotNull( - agent?.emoji?.takeIf(String::isNotBlank), - agent?.name ?: stringResource(R.string.agent), - ).joinToString(" "), - previous = - snapshot.agents - .getOrNull(agentIndex - 1) - ?.takeIf { snapshot.agentControlsSupported && !actionBusy } - ?.let { previous -> ({ onSelectAgent(previous.id) }) }, - next = - snapshot.agents - .getOrNull(if (agentIndex < 0) 0 else agentIndex + 1) - ?.takeIf { snapshot.agentControlsSupported && !actionBusy } - ?.let { next -> ({ onSelectAgent(next.id) }) }, - ) - ContextPickerRow( - label = stringResource(R.string.session), - value = session?.title ?: stringResource(R.string.current_session), - previous = - snapshot.sessions - .getOrNull(sessionIndex - 1) - ?.takeIf { !actionBusy } - ?.let { previous -> ({ onSelectSession(previous.id) }) }, - next = - snapshot.sessions - .getOrNull(if (sessionIndex < 0) 0 else sessionIndex + 1) - ?.takeIf { !actionBusy } - ?.let { next -> ({ onSelectSession(next.id) }) }, - ) - ContextPickerRow( - label = stringResource(R.string.model), - value = model?.name ?: snapshot.selectedModelRef ?: stringResource(R.string.model), - previous = - snapshot.models - .getOrNull(modelIndex - 1) - ?.takeIf { snapshot.modelControlsSupported && !actionBusy } - ?.let { previous -> ({ onSelectModel(previous.ref) }) }, - next = - snapshot.models - .getOrNull(if (modelIndex < 0) 0 else modelIndex + 1) - ?.takeIf { snapshot.modelControlsSupported && !actionBusy } - ?.let { next -> ({ onSelectModel(next.ref) }) }, + BackHandler(onBack = onDismiss) + val pageLabel = + when (picker) { + WearContextPicker.Agent -> stringResource(R.string.agent) + WearContextPicker.Session -> stringResource(R.string.session) + WearContextPicker.Model -> stringResource(R.string.model) + } + WearPage(pageLabel = pageLabel) { + item { + SecondaryButton( + label = stringResource(R.string.close), + enabled = true, + onClick = onDismiss, + ) + } + if (picker == WearContextPicker.Session) { + item { + val agent = snapshot.agents.firstOrNull(WearAgentSummary::selected) ?: snapshot.agents.firstOrNull() + val model = snapshot.models.firstOrNull(WearModelSummary::selected) + Panel { + ContextPickerRow( + label = stringResource(R.string.agent), + value = + listOfNotNull( + agent?.emoji?.takeIf(String::isNotBlank), + agent?.name ?: stringResource(R.string.agent), + ).joinToString(" "), + onClick = onOpenAgentPicker.takeIf { snapshot.agentControlsSupported && !actionBusy }, + ) + ContextPickerDivider() + ContextPickerRow( + label = stringResource(R.string.model), + value = model?.name ?: snapshot.selectedModelRef ?: stringResource(R.string.model), + onClick = onOpenModelPicker.takeIf { snapshot.modelControlsSupported && !actionBusy }, + ) + } + } + if (snapshot.sessionSearchSupported) { + item { + SecondaryButton( + label = stringResource(R.string.search_sessions), + enabled = !actionBusy, + onClick = onSearchSessions, + ) + } + snapshot.sessionSearchQuery?.let { query -> + item { PickerQueryLabel(query = query) } + } + } + } + if (picker == WearContextPicker.Model && snapshot.modelSearchSupported) { + item { + SecondaryButton( + label = stringResource(R.string.search_models), + enabled = !actionBusy, + onClick = onSearchModels, + ) + } + snapshot.modelSearchQuery?.let { query -> + item { PickerQueryLabel(query = query) } + } + } + when (picker) { + WearContextPicker.Agent -> + snapshot.agents.forEach { agent -> + item(key = "agent:${agent.id}") { + ContextPickerOption( + title = listOfNotNull(agent.emoji?.takeIf(String::isNotBlank), agent.name).joinToString(" "), + detail = agent.id, + status = null, + selected = agent.selected, + enabled = !actionBusy, + onClick = { onSelectAgent(agent.id) }, + ) + } + } + WearContextPicker.Session -> { + val sessions = + if (snapshot.sessionSearchQuery == null) snapshot.sessions else snapshot.sessionSearchResults + if (sessions.isEmpty()) { + item { PickerEmptyResult() } + } + sessions.forEach { session -> + item(key = "session:${session.id}") { + val status = + listOfNotNull( + stringResource(R.string.active_on_phone).takeIf { session.activeOnPhone }, + stringResource(R.string.open_on_watch).takeIf { session.openOnWatch }, + ).joinToString(" / ").takeIf(String::isNotEmpty) + ContextPickerOption( + title = session.title ?: stringResource(R.string.current_session), + detail = null, + status = status, + selected = session.openOnWatch, + enabled = !actionBusy, + onClick = { onSelectSession(session.id) }, + ) + } + } + if ( + snapshot.sessionSearchSupported && + snapshot.sessionSearchQuery != null && + snapshot.sessionSearchHasMore + ) { + item { + SecondaryButton( + label = stringResource(R.string.load_more), + enabled = !actionBusy, + onClick = onLoadMoreSessionSearch, + ) + } + } + } + WearContextPicker.Model -> { + val models = + if (snapshot.modelSearchQuery == null) snapshot.models else snapshot.modelSearchResults + if (models.isEmpty()) { + item { PickerEmptyResult() } + } + models.forEach { model -> + item(key = "model:${model.ref}") { + ContextPickerOption( + title = model.name, + detail = model.ref, + status = null, + selected = model.selected, + enabled = !actionBusy, + onClick = { onSelectModel(model.ref) }, + ) + } + } + } + } + } +} + +@Composable +private fun PickerQueryLabel(query: String) { + Text( + text = query, + color = OpenClawWearTheme.colors.textMuted, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun PickerEmptyResult() { + Text( + text = stringResource(R.string.no_matches), + color = OpenClawWearTheme.colors.textMuted, + fontSize = 12.sp, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun ContextPickerOption( + title: String, + detail: String?, + status: String?, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = OpenClawWearTheme.colors + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .then( + Modifier.border( + width = 1.dp, + color = if (selected) colors.primary else colors.border, + shape = RoundedCornerShape(14.dp), + ), + ).padding(horizontal = 12.dp, vertical = 9.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + color = if (enabled) colors.text else colors.textMuted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) + detail?.takeIf(String::isNotBlank)?.let { + Text( + text = it, + color = colors.textMuted, + fontSize = 9.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + status?.let { + Text( + text = it, + color = colors.primary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } } } @@ -1910,80 +2182,50 @@ private fun ConversationIdentity( private fun ContextPickerRow( label: String, value: String, - previous: (() -> Unit)?, - next: (() -> Unit)?, + onClick: (() -> Unit)?, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + val colors = OpenClawWearTheme.colors + val enabled = onClick != null + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable( + enabled = enabled, + role = Role.Button, + onClick = { onClick?.invoke() }, + ).padding(vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - PickerChevron( - glyph = "‹", - contentDescription = stringResource(R.string.previous_item, label), - onClick = previous, + Text( + text = localizedWearUppercase(label), + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + maxLines = 1, ) - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = localizedWearUppercase(label), - color = OpenClawWearTheme.colors.textMuted, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.8.sp, - maxLines = 1, - ) - Text( - text = value, - color = OpenClawWearTheme.colors.text, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - PickerChevron( - glyph = "›", - contentDescription = stringResource(R.string.next_item, label), - onClick = next, + Text( + text = value, + color = if (enabled) colors.text else colors.textMuted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @Composable -private fun PickerChevron( - glyph: String, - contentDescription: String, - onClick: (() -> Unit)?, -) { - val colors = OpenClawWearTheme.colors - val enabled = onClick != null +private fun ContextPickerDivider() { Box( modifier = Modifier - // Foundation clickable expands hit testing to the system minimum touch target. - // Compact visual bounds keep picker values readable on 192dp round screens. - .width(32.dp) - .height(30.dp) - .semantics { this.contentDescription = contentDescription } - .clickable( - enabled = enabled, - role = Role.Button, - onClick = { onClick?.invoke() }, - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = glyph, - color = if (enabled) colors.primary else colors.textMuted.copy(alpha = 0.42f), - fontSize = 24.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - } + .fillMaxWidth() + .height(1.dp) + .background(OpenClawWearTheme.colors.borderStrong.copy(alpha = 0.45f)), + ) } @Composable @@ -2498,6 +2740,6 @@ private fun failureDetail(failure: WearConversationFailure?): String = -> stringResource(R.string.try_again) } -private const val CHAT_FIXED_ITEM_COUNT = 4 +private const val CHAT_FIXED_ITEM_COUNT = 2 private const val VISIBLE_MESSAGE_COUNT = 8 private const val VISIBLE_REALTIME_ENTRY_COUNT = 6 diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt index 8c997c524d88..534c4cb515f5 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt @@ -34,6 +34,13 @@ internal data class WearUiState( val proxyCapabilities: Set = emptySet(), val sessions: List = emptyList(), val selectedSession: WearSession? = null, + val phoneActiveSessionKey: String? = null, + val sessionSearchQuery: String? = null, + val sessionSearchResults: List = emptyList(), + val sessionSearchHasMore: Boolean = false, + val sessionSearchNextOffset: Int? = null, + val modelSearchQuery: String? = null, + val modelSearchResults: List = emptyList(), val messages: List = emptyList(), val streamText: String? = null, val activeRunId: String? = null, @@ -63,6 +70,13 @@ internal fun WearUiState.resetForPhoneChange(): WearUiState = proxyCapabilities = emptySet(), sessions = emptyList(), selectedSession = null, + phoneActiveSessionKey = null, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = emptyList(), streamText = null, activeRunId = null, @@ -85,6 +99,13 @@ internal fun WearUiState.switchAgentContext(agentId: String): WearUiState = activeAgentId = agentId, sessions = emptyList(), selectedSession = null, + phoneActiveSessionKey = null, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = emptyList(), streamText = null, activeRunId = null, @@ -98,6 +119,12 @@ internal fun WearUiState.switchAgentContext(agentId: String): WearUiState = internal fun WearUiState.switchSessionContext(session: WearSession): WearUiState = copy( selectedSession = session, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = emptyList(), streamText = null, activeRunId = null, @@ -125,6 +152,10 @@ internal fun WearUiState.switchModelContext(modelRef: String): WearUiState { ) } +internal fun WearUiState.containsModelRef(modelRef: String): Boolean = + models.any { model -> model.ref == modelRef } || + modelSearchResults.any { model -> model.ref == modelRef } + internal fun shouldAcceptWearTalkSnapshot( snapshot: WearRealtimeTalkSnapshot, attemptId: String?, @@ -199,6 +230,7 @@ internal class WearViewModel( private val sendAttemptTracker = WearSendAttemptTracker() private val controlBusyOwner = WearControlBusyOwner() private var loadJob: Job? = null + private var sessionSearchJob: Job? = null private var phoneRouteGeneration = 0L private var agentPulsePollJob: Job? = null private var agentPulseVisible = false @@ -299,6 +331,46 @@ internal class WearViewModel( restartAgentPulsePolling(forceLoading = true) } + fun searchSessions(query: String) { + if (WearProxyCapability.SessionSearchPagination !in mutableState.value.proxyCapabilities) return + val normalized = query.trim() + if (normalized.isEmpty()) return + loadSessionSearch(normalized, offset = 0, append = false) + } + + fun loadMoreSessionSearch() { + val current = mutableState.value + if (WearProxyCapability.SessionSearchPagination !in current.proxyCapabilities) return + val query = current.sessionSearchQuery ?: return + val offset = current.sessionSearchNextOffset ?: return + if (!current.sessionSearchHasMore) return + loadSessionSearch(query, offset = offset, append = true) + } + + fun clearSessionSearch() { + sessionSearchJob?.cancel() + mutableState.update { + it.copy( + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + ) + } + } + + fun searchModels(query: String) { + if (WearProxyCapability.ModelCatalogSearch !in mutableState.value.proxyCapabilities) return + val normalized = query.trim() + if (normalized.isEmpty()) return + mutableState.value.selectedSession?.let { session -> loadModels(session, normalized) } + } + + fun clearModelSearch() { + cancelModelLoad() + mutableState.update { it.copy(modelSearchQuery = null, modelSearchResults = emptyList()) } + } + fun closeSession() { endRealtimeTalkForNavigation() cancelModelLoad() @@ -491,7 +563,7 @@ internal class WearViewModel( current.realtimeCapturing || current.realtimePlaying || current.selectedModelRef == modelRef || - current.models.none { model -> model.ref == modelRef } || + !current.containsModelRef(modelRef) || WearProxyCapability.ModelControls !in current.proxyCapabilities ) { return @@ -586,6 +658,7 @@ internal class WearViewModel( } private fun loadSessions(expectedNodeId: String? = null) { + sessionSearchJob?.cancel() invalidateAgentPulse(clearSnapshot = true) cancelLoad() cancelModelLoad() @@ -708,6 +781,13 @@ internal class WearViewModel( proxyCapabilities = status.capabilities, sessions = projectedSessions, selectedSession = selectedSession, + phoneActiveSessionKey = activeSessionKey, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = if (selectionChanged || !status.connected) emptyList() else it.messages, streamText = if (selectionChanged || !status.connected) null else it.streamText, activeRunId = if (selectionChanged || !status.connected) null else it.activeRunId, @@ -834,11 +914,63 @@ internal class WearViewModel( } } - private fun loadModels(session: WearSession) { + private fun loadSessionSearch( + query: String, + offset: Int, + append: Boolean, + ) { + val current = mutableState.value + if (WearProxyCapability.SessionSearchPagination !in current.proxyCapabilities) return + val phoneNodeId = current.phoneNodeId ?: return + if (!current.connected) return + val routeGeneration = phoneRouteGeneration + sessionSearchJob?.cancel() + sessionSearchJob = + viewModelScope.launch { + try { + val result = + repository.sessions( + expectedNodeId = phoneNodeId, + capabilities = current.proxyCapabilities, + limit = 50, + offset = offset, + search = query, + ) + if (routeGeneration != phoneRouteGeneration || mutableState.value.phoneNodeId != result.phoneNodeId) { + return@launch + } + mutableState.update { state -> + val results = + if (append && state.sessionSearchQuery == query) { + (state.sessionSearchResults + result.sessions).distinctBy(WearSession::key) + } else { + result.sessions + } + state.copy( + sessionSearchQuery = query, + sessionSearchResults = results, + sessionSearchHasMore = result.hasMore, + sessionSearchNextOffset = result.nextOffset, + failure = null, + ) + } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + recordFailureForControlRoute(err, phoneNodeId, routeGeneration, loading = false) + } + } + } + + private fun loadModels( + session: WearSession, + query: String? = null, + ) { val current = mutableState.value val capabilities = current.proxyCapabilities if ( WearProxyCapability.ModelControls !in capabilities || + (query != null && WearProxyCapability.ModelCatalogSearch !in capabilities) || !wearSessionRequestIsCurrent(session, current.selectedSession, session.phoneNodeId) ) { return @@ -853,6 +985,7 @@ internal class WearViewModel( expectedNodeId = session.phoneNodeId, capabilities = capabilities, selectedModelRef = session.modelRef, + query = query, ) val selectedSession = mutableState.value.selectedSession if (!wearSessionRequestIsCurrent(session, selectedSession, modelList.phoneNodeId)) return@launch @@ -871,7 +1004,11 @@ internal class WearViewModel( if (!wearSessionRequestIsCurrent(session, state.selectedSession, modelList.phoneNodeId)) { state } else { - state.copy(models = modelList.models) + if (query == null) { + state.copy(models = modelList.models, modelSearchQuery = null, modelSearchResults = emptyList()) + } else { + state.copy(modelSearchQuery = query, modelSearchResults = modelList.models) + } } } } catch (err: CancellationException) { diff --git a/apps/android/wear/src/main/res/values/strings.xml b/apps/android/wear/src/main/res/values/strings.xml index 83091df3cfc4..6c360210fbf9 100644 --- a/apps/android/wear/src/main/res/values/strings.xml +++ b/apps/android/wear/src/main/res/values/strings.xml @@ -4,8 +4,6 @@ Chat Session Model - Previous %1$s - Next %1$s Controls Talk Watch audio failed @@ -43,6 +41,17 @@ Start a conversation Talk or type on your watch. The paired phone sends the message through its authenticated OpenClaw session. Current session + %1$s: %2$s + Close + Search + Search sessions + Search models + Load more + Active on phone + Open on watch + No matches + Find a session + Find a model Appearance Dark Light diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt index d42a2094b111..f3533a8cf2e1 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt @@ -80,7 +80,7 @@ class MainActivityTest { @Test fun chatFollowTracksStreamingGrowthAtLatest() { val messages = listOf(WearChatMessage(id = "user-1", role = "user", text = "Status?", timestamp = 1L)) - val anchor = wearChatLatestAnchorIndex(1, hasStreaming = true, canAbort = false, hasAssistant = false, hasFailure = false) + val anchor = wearChatLatestAnchorIndex(1, hasStreaming = true, canAbort = false) val first = nextWearThreadFollowForContent( state = WearThreadFollowState(), @@ -131,21 +131,28 @@ class MainActivityTest { } @Test - fun chatFollowTargetsRenderedTrailingAnchor() { + fun chatFollowStopsAtLatestMessageBeforeControls() { assertEquals( -1, - wearChatLatestAnchorIndex(0, hasStreaming = false, canAbort = false, hasAssistant = false, hasFailure = true), + wearChatLatestAnchorIndex(0, hasStreaming = false, canAbort = false), ) assertEquals( - 5, - wearChatLatestAnchorIndex(1, hasStreaming = false, canAbort = false, hasAssistant = false, hasFailure = false), + 3, + wearChatLatestAnchorIndex(1, hasStreaming = false, canAbort = false), ) assertEquals( - 10, - wearChatLatestAnchorIndex(2, hasStreaming = true, canAbort = true, hasAssistant = true, hasFailure = true), + 6, + wearChatLatestAnchorIndex(2, hasStreaming = true, canAbort = true), ) } + @Test + fun nestedContextPickerCloseReturnsToSessionPicker() { + assertEquals(WearContextPicker.Session, wearContextPickerAfterClose(WearContextPicker.Agent)) + assertEquals(WearContextPicker.Session, wearContextPickerAfterClose(WearContextPicker.Model)) + assertNull(wearContextPickerAfterClose(WearContextPicker.Session)) + } + @Test fun threadFollowTargetsTrailingAnchorAfterLatestContent() { assertEquals(-1, wearThreadLatestAnchorIndex(entryCount = 0, thinking = false)) @@ -254,17 +261,72 @@ class MainActivityTest { hasActiveRun = false, phoneNodeId = "phone-1", ) + val phoneSession = + WearSession( + key = "session-2", + title = "Phone session", + updatedAt = 7, + hasActiveRun = false, + phoneNodeId = "phone-1", + ) val snapshot = WearUiState( connected = true, phoneNodeId = "phone-1", - sessions = listOf(session), + phoneActiveSessionKey = phoneSession.key, + sessions = listOf(session, phoneSession), selectedSession = session, failure = WearConversationFailure.ACTION_REJECTED, ).toConversationSnapshot() assertEquals(WearConversationFailure.ACTION_REJECTED, snapshot?.failure) - assertNull(snapshot?.sessions?.single()?.title) + assertNull(snapshot?.sessions?.first()?.title) + assertFalse(snapshot?.sessions?.first()?.activeOnPhone == true) + assertTrue(snapshot?.sessions?.first()?.openOnWatch == true) + assertTrue(snapshot?.sessions?.last()?.activeOnPhone == true) + assertFalse(snapshot?.sessions?.last()?.openOnWatch == true) + assertEquals(phoneSession.key, snapshot?.phoneActiveSessionId) + } + + @Test + fun modelSearchResultsRemainSelectableOutsideTheCompactModelWindow() { + val state = + WearUiState( + models = listOf(WearModel(ref = "openai/gpt-a", name = "GPT A")), + modelSearchResults = + listOf(WearModel(ref = "anthropic/claude", name = "Claude")), + ) + + assertTrue(state.containsModelRef("openai/gpt-a")) + assertTrue(state.containsModelRef("anthropic/claude")) + assertFalse(state.containsModelRef("google/gemini")) + } + + @Test + fun pickerSearchVisibilityFollowsNegotiatedCapabilities() { + val legacySnapshot = + WearUiState( + phoneNodeId = "phone-1", + proxyCapabilities = + setOf( + WearProxyCapability.ModelControls, + WearProxyCapability.SessionSelectionLookup, + ), + ).toConversationSnapshot() + val currentSnapshot = + WearUiState( + phoneNodeId = "phone-1", + proxyCapabilities = + setOf( + WearProxyCapability.ModelCatalogSearch, + WearProxyCapability.SessionSearchPagination, + ), + ).toConversationSnapshot() + + assertFalse(legacySnapshot?.modelSearchSupported == true) + assertFalse(legacySnapshot?.sessionSearchSupported == true) + assertTrue(currentSnapshot?.modelSearchSupported == true) + assertTrue(currentSnapshot?.sessionSearchSupported == true) } @Test diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt index f7984b39f4b3..764734d1bbca 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt @@ -157,7 +157,7 @@ class WearGatewayRepositoryTest { when (method) { WearRpcMethod.SessionsList -> json.parseToJsonElement( - """{"sessions":[{"key":"agent:main","agentId":"main","displayName":"Main","updatedAt":7,"hasActiveRun":true,"modelRef":"openai/gpt-test"}],"activeAgentId":"main","selectedSessionValid":true}""", + """{"sessions":[{"key":"agent:main","agentId":"main","displayName":"Main","updatedAt":7,"hasActiveRun":true,"modelRef":"openai/gpt-test"}],"activeAgentId":"main","selectedSessionValid":true,"hasMore":true,"nextOffset":35}""", ) WearRpcMethod.ChatHistory -> json.parseToJsonElement( @@ -171,7 +171,13 @@ class WearGatewayRepositoryTest { val sessions = repository.sessions( selectedSessionKey = "agent:main", - capabilities = setOf(WearProxyCapability.SessionSelectionLookup), + offset = 5, + search = "older", + capabilities = + setOf( + WearProxyCapability.SessionSelectionLookup, + WearProxyCapability.SessionSearchPagination, + ), ) val history = repository.history("agent:main", sessions.phoneNodeId) @@ -189,7 +195,9 @@ class WearGatewayRepositoryTest { assertEquals("working", history.activeText) assertEquals("openai/gpt-test", history.selectedModelRef) assertEquals(7L, history.eventSequence) - assertEquals(setOf("limit", "selectedSessionKey"), requester.calls[0].second.keys) + assertTrue(sessions.hasMore) + assertEquals(35, sessions.nextOffset) + assertEquals(setOf("limit", "offset", "search", "selectedSessionKey"), requester.calls[0].second.keys) assertEquals(setOf("sessionKey", "limit", "maxChars"), requester.calls[1].second.keys) } @@ -207,7 +215,7 @@ class WearGatewayRepositoryTest { WearRpcMethod.AgentsSelect -> JsonObject(emptyMap()) WearRpcMethod.GatewayDisconnect -> json.parseToJsonElement( - """{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup","agent-pulse","attempt-scoped-realtime-audio"]}""", + """{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","model-catalog-search","session-selection-lookup","session-search-pagination","agent-pulse","attempt-scoped-realtime-audio"]}""", ) else -> error("unexpected $method") } @@ -275,7 +283,7 @@ class WearGatewayRepositoryTest { val requester = RecordingRequester { _, _ -> json.parseToJsonElement( - """{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup","agent-pulse","attempt-scoped-realtime-audio"]}""", + """{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","model-catalog-search","session-selection-lookup","session-search-pagination","agent-pulse","attempt-scoped-realtime-audio"]}""", ) } @@ -287,12 +295,14 @@ class WearGatewayRepositoryTest { @Test fun modelSelectionKeepsTheSelectedSessionAndUsesThePreferredPhone() = runTest { - val capabilities = setOf(WearProxyCapability.ModelControls) + val capabilities = + setOf(WearProxyCapability.ModelControls, WearProxyCapability.ModelCatalogSearch) val requester = RecordingRequester { method, params -> when (method) { WearRpcMethod.ModelsList -> { assertEquals("openai/gpt-a", params.getValue("selectedModelRef").jsonPrimitive.content) + assertEquals("anthropic", params.getValue("query").jsonPrimitive.content) json.parseToJsonElement( """{"models":[{"ref":"openai/gpt-a","name":"GPT A"},{"ref":"openai/gpt-b","name":"GPT B"}]}""", ) @@ -309,7 +319,13 @@ class WearGatewayRepositoryTest { } val repository = WearGatewayRepository(requester) - val models = repository.models("phone-a", capabilities, selectedModelRef = "openai/gpt-a") + val models = + repository.models( + "phone-a", + capabilities, + selectedModelRef = "openai/gpt-a", + query = "anthropic", + ) val selected = repository.selectModel( sessionKey = "agent:main:thread-7", @@ -327,6 +343,40 @@ class WearGatewayRepositoryTest { assertTrue(requester.requirePreferredNodes.all { it }) } + @Test + fun oldPhoneCapabilitiesDoNotReceivePickerSearchFields() = + runTest { + val requester = + RecordingRequester { method, params -> + when (method) { + WearRpcMethod.ModelsList -> { + assertEquals(setOf("selectedModelRef"), params.keys) + json.parseToJsonElement("""{"models":[]}""") + } + WearRpcMethod.SessionsList -> { + assertEquals(setOf("limit", "selectedSessionKey"), params.keys) + json.parseToJsonElement("""{"sessions":[]}""") + } + else -> error("unexpected $method") + } + } + val repository = WearGatewayRepository(requester) + + repository.models( + expectedNodeId = "phone-a", + capabilities = setOf(WearProxyCapability.ModelControls), + selectedModelRef = "openai/gpt-a", + query = "anthropic", + ) + repository.sessions( + expectedNodeId = "phone-a", + selectedSessionKey = "agent:main", + capabilities = setOf(WearProxyCapability.SessionSelectionLookup), + offset = 50, + search = "older", + ) + } + @Test fun chatEventPreservesReplaceAndTextOnlyMessage() { val event = From 2fe6b678713fd1761adfffe8230822d58c4d89ae Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 21 Aug 2026 09:04:39 -0700 Subject: [PATCH 020/124] fix(test): read migrated Codex auth from shared owner (#127097) --- .../qa-lab/runtime/codex-auth-product-proof.test-support.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/qa-lab/runtime/codex-auth-product-proof.test-support.ts b/test/e2e/qa-lab/runtime/codex-auth-product-proof.test-support.ts index 349a3ae7e59d..520e21b2aab7 100644 --- a/test/e2e/qa-lab/runtime/codex-auth-product-proof.test-support.ts +++ b/test/e2e/qa-lab/runtime/codex-auth-product-proof.test-support.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import { expect } from "vitest"; -import { loadPersistedAuthProfileStore } from "../../../../src/agents/auth-profiles/persisted.js"; +import { loadPersistedSharedAuthProfileStore } from "../../../../src/agents/auth-profiles/persisted.js"; import type { OpenClawTestInstance } from "../../../helpers/openclaw-test-instance.js"; const OAUTH_PROFILE_ID = "openai:qa-oauth"; @@ -50,7 +50,7 @@ export async function runCodexAuthDoctorMigrationProof( }); expect(doctor.code, doctor.stderr).toBe(0); - const canonicalStore = loadPersistedAuthProfileStore(instance.state.agentDir()); + const canonicalStore = loadPersistedSharedAuthProfileStore(instance.env); const expectedProfiles: Record> = { [OAUTH_PROFILE_ID]: { type: "oauth", From 8531da19930d71ab742411eef4be910c09a477ba Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Fri, 21 Aug 2026 09:05:05 -0700 Subject: [PATCH 021/124] fix(gateway): run transcript cursor E2E with full startup (#127094) * test(gateway): publish runtime in agent E2Es * test(gateway): leave replay fix to owner PR --- test/embedded-transcript-cursor.e2e.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/embedded-transcript-cursor.e2e.test.ts b/test/embedded-transcript-cursor.e2e.test.ts index b6f61fe10a9e..5b9d5e611c6a 100644 --- a/test/embedded-transcript-cursor.e2e.test.ts +++ b/test/embedded-transcript-cursor.e2e.test.ts @@ -43,7 +43,10 @@ describe("embedded transcript cursor settlement", () => { const instance = await createOpenClawTestInstance({ name: "embedded-transcript-cursor", config: createTestConfig(modelServer.baseUrl), - env: { OPENCLAW_SKIP_PROVIDERS: undefined }, + env: { + OPENCLAW_SKIP_PROVIDERS: undefined, + OPENCLAW_TEST_MINIMAL_GATEWAY: undefined, + }, }); instances.push(instance); await instance.startGateway(); From 7085604a269deea4bb67754ec668a25b63c813ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 09:05:52 -0700 Subject: [PATCH 022/124] fix(ui): correct Automations row semantics (#127252) Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b Co-authored-by: Amp --- ui/src/e2e/cron-remove.e2e.test.ts | 6 +++++- ui/src/pages/cron/view.test.ts | 20 ++++++++++++++++++-- ui/src/pages/cron/view.ts | 20 ++++---------------- ui/src/styles/cron.css | 6 +++++- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/ui/src/e2e/cron-remove.e2e.test.ts b/ui/src/e2e/cron-remove.e2e.test.ts index f2d49f8008a6..9e734902b43b 100644 --- a/ui/src/e2e/cron-remove.e2e.test.ts +++ b/ui/src/e2e/cron-remove.e2e.test.ts @@ -63,7 +63,11 @@ suite.define(() => { expect(response?.status()).toBe(200); const row = page.locator(`[data-test-id="cron-row-${job.id}"]`); await row.waitFor({ state: "visible", timeout: 10_000 }); - await row.locator(".cron-table__name-text").click(); + expect(await page.locator(".cron-table__head").getAttribute("role")).toBeNull(); + expect(await row.getAttribute("role")).toBeNull(); + const openTask = row.getByRole("button", { name: /Nightly digest/ }); + await openTask.focus(); + await page.keyboard.press("Enter"); const detail = page.locator('.cron-page[data-panel-mode="job"]'); await detail.waitFor({ state: "visible" }); diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index fed8803ff32b..2323d70f4e04 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -116,7 +116,22 @@ describe("cron view list pane", () => { expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); }); - it("renders table rows with schedule and status cells and selects on click", () => { + it("does not expose table rows without complete table semantics", () => { + const container = renderView({ jobs: [createJob("job-1")] }); + + for (const row of container.querySelectorAll('[role="row"]')) { + expect(row.closest('[role="table"], [role="grid"], [role="treegrid"]')).not.toBeNull(); + expect( + Array.from(row.children).every((child) => + child.matches( + '[role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"]', + ), + ), + ).toBe(true); + } + }); + + it("renders table rows with independent native buttons for opening tasks", () => { const onSelectJob = vi.fn(); const job = createJob("job-1", { trigger: { script: "json({ fire: true })" }, @@ -134,6 +149,7 @@ describe("cron view list pane", () => { const rows = Array.from(container.querySelectorAll(".cron-table__row")); expect(rows).toHaveLength(3); + expect(rows[0]?.getAttribute("role")).toBeNull(); expect(rows[0]?.textContent).toContain("Cron 0 9 * * *"); expect(rows[1]?.classList.contains("cron-table__row--paused")).toBe(true); expect(rows[1]?.textContent).toContain("Paused"); @@ -150,7 +166,7 @@ describe("cron view list pane", () => { "Trigger configured", ); - (rows[1] as HTMLElement).click(); + getElement(rows[1] as Element, ".cron-table__name", HTMLButtonElement).click(); expect(onSelectJob).toHaveBeenCalledWith(paused); }); diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index 5f3cb7b80067..1bad08ad1c1a 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -728,7 +728,7 @@ function renderJobsFilterPopover(props: CronProps, active: boolean) { function renderJobsTable(props: CronProps, hasAnyJobsFilters: boolean) { return html`
-
+
${t("cron.jobs.name")} ${t("cron.jobs.schedule")} ${t("cron.jobs.nextRun")} @@ -775,18 +775,10 @@ function renderJobRow(job: CronJob, props: CronProps) { return html`
props.onSelectJob(job)} - @keydown=${(e: KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - props.onSelectJob(job); - } - }} > - + ${renderJobCell("cron-table__schedule", t("cron.jobs.schedule"), formatCronSchedule(job))} ${renderJobCell("cron-table__next", t("cron.jobs.nextRun"), nextRun)} ${renderJobCell("cron-table__last", t("cron.jobs.lastRun"), renderLastRunCell(job))} ${props.canManage ? html` - e.stopPropagation()} - @keydown=${(e: Event) => e.stopPropagation()} - > + e.stopPropagation()}> + + ${t("cron.runs.newestFirst")} + + + + ${t("cron.runs.oldestFirst")} + + + +
${runs.length === 0 ? hasRunFilters diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index 2323d70f4e04..00cdb56ad1ab 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -355,9 +355,11 @@ describe("cron view selects", () => { it("shows persisted non-first values in jobs filters and runs sort", () => { const activity = renderView({ listTab: "activity", runsSortDir: "asc" }); - const sort = getElement(activity, "select.cron-run-sort", HTMLSelectElement); - expect(sort.value).toBe("asc"); - expect(sort.querySelector('option[value="asc"]')?.hasAttribute("selected")).toBe(true); + const sort = getElement(activity, ".cron-run-sort", HTMLButtonElement); + expect(sort.textContent).toContain("Oldest first"); + expect( + activity.querySelector('wa-dropdown-item[value="asc"]')?.getAttribute("aria-current"), + ).toBe("true"); const tasks = renderView({ jobsLastStatusFilter: "error" }); const lastStatus = getElement( tasks, diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index 083dc729944e..5eed30abdbd6 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -1033,25 +1033,6 @@ } } -/* Match the .btn--sm dropdown triggers beside it instead of the native look. */ -.cron-run-sort { - flex: 0 0 auto; - width: auto; - padding: 6px 10px; - font-size: 12px; - font-weight: 500; - color: var(--text); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - cursor: var(--cursor-action); -} - -.cron-run-sort:hover { - background: var(--bg-hover); - border-color: var(--border-strong); -} - .cron-runs__empty { font-size: 13px; } From 731e9e768152855430b7270802f8808451afbfcc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 13:18:11 -0700 Subject: [PATCH 087/124] perf(test): speed up browser extension state observations (#127471) --- .../chrome-extension/background.test-harness.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/extensions/browser/chrome-extension/background.test-harness.ts b/extensions/browser/chrome-extension/background.test-harness.ts index bb4808577b3b..f309f632a37a 100644 --- a/extensions/browser/chrome-extension/background.test-harness.ts +++ b/extensions/browser/chrome-extension/background.test-harness.ts @@ -16,6 +16,10 @@ const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"]; const RETIRED_CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1"; const backgroundCleanups = new Set<() => Promise>(); +function waitForBackgroundState(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { interval: 1 }); +} + export async function cleanupBackgroundHarnesses(): Promise { await Promise.all([...backgroundCleanups].map(async (cleanup) => await cleanup())); } @@ -388,7 +392,7 @@ export async function loadBackground({ const backgroundModulePath = "./background.js"; await import(backgroundModulePath); if (!deferRetiredStatePreparation) { - await vi.waitFor(() => { + await waitForBackgroundState(() => { const pairingReads = storageGet.mock.calls.filter(([keys]) => PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)), ); @@ -396,7 +400,7 @@ export async function loadBackground({ }); } if (!deferTabAccessInitialization && !deferRetiredStatePreparation) { - await vi.waitFor(() => { + await waitForBackgroundState(() => { const pairingWasCleared = storageRemove.mock.calls.some(([keys]) => keys.includes("relayUrl"), ); @@ -482,7 +486,7 @@ export async function loadBackground({ if (socket.readyState !== FakeWebSocket.OPEN) { socket.open(); } - await vi.waitFor(() => expect(socket.send).toHaveBeenCalled()); + await waitForBackgroundState(() => expect(socket.send).toHaveBeenCalled()); const helloRaw = socket.send.mock.calls.find( ([raw]) => JSON.parse(raw).type === "auth.hello", )?.[0]; @@ -511,7 +515,7 @@ export async function loadBackground({ ...fields, serverProof: await computeRelayAuthProof(String(storageValues.token), "server", fields), }); - await vi.waitFor(() => { + await waitForBackgroundState(() => { expect( socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.response"), ).toBe(true); @@ -534,7 +538,7 @@ export async function loadBackground({ response.clientProof, ), }); - await vi.waitFor(() => { + await waitForBackgroundState(() => { expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "hello")).toBe(true); }); }, From 7909ea698374c945cbddb7bb6ffdd07ad88a9072 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 13:19:06 -0700 Subject: [PATCH 088/124] ci: remove the Test Performance Agent The workflow ran a Codex agent over the full test suite and pushed `test: optimize slow tests` straight to `main` under `contents: write`, with no pull request and no human review. Its gates were a path allowlist, a no-add/delete/rename rule, a non-decreasing total test count, and `pnpm check:changed` -- which covers changed lanes, not the full suite. Test optimization is exactly the class of change where a plausible edit can weaken coverage without moving the test count, so unattended landing is the wrong trade. Autonomous commits to `main` are not something this repo wants. It had also been inert since well before this. The daily-cadence gate excluded prior runs with `select(.status != "cancelled")`, but a finished cancelled run reports `status: "completed"` with `conclusion: "cancelled"` -- verified against run 32506655531, which that filter counts as a prior run. Its `concurrency` block sets `cancel-in-progress: false`, so main's push rate produced dozens of cancelled runs per hour and every trigger skipped, reporting green after ~2 minutes of doing nothing. No `test: optimize slow tests` commit has ever landed on `main`. `pnpm test:perf:groups` and the rest of the performance tooling it drove stay; they are useful by hand and documented in docs/reference/test.md. Repository secret OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY now has no consumer and can be deleted. --- .github/workflows/test-performance-agent.yml | 280 ------------------- docs/ci.md | 5 - docs/reference/test.md | 2 +- 3 files changed, 1 insertion(+), 286 deletions(-) delete mode 100644 .github/workflows/test-performance-agent.yml diff --git a/.github/workflows/test-performance-agent.yml b/.github/workflows/test-performance-agent.yml deleted file mode 100644 index c80c9aeb76aa..000000000000 --- a/.github/workflows/test-performance-agent.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Test Performance Agent - -on: - workflow_run: # zizmor: ignore[dangerous-triggers] main-only test optimization after trusted CI; job gates repository, event, branch, actor, conclusion, current main SHA, and daily cadence before using write token - workflows: - - CI - types: - - completed - workflow_dispatch: - -permissions: - actions: read - contents: write - -concurrency: - group: test-performance-agent-main - cancel-in-progress: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - TEST_PERF_BEFORE: .artifacts/test-perf/baseline-before.json - TEST_PERF_AFTER: .artifacts/test-perf/after-agent.json - TEST_PERF_COMPARE: .artifacts/test-perf/agent-compare.json - -jobs: - optimize-tests: - if: > - github.repository == 'openclaw/openclaw' && - (github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' && - !endsWith(github.event.workflow_run.actor.login, '[bot]'))) - runs-on: ubuntu-24.04 - timeout-minutes: 240 - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: main - fetch-depth: 0 - persist-credentials: false - submodules: false - - - name: Gate trusted main activity and daily cadence - id: gate - env: - EVENT_NAME: ${{ github.event_name }} - GH_TOKEN: ${{ github.token }} - WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - set -euo pipefail - - if [ "$EVENT_NAME" != "workflow_run" ]; then - echo "run_agent=true" >> "$GITHUB_OUTPUT" - echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - exit 0 - fi - - for attempt in 1 2 3 4 5; do - if git fetch --no-tags origin main; then - break - fi - if [ "$attempt" = "5" ]; then - echo "Failed to fetch main after retries." >&2 - exit 1 - fi - echo "Fetch attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - done - - remote_main="$(git rev-parse origin/main)" - if [ "$remote_main" != "$WORKFLOW_HEAD_SHA" ]; then - echo "CI run is superseded by ${remote_main}; skipping test performance agent for ${WORKFLOW_HEAD_SHA}." - echo "run_agent=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - day_start="$(date -u +%Y-%m-%dT00:00:00Z)" - runs_json="$RUNNER_TEMP/test-performance-agent-runs.json" - gh api --method GET "repos/${GITHUB_REPOSITORY}/actions/workflows/test-performance-agent.yml/runs" \ - -f branch=main \ - -f event=workflow_run \ - -f per_page=50 > "$runs_json" - - prior_runs="$( - jq -r \ - --argjson current_run_id "$GITHUB_RUN_ID" \ - --arg day_start "$day_start" \ - '.workflow_runs[] - | select(.database_id != $current_run_id) - | select(.created_at >= $day_start) - | select(.status != "cancelled") - | select((.conclusion // "") != "skipped") - | [.database_id, .status, (.conclusion // ""), .created_at, .head_sha] - | @tsv' "$runs_json" - )" - - if [ -n "$prior_runs" ]; then - echo "Test performance agent already ran or is running today; skipping." - printf '%s\n' "$prior_runs" - echo "run_agent=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "run_agent=true" >> "$GITHUB_OUTPUT" - echo "base_sha=${remote_main}" >> "$GITHUB_OUTPUT" - - - name: Setup Node environment - if: steps.gate.outputs.run_agent == 'true' - uses: ./.github/actions/setup-node-env - with: - cache-mode: restore - install-bun: "false" - - - name: Ensure test performance agent key exists - if: steps.gate.outputs.run_agent == 'true' - env: - OPENAI_API_KEY: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} - run: | - set -euo pipefail - if [ -z "${OPENAI_API_KEY:-}" ]; then - echo "Missing OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2 - exit 1 - fi - - - name: Build baseline full-suite performance report - if: steps.gate.outputs.run_agent == 'true' - run: pnpm test:perf:groups --full-suite --allow-failures --output "$TEST_PERF_BEFORE" --limit 20 --top-files 40 - - - name: Run Codex test performance agent - if: steps.gate.outputs.run_agent == 'true' - uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 - with: - openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} - prompt-file: .github/codex/prompts/test-performance-agent.md - model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }} - effort: high - sandbox: workspace-write - safety-strategy: drop-sudo - codex-args: '["--full-auto"]' - - - name: Enforce focused test performance patch - if: steps.gate.outputs.run_agent == 'true' - id: patch - run: | - set -euo pipefail - - untracked="$(git ls-files --others --exclude-standard)" - if [ -n "$untracked" ]; then - echo "Test performance agent created untracked files; forbidden:" - printf '%s\n' "$untracked" - exit 1 - fi - - added_deleted_or_renamed="$(git diff --name-status --diff-filter=ADR)" - if [ -n "$added_deleted_or_renamed" ]; then - echo "Test performance agent added, deleted, or renamed tracked files; forbidden:" - printf '%s\n' "$added_deleted_or_renamed" - exit 1 - fi - - bad_paths="$( - git diff --name-only | while IFS= read -r path; do - case "$path" in - apps/*|extensions/*|packages/*|scripts/*|src/*|test/*|ui/*) ;; - *) printf '%s\n' "$path" ;; - esac - done - )" - if [ -n "$bad_paths" ]; then - echo "Test performance agent touched forbidden paths:" - printf '%s\n' "$bad_paths" - exit 1 - fi - - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Restore Node 24 path - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: - | # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job - set -euo pipefail - export PATH="${NODE_BIN}:${PATH}" - echo "${NODE_BIN}" >> "$GITHUB_PATH" - node -v - corepack enable - pnpm -v - - - name: Run full-suite performance report after agent changes - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm test:perf:groups --full-suite --output "$TEST_PERF_AFTER" --limit 20 --top-files 40 - - - name: Compare test performance reports - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm test:perf:groups:compare "$TEST_PERF_BEFORE" "$TEST_PERF_AFTER" --output "$TEST_PERF_COMPARE" --limit 20 --top-files 40 - - - name: Enforce coverage-preserving test count - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: | - set -euo pipefail - node <<'NODE' - const fs = require("node:fs"); - const before = JSON.parse(fs.readFileSync(process.env.TEST_PERF_BEFORE, "utf8")); - const after = JSON.parse(fs.readFileSync(process.env.TEST_PERF_AFTER, "utf8")); - - if (before.failed) { - console.log("Baseline had failing configs; skipping total test-count comparison against partial report."); - process.exit(0); - } - - const beforeTests = before.totals?.testCount ?? 0; - const afterTests = after.totals?.testCount ?? 0; - if (afterTests < beforeTests) { - console.error(`Test count decreased from ${beforeTests} to ${afterTests}; refusing coverage-reducing patch.`); - process.exit(1); - } - console.log(`Test count preserved: ${beforeTests} -> ${afterTests}.`); - NODE - - - name: Check changed lanes - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm check:changed - - - name: Commit test performance updates - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - env: - GITHUB_TOKEN: ${{ github.token }} - TARGET_BRANCH: main - run: | - set -euo pipefail - - if git diff --quiet; then - echo "No test performance changes." - exit 0 - fi - - git config user.name "openclaw-test-performance-agent[bot]" - git config user.email "openclaw-test-performance-agent[bot]@users.noreply.github.com" - git add apps extensions packages scripts src test ui - git commit --no-verify -m "test: optimize slow tests" - - for attempt in 1 2 3 4 5; do - if ! git fetch --no-tags origin "${TARGET_BRANCH}"; then - echo "Fetch attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - continue - fi - if git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"${TARGET_BRANCH}"; then - exit 0 - fi - remote_main="$(git rev-parse "origin/${TARGET_BRANCH}")" - if [ "$remote_main" != "$(git rev-parse HEAD^)" ]; then - echo "main advanced; rebasing test performance update onto ${remote_main}." - if ! git rebase "origin/${TARGET_BRANCH}"; then - echo "Test performance update no longer applies cleanly; skipping stale update." - git rebase --abort || true - exit 0 - fi - pnpm check:changed - fi - echo "Test performance update attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - done - - echo "Failed to push test performance updates after retries." >&2 - exit 1 - - - name: Upload test performance artifacts - if: steps.gate.outputs.run_agent == 'true' && always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: test-performance-agent-${{ github.run_id }} - path: .artifacts/test-perf/ - if-no-files-found: ignore - retention-days: 14 diff --git a/docs/ci.md b/docs/ci.md index 3cc52aa816d2..17d39df2231f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -55,7 +55,6 @@ dispatch. | `ios-build` | Swift lint, Debug and Release builds, focused simulator lifecycle tests, and the full release screenshot matrix when screenshot-pipeline owners changed | iOS/capture changes | | `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | | `openclaw/ci-gate` | Final aggregate: requires preflight and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run | -| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | | `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch | The rare path-triggered `docker-seed-e2e` job selects only the executable @@ -726,10 +725,6 @@ Quality stays separate from security so quality findings can be scheduled, measu The `Docs Agent` workflow is an event-driven Codex maintenance lane for keeping existing docs aligned with recently landed changes. It has no pure schedule: a successful non-bot push CI run on `main` can trigger it, and manual dispatch can run it directly. Workflow-run invocations skip when `main` has moved on or when another non-skipped Docs Agent run was created in the last hour. When it runs, it reviews the commit range from the previous non-skipped Docs Agent source SHA to current `main`, so one hourly run can cover all main changes accumulated since the last docs pass. -### Test Performance Agent - -The `Test Performance Agent` workflow is an event-driven Codex maintenance lane for slow tests. It has no pure schedule: a successful non-bot push CI run on `main` can trigger it, but it skips if another workflow-run invocation already ran or is running that UTC day. Manual dispatch bypasses that daily activity gate. The lane builds a full-suite grouped Vitest performance report, lets Codex make only small coverage-preserving test performance fixes instead of broad refactors, then reruns the full-suite report and rejects changes that reduce the passing baseline test count. The grouped report records per-config wall time and max RSS on Linux and macOS, so the before/after comparison surfaces test memory deltas beside duration deltas. If the baseline has failing tests, Codex may fix only obvious failures and the after-agent full-suite report must pass before anything is committed. When `main` advances before the bot push lands, the lane rebases the validated patch, reruns `pnpm check:changed`, and retries the push; conflicting stale patches are skipped. It uses GitHub-hosted Ubuntu so the Codex action can keep the same drop-sudo safety posture as the docs agent. - ### Duplicate PRs After Merge The `Duplicate PRs After Merge` workflow is a manual maintainer workflow for post-land duplicate cleanup. It defaults to dry-run and only closes explicitly listed PRs when `apply=true`. Before mutating GitHub, it verifies that the landed PR is merged and that each duplicate has either a shared referenced issue or overlapping changed hunks. diff --git a/docs/reference/test.md b/docs/reference/test.md index b2103d742a10..5f24376016a7 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -198,7 +198,7 @@ If `pnpm test` flakes on a loaded host, rerun once before treating it as a regre - `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting, while still using scoped lane routing for explicit file/directory targets. `pnpm test:perf:imports:changed` scopes the same profiling to files changed since `origin/main`. - `pnpm test:perf:changed:bench -- --ref ` benchmarks the routed changed-mode path against the native root-project run for the same committed git diff; `pnpm test:perf:changed:bench -- --worktree` benchmarks the current worktree change set without committing first. - `pnpm test:perf:profile:main` writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`); `pnpm test:perf:profile:runner` writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`). -- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. The Test Performance Agent uses this as its baseline before attempting slow-test fixes. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. +- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. - Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact. ## Benchmarks From 3ea90bdcdb30f2ad18a68203fee09fa7824170a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 13:21:43 -0700 Subject: [PATCH 089/124] feat(ui): add Person grouping mode for sessions sidebar and sessions page (#127346) * feat(ui): add Person grouping mode for sessions sidebar and sessions page Sessions can now be grouped by their durable owner identity: the sidebar Group-by menu gains a capability-gated Person mode (self first, humans by label, agent identities after; ownerless rows keep their smart zones), and the sessions page gains the matching person mode. Person section headers render the owner avatar and profile label; the mock dev server now advertises the multi-identity policy and carries explicit row owners the way the gateway projects createdActor fallbacks. * feat(ui): gate sessions-page Person grouping on the identity capability Mirrors the sidebar: the Person option hides without hasMultipleSessionSharingIdentities and a stored Person preference renders as None until the capability returns. --- scripts/control-ui-mock-dev.ts | 9 ++- .../app-sidebar-session-list-render.ts | 49 ++++++++--- .../app-sidebar-session-menu-renderers.ts | 17 ++-- .../app-sidebar-session-navigation-logic.ts | 10 +-- .../app-sidebar-session-navigation.ts | 35 ++++---- ui/src/components/sidebar-menus-controller.ts | 2 + ui/src/components/sidebar-menus-render.ts | 2 +- ui/src/i18n/locales/en.ts | 1 + ui/src/lib/sessions/grouping.test.ts | 81 ++++++++++++++++++- ui/src/lib/sessions/grouping.ts | 57 ++++++++++++- ui/src/pages/sessions/sessions-page.ts | 7 +- ui/src/pages/sessions/view.test.ts | 52 ++++++++++++ ui/src/pages/sessions/view.ts | 15 +++- .../app-sidebar-cases/session-ownership.ts | 75 +++++++++++++++++ 14 files changed, 360 insertions(+), 52 deletions(-) diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 2b151fc65302..844d3a2f1c93 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -65,7 +65,7 @@ const MOCK_ACTOR_MIRA: SessionActorFixture = { id: "profile-mira", label: "Mira", }; -// These actors are also the effective owners because the mock rows are unassigned. +// Rows carry explicit owners the way the gateway projects createdActor fallbacks. const MOCK_SESSION_OWNERS: readonly SessionActorFixture[] = [MOCK_ACTOR_PETER, MOCK_ACTOR_MIRA]; const SESSION_PAGE_SIZE = 50; @@ -1531,6 +1531,7 @@ async function createChatPickerScenario( sessionRow(NARRATION_DEMO_SESSION_KEY, "Sidebar narration demo", baseTime - 15_000, { createdActor: MOCK_ACTOR_MIRA, hasActiveRun: true, + owner: { actor: MOCK_ACTOR_MIRA }, startedAt: baseTime - 45_000, status: "running", }), @@ -1544,10 +1545,12 @@ async function createChatPickerScenario( category: "Research", createdActor: MOCK_ACTOR_MIRA, execCwd: "/Users/peter/Projects/clawdbot", + owner: { actor: MOCK_ACTOR_MIRA }, }), sessionRow("agent:main:model-budget", "Model budget review", baseTime - 80_000, { category: "Research", execCwd: "/Users/peter/Projects/openclaw", + owner: { actor: { type: "human", id: "presence-riley", label: "Riley" } }, status: "failed", lastRunError: "Model out of credits: openai/gpt-5.6", }), @@ -1555,6 +1558,7 @@ async function createChatPickerScenario( createdActor: MOCK_ACTOR_PETER, execCwd: "/Users/peter/Work/openclaw", lastReadAt: baseTime - 120_000, + owner: { actor: MOCK_ACTOR_PETER }, observerDigest: { headline: "Done: fixed the flaky retry-window test", health: "done", @@ -1787,6 +1791,9 @@ async function createChatPickerScenario( // Terminal has a second gate beyond the advertised method (see // ui/src/lib/terminal-availability.ts). terminalEnabled: true, + // The mock rows span several owners; advertise the multi-identity policy + // so people-aware UI (People sort, Person grouping) is exercisable here. + hasMultipleSessionSharingIdentities: true, historyMessages, // Lights up the footer facepile and who's-online roster; the email-only // entry keeps the roster's no-display-name row exercised. diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index 9289fce9a4c0..94791308b716 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -59,17 +59,28 @@ function renderSessionSection(params: { const { host, section } = params; const totalRowCount = section.totalRowCount; const group = section.category; + const personOwner = section.personOwner; // Pinned rows render in the nav zone; renderHeader records whether this list // section owns collapse UI or sits directly below the global toolbar. const collapsed = section.renderHeader && host.collapsedSessionSections.has(section.id); - const label = section.groups - ? t("chat.sidebar.groups") - : section.work - ? t("chat.sidebar.coding") - : group - ? group - : t("chat.sidebar.otherSessions"); - const zone = section.groups ? "groups" : section.work ? "coding" : group ? "category" : "threads"; + const label = personOwner + ? personOwner.label || personOwner.id + : section.groups + ? t("chat.sidebar.groups") + : section.work + ? t("chat.sidebar.coding") + : group + ? group + : t("chat.sidebar.otherSessions"); + const zone = personOwner + ? "person" + : section.groups + ? "groups" + : section.work + ? "coding" + : group + ? "category" + : "threads"; // Collapsed Coding still signals live runs so background work stays visible. const collapsedRunningDot = collapsed && @@ -83,6 +94,7 @@ function renderSessionSection(params: { method: "sessions.groups.put", requiredScope: "operator.write", }); + const sectionDropEnabled = groupWriteAccess.allowed && !personOwner; const sectionClass = [ "sidebar-recent-sessions__group", `sidebar-recent-sessions__group--zone-${zone}`, @@ -103,19 +115,21 @@ function renderSessionSection(params: {
host.sectionDragOver(event, section.id, group) : nothing} - @dragleave=${groupWriteAccess.allowed + @dragleave=${sectionDropEnabled ? (event: DragEvent) => host.sectionDragLeave(event, section.id, group) : nothing} - @drop=${groupWriteAccess.allowed + @drop=${sectionDropEnabled ? (event: DragEvent) => host.sectionDrop(event, section.id, group) : nothing} > ${section.renderHeader ? renderSidebarSessionSectionHeader({ sectionId: section.id, + draggable: !personOwner, disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason, onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId), onFinishDrag: () => host.finishSidebarSectionDrag(), @@ -138,6 +152,19 @@ function renderSessionSection(params: { >${collapsed ? icons.chevronRight : icons.chevronDown} + ${personOwner + ? html`` + : nothing} ${label} ${collapsed && totalRowCount > 0 ? html`${totalRowCount}` diff --git a/ui/src/components/app-sidebar-session-menu-renderers.ts b/ui/src/components/app-sidebar-session-menu-renderers.ts index 3015c9b9cea5..7d5cd4690423 100644 --- a/ui/src/components/app-sidebar-session-menu-renderers.ts +++ b/ui/src/components/app-sidebar-session-menu-renderers.ts @@ -294,6 +294,7 @@ export function renderSidebarSessionSortMenu(params: { } const groupingOptions = [ { grouping: "category", label: t("sessionsView.groupByCategory") }, + { grouping: "person", label: t("sessionsView.groupByPerson") }, { grouping: "none", label: t("sessionsView.groupByNone") }, ] as const satisfies ReadonlyArray<{ grouping: SidebarSessionsGrouping; label: string }>; return keyed( @@ -336,13 +337,15 @@ export function renderSidebarSessionSortMenu(params: { > ${renderSidebarMenuTrigger(position, t("chat.sidebar.sortSessions"))} - ${groupingOptions.map((option) => - renderSidebarMenuRadioItem({ - value: `grouping:${option.grouping}`, - checked: params.grouping === option.grouping, - label: option.label, - }), - )} + ${groupingOptions + .filter((option) => option.grouping !== "person" || params.peopleSortAvailable) + .map((option) => + renderSidebarMenuRadioItem({ + value: `grouping:${option.grouping}`, + checked: params.grouping === option.grouping, + label: option.label, + }), + )} ${SIDEBAR_SESSION_SORT_OPTIONS.filter( diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index b22d8beda1ea..8afe28bb7222 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -308,18 +308,16 @@ export function partitionSidebarVisibleSections(input: { rows: SidebarRecentSession[]; grouping: SidebarSessionsGrouping; knownGroups: string[] | undefined; + selfOwnerId?: string | null; catalogIds?: readonly string[]; sectionOrder?: readonly string[]; collapsedSections: ReadonlySet; hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean; visibleSessionLimits: ReadonlyMap; }): SidebarVisibleSections { - const sections = groupSidebarSessionRows(input.rows, { - grouping: input.grouping, - knownGroups: input.knownGroups, - sectionOrder: input.sectionOrder, - catalogIds: input.catalogIds, - }).filter( + const { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds } = input; + const sectionOptions = { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds }; + const sections = groupSidebarSessionRows(input.rows, sectionOptions).filter( (section) => section.id !== "pinned" && !input.hideEmptyOwnerFilteredGroup(section.category, section.rows.length), diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 9dfc0733d639..e7e0f7b25c43 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -120,6 +120,13 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { return resolveSidebarSessionSortMode(this.sessionSortMode, this.sessionPeopleSortAvailable()); } + effectiveSessionsGrouping(): SidebarSessionsGrouping { + // Reconnects temporarily hide the capability; retain the stored Person + // preference so it returns when the authoritative identity policy does. + const grouping = this.sessionsGrouping; + return grouping === "person" && !this.sessionPeopleSortAvailable() ? "category" : grouping; + } + setSessionSortMode(mode: SidebarSessionSortMode) { this.sessionSortMode = storeSidebarSessionSortMode(mode, this.sessionPeopleSortCapability()); } @@ -331,10 +338,12 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { /** Collapsed zones keep full rows for true header counts and status dots. */ protected zonedVisibleSections(rows: SidebarRecentSession[]): SidebarVisibleSections { + const grouping = this.effectiveSessionsGrouping(); return partitionSidebarVisibleSections({ rows, - grouping: this.sessionsGrouping, - knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : [], + grouping, + knownGroups: grouping === "category" ? this.knownSessionGroups() : [], + selfOwnerId: this.context?.gateway.snapshot.selfUser?.id ?? null, // Normalize gateway order without dropping catalog-lagging categories. sectionOrder: this.knownSectionOrder(), catalogIds: @@ -381,13 +390,10 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { const rows = this.selectedAgentSessionRows(navigationState); const { visibleRows } = this.zonedVisibleSections(rows); const pinnedByKey = new Map(rows.filter((row) => row.pinned).map((row) => [row.key, row])); - const pinnedRows = this.reconciledSidebarZone().entries.flatMap((entry) => - entry.type === "session" - ? pinnedByKey.get(entry.key) - ? [pinnedByKey.get(entry.key)!] - : [] - : [], - ); + const pinnedRows = this.reconciledSidebarZone().entries.flatMap((entry) => { + const row = entry.type === "session" ? pinnedByKey.get(entry.key) : undefined; + return row ? [row] : []; + }); return [...pinnedRows, ...visibleRows]; } @@ -486,9 +492,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { expandedAgentId(): string { const selected = normalizeOptionalString(this.context?.agentSelection.state.selectedId); - return selected - ? normalizeAgentId(selected) - : normalizeAgentId(this.getSessionNavigationState().selectedAgentId); + return normalizeAgentId(selected || this.getSessionNavigationState().selectedAgentId); } activeChipAgent() { @@ -509,11 +513,8 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { } private agentResumeKey(agentId: string): string { - return resolveSidebarAgentResumeKey( - this.latestAgentSessionRow(agentId), - agentId, - this.sessionMainKey(), - ); + const latest = this.latestAgentSessionRow(agentId); + return resolveSidebarAgentResumeKey(latest, agentId, this.sessionMainKey()); } /** Offline routes to Settings instead of a dead chat load. */ diff --git a/ui/src/components/sidebar-menus-controller.ts b/ui/src/components/sidebar-menus-controller.ts index f90cf074180d..2bc0308974dc 100644 --- a/ui/src/components/sidebar-menus-controller.ts +++ b/ui/src/components/sidebar-menus-controller.ts @@ -17,6 +17,7 @@ import { sessionPullRequestsForGateway, } from "../lib/session-pull-requests.ts"; import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts"; +import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts"; import { parseAgentSessionKey } from "../lib/sessions/session-key.ts"; import { SidebarCatalogMenuController } from "./app-sidebar-catalog-menu.ts"; @@ -117,6 +118,7 @@ interface SidebarMenusControllerHost hideSessionCatalog(catalogId: string): void; sessionSortMode: SidebarSessionSortMode; effectiveSessionSortMode(): SidebarSessionSortMode; + effectiveSessionsGrouping(): SidebarSessionsGrouping; sessionPeopleSortAvailable(): boolean; setSessionSortMode(mode: SidebarSessionSortMode): void; readonly terminalAvailable: boolean; diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts index 82d1f7d606ed..23bb0b62d150 100644 --- a/ui/src/components/sidebar-menus-render.ts +++ b/ui/src/components/sidebar-menus-render.ts @@ -380,7 +380,7 @@ export function renderSidebarSessionSortMenuForController(controller: SidebarMen return renderSidebarSessionSortMenu({ position, trigger: controller.sessionSortMenuTrigger, - grouping: host.sessionsGrouping, + grouping: host.effectiveSessionsGrouping(), sortMode: host.effectiveSessionSortMode(), peopleSortAvailable: host.sessionPeopleSortAvailable(), statusFilter: host.sessionsStatusFilter, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c33b6123ad13..019940ecdc28 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1164,6 +1164,7 @@ export const en: TranslationMap = { groupBy: "Group by", groupByNone: "None", groupByCategory: "Custom groups", + groupByPerson: "Person", showSessionPreview: "Show message preview", showCronSessions: "Show automation sessions", showSystemSessions: "Show system sessions", diff --git a/ui/src/lib/sessions/grouping.test.ts b/ui/src/lib/sessions/grouping.test.ts index fdfdd583c48e..551b9b1a73fa 100644 --- a/ui/src/lib/sessions/grouping.test.ts +++ b/ui/src/lib/sessions/grouping.test.ts @@ -82,6 +82,68 @@ describe("groupSidebarSessionRows", () => { expect(sections[1]?.rows.map((item) => item.key)).toEqual(["tg"]); }); + it("orders owner sections before stored zones and leaves ownerless rows in their smart zones", () => { + const sections = groupSidebarSessionRows( + [ + row({ key: "agent", owner: { actor: { type: "agent", id: "agent-z", label: "Zed" } } }), + row({ + key: "owned-group", + kind: "group", + category: "Ignored", + owner: { actor: { type: "human", id: "profile-b", label: "Bea" } }, + }), + row({ key: "thread" }), + row({ key: "group", kind: "group" }), + row({ key: "work", workSession: true }), + row({ key: "human-a", owner: { actor: { type: "human", id: "profile-a", label: "Ada" } } }), + row({ + key: "self", + owner: { + actor: { + type: "human", + id: "profile-self", + label: "Zoe", + avatarUrl: "/avatars/self", + }, + }, + }), + row({ key: "agent-a", owner: { actor: { type: "agent", id: "agent-a", label: "Alpha" } } }), + row({ key: "blank-owner", owner: { actor: { type: "human", id: " " } } }), + row({ key: "pinned", pinned: true, owner: { actor: { type: "human", id: "profile-a" } } }), + ], + { + grouping: "person", + selfOwnerId: "profile-self", + knownGroups: ["Ignored"], + catalogIds: ["catalog"], + sectionOrder: ["work", "person:profile-b", "groups", "ungrouped", "catalog:catalog"], + }, + ); + + expect(sections.map((section) => section.id)).toEqual([ + "pinned", + "person:profile-self", + "person:profile-a", + "person:profile-b", + "person:agent-a", + "person:agent-z", + "work", + "groups", + "ungrouped", + "catalog:catalog", + ]); + expect(sections[1]?.personOwner).toEqual({ + type: "human", + id: "profile-self", + label: "Zoe", + avatarUrl: "/avatars/self", + }); + expect(sections[3]?.rows.map((item) => item.key)).toEqual(["owned-group"]); + expect(sections[6]?.rows.map((item) => item.key)).toEqual(["work"]); + expect(sections[7]?.rows.map((item) => item.key)).toEqual(["group"]); + expect(sections[8]?.rows.map((item) => item.key)).toEqual(["thread", "blank-owner"]); + }); + it("always emits threads and coding so the renderer can host fallbacks and catalogs", () => { expect(groupSidebarSessionRows([row({ key: "a" })]).map((section) => section.id)).toEqual([ "ungrouped", @@ -302,8 +364,9 @@ describe("moveSessionSection", () => { }); describe("normalizeSidebarSessionsGrouping", () => { - it("accepts none and falls back to category grouping", () => { + it("accepts supported modes and falls back to category grouping", () => { expect(normalizeSidebarSessionsGrouping("none")).toBe("none"); + expect(normalizeSidebarSessionsGrouping("person")).toBe("person"); expect(normalizeSidebarSessionsGrouping("category")).toBe("category"); expect(normalizeSidebarSessionsGrouping(null)).toBe("category"); expect(normalizeSidebarSessionsGrouping("bogus")).toBe("category"); @@ -329,6 +392,7 @@ function row( describe("normalizeSessionsGroupBy", () => { it("accepts known modes and falls back to none", () => { expect(normalizeSessionsGroupBy("category")).toBe("category"); + expect(normalizeSessionsGroupBy("person")).toBe("person"); expect(normalizeSessionsGroupBy("date")).toBe("date"); expect(normalizeSessionsGroupBy("bogus")).toBe("none"); expect(normalizeSessionsGroupBy(null)).toBe("none"); @@ -362,6 +426,21 @@ describe("groupSessionRows", () => { expect(groups.map((group) => group.id)).toEqual(["discord", "telegram", UNGROUPED_ID]); }); + it("groups sessions by their durable owner identity and leaves ownerless sessions last", () => { + const groups = groupSessionRows({ + rows: [ + row({ key: "bob", owner: { actor: { type: "human", id: "profile-b", label: "Bob" } } }), + row({ key: "ownerless" }), + row({ key: "ada", owner: { actor: { type: "human", id: " profile-a ", label: "Ada" } } }), + row({ key: "blank", owner: { actor: { type: "human", id: " " } } }), + ], + mode: "person", + }); + + expect(groups.map((group) => group.id)).toEqual(["profile-a", "profile-b", UNGROUPED_ID]); + expect(groups[2]?.rows.map((item) => item.key)).toEqual(["ownerless", "blank"]); + }); + it("preserves row order within a group", () => { const rows = [ row({ key: "agent:main:discord:channel:1" }), diff --git a/ui/src/lib/sessions/grouping.ts b/ui/src/lib/sessions/grouping.ts index ec7c647b1334..501cdb4dc594 100644 --- a/ui/src/lib/sessions/grouping.ts +++ b/ui/src/lib/sessions/grouping.ts @@ -6,6 +6,7 @@ import { parseAgentSessionKey, parseSessionKeyParts } from "./session-key.ts"; export const SESSION_GROUP_MODES = [ "none", "category", + "person", "channel", "kind", "agent", @@ -25,8 +26,16 @@ export type SessionRowGroup = { }; export type SidebarSessionSection = { - id: "pinned" | "ungrouped" | "groups" | "work" | `category:${string}` | `catalog:${string}`; + id: + | "pinned" + | "ungrouped" + | "groups" + | "work" + | `category:${string}` + | `person:${string}` + | `catalog:${string}`; category?: string; + personOwner?: { type: string; id: string; label?: string; avatarUrl?: string }; /** Built-in smart group-conversation section (kind "group" rows). */ groups?: boolean; /** Built-in smart coding section (worktree/exec-node/ACP sessions). */ @@ -128,6 +137,8 @@ function resolveSessionGroupId(row: GatewaySessionRow, mode: SessionsGroupBy, no switch (mode) { case "category": return row.category?.trim() ?? UNGROUPED_ID; + case "person": + return row.owner?.actor.id?.trim() || UNGROUPED_ID; case "channel": return sessionRowChannel(row); case "kind": @@ -169,16 +180,17 @@ export function groupSessionRows(params: { return ids.map((id) => ({ id, rows: byId.get(id) ?? [] })); } -/** How the sidebar buckets non-pinned rows: category sections or one flat list. */ -export type SidebarSessionsGrouping = "category" | "none"; +/** How the sidebar buckets non-pinned rows before its built-in smart zones. */ +export type SidebarSessionsGrouping = "category" | "person" | "none"; export function normalizeSidebarSessionsGrouping(raw: unknown): SidebarSessionsGrouping { - return raw === "none" ? "none" : "category"; + return raw === "none" || raw === "person" ? raw : "category"; } type SidebarGroupableRow = { pinned?: boolean; category?: string | null; + owner?: { actor: { type: string; id?: string; label?: string; avatarUrl?: string } }; /** Session kind from the gateway row; "group" rows form the Groups zone. */ kind?: string; /** Session bound to a managed worktree or exec node (Coding zone). */ @@ -216,6 +228,7 @@ export function groupSidebarSessionRows( options: { knownGroups?: readonly string[]; grouping?: SidebarSessionsGrouping; + selfOwnerId?: string | null; sectionOrder?: readonly string[]; catalogIds?: readonly string[]; } = {}, @@ -226,6 +239,7 @@ export function groupSidebarSessionRows( const groups: Row[] = []; const coding: Row[] = []; const categories = new Map(); + const people = new Map>(); if (grouping === "category") { for (const name of options.knownGroups ?? []) { const trimmed = name.trim(); @@ -239,6 +253,26 @@ export function groupSidebarSessionRows( pinned.push(row); continue; } + const owner = grouping === "person" ? row.owner?.actor : undefined; + const ownerId = owner?.id?.trim(); + if (owner && ownerId) { + const personSection = people.get(ownerId); + if (personSection) { + personSection.rows.push(row); + } else { + people.set(ownerId, { + id: `person:${ownerId}`, + personOwner: { + type: owner.type, + id: ownerId, + ...(owner.label ? { label: owner.label } : {}), + ...(owner.avatarUrl ? { avatarUrl: owner.avatarUrl } : {}), + }, + rows: [row], + }); + } + continue; + } const category = grouping === "category" ? row.category?.trim() : undefined; if (category) { const categoryRows = categories.get(category); @@ -264,6 +298,21 @@ export function groupSidebarSessionRows( if (pinned.length > 0) { sections.push({ id: "pinned", rows: pinned }); } + sections.push( + ...[...people.values()].toSorted((left, right) => { + const leftOwner = left.personOwner!; + const rightOwner = right.personOwner!; + const leftRank = + leftOwner.id === options.selfOwnerId ? 0 : leftOwner.type === "agent" ? 2 : 1; + const rightRank = + rightOwner.id === options.selfOwnerId ? 0 : rightOwner.type === "agent" ? 2 : 1; + return ( + leftRank - rightRank || + (leftOwner.label || leftOwner.id).localeCompare(rightOwner.label || rightOwner.id) || + leftOwner.id.localeCompare(rightOwner.id) + ); + }), + ); const knownGroups = [ ...new Set((options.knownGroups ?? []).map((name) => name.trim()).filter(Boolean)), ]; diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index c75a5152922d..6d900556ad74 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -1510,6 +1510,8 @@ class SessionsPage extends OpenClawLightDomElement { override render() { const context = this.context; + const personGroupingAvailable = + context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities === true; if (!context) { return html``; } @@ -1559,7 +1561,10 @@ class SessionsPage extends OpenClawLightDomElement { ), sortColumn: this.sortColumn, sortDir: this.sortDir, - groupBy: this.groupBy, + // Same reconnect resilience as the sidebar: the stored Person + // preference survives a temporarily hidden identity capability. + groupBy: personGroupingAvailable || this.groupBy !== "person" ? this.groupBy : "none", + personGroupingAvailable, knownCategories: this.knownCategories(), page: this.page, pageSize: this.pageSize, diff --git a/ui/src/pages/sessions/view.test.ts b/ui/src/pages/sessions/view.test.ts index 26285eef43a8..f70ceae99223 100644 --- a/ui/src/pages/sessions/view.test.ts +++ b/ui/src/pages/sessions/view.test.ts @@ -49,6 +49,7 @@ function buildProps(result: SessionsListResult): SessionsProps { sortColumn: "updated", sortDir: "desc", groupBy: "none", + personGroupingAvailable: true, knownCategories: [], page: 0, pageSize: 10, @@ -482,6 +483,57 @@ describe("sessions view", () => { expect(container.querySelectorAll(".session-data-row")).toHaveLength(1); }); + it("offers person grouping and labels owner sections from their durable profile", async () => { + const container = document.createElement("div"); + render( + renderSessions({ + ...buildProps( + buildMultiResult([ + { + key: "agent:main:ada", + kind: "direct", + updatedAt: 2, + owner: { actor: { type: "human", id: "profile-ada", label: "Ada Lovelace" } }, + }, + { key: "agent:main:ownerless", kind: "direct", updatedAt: 1 }, + ]), + ), + groupBy: "person", + }), + container, + ); + await Promise.resolve(); + + expect( + container + .querySelector('.session-groupby__select option[value="person"]') + ?.textContent?.trim(), + ).toBe("Person"); + expect( + [...container.querySelectorAll(".session-group-row__label")].map((label) => + label.textContent?.trim(), + ), + ).toEqual(["Ada Lovelace", "Ungrouped"]); + }); + + it("hides the person grouping option without the identity capability", async () => { + const container = document.createElement("div"); + render( + renderSessions({ + ...buildProps(buildResult({ key: "agent:main:a", kind: "direct", updatedAt: 1 })), + personGroupingAvailable: false, + }), + container, + ); + await Promise.resolve(); + + const modes = [ + ...container.querySelectorAll(".session-groupby__select option"), + ].map((option) => option.value); + expect(modes).not.toContain("person"); + expect(modes).toContain("category"); + }); + it("selects and names the current page size on first render", async () => { const container = document.createElement("div"); render( diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts index a4cd25169c9b..3fe4a8b60c76 100644 --- a/ui/src/pages/sessions/view.ts +++ b/ui/src/pages/sessions/view.ts @@ -90,6 +90,8 @@ export type SessionsProps = { sortColumn: "key" | "kind" | "updated" | "tokens"; sortDir: "asc" | "desc"; groupBy: SessionsGroupBy; + /** Multi-identity gateways only; hides the Person mode elsewhere. */ + personGroupingAvailable: boolean; knownCategories: string[]; page: number; pageSize: number; @@ -780,6 +782,7 @@ function sessionsTableColumnCount(props: SessionsProps): number { const SESSION_GROUP_MODE_LABELS = { none: "sessionsView.groupByNone", category: "sessionsView.groupByCategory", + person: "sessionsView.groupByPerson", channel: "sessionsView.groupByChannel", kind: "sessionsView.groupByKind", agent: "sessionsView.groupByAgent", @@ -790,7 +793,8 @@ function groupModeLabel(mode: SessionsGroupBy): string { return t(SESSION_GROUP_MODE_LABELS[mode] ?? SESSION_GROUP_MODE_LABELS.none); } -function sessionGroupLabel(id: string, props: SessionsProps): string { +function sessionGroupLabel(group: SessionRowGroup, props: SessionsProps): string { + const { id } = group; if (props.groupBy === "date") { const labels: Record = { today: "sessionsView.dateToday", @@ -811,6 +815,9 @@ function sessionGroupLabel(id: string, props: SessionsProps): string { return emoji ? `${emoji} ${name}` : name; } } + if (props.groupBy === "person") { + return group.rows[0]?.owner?.actor.label?.trim() || id; + } return id; } @@ -856,7 +863,7 @@ function categoryDropHandlers(props: SessionsProps, category: string | null) { } function renderGroupHeaderRow(group: SessionRowGroup, props: SessionsProps) { - const label = sessionGroupLabel(group.id, props); + const label = sessionGroupLabel(group, props); const count = group.rows.length === 1 ? t("sessionsView.groupRowCountOne", { count: "1" }) @@ -1215,7 +1222,9 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) { @change=${(e: Event) => props.onGroupByChange((e.target as HTMLSelectElement).value as SessionsGroupBy)} > - ${SESSION_GROUP_MODES.map( + ${SESSION_GROUP_MODES.filter( + (mode) => mode !== "person" || props.personGroupingAvailable, + ).map( (mode) => html`