diff --git a/config/knip.config.ts b/config/knip.config.ts index 97184cde1fe4..46d89d0b5f77 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -156,7 +156,7 @@ const config = { entry: [ "index.html!", "src/main.ts!", - "src/ui/browser-redact.ts!", + "src/lib/browser-redact.ts!", "vite.config.ts!", "vitest*.ts!", ], diff --git a/extensions/qa-lab/src/coverage-report.test.ts b/extensions/qa-lab/src/coverage-report.test.ts index acf08047c095..bf8b4914b49a 100644 --- a/extensions/qa-lab/src/coverage-report.test.ts +++ b/extensions/qa-lab/src/coverage-report.test.ts @@ -211,7 +211,7 @@ describe("qa coverage report", () => { ).toContainEqual({ coverageId: TEST_BROWSER_COVERAGE_ID, kind: "playwright", - path: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + path: "ui/src/e2e/chat-flow.e2e.test.ts", role: "primary", scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.yaml"], }); @@ -286,7 +286,7 @@ describe("qa coverage report", () => { expect(report).toContain( "- browser-automation-and-exec-sandbox-tools.tool-invocation-and-execution (browser-automation-and-exec-sandbox-tools / Tool Invocation and Execution; partial): profiles: all, release, smoke-ci; coverage IDs:", ); - expect(report).toContain("primary:playwright:ui/src/ui/e2e/chat-flow.e2e.test.ts (ui.control)"); + expect(report).toContain("primary:playwright:ui/src/e2e/chat-flow.e2e.test.ts (ui.control)"); expect(report).not.toContain("### Unknown Scenario Coverage IDs"); }); @@ -300,12 +300,12 @@ describe("qa coverage report", () => { expect(report).toContain( "- Suite command: `pnpm openclaw qa suite --scenario control-ui-chat-flow-playwright`", ); - expect(report).toContain(" - execution: playwright ui/src/ui/e2e/chat-flow.e2e.test.ts"); + expect(report).toContain(" - execution: playwright ui/src/e2e/chat-flow.e2e.test.ts"); expect(report).not.toContain("Native test refs"); }); it("splits qa suite targets when matches mix execution kinds", () => { - const playwrightExecutionPath = "ui/src/ui/e2e/chat-flow.e2e.test.ts"; + const playwrightExecutionPath = "ui/src/e2e/chat-flow.e2e.test.ts"; const flowScenario = scenarioWithCoverage({ primary: [TEST_EXECUTABLE_COVERAGE_ID], }); @@ -389,7 +389,7 @@ describe("qa coverage report", () => { primary: [TEST_BROWSER_COVERAGE_ID], sourcePath: "qa/scenarios/ui/control-ui-chat-flow-playwright.yaml", executionKind: "playwright", - executionPath: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + executionPath: "ui/src/e2e/chat-flow.e2e.test.ts", }), ], }); @@ -405,7 +405,7 @@ describe("qa coverage report", () => { { coverageId: TEST_BROWSER_COVERAGE_ID, kind: "playwright", - path: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + path: "ui/src/e2e/chat-flow.e2e.test.ts", role: "primary", scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.yaml"], }, diff --git a/extensions/qa-lab/src/run-config.test.ts b/extensions/qa-lab/src/run-config.test.ts index 882ac76acfce..b0102f41438b 100644 --- a/extensions/qa-lab/src/run-config.test.ts +++ b/extensions/qa-lab/src/run-config.test.ts @@ -44,7 +44,7 @@ const scenarios = [ successCriteria: ["playwright pass"], execution: { kind: "playwright" as const, - path: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + path: "ui/src/e2e/chat-flow.e2e.test.ts", }, }, ]; diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index e2cbdb5bd429..eb8dfe0e79ea 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -221,7 +221,7 @@ describe("qa scenario catalog", () => { if (scenario.execution.kind !== "playwright") { throw new Error(`expected Playwright scenario, got ${scenario.execution.kind}`); } - expect(scenario.execution.path).toBe("ui/src/ui/e2e/chat-flow.e2e.test.ts"); + expect(scenario.execution.path).toBe("ui/src/e2e/chat-flow.e2e.test.ts"); expect(scenario.execution.flow).toBeUndefined(); expect(scenario.coverage?.primary).toContain("ui.control"); expect(uxMatrix.execution.kind).toBe("script"); diff --git a/extensions/qa-lab/src/suite-planning.test.ts b/extensions/qa-lab/src/suite-planning.test.ts index 073f45f31385..0f2bee883e89 100644 --- a/extensions/qa-lab/src/suite-planning.test.ts +++ b/extensions/qa-lab/src/suite-planning.test.ts @@ -25,7 +25,7 @@ function makePlaywrightQaSuiteTestScenario(id: string): ReturnType { outputDir: path.join(repoRoot, ".artifacts", "qa-e2e", "scenario-playwright"), providerMode: "mock-openai", primaryModel: "mock-openai/gpt-5.5", - scenarios: [makeTestFileScenario("playwright", "ui/src/ui/e2e/chat-flow.e2e.test.ts")], + scenarios: [makeTestFileScenario("playwright", "ui/src/e2e/chat-flow.e2e.test.ts")], runCommand: async (command) => { commands.push(command); return { @@ -187,7 +187,7 @@ describe("qa test file scenario runner", () => { "test/vitest/vitest.ui-e2e.config.ts", "--configLoader", "runner", - "ui/src/ui/e2e/chat-flow.e2e.test.ts", + "ui/src/e2e/chat-flow.e2e.test.ts", "--reporter=verbose", ], ]); @@ -202,7 +202,7 @@ describe("qa test file scenario runner", () => { kind: "playwright-test", id: "scenario-playwright", source: { - path: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + path: "ui/src/e2e/chat-flow.e2e.test.ts", }, }, coverage: [ @@ -222,7 +222,7 @@ describe("qa test file scenario runner", () => { }, { kind: "code", - path: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + path: "ui/src/e2e/chat-flow.e2e.test.ts", }, ], execution: { @@ -248,7 +248,7 @@ describe("qa test file scenario runner", () => { outputDir: path.join(repoRoot, ".artifacts", "qa-e2e", "scenario-playwright"), providerMode: "mock-openai", primaryModel: "mock-openai/gpt-5.5", - scenarios: [makeTestFileScenario("playwright", "ui/src/ui/e2e/chat-flow.e2e.test.ts")], + scenarios: [makeTestFileScenario("playwright", "ui/src/e2e/chat-flow.e2e.test.ts")], writeEvidenceFile: false, runCommand: async () => ({ exitCode: 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e97472d2641..a3d2ca514a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1942,6 +1942,9 @@ importers: '@openclaw/normalization-core': specifier: workspace:* version: link:../packages/normalization-core + '@openclaw/uirouter': + specifier: 0.1.0 + version: 0.1.0 dompurify: specifier: 3.4.11 version: 3.4.11 @@ -3149,6 +3152,10 @@ packages: peerDependencies: undici: '>=8.3.0 <9' + '@openclaw/uirouter@0.1.0': + resolution: {integrity: sha512-w5tNj2FIukVJqJ1wt5wiDbrI6DI4tOkUbtqnnU5Fl4EgnRKFJtfHI3WrWTWTTJlXbbrwGSMptyrSZmQVdRo83Q==} + engines: {node: ^22.18.0 || >=24.11.0} + '@opentelemetry/api-logs@0.219.0': resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} engines: {node: '>=8.0.0'} @@ -8910,6 +8917,8 @@ snapshots: dependencies: undici: 8.5.0 + '@openclaw/uirouter@0.1.0': {} + '@opentelemetry/api-logs@0.219.0': dependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 05dbde670cc6..b04519a13a20 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ minimumReleaseAgeExclude: - "@openclaw/crabline@0.1.9" - "@openclaw/fs-safe@0.4.1" - "@openclaw/proxyline@0.3.3" + - "@openclaw/uirouter@0.1.0" - "acpx" - "tokenjuice" - "@agentclientprotocol/sdk" diff --git a/qa/scenarios/ui/browser-talk-start-stop.yaml b/qa/scenarios/ui/browser-talk-start-stop.yaml index f69cf0a9435f..35020726bc12 100644 --- a/qa/scenarios/ui/browser-talk-start-stop.yaml +++ b/qa/scenarios/ui/browser-talk-start-stop.yaml @@ -18,9 +18,10 @@ scenario: - docs/web/control-ui.md - docs/help/testing.md codeRefs: - - ui/src/ui/e2e/browser-talk-start-stop.e2e.test.ts + - ui/src/e2e/browser-talk-start-stop.e2e.test.ts - ui/src/test-helpers/control-ui-e2e.ts + - ui/src/pages/chat/realtime-talk-google-live.test.ts execution: kind: playwright - path: ui/src/ui/e2e/browser-talk-start-stop.e2e.test.ts + path: ui/src/e2e/browser-talk-start-stop.e2e.test.ts summary: Playwright coverage for browser realtime Talk start and stop through the Control UI. diff --git a/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml b/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml index ee6151fda8fb..461897fbf684 100644 --- a/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml +++ b/qa/scenarios/ui/control-ui-chat-flow-playwright.yaml @@ -12,8 +12,8 @@ scenario: docsRefs: - docs/web/control-ui.md codeRefs: - - ui/src/ui/e2e/chat-flow.e2e.test.ts + - ui/src/e2e/chat-flow.e2e.test.ts execution: kind: playwright - path: ui/src/ui/e2e/chat-flow.e2e.test.ts + path: ui/src/e2e/chat-flow.e2e.test.ts summary: Playwright coverage for the Control UI chat flow. diff --git a/scripts/check-no-raw-window-open.mjs b/scripts/check-no-raw-window-open.mjs index a78b686138d0..5f685796724b 100644 --- a/scripts/check-no-raw-window-open.mjs +++ b/scripts/check-no-raw-window-open.mjs @@ -86,7 +86,7 @@ export async function main() { for (const violation of violations) { console.error(`- ${violation}`); } - console.error("Use openExternalUrlSafe(...) from ui/src/ui/open-external-url.ts instead."); + console.error("Use openExternalUrlSafe(...) from ui/src/lib/open-external-url.ts instead."); process.exit(1); } diff --git a/scripts/committer b/scripts/committer index 3f49e3c0652e..1fbf4eac22ee 100755 --- a/scripts/committer +++ b/scripts/committer @@ -65,7 +65,9 @@ fi path_exists_or_tracked() { local candidate=$1 - [ -e "$candidate" ] || git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1 + [ -e "$candidate" ] || + git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1 || + git cat-file -e "HEAD:$candidate" >/dev/null 2>&1 } append_normalized_file_arg() { @@ -206,7 +208,7 @@ for file in "${files[@]}"; do done run_git_with_lock_retry "unstaging files" git restore --staged :/ -run_git_with_lock_retry "staging files" git add --force -- "${files[@]}" +run_git_with_lock_retry "staging files" git add --all --force -- "${files[@]}" if git diff --staged --quiet; then printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2 diff --git a/scripts/control-ui-i18n-report.ts b/scripts/control-ui-i18n-report.ts index cb43eb26eb88..fef1111a7746 100644 --- a/scripts/control-ui-i18n-report.ts +++ b/scripts/control-ui-i18n-report.ts @@ -22,13 +22,13 @@ const LOCALE_LABELS: Record = { nl: "Dutch", pl: "Polish", "pt-BR": "Brazilian Portuguese", + ru: "Russian", th: "Thai", tr: "Turkish", uk: "Ukrainian", vi: "Vietnamese", "zh-CN": "Simplified Chinese", "zh-TW": "Traditional Chinese", - ru: "Russian", }; const REPORT_LOCALES = new Set(Object.keys(LOCALE_LABELS)); const PATH_LABELS: Record = { diff --git a/scripts/control-ui-i18n.ts b/scripts/control-ui-i18n.ts index d5de84a4f1c4..afd0644871e7 100644 --- a/scripts/control-ui-i18n.ts +++ b/scripts/control-ui-i18n.ts @@ -111,7 +111,12 @@ const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales"); const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n"); const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts"); const SOURCE_LOCALE = "en"; -const CONTROL_UI_SOURCE_DIR = path.join(ROOT, "ui", "src", "ui"); +const CONTROL_UI_RAW_COPY_SOURCE_DIRS = [ + path.join(ROOT, "ui", "src", "app"), + path.join(ROOT, "ui", "src", "components"), + path.join(ROOT, "ui", "src", "lib"), + path.join(ROOT, "ui", "src", "pages"), +] as const; const RAW_COPY_BASELINE_PATH = path.join(I18N_ASSETS_DIR, "raw-copy-baseline.json"); const RAW_COPY_BASELINE_VERSION = 1; const MAX_BATCH_ITEMS = 20; @@ -809,7 +814,9 @@ function collectRawCopyFromSource(params: { } async function collectControlUiRawCopyFindings(): Promise { - const files = await walkControlUiSourceFiles(CONTROL_UI_SOURCE_DIR); + const files = ( + await Promise.all(CONTROL_UI_RAW_COPY_SOURCE_DIRS.map((dir) => walkControlUiSourceFiles(dir))) + ).flat(); const findings: RawCopyFinding[] = []; for (const filePath of files.toSorted((left, right) => left.localeCompare(right))) { const source = await readFile(filePath, "utf8"); diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index e5ba572abd3e..e31686815332 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -338,7 +338,7 @@ async function createChatPickerScenario(): Promise "export default function controlUiViteConfig() {\n return { server: { strictPort: true } };\n}\n", ], [ - "ui/src/ui/e2e/chat-flow.e2e.test.ts", + "ui/src/e2e/chat-flow.e2e.test.ts", "it('keeps the session workspace useful while browsing files', async () => {\n await page.getByText('Project files').waitFor();\n});\n", ], ]); @@ -510,7 +510,7 @@ async function createChatPickerScenario(): Promise { kind: "file", name: "chat-flow.e2e.test.ts", - path: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + path: "ui/src/e2e/chat-flow.e2e.test.ts", size: 24950, updatedAtMs: baseTime - 25_000, }, diff --git a/scripts/deadcode-unused-files.allowlist.mjs b/scripts/deadcode-unused-files.allowlist.mjs index 090df07f9232..757eb4543e2d 100644 --- a/scripts/deadcode-unused-files.allowlist.mjs +++ b/scripts/deadcode-unused-files.allowlist.mjs @@ -16,7 +16,6 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [ "extensions/diffs/src/viewer-client.ts", "extensions/diffs/src/viewer-payload.ts", "extensions/matrix/src/plugin-entry.runtime.js", - "ui/src/ui/browser-redact.ts", "src/agents/subagent-registry.runtime.ts", "src/auto-reply/reply/get-reply.test-loader.ts", "src/cli/daemon-cli-compat.ts", diff --git a/scripts/lib/tsgo-sparse-guard.mjs b/scripts/lib/tsgo-sparse-guard.mjs index 6c6d1fdf5fda..c040a6837cc3 100644 --- a/scripts/lib/tsgo-sparse-guard.mjs +++ b/scripts/lib/tsgo-sparse-guard.mjs @@ -47,8 +47,8 @@ const CORE_TEST_REQUIRED_PATHS = [ "ui/config/control-ui-chunking.ts", "ui/src/i18n/lib/registry.ts", "ui/src/i18n/lib/types.ts", - "ui/src/ui/app-settings.ts", - "ui/src/ui/gateway.ts", + "ui/src/app/settings.ts", + "ui/src/api/gateway.ts", ]; /** diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index 78690fec11bc..e40c4a9a49fe 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -5,7 +5,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { isUiTestTarget, isUnitUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs"; +import { isUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs"; import { boundaryTestFiles } from "../test/vitest/vitest.unit-paths.mjs"; import { resolveLocalVitestEnv } from "./lib/vitest-local-scheduling.mjs"; import { spawnPnpmRunner } from "./pnpm-runner.mjs"; @@ -31,7 +31,6 @@ export const DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS = 2_400_000; const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS"; const VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_HEARTBEAT_MS"; const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts"; -const UNIT_UI_VITEST_CONFIG = "test/vitest/vitest.unit-ui.config.ts"; const TOOLING_DOCKER_VITEST_CONFIG = "test/vitest/vitest.tooling-docker.config.ts"; const TOOLING_VITEST_CONFIG = "test/vitest/vitest.tooling.config.ts"; const GATEWAY_CORE_VITEST_CONFIG = "test/vitest/vitest.gateway-core.config.ts"; @@ -744,16 +743,10 @@ export function resolveImplicitVitestArgs(argv, cwd = process.cwd()) { if (testTargets.length > 0 && testTargets.every(isToolingTestTarget)) { return withImplicitVitestConfig(argv, TOOLING_VITEST_CONFIG); } - if (testTargets.length === 0 || !testTargets.every(isUnitUiTestTarget)) { - if ( - testTargets.length > 0 && - testTargets.every((target) => isUiTestTarget(target) && !isUnitUiTestTarget(target)) - ) { - return withImplicitVitestConfig(argv, UI_VITEST_CONFIG); - } - return argv; + if (testTargets.length > 0 && testTargets.every(isUiTestTarget)) { + return withImplicitVitestConfig(argv, UI_VITEST_CONFIG); } - return withImplicitVitestConfig(argv, UNIT_UI_VITEST_CONFIG); + return argv; } function spawnVitestProcess({ pnpmArgs, spawnParams }) { diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 06fff758063a..b6e61c8b60cc 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -42,7 +42,6 @@ import { resolvePluginSdkLightIncludePattern, } from "../test/vitest/vitest.plugin-sdk-paths.mjs"; import { fullSuiteVitestShards } from "../test/vitest/vitest.test-shards.mjs"; -import { isUnitUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs"; import { getUnitFastTestFiles, resolveUnitFastTestIncludePattern, @@ -158,7 +157,6 @@ const UNIT_FAST_FAKE_TIMERS_VITEST_CONFIG = "test/vitest/vitest.unit-fast-fake-t const UNIT_SECURITY_VITEST_CONFIG = "test/vitest/vitest.unit-security.config.ts"; const UNIT_SRC_VITEST_CONFIG = "test/vitest/vitest.unit-src.config.ts"; const UNIT_SUPPORT_VITEST_CONFIG = "test/vitest/vitest.unit-support.config.ts"; -const UNIT_UI_VITEST_CONFIG = "test/vitest/vitest.unit-ui.config.ts"; const FULL_SUITE_CONFIG_WEIGHT = new Map([ [GATEWAY_VITEST_CONFIG, 180], @@ -212,7 +210,6 @@ const FULL_SUITE_CONFIG_WEIGHT = new Map([ [COMMANDS_LIGHT_VITEST_CONFIG, 48], [PLUGIN_SDK_VITEST_CONFIG, 46], [AUTO_REPLY_TOP_LEVEL_VITEST_CONFIG, 45], - [UNIT_UI_VITEST_CONFIG, 40], [PLUGIN_SDK_LIGHT_VITEST_CONFIG, 38], [DAEMON_VITEST_CONFIG, 36], [BOUNDARY_VITEST_CONFIG, 34], @@ -378,7 +375,6 @@ const VITEST_CONFIG_BY_KIND = { unitSecurity: UNIT_SECURITY_VITEST_CONFIG, unitSrc: UNIT_SRC_VITEST_CONFIG, unitSupport: UNIT_SUPPORT_VITEST_CONFIG, - unitUi: UNIT_UI_VITEST_CONFIG, runtimeConfig: RUNTIME_CONFIG_VITEST_CONFIG, secrets: SECRETS_VITEST_CONFIG, sharedCore: SHARED_CORE_VITEST_CONFIG, @@ -2079,7 +2075,7 @@ const SOURCE_TEST_TARGETS = new Map([ ], ["src/plugins/runtime-sidecar-paths-baseline.ts", RUNTIME_SIDECAR_BASELINE_OWNER_TEST_TARGETS], ["src/plugins/runtime-sidecar-paths.ts", RUNTIME_SIDECAR_PATH_CONSUMER_TEST_TARGETS], - ["ui/config/control-ui-chunking.ts", ["ui/src/ui/control-ui-chunking.test.ts"]], + ["ui/config/control-ui-chunking.ts", ["ui/src/app/control-ui-chunking.test.ts"]], [ "src/plugin-sdk/test-helpers/directory-ids.ts", [ @@ -3024,8 +3020,8 @@ function isVitestConfigTargetForKind(kind, targetArg, cwd) { function isControlUiE2eTarget(relative) { return ( relative === "ui/src/test-helpers/control-ui-e2e.ts" || - relative === "ui/src/ui/e2e" || - relative.startsWith("ui/src/ui/e2e/") || + relative === "ui/src/e2e" || + relative.startsWith("ui/src/e2e/") || (relative.startsWith("ui/src/") && relative.endsWith(".e2e.test.ts")) ); } @@ -3306,10 +3302,7 @@ function shouldCombineSiblingTestWithImportGraph(changedPath) { } function shouldRouteChangedTargetWithoutImportGraph(changedPath) { - return ( - changedPath.endsWith(".live.test.ts") || - (changedPath.startsWith("ui/src/") && !changedPath.startsWith("ui/src/ui/")) - ); + return changedPath.endsWith(".live.test.ts") || changedPath.startsWith("ui/src/"); } function resolvePromptSnapshotFixtureTargets(changedPath) { @@ -3463,9 +3456,6 @@ function classifyTarget(arg, cwd) { return "uiE2e"; } if (isPathAtOrUnder(relative, "ui/src")) { - if (isUnitUiTestTarget(relative)) { - return "unitUi"; - } return "ui"; } if (relative.startsWith("src/tui/tui-pty-")) { @@ -3704,7 +3694,7 @@ function shouldUseWholeConfigTarget(kind, targetArg, cwd) { if (isTestFileTarget(relative)) { return false; } - return relative.startsWith("ui/src/") && !relative.startsWith("ui/src/ui/"); + return relative.startsWith("ui/src/"); } function createVitestArgs(params) { @@ -3903,7 +3893,6 @@ export function buildVitestRunPlans( "unitSrc", "unitSecurity", "unitSupport", - "unitUi", "utils", "wizard", "e2e", diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 54c78927de2b..34356462a27b 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -40,7 +40,7 @@ function extractConstUnionValues(schema: SchemaLike): string[] { .filter((value): value is string => typeof value === "string"); } -const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; +const UI_FILES = ["ui/src/api/types.ts", "ui/src/lib/cron/index.ts", "ui/src/pages/cron/view.ts"]; const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`]; const SWIFT_STATUS_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`]; @@ -86,7 +86,7 @@ describe("cron protocol conformance", () => { it("cron status shape matches gateway fields in UI + Swift", async () => { const cwd = process.cwd(); - const uiTypes = await fs.readFile(path.join(cwd, "ui/src/ui/types.ts"), "utf-8"); + const uiTypes = await fs.readFile(path.join(cwd, "ui/src/api/types.ts"), "utf-8"); expect(uiTypes).toContain("export type CronStatus"); expect(uiTypes).toContain("jobs:"); expect(uiTypes).not.toContain("jobCount"); diff --git a/src/gateway/reconnect-gating.test.ts b/src/gateway/reconnect-gating.test.ts index 52f1c53ad417..35dafebd4986 100644 --- a/src/gateway/reconnect-gating.test.ts +++ b/src/gateway/reconnect-gating.test.ts @@ -2,7 +2,7 @@ // auth error details that cannot recover by retrying. import { describe, expect, it } from "vitest"; import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; -import { type GatewayErrorInfo, isNonRecoverableAuthError } from "../../ui/src/ui/gateway.ts"; +import { type GatewayErrorInfo, isNonRecoverableAuthError } from "../../ui/src/api/gateway.ts"; function makeError(detailCode: string): GatewayErrorInfo { return { code: "connect_failed", message: "auth failed", details: { code: detailCode } }; diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 1e7ff6ce4ba6..6ce0ab3061a3 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -834,7 +834,7 @@ describe("test-projects args", () => { it("routes unit ui targets to the unit ui config", () => { expect(buildVitestRunPlans(["ui/src/ui/views/channels.test.ts"])).toEqual([ { - config: "test/vitest/vitest.unit-ui.config.ts", + config: "test/vitest/vitest.ui.config.ts", forwardedArgs: [], includePatterns: ["ui/src/ui/views/channels.test.ts"], watchMode: false, diff --git a/src/ui-app-settings.agents-files-refresh.test.ts b/src/ui-app-settings.agents-files-refresh.test.ts deleted file mode 100644 index 2627cfd35d96..000000000000 --- a/src/ui-app-settings.agents-files-refresh.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Tests UI app settings refresh behavior for agent files. -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const loadAgentsMock = vi.hoisted(() => - vi.fn(async (host: { agentsList?: unknown }) => { - host.agentsList = { - defaultId: "main", - mainKey: "main", - scope: "per-sender", - agents: [{ id: "main" }], - }; - }), -); -const loadConfigMock = vi.hoisted(() => vi.fn(async () => undefined)); -const loadAgentIdentitiesMock = vi.hoisted(() => vi.fn(async () => undefined)); -const loadAgentIdentityMock = vi.hoisted(() => vi.fn(async () => undefined)); -const loadAgentSkillsMock = vi.hoisted(() => vi.fn(async () => undefined)); -const loadAgentFilesMock = vi.hoisted(() => vi.fn(async () => undefined)); -const loadChannelsMock = vi.hoisted(() => vi.fn(async () => undefined)); - -vi.mock("../ui/src/ui/controllers/agents.ts", () => ({ - loadAgents: loadAgentsMock, -})); - -vi.mock("../ui/src/ui/controllers/config.ts", () => ({ - loadConfig: loadConfigMock, - loadConfigSchema: vi.fn(async () => undefined), -})); - -vi.mock("../ui/src/ui/controllers/agent-identity.ts", () => ({ - loadAgentIdentities: loadAgentIdentitiesMock, - loadAgentIdentity: loadAgentIdentityMock, -})); - -vi.mock("../ui/src/ui/controllers/agent-skills.ts", () => ({ - loadAgentSkills: loadAgentSkillsMock, -})); - -vi.mock("../ui/src/ui/controllers/agent-files.ts", () => ({ - loadAgentFiles: loadAgentFilesMock, -})); - -vi.mock("../ui/src/ui/controllers/channels.ts", () => ({ - loadChannels: loadChannelsMock, -})); - -vi.mock("../ui/src/ui/controllers/cron.ts", () => ({ - loadCronJobsPage: vi.fn(async () => undefined), - loadCronRuns: vi.fn(async () => undefined), - loadCronStatus: vi.fn(async () => undefined), -})); - -import { refreshActiveTab } from "../ui/src/ui/app-settings.ts"; - -type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; - -function createHost(agentsPanel: AgentsPanel): Parameters[0] { - return { - tab: "agents", - connected: true, - agentsPanel, - agentsList: null, - agentsSelectedId: null, - settings: { - gatewayUrl: "", - token: "", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatShowThinking: true, - chatShowToolCalls: true, - splitRatio: 0.6, - navCollapsed: false, - navWidth: 220, - navGroupsCollapsed: {}, - borderRadius: 50, - }, - theme: "claw", - themeMode: "system", - themeResolved: "dark", - applySessionKey: "main", - sessionKey: "main", - chatHasAutoScrolled: false, - logsAtBottom: false, - eventLog: [], - eventLogBuffer: [], - basePath: "", - dreamingStatusLoading: false, - dreamingStatusError: null, - dreamingStatus: null, - dreamingModeSaving: false, - dreamDiaryLoading: false, - dreamDiaryError: null, - dreamDiaryPath: null, - dreamDiaryContent: null, - } as Parameters[0]; -} - -describe("refreshActiveTab (agents/files)", () => { - beforeEach(() => { - loadAgentsMock.mockClear(); - loadConfigMock.mockClear(); - loadAgentIdentitiesMock.mockClear(); - loadAgentIdentityMock.mockClear(); - loadAgentSkillsMock.mockClear(); - loadAgentFilesMock.mockClear(); - loadChannelsMock.mockClear(); - }); - - it("loads agent files when the active agents panel is files", async () => { - const host = createHost("files"); - await refreshActiveTab(host); - - expect(loadAgentFilesMock).toHaveBeenCalledTimes(1); - expect(loadAgentFilesMock).toHaveBeenCalledWith(host, "main"); - }); - - it("does not load agent files on non-files panels", async () => { - const host = createHost("overview"); - await refreshActiveTab(host); - - expect(loadAgentFilesMock).not.toHaveBeenCalled(); - }); -}); diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 4337a93b120f..a5da6dffd7ab 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -215,11 +215,6 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { requiresDist: false, shardName: "core-unit-src-security", }, - { - configs: ["test/vitest/vitest.unit-ui.config.ts"], - requiresDist: false, - shardName: "core-unit-ui", - }, { configs: ["test/vitest/vitest.unit-support.config.ts"], requiresDist: false, diff --git a/test/scripts/run-tsgo.test.ts b/test/scripts/run-tsgo.test.ts index 8b634b142ee4..703e5165e852 100644 --- a/test/scripts/run-tsgo.test.ts +++ b/test/scripts/run-tsgo.test.ts @@ -52,8 +52,8 @@ describe("run-tsgo sparse guard", () => { "ui/config/control-ui-chunking.ts", "ui/src/i18n/lib/registry.ts", "ui/src/i18n/lib/types.ts", - "ui/src/ui/app-settings.ts", - "ui/src/ui/gateway.ts", + "ui/src/app/settings.ts", + "ui/src/api/gateway.ts", ]; for (const relativePath of requiredPaths) { @@ -79,8 +79,8 @@ describe("run-tsgo sparse guard", () => { "ui/config/control-ui-chunking.ts", "ui/src/i18n/lib/registry.ts", "ui/src/i18n/lib/types.ts", - "ui/src/ui/app-settings.ts", - "ui/src/ui/gateway.ts", + "ui/src/app/settings.ts", + "ui/src/api/gateway.ts", ]; for (const relativePath of requiredPaths) { @@ -98,8 +98,8 @@ describe("run-tsgo sparse guard", () => { "/ui/config/control-ui-chunking.ts", "/ui/src/i18n/lib/registry.ts", "/ui/src/i18n/lib/types.ts", - "/ui/src/ui/app-settings.ts", - "/ui/src/ui/gateway.ts", + "/ui/src/app/settings.ts", + "/ui/src/api/gateway.ts", ], }), ).toMatchInlineSnapshot(` @@ -141,10 +141,10 @@ describe("run-tsgo sparse guard", () => { "tsconfig.core.test.json cannot be typechecked from this sparse checkout because tracked project inputs are missing or only partially included: - packages/plugin-package-contract/src/index.ts - ui/config/control-ui-chunking.ts + - ui/src/api/gateway.ts + - ui/src/app/settings.ts - ui/src/i18n/lib/registry.ts - ui/src/i18n/lib/types.ts - - ui/src/ui/app-settings.ts - - ui/src/ui/gateway.ts Expand this worktree's sparse checkout to include those paths, or rerun in a full worktree." `); }); diff --git a/test/scripts/run-vitest.test.ts b/test/scripts/run-vitest.test.ts index b3a3f6df732e..ca8a995f2b10 100644 --- a/test/scripts/run-vitest.test.ts +++ b/test/scripts/run-vitest.test.ts @@ -148,27 +148,11 @@ describe("scripts/run-vitest", () => { ).toContain("[vitest] Vitest is not installed in node_modules."); }); - it("routes explicit unit ui tests through the narrow unit ui config", () => { - expect( - resolveImplicitVitestArgs([ - "ui/src/ui/controllers/chat.test.ts", - "-t", - "keeps optimistic user attachment previews", - ]), - ).toEqual([ - "--config", - "test/vitest/vitest.unit-ui.config.ts", - "ui/src/ui/controllers/chat.test.ts", - "-t", - "keeps optimistic user attachment previews", - ]); - }); - it("does not override explicit vitest configs", () => { const argv = [ "--config", "test/vitest/vitest.ui.config.ts", - "ui/src/ui/controllers/chat.test.ts", + "ui/src/pages/chat/chat-send.test.ts", ]; expect(resolveImplicitVitestArgs(argv)).toBe(argv); }); @@ -400,29 +384,15 @@ describe("scripts/run-vitest", () => { ).toEqual([]); }); - it("keeps the run subcommand first when routing unit ui tests", () => { - expect(resolveImplicitVitestArgs(["run", "ui/src/ui/controllers/chat.test.ts"])).toEqual([ - "run", - "--config", - "test/vitest/vitest.unit-ui.config.ts", - "ui/src/ui/controllers/chat.test.ts", - ]); - }); - it("routes explicit non-e2e ui tests through the ui config", () => { - expect(resolveImplicitVitestArgs(["run", "ui/src/ui/app-gateway.node.test.ts"])).toEqual([ + expect(resolveImplicitVitestArgs(["run", "ui/src/pages/chat/chat-send.test.ts"])).toEqual([ "run", "--config", "test/vitest/vitest.ui.config.ts", - "ui/src/ui/app-gateway.node.test.ts", + "ui/src/pages/chat/chat-send.test.ts", ]); }); - it("keeps mixed unit ui and broader ui targets on existing routing", () => { - const argv = ["ui/src/ui/controllers/chat.test.ts", "ui/src/ui/app-gateway.node.test.ts"]; - expect(resolveImplicitVitestArgs(argv)).toBe(argv); - }); - it("allows opting back into Maglev explicitly", () => { expect( resolveVitestNodeArgs({ diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 21a1ae5182da..d0648464aa73 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -161,21 +161,6 @@ function withTinyGitRepo(files: Record, test: (cwd: string) => v } } -function commitTinyGitRepo(cwd: string): void { - const commit = spawnSync("git", ["commit", "-m", "initial"], { - cwd, - env: { - ...process.env, - GIT_AUTHOR_EMAIL: "test@example.com", - GIT_AUTHOR_NAME: "OpenClaw Test", - GIT_COMMITTER_EMAIL: "test@example.com", - GIT_COMMITTER_NAME: "OpenClaw Test", - }, - stdio: "ignore", - }); - expect(commit.status).toBe(0); -} - function withTinyFileTree(files: Record, test: (cwd: string) => void): void { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-projects-")); try { @@ -3185,36 +3170,7 @@ describe("scripts/test-projects changed-target routing", () => { { config: "test/vitest/vitest.ui.config.ts", forwardedArgs: [], - includePatterns: ["ui/src/ui/control-ui-chunking.test.ts"], - watchMode: false, - }, - ]); - }); - - it("routes unit ui test targets to the unit ui lane", () => { - expect(buildVitestRunPlans(["ui/src/ui/chat/grouped-render.test.ts"])).toEqual([ - { - config: "test/vitest/vitest.unit-ui.config.ts", - forwardedArgs: [], - includePatterns: ["ui/src/ui/chat/grouped-render.test.ts"], - watchMode: false, - }, - ]); - - expect(buildVitestRunPlans(["ui/src/ui/views/chat.test.ts"])).toEqual([ - { - config: "test/vitest/vitest.unit-ui.config.ts", - forwardedArgs: [], - includePatterns: ["ui/src/ui/views/chat.test.ts"], - watchMode: false, - }, - ]); - - expect(buildVitestRunPlans(["ui/src/ui/views/dreaming.test.ts"])).toEqual([ - { - config: "test/vitest/vitest.unit-ui.config.ts", - forwardedArgs: [], - includePatterns: ["ui/src/ui/views/dreaming.test.ts"], + includePatterns: ["ui/src/app/control-ui-chunking.test.ts"], watchMode: false, }, ]); @@ -3243,11 +3199,11 @@ describe("scripts/test-projects changed-target routing", () => { }); it("routes control ui e2e tests to the ui e2e lane", () => { - expect(buildVitestRunPlans(["ui/src/ui/e2e/chat-flow.e2e.test.ts"])).toEqual([ + expect(buildVitestRunPlans(["ui/src/e2e/chat-flow.e2e.test.ts"])).toEqual([ { config: "test/vitest/vitest.ui-e2e.config.ts", forwardedArgs: [], - includePatterns: ["ui/src/ui/e2e/chat-flow.e2e.test.ts"], + includePatterns: ["ui/src/e2e/chat-flow.e2e.test.ts"], watchMode: false, }, ]); @@ -3261,31 +3217,16 @@ describe("scripts/test-projects changed-target routing", () => { }, ]); - expect(buildVitestRunPlans(["ui/src/ui/e2e"])).toEqual([ + expect(buildVitestRunPlans(["ui/src/e2e"])).toEqual([ { config: "test/vitest/vitest.ui-e2e.config.ts", forwardedArgs: [], - includePatterns: ["ui/src/ui/e2e/**/*.test.ts"], + includePatterns: ["ui/src/e2e/**/*.test.ts"], watchMode: false, }, ]); - expect(buildVitestArgs(["ui/src/ui/e2e"])).toContain("--configLoader"); - }); - - it("routes changed unit ui tests to the unit ui lane", () => { - const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [ - "ui/src/ui/chat/grouped-render.test.ts", - ]); - - expect(plans).toEqual([ - { - config: "test/vitest/vitest.unit-ui.config.ts", - forwardedArgs: [], - includePatterns: ["ui/src/ui/chat/grouped-render.test.ts"], - watchMode: false, - }, - ]); + expect(buildVitestArgs(["ui/src/e2e"])).toContain("--configLoader"); }); it("routes auto-reply route source files to route regression tests", () => { @@ -4199,7 +4140,6 @@ describe("scripts/test-projects full-suite sharding", () => { "test/vitest/vitest.unit-fast-fake-timers.config.ts", "test/vitest/vitest.unit-src.config.ts", "test/vitest/vitest.unit-security.config.ts", - "test/vitest/vitest.unit-ui.config.ts", "test/vitest/vitest.unit-support.config.ts", "test/vitest/vitest.boundary.config.ts", "test/vitest/vitest.tooling.config.ts", diff --git a/test/scripts/test-skip-inventory.test.ts b/test/scripts/test-skip-inventory.test.ts index 42242c97d96d..f5bb48d4511f 100644 --- a/test/scripts/test-skip-inventory.test.ts +++ b/test/scripts/test-skip-inventory.test.ts @@ -70,7 +70,7 @@ describeLive("provider live", () => {}); ); writeRepoFile( repoRoot, - "ui/src/ui/e2e/chat-flow.e2e.test.ts", + "ui/src/e2e/chat-flow.e2e.test.ts", ` const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; describeControlUiE2e("control UI", () => {}); @@ -184,7 +184,7 @@ describe("collectTestSkipInventoryReport", () => { target: "it", }, { - file: "ui/src/ui/e2e/chat-flow.e2e.test.ts", + file: "ui/src/e2e/chat-flow.e2e.test.ts", kind: "alias", method: "skip", reason: "optional-dependency", diff --git a/test/tsconfig/tsconfig.core.test.json b/test/tsconfig/tsconfig.core.test.json index 32fafefbc6a1..e786fde1423f 100644 --- a/test/tsconfig/tsconfig.core.test.json +++ b/test/tsconfig/tsconfig.core.test.json @@ -8,7 +8,7 @@ "../../src/**/*.test.ts", "../../src/**/*.test.tsx", "../../ui/**/*.d.ts", - "../../ui/src/ui/app.ts", + "../../ui/src/main.ts", "../../ui/**/*.test.ts", "../../ui/**/*.test.tsx", "../../packages/**/*.d.ts", diff --git a/test/tsconfig/tsconfig.test.src.json b/test/tsconfig/tsconfig.test.src.json index d4b7131e429a..f26f54dd3e30 100644 --- a/test/tsconfig/tsconfig.test.src.json +++ b/test/tsconfig/tsconfig.test.src.json @@ -5,7 +5,7 @@ "../../src/**/*.test.ts", "../../src/**/*.test.tsx", "../../ui/**/*.d.ts", - "../../ui/src/ui/app.ts", + "../../ui/src/main.ts", "../../extensions/**/*.d.ts", "../../packages/**/*.d.ts" ] diff --git a/test/ui.presenter-next-run.test.ts b/test/ui.presenter-next-run.test.ts index 69a172f76f9c..987dc2505b42 100644 --- a/test/ui.presenter-next-run.test.ts +++ b/test/ui.presenter-next-run.test.ts @@ -1,7 +1,7 @@ // UI presenter next-run tests cover presenter scheduling output. import { describe, expect, it } from "vitest"; import { t } from "../ui/src/i18n/index.ts"; -import { formatNextRun } from "../ui/src/ui/presenter.ts"; +import { formatNextRun } from "../ui/src/lib/presenter.ts"; describe("formatNextRun", () => { it("returns localized n/a for nullish values", () => { diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index d43e389a3298..fc04fccd09c2 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -27,11 +27,9 @@ import { sharedVitestConfig, } from "./vitest/vitest.shared.config.ts"; import { fullSuiteVitestShards } from "./vitest/vitest.test-shards.mjs"; -import { unitUiIncludePatterns } from "./vitest/vitest.ui-paths.mjs"; import { createUiVitestConfig } from "./vitest/vitest.ui.config.ts"; import { createUnitFastFakeTimersVitestConfig } from "./vitest/vitest.unit-fast-fake-timers.config.ts"; import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts"; -import unitUiConfig from "./vitest/vitest.unit-ui.config.ts"; import { createUnitVitestConfig } from "./vitest/vitest.unit.config.ts"; const patternFiles = createPatternFileHelper("openclaw-vitest-projects-config-"); @@ -217,17 +215,6 @@ describe("projects vitest config", () => { expect(requireWebOptimizer(testConfig).enabled).toBe(true); }); - it("keeps the unit-ui shard aligned with the shared jsdom setup", () => { - const testConfig = requireTestConfig(unitUiConfig); - expect(testConfig.environment).toBe("jsdom"); - expect(testConfig.isolate).toBe(false); - expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts"); - expect(unitUiIncludePatterns).toContain("ui/src/ui/views/dreaming.test.ts"); - const setupFiles = normalizeConfigPaths(testConfig.setupFiles); - expect(setupFiles).not.toContain("test/setup-openclaw-runtime.ts"); - expect(setupFiles).toContain("ui/src/test-helpers/lit-warnings.setup.ts"); - }); - it("keeps the unit lane on the non-isolated runner by default", () => { const config = createUnitVitestConfig(); expect(config.test.isolate).toBe(false); diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 31c8b2023e5b..4aa6bd1385f0 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -1039,7 +1039,7 @@ describe("scoped vitest configs", () => { const testConfig = requireTestConfig(defaultUiConfig); expect(testConfig.dir).toBe(process.cwd()); expect(testConfig.include).toEqual(["ui/src/**/*.test.ts"]); - expect(testConfig.exclude).toContain("ui/src/ui/app-chat.test.ts"); + expect(testConfig.exclude).toContain("ui/src/**/*.e2e.test.ts"); }); it("normalizes utils include patterns relative to the scoped dir", () => { diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 6a79d163ae92..9b12e7570e49 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -132,7 +132,6 @@ describe("unit-fast vitest lane", () => { expect(testConfig.include).toContain("src/security/audit-gateway-tools-http.test.ts"); expect(testConfig.include).toContain("src/security/audit-plugin-readonly-scope.test.ts"); expect(testConfig.include).toContain("src/security/audit-loopback-logging.test.ts"); - expect(testConfig.include).toContain("src/ui-app-settings.agents-files-refresh.test.ts"); expect(testConfig.include).toContain("src/video-generation/provider-registry.test.ts"); expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts"); expect(testConfig.include).toContain("src/security/dangerous-config-flags.test.ts"); diff --git a/test/vitest/vitest.config.ts b/test/vitest/vitest.config.ts index e1849cbf6a85..f77e7e468bc5 100644 --- a/test/vitest/vitest.config.ts +++ b/test/vitest/vitest.config.ts @@ -12,7 +12,6 @@ export { resolveDefaultVitestPool, resolveLocalVitestMaxWorkers, resolveLocalVit export const rootVitestProjects = [ "test/vitest/vitest.unit.config.ts", - "test/vitest/vitest.unit-ui.config.ts", "test/vitest/vitest.infra.config.ts", "test/vitest/vitest.boundary.config.ts", "test/vitest/vitest.contracts-channel-surface.config.ts", diff --git a/test/vitest/vitest.full-core-unit-ui.config.ts b/test/vitest/vitest.full-core-unit-ui.config.ts deleted file mode 100644 index e4f37a3babaf..000000000000 --- a/test/vitest/vitest.full-core-unit-ui.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Vitest full core unit ui config wires the full core unit ui test shard. -import { createProjectShardVitestConfig } from "./vitest.project-shard-config.ts"; -import { fullSuiteVitestShards } from "./vitest.test-shards.mjs"; - -export default createProjectShardVitestConfig( - fullSuiteVitestShards.find( - (shard) => shard.config === "test/vitest/vitest.full-core-unit-ui.config.ts", - )?.projects ?? [], -); diff --git a/test/vitest/vitest.scoped-config.ts b/test/vitest/vitest.scoped-config.ts index c941ea152b12..413f00c56ee0 100644 --- a/test/vitest/vitest.scoped-config.ts +++ b/test/vitest/vitest.scoped-config.ts @@ -160,7 +160,6 @@ const SCOPED_PROJECT_GROUP_ORDER_BY_NAME = new Map( "unit-security", "unit-src", "unit-support", - "unit-ui", "utils", "wizard", ].map((name, index) => [name, index + 10]), diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 29c302af5a47..e79fca609868 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -571,23 +571,7 @@ export const sharedVitestConfig = { BUNDLED_PLUGIN_TEST_GLOB, "packages/**/*.test.ts", "test/**/*.test.ts", - "ui/src/ui/app-chat.test.ts", - "ui/src/ui/chat/**/*.test.ts", - "ui/src/ui/views/agents-utils.test.ts", - "ui/src/ui/views/channels.test.ts", - "ui/src/ui/views/chat.test.ts", - "ui/src/ui/views/nodes.devices.test.ts", - "ui/src/ui/views/skills.test.ts", - "ui/src/ui/views/dreaming.test.ts", - "ui/src/ui/views/usage-render-details.test.ts", - "ui/src/ui/controllers/agents.test.ts", - "ui/src/ui/controllers/chat.test.ts", - "ui/src/ui/controllers/skills.test.ts", - "ui/src/ui/controllers/sessions.test.ts", - "ui/src/ui/views/sessions.test.ts", - "ui/src/ui/app-tool-stream.node.test.ts", - "ui/src/ui/app-gateway.sessions.node.test.ts", - "ui/src/ui/chat/slash-command-executor.node.test.ts", + "ui/src/pages/chat/tool-stream.node.test.ts", ], setupFiles: [resolveRepoRootPath("test/setup.ts")], exclude: [ diff --git a/test/vitest/vitest.test-shards.mjs b/test/vitest/vitest.test-shards.mjs index cd09a0e4f6b0..0cdfc6bb73fd 100644 --- a/test/vitest/vitest.test-shards.mjs +++ b/test/vitest/vitest.test-shards.mjs @@ -29,11 +29,6 @@ export const fullSuiteVitestShards = [ name: "core-unit-security", projects: ["test/vitest/vitest.unit-security.config.ts"], }, - { - config: "test/vitest/vitest.full-core-unit-ui.config.ts", - name: "core-unit-ui", - projects: ["test/vitest/vitest.unit-ui.config.ts"], - }, { config: "test/vitest/vitest.full-core-unit-support.config.ts", name: "core-unit-support", diff --git a/test/vitest/vitest.ui-paths.mjs b/test/vitest/vitest.ui-paths.mjs index 9e9f25309226..3978935d1f98 100644 --- a/test/vitest/vitest.ui-paths.mjs +++ b/test/vitest/vitest.ui-paths.mjs @@ -1,33 +1,4 @@ -// Test routing globs and predicates for UI unit tests. -export const unitUiIncludePatterns = [ - "ui/src/ui/app-chat.test.ts", - "ui/src/ui/chat/**/*.test.ts", - "ui/src/ui/views/agents-utils.test.ts", - "ui/src/ui/views/channels.test.ts", - "ui/src/ui/views/chat.test.ts", - "ui/src/ui/views/dreaming.test.ts", - "ui/src/ui/views/usage-render-details.test.ts", - "ui/src/ui/controllers/agents.test.ts", - "ui/src/ui/controllers/chat.test.ts", -]; - -export function isUnitUiTestTarget(relative) { - if (!relative.endsWith(".test.ts")) { - return false; - } - return ( - relative === "ui/src/ui/app-chat.test.ts" || - relative.startsWith("ui/src/ui/chat/") || - relative === "ui/src/ui/views/agents-utils.test.ts" || - relative === "ui/src/ui/views/channels.test.ts" || - relative === "ui/src/ui/views/chat.test.ts" || - relative === "ui/src/ui/views/dreaming.test.ts" || - relative === "ui/src/ui/views/usage-render-details.test.ts" || - relative === "ui/src/ui/controllers/agents.test.ts" || - relative === "ui/src/ui/controllers/chat.test.ts" - ); -} - +// Test routing predicate for Control UI tests. export function isUiTestTarget(relative) { return ( relative.startsWith("ui/src/") && diff --git a/test/vitest/vitest.ui.config.ts b/test/vitest/vitest.ui.config.ts index 1a11be7e58d8..dd53d94ee5ac 100644 --- a/test/vitest/vitest.ui.config.ts +++ b/test/vitest/vitest.ui.config.ts @@ -1,16 +1,13 @@ // Vitest ui config wires the ui test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; import { jsdomOptimizedDeps } from "./vitest.shared.config.ts"; -import { unitUiIncludePatterns } from "./vitest.ui-paths.mjs"; export function createUiVitestConfig( env?: Record, options?: { includePatterns?: string[]; name?: string }, ) { const includePatterns = options?.includePatterns ?? ["ui/src/**/*.test.ts"]; - const exclude = options?.includePatterns - ? [] - : [...unitUiIncludePatterns, "ui/src/**/*.e2e.test.ts"]; + const exclude = options?.includePatterns ? [] : ["ui/src/**/*.e2e.test.ts"]; return createScopedVitestConfig(includePatterns, { deps: jsdomOptimizedDeps, environment: "jsdom", diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 1160c0349b10..8bcb318de8f6 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -200,7 +200,6 @@ export const forcedUnitFastTestFiles = [ "src/tts/provider-registry.test.ts", "src/tts/status-config.test.ts", "src/tts/tts-config.test.ts", - "src/ui-app-settings.agents-files-refresh.test.ts", "packages/terminal-core/src/restore.test.ts", "packages/terminal-core/src/table.test.ts", "src/test-helpers/state-dir-env.test.ts", diff --git a/test/vitest/vitest.unit-ui.config.ts b/test/vitest/vitest.unit-ui.config.ts deleted file mode 100644 index 253193fae277..000000000000 --- a/test/vitest/vitest.unit-ui.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Vitest unit ui config wires the unit ui test shard. -import { unitUiIncludePatterns } from "./vitest.ui-paths.mjs"; -import { createUiVitestConfig } from "./vitest.ui.config.ts"; - -export default createUiVitestConfig(process.env, { - includePatterns: unitUiIncludePatterns, - name: "unit-ui", -}); diff --git a/ui/package.json b/ui/package.json index 6d7faa7d95ad..131486135b26 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,7 @@ "@noble/ed25519": "3.1.0", "@openclaw/media-core": "workspace:*", "@openclaw/normalization-core": "workspace:*", + "@openclaw/uirouter": "0.1.0", "dompurify": "3.4.11", "ghostty-web": "0.4.0", "highlight.js": "11.11.1", diff --git a/ui/src/ui/app-events.ts b/ui/src/api/event-log.ts similarity index 51% rename from ui/src/ui/app-events.ts rename to ui/src/api/event-log.ts index 807ff707c78e..7dd2c9b3c8a0 100644 --- a/ui/src/ui/app-events.ts +++ b/ui/src/api/event-log.ts @@ -1,4 +1,4 @@ -// Control UI module implements app events behavior. +// Shared event-log contract used by application instrumentation and page views. export type EventLogEntry = { ts: number; event: string; diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts similarity index 99% rename from ui/src/ui/gateway.node.test.ts rename to ui/src/api/gateway.node.test.ts index 8f3c4e9eb4b9..8ee4f3ea808c 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -4,9 +4,9 @@ import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, } from "../../../packages/gateway-protocol/src/version.js"; +import type { DeviceIdentity } from "../lib/nodes/index.ts"; +import { loadDeviceAuthToken, storeDeviceAuthToken } from "../lib/nodes/index.ts"; import { createStorageMock } from "../test-helpers/storage.ts"; -import { loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts"; -import type { DeviceIdentity } from "./device-identity.ts"; const wsInstances = vi.hoisted((): MockWebSocket[] => []); const loadOrCreateDeviceIdentityMock = vi.hoisted(() => @@ -93,7 +93,8 @@ class MockWebSocket { } } -vi.mock("./device-identity.ts", () => ({ +vi.mock("../lib/nodes/index.ts", async (importOriginal) => ({ + ...(await importOriginal()), loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock, signDevicePayload: signDevicePayloadMock, })); diff --git a/ui/src/ui/gateway.ts b/ui/src/api/gateway.ts similarity index 99% rename from ui/src/ui/gateway.ts rename to ui/src/api/gateway.ts index eff05a39ee3c..58558ee32ea8 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/api/gateway.ts @@ -21,9 +21,14 @@ import { PROTOCOL_VERSION, } from "../../../packages/gateway-protocol/src/version.js"; import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js"; -import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts"; -import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts"; -import { generateUUID } from "./uuid.ts"; +import { + clearDeviceAuthToken, + loadDeviceAuthToken, + storeDeviceAuthToken, + loadOrCreateDeviceIdentity, + signDevicePayload, +} from "../lib/nodes/index.ts"; +import { generateUUID } from "../lib/uuid.ts"; export type GatewayEventFrame = { type: "event"; diff --git a/ui/src/ui/types.ts b/ui/src/api/types.ts similarity index 97% rename from ui/src/ui/types.ts rename to ui/src/api/types.ts index dfe85bf5c87c..cf82b07c9ea7 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/api/types.ts @@ -322,8 +322,6 @@ export type GatewayThinkingLevelOption = { label: string; }; -export type ChatModelOverride = import("./chat-model-ref.types.ts").ChatModelOverride; - export type GatewayAgentRow = SharedGatewayAgentRow; export type AgentsListResult = { @@ -509,7 +507,6 @@ export type GatewaySessionRow = { childSessions?: string[]; model?: string; modelProvider?: string; - /** Resolved effective usage-footer mode (session override → per-channel config → default → off), carried from gateway session rows/events. */ effectiveResponseUsage?: "on" | "off" | "tokens" | "full"; agentRuntime?: GatewayAgentRuntime; contextTokens?: number; @@ -579,7 +576,7 @@ export type { SessionsUsageTotals, SessionUsageTimePoint, SessionUsageTimeSeries, -} from "./usage-types.ts"; +} from "../pages/usage/data-types.ts"; export type CronRunStatus = "ok" | "error" | "skipped"; export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested"; @@ -877,17 +874,6 @@ export type ModelAuthStatusProvider = export type ModelAuthStatusResult = import("../../../src/gateway/server-methods/models-auth-status.js").ModelAuthStatusResult; -export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; - -export type LogEntry = { - raw: string; - time?: string | null; - level?: LogLevel | null; - subsystem?: string | null; - message?: string | null; - meta?: Record | null; -}; - // ── Attention ─────────────────────────────────────── export type AttentionSeverity = "error" | "warning" | "info"; diff --git a/ui/src/app-navigation-groups.test.ts b/ui/src/app-navigation-groups.test.ts new file mode 100644 index 000000000000..2ed1f274e23a --- /dev/null +++ b/ui/src/app-navigation-groups.test.ts @@ -0,0 +1,55 @@ +// Control UI tests cover navigation groups behavior. +import { describe, expect, it } from "vitest"; +import { + SETTINGS_NAVIGATION_ROUTES, + SIDEBAR_SECTIONS, + isSettingsNavigationRoute, + isRouteInSidebarSection, +} from "./app-navigation.ts"; +import { routeIdFromPath } from "./app-routes.ts"; + +describe("SIDEBAR_SECTIONS", () => { + it("collapses detailed settings slices into one sidebar entry", () => { + const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings"); + expect(settings?.routes).toEqual(["config"]); + expect(SETTINGS_NAVIGATION_ROUTES.every((routeId) => isSettingsNavigationRoute(routeId))).toBe( + true, + ); + }); + + it("keeps channel management out of the primary control sidebar", () => { + const control = SIDEBAR_SECTIONS.find((group) => group.label === "control"); + expect(control?.routes).toEqual([ + "overview", + "activity", + "workboard", + "instances", + "sessions", + "usage", + "cron", + ]); + expect(SETTINGS_NAVIGATION_ROUTES).toContain("channels"); + }); + + it("keeps the settings group active for nested settings routes", () => { + const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings"); + if (!settings) { + throw new Error("Expected settings group"); + } + + expect(isRouteInSidebarSection(settings, "appearance")).toBe(true); + expect(isRouteInSidebarSection(settings, "channels")).toBe(true); + expect(isRouteInSidebarSection(settings, "debug")).toBe(true); + expect(isRouteInSidebarSection(settings, "chat")).toBe(false); + }); + + it("routes every published settings slice", () => { + expect(routeIdFromPath("/communications")).toBe("communications"); + expect(routeIdFromPath("/appearance")).toBe("appearance"); + expect(routeIdFromPath("/automation")).toBe("automation"); + expect(routeIdFromPath("/infrastructure")).toBe("infrastructure"); + expect(routeIdFromPath("/ai-agents")).toBe("ai-agents"); + expect(routeIdFromPath("/config")).toBe("config"); + expect(routeIdFromPath("/channels")).toBe("channels"); + }); +}); diff --git a/ui/src/ui/navigation.test.ts b/ui/src/app-navigation.test.ts similarity index 58% rename from ui/src/ui/navigation.test.ts rename to ui/src/app-navigation.test.ts index 7924dc3e7b7d..aad13f0ae054 100644 --- a/ui/src/ui/navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -1,23 +1,27 @@ // Control UI tests cover navigation behavior. import { describe, expect, it } from "vitest"; import { - TAB_GROUPS, - SETTINGS_TABS, - iconForTab, + SETTINGS_NAVIGATION_ROUTES, + SIDEBAR_SECTIONS, + navigationIconForRoute, + subtitleForRoute, + titleForRoute, +} from "./app-navigation.ts"; +import { inferBasePathFromPathname, - isSettingsTab, normalizeBasePath, normalizePath, - pathForTab, - subtitleForTab, - tabFromPath, - titleForTab, - type Tab, -} from "./navigation.ts"; + pathForRoute, + routeIdFromPath, + type RouteId, +} from "./app-routes.ts"; -/** All valid tab identifiers derived from visible groups plus routed settings slices. */ -const ALL_TABS: Tab[] = Array.from( - new Set([...(TAB_GROUPS.flatMap((group) => group.tabs) as Tab[]), ...SETTINGS_TABS]), +/** All route identifiers derived from visible groups plus routed settings slices. */ +const ALL_ROUTES: RouteId[] = Array.from( + new Set([ + ...(SIDEBAR_SECTIONS.flatMap((group) => group.routes) as RouteId[]), + ...SETTINGS_NAVIGATION_ROUTES, + ]), ); const leadingSlashNormalizerCases = [ @@ -25,9 +29,11 @@ const leadingSlashNormalizerCases = [ { name: "normalizePath", normalize: normalizePath, input: "chat", expected: "/chat" }, ]; -describe("iconForTab", () => { - it("returns stable icons for every tab", () => { - expect(Object.fromEntries(ALL_TABS.map((tab) => [tab, iconForTab(tab)]))).toEqual({ +describe("navigationIconForRoute", () => { + it("returns stable icons for every route", () => { + expect( + Object.fromEntries(ALL_ROUTES.map((routeId) => [routeId, navigationIconForRoute(routeId)])), + ).toEqual({ chat: "messageSquare", overview: "barChart", activity: "activity", @@ -39,7 +45,7 @@ describe("iconForTab", () => { cron: "loader", agents: "folder", skills: "zap", - skillWorkshop: "wrench", + "skill-workshop": "wrench", nodes: "monitor", dreams: "moon", config: "settings", @@ -48,22 +54,24 @@ describe("iconForTab", () => { automation: "terminal", mcp: "wrench", infrastructure: "globe", - aiAgents: "brain", + "ai-agents": "brain", debug: "bug", logs: "scrollText", }); }); - it("returns a fallback icon for unknown tab", () => { + it("returns a fallback icon for unknown route", () => { // TypeScript won't allow this normally, but runtime could receive unexpected values - const unknownTab = "unknown" as Tab; - expect(iconForTab(unknownTab)).toBe("folder"); + const unknownRouteId = "unknown" as RouteId; + expect(navigationIconForRoute(unknownRouteId)).toBe("folder"); }); }); -describe("titleForTab", () => { - it("returns expected titles for every tab", () => { - expect(Object.fromEntries(ALL_TABS.map((tab) => [tab, titleForTab(tab)]))).toEqual({ +describe("titleForRoute", () => { + it("returns expected titles for every route", () => { + expect( + Object.fromEntries(ALL_ROUTES.map((routeId) => [routeId, titleForRoute(routeId)])), + ).toEqual({ chat: "Chat", overview: "Overview", activity: "Activity", @@ -75,7 +83,7 @@ describe("titleForTab", () => { cron: "Cron Jobs", agents: "Agents", skills: "Skills", - skillWorkshop: "Skill Workshop", + "skill-workshop": "Skill Workshop", nodes: "Nodes", dreams: "Dreaming", config: "Settings", @@ -84,16 +92,18 @@ describe("titleForTab", () => { automation: "Automation", mcp: "MCP", infrastructure: "Infrastructure", - aiAgents: "AI & Agents", + "ai-agents": "AI & Agents", debug: "Debug", logs: "Logs", }); }); }); -describe("subtitleForTab", () => { - it("returns expected subtitles for every tab", () => { - expect(Object.fromEntries(ALL_TABS.map((tab) => [tab, subtitleForTab(tab)]))).toEqual({ +describe("subtitleForRoute", () => { + it("returns expected subtitles for every route", () => { + expect( + Object.fromEntries(ALL_ROUTES.map((routeId) => [routeId, subtitleForRoute(routeId)])), + ).toEqual({ chat: "Gateway chat for quick interventions.", overview: "Status, entry points, health.", activity: "Browser-local tool activity summaries.", @@ -105,7 +115,7 @@ describe("subtitleForTab", () => { cron: "Wakeups and recurring runs.", agents: "Workspaces, tools, identities.", skills: "Skills and API keys.", - skillWorkshop: "Review, refine, and apply proposals before they become live skills.", + "skill-workshop": "Review, refine, and apply proposals before they become live skills.", nodes: "Paired devices and commands.", dreams: "Memory dreaming, consolidation, and reflection.", config: "Edit openclaw.json.", @@ -114,7 +124,7 @@ describe("subtitleForTab", () => { automation: "Commands, hooks, cron, and plugins.", mcp: "MCP servers, auth, tools, and diagnostics.", infrastructure: "Gateway, web, browser, and media settings.", - aiAgents: "Agents, models, skills, tools, memory, session.", + "ai-agents": "Agents, models, skills, tools, memory, session.", debug: "Snapshots, events, RPC.", logs: "Live gateway logs.", }); @@ -159,44 +169,44 @@ describe("normalizePath", () => { }); }); -describe("pathForTab", () => { +describe("pathForRoute", () => { it("returns correct path without base", () => { - expect(pathForTab("chat")).toBe("/chat"); - expect(pathForTab("overview")).toBe("/overview"); + expect(pathForRoute("chat")).toBe("/chat"); + expect(pathForRoute("overview")).toBe("/overview"); }); it("prepends base path", () => { - expect(pathForTab("chat", "/ui")).toBe("/ui/chat"); - expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions"); + expect(pathForRoute("chat", "/ui")).toBe("/ui/chat"); + expect(pathForRoute("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions"); }); }); -describe("tabFromPath", () => { +describe("routeIdFromPath", () => { it("returns tab for valid path", () => { - expect(tabFromPath("/chat")).toBe("chat"); - expect(tabFromPath("/overview")).toBe("overview"); - expect(tabFromPath("/activity")).toBe("activity"); - expect(tabFromPath("/sessions")).toBe("sessions"); - expect(tabFromPath("/dreaming")).toBe("dreams"); - expect(tabFromPath("/dreams")).toBe("dreams"); + expect(routeIdFromPath("/chat")).toBe("chat"); + expect(routeIdFromPath("/overview")).toBe("overview"); + expect(routeIdFromPath("/activity")).toBe("activity"); + expect(routeIdFromPath("/sessions")).toBe("sessions"); + expect(routeIdFromPath("/dreaming")).toBe("dreams"); + expect(routeIdFromPath("/dreams")).toBe("dreams"); }); - it("returns chat for root path", () => { - expect(tabFromPath("/")).toBe("chat"); + it("leaves root fallback to application startup", () => { + expect(routeIdFromPath("/")).toBeNull(); }); it("handles base paths", () => { - expect(tabFromPath("/ui/chat", "/ui")).toBe("chat"); - expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions"); + expect(routeIdFromPath("/ui/chat", "/ui")).toBe("chat"); + expect(routeIdFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions"); }); it("returns null for unknown path", () => { - expect(tabFromPath("/unknown")).toBeNull(); + expect(routeIdFromPath("/unknown")).toBeNull(); }); - it("is case-insensitive", () => { - expect(tabFromPath("/CHAT")).toBe("chat"); - expect(tabFromPath("/Overview")).toBe("overview"); + it("matches canonical route casing exactly", () => { + expect(routeIdFromPath("/CHAT")).toBeNull(); + expect(routeIdFromPath("/Overview")).toBeNull(); }); }); @@ -217,27 +227,33 @@ describe("inferBasePathFromPathname", () => { expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw"); }); + it("preserves mount roots without a route suffix", () => { + expect(inferBasePathFromPathname("/__openclaw__/")).toBe("/__openclaw__"); + expect(inferBasePathFromPathname("/apps/openclaw/")).toBe("/apps/openclaw"); + expect(inferBasePathFromPathname("/typo")).toBe(""); + }); + it("handles index.html suffix", () => { expect(inferBasePathFromPathname("/index.html")).toBe(""); expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui"); }); }); -describe("TAB_GROUPS", () => { +describe("SIDEBAR_SECTIONS", () => { it("contains all expected groups", () => { - expect(TAB_GROUPS.map((g) => g.label)).toEqual(["chat", "control", "agent", "settings"]); + expect(SIDEBAR_SECTIONS.map((g) => g.label)).toEqual(["chat", "control", "agent", "settings"]); }); - it("all tabs are unique", () => { - const allTabs = TAB_GROUPS.flatMap((g) => g.tabs); - const uniqueTabs = new Set(allTabs); - expect(uniqueTabs.size).toBe(allTabs.length); + it("all routes are unique", () => { + const allRoutes = SIDEBAR_SECTIONS.flatMap((g) => g.routes); + const uniqueRoutes = new Set(allRoutes); + expect(uniqueRoutes.size).toBe(allRoutes.length); }); it("keeps detailed settings slices routed but out of the root sidebar", () => { - const settings = TAB_GROUPS.find((group) => group.label === "settings"); - expect(settings?.tabs).toEqual(["config"]); - expect(SETTINGS_TABS).toEqual([ + const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings"); + expect(settings?.routes).toEqual(["config"]); + expect(SETTINGS_NAVIGATION_ROUTES).toEqual([ "config", "channels", "communications", @@ -245,10 +261,9 @@ describe("TAB_GROUPS", () => { "automation", "mcp", "infrastructure", - "aiAgents", + "ai-agents", "debug", "logs", ]); - expect(SETTINGS_TABS.every((tab) => isSettingsTab(tab))).toBe(true); }); }); diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts new file mode 100644 index 000000000000..aa7096f004b9 --- /dev/null +++ b/ui/src/app-navigation.ts @@ -0,0 +1,170 @@ +// Control UI app navigation defines sidebar and settings presentation metadata. +import type { RouteId } from "./app-route-paths.ts"; +import type { IconName } from "./components/icons.ts"; +import { t } from "./i18n/index.ts"; + +export type NavigationRouteId = RouteId; + +type SidebarSection = { + label: string; + routes: readonly NavigationRouteId[]; +}; + +type NavigationItem = { + [TRouteId in NavigationRouteId]: IconName; +}; + +export const SIDEBAR_SECTIONS = [ + { label: "chat", routes: ["chat"] }, + { + label: "control", + routes: ["overview", "activity", "workboard", "instances", "sessions", "usage", "cron"], + }, + { label: "agent", routes: ["agents", "skills", "skill-workshop", "nodes", "dreams"] }, + { label: "settings", routes: ["config"] }, +] as const satisfies readonly SidebarSection[]; + +export const SETTINGS_NAVIGATION_ROUTES = [ + "config", + "channels", + "communications", + "appearance", + "automation", + "mcp", + "infrastructure", + "ai-agents", + "debug", + "logs", +] as const satisfies readonly NavigationRouteId[]; + +const NAVIGATION_ICONS: NavigationItem = { + agents: "folder", + activity: "activity", + overview: "barChart", + workboard: "folder", + channels: "link", + instances: "radio", + sessions: "fileText", + usage: "barChart", + cron: "loader", + skills: "zap", + "skill-workshop": "wrench", + nodes: "monitor", + chat: "messageSquare", + config: "settings", + communications: "send", + appearance: "spark", + automation: "terminal", + mcp: "wrench", + infrastructure: "globe", + "ai-agents": "brain", + debug: "bug", + logs: "scrollText", + dreams: "moon", +}; + +export function isSettingsNavigationRoute(routeId: NavigationRouteId): boolean { + return (SETTINGS_NAVIGATION_ROUTES as readonly NavigationRouteId[]).includes(routeId); +} + +export function isRouteInSidebarSection( + section: SidebarSection, + routeId: NavigationRouteId, +): boolean { + if (section.label === "settings") { + return isSettingsNavigationRoute(routeId); + } + return section.routes.includes(routeId); +} + +export function navigationIconForRoute(routeId: NavigationRouteId): IconName { + return NAVIGATION_ICONS[routeId] ?? "folder"; +} + +export function scheduleRoutePreload( + timers: Map>, + routeId: TRouteId, + event: Event, + preload: ((routeId: TRouteId) => Promise | void) | undefined, + disabled = false, + immediate = false, +) { + if (disabled || !preload) { + return; + } + const target = event.currentTarget; + if (!target) { + return; + } + const start = () => { + timers.delete(target); + try { + void Promise.resolve(preload(routeId)).catch(() => undefined); + } catch { + // Preloading is opportunistic; navigation still handles real route errors. + } + }; + if (immediate) { + cancelRoutePreload(timers, event); + start(); + return; + } + if (!timers.has(target)) { + timers.set(target, globalThis.setTimeout(start, 50)); + } +} + +export function cancelRoutePreload( + timers: Map>, + event: Event, +) { + const target = event.currentTarget; + if (!target) { + return; + } + const timer = timers.get(target); + if (timer !== undefined) { + globalThis.clearTimeout(timer); + timers.delete(target); + } +} + +const NAVIGATION_COPY: Record = { + agents: { titleKey: "tabs.agents", subtitleKey: "subtitles.agents" }, + activity: { titleKey: "tabs.activity", subtitleKey: "subtitles.activity" }, + overview: { titleKey: "tabs.overview", subtitleKey: "subtitles.overview" }, + workboard: { titleKey: "tabs.workboard", subtitleKey: "subtitles.workboard" }, + channels: { titleKey: "tabs.channels", subtitleKey: "subtitles.channels" }, + instances: { titleKey: "tabs.instances", subtitleKey: "subtitles.instances" }, + sessions: { titleKey: "tabs.sessions", subtitleKey: "subtitles.sessions" }, + usage: { titleKey: "tabs.usage", subtitleKey: "subtitles.usage" }, + cron: { titleKey: "tabs.cron", subtitleKey: "subtitles.cron" }, + skills: { titleKey: "tabs.skills", subtitleKey: "subtitles.skills" }, + "skill-workshop": { + titleKey: "tabs.skillWorkshop", + subtitleKey: "subtitles.skillWorkshop", + }, + nodes: { titleKey: "tabs.nodes", subtitleKey: "subtitles.nodes" }, + chat: { titleKey: "tabs.chat", subtitleKey: "subtitles.chat" }, + config: { titleKey: "nav.settings", subtitleKey: "subtitles.config" }, + communications: { + titleKey: "tabs.communications", + subtitleKey: "subtitles.communications", + }, + appearance: { titleKey: "tabs.appearance", subtitleKey: "subtitles.appearance" }, + automation: { titleKey: "tabs.automation", subtitleKey: "subtitles.automation" }, + mcp: { titleKey: "tabs.mcp", subtitleKey: "subtitles.mcp" }, + infrastructure: { titleKey: "tabs.infrastructure", subtitleKey: "subtitles.infrastructure" }, + "ai-agents": { titleKey: "tabs.aiAgents", subtitleKey: "subtitles.aiAgents" }, + debug: { titleKey: "tabs.debug", subtitleKey: "subtitles.debug" }, + logs: { titleKey: "tabs.logs", subtitleKey: "subtitles.logs" }, + dreams: { titleKey: "tabs.dreams", subtitleKey: "subtitles.dreams" }, +}; + +export function titleForRoute(routeId: NavigationRouteId): string { + return t(NAVIGATION_COPY[routeId].titleKey); +} + +export function subtitleForRoute(routeId: NavigationRouteId): string { + return t(NAVIGATION_COPY[routeId].subtitleKey); +} diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts new file mode 100644 index 000000000000..d92b76ccfdf0 --- /dev/null +++ b/ui/src/app-route-paths.ts @@ -0,0 +1,108 @@ +import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter"; +import type { RouteLocation } from "@openclaw/uirouter"; + +export const APP_ROUTE_DEFINITIONS = { + chat: { path: "/chat" }, + overview: { path: "/overview" }, + activity: { path: "/activity" }, + agents: { path: "/agents" }, + channels: { path: "/channels" }, + config: { path: "/config" }, + communications: { path: "/communications" }, + appearance: { path: "/appearance" }, + automation: { path: "/automation" }, + mcp: { path: "/mcp" }, + infrastructure: { path: "/infrastructure" }, + "ai-agents": { path: "/ai-agents" }, + workboard: { path: "/workboard" }, + instances: { path: "/instances" }, + sessions: { path: "/sessions" }, + usage: { path: "/usage" }, + debug: { path: "/debug" }, + logs: { path: "/logs" }, + "skill-workshop": { path: "/skills/workshop" }, + skills: { path: "/skills" }, + cron: { path: "/cron" }, + nodes: { path: "/nodes" }, + dreams: { path: "/dreaming", aliases: ["/dreams"] }, +} as const; + +export type RouteId = keyof typeof APP_ROUTE_DEFINITIONS; +export const APP_ROUTE_IDS = Object.keys(APP_ROUTE_DEFINITIONS) as RouteId[]; + +export function isRouteId(routeId: string): routeId is RouteId { + return routeId in APP_ROUTE_DEFINITIONS; +} + +export function normalizeBasePath(basePath: string): string { + return normalizeRouteBasePath(basePath); +} + +export function normalizePath(path: string): string { + return normalizeRoutePath(path); +} + +export function pathForRoute(routeId: RouteId, basePath = ""): string { + const normalizedBasePath = normalizeBasePath(basePath); + const path = APP_ROUTE_DEFINITIONS[routeId].path; + return normalizedBasePath ? `${normalizedBasePath}${path}` : path; +} + +export function routeIdFromPath(pathname: string, basePath = ""): RouteId | null { + const normalizedPath = normalizePath(pathname); + const normalizedBasePath = normalizeBasePath(basePath); + const routePath = normalizedBasePath + ? normalizedPath.slice(normalizedBasePath.length) || "/" + : normalizedPath; + for (const routeId of APP_ROUTE_IDS) { + const definition = APP_ROUTE_DEFINITIONS[routeId]; + const paths: readonly string[] = + "aliases" in definition ? [definition.path, ...definition.aliases] : [definition.path]; + if (paths.some((candidate) => normalizePath(candidate) === routePath)) { + return routeId; + } + } + return null; +} + +export function inferBasePathFromPathname(pathname: string): string { + const isMountRoot = pathname.trim().endsWith("/"); + const normalizedPath = normalizePath(pathname); + if (normalizedPath.toLowerCase().endsWith("/index.html")) { + return normalizeBasePath(normalizedPath.slice(0, -"/index.html".length)); + } + if (normalizedPath === "/") { + return ""; + } + const segments = normalizedPath.split("/").filter(Boolean); + const routePaths = APP_ROUTE_IDS.flatMap((routeId) => { + const definition = APP_ROUTE_DEFINITIONS[routeId]; + const paths: string[] = [definition.path]; + if ("aliases" in definition) { + paths.push(...definition.aliases); + } + return paths; + }); + for (let index = 0; index < segments.length; index += 1) { + const candidate = `/${segments.slice(index).join("/")}`; + const routePath = routePaths.find((path) => normalizePath(path) === candidate); + if (!routePath) { + continue; + } + const previousSegment = segments[index - 1]; + const firstRouteSegment = routePath.split("/").find(Boolean); + if (index > 0 && previousSegment === firstRouteSegment && candidate === routePath) { + return ""; + } + return index ? `/${segments.slice(0, index).join("/")}` : ""; + } + return isMountRoot && segments.length ? `/${segments.join("/")}` : ""; +} + +export function locationForRoute(routeId: RouteId, basePath: string): RouteLocation { + return { + pathname: pathForRoute(routeId, basePath), + search: "", + hash: "", + }; +} diff --git a/ui/src/app-routes.ts b/ui/src/app-routes.ts new file mode 100644 index 000000000000..bb5ff37f5346 --- /dev/null +++ b/ui/src/app-routes.ts @@ -0,0 +1,99 @@ +import { createRouter } from "@openclaw/uirouter"; +import type { PageDefinition, Router, RouterHistory } from "@openclaw/uirouter"; +import { routeIdFromPath, type RouteId } from "./app-route-paths.ts"; +import type { ApplicationContext } from "./app/context.ts"; +import { page as activityPage } from "./pages/activity/route.ts"; +import { page as agentsPage } from "./pages/agents/route.ts"; +import { page as channelsPage } from "./pages/channels/route.ts"; +import { page as chatPage } from "./pages/chat/route.ts"; +import { pages as configPages } from "./pages/config/route.ts"; +import { page as cronPage } from "./pages/cron/route.ts"; +import { page as debugPage } from "./pages/debug/route.ts"; +import { page as dreamsPage } from "./pages/dreams/route.ts"; +import { page as instancesPage } from "./pages/instances/route.ts"; +import { page as logsPage } from "./pages/logs/route.ts"; +import { page as nodesPage } from "./pages/nodes/route.ts"; +import { page as overviewPage } from "./pages/overview/route.ts"; +import { page as sessionsPage } from "./pages/sessions/route.ts"; +import { page as skillWorkshopPage } from "./pages/skill-workshop/route.ts"; +import { page as skillsPage } from "./pages/skills/route.ts"; +import { page as usagePage } from "./pages/usage/route.ts"; +import { page as workboardPage } from "./pages/workboard/route.ts"; + +export type AppRouteModule = { + render: (data: unknown) => unknown; +}; + +export type ApplicationRouter = Router< + RouteId, + ApplicationContext, + AppRouteModule, + unknown +>; +export type AppRoute = PageDefinition, AppRouteModule>; + +export const APP_ROUTE_TREE = [ + chatPage, + overviewPage, + activityPage, + agentsPage, + channelsPage, + ...configPages, + workboardPage, + instancesPage, + sessionsPage, + usagePage, + debugPage, + logsPage, + skillWorkshopPage, + skillsPage, + cronPage, + nodesPage, + dreamsPage, +] as const; + +const appRoutes = APP_ROUTE_TREE as readonly AppRoute[]; + +export function createApplicationRouter(): ApplicationRouter { + return createRouter, AppRouteModule>({ + routes: appRoutes, + }); +} + +export async function startApplicationRouter( + router: ApplicationRouter, + history: RouterHistory, + basePath: string, + context: ApplicationContext, +): Promise { + const location = history.location(); + if (routeIdFromPath(location.pathname, basePath) === null) { + history.replace({ + ...location, + pathname: router.pathForRoute("chat", basePath), + }); + } + await router.start(history, basePath, context); +} + +export function startAppRouter( + router: ApplicationRouter, + history: RouterHistory, + basePath: string, + context: ApplicationContext, +): Promise { + return startApplicationRouter(router, history, basePath, context); +} + +export { + APP_ROUTE_DEFINITIONS, + APP_ROUTE_IDS, + inferBasePathFromPathname, + isRouteId, + locationForRoute, + normalizeBasePath, + normalizePath, + pathForRoute, + routeIdFromPath, + type RouteId, +} from "./app-route-paths.ts"; diff --git a/ui/src/app/agent-selection.ts b/ui/src/app/agent-selection.ts new file mode 100644 index 000000000000..c11ae7551086 --- /dev/null +++ b/ui/src/app/agent-selection.ts @@ -0,0 +1,62 @@ +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { normalizeAgentId } from "../lib/sessions/session-key.ts"; + +type AgentSelectionGateway = { + readonly snapshot: { + client: GatewayBrowserClient | null; + assistantAgentId: string | null; + }; + subscribe: (listener: (snapshot: AgentSelectionGateway["snapshot"]) => void) => () => void; +}; + +export type AgentSelectionState = { + selectedId: string | null; +}; + +export type AgentSelectionCapability = { + readonly state: AgentSelectionState; + set: (agentId: string | null) => void; + subscribe: (listener: (state: AgentSelectionState) => void) => () => void; +}; + +export function createAgentSelectionCapability( + gateway: AgentSelectionGateway, +): AgentSelectionCapability { + let state: AgentSelectionState = { + selectedId: gateway.snapshot.assistantAgentId + ? normalizeAgentId(gateway.snapshot.assistantAgentId) + : null, + }; + let client = gateway.snapshot.client; + const listeners = new Set<(next: AgentSelectionState) => void>(); + + const publish = (selectedId: string | null) => { + if (state.selectedId === selectedId) { + return; + } + state = { selectedId }; + for (const listener of listeners) { + listener(state); + } + }; + + gateway.subscribe((next) => { + if (next.client !== client) { + client = next.client; + publish(next.assistantAgentId ? normalizeAgentId(next.assistantAgentId) : null); + } + }); + + return { + get state() { + return state; + }, + set(agentId) { + publish(agentId?.trim() ? normalizeAgentId(agentId) : null); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts new file mode 100644 index 000000000000..9f0eb29a0233 --- /dev/null +++ b/ui/src/app/app-host.ts @@ -0,0 +1,668 @@ +import { consume, ContextProvider } from "@lit/context"; +import type { RouteLocation, RouterState } from "@openclaw/uirouter"; +import { html, LitElement, nothing } from "lit"; +import { property, query, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { AgentsListResult } from "../api/types.ts"; +import "../components/app-sidebar.ts"; +import "../components/app-topbar.ts"; +import "../components/exec-approval.ts"; +import "../components/gateway-url-confirmation.ts"; +import "../components/login-gate.ts"; +import "../components/terminal/terminal-panel.ts"; +import "../components/tooltip.ts"; +import "../components/update-banner.ts"; +import { APP_ROUTE_IDS, isRouteId, pathForRoute, type RouteId } from "../app-routes.ts"; +import { + COMMAND_PALETTE_TARGET_EVENT, + type CommandPalette, + type CommandPaletteTargetDetail, +} from "../components/command-palette.ts"; +import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts"; +import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import { searchForSession } from "../lib/sessions/index.ts"; +import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts"; +import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationNavigationOptions, +} from "./context.ts"; +import { hasOperatorAdminAccess } from "./operator-access.ts"; +import type { ApplicationOverlaySnapshot } from "./overlays.ts"; +import { selectRenderedRouteMatch } from "./router-outlet.ts"; + +type ShellRouteState = { + routeId?: RouteId; + location?: RouteLocation; +}; + +function selectShellRouteState(routerState: RouterState): ShellRouteState { + const match = selectRenderedRouteMatch(routerState.matches[0], routerState.pendingMatches[0]); + return match + ? { + routeId: match.routeId, + location: match.location, + } + : {}; +} + +function equalShellRouteState(previous: ShellRouteState, next: ShellRouteState): boolean { + return ( + previous.routeId === next.routeId && + previous.location?.pathname === next.location?.pathname && + previous.location?.search === next.location?.search && + previous.location?.hash === next.location?.hash + ); +} + +function resolveAgentLabel(sessionKey: string, agentsList: AgentsListResult | null): string { + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const agent = agentsList?.agents.find( + (entry) => normalizeLowercaseStringOrEmpty(entry.id) === agentId, + ); + return ( + normalizeOptionalString(agent?.identity?.name) ?? + normalizeOptionalString(agent?.name) ?? + agentId + ); +} + +function resolveOnboardingMode(): boolean { + const raw = new URLSearchParams(globalThis.location?.search ?? "").get("onboarding"); + return raw !== null && /^(?:1|true|yes|on)$/iu.test(raw.trim()); +} + +function resolveTerminalThemeMode(): "dark" | "light" { + return document.documentElement.dataset.themeMode === "light" ? "light" : "dark"; +} + +function isTerminalAvailable( + snapshot: ApplicationContext["gateway"]["snapshot"], + terminalEnabled: boolean, +): boolean { + if (!snapshot.connected || !terminalEnabled) { + return false; + } + return ( + hasOperatorAdminAccess(snapshot.hello?.auth ?? null) && + isGatewayMethodAdvertised(snapshot, "terminal.open") === true + ); +} + +export class OpenClawApp extends LitElement { + @state() private gatewayConnected = false; + @state() private gatewayLastError: string | null = null; + @state() private gatewayLastErrorCode: string | null = null; + @state() private loginGatewayUrl = ""; + @state() private loginToken = ""; + @state() private loginPassword = ""; + @state() private loginShowGatewayToken = false; + @state() private loginShowGatewayPassword = false; + @state() private pendingGatewayUrl: string | null = null; + @state() private onboarding = resolveOnboardingMode(); + + private runtime: ApplicationRuntime | undefined; + private context: ApplicationContext | undefined; + private readonly contextProvider = new ContextProvider(this, { + context: applicationContext, + }); + private stopGatewaySubscription: (() => void) | undefined; + + override createRenderRoot() { + return this; + } + + override connectedCallback() { + super.connectedCallback(); + this.runtime = bootstrapApplication(); + this.context = this.runtime.context; + this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null; + this.contextProvider.setValue(this.context); + this.syncLoginConnection(); + let gatewayClient = this.context.gateway.snapshot.client; + this.updateGatewayStatus(this.context.gateway.snapshot); + this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => { + if (snapshot.client !== gatewayClient) { + gatewayClient = snapshot.client; + this.syncLoginConnection(); + } + this.updateGatewayStatus(snapshot); + }); + void this.runtime.start().catch((error: unknown) => { + console.error("[openclaw] application start failed", error); + }); + } + + override disconnectedCallback() { + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.runtime?.stop(); + this.runtime = undefined; + this.context = undefined; + this.pendingGatewayUrl = null; + super.disconnectedCallback(); + } + + private syncLoginConnection() { + const connection = this.context?.gateway.connection; + if (!connection) { + return; + } + this.loginGatewayUrl = connection.gatewayUrl; + this.loginToken = connection.token; + this.loginPassword = connection.password; + } + + private readonly updateGatewayStatus = (snapshot: { + connected: boolean; + lastError: string | null; + lastErrorCode: string | null; + }) => { + this.gatewayConnected = snapshot.connected; + this.gatewayLastError = snapshot.lastError; + this.gatewayLastErrorCode = snapshot.lastErrorCode; + }; + + override render() { + const context = this.context; + const runtime = this.runtime; + if (!context || !runtime) { + return html`
`; + } + const gatewayUrlConfirmation = this.pendingGatewayUrl + ? html` + { + runtime.confirmPendingGatewayConnection(); + this.pendingGatewayUrl = null; + }, + onCancel: () => { + runtime.cancelPendingGatewayConnection(); + this.pendingGatewayUrl = null; + }, + }} + > + ` + : nothing; + if (!this.gatewayConnected) { + return html` + + { + this.loginGatewayUrl = value; + }, + onTokenChange: (value: string) => { + this.loginToken = value; + }, + onPasswordChange: (value: string) => { + this.loginPassword = value; + }, + onToggleGatewayToken: () => { + this.loginShowGatewayToken = !this.loginShowGatewayToken; + }, + onToggleGatewayPassword: () => { + this.loginShowGatewayPassword = !this.loginShowGatewayPassword; + }, + onConnect: () => { + context.gateway.connect({ + gatewayUrl: this.loginGatewayUrl, + token: this.loginToken, + password: this.loginPassword, + }); + }, + }} + > + ${gatewayUrlConfirmation} + + `; + } + return html` + + ${gatewayUrlConfirmation} + + + `; + } +} + +class OpenClawShell extends LitElement { + @property({ attribute: false }) runtime?: ApplicationRuntime; + @property({ attribute: false }) onboarding = false; + @consume({ context: applicationContext, subscribe: false }) + private context?: ApplicationContext; + + @state() private navCollapsed = false; + @state() private navGroupsCollapsed: Record = {}; + @state() private recentSessionsCollapsed = false; + @state() private navDrawerOpen = false; + @state() private gatewayConnected = false; + @state() private terminalAvailable = false; + @state() private terminalClient: GatewayBrowserClient | null = null; + @state() private activeSessionKey = ""; + @state() private agentLabel = ""; + @state() private routeState: ShellRouteState = {}; + @state() private overlaySnapshot: ApplicationOverlaySnapshot = { + updateAvailable: null, + updateRunning: false, + updateStatusBanner: null, + approvalQueue: [], + approvalBusy: false, + approvalError: null, + }; + @query("openclaw-command-palette") private commandPalette?: CommandPalette; + private commandPaletteTarget?: CommandPaletteTargetDetail; + private navDrawerTrigger: HTMLElement | null = null; + private agentsListClient: GatewayBrowserClient | null = null; + private sessionKeyClient: GatewayBrowserClient | null = null; + private stopAgentsSubscription: (() => void) | undefined; + private stopConfigSubscription: (() => void) | undefined; + private stopGatewaySubscription: (() => void) | undefined; + private stopNavigationSubscription: (() => void) | undefined; + private stopRouteSubscription: (() => void) | undefined; + private stopOverlaySubscription: (() => void) | undefined; + private stopThemeSubscription: (() => void) | undefined; + + override createRenderRoot() { + return this; + } + + override connectedCallback() { + super.connectedCallback(); + this.startSubscriptions(); + this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); + } + + override updated() { + this.startSubscriptions(); + } + + private startSubscriptions() { + const runtime = this.runtime; + const context = this.context; + if ( + !runtime || + !context || + this.stopAgentsSubscription || + this.stopConfigSubscription || + this.stopGatewaySubscription || + this.stopNavigationSubscription || + this.stopRouteSubscription || + this.stopOverlaySubscription || + this.stopThemeSubscription + ) { + return; + } + this.updateNavigationPreferences(context.navigation.snapshot); + this.stopNavigationSubscription = context.navigation.subscribe((snapshot) => { + this.updateNavigationPreferences(snapshot); + }); + this.updateGatewaySessionKey(context.gateway.snapshot); + this.updateGatewayStatus(context.gateway.snapshot); + this.updateTerminalSurface(context.gateway.snapshot); + this.updateAgentLabel(); + this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { + this.updateGatewaySessionKey(snapshot); + this.updateGatewayStatus(snapshot); + this.updateTerminalSurface(snapshot); + this.updateAgentLabel(); + this.ensureAgentsList(snapshot); + }); + this.stopConfigSubscription = context.config.subscribe(() => { + this.updateTerminalSurface(context.gateway.snapshot); + }); + this.stopThemeSubscription = context.theme.subscribe(() => this.requestUpdate()); + this.stopAgentsSubscription = context.agents.subscribe(() => { + this.updateAgentLabel(); + }); + this.updateRouteState(selectShellRouteState(runtime.router.getState())); + this.stopRouteSubscription = runtime.router.subscribeSelector( + selectShellRouteState, + (routeState) => { + this.updateRouteState(routeState); + }, + equalShellRouteState, + ); + this.overlaySnapshot = context.overlays.snapshot; + this.stopOverlaySubscription = context.overlays.subscribe((snapshot) => { + this.overlaySnapshot = snapshot; + }); + } + + override disconnectedCallback() { + this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); + this.stopAgentsSubscription?.(); + this.stopAgentsSubscription = undefined; + this.stopConfigSubscription?.(); + this.stopConfigSubscription = undefined; + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopNavigationSubscription?.(); + this.stopNavigationSubscription = undefined; + this.stopRouteSubscription?.(); + this.stopRouteSubscription = undefined; + this.stopOverlaySubscription?.(); + this.stopOverlaySubscription = undefined; + this.stopThemeSubscription?.(); + this.stopThemeSubscription = undefined; + this.agentsListClient = null; + this.sessionKeyClient = null; + this.terminalClient = null; + this.navDrawerTrigger = null; + super.disconnectedCallback(); + } + + private readonly handleThemeChange = (event: CustomEvent) => { + const context = this.context; + if (!context) { + return; + } + context.theme.setMode(event.detail.mode, event.detail.element); + this.requestUpdate(); + }; + + private chatNavigationOptions(options?: ApplicationNavigationOptions) { + if (options) { + return options; + } + const sessionKey = this.activeSessionKey.trim(); + return sessionKey ? { search: searchForSession(sessionKey) } : undefined; + } + + private navigate(routeId: string, options?: ApplicationNavigationOptions) { + const context = this.context; + if (!context || !isRouteId(routeId)) { + return; + } + this.closeNavDrawer({ restoreFocus: true }); + context.navigate(routeId, routeId === "chat" ? this.chatNavigationOptions(options) : options); + } + + private replaceChatWithCurrentSession() { + this.context?.replace("chat", this.chatNavigationOptions()); + } + + private toggleNavDrawer(trigger: HTMLElement) { + if (this.navDrawerOpen) { + this.closeNavDrawer({ restoreFocus: true }); + return; + } + this.navDrawerTrigger = trigger; + this.navDrawerOpen = true; + } + + private closeNavDrawer(options: { restoreFocus?: boolean } = {}) { + const focusTarget = options.restoreFocus ? this.navDrawerTrigger : null; + this.navDrawerOpen = false; + this.navDrawerTrigger = null; + if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) { + return; + } + requestAnimationFrame(() => { + if (focusTarget.isConnected) { + focusTarget.focus(); + } + }); + } + + private readonly handleShellKeydown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.key !== "Escape" || !this.navDrawerOpen) { + return; + } + event.preventDefault(); + this.closeNavDrawer({ restoreFocus: true }); + }; + + private readonly openPalette = () => { + this.commandPalette?.openPalette(); + }; + + private readonly handleCommandPaletteSlashCommand = (command: string) => { + const chatHandler = this.commandPaletteTarget?.owner.isConnected + ? this.commandPaletteTarget.onSlashCommand + : null; + if (chatHandler) { + chatHandler(command); + return; + } + // Keep Chat's in-place draft path fast; other routes hand the draft through navigation. + const search = new URLSearchParams(this.chatNavigationOptions()?.search); + search.set("draft", command.endsWith(" ") ? command : `${command} `); + this.navigate("chat", { search: `?${search.toString()}` }); + }; + + private readonly handleCommandPaletteTarget = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || !(detail.owner instanceof Element)) { + return; + } + if (detail.onSlashCommand) { + this.commandPaletteTarget = detail; + } else if (this.commandPaletteTarget?.owner === detail.owner) { + this.commandPaletteTarget = undefined; + } + this.requestUpdate(); + }; + + private readonly updateGatewayStatus = (snapshot: { connected: boolean }) => { + if (snapshot.connected === this.gatewayConnected) { + return; + } + this.gatewayConnected = snapshot.connected; + }; + + private updateTerminalSurface(snapshot: ApplicationContext["gateway"]["snapshot"]) { + this.terminalClient = snapshot.connected ? snapshot.client : null; + this.terminalAvailable = isTerminalAvailable( + snapshot, + this.context?.config.current.terminalEnabled ?? false, + ); + } + + private ensureAgentsList(snapshot: { client: GatewayBrowserClient | null; connected: boolean }) { + if (!snapshot.connected || !snapshot.client) { + this.agentsListClient = null; + return; + } + const routeId = this.routeState.routeId; + if (!routeId || routeId === "chat" || this.context?.agents.state.agentsList) { + return; + } + if (this.agentsListClient === snapshot.client) { + return; + } + this.agentsListClient = snapshot.client; + void this.context?.agents.ensureList(); + } + + private updateGatewaySessionKey(snapshot: { + client: GatewayBrowserClient | null; + sessionKey: string; + }) { + const sessionKey = snapshot.sessionKey.trim(); + if (snapshot.client === this.sessionKeyClient && sessionKey === this.activeSessionKey) { + return; + } + this.sessionKeyClient = snapshot.client; + if (sessionKey) { + this.activeSessionKey = sessionKey; + } + } + + private updateRouteState(routeState: ShellRouteState) { + this.routeState = routeState; + const context = this.context; + if (context) { + this.ensureAgentsList(context.gateway.snapshot); + } + if (routeState.routeId !== "chat") { + return; + } + const sessionKey = new URLSearchParams(routeState.location?.search).get("session")?.trim(); + if (sessionKey) { + this.activeSessionKey = sessionKey; + this.updateAgentLabel(); + } + } + + private updateAgentLabel() { + const context = this.context; + if (!context) { + return; + } + this.agentLabel = resolveAgentLabel( + this.activeSessionKey || context.gateway.snapshot.sessionKey, + context.agents.state.agentsList, + ); + } + + private readonly updateNavigationPreferences = ( + snapshot: ApplicationRuntime["context"]["navigation"]["snapshot"], + ) => { + this.navCollapsed = snapshot.navCollapsed; + this.navGroupsCollapsed = snapshot.navGroupsCollapsed; + this.recentSessionsCollapsed = snapshot.recentSessionsCollapsed; + }; + + override render() { + const context = this.context; + const runtime = this.runtime; + if (!context || !runtime) { + return nothing; + } + const activeRoute = this.routeState.routeId ?? "chat"; + const navDrawerOpen = this.navDrawerOpen && !this.onboarding; + const navCollapsed = this.navCollapsed && !navDrawerOpen; + return html` + this.navigate(routeId)} + .onSlashCommand=${this.handleCommandPaletteSlashCommand} + > +
+ + + window.dispatchEvent(new CustomEvent("openclaw:terminal-toggle"))} + .onToggleDrawer=${(trigger: HTMLElement) => this.toggleNavDrawer(trigger)} + .onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) => + this.navigate(routeId, options)} + > +
+ { + if (navDrawerOpen) { + this.closeNavDrawer({ restoreFocus: true }); + return; + } + context.navigation.update({ + navCollapsed: !navCollapsed, + }); + }} + .onToggleGroup=${(label: string) => { + const current = context.navigation.snapshot.navGroupsCollapsed[label] ?? false; + context.navigation.update({ + navGroupsCollapsed: { + ...context.navigation.snapshot.navGroupsCollapsed, + [label]: !current, + }, + }); + }} + .onToggleRecentSessions=${() => + context.navigation.update({ + recentSessionsCollapsed: !context.navigation.snapshot.recentSessionsCollapsed, + })} + .onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) => + this.navigate(routeId, options)} + .onPreloadRoute=${(routeId: string) => + isRouteId(routeId) ? context.preload(routeId) : Promise.resolve()} + > +
+
+ context.overlays.runUpdate(), + onDismiss: () => context.overlays.dismissUpdate(), + }} + > + this.replaceChatWithCurrentSession()} + > +
+ + [0]) => + context.overlays.decideApproval(decision), + }} + > +
+ `; + } +} + +if (!customElements.get("openclaw-app")) { + customElements.define("openclaw-app", OpenClawApp); +} +if (!customElements.get("openclaw-app-shell")) { + customElements.define("openclaw-app-shell", OpenClawShell); +} diff --git a/ui/src/app/assistant-identity.ts b/ui/src/app/assistant-identity.ts new file mode 100644 index 000000000000..928e8a896ebf --- /dev/null +++ b/ui/src/app/assistant-identity.ts @@ -0,0 +1,118 @@ +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { normalizeAssistantIdentity, type AssistantIdentity } from "../lib/assistant-identity.ts"; +import { normalizeOptionalString } from "../lib/string-coerce.ts"; +import { getSafeLocalStorage } from "../local-storage.ts"; + +const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1"; + +export type LocalAssistantIdentity = { avatar: string | null; agentId?: string | null }; + +type PersistedLocalAssistantIdentities = { + avatars?: Record; + avatar?: unknown; + agentId?: unknown; +}; + +function parseLocalAssistantAvatarMap(raw: string): { + avatars: Record; + legacyAvatar: string | null; +} { + const parsed = JSON.parse(raw) as PersistedLocalAssistantIdentities; + const avatars = Object.create(null) as Record; + if (parsed.avatars && typeof parsed.avatars === "object" && !Array.isArray(parsed.avatars)) { + for (const [agentId, avatar] of Object.entries(parsed.avatars)) { + const normalizedAgentId = normalizeOptionalString(agentId); + const normalizedAvatar = normalizeOptionalString(avatar); + if (normalizedAgentId && normalizedAvatar) { + avatars[normalizedAgentId] = normalizedAvatar; + } + } + } + const legacyAvatar = normalizeOptionalString(parsed.avatar); + const legacyAgentId = normalizeOptionalString(parsed.agentId); + if (legacyAvatar && legacyAgentId && !Object.hasOwn(avatars, legacyAgentId)) { + avatars[legacyAgentId] = legacyAvatar; + } + return { avatars, legacyAvatar: legacyAgentId ? null : (legacyAvatar ?? null) }; +} + +function persistLocalAssistantAvatarMap(storage: Storage | null, avatars: Record) { + if (Object.keys(avatars).length === 0) { + storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY); + return; + } + storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatars })); +} + +export function loadLocalAssistantIdentity(opts?: { + agentId?: string | null; +}): LocalAssistantIdentity { + const agentId = normalizeOptionalString(opts?.agentId); + if (!agentId) { + return { avatar: null }; + } + const storage = getSafeLocalStorage(); + try { + const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY); + if (!raw) { + return { avatar: null }; + } + const { avatars, legacyAvatar } = parseLocalAssistantAvatarMap(raw); + if (!Object.hasOwn(avatars, agentId) && legacyAvatar) { + // Assign the old global override to the first concrete agent that loads it. + avatars[agentId] = legacyAvatar; + persistLocalAssistantAvatarMap(storage, avatars); + } + return { avatar: Object.hasOwn(avatars, agentId) ? avatars[agentId] : null, agentId }; + } catch { + return { avatar: null }; + } +} + +export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) { + const agentId = normalizeOptionalString(next.agentId); + if (!agentId) { + return; + } + const storage = getSafeLocalStorage(); + try { + const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY); + const avatars = raw + ? parseLocalAssistantAvatarMap(raw).avatars + : (Object.create(null) as Record); + const avatar = normalizeOptionalString(next.avatar); + if (avatar) { + avatars[agentId] = avatar; + } else { + delete avatars[agentId]; + } + persistLocalAssistantAvatarMap(storage, avatars); + } catch { + // best-effort — quota exceeded or security restrictions should not + // prevent in-memory identity updates from being applied + } +} + +export async function fetchAssistantIdentity( + client: GatewayBrowserClient, + sessionKey?: string, +): Promise { + const result = await client.request>( + "agent.identity.get", + sessionKey?.trim() ? { sessionKey: sessionKey.trim() } : {}, + ); + if (!result) { + return null; + } + const identity = normalizeAssistantIdentity(result); + const localAvatar = loadLocalAssistantIdentity({ agentId: identity.agentId }).avatar; + return localAvatar + ? { + ...identity, + avatar: localAvatar, + avatarSource: localAvatar, + avatarStatus: "data", + avatarReason: null, + } + : identity; +} diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts new file mode 100644 index 000000000000..a2f978d3b44e --- /dev/null +++ b/ui/src/app/bootstrap.ts @@ -0,0 +1,630 @@ +import type { RouteLocation } from "@openclaw/uirouter"; +import type { EventLogEntry } from "../api/event-log.ts"; +import { + GatewayBrowserClient, + type GatewayEventListener, + type GatewayHelloOk, +} from "../api/gateway.ts"; +import { + createApplicationRouter, + inferBasePathFromPathname, + locationForRoute, + normalizeBasePath, + pathForRoute, + routeIdFromPath, + startApplicationRouter, + type ApplicationRouter, + type RouteId, +} from "../app-routes.ts"; +import { createAgentIdentityCapability } from "../lib/agents/identity.ts"; +import { createAgentCapability } from "../lib/agents/index.ts"; +import { createChannelCapability } from "../lib/channels/index.ts"; +import { createRuntimeConfigCapability } from "../lib/config/index.ts"; +import { createSessionCapability, resolveSessionKey } from "../lib/sessions/index.ts"; +import { generateUUID } from "../lib/uuid.ts"; +import { createWorkboardCapability } from "../lib/workboard/capability.ts"; +import { createAgentSelectionCapability } from "./agent-selection.ts"; +import { createBrowserHistory } from "./browser.ts"; +import { createApplicationConfigCapability } from "./config.ts"; +import type { + ApplicationGateway, + ApplicationGatewayConnectOptions, + ApplicationGatewayConnection, + ApplicationNavigationOptions, + ApplicationGatewaySnapshot, + ApplicationContext, + ApplicationNavigationPreferences, + ApplicationNavigationPreferencesSnapshot, + ApplicationSkillWorkshopRevisionHandoff, + ApplicationTheme, +} from "./context.ts"; +import { syncCustomThemeStyleTag } from "./custom-theme.ts"; +import { createNativeChatDrafts } from "./native-bridge.ts"; +import { createApplicationOverlays } from "./overlays.ts"; +import { + loadSettings, + patchSettings, + resolveApplicationStartupSettings, + saveSettings, + type UiSettings, +} from "./settings.ts"; +import { startThemeTransition } from "./theme-transition.ts"; +import { resolveTheme, type ThemeMode } from "./theme.ts"; +import { createWebPushCapability } from "./web-push.ts"; + +function normalizeInitialApplicationLocation( + location: RouteLocation, + basePath: string, + sessionKey: string, +) { + const routeId = routeIdFromPath(location.pathname, basePath); + if ((routeId !== null && routeId !== "chat") || !sessionKey.trim()) { + return location; + } + + const search = new URLSearchParams(location.search); + if (!search.get("session")?.trim()) { + search.set("session", sessionKey); + } + return { + ...location, + pathname: routeId === null ? pathForRoute("chat", basePath) : location.pathname, + search: `?${search.toString()}`, + }; +} + +function applyStartupPresentation(settings: ReturnType): void { + if (typeof document === "undefined") { + return; + } + const root = document.documentElement; + const resolvedTheme = resolveTheme(settings.theme, settings.themeMode); + root.dataset.theme = resolvedTheme; + root.dataset.themeMode = resolvedTheme.endsWith("light") ? "light" : "dark"; + root.style.colorScheme = root.dataset.themeMode; + root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`); + syncCustomThemeStyleTag(settings.customTheme); +} + +function createApplicationTheme( + initialSettings: UiSettings, +): ApplicationTheme & { dispose: () => void } { + let settings = initialSettings; + let systemThemeCleanup: (() => void) | undefined; + const listeners = new Set<() => void>(); + + const publish = () => { + applyStartupPresentation(settings); + for (const listener of listeners) { + listener(); + } + }; + + const detachSystemThemeListener = () => { + systemThemeCleanup?.(); + systemThemeCleanup = undefined; + }; + + const syncSystemThemeListener = () => { + detachSystemThemeListener(); + if (settings.themeMode !== "system" || typeof globalThis.matchMedia !== "function") { + return; + } + const mediaQuery = globalThis.matchMedia("(prefers-color-scheme: light)"); + const onChange = () => { + if (settings.themeMode === "system") { + publish(); + } + }; + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", onChange); + systemThemeCleanup = () => mediaQuery.removeEventListener("change", onChange); + } else if (typeof mediaQuery.addListener === "function") { + mediaQuery.addListener(onChange); + systemThemeCleanup = () => mediaQuery.removeListener(onChange); + } + }; + + syncSystemThemeListener(); + + return { + get mode() { + return settings.themeMode; + }, + setMode(mode: ThemeMode, element) { + const currentSettings = loadSettings(); + const nextSettings = { ...currentSettings, themeMode: mode }; + const currentTheme = resolveTheme(currentSettings.theme, currentSettings.themeMode); + const nextTheme = resolveTheme(nextSettings.theme, nextSettings.themeMode); + startThemeTransition({ + nextTheme, + currentTheme, + context: { element }, + applyTheme: () => { + settings = patchSettings({ themeMode: mode }); + publish(); + syncSystemThemeListener(); + }, + }); + }, + refresh() { + settings = loadSettings(); + publish(); + syncSystemThemeListener(); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + detachSystemThemeListener(); + listeners.clear(); + }, + }; +} + +function createApplicationNavigationPreferences( + initialSettings: UiSettings, +): ApplicationNavigationPreferences { + let settings = initialSettings; + let snapshot: ApplicationNavigationPreferencesSnapshot = { + navCollapsed: settings.navCollapsed, + navGroupsCollapsed: settings.navGroupsCollapsed, + recentSessionsCollapsed: settings.recentSessionsCollapsed ?? false, + }; + const listeners = new Set<(next: ApplicationNavigationPreferencesSnapshot) => void>(); + + return { + get snapshot() { + return snapshot; + }, + update(patch) { + const nextSnapshot = { ...snapshot, ...patch }; + if ( + nextSnapshot.navCollapsed === snapshot.navCollapsed && + nextSnapshot.recentSessionsCollapsed === snapshot.recentSessionsCollapsed && + nextSnapshot.navGroupsCollapsed === snapshot.navGroupsCollapsed + ) { + return; + } + settings = patchSettings({ + navCollapsed: nextSnapshot.navCollapsed, + navGroupsCollapsed: nextSnapshot.navGroupsCollapsed, + recentSessionsCollapsed: nextSnapshot.recentSessionsCollapsed, + }); + snapshot = nextSnapshot; + for (const listener of listeners) { + listener(snapshot); + } + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +function createSkillWorkshopRevisionHandoff(): ApplicationSkillWorkshopRevisionHandoff { + let pending: Parameters[0] | null = null; + return { + prepare: (handoff) => { + pending = handoff; + }, + consume: (sessionKey) => { + if (!pending || pending.sessionKey !== sessionKey) { + return null; + } + const handoff = pending; + pending = null; + return handoff; + }, + clear: () => { + pending = null; + }, + }; +} + +function createApplicationGateway( + initialSettings: ReturnType, + initialPassword = "", +): ApplicationGateway { + let settings = initialSettings; + let connection: ApplicationGatewayConnection = { + gatewayUrl: settings.gatewayUrl, + token: settings.token, + password: initialPassword, + }; + let snapshot: ApplicationGatewaySnapshot = { + client: null, + connected: false, + hello: null, + assistantAgentId: "main", + sessionKey: settings.sessionKey, + lastError: null, + lastErrorCode: null, + }; + let client: GatewayBrowserClient | null = null; + const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>(); + const eventListeners = new Set(); + const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>(); + let eventLog: EventLogEntry[] = []; + let stopClientEvents: (() => void) | undefined; + const syncClientEvents = (nextClient: GatewayBrowserClient | null) => { + stopClientEvents?.(); + stopClientEvents = undefined; + if (!nextClient || eventListeners.size === 0) { + return; + } + const removers = [...eventListeners].map((listener) => nextClient.addEventListener(listener)); + stopClientEvents = () => { + for (const remove of removers) { + remove(); + } + }; + }; + const notify = () => { + for (const listener of listeners) { + listener(snapshot); + } + }; + const setSnapshot = (next: ApplicationGatewaySnapshot) => { + snapshot = next; + notify(); + }; + const publishEventLog = () => { + for (const listener of eventLogListeners) { + listener(eventLog); + } + }; + const recordGatewayEvent = (event: Parameters[0]) => { + eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice( + 0, + 250, + ); + publishEventLog(); + }; + + const connect = (overrides: ApplicationGatewayConnectOptions = {}) => { + const { sessionKey: requestedSessionKey, ...connectionOverrides } = overrides; + const nextConnection = { ...connection, ...connectionOverrides }; + const hasRequestedSessionKey = requestedSessionKey !== undefined; + const nextSessionKey = hasRequestedSessionKey + ? requestedSessionKey.trim() + : snapshot.sessionKey; + connection = nextConnection; + settings = patchSettings({ + gatewayUrl: nextConnection.gatewayUrl, + token: nextConnection.token, + ...(hasRequestedSessionKey + ? { + sessionKey: nextSessionKey, + lastActiveSessionKey: nextSessionKey, + } + : {}), + }); + client?.stop(); + stopClientEvents?.(); + stopClientEvents = undefined; + + const nextClient = new GatewayBrowserClient({ + url: nextConnection.gatewayUrl, + token: nextConnection.token.trim() ? nextConnection.token : undefined, + password: nextConnection.password.trim() ? nextConnection.password : undefined, + clientName: "openclaw-control-ui", + clientVersion: "dev", + mode: "webchat", + instanceId: generateUUID(), + onHello: (hello: GatewayHelloOk) => { + if (client !== nextClient) { + return; + } + settings = loadSettings(); + const sessionDefaults = readSessionDefaults(hello); + const sessionKey = resolveSessionKey(snapshot.sessionKey, hello); + const lastActiveSessionKey = resolveSessionKey(settings.lastActiveSessionKey, hello); + if ( + sessionKey !== settings.sessionKey || + lastActiveSessionKey !== settings.lastActiveSessionKey + ) { + settings = patchSettings({ + sessionKey, + lastActiveSessionKey, + }); + } + setSnapshot({ + ...snapshot, + client: nextClient, + connected: true, + hello, + assistantAgentId: sessionDefaults?.defaultAgentId ?? "main", + sessionKey, + lastError: null, + lastErrorCode: null, + }); + }, + onClose: ({ code, reason, error }) => { + if (client !== nextClient) { + return; + } + setSnapshot({ + ...snapshot, + client: nextClient, + connected: false, + hello: null, + lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`, + lastErrorCode: error?.code ?? null, + }); + }, + onGap: ({ expected, received }) => { + if (client !== nextClient) { + return; + } + setSnapshot({ + ...snapshot, + lastError: `event gap detected (expected seq ${expected}, got ${received}); reconnecting`, + lastErrorCode: null, + }); + connect(); + }, + onEvent: recordGatewayEvent, + }); + client = nextClient; + syncClientEvents(nextClient); + setSnapshot({ + ...snapshot, + client: nextClient, + connected: false, + hello: null, + sessionKey: nextSessionKey, + lastError: null, + lastErrorCode: null, + }); + nextClient.start(); + }; + + const gateway: ApplicationGateway = { + get snapshot() { + return snapshot; + }, + get connection() { + return connection; + }, + get eventLog() { + return eventLog; + }, + connect, + setSessionKey: (sessionKey) => { + const nextSessionKey = sessionKey.trim(); + if (!nextSessionKey || nextSessionKey === snapshot.sessionKey) { + return; + } + settings = patchSettings({ + sessionKey: nextSessionKey, + lastActiveSessionKey: nextSessionKey, + }); + setSnapshot({ ...snapshot, sessionKey: nextSessionKey }); + }, + start: () => connect(), + stop: () => { + stopClientEvents?.(); + stopClientEvents = undefined; + client?.stop(); + client = null; + setSnapshot({ + ...snapshot, + client: null, + connected: false, + hello: null, + lastError: null, + lastErrorCode: null, + }); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + subscribeEventLog: (listener) => { + eventLogListeners.add(listener); + return () => eventLogListeners.delete(listener); + }, + subscribeEvents: (listener) => { + eventListeners.add(listener); + syncClientEvents(client); + return () => { + if (eventListeners.delete(listener)) { + syncClientEvents(client); + } + }; + }, + }; + return gateway; +} + +function readSessionDefaults( + hello: GatewayHelloOk, +): { defaultAgentId?: string | null } | undefined { + const snapshot = hello.snapshot; + if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) { + return undefined; + } + const defaults = snapshot.sessionDefaults; + return defaults && typeof defaults === "object" + ? (defaults as { defaultAgentId?: string | null }) + : undefined; +} + +export type ApplicationRuntime = { + readonly context: ApplicationContext; + readonly router: ApplicationRouter; + readonly pendingGatewayConnection: { + readonly gatewayUrl: string; + readonly token: string; + } | null; + readonly confirmPendingGatewayConnection: () => void; + readonly cancelPendingGatewayConnection: () => void; + start: () => Promise; + stop: () => void; +}; + +export function bootstrapApplication(): ApplicationRuntime { + const initialSettings = loadSettings(); + const history = createBrowserHistory(); + const startup = resolveApplicationStartupSettings(initialSettings, history.location()); + if (startup.changed) { + saveSettings(startup.settings); + } + const basePath = normalizeBasePath( + inferBasePathFromPathname(startup.location.pathname || globalThis.location?.pathname || "/"), + ); + const initialLocation = normalizeInitialApplicationLocation( + startup.location, + basePath, + startup.settings.sessionKey, + ); + const currentLocation = history.location(); + if ( + currentLocation.pathname !== initialLocation.pathname || + currentLocation.search !== initialLocation.search || + currentLocation.hash !== initialLocation.hash + ) { + history.replace(initialLocation); + } + + const settings = startup.settings; + const gateway = createApplicationGateway(settings, startup.password ?? ""); + const agents = createAgentCapability(gateway); + const agentIdentity = createAgentIdentityCapability(gateway); + const agentSelection = createAgentSelectionCapability(gateway); + const channels = createChannelCapability(gateway); + const config = createApplicationConfigCapability({ + basePath, + auth: { + settings: { token: settings.token }, + password: startup.password ?? "", + }, + }); + const sessions = createSessionCapability(gateway); + const workboard = createWorkboardCapability(); + const runtimeConfig = createRuntimeConfigCapability(gateway); + const overlays = createApplicationOverlays(gateway); + const navigation = createApplicationNavigationPreferences(settings); + const theme = createApplicationTheme(settings); + const nativeChatDrafts = createNativeChatDrafts(); + const webPush = createWebPushCapability(gateway); + const skillWorkshopRevision = createSkillWorkshopRevisionHandoff(); + applyStartupPresentation(settings); + const router = createApplicationRouter(); + let pendingGatewayConnection = + startup.pendingGatewayUrl !== null + ? { + gatewayUrl: startup.pendingGatewayUrl, + token: startup.pendingGatewayToken ?? "", + } + : null; + let lastConfigRefreshClient: GatewayBrowserClient | null = null; + const stopConfigRefresh = gateway.subscribe((snapshot) => { + if (!snapshot.connected || !snapshot.client) { + lastConfigRefreshClient = null; + return; + } + if (lastConfigRefreshClient === snapshot.client) { + return; + } + lastConfigRefreshClient = snapshot.client; + void config.refresh({ + auth: { + hello: snapshot.hello, + settings: { token: gateway.connection.token }, + password: gateway.connection.password, + }, + }); + }); + const routeLocation = (routeId: RouteId, options?: ApplicationNavigationOptions) => { + const location = locationForRoute(routeId, basePath); + if (options?.search !== undefined || options?.hash !== undefined) { + return { + ...location, + search: options?.search ?? "", + hash: options?.hash ?? "", + }; + } + return location; + }; + const confirmPendingGatewayConnection = () => { + const pending = pendingGatewayConnection; + if (!pending) { + return; + } + pendingGatewayConnection = null; + gateway.connect({ + gatewayUrl: pending.gatewayUrl, + token: pending.token, + }); + }; + const cancelPendingGatewayConnection = () => { + pendingGatewayConnection = null; + }; + const context: ApplicationContext = { + basePath, + gateway, + agents, + agentIdentity, + agentSelection, + channels, + config, + runtimeConfig, + sessions, + workboard, + overlays, + navigation, + theme, + nativeChatDrafts, + webPush, + skillWorkshopRevision, + navigate: (routeId, options) => { + void router + .navigate(routeId, context, { history: "push" }, routeLocation(routeId, options)) + .catch((error: unknown) => { + console.error("[openclaw] route navigation failed", error); + }); + }, + replace: (routeId, options) => { + void router + .navigate(routeId, context, { history: "replace" }, routeLocation(routeId, options)) + .catch((error: unknown) => { + console.error("[openclaw] route replacement failed", error); + }); + }, + preload: (routeId) => router.preloadRoute(routeId, context), + }; + return { + context, + router, + get pendingGatewayConnection() { + return pendingGatewayConnection; + }, + confirmPendingGatewayConnection, + cancelPendingGatewayConnection, + start: async () => { + void config.refresh({ skipWithoutAuthCandidate: true }); + const routerStart = startApplicationRouter(router, history, basePath, context); + gateway.start(); + await routerStart; + }, + stop: () => { + stopConfigRefresh(); + router.stop(); + gateway.stop(); + agents.dispose(); + channels.dispose(); + sessions.dispose(); + workboard.dispose(); + runtimeConfig.dispose(); + overlays.dispose(); + theme.dispose(); + nativeChatDrafts.dispose(); + webPush.dispose(); + skillWorkshopRevision.clear(); + }, + }; +} diff --git a/ui/src/app/browser.ts b/ui/src/app/browser.ts new file mode 100644 index 000000000000..2277c5beac20 --- /dev/null +++ b/ui/src/app/browser.ts @@ -0,0 +1,53 @@ +import type { RouteLocation, RouterHistory } from "@openclaw/uirouter"; + +function readLocation(): RouteLocation { + return { + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + }; +} + +function writeLocation(location: RouteLocation) { + return `${location.pathname}${location.search}${location.hash}`; +} + +export function createBrowserHistory(): RouterHistory { + const listeners = new Set<(location: RouteLocation) => void>(); + let stopPopState: (() => void) | undefined; + + const ensurePopStateListener = () => { + if (stopPopState) { + return; + } + const onPopState = () => { + const location = readLocation(); + for (const listener of listeners) { + listener(location); + } + }; + window.addEventListener("popstate", onPopState); + stopPopState = () => window.removeEventListener("popstate", onPopState); + }; + + const releasePopStateListener = () => { + if (listeners.size === 0) { + stopPopState?.(); + stopPopState = undefined; + } + }; + + return { + location: readLocation, + push: (location) => window.history.pushState({}, "", writeLocation(location)), + replace: (location) => window.history.replaceState({}, "", writeLocation(location)), + listen: (listener) => { + listeners.add(listener); + ensurePopStateListener(); + return () => { + listeners.delete(listener); + releasePopStateListener(); + }; + }, + }; +} diff --git a/ui/src/app/config.ts b/ui/src/app/config.ts new file mode 100644 index 000000000000..fb8ffcd8e530 --- /dev/null +++ b/ui/src/app/config.ts @@ -0,0 +1,248 @@ +import { normalizeRouteBasePath } from "@openclaw/uirouter"; +import { + CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, + type ControlUiBootstrapConfig, + type ControlUiEmbedSandboxMode, +} from "../../../src/gateway/control-ui-contract.js"; +import { normalizeAssistantIdentity } from "../lib/assistant-identity.ts"; +import { setUiTimeFormatPreference } from "../lib/format.ts"; +import { resolveControlUiAuthCandidates } from "./control-ui-auth.ts"; + +type ApplicationConfigAuthSource = { + hello?: { auth?: { deviceToken?: string | null } | null } | null; + settings?: { token?: string | null } | null; + password?: string | null; +}; + +const SEAM_COLOR_CSS_VARIABLES = [ + "--ring", + "--accent", + "--accent-hover", + "--accent-muted", + "--accent-subtle", + "--accent-glow", + "--primary", + "--focus", + "--focus-ring", + "--focus-glow", +] as const; + +export type ApplicationConfig = { + assistantIdentity: { + agentId: string | null; + name: string; + avatar: string | null; + avatarSource: string | null; + avatarStatus: "none" | "local" | "remote" | "data" | null; + avatarReason: string | null; + }; + serverVersion: string | null; + localMediaPreviewRoots: string[]; + embedSandboxMode: ControlUiEmbedSandboxMode; + allowExternalEmbedUrls: boolean; + chatMessageMaxWidth: string | null; + terminalEnabled: boolean; +}; + +export type ApplicationConfigCapability = { + readonly current: ApplicationConfig; + refresh: (options?: { + auth?: ApplicationConfigAuthSource; + skipWithoutAuthCandidate?: boolean; + }) => Promise; + subscribe: (listener: (config: ApplicationConfig) => void) => () => void; +}; + +function readDocumentTerminalEnabled(): boolean | null { + if (typeof document === "undefined") { + return null; + } + const value = document.documentElement.getAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE); + return value === "true" ? true : value === "false" ? false : null; +} + +export const DEFAULT_APPLICATION_CONFIG: ApplicationConfig = { + assistantIdentity: { + agentId: null, + name: "Assistant", + avatar: null, + avatarSource: null, + avatarStatus: null, + avatarReason: null, + }, + serverVersion: null, + localMediaPreviewRoots: [], + embedSandboxMode: "strict", + allowExternalEmbedUrls: false, + chatMessageMaxWidth: null, + terminalEnabled: readDocumentTerminalEnabled() ?? false, +}; + +function normalizeSeamColor(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const hex = value.trim().replace(/^#/, ""); + return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; +} + +function applyControlUiSeamColor(value: unknown): void { + if (typeof document === "undefined") { + return; + } + const root = document.documentElement; + const color = normalizeSeamColor(value); + if (!color) { + for (const property of SEAM_COLOR_CSS_VARIABLES) { + root.style.removeProperty(property); + } + return; + } + + root.style.setProperty("--ring", color); + root.style.setProperty("--accent", color); + root.style.setProperty("--accent-hover", "color-mix(in srgb, var(--accent) 82%, white 18%)"); + root.style.setProperty("--accent-muted", color); + root.style.setProperty("--accent-subtle", "color-mix(in srgb, var(--accent) 16%, transparent)"); + root.style.setProperty("--accent-glow", "color-mix(in srgb, var(--accent) 30%, transparent)"); + root.style.setProperty("--primary", color); + root.style.setProperty("--focus", "color-mix(in srgb, var(--ring) 22%, transparent)"); + root.style.setProperty( + "--focus-ring", + "0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent)", + ); + root.style.setProperty( + "--focus-glow", + "0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)", + ); +} + +export function normalizeApplicationConfig(parsed: ControlUiBootstrapConfig): ApplicationConfig { + const identity = normalizeAssistantIdentity({ + agentId: parsed.assistantAgentId ?? null, + name: parsed.assistantName, + avatar: parsed.assistantAvatar ?? null, + avatarSource: parsed.assistantAvatarSource ?? null, + avatarStatus: parsed.assistantAvatarStatus ?? null, + avatarReason: parsed.assistantAvatarReason ?? null, + }); + return { + assistantIdentity: { + agentId: identity.agentId ?? null, + name: identity.name, + avatar: identity.avatar, + avatarSource: identity.avatarSource ?? null, + avatarStatus: identity.avatarStatus ?? null, + avatarReason: identity.avatarReason ?? null, + }, + serverVersion: parsed.serverVersion ?? null, + localMediaPreviewRoots: Array.isArray(parsed.localMediaPreviewRoots) + ? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string") + : [], + embedSandboxMode: + parsed.embedSandbox === "trusted" + ? "trusted" + : parsed.embedSandbox === "strict" + ? "strict" + : "scripts", + allowExternalEmbedUrls: parsed.allowExternalEmbedUrls === true, + chatMessageMaxWidth: + typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim() + ? parsed.chatMessageMaxWidth + : null, + terminalEnabled: parsed.terminalEnabled === true, + }; +} + +export async function loadApplicationConfig(params: { + basePath: string; + auth?: ApplicationConfigAuthSource; + skipWithoutAuthCandidate?: boolean; +}): Promise { + if (typeof window === "undefined" || typeof fetch !== "function") { + return null; + } + + const basePath = normalizeRouteBasePath(params.basePath); + const url = basePath + ? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` + : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; + + try { + const resolvedUrl = new URL(url, window.location.origin); + const sameOrigin = resolvedUrl.origin === window.location.origin; + const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(params.auth ?? {}) : []; + if (params.skipWithoutAuthCandidate && sameOrigin && authCandidates.length === 0) { + return null; + } + const attempts = authCandidates.length > 0 ? authCandidates : [""]; + let res: Response | null = null; + for (const candidate of attempts) { + const headers: Record = { Accept: "application/json" }; + if (candidate) { + headers.Authorization = `Bearer ${candidate}`; + } + res = await fetch(url, { method: "GET", headers, credentials: "same-origin" }); + if (res.ok) { + break; + } + if (res.status !== 401 && res.status !== 403) { + return null; + } + } + if (!res || !res.ok) { + return null; + } + const parsed = (await res.json()) as ControlUiBootstrapConfig; + setUiTimeFormatPreference(parsed.timeFormat); + applyControlUiSeamColor(parsed.seamColor); + return normalizeApplicationConfig(parsed); + } catch { + return null; + } +} + +export function createApplicationConfigCapability(params: { + basePath: string; + auth?: ApplicationConfigAuthSource; +}): ApplicationConfigCapability { + let current = DEFAULT_APPLICATION_CONFIG; + let refreshVersion = 0; + const listeners = new Set<(config: ApplicationConfig) => void>(); + + const publish = (next: ApplicationConfig) => { + current = next; + for (const listener of listeners) { + listener(current); + } + }; + + return { + get current() { + return current; + }, + async refresh(options) { + const version = ++refreshVersion; + const next = await loadApplicationConfig({ + basePath: params.basePath, + auth: options?.auth ?? params.auth, + skipWithoutAuthCandidate: options?.skipWithoutAuthCandidate, + }); + if (next && version === refreshVersion) { + const documentTerminalEnabled = readDocumentTerminalEnabled(); + if (documentTerminalEnabled !== null && next.terminalEnabled !== documentTerminalEnabled) { + // CSP headers cannot change on a live document. Reload in either + // direction so the document and accepted terminal state stay aligned. + window.location.reload(); + return; + } + publish(next); + } + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts new file mode 100644 index 000000000000..08d2f0422981 --- /dev/null +++ b/ui/src/app/context.ts @@ -0,0 +1,82 @@ +import { createContext } from "@lit/context"; +import type { RouteLocation } from "@openclaw/uirouter"; +import type { RouteId } from "../app-route-paths.ts"; +import type { AgentIdentityCapability } from "../lib/agents/identity.ts"; +import type { AgentCapability } from "../lib/agents/index.ts"; +import type { ChannelCapability } from "../lib/channels/index.ts"; +import type { RuntimeConfigCapability } from "../lib/config/index.ts"; +import type { SessionCapability } from "../lib/sessions/index.ts"; +import type { WorkboardCapability } from "../lib/workboard/capability.ts"; +import type { AgentSelectionCapability } from "./agent-selection.ts"; +import type { ApplicationConfigCapability } from "./config.ts"; +import type { ApplicationGateway } from "./gateway.ts"; +import type { NativeChatDrafts } from "./native-bridge.ts"; +import type { ApplicationOverlays } from "./overlays.ts"; +import type { ThemeMode } from "./theme.ts"; +import type { WebPushCapability } from "./web-push.ts"; + +export type { + ApplicationGateway, + ApplicationGatewayConnection, + ApplicationGatewayConnectOptions, + ApplicationGatewaySnapshot, +} from "./gateway.ts"; + +export type ApplicationTheme = { + readonly mode: ThemeMode; + setMode: (mode: ThemeMode, element?: HTMLElement | null) => void; + refresh: () => void; + subscribe: (listener: () => void) => () => void; +}; + +export type ApplicationNavigationPreferencesSnapshot = { + navCollapsed: boolean; + navGroupsCollapsed: Record; + recentSessionsCollapsed: boolean; +}; + +export type ApplicationNavigationPreferences = { + readonly snapshot: ApplicationNavigationPreferencesSnapshot; + update: (patch: Partial) => void; + subscribe: (listener: (snapshot: ApplicationNavigationPreferencesSnapshot) => void) => () => void; +}; + +export type ApplicationNavigationOptions = Partial>; + +export type SkillWorkshopRevisionHandoff = { + sessionKey: string; + instructions: string; + proposalId: string; + proposalAgentId: string; +}; + +export type ApplicationSkillWorkshopRevisionHandoff = { + prepare: (handoff: SkillWorkshopRevisionHandoff) => void; + consume: (sessionKey: string) => SkillWorkshopRevisionHandoff | null; + clear: () => void; +}; + +export type ApplicationContext = { + readonly basePath: string; + readonly gateway: ApplicationGateway; + readonly agents: AgentCapability; + readonly agentIdentity: AgentIdentityCapability; + readonly agentSelection: AgentSelectionCapability; + readonly channels: ChannelCapability; + readonly config: ApplicationConfigCapability; + readonly runtimeConfig: RuntimeConfigCapability; + readonly sessions: SessionCapability; + readonly workboard: WorkboardCapability; + readonly overlays: ApplicationOverlays; + readonly navigation: ApplicationNavigationPreferences; + readonly theme: ApplicationTheme; + readonly nativeChatDrafts: NativeChatDrafts; + readonly webPush: WebPushCapability; + readonly skillWorkshopRevision: ApplicationSkillWorkshopRevisionHandoff; + readonly navigate: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void; + readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void; + readonly preload: (routeId: TRouteId) => Promise; +}; + +export const applicationContext = + createContext>("openclaw.application"); diff --git a/ui/src/ui/control-ui-auth.ts b/ui/src/app/control-ui-auth.ts similarity index 96% rename from ui/src/ui/control-ui-auth.ts rename to ui/src/app/control-ui-auth.ts index 5fb15cb6387b..deb4cc213f3b 100644 --- a/ui/src/ui/control-ui-auth.ts +++ b/ui/src/app/control-ui-auth.ts @@ -1,5 +1,5 @@ // Control UI module implements control ui auth behavior. -import { normalizeOptionalString, uniqueStrings } from "./string-coerce.ts"; +import { normalizeOptionalString, uniqueStrings } from "../lib/string-coerce.ts"; type ControlUiAuthSource = { hello?: { auth?: { deviceToken?: string | null } | null } | null; diff --git a/ui/src/ui/control-ui-chunking.test.ts b/ui/src/app/control-ui-chunking.test.ts similarity index 91% rename from ui/src/ui/control-ui-chunking.test.ts rename to ui/src/app/control-ui-chunking.test.ts index 78c63b52edf0..97967a333d0a 100644 --- a/ui/src/ui/control-ui-chunking.test.ts +++ b/ui/src/app/control-ui-chunking.test.ts @@ -1,4 +1,3 @@ -// Control UI tests cover control ui chunking behavior. import { describe, expect, it } from "vitest"; import { controlUiManualChunk, normalizeModuleId } from "../../config/control-ui-chunking.ts"; @@ -23,7 +22,7 @@ describe("Control UI build chunking", () => { expect(controlUiManualChunk("/tmp/openclaw-pnpm-node-modules/@noble/ed25519/index.js")).toBe( "gateway-runtime", ); - expect(controlUiManualChunk("/repo/ui/src/ui/app-render.ts")).toBeUndefined(); + expect(controlUiManualChunk("/repo/ui/src/app/app-host.ts")).toBeUndefined(); }); it("normalizes Windows module paths before package matching", () => { diff --git a/ui/src/ui/custom-theme.test.ts b/ui/src/app/custom-theme.test.ts similarity index 100% rename from ui/src/ui/custom-theme.test.ts rename to ui/src/app/custom-theme.test.ts diff --git a/ui/src/ui/custom-theme.ts b/ui/src/app/custom-theme.ts similarity index 99% rename from ui/src/ui/custom-theme.ts rename to ui/src/app/custom-theme.ts index 458de75bad3e..3fb796030e3f 100644 --- a/ui/src/ui/custom-theme.ts +++ b/ui/src/app/custom-theme.ts @@ -1,6 +1,6 @@ // Control UI module implements custom theme behavior. import { z } from "zod"; -import { normalizeOptionalString } from "./string-coerce.ts"; +import { normalizeOptionalString } from "../lib/string-coerce.ts"; const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]); const THEME_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; diff --git a/ui/src/ui/controllers/exec-approval.test.ts b/ui/src/app/exec-approval.test.ts similarity index 100% rename from ui/src/ui/controllers/exec-approval.test.ts rename to ui/src/app/exec-approval.test.ts diff --git a/ui/src/ui/controllers/exec-approval.ts b/ui/src/app/exec-approval.ts similarity index 82% rename from ui/src/ui/controllers/exec-approval.ts rename to ui/src/app/exec-approval.ts index 385cb7502056..57da29f45583 100644 --- a/ui/src/ui/controllers/exec-approval.ts +++ b/ui/src/app/exec-approval.ts @@ -1,5 +1,5 @@ -// Control UI controller manages exec approval gateway state. -import { normalizeOptionalString } from "../string-coerce.ts"; +// Application-owned approval parsing and queue state. +import { normalizeOptionalString } from "../lib/string-coerce.ts"; export type ExecApprovalRequestPayload = { command: string; @@ -45,7 +45,9 @@ export type ExecApprovalPromptState = { execApprovalQueue: ExecApprovalRequest[]; execApprovalBusy: boolean; execApprovalError: string | null; - execApprovalRefreshRemovedIds?: Set | null; + execApprovalRefreshes?: Set<{ removedIds: Set }>; + execApprovalExpiryTimers?: Map>; + execApprovalExpired?: () => void; }; const APPROVAL_ALREADY_RESOLVED = "APPROVAL_ALREADY_RESOLVED"; @@ -297,17 +299,40 @@ function mergeRefreshedApprovalQueue( return sortApprovalsNewestFirst([...currentRefreshed, ...arrivedDuringRefresh]); } +function clearApprovalExpiryTimer(state: ExecApprovalPromptState, id: string): void { + const timer = state.execApprovalExpiryTimers?.get(id); + if (timer === undefined) { + return; + } + globalThis.clearTimeout(timer); + state.execApprovalExpiryTimers?.delete(id); +} + function scheduleApprovalExpiryPrune( state: ExecApprovalPromptState, entry: ExecApprovalRequest, ): void { - const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500); - globalThis.setTimeout(() => { - removeExecApprovalFromState(state, entry.id); - }, delay); + clearApprovalExpiryTimer(state, entry.id); + const timer = globalThis.setTimeout( + () => { + const trackedTimer = state.execApprovalExpiryTimers?.get(entry.id); + if (trackedTimer !== undefined && trackedTimer !== timer) { + return; + } + state.execApprovalExpiryTimers?.delete(entry.id); + const hadEntry = state.execApprovalQueue.some((item) => item.id === entry.id); + removeExecApprovalFromState(state, entry.id); + if (hadEntry) { + state.execApprovalExpired?.(); + } + }, + Math.max(0, entry.expiresAtMs - Date.now() + 500), + ); + state.execApprovalExpiryTimers?.set(entry.id, timer); } function removeExecApprovalFromState(state: ExecApprovalPromptState, id: string): void { + clearApprovalExpiryTimer(state, id); const activeId = state.execApprovalQueue[0]?.id ?? null; state.execApprovalQueue = removeExecApproval(state.execApprovalQueue, id); if (activeId !== (state.execApprovalQueue[0]?.id ?? null)) { @@ -324,16 +349,22 @@ export function enqueueExecApprovalPrompt( scheduleApprovalExpiryPrune(state, entry); } -export async function refreshPendingApprovalQueue(state: ExecApprovalPromptState): Promise { +export async function refreshPendingApprovalQueue( + state: ExecApprovalPromptState, + options?: { + isCurrentClient?: (client: NonNullable) => boolean; + }, +): Promise { const client = state.client; if (!client) { - return; + return false; } - const removedDuringRefresh = state.execApprovalRefreshRemovedIds ?? new Set(); - const ownsRemovedSet = !state.execApprovalRefreshRemovedIds; - if (ownsRemovedSet) { - state.execApprovalRefreshRemovedIds = removedDuringRefresh; + if (options?.isCurrentClient && !options.isCurrentClient(client)) { + return false; } + const refresh = { removedIds: new Set() }; + const refreshes = (state.execApprovalRefreshes ??= new Set()); + refreshes.add(refresh); const refreshStartedWith = pruneExecApprovalQueue(state.execApprovalQueue); try { const [execResult, pluginResult] = await Promise.allSettled([ @@ -352,26 +383,41 @@ export async function refreshPendingApprovalQueue(state: ExecApprovalPromptState sortApprovalsNewestFirst([...execApprovals, ...pluginApprovals]), refreshStartedWith, state.execApprovalQueue, - removedDuringRefresh, + refresh.removedIds, ); + if (options?.isCurrentClient && !options.isCurrentClient(client)) { + return false; + } state.execApprovalQueue = refreshed; + const refreshedIds = new Set(refreshed.map((entry) => entry.id)); + for (const id of state.execApprovalExpiryTimers?.keys() ?? []) { + if (!refreshedIds.has(id)) { + clearApprovalExpiryTimer(state, id); + } + } for (const entry of refreshed) { scheduleApprovalExpiryPrune(state, entry); } + return true; } finally { - if (ownsRemovedSet) { - state.execApprovalRefreshRemovedIds = null; + refreshes.delete(refresh); + if (refreshes.size === 0) { + state.execApprovalRefreshes = undefined; } } } export function dismissExecApprovalPrompt(state: ExecApprovalPromptState, id: string): void { removeExecApprovalFromState(state, id); - state.execApprovalRefreshRemovedIds?.add(id); + for (const refresh of state.execApprovalRefreshes ?? []) { + refresh.removedIds.add(id); + } state.execApprovalError = null; } export function clearResolvedExecApprovalPrompt(state: ExecApprovalPromptState, id: string): void { removeExecApprovalFromState(state, id); - state.execApprovalRefreshRemovedIds?.add(id); + for (const refresh of state.execApprovalRefreshes ?? []) { + refresh.removedIds.add(id); + } } diff --git a/ui/src/app/gateway.ts b/ui/src/app/gateway.ts new file mode 100644 index 000000000000..301b327f332a --- /dev/null +++ b/ui/src/app/gateway.ts @@ -0,0 +1,35 @@ +import type { EventLogEntry } from "../api/event-log.ts"; +import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts"; + +export type ApplicationGatewaySnapshot = { + client: GatewayBrowserClient | null; + connected: boolean; + hello: GatewayHelloOk | null; + assistantAgentId: string | null; + sessionKey: string; + lastError: string | null; + lastErrorCode: string | null; +}; + +export type ApplicationGatewayConnection = { + gatewayUrl: string; + token: string; + password: string; +}; + +export type ApplicationGatewayConnectOptions = Partial & { + sessionKey?: string; +}; + +export type ApplicationGateway = { + readonly snapshot: ApplicationGatewaySnapshot; + readonly connection: ApplicationGatewayConnection; + readonly eventLog: readonly EventLogEntry[]; + connect: (connection?: ApplicationGatewayConnectOptions) => void; + setSessionKey: (sessionKey: string) => void; + start: () => void; + stop: () => void; + subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => () => void; + subscribeEventLog: (listener: (events: readonly EventLogEntry[]) => void) => () => void; + subscribeEvents: (listener: GatewayEventListener) => () => void; +}; diff --git a/ui/src/ui/mount-fallback.test.ts b/ui/src/app/mount-fallback.test.ts similarity index 100% rename from ui/src/ui/mount-fallback.test.ts rename to ui/src/app/mount-fallback.test.ts diff --git a/ui/src/app/native-bridge.test.ts b/ui/src/app/native-bridge.test.ts new file mode 100644 index 000000000000..6e33af51db66 --- /dev/null +++ b/ui/src/app/native-bridge.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + handleChatDraftChange as applyDraftChange, + navigateChatInputHistory, + type ChatInputHistoryState, +} from "../pages/chat/input-history.ts"; +import { createNativeChatDrafts, isWebView2, sendToNative } from "./native-bridge.ts"; + +type FakeBridge = { + postMessage: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; + listeners: ((event: MessageEvent) => void)[]; + posted: unknown[]; +}; + +function makeBridge(): FakeBridge { + const listeners: ((event: MessageEvent) => void)[] = []; + const posted: unknown[] = []; + const bridge: FakeBridge = { + posted, + listeners, + postMessage: vi.fn((message: unknown) => posted.push(message)), + addEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => { + listeners.push(listener); + }), + removeEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => { + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + }), + }; + vi.stubGlobal("chrome", { webview: bridge }); + return bridge; +} + +function dispatch(bridge: FakeBridge, data: unknown) { + for (const listener of bridge.listeners) { + listener({ data } as MessageEvent); + } +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("native chat drafts", () => { + it("detects WebView2 and sends native messages", () => { + expect(isWebView2()).toBe(false); + const bridge = makeBridge(); + expect(isWebView2()).toBe(true); + sendToNative({ type: "ready" }); + expect(bridge.posted).toEqual([{ type: "ready" }]); + }); + + it("registers the listener before the ready handshake", () => { + const callOrder: string[] = []; + vi.stubGlobal("chrome", { + webview: { + postMessage: vi.fn(() => callOrder.push("post")), + addEventListener: vi.fn(() => callOrder.push("listen")), + removeEventListener: vi.fn(), + }, + }); + + createNativeChatDrafts(); + + expect(callOrder).toEqual(["listen", "post"]); + }); + + it("delivers drafts and ignores invalid messages", () => { + const bridge = makeBridge(); + const drafts = createNativeChatDrafts(); + const listener = vi.fn(); + drafts.subscribe(listener); + + dispatch(bridge, { type: "draft-text", payload: { text: "hello from native" } }); + dispatch(bridge, { type: "draft-text" }); + dispatch(bridge, { type: "draft-text", payload: { text: 42 } }); + dispatch(bridge, { type: "unknown" }); + dispatch(bridge, null); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith("hello from native"); + }); + + it("removes the native listener and stops delivery on dispose", () => { + const bridge = makeBridge(); + const drafts = createNativeChatDrafts(); + const listener = vi.fn(); + drafts.subscribe(listener); + + drafts.dispose(); + dispatch(bridge, { type: "draft-text", payload: { text: "after cleanup" } }); + + expect(bridge.listeners).toHaveLength(0); + expect(listener).not.toHaveBeenCalled(); + }); + + it("applies native drafts through the real Chat draft owner", () => { + const bridge = makeBridge(); + const state: ChatInputHistoryState = { + sessionKey: "s1", + chatLoading: false, + chatMessage: "", + chatMessages: [], + chatLocalInputHistoryBySession: { s1: [{ text: "previous input", ts: 1 }] }, + chatInputHistorySessionKey: null, + chatInputHistoryItems: null, + chatInputHistoryIndex: -1, + chatDraftBeforeHistory: null, + }; + navigateChatInputHistory(state, "up"); + const drafts = createNativeChatDrafts(); + drafts.subscribe((text) => applyDraftChange(state, text)); + + dispatch(bridge, { type: "draft-text", payload: { text: "native injection" } }); + + expect(state.chatMessage).toBe("native injection"); + expect(state.chatInputHistoryIndex).toBe(-1); + expect(state.chatInputHistoryItems).toBeNull(); + expect(state.chatInputHistorySessionKey).toBeNull(); + }); +}); diff --git a/ui/src/ui/app-native-bridge.ts b/ui/src/app/native-bridge.ts similarity index 55% rename from ui/src/ui/app-native-bridge.ts rename to ui/src/app/native-bridge.ts index f88feb8a71a4..8a25e369c154 100644 --- a/ui/src/ui/app-native-bridge.ts +++ b/ui/src/app/native-bridge.ts @@ -1,4 +1,4 @@ -// Control UI module implements app native bridge behavior. +// Application-owned native draft delivery. type WebView2Bridge = { postMessage(message: unknown): void; addEventListener(type: "message", listener: (event: MessageEvent) => void): void; @@ -9,8 +9,9 @@ export type NativeBridgeMessage = | { type: "draft-text"; payload: { text: string } } | { type: "ready"; payload?: Record }; -export type NativeBridgeHost = { - handleChatDraftChange: (next: string) => void; +export type NativeChatDrafts = { + subscribe: (listener: (draft: string) => void) => () => void; + dispose: () => void; }; function getWebview(): WebView2Bridge | undefined { @@ -26,13 +27,13 @@ export function sendToNative(msg: NativeBridgeMessage): void { getWebview()?.postMessage(msg); } -function handleNativeMessage(host: NativeBridgeHost, raw: unknown): void { +function readNativeDraft(raw: unknown): string | null { if (!raw || typeof raw !== "object") { - return; + return null; } const msg = raw as Record; if (typeof msg.type !== "string") { - return; + return null; } if (msg.type === "draft-text") { const text = @@ -40,32 +41,60 @@ function handleNativeMessage(host: NativeBridgeHost, raw: unknown): void { ? (msg.payload as Record).text : undefined; if (typeof text === "string") { - host.handleChatDraftChange(text); + return text; } } + return null; } /** * Subscribes to WebView2 native messages and sends the ready handshake. * addEventListener is called BEFORE the ready handshake so no messages * are missed between the handshake and the first listen. - * Returns a cleanup function that removes the listener. - * No-op (returns empty cleanup) when not running inside WebView2. + * Drafts received while Chat is not mounted are retained for its next subscriber. */ -export function initNativeBridge(host: NativeBridgeHost): () => void { +export function createNativeChatDrafts(): NativeChatDrafts { const bridge = getWebview(); if (!bridge) { - return () => {}; + return { + subscribe: () => () => {}, + dispose: () => {}, + }; } + let pendingDraft: string | null = null; + const listeners = new Set<(draft: string) => void>(); const handler = (event: MessageEvent) => { - handleNativeMessage(host, event.data); + const draft = readNativeDraft(event.data); + if (draft === null) { + return; + } + if (listeners.size === 0) { + pendingDraft = draft; + return; + } + for (const listener of listeners) { + listener(draft); + } }; bridge.addEventListener("message", handler); sendToNative({ type: "ready" }); - return () => { - bridge.removeEventListener("message", handler); + return { + subscribe(listener) { + listeners.add(listener); + if (pendingDraft !== null) { + const draft = pendingDraft; + pendingDraft = null; + listener(draft); + } + return () => listeners.delete(listener); + }, + dispose() { + listeners.clear(); + pendingDraft = null; + bridge.removeEventListener("message", handler); + }, }; } diff --git a/ui/src/app/operator-access.ts b/ui/src/app/operator-access.ts new file mode 100644 index 000000000000..49e93ec6bf91 --- /dev/null +++ b/ui/src/app/operator-access.ts @@ -0,0 +1,41 @@ +// Control UI app-level operator scope checks. +import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js"; + +export function hasOperatorReadAccess( + auth: { role?: string; scopes?: readonly string[] } | null, +): boolean { + if (!auth?.scopes) { + return false; + } + return roleScopesAllow({ + role: auth.role ?? "operator", + requestedScopes: ["operator.read"], + allowedScopes: auth.scopes, + }); +} + +export function hasOperatorWriteAccess( + auth: { role?: string; scopes?: readonly string[] } | null, +): boolean { + if (!auth?.scopes) { + return true; + } + return roleScopesAllow({ + role: auth.role ?? "operator", + requestedScopes: ["operator.write"], + allowedScopes: auth.scopes, + }); +} + +export function hasOperatorAdminAccess( + auth: { role?: string; scopes?: readonly string[] } | null, +): boolean { + if (!auth?.scopes) { + return true; + } + return roleScopesAllow({ + role: auth.role ?? "operator", + requestedScopes: ["operator.admin"], + allowedScopes: auth.scopes, + }); +} diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts new file mode 100644 index 000000000000..c65333695eaf --- /dev/null +++ b/ui/src/app/overlays.ts @@ -0,0 +1,554 @@ +import { + GATEWAY_EVENT_UPDATE_AVAILABLE, + type GatewayUpdateAvailableEventPayload, +} from "../../../src/gateway/events.js"; +import type { GatewayEventFrame, GatewayHelloOk } from "../api/gateway.ts"; +import type { UpdateAvailable } from "../api/types.ts"; +import { + clearResolvedExecApprovalPrompt, + dismissExecApprovalPrompt, + enqueueExecApprovalPrompt, + isStaleApprovalResolutionError, + parseExecApprovalRequested, + parseExecApprovalResolved, + parsePluginApprovalRequested, + refreshPendingApprovalQueue, + type ExecApprovalDecision, + type ExecApprovalPromptState, + type ExecApprovalRequest, +} from "./exec-approval.ts"; +import type { ApplicationGateway } from "./gateway.ts"; + +export type ApplicationStatusBanner = { + tone: "danger" | "warn" | "info"; + text: string; +}; + +export type ApplicationOverlaySnapshot = { + updateAvailable: UpdateAvailable | null; + updateRunning: boolean; + updateStatusBanner: ApplicationStatusBanner | null; + approvalQueue: readonly ExecApprovalRequest[]; + approvalBusy: boolean; + approvalError: string | null; +}; + +export type ApplicationOverlays = { + readonly snapshot: ApplicationOverlaySnapshot; + subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void; + runUpdate: () => Promise; + dismissUpdate: () => void; + decideApproval: (decision: ExecApprovalDecision) => Promise; + dispose: () => void; +}; + +const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; +const UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending"; +const UPDATE_RESTART_VERIFICATION_POLL_MS = 250; +const UPDATE_RESTART_VERIFICATION_TIMEOUT_MS = 10_000; +const UPDATE_HANDOFF_POLL_MS = 1_000; +const UPDATE_HANDOFF_TIMEOUT_MS = 35 * 60_000; +const PENDING_UPDATE_HANDOFF_REASONS = new Set([ + UPDATE_HANDOFF_STARTED_REASON, + UPDATE_RESTART_HEALTH_PENDING_REASON, +]); + +type UpdateRestartStatusResponse = { + sentinel?: { + kind?: string; + status?: string; + stats?: { + reason?: string | null; + after?: { version?: string | null } | null; + } | null; + } | null; +}; + +function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailable | null { + const snapshot = hello?.snapshot; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + return null; + } + const update = (snapshot as { updateAvailable?: unknown }).updateAvailable; + if (!update || typeof update !== "object" || Array.isArray(update)) { + return null; + } + const value = update as Partial; + return typeof value.currentVersion === "string" && + typeof value.latestVersion === "string" && + typeof value.channel === "string" + ? { + currentVersion: value.currentVersion, + latestVersion: value.latestVersion, + channel: value.channel, + } + : null; +} + +function resolveUpdateStatusBanner(params: { + status?: string; + reason?: string; +}): ApplicationStatusBanner { + const status = (params.status ?? "error").trim() || "error"; + const reason = (params.reason ?? "unexpected-error").trim() || "unexpected-error"; + const guidance = + { + dirty: "Commit or stash changes, then retry.", + "no-upstream": "Set an upstream branch, then retry.", + "not-git-install": + "Not a git checkout. Run `openclaw update` from the CLI for a global reinstall.", + "not-openclaw-root": + "Run the update from an OpenClaw checkout or use the CLI global reinstall path.", + "deps-install-failed": "Dependency install failed. Fix the install error and retry.", + "build-failed": "Build failed. Fix the build error and retry.", + "ui-build-failed": "The control UI rebuild failed. Fix the UI build error and retry.", + "global-install-failed": + "The global package install did not verify on disk. Retry or reinstall from the CLI.", + "restart-disabled": + "The update was not applied because gateway restarts are disabled. Enable restarts in config, then retry.", + "restart-unavailable": + "This global install cannot be safely replaced while restarts are disabled and no supervisor is present.", + "restart-unhealthy": + "The replacement process never became healthy. The previous process stayed up so you can recover.", + "doctor-failed": "Doctor repair failed. Run `openclaw doctor --non-interactive` and retry.", + }[reason] ?? "See the gateway logs for the exact failure and retry once the cause is fixed."; + return { + tone: status === "skipped" ? "warn" : "danger", + text: `Update ${status}: ${reason}. ${guidance}`, + }; +} + +function resolveUpdateVerificationBanner(params: { + expectedVersion: string; + actualVersion: string | null; +}): ApplicationStatusBanner { + const actualSuffix = params.actualVersion + ? ` Expected v${params.expectedVersion}, running v${params.actualVersion}.` + : ""; + return { + tone: "danger", + text: `Update installed but running version did not change — restart may have been blocked.${actualSuffix}`, + }; +} + +function resolvePostRestartUpdateBanner( + reason: string | null | undefined, +): ApplicationStatusBanner { + const normalizedReason = reason?.trim() || "restart-unhealthy"; + const guidance = + normalizedReason === "restart-unhealthy" + ? "The replacement process never became healthy and the previous process stayed up." + : "Check the gateway logs for the replacement failure."; + return { + tone: "danger", + text: `Update error: ${normalizedReason}. ${guidance}`, + }; +} + +function resolvePendingUpdateHandoffTimeoutBanner(): ApplicationStatusBanner { + return { + tone: "danger", + text: "Update handoff started, but completion was not reported after reconnect. Run `openclaw update status` for the final result.", + }; +} + +function isPendingUpdateHandoffSentinel( + sentinel: UpdateRestartStatusResponse["sentinel"], +): boolean { + const reason = sentinel?.stats?.reason; + return ( + sentinel?.kind === "update" && + sentinel.status === "skipped" && + typeof reason === "string" && + PENDING_UPDATE_HANDOFF_REASONS.has(reason) + ); +} + +function isGatewayEvent(value: unknown): value is GatewayEventFrame { + return Boolean(value && typeof value === "object" && "event" in value); +} + +type UpdateRunResponse = { + ok?: boolean; + result?: { + status?: string; + reason?: string; + after?: { version?: string | null } | null; + }; + handoff?: { status?: string }; +}; + +export function createApplicationOverlays(gateway: ApplicationGateway): ApplicationOverlays { + let snapshot: ApplicationOverlaySnapshot = { + updateAvailable: null, + updateRunning: false, + updateStatusBanner: null, + approvalQueue: [], + approvalBusy: false, + approvalError: null, + }; + const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>(); + let disposed = false; + let activeClient = gateway.snapshot.client; + let pendingUpdateExpectedVersion: string | null = null; + let pendingUpdateHandoff = false; + let updateRunGeneration = 0; + let updateVerificationGeneration = 0; + let updateVerificationTimer: ReturnType | null = null; + const promptState: ExecApprovalPromptState = { + client: activeClient, + execApprovalQueue: [], + execApprovalBusy: false, + execApprovalError: null, + execApprovalExpiryTimers: new Map(), + }; + + const publish = () => { + snapshot = { + updateAvailable: snapshot.updateAvailable, + updateRunning: snapshot.updateRunning, + updateStatusBanner: snapshot.updateStatusBanner, + approvalQueue: promptState.execApprovalQueue, + approvalBusy: promptState.execApprovalBusy, + approvalError: promptState.execApprovalError, + }; + for (const listener of listeners) { + listener(snapshot); + } + }; + promptState.execApprovalExpired = publish; + + const refreshApprovals = async (client: NonNullable) => { + const applied = await refreshPendingApprovalQueue(promptState, { + isCurrentClient: (requestClient) => + !disposed && + requestClient === client && + activeClient === client && + gateway.snapshot.client === client && + gateway.snapshot.connected, + }); + if (applied && !disposed) { + publish(); + } + }; + + const publishUpdateBanner = (updateStatusBanner: ApplicationStatusBanner | null) => { + snapshot = { ...snapshot, updateStatusBanner }; + publish(); + }; + + const cancelUpdateVerification = () => { + updateVerificationGeneration += 1; + if (updateVerificationTimer !== null) { + globalThis.clearTimeout(updateVerificationTimer); + updateVerificationTimer = null; + } + }; + + const waitForUpdateVerification = (delayMs: number, generation: number) => + new Promise((resolve) => { + const timer = globalThis.setTimeout(() => { + if (updateVerificationTimer === timer) { + updateVerificationTimer = null; + } + resolve(generation === updateVerificationGeneration && !disposed); + }, delayMs); + updateVerificationTimer = timer; + }); + + const verifyPendingUpdateVersion = async (client: NonNullable) => { + const generation = updateVerificationGeneration; + const expectedVersion = pendingUpdateExpectedVersion?.trim() || null; + const pendingHandoff = pendingUpdateHandoff; + if (!expectedVersion && !pendingHandoff) { + return; + } + const isCurrentVerification = () => + generation === updateVerificationGeneration && + !disposed && + activeClient === client && + gateway.snapshot.client === client && + gateway.snapshot.connected; + const deadline = + Date.now() + + (pendingHandoff ? UPDATE_HANDOFF_TIMEOUT_MS : UPDATE_RESTART_VERIFICATION_TIMEOUT_MS); + const pollMs = pendingHandoff ? UPDATE_HANDOFF_POLL_MS : UPDATE_RESTART_VERIFICATION_POLL_MS; + while (isCurrentVerification() && Date.now() < deadline) { + let response: UpdateRestartStatusResponse | null; + try { + response = await client.request("update.status", {}); + } catch { + response = null; + } + if (!isCurrentVerification()) { + return; + } + const sentinel = response?.sentinel; + if (isPendingUpdateHandoffSentinel(sentinel)) { + if (!(await waitForUpdateVerification(pollMs, generation))) { + return; + } + continue; + } + if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") { + pendingUpdateExpectedVersion = null; + pendingUpdateHandoff = false; + publishUpdateBanner(resolvePostRestartUpdateBanner(sentinel.stats?.reason)); + return; + } + const actualVersion = sentinel?.stats?.after?.version?.trim() || null; + if ( + sentinel?.kind === "update" && + sentinel.status === "ok" && + !actualVersion && + !expectedVersion + ) { + pendingUpdateExpectedVersion = null; + pendingUpdateHandoff = false; + publish(); + return; + } + if (sentinel?.kind === "update" && actualVersion) { + pendingUpdateExpectedVersion = null; + pendingUpdateHandoff = false; + publishUpdateBanner( + expectedVersion && actualVersion !== expectedVersion + ? resolveUpdateVerificationBanner({ expectedVersion, actualVersion }) + : null, + ); + return; + } + if (!(await waitForUpdateVerification(pollMs, generation))) { + return; + } + } + if (!isCurrentVerification()) { + return; + } + const currentVersion = gateway.snapshot.hello?.server?.version?.trim() || null; + pendingUpdateExpectedVersion = null; + pendingUpdateHandoff = false; + publishUpdateBanner( + expectedVersion && currentVersion !== expectedVersion + ? resolveUpdateVerificationBanner({ expectedVersion, actualVersion: currentVersion }) + : pendingHandoff + ? resolvePendingUpdateHandoffTimeoutBanner() + : null, + ); + }; + + const stopGateway = gateway.subscribe((next) => { + updateRunGeneration += 1; + cancelUpdateVerification(); + const previousClient = activeClient; + activeClient = next.client; + promptState.client = next.client; + if (!next.connected || !next.client) { + promptState.execApprovalQueue = []; + promptState.execApprovalBusy = false; + promptState.execApprovalError = null; + snapshot = { ...snapshot, updateAvailable: null, updateRunning: false }; + for (const timer of promptState.execApprovalExpiryTimers?.values() ?? []) { + globalThis.clearTimeout(timer); + } + promptState.execApprovalExpiryTimers?.clear(); + publish(); + return; + } + snapshot = { ...snapshot, updateAvailable: readUpdateAvailable(next.hello) }; + if (previousClient !== next.client) { + void refreshApprovals(next.client); + if (next.client) { + void verifyPendingUpdateVersion(next.client); + } + } else { + publish(); + } + }); + + const stopEvents = gateway.subscribeEvents((event) => { + if (disposed || !isGatewayEvent(event)) { + return; + } + if (event.event === GATEWAY_EVENT_UPDATE_AVAILABLE) { + const payload = event.payload as GatewayUpdateAvailableEventPayload | undefined; + snapshot = { ...snapshot, updateAvailable: payload?.updateAvailable ?? null }; + publish(); + return; + } + if (event.event === "exec.approval.requested") { + const entry = parseExecApprovalRequested(event.payload); + if (entry) { + enqueueExecApprovalPrompt(promptState, entry); + publish(); + } + return; + } + if (event.event === "plugin.approval.requested") { + const entry = parsePluginApprovalRequested(event.payload); + if (entry) { + enqueueExecApprovalPrompt(promptState, entry); + publish(); + } + return; + } + if (event.event === "exec.approval.resolved" || event.event === "plugin.approval.resolved") { + const resolved = parseExecApprovalResolved(event.payload); + if (resolved) { + clearResolvedExecApprovalPrompt(promptState, resolved.id); + publish(); + } + } + }); + + return { + get snapshot() { + return snapshot; + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async runUpdate() { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed || snapshot.updateRunning) { + return; + } + const generation = ++updateRunGeneration; + snapshot = { ...snapshot, updateRunning: true, updateStatusBanner: null }; + publish(); + try { + const response = await client.request("update.run", {}); + if ( + disposed || + generation !== updateRunGeneration || + activeClient !== client || + gateway.snapshot.client !== client + ) { + return; + } + const status = response.result?.status ?? (response.ok === true ? "ok" : "error"); + const expectedVersion = response.result?.after?.version?.trim() || null; + if ( + response.ok === true && + status === "skipped" && + response.result?.reason === UPDATE_HANDOFF_STARTED_REASON && + response.handoff?.status === "started" + ) { + pendingUpdateExpectedVersion = expectedVersion; + pendingUpdateHandoff = true; + return; + } + if (response.ok === true && status === "ok") { + pendingUpdateExpectedVersion = expectedVersion; + pendingUpdateHandoff = false; + return; + } + pendingUpdateExpectedVersion = null; + pendingUpdateHandoff = false; + if (response.ok !== true || status !== "ok") { + snapshot = { + ...snapshot, + updateStatusBanner: resolveUpdateStatusBanner({ + status, + reason: response.result?.reason, + }), + }; + } + } catch (error) { + if ( + disposed || + generation !== updateRunGeneration || + activeClient !== client || + gateway.snapshot.client !== client + ) { + return; + } + snapshot = { + ...snapshot, + updateStatusBanner: { + tone: "danger", + text: `Update error: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } finally { + if ( + !disposed && + generation === updateRunGeneration && + activeClient === client && + gateway.snapshot.client === client + ) { + snapshot = { ...snapshot, updateRunning: false }; + publish(); + } + } + }, + dismissUpdate() { + snapshot = { ...snapshot, updateAvailable: null }; + publish(); + }, + async decideApproval(decision) { + const active = promptState.execApprovalQueue[0]; + const client = gateway.snapshot.client; + if (!active || !client || promptState.execApprovalBusy || disposed) { + return; + } + promptState.execApprovalBusy = true; + promptState.execApprovalError = null; + publish(); + try { + const method = + active.kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; + await client.request(method, { id: active.id, decision }); + if ( + disposed || + activeClient !== client || + gateway.snapshot.client !== client || + !gateway.snapshot.connected + ) { + return; + } + dismissExecApprovalPrompt(promptState, active.id); + } catch (error) { + if (isStaleApprovalResolutionError(error)) { + if ( + disposed || + activeClient !== client || + gateway.snapshot.client !== client || + !gateway.snapshot.connected + ) { + return; + } + dismissExecApprovalPrompt(promptState, active.id); + const currentClient = activeClient; + if ( + currentClient && + gateway.snapshot.client === currentClient && + gateway.snapshot.connected + ) { + await refreshApprovals(currentClient); + } + return; + } + if (promptState.execApprovalQueue.some((entry) => entry.id === active.id)) { + promptState.execApprovalError = `Approval failed: ${error instanceof Error ? error.message : String(error)}`; + } + } finally { + promptState.execApprovalBusy = false; + publish(); + } + }, + dispose() { + disposed = true; + updateRunGeneration += 1; + cancelUpdateVerification(); + stopGateway(); + stopEvents(); + for (const timer of promptState.execApprovalExpiryTimers?.values() ?? []) { + globalThis.clearTimeout(timer); + } + promptState.execApprovalExpiryTimers?.clear(); + listeners.clear(); + }, + }; +} diff --git a/ui/src/ui/public-assets.test.ts b/ui/src/app/public-assets.test.ts similarity index 100% rename from ui/src/ui/public-assets.test.ts rename to ui/src/app/public-assets.test.ts diff --git a/ui/src/ui/public-assets.ts b/ui/src/app/public-assets.ts similarity index 94% rename from ui/src/ui/public-assets.ts rename to ui/src/app/public-assets.ts index 0e0aafeabb85..355777553ef3 100644 --- a/ui/src/ui/public-assets.ts +++ b/ui/src/app/public-assets.ts @@ -1,5 +1,5 @@ // Control UI module implements public assets behavior. -import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; +import { inferBasePathFromPathname, normalizeBasePath } from "../app-route-paths.ts"; export type ControlUiPublicAsset = | "apple-touch-icon.png" diff --git a/ui/src/app/router-outlet.ts b/ui/src/app/router-outlet.ts new file mode 100644 index 000000000000..008321c68a8b --- /dev/null +++ b/ui/src/app/router-outlet.ts @@ -0,0 +1,324 @@ +import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter"; +import { html, LitElement, nothing } from "lit"; +import { AsyncDirective } from "lit/async-directive.js"; +import { property } from "lit/decorators.js"; +import { directive } from "lit/directive.js"; +import { t } from "../i18n/index.ts"; + +const PENDING_UI_DELAY_MS = 1_000; + +type RenderableModule = { + render: (data: TData | undefined) => unknown; +}; + +export type RouterOutletOptions = { + retryContext?: TLoadContext; +}; + +export type RouterOutletBoundaryOptions = { + onNotFound?: () => void; +}; + +export type RouterOutletSelection< + TRouteId extends string = string, + TModule = unknown, + TData = unknown, +> = { + status: RouterState["status"]; + active: RouteMatch | undefined; + pending: RouteMatch | undefined; + showPending: boolean; +}; + +export function selectRenderedRouteMatch( + active: RouteMatch | undefined, + pending: RouteMatch | undefined, +): RouteMatch | undefined { + const coldPending = + pending?.status === "pending" && pending.module === undefined && pending.error === undefined; + return coldPending && active ? active : (pending ?? active); +} + +function selectRouterOutletState( + state: RouterState, +): RouterOutletSelection { + return { + status: state.status, + active: state.matches[0], + pending: state.pendingMatches[0], + showPending: false, + }; +} + +function equalRouterOutletState( + previous: RouterOutletSelection, + next: RouterOutletSelection, +): boolean { + return ( + previous.status === next.status && + previous.active === next.active && + previous.pending === next.pending + ); +} + +function isRenderableModule(module: unknown): module is RenderableModule { + return ( + typeof module === "object" && + module !== null && + "render" in module && + typeof module.render === "function" + ); +} + +function measureRoutedRender(routeId: string, render: () => T): T { + const startedAt = globalThis.performance?.now() ?? 0; + const result = render(); + const durationMs = Math.round((globalThis.performance?.now() ?? startedAt) - startedAt); + if (durationMs >= 16) { + console.debug("[openclaw] routed render", { routeId, durationMs }); + } + return result; +} + +function renderPending() { + return html` +
+
${t("lazyView.loadingTitle")}
+
${t("common.loading")}
+
+ `; +} + +function renderError( + router: Router, + retryContext: TLoadContext | undefined, + error: unknown, + routeId: TRouteId, + render?: () => unknown, +) { + const routeError = error instanceof Error ? error.message : String(error); + return html` + ${render?.() ?? nothing} + + `; +} + +export function renderRouterOutlet( + router: Router, + selection: RouterOutletSelection, + options: RouterOutletOptions = {}, +): unknown { + const pending = selection.pending; + const renderedMatch = selectRenderedRouteMatch(selection.active, pending); + if (renderedMatch?.status === "notFound") { + return nothing; + } + if (renderedMatch?.status === "redirected") { + return nothing; + } + if (!renderedMatch) { + return nothing; + } + + const routeId = renderedMatch.routeId; + if (!renderedMatch?.module) { + return renderedMatch.error + ? renderError( + router, + options.retryContext, + renderedMatch.error, + routeId, + ) + : selection.showPending + ? renderPending() + : nothing; + } + const routeModule = renderedMatch.module; + if (!isRenderableModule(routeModule)) { + return renderedMatch.error + ? renderError( + router, + options.retryContext, + renderedMatch.error, + routeId, + ) + : null; + } + const renderedPage = () => + measureRoutedRender(routeId, () => routeModule.render(renderedMatch.data)); + return renderedMatch.error + ? renderError( + router, + options.retryContext, + renderedMatch.error, + routeId, + renderedPage, + ) + : renderedPage(); +} + +class RouterOutletDirective extends AsyncDirective { + private router?: Router; + private retryContext: unknown; + private unsubscribe?: () => void; + private boundaryOptions?: RouterOutletBoundaryOptions; + private notFoundScheduled = false; + private pendingMatchId?: string; + private pendingTimer?: ReturnType; + private pendingSelection?: RouterOutletSelection; + private showPending = false; + + override render( + router: unknown, + retryContext: unknown, + boundaryOptions: RouterOutletBoundaryOptions, + ) { + const nextRouter = router as Router; + this.updateSubscription(nextRouter); + this.router = nextRouter; + this.retryContext = retryContext; + this.boundaryOptions = boundaryOptions; + return this.renderSelection(selectRouterOutletState(nextRouter.getState())); + } + + override disconnected() { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.clearPendingTimer(); + this.pendingSelection = undefined; + this.boundaryOptions = undefined; + this.retryContext = undefined; + this.notFoundScheduled = false; + } + + override reconnected() { + if (this.router) { + this.updateSubscription(this.router); + } + } + + private updateSubscription(router: Router) { + if (this.router === router && this.unsubscribe) { + return; + } + this.unsubscribe?.(); + this.unsubscribe = router.subscribeSelector( + selectRouterOutletState, + (selection) => { + if (this.isConnected) { + this.setValue(this.renderSelection(selection)); + } + }, + equalRouterOutletState, + ); + } + + private renderSelection(selection: RouterOutletSelection) { + this.pendingSelection = selection; + const pending = selection.pending; + const coldPending = + pending?.status === "pending" && pending.module === undefined && pending.error === undefined; + const needsPendingFallback = coldPending && !selection.active; + if (!needsPendingFallback) { + this.clearPendingTimer(); + this.pendingMatchId = undefined; + this.showPending = false; + } else if (this.pendingMatchId !== pending.id) { + this.clearPendingTimer(); + this.pendingMatchId = pending.id; + this.showPending = false; + this.pendingTimer = globalThis.setTimeout(() => { + this.pendingTimer = undefined; + const pendingSelection = this.pendingSelection; + if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) { + return; + } + this.showPending = true; + this.setValue(this.renderSelection(pendingSelection)); + }, PENDING_UI_DELAY_MS); + } + if (selection.status === "notFound") { + if (!this.notFoundScheduled) { + this.notFoundScheduled = true; + queueMicrotask(() => { + this.notFoundScheduled = false; + this.boundaryOptions?.onNotFound?.(); + }); + } + } else { + this.notFoundScheduled = false; + } + const router = this.router; + if (!router) { + return nothing; + } + return renderRouterOutlet( + router, + { ...selection, showPending: this.showPending }, + { + retryContext: this.retryContext, + }, + ); + } + + private clearPendingTimer() { + if (this.pendingTimer !== undefined) { + globalThis.clearTimeout(this.pendingTimer); + this.pendingTimer = undefined; + } + } +} + +const routerOutletDirective = directive(RouterOutletDirective); + +export function routerOutlet( + router: Router, + boundaryOptions: RouterOutletBoundaryOptions, + options: RouterOutletOptions = {}, +): unknown { + return routerOutletDirective(router, options.retryContext, boundaryOptions); +} + +export class OpenClawRouterOutlet< + TRouteId extends string = string, + TLoadContext = unknown, + TModule = unknown, + TData = unknown, +> extends LitElement { + @property({ attribute: false }) router?: Router; + @property({ attribute: false }) retryContext?: TLoadContext; + @property({ attribute: false }) onNotFound?: () => void; + + override createRenderRoot() { + return this; + } + + override render() { + if (!this.router) { + return nothing; + } + return routerOutlet( + this.router, + { onNotFound: this.onNotFound }, + { + retryContext: this.retryContext, + }, + ); + } +} + +if (!customElements.get("openclaw-router-outlet")) { + customElements.define("openclaw-router-outlet", OpenClawRouterOutlet); +} diff --git a/ui/src/ui/service-worker-cache.test.ts b/ui/src/app/service-worker-cache.test.ts similarity index 100% rename from ui/src/ui/service-worker-cache.test.ts rename to ui/src/app/service-worker-cache.test.ts diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/app/settings.node.test.ts similarity index 98% rename from ui/src/ui/storage.node.test.ts rename to ui/src/app/settings.node.test.ts index f441eb83a1b4..358ac92c1926 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -7,7 +7,7 @@ import { loadSettings, saveLocalUserIdentity, saveSettings, -} from "./storage.ts"; +} from "./settings.ts"; function setTestLocation(params: { protocol: string; host: string; pathname: string }) { vi.stubGlobal("location", { @@ -19,17 +19,18 @@ function setTestLocation(params: { protocol: string; host: string; pathname: str } function setControlUiBasePath(value: string | undefined) { + type TestWindow = Window & typeof globalThis & { [key: string]: unknown }; if (typeof window === "undefined") { vi.stubGlobal( "window", value == null - ? ({} as Window & typeof globalThis) - : ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window & typeof globalThis), + ? ({} as TestWindow) + : ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as unknown as TestWindow), ); return; } if (value == null) { - delete window["__OPENCLAW_CONTROL_UI_BASE_PATH__"]; + delete (window as TestWindow)["__OPENCLAW_CONTROL_UI_BASE_PATH__"]; return; } Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", { diff --git a/ui/src/ui/storage.ts b/ui/src/app/settings.ts similarity index 70% rename from ui/src/ui/storage.ts rename to ui/src/app/settings.ts index 6e571cd8fda1..e70bce7d4d06 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/app/settings.ts @@ -2,11 +2,15 @@ const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:"; const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1"; const LOCAL_USER_IDENTITY_KEY = "openclaw.control.user.v1"; -const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1"; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; const MAX_SCOPED_SESSION_ENTRIES = 10; +type WindowWithControlUiBasePath = Window & + typeof globalThis & { + [key: string]: unknown; + }; + function settingsKeyForGateway(gatewayUrl: string): string { return `${SETTINGS_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; } @@ -23,11 +27,11 @@ type PersistedUiSettings = Omit; }; +import { inferBasePathFromPathname, normalizeBasePath } from "../app-route-paths.ts"; import { isSupportedLocale } from "../i18n/index.ts"; +import { normalizeOptionalString } from "../lib/string-coerce.ts"; import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts"; import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts"; -import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; -import { normalizeOptionalString } from "./string-coerce.ts"; import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts"; import { hasLocalUserIdentity, @@ -103,6 +107,193 @@ export type UiSettings = { export type { LocalUserIdentity } from "./user-identity.ts"; +type LastActiveSessionHost = { + settings: UiSettings; + applySettings(next: UiSettings): void; +}; + +export function setLastActiveSessionKey(host: LastActiveSessionHost, next: string) { + const trimmed = next.trim(); + if (!trimmed || host.settings.lastActiveSessionKey === trimmed) { + return; + } + host.applySettings({ ...host.settings, lastActiveSessionKey: trimmed }); +} + +export type ApplicationStartupLocation = { + pathname: string; + search: string; + hash: string; +}; + +type NativeControlAuth = { + gatewayUrl?: string | null; + token?: string | null; + password?: string | null; +}; + +export type ApplicationStartupSettings = { + settings: UiSettings; + password: string | null; + pendingGatewayUrl: string | null; + pendingGatewayToken: string | null; + queryTokenUsed: boolean; + location: ApplicationStartupLocation; + changed: boolean; +}; + +declare global { + interface Window { + __OPENCLAW_NATIVE_CONTROL_AUTH__?: NativeControlAuth; + } +} + +export function resolveApplicationStartupSettings( + initialSettings: UiSettings, + location: ApplicationStartupLocation, +): ApplicationStartupSettings { + let settings = initialSettings; + let changed = false; + let password: string | null = null; + let pendingGatewayUrl: string | null = null; + let pendingGatewayToken: string | null = null; + let queryTokenUsed = false; + + const updateSettings = (patch: Partial) => { + const entries = Object.entries(patch) as Array< + [keyof UiSettings, UiSettings[keyof UiSettings]] + >; + if (entries.every(([key, value]) => settings[key] === value)) { + return; + } + settings = { ...settings, ...patch }; + changed = true; + }; + + const nativeAuth = + typeof window === "undefined" ? undefined : window["__OPENCLAW_NATIVE_CONTROL_AUTH__"]; + if (nativeAuth) { + try { + delete window["__OPENCLAW_NATIVE_CONTROL_AUTH__"]; + } catch { + window["__OPENCLAW_NATIVE_CONTROL_AUTH__"] = undefined; + } + + const gatewayUrl = normalizeOptionalString(nativeAuth.gatewayUrl); + const token = normalizeOptionalString(nativeAuth.token); + const nativePassword = normalizeOptionalString(nativeAuth.password); + updateSettings({ + ...(gatewayUrl ? { gatewayUrl } : {}), + ...(token ? { token } : {}), + }); + if (nativePassword) { + password = nativePassword; + } + } + + if (!location.search && !location.hash) { + return { + settings, + password, + pendingGatewayUrl, + pendingGatewayToken, + queryTokenUsed, + location, + changed, + }; + } + + const url = new URL( + `${location.pathname}${location.search}${location.hash}`, + "http://openclaw.local", + ); + const params = new URLSearchParams(url.search); + const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); + const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl"); + const nextGatewayUrl = normalizeOptionalString(gatewayUrlRaw) ?? ""; + const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== settings.gatewayUrl); + const queryToken = params.get("token"); + const hashToken = hashParams.get("token"); + const hasTokenParam = hashToken != null || queryToken != null; + const token = normalizeOptionalString(hashToken ?? queryToken); + const session = normalizeOptionalString(params.get("session") ?? hashParams.get("session")); + const shouldResetSessionForToken = Boolean(token && !session && !gatewayUrlChanged); + let shouldCleanUrl = false; + + if (params.has("token")) { + params.delete("token"); + shouldCleanUrl = true; + } + + if (hasTokenParam) { + if (queryToken != null) { + queryTokenUsed = true; + console.warn( + "[openclaw] Auth token passed as query parameter (?token=). Use URL fragment instead: #token=. Query parameters may appear in server logs.", + ); + } + if (token && gatewayUrlChanged) { + pendingGatewayToken = token; + } else if (token) { + updateSettings({ token }); + } + hashParams.delete("token"); + shouldCleanUrl = true; + } + + if (shouldResetSessionForToken) { + updateSettings({ + sessionKey: "main", + lastActiveSessionKey: "main", + }); + } + + if (params.has("password") || hashParams.has("password")) { + params.delete("password"); + hashParams.delete("password"); + shouldCleanUrl = true; + } + + if (session) { + updateSettings({ + sessionKey: session, + lastActiveSessionKey: session, + }); + } + + if (gatewayUrlRaw != null) { + pendingGatewayUrl = gatewayUrlChanged ? nextGatewayUrl : null; + if (!gatewayUrlChanged) { + pendingGatewayToken = null; + } + params.delete("gatewayUrl"); + hashParams.delete("gatewayUrl"); + shouldCleanUrl = true; + } + + if (shouldCleanUrl) { + url.search = params.toString(); + const nextHash = hashParams.toString(); + url.hash = nextHash ? `#${nextHash}` : ""; + } + + return { + settings, + password, + pendingGatewayUrl, + pendingGatewayToken, + queryTokenUsed, + location: shouldCleanUrl + ? { + pathname: url.pathname, + search: url.search, + hash: url.hash, + } + : location, + changed, + }; +} + function isViteDevPage(): boolean { if (typeof document === "undefined") { return false; @@ -119,7 +310,9 @@ function deriveDefaultGatewayUrl(): { pageUrl: string; effectiveUrl: string } { const proto = location.protocol === "https:" ? "wss" : "ws"; const configured = typeof window !== "undefined" && - normalizeOptionalString(window["__OPENCLAW_CONTROL_UI_BASE_PATH__"]); + normalizeOptionalString( + (window as WindowWithControlUiBasePath)["__OPENCLAW_CONTROL_UI_BASE_PATH__"], + ); const basePath = configured ? normalizeBasePath(configured) : inferBasePathFromPathname(location.pathname); @@ -161,7 +354,7 @@ function tokenSessionKeyForGateway(gatewayUrl: string): string { function resolveScopedSessionSelection( gatewayUrl: string, parsed: PersistedUiSettings, - defaults: UiSettings, + fallback: ScopedSessionSelection, ): ScopedSessionSelection { const scope = normalizeGatewayTokenScope(gatewayUrl); const scoped = parsed.sessionsByGateway?.[scope]; @@ -174,11 +367,11 @@ function resolveScopedSessionSelection( }; } - const legacySessionKey = normalizeOptionalString(parsed.sessionKey) ?? defaults.sessionKey; + const legacySessionKey = normalizeOptionalString(parsed.sessionKey) ?? fallback.sessionKey; const legacyLastActiveSessionKey = normalizeOptionalString(parsed.lastActiveSessionKey) ?? legacySessionKey ?? - defaults.lastActiveSessionKey; + fallback.lastActiveSessionKey; return { sessionKey: legacySessionKey, @@ -186,6 +379,20 @@ function resolveScopedSessionSelection( }; } +export function loadGatewaySessionSelection(gatewayUrl: string): ScopedSessionSelection { + const fallback = { sessionKey: "main", lastActiveSessionKey: "main" }; + try { + const storage = getSafeLocalStorage(); + const raw = + storage?.getItem(settingsKeyForGateway(gatewayUrl)) ?? storage?.getItem(LEGACY_SETTINGS_KEY); + return raw + ? resolveScopedSessionSelection(gatewayUrl, JSON.parse(raw) as PersistedUiSettings, fallback) + : fallback; + } catch { + return fallback; + } +} + function loadSessionToken(gatewayUrl: string): string { try { const storage = getSessionStorage(); @@ -277,7 +484,7 @@ export function loadSettings(): UiSettings { (parsed as { theme?: unknown }).theme, (parsed as { themeMode?: unknown }).themeMode, ); - const settings = { + const settings: UiSettings = { gatewayUrl, // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. token: loadSessionToken(gatewayUrl), @@ -341,6 +548,12 @@ export function saveSettings(next: UiSettings) { persistSettings(next); } +export function patchSettings(patch: Partial): UiSettings { + const next = { ...loadSettings(), ...patch }; + persistSettings(next); + return next; +} + export function loadLocalUserIdentity(): LocalUserIdentity { const storage = getSafeLocalStorage(); try { @@ -369,94 +582,6 @@ export function saveLocalUserIdentity(next: LocalUserIdentity) { } } -export type LocalAssistantIdentity = { avatar: string | null; agentId?: string | null }; - -type PersistedLocalAssistantIdentities = { - avatars?: Record; - avatar?: unknown; - agentId?: unknown; -}; - -function parseLocalAssistantAvatarMap(raw: string): { - avatars: Record; - legacyAvatar: string | null; -} { - const parsed = JSON.parse(raw) as PersistedLocalAssistantIdentities; - const avatars = Object.create(null) as Record; - if (parsed.avatars && typeof parsed.avatars === "object" && !Array.isArray(parsed.avatars)) { - for (const [agentId, avatar] of Object.entries(parsed.avatars)) { - const normalizedAgentId = normalizeOptionalString(agentId); - const normalizedAvatar = normalizeOptionalString(avatar); - if (normalizedAgentId && normalizedAvatar) { - avatars[normalizedAgentId] = normalizedAvatar; - } - } - } - const legacyAvatar = normalizeOptionalString(parsed.avatar); - const legacyAgentId = normalizeOptionalString(parsed.agentId); - if (legacyAvatar && legacyAgentId && !Object.hasOwn(avatars, legacyAgentId)) { - avatars[legacyAgentId] = legacyAvatar; - } - return { avatars, legacyAvatar: legacyAgentId ? null : (legacyAvatar ?? null) }; -} - -function persistLocalAssistantAvatarMap(storage: Storage | null, avatars: Record) { - if (Object.keys(avatars).length === 0) { - storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY); - return; - } - storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatars })); -} - -export function loadLocalAssistantIdentity(opts?: { - agentId?: string | null; -}): LocalAssistantIdentity { - const agentId = normalizeOptionalString(opts?.agentId); - if (!agentId) { - return { avatar: null }; - } - const storage = getSafeLocalStorage(); - try { - const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY); - if (!raw) { - return { avatar: null }; - } - const { avatars, legacyAvatar } = parseLocalAssistantAvatarMap(raw); - if (!Object.hasOwn(avatars, agentId) && legacyAvatar) { - // Assign the old global override to the first concrete agent that loads it. - avatars[agentId] = legacyAvatar; - persistLocalAssistantAvatarMap(storage, avatars); - } - return { avatar: Object.hasOwn(avatars, agentId) ? avatars[agentId] : null, agentId }; - } catch { - return { avatar: null }; - } -} - -export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) { - const agentId = normalizeOptionalString(next.agentId); - if (!agentId) { - return; - } - const storage = getSafeLocalStorage(); - try { - const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY); - const avatars = raw - ? parseLocalAssistantAvatarMap(raw).avatars - : (Object.create(null) as Record); - const avatar = normalizeOptionalString(next.avatar); - if (avatar) { - avatars[agentId] = avatar; - } else { - delete avatars[agentId]; - } - persistLocalAssistantAvatarMap(storage, avatars); - } catch { - // best-effort — quota exceeded or security restrictions should not - // prevent in-memory identity updates from being applied - } -} - function persistSettings(next: UiSettings) { persistSessionToken(next.gatewayUrl, next.token); const storage = getSafeLocalStorage(); diff --git a/ui/src/ui/theme-transition.ts b/ui/src/app/theme-transition.ts similarity index 100% rename from ui/src/ui/theme-transition.ts rename to ui/src/app/theme-transition.ts diff --git a/ui/src/ui/theme.test.ts b/ui/src/app/theme.test.ts similarity index 100% rename from ui/src/ui/theme.test.ts rename to ui/src/app/theme.test.ts diff --git a/ui/src/ui/theme.ts b/ui/src/app/theme.ts similarity index 100% rename from ui/src/ui/theme.ts rename to ui/src/app/theme.ts diff --git a/ui/src/ui/user-identity.test.ts b/ui/src/app/user-identity.test.ts similarity index 100% rename from ui/src/ui/user-identity.test.ts rename to ui/src/app/user-identity.test.ts diff --git a/ui/src/ui/user-identity.ts b/ui/src/app/user-identity.ts similarity index 92% rename from ui/src/ui/user-identity.ts rename to ui/src/app/user-identity.ts index 315836097bbb..dfce21a561a9 100644 --- a/ui/src/ui/user-identity.ts +++ b/ui/src/app/user-identity.ts @@ -1,10 +1,7 @@ // Control UI module implements user identity behavior. import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js"; -import { normalizeOptionalString } from "./string-coerce.ts"; -import { - isRenderableControlUiAvatarUrl, - resolveChatAvatarRenderUrl, -} from "./views/agents-utils.ts"; +import { isRenderableControlUiAvatarUrl, resolveChatAvatarRenderUrl } from "../lib/avatar.ts"; +import { normalizeOptionalString } from "../lib/string-coerce.ts"; const MAX_LOCAL_USER_NAME = 50; const MAX_LOCAL_USER_TEXT_AVATAR = 16; diff --git a/ui/src/ui/control-ui-vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts similarity index 95% rename from ui/src/ui/control-ui-vite-config.node.test.ts rename to ui/src/app/vite-config.node.test.ts index 50281c45108e..94022e0e909c 100644 --- a/ui/src/ui/control-ui-vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -1,4 +1,3 @@ -// Control UI tests cover control ui vite config behavior. import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; @@ -77,7 +76,7 @@ describe("Control UI Vite config", () => { { custom: {}, isEntry: false, ssr: false }, ); - expect(resolved).toBe(path.join(repoRoot, "ui/src/ui/browser-redact.ts")); + expect(resolved).toBe(path.join(repoRoot, "ui/src/lib/browser-redact.ts")); } }); }); diff --git a/ui/src/app/web-push.runtime.ts b/ui/src/app/web-push.runtime.ts new file mode 100644 index 000000000000..ee86b44ec33a --- /dev/null +++ b/ui/src/app/web-push.runtime.ts @@ -0,0 +1,93 @@ +import type { GatewayBrowserClient } from "../api/gateway.ts"; + +const SW_READY_TIMEOUT = 10_000; + +function swReady(): Promise { + return Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => { + setTimeout(() => reject(new Error("Service worker not ready (timed out)")), SW_READY_TIMEOUT); + }), + ]); +} + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + const output = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) { + output[i] = raw.charCodeAt(i); + } + return output; +} + +export async function getExistingSubscription(): Promise { + if (!("serviceWorker" in navigator)) { + return null; + } + const registration = await swReady(); + return await registration.pushManager.getSubscription(); +} + +export async function subscribeToWebPush( + client: GatewayBrowserClient, +): Promise<{ subscriptionId: string }> { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + throw new Error(`Notification permission ${permission}`); + } + + const vapidRes = await client.request("push.web.vapidPublicKey", {}); + const vapidPublicKey = (vapidRes as { vapidPublicKey: string }).vapidPublicKey; + if (!vapidPublicKey) { + throw new Error("Failed to retrieve VAPID public key"); + } + + const registration = await swReady(); + const pushSubscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer, + }); + const subscription = pushSubscription.toJSON(); + if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys.auth) { + throw new Error("Invalid push subscription from browser"); + } + + try { + return (await client.request("push.web.subscribe", { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + }, + })) as { subscriptionId: string }; + } catch (error) { + try { + await pushSubscription.unsubscribe(); + } catch { + // The Gateway error remains the actionable failure. + } + throw error; + } +} + +export async function unsubscribeFromWebPush(client: GatewayBrowserClient): Promise { + const registration = await swReady(); + const subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + return; + } + try { + await client.request("push.web.unsubscribe", { + endpoint: subscription.endpoint, + }); + } catch { + // Local unsubscribe still prevents a stale browser subscription. + } + await subscription.unsubscribe(); +} + +export async function sendTestWebPush(client: GatewayBrowserClient): Promise { + await client.request("push.web.test", {}); +} diff --git a/ui/src/app/web-push.ts b/ui/src/app/web-push.ts new file mode 100644 index 000000000000..15f1c63f374b --- /dev/null +++ b/ui/src/app/web-push.ts @@ -0,0 +1,147 @@ +// Application-owned browser push subscription lifecycle. +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { ApplicationGateway } from "./gateway.ts"; + +export type WebPushSnapshot = { + supported: boolean; + permission: NotificationPermission | "unsupported"; + subscribed: boolean; + loading: boolean; + error: string | null; +}; + +export type WebPushCapability = { + readonly snapshot: WebPushSnapshot; + subscribe: (listener: (snapshot: WebPushSnapshot) => void) => () => void; + enable: () => Promise; + disable: () => Promise; + sendTest: () => Promise; + dispose: () => void; +}; + +function isWebPushSupported(): boolean { + return ( + typeof navigator !== "undefined" && + "serviceWorker" in navigator && + typeof window !== "undefined" && + "PushManager" in window && + "Notification" in window + ); +} + +function webPushError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createWebPushCapability(gateway: ApplicationGateway): WebPushCapability { + const supported = isWebPushSupported(); + let snapshot: WebPushSnapshot = { + supported, + permission: supported ? Notification.permission : "unsupported", + subscribed: false, + loading: false, + error: null, + }; + let disposed = false; + let wasConnected = false; + let operation: Promise | null = null; + const listeners = new Set<(snapshot: WebPushSnapshot) => void>(); + + const publish = (patch: Partial) => { + if (disposed) { + return; + } + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) { + listener(snapshot); + } + }; + + const readExistingSubscription = async () => { + if (!supported) { + return null; + } + const { getExistingSubscription } = await import("./web-push.runtime.ts"); + const subscription = await getExistingSubscription(); + publish({ subscribed: subscription !== null }); + return subscription; + }; + + const reconcile = async (client: GatewayBrowserClient) => { + try { + const subscription = await readExistingSubscription(); + const json = subscription?.toJSON(); + if (!json?.endpoint || !json.keys?.p256dh || !json.keys.auth) { + return; + } + await client.request("push.web.subscribe", { + endpoint: json.endpoint, + keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, + }); + } catch { + // Existing subscriptions are reconciled best-effort after reconnect. + } + }; + + const run = (action: (client: GatewayBrowserClient) => Promise) => { + const client = gateway.snapshot.client; + if (!supported || !client || operation) { + return operation ?? Promise.resolve(); + } + publish({ loading: true, error: null }); + operation = action(client) + .catch((error: unknown) => { + publish({ error: webPushError(error) }); + }) + .finally(() => { + operation = null; + publish({ + loading: false, + permission: "Notification" in window ? Notification.permission : "unsupported", + }); + }); + return operation; + }; + + void readExistingSubscription().catch(() => {}); + const stopGateway = gateway.subscribe((gatewaySnapshot) => { + const client = gatewaySnapshot.client; + const connected = gatewaySnapshot.connected && client !== null; + if (connected && !wasConnected && client) { + void reconcile(client); + } + wasConnected = connected; + }); + + return { + get snapshot() { + return snapshot; + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + enable: () => + run(async (client) => { + const { subscribeToWebPush } = await import("./web-push.runtime.ts"); + await subscribeToWebPush(client); + publish({ subscribed: true }); + }), + disable: () => + run(async (client) => { + const { unsubscribeFromWebPush } = await import("./web-push.runtime.ts"); + await unsubscribeFromWebPush(client); + publish({ subscribed: false }); + }), + sendTest: () => + run(async (client) => { + const { sendTestWebPush } = await import("./web-push.runtime.ts"); + await sendTestWebPush(client); + }), + dispose() { + disposed = true; + stopGateway(); + listeners.clear(); + }, + }; +} diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts new file mode 100644 index 000000000000..06ed2ed9ffd0 --- /dev/null +++ b/ui/src/components/app-sidebar.ts @@ -0,0 +1,808 @@ +import { consume } from "@lit/context"; +import { LitElement, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { ModelAuthStatusResult, SessionsListResult } from "../api/types.ts"; +import { + cancelRoutePreload, + isSettingsNavigationRoute, + navigationIconForRoute, + scheduleRoutePreload, + type NavigationRouteId, + SIDEBAR_SECTIONS, + titleForRoute, +} from "../app-navigation.ts"; +import { pathForRoute, type RouteId } from "../app-route-paths.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationNavigationOptions, +} from "../app/context.ts"; +import { controlUiPublicAssetPath } from "../app/public-assets.ts"; +import "./theme-mode-toggle.ts"; +import "./session-picker.ts"; +import "./tooltip.ts"; +import type { ThemeMode } from "../app/theme.ts"; +import { t } from "../i18n/index.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts"; +import { formatRelativeTimestamp } from "../lib/format.ts"; +import { resolveSessionDisplayName } from "../lib/session-display.ts"; +import { + compareSessionRowsByUpdatedAt, + resolveSessionNavigation, + searchForSession, +} from "../lib/sessions/index.ts"; +import { + buildAgentMainSessionKey, + canArchiveSessionRow, + normalizeAgentId, + parseAgentSessionKey, + resolveUiConfiguredMainKey, +} from "../lib/sessions/session-key.ts"; +import { + resolvePreferredSessionForAgent, + resolveSessionAgentFilterOptions, +} from "../lib/sessions/session-options.ts"; +import { icons } from "./icons.ts"; + +type ProviderQuotaPillRenderer = typeof import("./provider-quota-pill.ts").renderProviderQuotaPill; + +type SidebarRecentSession = { + key: string; + label: string; + meta: string; + href: string; + active: boolean; + hasActiveRun: boolean; + kind?: string; + pinned: boolean; + pinnedAt?: number | null; +}; + +function shouldHandleNavigationClick(event: MouseEvent): boolean { + return ( + !event.defaultPrevented && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); +} + +export class AppSidebar extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) basePath = ""; + @property({ attribute: false }) activeRouteId?: NavigationRouteId; + @property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[]; + @property({ attribute: false }) collapsed = false; + @property({ attribute: false }) connected = false; + @property({ attribute: false }) sessionKey = ""; + @property({ attribute: false }) navGroupsCollapsed: Record = {}; + @property({ attribute: false }) recentSessionsCollapsed = false; + @property({ attribute: false }) themeMode: ThemeMode = "system"; + @property({ attribute: false }) onToggleCollapsed?: () => void; + @property({ attribute: false }) onToggleGroup?: (label: string) => void; + @property({ attribute: false }) onToggleRecentSessions?: () => void; + @property({ attribute: false }) + onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void; + @property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise; + + @consume({ context: applicationContext, subscribe: false }) + private context?: ApplicationContext; + @state() private sessionsResult: SessionsListResult | null = null; + @state() private sessionsAgentId: string | null = null; + @state() private sessionsLoading = false; + @state() private modelAuthStatusResult: ModelAuthStatusResult | null = null; + @state() private providerQuotaPillRenderer: ProviderQuotaPillRenderer | null = null; + + private stopSessionsSubscription: (() => void) | undefined; + private stopAgentsSubscription: (() => void) | undefined; + private stopAgentSelectionSubscription: (() => void) | undefined; + private stopGatewaySubscription: (() => void) | undefined; + private sessionRowsByAgent: Record = {}; + private modelAuthClient: GatewayBrowserClient | null = null; + private readonly routePreloadTimers = new Map< + EventTarget, + ReturnType + >(); + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + this.startSubscriptions(); + } + + override disconnectedCallback() { + this.stopSessionsSubscription?.(); + this.stopSessionsSubscription = undefined; + this.stopAgentsSubscription?.(); + this.stopAgentsSubscription = undefined; + this.stopAgentSelectionSubscription?.(); + this.stopAgentSelectionSubscription = undefined; + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.modelAuthClient = null; + for (const timer of this.routePreloadTimers.values()) { + globalThis.clearTimeout(timer); + } + this.routePreloadTimers.clear(); + super.disconnectedCallback(); + } + + private startSubscriptions() { + const context = this.context; + if ( + !context || + this.stopSessionsSubscription || + this.stopAgentsSubscription || + this.stopAgentSelectionSubscription || + this.stopGatewaySubscription + ) { + return; + } + this.updateModelAuthStatus(context.gateway.snapshot); + this.updateSessions(context.sessions.state); + this.stopSessionsSubscription = context.sessions.subscribe((snapshot) => { + this.updateSessions(snapshot); + }); + this.stopAgentsSubscription = context.agents.subscribe(() => { + this.requestUpdate(); + }); + this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => { + this.requestUpdate(); + }); + this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { + this.updateModelAuthStatus(snapshot); + this.requestUpdate(); + }); + } + + override updated() { + this.startSubscriptions(); + } + + private readonly updateSessions = (snapshot: { + result: SessionsListResult | null; + agentId: string | null; + loading: boolean; + }) => { + this.sessionsResult = snapshot.result; + this.sessionsAgentId = snapshot.agentId; + this.sessionsLoading = snapshot.loading; + if (snapshot.result && snapshot.agentId) { + this.sessionRowsByAgent[normalizeAgentId(snapshot.agentId)] = snapshot.result.sessions; + } + }; + + private updateModelAuthStatus(snapshot: { + client: GatewayBrowserClient | null; + connected: boolean; + }) { + const client = snapshot.connected ? snapshot.client : null; + if (client === this.modelAuthClient) { + return; + } + this.sessionRowsByAgent = {}; + this.modelAuthClient = client; + this.modelAuthStatusResult = null; + if (!client) { + return; + } + void import("../lib/model-auth.ts") + .then(async ({ isMonitoredAuthProvider, loadModelAuthStatus }) => { + const result = await loadModelAuthStatus(client); + if (this.modelAuthClient !== client) { + return; + } + this.modelAuthStatusResult = result; + const hasQuota = result.providers.some( + (provider) => + isMonitoredAuthProvider(provider) && Boolean(provider.usage?.windows?.length), + ); + if (!hasQuota || this.providerQuotaPillRenderer) { + return; + } + const { renderProviderQuotaPill } = await import("./provider-quota-pill.ts"); + if (this.modelAuthClient === client) { + this.providerQuotaPillRenderer = renderProviderQuotaPill; + } + }) + .catch(() => { + if (this.modelAuthClient === client) { + this.modelAuthStatusResult = null; + } + }); + } + + private getRouteSessionKey(): string { + return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || ""; + } + + private getSessionNavigationState() { + const context = this.context; + const routeSessionKey = this.getRouteSessionKey(); + const navigation = resolveSessionNavigation({ + result: this.sessionsResult, + resultAgentId: this.sessionsAgentId, + sessionKey: routeSessionKey, + assistantAgentId: + context?.agentSelection.state.selectedId ?? context?.gateway.snapshot.assistantAgentId, + hello: context?.gateway.snapshot.hello, + }); + const toSidebarSession = (row: SessionsListResult["sessions"][number]) => ({ + key: row.key, + label: resolveSessionDisplayName(row.key, row), + meta: row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "", + href: `${pathForRoute("chat", context?.basePath ?? "")}${searchForSession(row.key)}`, + active: row.key === navigation.currentSessionKey, + hasActiveRun: Boolean(row.hasActiveRun), + kind: row.kind, + pinned: row.pinned === true, + pinnedAt: row.pinnedAt, + }); + const activeSession = navigation.selectedSession + ? toSidebarSession(navigation.selectedSession) + : null; + const recentSessions = navigation.recentSessions + .slice(activeSession ? 1 : 0) + .toSorted(compareSessionRowsByUpdatedAt) + .map(toSidebarSession); + const newSessionDisabled = + !this.connected || this.sessionsLoading || Boolean(navigation.selectedSession?.hasActiveRun); + return { + routeSessionKey: navigation.currentSessionKey, + selectedAgentId: navigation.selectedAgentId, + defaultAgentId: navigation.defaultAgentId, + activeSession, + recentSessions, + newSessionDisabled, + newSessionTitle: !this.connected + ? "Connect to create a new session" + : navigation.selectedSession?.hasActiveRun + ? "Finish the active run before creating a new session" + : "New session", + }; + } + + private readonly selectSession = (sessionKey: string) => { + this.context?.gateway.setSessionKey(sessionKey); + this.onNavigate?.("chat", { + search: searchForSession(sessionKey), + }); + }; + + private readonly replaceCurrentSession = (sessionKey: string) => { + this.context?.gateway.setSessionKey(sessionKey); + if (this.activeRouteId === "chat") { + this.onNavigate?.("chat", { + search: searchForSession(sessionKey), + }); + } + }; + + private readonly selectAgent = (agentId: string) => { + const context = this.context; + if (!context) { + return; + } + const { routeSessionKey, selectedAgentId } = this.getSessionNavigationState(); + const nextAgentId = normalizeAgentId(agentId); + if (nextAgentId === normalizeAgentId(selectedAgentId)) { + return; + } + const nextSessionKey = resolvePreferredSessionForAgent( + { + agentsList: context.agents.state.agentsList, + chatAgentSessionRowsByAgent: this.sessionRowsByAgent, + sessionsResult: this.sessionsResult, + sessionsResultAgentId: this.sessionsAgentId, + sessionKey: routeSessionKey, + }, + nextAgentId, + ); + context.agentSelection.set(nextAgentId); + this.selectSession(nextSessionKey); + }; + + private readonly createSession = async () => { + const context = this.context; + if (!context) { + return; + } + const { routeSessionKey, selectedAgentId, newSessionDisabled } = + this.getSessionNavigationState(); + if (newSessionDisabled) { + return; + } + const nextSessionKey = await context.sessions.create({ + currentSessionKey: routeSessionKey, + agentId: selectedAgentId, + }); + if (nextSessionKey) { + this.selectSession(nextSessionKey); + } + }; + + private readonly patchSession = async ( + session: SidebarRecentSession, + patch: { archived?: boolean; pinned?: boolean }, + ) => { + const context = this.context; + if (!context || !this.connected) { + return; + } + const { selectedAgentId } = this.getSessionNavigationState(); + const agentId = parseAgentSessionKey(session.key)?.agentId; + try { + const patched = await context.sessions.patch(session.key, patch, agentId ? { agentId } : {}); + if (!patched || patch.archived !== true || !session.active) { + return; + } + this.replaceCurrentSession( + buildAgentMainSessionKey({ + agentId: agentId ?? selectedAgentId, + mainKey: resolveUiConfiguredMainKey({ + agentsList: context.agents.state.agentsList, + hello: context.gateway.snapshot.hello, + }), + }), + ); + } catch { + // Session capability publishes the actionable error for the owning page. + } + }; + + private preloadRoute(routeId: NavigationRouteId, event: Event, immediate = false) { + scheduleRoutePreload( + this.routePreloadTimers, + routeId, + event, + (nextRouteId) => this.onPreloadRoute?.(nextRouteId), + routeId === this.activeRouteId || !this.isRouteEnabled(routeId), + immediate, + ); + } + + private readonly cancelPreload = (event: Event) => { + cancelRoutePreload(this.routePreloadTimers, event); + }; + + private isRouteEnabled(routeId: NavigationRouteId): boolean { + return this.enabledRouteIds?.includes(routeId) ?? true; + } + + private renderRoute(routeId: NavigationRouteId) { + const active = + routeId === "config" + ? this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId) + : this.activeRouteId === routeId; + const enabled = this.isRouteEnabled(routeId); + if (!enabled) { + return html` + + + ${!this.collapsed + ? html`${titleForRoute(routeId)}` + : nothing} + + `; + } + const routeSessionKey = routeId === "chat" ? this.getRouteSessionKey() : ""; + const href = + routeSessionKey && routeId === "chat" + ? `${pathForRoute("chat", this.basePath)}${searchForSession(routeSessionKey)}` + : pathForRoute(routeId, this.basePath); + const label = titleForRoute(routeId); + const link = html` + this.preloadRoute(routeId, event)} + @blur=${this.cancelPreload} + @pointerenter=${(event: Event) => this.preloadRoute(routeId, event)} + @pointerleave=${this.cancelPreload} + @touchstart=${(event: TouchEvent) => this.preloadRoute(routeId, event, true)} + @click=${(event: MouseEvent) => { + if (!shouldHandleNavigationClick(event)) { + return; + } + event.preventDefault(); + this.onNavigate?.( + routeId, + routeId === "chat" && routeSessionKey + ? { + search: searchForSession(routeSessionKey), + } + : undefined, + ); + }} + > + + ${!this.collapsed ? html`${label}` : nothing} + + `; + return this.collapsed + ? html`${link}` + : link; + } + + private renderRecentSession(session: SidebarRecentSession) { + const context = this.context; + const archiveAllowed = canArchiveSessionRow( + session, + resolveUiConfiguredMainKey({ + agentsList: context?.agents.state.agentsList, + hello: context?.gateway.snapshot.hello, + }), + ); + const rowClass = [ + "sidebar-recent-session", + "session-row-host", + session.active ? "sidebar-recent-session--active" : "", + session.pinned ? "session-row-host--pinned" : "", + session.hasActiveRun ? "session-row-host--running" : "", + ] + .filter(Boolean) + .join(" "); + return html` +
+ { + if (!shouldHandleNavigationClick(event)) { + return; + } + event.preventDefault(); + this.selectSession(session.key); + }} + > + ${session.label} + + + + ${session.hasActiveRun + ? html`` + : session.meta} + + + + + + +
+ `; + } + + private renderSessions() { + const context = this.context; + const { + routeSessionKey, + selectedAgentId, + defaultAgentId, + activeSession, + recentSessions, + newSessionDisabled, + newSessionTitle, + } = this.getSessionNavigationState(); + const newSessionButton = html` + + `; + return html` + + `; + } + + private renderAgentFilter(sessionKey: string, selectedAgentId: string) { + const options = resolveSessionAgentFilterOptions({ + agentsList: this.context?.agents.state.agentsList, + sessionsResult: this.sessionsResult, + sessionsResultAgentId: this.sessionsAgentId, + sessionKey, + }); + if (options.length <= 1) { + return nothing; + } + const selectedLabel = + options.find((option) => option.id === selectedAgentId)?.label ?? selectedAgentId; + return html` + + `; + } + + private renderChatFallback() { + return html` + { + if (!shouldHandleNavigationClick(event)) { + return; + } + event.preventDefault(); + this.onNavigate?.("chat"); + }} + > + + ${t("nav.chat")} + + + `; + } + + override render() { + const gatewayStatus = t("chat.gatewayStatus", { + status: this.connected ? t("common.online") : t("common.offline"), + }); + const quotaPill = this.collapsed + ? "" + : (this.providerQuotaPillRenderer?.({ + basePath: this.basePath, + modelAuthStatusResult: this.modelAuthStatusResult, + }) ?? ""); + return html` + + `; + } +} + +if (!customElements.get("openclaw-app-sidebar")) { + customElements.define("openclaw-app-sidebar", AppSidebar); +} diff --git a/ui/src/components/app-topbar.ts b/ui/src/components/app-topbar.ts new file mode 100644 index 000000000000..f20c4f9ea830 --- /dev/null +++ b/ui/src/components/app-topbar.ts @@ -0,0 +1,113 @@ +import { LitElement, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import type { NavigationRouteId } from "../app-navigation.ts"; +import type { ThemeMode } from "../app/theme.ts"; +import "./dashboard-header.ts"; +import "./theme-mode-toggle.ts"; +import "./tooltip.ts"; +import { t } from "../i18n/index.ts"; +import { icons } from "./icons.ts"; + +export class AppTopbar extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) routeId?: NavigationRouteId; + @property({ attribute: false }) basePath = ""; + @property({ attribute: false }) agentLabel = ""; + @property({ attribute: false }) navDrawerOpen = false; + @property({ attribute: false }) onboarding = false; + @property({ attribute: false }) routeOwnsHeader = false; + @property({ attribute: false }) headerError: string | null = null; + @property({ attribute: false }) themeMode: ThemeMode = "system"; + @property({ attribute: false }) onToggleDrawer?: (trigger: HTMLElement) => void; + @property({ attribute: false }) onOpenPalette?: () => void; + @property({ attribute: false }) onToggleTerminal?: () => void; + @property({ attribute: false }) onNavigate?: (routeId: NavigationRouteId) => void; + @property({ attribute: false }) overviewHref = ""; + @property({ attribute: false }) searchDisabled = false; + @property({ attribute: false }) terminalAvailable = false; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + } + + private readonly handleNavigate = (event: CustomEvent) => { + this.onNavigate?.(event.detail); + }; + + override render() { + const drawerLabel = this.navDrawerOpen ? t("nav.collapse") : t("nav.expand"); + const paletteLabel = t("chat.commandPaletteTitle"); + return html` +
+
+ + + +
+ +
+
+ + + + ${this.terminalAvailable + ? html` + + + + ` + : nothing} +
+ ${this.routeOwnsHeader && this.headerError + ? html`
${this.headerError}
` + : nothing} + +
+
+
+
+ `; + } +} + +if (!customElements.get("openclaw-app-topbar")) { + customElements.define("openclaw-app-topbar", AppTopbar); +} diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/components/command-palette.ts similarity index 75% rename from ui/src/ui/views/command-palette.ts rename to ui/src/components/command-palette.ts index eb266f8b6956..137a57398b10 100644 --- a/ui/src/ui/views/command-palette.ts +++ b/ui/src/components/command-palette.ts @@ -1,10 +1,11 @@ -// Control UI view renders command palette screen content. -import { html, nothing } from "lit"; +// Control UI component renders the command palette. +import { LitElement, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; import { ref } from "lit/directives/ref.js"; -import { t } from "../../i18n/index.ts"; -import { SLASH_COMMANDS } from "../chat/slash-commands.ts"; -import { icons, type IconName } from "../icons.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +import type { RouteId } from "../app-route-paths.ts"; +import { t } from "../i18n/index.ts"; +import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; +import { icons, type IconName } from "./icons.ts"; type PaletteItem = { id: string; @@ -15,16 +16,12 @@ type PaletteItem = { description?: string; }; -function buildSlashPaletteItems(): PaletteItem[] { - return SLASH_COMMANDS.map((command) => ({ - id: `slash:${command.name}`, - label: `/${command.name}`, - icon: command.icon ?? "terminal", - category: "search", - action: `/${command.name}`, - description: command.description, - })); -} +export const COMMAND_PALETTE_TARGET_EVENT = "openclaw-command-palette-target"; + +export type CommandPaletteTargetDetail = { + owner: Element; + onSlashCommand: ((command: string) => void) | null; +}; function getPaletteBaseItems(): PaletteItem[] { return [ @@ -71,46 +68,39 @@ function getPaletteBaseItems(): PaletteItem[] { action: "nav:agents", }, { - id: "skill-shell", - label: t("overview.palette.items.shellCommand"), - icon: "monitor", - category: "skills", - action: "/skill shell", - description: t("overview.palette.descriptions.shellCommand"), - }, - { - id: "skill-debug", - label: t("overview.palette.items.debugMode"), - icon: "bug", - category: "skills", + id: "slash:verbose", + label: "/verbose", + icon: "terminal", + category: "search", action: "/verbose full", - description: t("overview.palette.descriptions.debugMode"), + description: "Toggle verbose mode.", }, ]; } function getPaletteItemsInternal(): PaletteItem[] { - return [...buildSlashPaletteItems(), ...getPaletteBaseItems()]; + return getPaletteBaseItems(); } export function getPaletteItems(): readonly PaletteItem[] { return getPaletteItemsInternal(); } -export type CommandPaletteProps = { +type CommandPaletteProps = { open: boolean; query: string; activeIndex: number; - onOpen?: () => void | Promise; onToggle: () => void; onQueryChange: (query: string) => void; onActiveIndexChange: (index: number) => void; - onNavigate: (tab: string) => void; - onSlashCommand: (command: string) => void; + onNavigate: (routeId: RouteId) => void; + onSlashCommand?: (command: string) => void; }; -function filteredItems(query: string): PaletteItem[] { - const items = getPaletteItemsInternal(); +function filteredItems(query: string, includeSlashCommands = true): PaletteItem[] { + const items = getPaletteItemsInternal().filter( + (item) => includeSlashCommands || item.category !== "search", + ); if (!query) { return items; } @@ -138,7 +128,6 @@ function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> { let previouslyFocused: Element | null = null; let activeDialog: HTMLDialogElement | null = null; -let activeProps: CommandPaletteProps | null = null; const FOCUSABLE_SELECTOR = [ "a[href]", @@ -176,9 +165,9 @@ function restoreFocus() { function selectItem(item: PaletteItem, props: CommandPaletteProps) { if (item.action.startsWith("nav:")) { - props.onNavigate(item.action.slice(4)); + props.onNavigate(item.action.slice(4) as RouteId); } else { - props.onSlashCommand(item.action); + props.onSlashCommand?.(item.action); } props.onToggle(); restoreFocus(); @@ -234,7 +223,7 @@ function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) { return; } - const items = filteredItems(props.query); + const items = filteredItems(props.query, Boolean(props.onSlashCommand)); if (items.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter")) { return; } @@ -290,7 +279,6 @@ function syncDialog(el: Element | undefined) { if (activeDialog !== el) { saveFocus(); activeDialog = el; - void activeProps?.onOpen?.(); } if (el.open) { return; @@ -318,13 +306,11 @@ function focusInput(el: Element | undefined) { } } -export function renderCommandPalette(props: CommandPaletteProps) { +function renderCommandPalette(props: CommandPaletteProps) { if (!props.open) { return nothing; } - activeProps = props; - - const items = filteredItems(props.query); + const items = filteredItems(props.query, Boolean(props.onSlashCommand)); const grouped = groupItems(items); const activeItem = items[props.activeIndex]; const activeOptionId = activeItem ? getOptionId(activeItem) : nothing; @@ -417,3 +403,79 @@ export function renderCommandPalette(props: CommandPaletteProps) { `; } + +export class CommandPalette extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) onNavigate?: (routeId: RouteId) => void; + @property({ attribute: false }) onSlashCommand?: (command: string) => void; + @state() private open = false; + @state() private query = ""; + @state() private activeIndex = 0; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + document.addEventListener("keydown", this.handleGlobalKeydown); + } + + override disconnectedCallback() { + document.removeEventListener("keydown", this.handleGlobalKeydown); + if (activeDialog) { + activeDialog.close(); + restoreFocus(); + } + super.disconnectedCallback(); + } + + openPalette() { + this.open = true; + this.query = ""; + this.activeIndex = 0; + } + + private readonly togglePalette = () => { + if (this.open) { + this.open = false; + restoreFocus(); + return; + } + this.openPalette(); + }; + + private readonly handleGlobalKeydown = (event: KeyboardEvent) => { + if (!event.defaultPrevented && event.key === "Escape" && this.open) { + event.preventDefault(); + this.togglePalette(); + return; + } + if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "k") { + event.preventDefault(); + this.togglePalette(); + } + }; + + override render() { + return renderCommandPalette({ + open: this.open, + query: this.query, + activeIndex: this.activeIndex, + onToggle: this.togglePalette, + onQueryChange: (query) => { + this.query = query; + this.activeIndex = 0; + }, + onActiveIndexChange: (index) => { + this.activeIndex = index; + }, + onNavigate: (routeId) => this.onNavigate?.(routeId), + onSlashCommand: this.onSlashCommand, + }); + } +} + +if (!customElements.get("openclaw-command-palette")) { + customElements.define("openclaw-command-palette", CommandPalette); +} diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/components/config-form.analyze.ts similarity index 100% rename from ui/src/ui/views/config-form.analyze.ts rename to ui/src/components/config-form.analyze.ts diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/components/config-form.browser.test.ts similarity index 99% rename from ui/src/ui/config-form.browser.test.ts rename to ui/src/components/config-form.browser.test.ts index 71271c35f0fe..96eb36f179fe 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/components/config-form.browser.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover config form behavior. import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { analyzeConfigSchema, renderConfigForm } from "./views/config-form.ts"; +import { analyzeConfigSchema, renderConfigForm } from "./config-form.ts"; const rootSchema = { type: "object", diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/components/config-form.node.ts similarity index 93% rename from ui/src/ui/views/config-form.node.ts rename to ui/src/components/config-form.node.ts index 6bf717f7ccd8..7064afbdec33 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/components/config-form.node.ts @@ -1,12 +1,13 @@ // Control UI view renders config form screen content. import { html, nothing, type TemplateResult } from "lit"; -import { formatUnknownText } from "../format.ts"; -import { icons as sharedIcons } from "../icons.ts"; +import type { ConfigUiHints } from "../api/types.ts"; +import { icons as sharedIcons } from "../components/icons.ts"; +import "../components/tooltip.ts"; +import { formatUnknownText } from "../lib/format.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, -} from "../string-coerce.ts"; -import type { ConfigUiHints } from "../types.ts"; +} from "../lib/string-coerce.ts"; import { defaultValue, hasSensitiveConfigData, @@ -190,27 +191,25 @@ function renderSensitiveToggleButton(params: { if (!state.isSensitive || !params.onToggleSensitivePath) { return nothing; } + const label = state.canReveal + ? state.isRevealed + ? "Hide value" + : "Reveal value" + : "Disable stream mode to reveal value"; return html` - + + + `; } @@ -751,15 +750,17 @@ function renderTextInput(params: { })} ${schema.default !== undefined ? html` - + + + ` : nothing} @@ -1135,19 +1136,21 @@ function renderArray(params: {
#${idx + 1} - + + +
${renderNode({ @@ -1284,19 +1287,21 @@ function renderMapField(params: { }} />
- + + +
${anySchema diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/components/config-form.render.ts similarity index 99% rename from ui/src/ui/views/config-form.render.ts rename to ui/src/components/config-form.render.ts index 61f931f71137..d1b424e009ee 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/components/config-form.render.ts @@ -1,8 +1,8 @@ // Control UI view renders config form.render screen content. import { html, nothing } from "lit"; -import { icons } from "../icons.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { ConfigUiHints } from "../types.ts"; +import type { ConfigUiHints } from "../api/types.ts"; +import { icons } from "../components/icons.ts"; +import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; import { matchesNodeSearch, parseConfigSearchQuery, renderNode } from "./config-form.node.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; diff --git a/ui/src/ui/views/config-form.search.node.test.ts b/ui/src/components/config-form.search.node.test.ts similarity index 100% rename from ui/src/ui/views/config-form.search.node.test.ts rename to ui/src/components/config-form.search.node.test.ts diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/components/config-form.shared.ts similarity index 97% rename from ui/src/ui/views/config-form.shared.ts rename to ui/src/components/config-form.shared.ts index 9e3a1b748ea2..405c1894605a 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/components/config-form.shared.ts @@ -1,6 +1,6 @@ +import type { ConfigUiHint, ConfigUiHints } from "../api/types.ts"; // Control UI view renders config form.shared screen content. -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { ConfigUiHint, ConfigUiHints } from "../types.ts"; +import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; export type JsonSchema = { type?: string | string[]; diff --git a/ui/src/ui/views/config-form.ts b/ui/src/components/config-form.ts similarity index 100% rename from ui/src/ui/views/config-form.ts rename to ui/src/components/config-form.ts diff --git a/ui/src/components/connect-command.ts b/ui/src/components/connect-command.ts new file mode 100644 index 000000000000..8e9226d752fe --- /dev/null +++ b/ui/src/components/connect-command.ts @@ -0,0 +1,43 @@ +// Control UI component renders a copyable gateway connection command. +import { html } from "lit"; +import { t } from "../i18n/index.ts"; +import { renderCopyButton } from "./copy-button.ts"; +import "./tooltip.ts"; + +async function copyCommand(command: string) { + try { + await navigator.clipboard.writeText(command); + } catch { + // Best effort only; the explicit copy button provides visible feedback. + } +} + +export function renderConnectCommand(command: string) { + const copyLabel = t("overview.connection.copyCommand"); + return html` + + + + `; +} diff --git a/ui/src/components/copy-button.ts b/ui/src/components/copy-button.ts new file mode 100644 index 000000000000..c156041b832f --- /dev/null +++ b/ui/src/components/copy-button.ts @@ -0,0 +1,91 @@ +// Control UI chat module implements copy as markdown behavior. +import { html, type TemplateResult } from "lit"; +import { copyToClipboard } from "../lib/clipboard.ts"; +import { icons } from "./icons.ts"; +import "./tooltip.ts"; + +const COPIED_FOR_MS = 1500; +const ERROR_FOR_MS = 2000; +const COPY_LABEL = "Copy as markdown"; +const COPIED_LABEL = "Copied"; +const ERROR_LABEL = "Copy failed"; + +type CopyButtonOptions = { + text: () => string; + label?: string; +}; + +function setButtonLabel(button: HTMLButtonElement, label: string) { + button.setAttribute("aria-label", label); +} + +function createCopyButton(options: CopyButtonOptions): TemplateResult { + const idleLabel = options.label ?? COPY_LABEL; + return html` + + + + `; +} + +export function renderCopyButton(text: string, label = COPY_LABEL): TemplateResult { + return createCopyButton({ text: () => text, label }); +} + +export function renderCopyAsMarkdownButton(markdown: string): TemplateResult { + return renderCopyButton(markdown, COPY_LABEL); +} diff --git a/ui/src/ui/components/dashboard-header.ts b/ui/src/components/dashboard-header.ts similarity index 75% rename from ui/src/ui/components/dashboard-header.ts rename to ui/src/components/dashboard-header.ts index 6babdc302de3..b3a6f34015ed 100644 --- a/ui/src/ui/components/dashboard-header.ts +++ b/ui/src/components/dashboard-header.ts @@ -1,16 +1,17 @@ // Control UI component implements the dashboard header element. import { LitElement, html, nothing } from "lit"; import { property } from "lit/decorators.js"; -import { pathForTab, titleForTab, type Tab } from "../navigation.js"; +import { titleForRoute, type NavigationRouteId } from "../app-navigation.ts"; export class DashboardHeader extends LitElement { override createRenderRoot() { return this; } - @property() tab: Tab = "overview"; + @property() routeId?: NavigationRouteId; @property() basePath = ""; @property() agentLabel = ""; + @property() overviewHref = ""; private readonly handleOverviewClick = (event: MouseEvent) => { if ( @@ -30,7 +31,7 @@ export class DashboardHeader extends LitElement { }; override render() { - const label = titleForTab(this.tab); + const label = this.routeId ? titleForRoute(this.routeId) : ""; const rawAgentLabel = this.agentLabel.trim(); // Skip the agent crumb when it repeats the brand crumb ("OpenClaw › OpenClaw › …"). const agentLabel = rawAgentLabel.toLowerCase() === "openclaw" ? "" : rawAgentLabel; @@ -38,13 +39,17 @@ export class DashboardHeader extends LitElement { return html`
- - OpenClaw - + ${this.overviewHref + ? html` + + OpenClaw + + ` + : html`OpenClaw`} ${agentLabel ? html` diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/components/exec-approval.ts similarity index 82% rename from ui/src/ui/views/exec-approval.ts rename to ui/src/components/exec-approval.ts index 372be14a5d6d..08fa0b3d089a 100644 --- a/ui/src/ui/views/exec-approval.ts +++ b/ui/src/components/exec-approval.ts @@ -1,14 +1,14 @@ -// Control UI view renders exec approval screen content. -import { html, nothing } from "lit"; -import { formatApprovalDisplayPath } from "../../../../src/infra/approval-display-paths.ts"; -import { t } from "../../i18n/index.ts"; -import type { AppViewState } from "../app-view-state.ts"; -import "../components/modal-dialog.ts"; +// Control UI component renders exec approval. +import { LitElement, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { formatApprovalDisplayPath } from "../../../src/infra/approval-display-paths.ts"; import type { ExecApprovalDecision, ExecApprovalRequest, ExecApprovalRequestPayload, -} from "../controllers/exec-approval.ts"; +} from "../app/exec-approval.ts"; +import "./modal-dialog.ts"; +import { t } from "../i18n/index.ts"; const DEFAULT_EXEC_APPROVAL_DECISIONS = [ "allow-once", @@ -16,6 +16,13 @@ const DEFAULT_EXEC_APPROVAL_DECISIONS = [ "deny", ] as const satisfies readonly ExecApprovalDecision[]; +export type ExecApprovalProps = { + queue: readonly ExecApprovalRequest[]; + busy: boolean; + error: string | null; + onDecision: (decision: ExecApprovalDecision) => void | Promise; +}; + function formatRemaining(ms: number): string { const remaining = Math.max(0, ms); const totalSeconds = Math.floor(remaining / 1000); @@ -158,8 +165,8 @@ function renderUnavailableDecisionWarning( : html`
${t("execApproval.allowAlwaysUnavailable")}
`; } -export function renderExecApprovalPrompt(state: AppViewState) { - const active = state.execApprovalQueue[0]; +function renderExecApprovalPrompt(props: ExecApprovalProps) { + const active = props.queue[0]; if (!active) { return nothing; } @@ -169,7 +176,7 @@ export function renderExecApprovalPrompt(state: AppViewState) { remainingMs > 0 ? t("execApproval.expiresIn", { time: formatRemaining(remainingMs) }) : t("execApproval.expired"); - const queueCount = state.execApprovalQueue.length; + const queueCount = props.queue.length; const isPlugin = active.kind === "plugin"; const title = isPlugin ? (active.pluginTitle ?? t("execApproval.pluginApprovalNeeded")) @@ -178,8 +185,8 @@ export function renderExecApprovalPrompt(state: AppViewState) { const descriptionId = "exec-approval-description"; const decisions = resolveApprovalDecisions(active); const handleCancel = () => { - if (!state.execApprovalBusy && decisions.includes("deny")) { - void state.handleExecApprovalDecision("deny"); + if (!props.busy && decisions.includes("deny")) { + void props.onDecision("deny"); } }; return html` @@ -198,16 +205,14 @@ export function renderExecApprovalPrompt(state: AppViewState) {
${isPlugin ? renderPluginBody(active) : renderExecBody(request)} ${renderUnavailableDecisionWarning(active, decisions)} - ${state.execApprovalError - ? html`
${state.execApprovalError}
` - : nothing} + ${props.error ? html`
${props.error}
` : nothing}
${decisions.map( (decision) => html` @@ -218,3 +223,24 @@ export function renderExecApprovalPrompt(state: AppViewState) { `; } + +export class ExecApproval extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) props?: ExecApprovalProps; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + } + + override render() { + return this.props ? renderExecApprovalPrompt(this.props) : nothing; + } +} + +if (!customElements.get("openclaw-exec-approval")) { + customElements.define("openclaw-exec-approval", ExecApproval); +} diff --git a/ui/src/ui/components/file-preview-modal.test.ts b/ui/src/components/file-preview-modal.test.ts similarity index 100% rename from ui/src/ui/components/file-preview-modal.test.ts rename to ui/src/components/file-preview-modal.test.ts diff --git a/ui/src/ui/components/file-preview-modal.ts b/ui/src/components/file-preview-modal.ts similarity index 99% rename from ui/src/ui/components/file-preview-modal.ts rename to ui/src/components/file-preview-modal.ts index e6806fbec4d6..68623f1e83c3 100644 --- a/ui/src/ui/components/file-preview-modal.ts +++ b/ui/src/components/file-preview-modal.ts @@ -1,7 +1,7 @@ // Control UI component implements the file preview modal element. import { LitElement, css, html, type PropertyValues } from "lit"; import { property, query } from "lit/decorators.js"; -import { icons } from "../icons.ts"; +import { icons } from "./icons.ts"; export type FilePreviewModalFile = { path: string; diff --git a/ui/src/ui/form-controls.browser.test.ts b/ui/src/components/form-controls.browser.test.ts similarity index 100% rename from ui/src/ui/form-controls.browser.test.ts rename to ui/src/components/form-controls.browser.test.ts diff --git a/ui/src/components/gateway-url-confirmation.ts b/ui/src/components/gateway-url-confirmation.ts new file mode 100644 index 000000000000..2e587c21bc7f --- /dev/null +++ b/ui/src/components/gateway-url-confirmation.ts @@ -0,0 +1,67 @@ +// Control UI component renders gateway URL confirmation. +import { LitElement, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { t } from "../i18n/index.ts"; +import "./modal-dialog.ts"; + +export type GatewayUrlConfirmationProps = { + pendingGatewayUrl: string | null; + onConfirm: () => void; + onCancel: () => void; +}; + +function renderGatewayUrlConfirmation(props: GatewayUrlConfirmationProps) { + if (!props.pendingGatewayUrl) { + return nothing; + } + const titleId = "gateway-url-confirmation-title"; + const descriptionId = "gateway-url-confirmation-description"; + const title = t("channels.gatewayUrlConfirmation.title"); + const description = t("channels.gatewayUrlConfirmation.subtitle"); + + return html` + +
+
+
+
${title}
+
${description}
+
+
+
${props.pendingGatewayUrl}
+
+ ${t("channels.gatewayUrlConfirmation.warning")} +
+
+ + +
+
+
+ `; +} + +export class GatewayUrlConfirmation extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) props?: GatewayUrlConfirmationProps; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + } + + override render() { + return this.props ? renderGatewayUrlConfirmation(this.props) : nothing; + } +} + +if (!customElements.get("openclaw-gateway-url-confirmation")) { + customElements.define("openclaw-gateway-url-confirmation", GatewayUrlConfirmation); +} diff --git a/ui/src/ui/icons.ts b/ui/src/components/icons.ts similarity index 100% rename from ui/src/ui/icons.ts rename to ui/src/components/icons.ts diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/components/login-gate.ts similarity index 74% rename from ui/src/ui/views/login-gate.ts rename to ui/src/components/login-gate.ts index fc735b3034af..a42571835a85 100644 --- a/ui/src/ui/views/login-gate.ts +++ b/ui/src/components/login-gate.ts @@ -1,19 +1,19 @@ -// Control UI view renders login gate screen content. -import { html } from "lit"; -import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; -import { t } from "../../i18n/index.ts"; -import type { AppViewState } from "../app-view-state.ts"; -import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; -import { icons } from "../icons.ts"; -import { normalizeBasePath } from "../navigation.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import { agentLogoUrl } from "./agents-utils.ts"; -import { renderConnectCommand } from "./connect-command.ts"; +// Control UI component renders the login gate. +import { LitElement, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { normalizeBasePath } from "../app-route-paths.ts"; +import { controlUiPublicAssetPath } from "../app/public-assets.ts"; +import { t } from "../i18n/index.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts"; import { resolveAuthHintKind, resolvePairingHint, shouldShowInsecureContextHint, -} from "./overview-hints.ts"; +} from "../lib/overview-hints.ts"; +import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; +import { renderConnectCommand } from "./connect-command.ts"; +import { icons } from "./icons.ts"; type LoginFailureKind = | "auth-required" @@ -35,6 +35,26 @@ export type LoginFailureFeedback = { rawError: string; }; +export type LoginGateProps = { + basePath: string; + connected: boolean; + lastError: string | null; + lastErrorCode?: string | null; + hasToken: boolean; + hasPassword: boolean; + gatewayUrl: string; + token: string; + password: string; + showGatewayToken: boolean; + showGatewayPassword: boolean; + onGatewayUrlChange: (value: string) => void; + onTokenChange: (value: string) => void; + onPasswordChange: (value: string) => void; + onToggleGatewayToken: () => void; + onToggleGatewayPassword: () => void; + onConnect: () => void; +}; + type LoginFailureFeedbackParams = { connected: boolean; lastError: string | null; @@ -270,15 +290,15 @@ function renderLoginFailure(feedback: LoginFailureFeedback) { `; } -export function renderLoginGate(state: AppViewState) { - const basePath = normalizeBasePath(state.basePath ?? ""); - const faviconSrc = agentLogoUrl(basePath); +function renderLoginGate(props: LoginGateProps) { + const basePath = normalizeBasePath(props.basePath); + const faviconSrc = controlUiPublicAssetPath("favicon.svg", basePath); const failure = resolveLoginFailureFeedback({ - connected: state.connected, - lastError: state.lastError, - lastErrorCode: state.lastErrorCode, - hasToken: Boolean(state.settings.token.trim()), - hasPassword: Boolean(state.password.trim()), + connected: props.connected, + lastError: props.lastError, + lastErrorCode: props.lastErrorCode, + hasToken: props.hasToken, + hasPassword: props.hasPassword, }); return html` @@ -293,10 +313,9 @@ export function renderLoginGate(state: AppViewState) { -
@@ -397,3 +416,24 @@ export function renderLoginGate(state: AppViewState) {
`; } + +export class LoginGate extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) props?: LoginGateProps; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + } + + override render() { + return this.props ? renderLoginGate(this.props) : nothing; + } +} + +if (!customElements.get("openclaw-login-gate")) { + customElements.define("openclaw-login-gate", LoginGate); +} diff --git a/ui/src/ui/markdown.test.ts b/ui/src/components/markdown.test.ts similarity index 99% rename from ui/src/ui/markdown.test.ts rename to ui/src/components/markdown.test.ts index 48854ecfd9ab..4976e24ce52a 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -2,17 +2,15 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; +import { renderMarkdownSidebar } from "../pages/chat/components/chat-sidebar.ts"; import { blockArtCodeBlockCopyPayloadEncoding, decodeCodeBlockCopyPayload, -} from "./chat/code-block-copy-payload.ts"; -import { md, toSanitizedMarkdownHtml, toStreamingMarkdownHtml, toStreamingPlainTextHtml, } from "./markdown.ts"; -import { renderMarkdownSidebar } from "./views/markdown-sidebar.ts"; function htmlFragment(html: string): HTMLElement { const container = document.createElement("div"); @@ -30,6 +28,7 @@ function escapedCodeBlockCopyAttribute(value: string): string { } function withControlUiBasePath(basePath: string, fn: () => T): T { + const testWindow = window as Window & typeof globalThis & { [key: string]: unknown }; Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", { value: basePath, writable: true, @@ -38,7 +37,7 @@ function withControlUiBasePath(basePath: string, fn: () => T): T { try { return fn(); } finally { - delete window["__OPENCLAW_CONTROL_UI_BASE_PATH__"]; + delete testWindow["__OPENCLAW_CONTROL_UI_BASE_PATH__"]; } } diff --git a/ui/src/ui/markdown.ts b/ui/src/components/markdown.ts similarity index 95% rename from ui/src/ui/markdown.ts rename to ui/src/components/markdown.ts index 1de72cca8b9a..8199c66396f4 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/components/markdown.ts @@ -18,14 +18,15 @@ import yaml from "highlight.js/lib/languages/yaml"; import MarkdownIt from "markdown-it"; import markdownItTaskLists from "markdown-it-task-lists"; import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js"; -import { i18n, t } from "../i18n/index.ts"; import { - blockArtCodeBlockCopyPayloadEncoding, - encodeBlockArtCodeBlockCopyPayload, -} from "./chat/code-block-copy-payload.ts"; -import { truncateText } from "./format.ts"; -import { inferBasePathFromPathname, normalizeBasePath, tabFromPath } from "./navigation.ts"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; + inferBasePathFromPathname, + normalizeBasePath, + routeIdFromPath, +} from "../app-route-paths.ts"; +import { i18n, t } from "../i18n/index.ts"; +import { copyToClipboard } from "../lib/clipboard.ts"; +import { truncateText } from "../lib/format.ts"; +import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; const allowedTags = [ "a", @@ -93,6 +94,8 @@ const MARKDOWN_CACHE_MAX_CHARS = 50_000; const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; const BLOCK_ART_LINE_RE = /^[\t \u00a0▀▄█]+$/u; const BLOCK_ART_GLYPH_RE = /[▀▄█]/u; +const blockArtCopyPayloadPrefix = "openclaw:block-art-code:"; +export const blockArtCodeBlockCopyPayloadEncoding = "block-art-json"; const HOST_LOCAL_FILE_HREF_RE = /^(?:~\/|\/(?:Users|home|tmp|private\/tmp|var\/folders|private\/var\/folders)\/|\/[A-Za-z]:\/|[A-Za-z]:[\\/])/; const DOCS_ORIGIN = "https://docs.openclaw.ai"; @@ -376,12 +379,50 @@ function shouldRenderCodeBlockCopy(env: unknown): boolean { return (env as Partial | undefined)?.codeBlockChrome !== "none"; } +export function encodeBlockArtCodeBlockCopyPayload(value: string): string { + return `${blockArtCopyPayloadPrefix}${JSON.stringify(value)}`; +} + +export function decodeCodeBlockCopyPayload(value: string, encoding?: string): string { + if ( + encoding !== blockArtCodeBlockCopyPayloadEncoding || + !value.startsWith(blockArtCopyPayloadPrefix) + ) { + return value; + } + try { + const decoded = JSON.parse(value.slice(blockArtCopyPayloadPrefix.length)); + return typeof decoded === "string" ? decoded : value; + } catch { + return value; + } +} + +export function handleMarkdownCodeBlockCopy(event: Event): void { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const button = target.closest(".code-block-copy"); + if (!button) { + return; + } + const code = decodeCodeBlockCopyPayload(button.dataset.code ?? "", button.dataset.codeEncoding); + void copyToClipboard(code).then((copied) => { + if (!copied) { + return; + } + button.classList.add("copied"); + setTimeout(() => button.classList.remove("copied"), 1500); + }); +} + function isHostLocalFileHref(href: string): boolean { return HOST_LOCAL_FILE_HREF_RE.test(href.trim()); } function isControlUiRoutePath(pathname: string): boolean { - if (tabFromPath(pathname) !== null) { + if (routeIdFromPath(pathname) !== null) { return true; } const basePath = currentControlUiBasePath(); @@ -391,7 +432,7 @@ function isControlUiRoutePath(pathname: string): boolean { if (pathname !== basePath && !pathname.startsWith(`${basePath}/`)) { return false; } - return tabFromPath(pathname, basePath) !== null; + return routeIdFromPath(pathname, basePath) !== null; } function currentControlUiBasePath(): string { @@ -539,6 +580,10 @@ function normalizeMarkdownInput(markdownLocal: string): string { if (!input) { return ""; } + return formatTruncatedMarkdownInput(input); +} + +function formatTruncatedMarkdownInput(input: string): string { const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); return appendMarkdownTruncationNotice(truncated).replace(/\r\n?/g, "\n"); } diff --git a/ui/src/ui/components/modal-dialog.test.ts b/ui/src/components/modal-dialog.test.ts similarity index 99% rename from ui/src/ui/components/modal-dialog.test.ts rename to ui/src/components/modal-dialog.test.ts index b27ba855a156..386c1f57fa7f 100644 --- a/ui/src/ui/components/modal-dialog.test.ts +++ b/ui/src/components/modal-dialog.test.ts @@ -6,7 +6,7 @@ import { getRenderedModalDialog, installDialogPolyfill, nextFrame, -} from "../../test-helpers/modal-dialog.ts"; +} from "../test-helpers/modal-dialog.ts"; import type { OpenClawModalDialog } from "./modal-dialog.ts"; import "./modal-dialog.ts"; diff --git a/ui/src/ui/components/modal-dialog.ts b/ui/src/components/modal-dialog.ts similarity index 100% rename from ui/src/ui/components/modal-dialog.ts rename to ui/src/components/modal-dialog.ts diff --git a/ui/src/components/provider-quota-pill.ts b/ui/src/components/provider-quota-pill.ts new file mode 100644 index 000000000000..29239ca8cc0a --- /dev/null +++ b/ui/src/components/provider-quota-pill.ts @@ -0,0 +1,51 @@ +import { html } from "lit"; +import type { ModelAuthStatusResult } from "../api/types.ts"; +import { normalizeBasePath } from "../app-route-paths.ts"; +import { t } from "../i18n/index.ts"; +import { isMonitoredAuthProvider } from "../lib/model-auth.ts"; +import { + collectQuotaWindowsFromAuthStatus, + formatQuotaReset, +} from "../lib/provider-quota-summary.ts"; + +export type ProviderQuotaPillProps = { + basePath?: string; + modelAuthStatusResult?: ModelAuthStatusResult | null; +}; + +export function renderProviderQuotaPill(props: ProviderQuotaPillProps) { + const windows = collectQuotaWindowsFromAuthStatus( + props.modelAuthStatusResult ?? null, + isMonitoredAuthProvider, + ); + const primary = windows[0]; + if (!primary) { + return ""; + } + const secondary = windows.find( + (entry) => entry.displayName !== primary.displayName || entry.label !== primary.label, + ); + const reset = formatQuotaReset(primary.resetAt); + const detail = [primary.displayName, primary.label, reset ? `resets ${reset}` : null] + .filter(Boolean) + .join(" · "); + const secondaryDetail = secondary + ? `${secondary.displayName}${secondary.label ? ` ${secondary.label}` : ""} ${secondary.remaining}% left` + : null; + const title = [detail, secondaryDetail].filter(Boolean).join(" · "); + const severity = primary.remaining <= 10 ? "danger" : primary.remaining <= 25 ? "warn" : "ok"; + const href = `${normalizeBasePath(props.basePath ?? "")}/usage`; + + return html` + + ${t("tabs.usage")} + ${primary.remaining}% + + `; +} diff --git a/ui/src/ui/components/resizable-divider.test.ts b/ui/src/components/resizable-divider.test.ts similarity index 100% rename from ui/src/ui/components/resizable-divider.test.ts rename to ui/src/components/resizable-divider.test.ts diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/components/resizable-divider.ts similarity index 100% rename from ui/src/ui/components/resizable-divider.ts rename to ui/src/components/resizable-divider.ts diff --git a/ui/src/components/session-picker.ts b/ui/src/components/session-picker.ts new file mode 100644 index 000000000000..2b55a61a841d --- /dev/null +++ b/ui/src/components/session-picker.ts @@ -0,0 +1,553 @@ +import { LitElement, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import type { SessionsListResult } from "../api/types.ts"; +import { t } from "../i18n/index.ts"; +import { formatDateTimeMs, formatRelativeTimestamp } from "../lib/format.ts"; +import { resolveSessionDisplayName } from "../lib/session-display.ts"; +import { + compareSessionRowsByUpdatedAt, + getVisibleSessionRows, + type SessionCapability, +} from "../lib/sessions/index.ts"; +import { + areUiSessionKeysEquivalent, + buildAgentMainSessionKey, + canArchiveSessionRow, + parseAgentSessionKey, +} from "../lib/sessions/session-key.ts"; +import { normalizeOptionalString } from "../lib/string-coerce.ts"; +import { icons } from "./icons.ts"; +import "./tooltip.ts"; + +const SEARCH_DEBOUNCE_MS = 300; +const SESSION_PICKER_ID = "chat-session-picker-sidebar"; + +export class SessionPicker extends LitElement { + @property({ attribute: false }) sessions?: SessionCapability; + @property({ attribute: false }) sessionsResult: SessionsListResult | null = null; + @property({ attribute: false }) currentSessionKey = ""; + @property({ attribute: false }) agentId = "main"; + @property({ attribute: false }) defaultAgentId = "main"; + @property({ attribute: false }) mainKey = "main"; + @property({ attribute: false }) connected = false; + @property({ attribute: false }) onSelectSession?: (sessionKey: string) => void; + @property({ attribute: false }) onReplaceCurrentSession?: (sessionKey: string) => void; + + @state() private open = false; + @state() private query = ""; + @state() private appliedQuery = ""; + @state() private result: SessionsListResult | null = null; + @state() private loading = false; + @state() private error: string | null = null; + private requestId = 0; + private searchTimer: ReturnType | null = null; + private triggerElement: HTMLElement | null = null; + + private readonly handleDocumentKeydown = (event: KeyboardEvent) => { + if (!this.open || event.defaultPrevented || event.key !== "Escape") { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.close({ restoreFocus: true }); + }; + + private readonly handleDocumentPointerdown = (event: PointerEvent) => { + if (!this.open || event.composedPath().includes(this)) { + return; + } + this.close(); + }; + + override createRenderRoot() { + return this; + } + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + document.addEventListener("keydown", this.handleDocumentKeydown, true); + document.addEventListener("pointerdown", this.handleDocumentPointerdown, true); + } + + override disconnectedCallback() { + document.removeEventListener("keydown", this.handleDocumentKeydown, true); + document.removeEventListener("pointerdown", this.handleDocumentPointerdown, true); + this.clearSearchTimer(); + this.triggerElement = null; + super.disconnectedCallback(); + } + + override willUpdate(changed: Map) { + if (changed.has("sessionsResult") && !this.appliedQuery) { + this.result = this.sessionsResult; + } + } + + override updated(changed: Map) { + if (!changed.has("open") || !this.open) { + return; + } + this.querySelector('[data-chat-session-picker-search="true"]')?.focus(); + } + + private clearSearchTimer() { + if (this.searchTimer !== null) { + globalThis.clearTimeout(this.searchTimer); + this.searchTimer = null; + } + } + + private openFromTrigger(trigger: HTMLElement) { + if (!this.connected) { + return; + } + this.triggerElement = trigger; + this.open = true; + if (!this.result) { + this.result = this.sessionsResult; + if (!this.result) { + void this.loadPage(); + } + } + } + + private toggle(trigger: HTMLElement) { + if (this.open) { + this.close({ restoreFocus: true }); + return; + } + this.openFromTrigger(trigger); + } + + private close(options: { restoreFocus?: boolean } = {}) { + this.clearSearchTimer(); + const focusTarget = options.restoreFocus ? this.triggerElement : null; + this.open = false; + this.triggerElement = null; + if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) { + return; + } + requestAnimationFrame(() => { + if (focusTarget.isConnected) { + focusTarget.focus(); + } + }); + } + + private scheduleSearch() { + this.clearSearchTimer(); + this.searchTimer = globalThis.setTimeout(() => { + this.searchTimer = null; + void this.applySearch(); + }, SEARCH_DEBOUNCE_MS); + } + + private async loadPage(options: { append?: boolean; offset?: number } = {}) { + const sessionService = this.sessions; + if (!sessionService || !this.connected) { + return; + } + const requestId = ++this.requestId; + this.loading = true; + this.error = null; + try { + const page = await sessionService.list({ + agentId: this.agentId, + search: this.appliedQuery, + offset: options.offset, + }); + if (requestId !== this.requestId) { + return; + } + if (!page) { + return; + } + if (!options.append || !this.result) { + this.result = page; + return; + } + const rowsByKey = new Set(this.result.sessions.map((row) => row.key)); + const combinedSessions = [ + ...this.result.sessions, + ...page.sessions.filter((row) => !rowsByKey.has(row.key)), + ]; + const totalCount = page.totalCount ?? this.result.totalCount; + const hasMore = + page.hasMore ?? + (typeof totalCount === "number" && Number.isFinite(totalCount) + ? combinedSessions.length < totalCount + : false); + this.result = { + ...page, + count: combinedSessions.length, + hasMore, + nextOffset: + page.nextOffset !== undefined + ? page.nextOffset + : hasMore + ? combinedSessions.length + : null, + sessions: combinedSessions, + totalCount, + }; + } catch (error) { + if (requestId === this.requestId) { + this.error = String(error); + } + } finally { + if (requestId === this.requestId) { + this.loading = false; + } + } + } + + private async applySearch() { + this.clearSearchTimer(); + this.appliedQuery = normalizeOptionalString(this.query) ?? ""; + await this.loadPage(); + } + + private clearSearch() { + this.clearSearchTimer(); + ++this.requestId; + this.query = ""; + this.appliedQuery = ""; + this.error = null; + this.result = this.sessionsResult; + if (this.open) { + void this.loadPage(); + } + } + + private async loadMore() { + if (this.loading) { + return; + } + let result = this.result; + let offset = this.resolveNextOffset(result); + let visibleCount = this.rows().length; + const seenOffsets = new Set(); + while (offset !== null && !seenOffsets.has(offset)) { + seenOffsets.add(offset); + await this.loadPage({ append: true, offset }); + result = this.result; + const nextVisibleCount = this.rows().length; + if (nextVisibleCount > visibleCount) { + return; + } + visibleCount = nextVisibleCount; + offset = this.resolveNextOffset(result); + } + } + + private resolveNextOffset(result: SessionsListResult | null): number | null { + if (!result?.hasMore) { + return null; + } + if (typeof result.nextOffset === "number" && Number.isFinite(result.nextOffset)) { + return Math.max(0, Math.floor(result.nextOffset)); + } + return result.sessions.length; + } + + private formatMeta(row: SessionsListResult["sessions"][number]): string { + const parts = [ + normalizeOptionalString(row.surface), + [normalizeOptionalString(row.modelProvider), normalizeOptionalString(row.model)] + .filter(Boolean) + .join("/"), + ].filter(Boolean); + const updatedAt = formatDateTimeMs(row.updatedAt, undefined, ""); + if (updatedAt) { + parts.push(updatedAt); + } + return parts.join(" · "); + } + + private countLabel(rows: SessionsListResult["sessions"]): string { + const loadedCount = this.result?.sessions.length ?? 0; + const totalCount = this.result?.totalCount; + return loadedCount === rows.length && + typeof totalCount === "number" && + Number.isFinite(totalCount) + ? `${rows.length} / ${totalCount}` + : String(rows.length); + } + + private rows() { + return getVisibleSessionRows(this.result, { + currentSessionKey: this.currentSessionKey, + agentId: this.agentId, + defaultAgentId: this.defaultAgentId, + }).toSorted(compareSessionRowsByUpdatedAt); + } + + private async patchSession( + row: SessionsListResult["sessions"][number], + patch: { label?: string | null; archived?: boolean; pinned?: boolean }, + ) { + const sessions = this.sessions; + if (!sessions || !this.connected) { + return; + } + this.error = null; + try { + const agentId = parseAgentSessionKey(row.key)?.agentId ?? this.agentId; + const patched = await sessions.patch(row.key, patch, { agentId }); + if (!patched) { + this.error = sessions.state.error; + return; + } + if (patch.archived === true && areUiSessionKeysEquivalent(row.key, this.currentSessionKey)) { + this.close(); + this.onReplaceCurrentSession?.( + buildAgentMainSessionKey({ + agentId, + mainKey: this.mainKey, + }), + ); + return; + } + this.result = sessions.state.result ?? this.result; + if (this.appliedQuery) { + await this.loadPage(); + } + } catch (error) { + this.error = String(error); + } + } + + private renderPicker() { + if (!this.open) { + return nothing; + } + const rows = this.rows(); + const hasQuery = Boolean(this.query || this.appliedQuery); + const searchPending = + normalizeOptionalString(this.query) !== normalizeOptionalString(this.appliedQuery); + const loadMore = + this.result?.hasMore === true && + (typeof this.result.nextOffset === "number" + ? this.result.nextOffset + : this.result.sessions.length); + return html` + + `; + } + + override render() { + const label = t("chat.selectors.sessionSearch"); + return html` + + `; + } +} + +if (!customElements.get("openclaw-session-picker")) { + customElements.define("openclaw-session-picker", SessionPicker); +} diff --git a/ui/src/components/settings-workspace.ts b/ui/src/components/settings-workspace.ts new file mode 100644 index 000000000000..01bdf1749453 --- /dev/null +++ b/ui/src/components/settings-workspace.ts @@ -0,0 +1,86 @@ +import { html, nothing } from "lit"; +import { + cancelRoutePreload, + isSettingsNavigationRoute, + navigationIconForRoute, + SETTINGS_NAVIGATION_ROUTES, + scheduleRoutePreload, + titleForRoute, +} from "../app-navigation.ts"; +import { isRouteId, pathForRoute, type RouteId } from "../app-route-paths.ts"; +import { icons } from "../components/icons.ts"; +import { t } from "../i18n/index.ts"; + +const preloadTimers = new Map>(); + +function renderSettingsSectionNav( + basePath: string, + currentRouteId: RouteId, + navigate: (routeId: RouteId) => void, + preload?: (routeId: RouteId) => Promise | void, +) { + if (!isSettingsNavigationRoute(currentRouteId)) { + return nothing; + } + const routes = SETTINGS_NAVIGATION_ROUTES.filter(isRouteId); + return html` + + `; +} + +export function renderSettingsWorkspace( + basePath: string, + body: unknown, + routeId: RouteId, + navigate: (routeId: RouteId) => void, + preload?: (routeId: RouteId) => Promise | void, + options: { fillHeight?: boolean } = {}, +) { + const className = options.fillHeight + ? "settings-workspace settings-workspace--fill-height" + : "settings-workspace"; + return html` +
+ ${renderSettingsSectionNav(basePath, routeId, navigate, preload)} +
${body}
+
+ `; +} diff --git a/ui/src/ui/terminal/terminal-connection.test.ts b/ui/src/components/terminal/terminal-connection.test.ts similarity index 100% rename from ui/src/ui/terminal/terminal-connection.test.ts rename to ui/src/components/terminal/terminal-connection.test.ts diff --git a/ui/src/ui/terminal/terminal-connection.ts b/ui/src/components/terminal/terminal-connection.ts similarity index 99% rename from ui/src/ui/terminal/terminal-connection.ts rename to ui/src/components/terminal/terminal-connection.ts index ee468ff5c59c..9a745fd1ff24 100644 --- a/ui/src/ui/terminal/terminal-connection.ts +++ b/ui/src/components/terminal/terminal-connection.ts @@ -1,4 +1,4 @@ -// Protocol layer for the operator terminal: wraps the gateway client with typed +// Terminal protocol layer: wraps the gateway client with typed // terminal.* RPCs and fans the terminal.data / terminal.exit event stream out to // per-session sinks. Kept DOM-free so it can be unit tested without ghostty-web. diff --git a/ui/src/ui/terminal/terminal-panel.test.ts b/ui/src/components/terminal/terminal-panel.test.ts similarity index 100% rename from ui/src/ui/terminal/terminal-panel.test.ts rename to ui/src/components/terminal/terminal-panel.test.ts diff --git a/ui/src/ui/terminal/terminal-panel.ts b/ui/src/components/terminal/terminal-panel.ts similarity index 99% rename from ui/src/ui/terminal/terminal-panel.ts rename to ui/src/components/terminal/terminal-panel.ts index f35f2125fed7..b35ffdfafa53 100644 --- a/ui/src/ui/terminal/terminal-panel.ts +++ b/ui/src/components/terminal/terminal-panel.ts @@ -1,5 +1,5 @@ import type { FitAddon, Terminal } from "ghostty-web"; -// Dockable operator terminal panel for the Control UI. +// Dockable operator terminal panel for the Control UI shell. // // Renders a VS Code-style shell dock (bottom by default, or right) with session // tabs. Each tab hosts one ghostty-web terminal wired to a gateway PTY session. diff --git a/ui/src/ui/terminal/terminal-theme.ts b/ui/src/components/terminal/terminal-theme.ts similarity index 93% rename from ui/src/ui/terminal/terminal-theme.ts rename to ui/src/components/terminal/terminal-theme.ts index 1298628082bd..b51ffc4f778d 100644 --- a/ui/src/ui/terminal/terminal-theme.ts +++ b/ui/src/components/terminal/terminal-theme.ts @@ -1,4 +1,4 @@ -// Maps the Control UI's light/dark surfaces onto ghostty-web's 16-color theme. +// Maps the Control UI light/dark surfaces onto ghostty-web's 16-color theme. import type { ITheme } from "ghostty-web"; // ANSI palette tuned to sit on the Control UI's near-black / near-white surfaces. diff --git a/ui/src/components/theme-mode-toggle.ts b/ui/src/components/theme-mode-toggle.ts new file mode 100644 index 000000000000..284c57029135 --- /dev/null +++ b/ui/src/components/theme-mode-toggle.ts @@ -0,0 +1,77 @@ +import { LitElement, html } from "lit"; +import { property } from "lit/decorators.js"; +import type { ThemeMode } from "../app/theme.ts"; +import { t } from "../i18n/index.ts"; +import { icons } from "./icons.ts"; +import "./tooltip.ts"; + +export type ThemeModeChangeDetail = { + mode: ThemeMode; + element: HTMLElement; +}; + +export class ThemeModeToggle extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) mode: ThemeMode = "system"; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + } + + private readonly handleModeChange = (mode: ThemeMode, event: Event) => { + if (mode === this.mode) { + return; + } + this.dispatchEvent( + new CustomEvent("theme-change", { + detail: { mode, element: event.currentTarget as HTMLElement }, + bubbles: true, + composed: true, + }), + ); + }; + + override render() { + const options: Array<{ id: ThemeMode; labelKey: string }> = [ + { id: "system", labelKey: "common.system" }, + { id: "light", labelKey: "common.light" }, + { id: "dark", labelKey: "common.dark" }, + ]; + + return html` +
+ ${options.map((option) => { + const label = t(option.labelKey); + const tooltip = t("common.colorModeOption", { mode: label }); + return html` + + + + `; + })} +
+ `; + } +} + +if (!customElements.get("openclaw-theme-mode-toggle")) { + customElements.define("openclaw-theme-mode-toggle", ThemeModeToggle); +} diff --git a/ui/src/components/tooltip.ts b/ui/src/components/tooltip.ts new file mode 100644 index 000000000000..9c4a961e5e78 --- /dev/null +++ b/ui/src/components/tooltip.ts @@ -0,0 +1,453 @@ +import { LitElement, html } from "lit"; +import { property } from "lit/decorators.js"; + +const HOVER_DELAY = 150; +const TOUCH_DELAY = 450; +const TOUCH_VISIBLE = 900; +const MOVE_LIMIT = 10; +const SKIP_DELAY = 300; +const VIEWPORT_PADDING = 8; +const TOOLTIP_GAP = 8; + +let nextTooltipId = 0; + +function createTooltipId() { + nextTooltipId += 1; + return `openclaw-tooltip-${nextTooltipId}`; +} + +export class TooltipProvider extends LitElement { + @property({ type: Number }) delay = HOVER_DELAY; + @property({ type: Number }) skipDelay = SKIP_DELAY; + @property({ type: Number }) touchDelay = TOUCH_DELAY; + + private delayed = true; + private skipDelayTimer: number | null = null; + private activeTooltip: Tooltip | null = null; + private suppressFocus = false; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + this.addEventListener("pointerdown", this.handlePointerDown, true); + } + + override disconnectedCallback() { + this.removeEventListener("pointerdown", this.handlePointerDown, true); + this.activeTooltip?.closeFromProvider(); + this.activeTooltip = null; + if (this.skipDelayTimer !== null) { + window.clearTimeout(this.skipDelayTimer); + this.skipDelayTimer = null; + } + this.suppressFocus = false; + super.disconnectedCallback(); + } + + private readonly handlePointerDown = () => { + this.suppressFocus = true; + this.activeTooltip?.closeFromProvider(); + }; + + suppressNextFocus() { + this.suppressFocus = true; + } + + consumeFocusSuppression() { + if (!this.suppressFocus) { + return false; + } + this.suppressFocus = false; + return true; + } + + openTooltip(tooltip: Tooltip) { + if (this.activeTooltip && this.activeTooltip !== tooltip) { + this.activeTooltip.closeFromProvider(); + } + this.activeTooltip = tooltip; + this.delayed = false; + if (this.skipDelayTimer !== null) { + window.clearTimeout(this.skipDelayTimer); + } + } + + closeTooltip(tooltip: Tooltip) { + if (this.activeTooltip !== tooltip) { + return; + } + this.activeTooltip = null; + if (this.skipDelay <= 0) { + this.delayed = true; + return; + } + if (this.skipDelayTimer !== null) { + window.clearTimeout(this.skipDelayTimer); + } + this.skipDelayTimer = window.setTimeout(() => { + this.skipDelayTimer = null; + this.delayed = true; + }, this.skipDelay); + } + + shouldDelayOpen() { + return this.delayed; + } + + override render() { + return html``; + } +} + +export class Tooltip extends LitElement { + @property() content = ""; + + private trigger: HTMLElement | null = null; + private portal: HTMLDivElement | null = null; + private openTimer: number | null = null; + private touchTimer: number | null = null; + private touchCloseTimer: number | null = null; + private touchStart: { x: number; y: number } | null = null; + private touchOpened = false; + private open = false; + private pointerDown = false; + private describedBy: string | null = null; + private readonly tooltipId = createTooltipId(); + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + } + + protected override firstUpdated() { + this.attachTrigger(); + } + + override disconnectedCallback() { + this.close(); + document.removeEventListener("pointerup", this.handleDocumentPointerUp); + this.detachTrigger(); + super.disconnectedCallback(); + } + + private attachTrigger() { + const slot = this.renderRoot.querySelector("slot"); + const trigger = slot + ?.assignedElements({ flatten: true }) + .find((element): element is HTMLElement => element instanceof HTMLElement); + if (trigger === this.trigger) { + return; + } + this.close(); + this.detachTrigger(); + if (!trigger) { + return; + } + this.trigger = trigger; + for (const type of [ + "pointermove", + "pointerdown", + "pointerup", + "pointerleave", + "pointercancel", + ]) { + trigger.addEventListener(type, this.handlePointer); + } + trigger.addEventListener("focusin", this.handleFocus); + trigger.addEventListener("focusout", this.handleFocus); + trigger.addEventListener("click", this.handleClick, true); + trigger.addEventListener("keydown", this.handleKeyDown); + } + + private detachTrigger() { + const trigger = this.trigger; + if (!trigger) { + return; + } + for (const type of [ + "pointermove", + "pointerdown", + "pointerup", + "pointerleave", + "pointercancel", + ]) { + trigger.removeEventListener(type, this.handlePointer); + } + trigger.removeEventListener("focusin", this.handleFocus); + trigger.removeEventListener("focusout", this.handleFocus); + trigger.removeEventListener("click", this.handleClick, true); + trigger.removeEventListener("keydown", this.handleKeyDown); + this.restoreDescription(); + this.trigger = null; + } + + private get provider() { + return this.closest("openclaw-tooltip-provider"); + } + + private get delay() { + return Math.max(0, this.provider?.delay ?? HOVER_DELAY); + } + + private get touchDelay() { + return Math.max(0, this.provider?.touchDelay ?? TOUCH_DELAY); + } + + private readonly handlePointer = (event: Event) => { + const pointer = event as PointerEvent; + if (pointer.pointerType === "touch") { + if (event.type === "pointerdown") { + this.pointerDown = true; + document.addEventListener("pointerup", this.handleDocumentPointerUp, { once: true }); + this.clearTimers(); + this.touchStart = { x: pointer.clientX, y: pointer.clientY }; + this.touchOpened = false; + this.touchTimer = window.setTimeout(() => { + this.touchTimer = null; + this.touchOpened = true; + this.show(); + }, this.touchDelay); + } else if (event.type === "pointermove" && this.touchStart) { + if ( + Math.hypot(pointer.clientX - this.touchStart.x, pointer.clientY - this.touchStart.y) > + MOVE_LIMIT + ) { + this.close(); + } + } else if (event.type === "pointerup") { + this.clearTouchTimer(); + this.touchStart = null; + if (this.touchOpened) { + this.touchCloseTimer = window.setTimeout(() => this.close(), TOUCH_VISIBLE); + } + } else if (event.type === "pointercancel") { + this.pointerDown = false; + document.removeEventListener("pointerup", this.handleDocumentPointerUp); + this.close(); + } else if (event.type === "pointerleave") { + this.close(); + } + return; + } + if (event.type === "pointermove") { + if (pointer.buttons === 0) { + this.scheduleOpen(); + } + } else if (event.type === "pointerleave" || event.type === "pointerdown") { + this.pointerDown = event.type === "pointerdown"; + this.close(); + if (this.pointerDown) { + document.addEventListener("pointerup", this.handleDocumentPointerUp, { once: true }); + } + } + }; + + private readonly handleFocus = (event: FocusEvent) => { + if (event.type === "focusin") { + if (this.provider?.consumeFocusSuppression()) { + return; + } + if (!this.pointerDown) { + this.show(); + } + return; + } + if (!(event.relatedTarget instanceof Node && this.trigger?.contains(event.relatedTarget))) { + this.close(); + } + }; + + private readonly handleClick = () => { + this.provider?.suppressNextFocus(); + this.close(); + }; + + private readonly handleDocumentPointerUp = () => { + this.pointerDown = false; + if (!this.touchStart) { + return; + } + this.clearTouchTimer(); + this.touchStart = null; + if (this.touchOpened) { + this.touchCloseTimer = window.setTimeout(() => this.close(), TOUCH_VISIBLE); + } + }; + + private readonly handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + this.close(); + } + }; + + private scheduleOpen() { + if (this.open || !this.trigger || !this.content.trim()) { + return; + } + this.clearOpenTimer(); + const delay = this.provider?.shouldDelayOpen() ? this.delay : 0; + this.openTimer = window.setTimeout(() => { + this.openTimer = null; + this.show(); + }, delay); + } + + private show() { + const trigger = this.trigger; + if (!trigger || !this.content.trim()) { + return; + } + this.clearTimers(); + this.provider?.openTooltip(this); + this.open = true; + this.describedBy ??= trigger.getAttribute("aria-describedby"); + this.portal = document.createElement("div"); + this.portal.className = "openclaw-tooltip"; + this.portal.id = this.tooltipId; + this.portal.setAttribute("role", "tooltip"); + this.portal.textContent = this.content; + this.portal.dataset.open = "true"; + document.body.append(this.portal); + trigger.setAttribute( + "aria-describedby", + this.describedBy ? `${this.describedBy} ${this.tooltipId}` : this.tooltipId, + ); + window.addEventListener("resize", this.handleViewportChange); + window.addEventListener("scroll", this.handleViewportChange, true); + const viewport = window.visualViewport; + if (typeof viewport?.addEventListener === "function") { + viewport.addEventListener("resize", this.handleViewportChange); + viewport.addEventListener("scroll", this.handleViewportChange); + } + this.positionTooltip(); + } + + private close() { + const wasOpen = this.open; + this.clearTimers(); + this.touchStart = null; + this.touchOpened = false; + this.open = false; + if (wasOpen) { + this.provider?.closeTooltip(this); + } + this.restoreDescription(); + this.portal?.remove(); + this.portal = null; + window.removeEventListener("resize", this.handleViewportChange); + window.removeEventListener("scroll", this.handleViewportChange, true); + const viewport = window.visualViewport; + if (typeof viewport?.removeEventListener === "function") { + viewport.removeEventListener("resize", this.handleViewportChange); + viewport.removeEventListener("scroll", this.handleViewportChange); + } + } + + closeFromProvider() { + this.close(); + } + + private restoreDescription() { + if (!this.trigger) { + return; + } + if (this.describedBy === null) { + this.trigger.removeAttribute("aria-describedby"); + } else { + this.trigger.setAttribute("aria-describedby", this.describedBy); + } + this.describedBy = null; + } + + private readonly handleViewportChange = () => { + if (this.open) { + this.positionTooltip(); + } + }; + + private positionTooltip() { + const trigger = this.trigger; + const portal = this.portal; + if (!trigger || !portal) { + return; + } + const triggerRect = trigger.getBoundingClientRect(); + const tooltipRect = portal.getBoundingClientRect(); + const available = { + top: triggerRect.top - TOOLTIP_GAP - VIEWPORT_PADDING, + bottom: window.innerHeight - triggerRect.bottom - TOOLTIP_GAP - VIEWPORT_PADDING, + left: triggerRect.left - TOOLTIP_GAP - VIEWPORT_PADDING, + right: window.innerWidth - triggerRect.right - TOOLTIP_GAP - VIEWPORT_PADDING, + }; + const preferredSide = + available.top >= tooltipRect.height + ? "top" + : available.bottom >= tooltipRect.height + ? "bottom" + : available.right >= tooltipRect.width + ? "right" + : available.left >= tooltipRect.width + ? "left" + : available.bottom >= available.top + ? "bottom" + : "top"; + const top = + preferredSide === "top" + ? triggerRect.top - tooltipRect.height - TOOLTIP_GAP + : preferredSide === "bottom" + ? triggerRect.bottom + TOOLTIP_GAP + : triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; + const left = + preferredSide === "left" + ? triggerRect.left - tooltipRect.width - TOOLTIP_GAP + : preferredSide === "right" + ? triggerRect.right + TOOLTIP_GAP + : triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; + const maxLeft = Math.max( + VIEWPORT_PADDING, + window.innerWidth - tooltipRect.width - VIEWPORT_PADDING, + ); + const maxTop = Math.max( + VIEWPORT_PADDING, + window.innerHeight - tooltipRect.height - VIEWPORT_PADDING, + ); + portal.dataset.side = preferredSide; + portal.style.left = `${Math.min(Math.max(VIEWPORT_PADDING, left), maxLeft)}px`; + portal.style.top = `${Math.min(Math.max(VIEWPORT_PADDING, top), maxTop)}px`; + } + + private clearTimers() { + this.clearOpenTimer(); + this.clearTouchTimer(); + } + + private clearOpenTimer() { + if (this.openTimer !== null) { + window.clearTimeout(this.openTimer); + this.openTimer = null; + } + } + + private clearTouchTimer() { + if (this.touchTimer !== null) { + window.clearTimeout(this.touchTimer); + this.touchTimer = null; + } + if (this.touchCloseTimer !== null) { + window.clearTimeout(this.touchCloseTimer); + this.touchCloseTimer = null; + } + } + + override render() { + return html` this.attachTrigger()}>`; + } +} + +if (!customElements.get("openclaw-tooltip-provider")) { + customElements.define("openclaw-tooltip-provider", TooltipProvider); +} + +if (!customElements.get("openclaw-tooltip")) { + customElements.define("openclaw-tooltip", Tooltip); +} diff --git a/ui/src/components/update-banner.ts b/ui/src/components/update-banner.ts new file mode 100644 index 000000000000..dfa5e337f6c2 --- /dev/null +++ b/ui/src/components/update-banner.ts @@ -0,0 +1,128 @@ +// Control UI component renders update status and available-update actions. +import { LitElement, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import type { UpdateAvailable } from "../api/types.ts"; +import { t } from "../i18n/index.ts"; +import { getSafeLocalStorage } from "../local-storage.ts"; +import { icons } from "./icons.ts"; + +const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; + +type DismissedUpdateBanner = { + latestVersion: string; + channel: string | null; + dismissedAtMs: number; +}; + +function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { + try { + const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed.latestVersion !== "string") { + return null; + } + return { + latestVersion: parsed.latestVersion, + channel: typeof parsed.channel === "string" ? parsed.channel : null, + dismissedAtMs: typeof parsed.dismissedAtMs === "number" ? parsed.dismissedAtMs : Date.now(), + }; + } catch { + return null; + } +} + +function isDismissed(updateAvailable: UpdateAvailable): boolean { + const dismissed = loadDismissedUpdateBanner(); + return Boolean( + dismissed && + dismissed.latestVersion === updateAvailable.latestVersion && + dismissed.channel === updateAvailable.channel, + ); +} + +function dismiss(updateAvailable: UpdateAvailable) { + try { + getSafeLocalStorage()?.setItem( + UPDATE_BANNER_DISMISS_KEY, + JSON.stringify({ + latestVersion: updateAvailable.latestVersion, + channel: updateAvailable.channel, + dismissedAtMs: Date.now(), + } satisfies DismissedUpdateBanner), + ); + } catch { + // Best effort only; dismissing the banner is not a product failure. + } +} + +export type UpdateBannerProps = { + statusBanner: { tone: "danger" | "warn" | "info"; text: string } | null; + updateAvailable: UpdateAvailable | null; + updateRunning: boolean; + connected: boolean; + onUpdate: () => void | Promise; + onDismiss: () => void; +}; + +export class UpdateBanner extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) props?: UpdateBannerProps; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = "contents"; + } + + override render() { + const props = this.props; + if (!props) { + return nothing; + } + const updateAvailable = props.updateAvailable; + return html` + ${props.statusBanner + ? html`` + : nothing} + ${updateAvailable && + updateAvailable.latestVersion !== updateAvailable.currentVersion && + !isDismissed(updateAvailable) + ? html`` + : nothing} + `; + } +} + +if (!customElements.get("openclaw-update-banner")) { + customElements.define("openclaw-update-banner", UpdateBanner); +} diff --git a/ui/src/ui/e2e/agents-set-default-persistence.e2e.test.ts b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts similarity index 98% rename from ui/src/ui/e2e/agents-set-default-persistence.e2e.test.ts rename to ui/src/e2e/agents-set-default-persistence.e2e.test.ts index 76ab46340402..7ae82c25e12b 100644 --- a/ui/src/ui/e2e/agents-set-default-persistence.e2e.test.ts +++ b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts @@ -8,7 +8,7 @@ import { startControlUiE2eServer, type ControlUiE2eServer, type MockGatewayRequest, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); diff --git a/ui/src/ui/e2e/browser-talk-start-stop.e2e.test.ts b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts similarity index 99% rename from ui/src/ui/e2e/browser-talk-start-stop.e2e.test.ts rename to ui/src/e2e/browser-talk-start-stop.e2e.test.ts index 092886940f4a..5993203771de 100644 --- a/ui/src/ui/e2e/browser-talk-start-stop.e2e.test.ts +++ b/ui/src/e2e/browser-talk-start-stop.e2e.test.ts @@ -7,7 +7,7 @@ import { resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, type ControlUiE2eServer, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts similarity index 89% rename from ui/src/ui/e2e/chat-flow.e2e.test.ts rename to ui/src/e2e/chat-flow.e2e.test.ts index a30e85bba040..b327ba8d58c9 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -8,7 +8,7 @@ import { startControlUiE2eServer, type ControlUiE2eServer, type MockGatewayRequest, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); @@ -158,56 +158,6 @@ async function visibleChatBubbleTexts(page: Page): Promise { .filter(Boolean); }); } - -async function controlUiEventPayloads( - page: Page, - event: string, -): Promise>> { - return page.evaluate((eventName) => { - const app = document.querySelector("openclaw-app") as - | (Element & { eventLogBuffer?: unknown[] }) - | null; - return (app?.eventLogBuffer ?? []) - .filter((entry): entry is { event: string; payload: Record } => { - const candidate = entry as { event?: unknown; payload?: unknown }; - return ( - candidate.event === eventName && - Boolean(candidate.payload && typeof candidate.payload === "object") - ); - }) - .map((entry) => entry.payload); - }, event); -} - -async function waitForControlUiChatSendPhases( - page: Page, - runId: string, - phases: string[], -): Promise { - await page.waitForFunction( - ({ expectedPhases, expectedRunId }) => { - const app = document.querySelector("openclaw-app") as - | (Element & { eventLogBuffer?: unknown[] }) - | null; - const observedPhases = new Set( - (app?.eventLogBuffer ?? []).flatMap((entry) => { - const candidate = entry as { - event?: unknown; - payload?: { phase?: unknown; runId?: unknown }; - }; - return candidate.event === "control-ui.chat.send" && - candidate.payload?.runId === expectedRunId && - typeof candidate.payload.phase === "string" - ? [candidate.payload.phase] - : []; - }), - ); - return expectedPhases.every((phase) => observedPhases.has(phase)); - }, - { expectedPhases: phases, expectedRunId: runId }, - ); -} - function chatSessionListResponse() { return { count: 2, @@ -685,12 +635,6 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { expect(await page.locator(".markdown-plain-text-fallback strong").count()).toBe(0); await gateway.resolveDeferred("chat.send", { runId, status: "started" }); - await page.waitForFunction(() => { - const app = document.querySelector("openclaw-app") as - | (Element & { chatSending?: unknown }) - | null; - return app?.chatSending === false; - }); await page.locator(".chat-thread h2").getByText("Streaming heading").waitFor({ timeout: 10_000, }); @@ -798,28 +742,6 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { const runId = requireString(params.idempotencyKey, "chat send idempotency key"); await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); - await waitForControlUiChatSendPhases(page, runId, ["ack"]); - await gateway.emitGatewayEvent("chat.send_timing", { - phase: "agent-run-started", - runId, - agentId: "ops", - sessionKey: "global", - ackToPhaseMs: 11, - receivedToPhaseMs: 20, - dispatchStartedToPhaseMs: 7, - agentRunId: "agent-run-e2e", - }); - await waitForControlUiChatSendPhases(page, runId, ["server-agent-run-started"]); - await gateway.emitGatewayEvent("chat.send_timing", { - phase: "first-assistant-event", - runId, - agentId: "ops", - sessionKey: "global", - ackToPhaseMs: 31, - receivedToPhaseMs: 40, - dispatchStartedToPhaseMs: 27, - }); - await waitForControlUiChatSendPhases(page, runId, ["server-first-assistant-event"]); await gateway.emitGatewayEvent("chat", { deltaText: "First token visible.", message: { @@ -833,67 +755,6 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { state: "delta", }); await page.getByText("First token visible.").waitFor({ timeout: 10_000 }); - await waitForControlUiChatSendPhases(page, runId, [ - "pending-visible", - "request-start", - "ack", - "server-agent-run-started", - "server-first-assistant-event", - "first-assistant-visible", - ]); - const sendTimingEvents = (await controlUiEventPayloads(page, "control-ui.chat.send")).filter( - (payload) => payload.runId === runId, - ); - const sendTimingByPhase = new Map( - sendTimingEvents.map((payload) => [payload.phase, payload]), - ); - expect(sendTimingEvents.map((payload) => payload.phase)).toEqual( - expect.arrayContaining([ - "pending-visible", - "request-start", - "ack", - "server-first-assistant-event", - "first-assistant-visible", - ]), - ); - const ackTiming = sendTimingByPhase.get("ack"); - expect(ackTiming).toMatchObject({ - ackStatus: "started", - runId, - sendState: "sending", - sessionKey: "global", - }); - expect(ackTiming?.requestDurationMs).toEqual(expect.any(Number)); - expect(sendTimingByPhase.get("server-agent-run-started")).toMatchObject({ - agentRunId: "agent-run-e2e", - agentId: "ops", - runId, - serverAckToPhaseMs: 11, - serverDispatchStartedToPhaseMs: 7, - serverPhase: "agent-run-started", - serverReceivedToPhaseMs: 20, - sessionKey: "global", - }); - expect(sendTimingByPhase.get("server-first-assistant-event")).toMatchObject({ - agentId: "ops", - runId, - serverAckToPhaseMs: 31, - serverDispatchStartedToPhaseMs: 27, - serverPhase: "first-assistant-event", - serverReceivedToPhaseMs: 40, - sessionKey: "global", - }); - const firstVisibleTiming = sendTimingByPhase.get("first-assistant-visible"); - expect(firstVisibleTiming).toMatchObject({ - ackStatus: "started", - eventState: "delta", - runId, - sendState: "sending", - sessionKey: "global", - }); - expect(firstVisibleTiming?.ackToFirstAssistantEventMs).toEqual(expect.any(Number)); - expect(firstVisibleTiming?.firstAssistantPaintMs).toEqual(expect.any(Number)); - expect(firstVisibleTiming?.requestToFirstAssistantEventMs).toEqual(expect.any(Number)); await gateway.resolveDeferred("chat.startup", { agentsList: { agents: [{ id: "ops", name: "OpenClaw" }], @@ -970,7 +831,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); - it("keeps a delayed chat.send ACK visible as pending until the ACK resolves", async () => { + it("replaces the pending reading indicator with the streamed response", async () => { const context = await newBrowserContext({ locale: "en-US", serviceWorkers: "block", @@ -996,14 +857,30 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { const params = requireRecord(sendRequest.params); const runId = requireString(params.idempotencyKey, "chat send idempotency key"); - await page.locator(".chat-queue").getByText("Sending").waitFor({ timeout: 10_000 }); - await page.locator(".chat-queue").getByText(prompt).waitFor({ timeout: 10_000 }); await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + await page.locator(".chat-reading-indicator").waitFor({ timeout: 10_000 }); + expect(await page.locator(".chat-queue").count()).toBe(0); await gateway.resolveDeferred("chat.send", { runId, status: "started" }); - await page.locator(".chat-queue").waitFor({ state: "detached", timeout: 10_000 }); await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 }); + await page.locator(".chat-reading-indicator").waitFor({ timeout: 10_000 }); + + const response = "The streamed response is now visible."; + await gateway.emitGatewayEvent("chat", { + deltaText: response, + message: { + content: [{ text: response, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + + await page.getByText(response).waitFor({ timeout: 10_000 }); + await page.locator(".chat-reading-indicator").waitFor({ state: "detached", timeout: 10_000 }); } finally { await closeBrowserContext(context); } diff --git a/ui/src/ui/e2e/chat-picker-pagination.e2e.test.ts b/ui/src/e2e/chat-picker-pagination.e2e.test.ts similarity index 99% rename from ui/src/ui/e2e/chat-picker-pagination.e2e.test.ts rename to ui/src/e2e/chat-picker-pagination.e2e.test.ts index 6a1ec271c4e2..977bac73210e 100644 --- a/ui/src/ui/e2e/chat-picker-pagination.e2e.test.ts +++ b/ui/src/e2e/chat-picker-pagination.e2e.test.ts @@ -11,7 +11,7 @@ import { type ControlUiE2eServer, type MockGatewayControls, type MockGatewayRequest, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); diff --git a/ui/src/ui/e2e/chat-quota-pill-93041.e2e.test.ts b/ui/src/e2e/chat-quota-pill-93041.e2e.test.ts similarity index 98% rename from ui/src/ui/e2e/chat-quota-pill-93041.e2e.test.ts rename to ui/src/e2e/chat-quota-pill-93041.e2e.test.ts index 36867818a46f..a42460d1604d 100644 --- a/ui/src/ui/e2e/chat-quota-pill-93041.e2e.test.ts +++ b/ui/src/e2e/chat-quota-pill-93041.e2e.test.ts @@ -9,7 +9,7 @@ import { resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, type ControlUiE2eServer, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); diff --git a/ui/src/ui/e2e/cron-filters.e2e.test.ts b/ui/src/e2e/cron-filters.e2e.test.ts similarity index 99% rename from ui/src/ui/e2e/cron-filters.e2e.test.ts rename to ui/src/e2e/cron-filters.e2e.test.ts index f3d622da0576..bb2bda097855 100644 --- a/ui/src/ui/e2e/cron-filters.e2e.test.ts +++ b/ui/src/e2e/cron-filters.e2e.test.ts @@ -9,7 +9,7 @@ import { type ControlUiE2eServer, type MockGatewayControls, type MockGatewayRequest, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); diff --git a/ui/src/ui/e2e/mobile-pairing.e2e.test.ts b/ui/src/e2e/mobile-pairing.e2e.test.ts similarity index 98% rename from ui/src/ui/e2e/mobile-pairing.e2e.test.ts rename to ui/src/e2e/mobile-pairing.e2e.test.ts index 4d915e11be14..be6ad0f3e76f 100644 --- a/ui/src/ui/e2e/mobile-pairing.e2e.test.ts +++ b/ui/src/e2e/mobile-pairing.e2e.test.ts @@ -8,7 +8,7 @@ import { resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, type ControlUiE2eServer, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); diff --git a/ui/src/ui/e2e/session-management.e2e.test.ts b/ui/src/e2e/session-management.e2e.test.ts similarity index 99% rename from ui/src/ui/e2e/session-management.e2e.test.ts rename to ui/src/e2e/session-management.e2e.test.ts index 56e33e22d1a1..76de37919bb6 100644 --- a/ui/src/ui/e2e/session-management.e2e.test.ts +++ b/ui/src/e2e/session-management.e2e.test.ts @@ -11,7 +11,7 @@ import { type ControlUiE2eServer, type MockGatewayControls, type MockGatewayRequest, -} from "../../test-helpers/control-ui-e2e.ts"; +} from "../test-helpers/control-ui-e2e.ts"; const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 268ea5ea0ba8..a43b05c12ce2 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -5,5229 +5,5047 @@ "count": 1, "kind": "html-attribute", "name": "aria-label", - "path": "ui/src/ui/app-render.ts", - "text": "Workshop view" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/app-render.ts", - "text": "Board view" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/app-render.ts", - "text": "Today view" + "path": "ui/src/app/app-host.ts", + "text": "Close navigation" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/app-render.ts", + "path": "ui/src/components/app-sidebar.ts", + "text": "OpenClaw" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/components/app-topbar.ts", "text": "⌘K" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/app-render.ts", - "text": "Board" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/app-render.ts", - "text": "OpenClaw" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/app-render.ts", - "text": "Today" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/app-render.ts", - "text": "Discord" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/app-render.ts", - "text": "iMessage" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/app-render.ts", - "text": "Signal" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/app-render.ts", - "text": "Slack" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/app-render.ts", - "text": "Telegram" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/app-render.ts", - "text": "WhatsApp" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/app-settings.ts", - "text": "This connection does not have the operator.read scope. Some features may be unavailable." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/app-settings.ts", - "text": "Gateway Error" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/app-settings.ts", - "text": "Missing operator.read scope" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/app-settings.ts", - "text": "Skills with missing dependencies" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/chat/build-chat-items.ts", - "text": "The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view." - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/build-chat-items.ts", - "text": "Compacted history" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/build-chat-items.ts", - "text": "Open checkpoints" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/chat-queue.ts", - "text": "Remove queued message" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/chat-queue.ts", - "text": "Steer queued message" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/chat/chat-queue.ts", - "text": "Steer now" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/chat-queue.ts", - "text": "Steer" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/chat-queue.ts", - "text": "Steered" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/context-notice.ts", - "text": "Compact recommended session context" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/chat/context-notice.ts", - "text": "Compact session context" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Delete message" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Open in canvas" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Tool returned an error" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Delete" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Open in canvas" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Show message context details" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Activity" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Context" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Error" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "JSON" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Voice note" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Tool output" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Unknown date" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "Unknown date" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/realtime-talk-catalog.ts", - "text": "Google" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/realtime-talk-catalog.ts", - "text": "OpenAI" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Faster" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Model" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Reasoning" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Smarter" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Speed" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Auto" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Default" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Fast" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/session-controls.ts", - "text": "Standard" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/side-result-render.ts", - "text": "BTW side result" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/side-result-render.ts", - "text": "Dismiss BTW result" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/chat/side-result-render.ts", - "text": "Dismiss" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/side-result-render.ts", - "text": "BTW" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/side-result-render.ts", - "text": "Not saved to chat history" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/chat/slash-commands.ts", - "text": "Abort and restart with a new message" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/chat/slash-commands.ts", - "text": "Clear chat history" - }, - { - "count": 1, - "kind": "object-property", - "name": "help", - "path": "ui/src/ui/chat/slash-commands.ts", - "text": "book" - }, - { - "count": 1, - "kind": "object-property", - "name": "help", - "path": "ui/src/ui/chat/slash-commands.ts", - "text": "tools" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/tool-cards.ts", - "text": "Open tool details in side panel" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/chat/tool-cards.ts", - "text": "Tool returned an error" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/chat/tool-cards.ts", - "text": "Open in the side panel" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/tool-cards.ts", - "text": "Error" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/tool-cards.ts", - "text": "Raw details" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/tool-cards.ts", - "text": "Tool input" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/chat/tool-cards.ts", - "text": "Tool output" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/components/dashboard-header.ts", - "text": "OpenClaw" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/components/file-preview-modal.ts", - "text": "Close" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/components/file-preview-modal.ts", + "path": "ui/src/components/command-palette.ts", "text": "esc" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/components/file-preview-modal.ts", - "text": "navigate" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/components/file-preview-modal.ts", - "text": "No files match." + "kind": "object-property", + "name": "description", + "path": "ui/src/components/command-palette.ts", + "text": "Toggle verbose mode." }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/navigation.ts", - "text": "agent" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/navigation.ts", - "text": "chat" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/navigation.ts", - "text": "control" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/navigation.ts", - "text": "settings" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Open Files tab" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "×" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Fallbacks" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Model Selection" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Not set" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Overview" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Primary Model" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Runtime" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Skills Filter" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Workspace" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "Workspace paths and identity metadata." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-overview.ts", - "text": "You have unsaved config changes." + "path": "ui/src/components/command-palette.ts", + "text": "/verbose" }, { "count": 1, "kind": "html-attribute", "name": "aria-label", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Tool preview" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Search skills" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Remove per-agent allowlist and use all skills" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Access" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "All skills are enabled. Disabling any skill will create a per-agent allowlist." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Available Right Now" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Could not load available tools for this session." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Could not load runtime tool catalog. Showing built-in fallback list instead." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Current Session" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Default Presets" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Disable All" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Enable All" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Enabled" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "enabled." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Filter" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Inherit" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Link to This Tool" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Live" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Load skills for this agent to view workspace-specific entries." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Load the gateway config to adjust tool profiles." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Load the gateway config to set per-agent skills." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Loading available tools…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Loading runtime tool catalog…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "No skills found." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "No tools are available for this session right now." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Profile" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Profile + per-tool overrides for this agent." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Quick Presets" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Reset" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Session" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Skills" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Source" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Status" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Switch chat to this agent to view its live runtime tools." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "This agent uses a custom skill allowlist." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "Tool Access" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/agents-panels-tools-skills.ts", - "text": "What this agent can use in the current chat session." - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Control canvases" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Control web browser" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Create or overwrite files" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Fetch web content" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Gateway control" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Image understanding" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "List agents" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "List sessions" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Make precise edits" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Manage background processes" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Nodes + devices" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Patch files (OpenAI)" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Read file contents" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Read memory files" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Run shell commands" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Schedule tasks" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Search the web" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Semantic search" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Send messages" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Send to session" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Session history" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Session status" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Spawn sub-agent" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Agents" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "agents_list" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "apply_patch" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Automation" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "browser" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "canvas" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Coding" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "cron" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "edit" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "exec" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Files" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Full" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "gateway" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "image" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Media" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Memory" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "memory_get" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "memory_search" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "message" - }, - { - "count": 2, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Messaging" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Minimal" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "nodes" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Nodes" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "process" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "read" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Runtime" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "session_status" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Sessions" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "sessions_history" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "sessions_list" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "sessions_send" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "sessions_spawn" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "UI" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "Web" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "web_fetch" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "web_search" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/agents-utils.ts", - "text": "write" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.config.ts", - "text": "Channel config schema unavailable." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.config.ts", - "text": "Loading config schema…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.config.ts", - "text": "Schema unavailable. Use Raw." - }, - { - "count": 1, - "kind": "object-property", - "name": "subtitle", - "path": "ui/src/ui/views/channels.discord.ts", - "text": "Bot status and channel configuration." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/channels.discord.ts", - "text": "Discord" - }, - { - "count": 1, - "kind": "object-property", - "name": "subtitle", - "path": "ui/src/ui/views/channels.googlechat.ts", - "text": "Chat API webhook status and channel configuration." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/channels.googlechat.ts", - "text": "Google Chat" - }, - { - "count": 1, - "kind": "object-property", - "name": "subtitle", - "path": "ui/src/ui/views/channels.imessage.ts", - "text": "macOS bridge status and channel configuration." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/channels.imessage.ts", - "text": "iMessage" - }, - { - "count": 1, - "kind": "object-property", - "name": "placeholder", - "path": "ui/src/ui/views/channels.nostr-profile-form.ts", - "text": "https://example.com" - }, - { - "count": 1, - "kind": "object-property", - "name": "placeholder", - "path": "ui/src/ui/views/channels.nostr-profile-form.ts", - "text": "https://example.com/avatar.jpg" - }, - { - "count": 1, - "kind": "object-property", - "name": "placeholder", - "path": "ui/src/ui/views/channels.nostr-profile-form.ts", - "text": "https://example.com/banner.jpg" - }, - { - "count": 1, - "kind": "object-property", - "name": "placeholder", - "path": "ui/src/ui/views/channels.nostr-profile-form.ts", - "text": "satoshi" - }, - { - "count": 1, - "kind": "object-property", - "name": "placeholder", - "path": "ui/src/ui/views/channels.nostr-profile-form.ts", - "text": "Satoshi Nakamoto" - }, - { - "count": 1, - "kind": "object-property", - "name": "placeholder", - "path": "ui/src/ui/views/channels.nostr-profile-form.ts", - "text": "you@example.com" - }, - { - "count": 1, - "kind": "object-property", - "name": "placeholder", - "path": "ui/src/ui/views/channels.nostr-profile-form.ts", - "text": "you@getalby.com" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.nostr.ts", - "text": "Decentralized DMs via Nostr relays (NIP-04)." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.nostr.ts", - "text": "NIP-05" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.nostr.ts", - "text": "Nostr" - }, - { - "count": 1, - "kind": "object-property", - "name": "subtitle", - "path": "ui/src/ui/views/channels.signal.ts", - "text": "signal-cli status and channel configuration." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/channels.signal.ts", - "text": "Signal" - }, - { - "count": 1, - "kind": "object-property", - "name": "subtitle", - "path": "ui/src/ui/views/channels.slack.ts", - "text": "Socket mode status and channel configuration." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/channels.slack.ts", - "text": "Slack" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.telegram.ts", - "text": "Bot status and channel configuration." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.telegram.ts", - "text": "Telegram" - }, - { - "count": 1, - "kind": "object-property", - "name": "subtitle", - "path": "ui/src/ui/views/channels.telegram.ts", - "text": "Bot status and channel configuration." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/channels.telegram.ts", - "text": "Telegram" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/channels.ts", - "text": "Refreshing channel status in the background; showing the last successful snapshot." - }, - { - "count": 1, - "kind": "object-property", - "name": "subtitle", - "path": "ui/src/ui/views/channels.whatsapp.ts", - "text": "Link WhatsApp Web and monitor connection health." - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/channels.whatsapp.ts", - "text": "WhatsApp" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Cancel reply" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Close search" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Command arguments" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Dismiss error" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Exit focus mode" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Loading chat" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Remove attachment" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Search messages" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Slash commands" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Talk options" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/chat.ts", - "text": "Talk settings" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/chat.ts", - "text": "Auto" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/chat.ts", - "text": "Search messages..." - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/chat.ts", - "text": "Cancel reply" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/chat.ts", - "text": "Dismiss error" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/chat.ts", - "text": "Exit focus mode" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/chat.ts", - "text": "Talk settings" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/chat.ts", - "text": "Unpin" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "×" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Advanced" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "close" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Enter" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Esc" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Exact VAD" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "fill" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "instant" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Lead-in" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Model" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "navigate" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "No matching messages" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Pause before send" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "run" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "select" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Tab" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/chat.ts", - "text": "Talk settings" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Alloy" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Ash" - }, - { - "count": 3, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Auto" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Ballad" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Cedar" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Coral" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Custom" - }, - { - "count": 3, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Default" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Echo" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Gateway relay" - }, - { - "count": 2, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "High" - }, - { - "count": 2, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Low" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Marin" - }, - { - "count": 2, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Medium" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Minimal" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Provider" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Provider WebSocket" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Reasoning" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Sage" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Sensitivity" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Shimmer" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Transport" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Verse" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "Voice" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/chat.ts", - "text": "WebRTC" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/command-palette.ts", - "text": "esc" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/config-form.node.ts", - "text": "Key" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "Remove entry" }, { "count": 1, "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/config-form.node.ts", + "name": "aria-label", + "path": "ui/src/components/config-form.node.ts", "text": "Remove item" }, { "count": 1, "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/config-form.node.ts", + "name": "aria-label", + "path": "ui/src/components/config-form.node.ts", "text": "Reset to default" }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/components/config-form.node.ts", + "text": "Key" + }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "Add" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "Add Entry" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "Custom entries" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "No custom entries." }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "No items yet. Click \"Add\" to create one." }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "Select..." }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "Unsupported array schema. Use Raw mode." }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.node.ts", + "path": "ui/src/components/config-form.node.ts", "text": "Unsupported schema node. Use Raw mode." }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Schema unavailable." }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Unsupported schema. Use Raw." }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Agent Communication Protocol runtime and streaming settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Agent configurations, models, and identities" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "AI model configurations and providers" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "API keys and authentication profiles" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Audio input/output settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Auto-update settings and release channel" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Broadcast and notification settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Browser automation settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Canvas rendering and display" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "CLI banner and startup behavior" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Custom slash commands" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Environment variables passed to the gateway process" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Gateway metadata and version information" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Gateway server settings (port, auth, binding)" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Instrumentation, OpenTelemetry, and cache-trace settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Key bindings and shortcuts" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Log levels and output configuration" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Message handling and routing settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Messaging channels (Telegram, Discord, Slack, etc.)" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Model Context Protocol server definitions" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Plugin management and extensions" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Scheduled tasks and automation" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Secret provider configuration" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Service discovery and networking" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Session management and persistence" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Setup wizard state and history" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Skill packs and capabilities" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Tool configurations (browser, search, etc.)" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "User interface preferences" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Voice and speech settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Web server and API settings" }, { "count": 1, "kind": "object-property", "name": "description", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Webhooks and event hooks" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "ACP" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Agents" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Audio" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Authentication" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Bindings" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Broadcast" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Browser" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Canvas Host" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Channels" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "CLI" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Commands" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Cron" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Diagnostics" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Discovery" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Environment Variables" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Gateway" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Hooks" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Logging" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "MCP" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Messages" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Metadata" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Models" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Plugins" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Secrets" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Session" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Setup Wizard" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Skills" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Talk" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Tools" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "UI" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Updates" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/config-form.render.ts", + "path": "ui/src/components/config-form.render.ts", "text": "Web" }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Balanced default for daily use." - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Highest context budget for repo work." - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Lean follow-ups for shared bots." - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Smallest context budget and lowest cost." - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Code Agent" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Minimal" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Personal Assistant" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-presets.ts", - "text": "Team Bot" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Assistant identity" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Your local chat identity" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/config-quick.ts", - "text": "JD or 🦞" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Assistant" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Avatar is browser-local" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Avatar text / emoji" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Bootstrap Context" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Browse →" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Choose a built-in profile to replace the current custom values." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Choose how much workspace context OpenClaw injects into each run. These profiles do not change your model, tools, channels, or theme." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Choose image" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Clear avatar" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Clear override" - }, { "count": 2, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Configure →" + "path": "ui/src/components/dashboard-header.ts", + "text": "OpenClaw" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Connect →" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Current" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Custom" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Custom bootstrap settings are active." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Device auth" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Discard" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Exec policy" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Fast mode" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Gateway auth" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Manage →" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Mode" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Model" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "No channels configured" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Pending" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Profiles only change bootstrap size and follow-up reinjection behavior." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Roundness" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Save Profile writes it as the default. Apply Now writes it and reloads the current session." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Saved" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Selected" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Stored in this browser only." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Stores a Control UI override. Clear it to return to IDENTITY.md." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Text size" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Theme" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Thinking" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config-quick.ts", - "text": "User" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Bootstrap Per File" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Bootstrap Total" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Claw" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Dash" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Default" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Follow-up Turns" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Full" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Knot" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "L" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "M" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "None" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Round" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "S" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "Slight" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "XL" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config-quick.ts", - "text": "XXL" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/config.ts", - "text": "Clear search" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/config.ts", - "text": "Search settings" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/config.ts", - "text": "Toggle raw config redaction" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/config.ts", - "text": "https://tweakcn.com/editor/theme?theme=... or amethyst-haze" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/config.ts", - "text": "Raw config (JSON/JSON5)" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/config.ts", - "text": "Search settings..." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Adjust corner radius across the UI." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Assistant" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Browser support" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Changes detected (JSON diff not available)" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Choose a theme family." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Clear" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Click" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Connection" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Don't remind again" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Form" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Gateway" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Import" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Import from tweakcn" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Loaded" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Loading schema…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "No changes" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Not available in this browser." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Notifications are blocked. Update your browser site permissions to allow notifications." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Open tweakcn.com, choose or create a theme, click Share, then paste the copied theme link here. Share links, editor URLs, registry URLs, theme IDs, and default theme names like amethyst-haze are accepted." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Peek" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Permission" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Push notifications" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Quick Settings" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Raw" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Raw mode disabled (snapshot cannot safely round-trip raw text)." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Receive browser push notifications from your gateway." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Roundness" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Status" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Text size" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Theme" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Theme link or ID" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "to add one browser-local tweakcn theme. In tweakcn, use Share and paste the copied link here." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Unavailable" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "View pending changes" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Your config contains fields the form editor can't safely represent. Use Raw mode to edit those entries." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/config.ts", - "text": "Your configuration is invalid. Some settings may not work as expected." - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/config.ts", - "text": "Black & red" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/config.ts", - "text": "Chocolate blueprint" - }, - { - "count": 1, - "kind": "object-property", - "name": "description", - "path": "ui/src/ui/views/config.ts", - "text": "Chroma family" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Acp" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Agents" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "AI & Agents" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Approvals" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Audio" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Authentication" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Automation" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Bindings" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Broadcast" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Browser" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "CanvasHost" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Channels" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Claw" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Cli" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Commands" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Communication" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Core" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Cron" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Dash" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Diagnostics" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Discovery" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Environment" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Gateway" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Hooks" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Infrastructure" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Knot" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Logging" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Mcp" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Media" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Memory" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Messages" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Meta" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Models" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "NodeHost" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Notifications" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Other" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Plugins" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Secrets" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Session" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Setup Wizard" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Skills" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Talk" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Theme" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Tools" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "UI" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Updates" - }, - { - "count": 1, - "kind": "object-property", - "name": "label", - "path": "ui/src/ui/views/config.ts", - "text": "Web" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/cron.ts", - "text": "+1555... or chat id" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/cron.ts", - "text": "Account ID for multi-account setups" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/cron.ts", - "text": "agent:main:main" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/cron.ts", - "text": "default" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Announce (via channel)" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Consecutive errors before alerting." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Control when this job sends repeated-failure alerts." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Custom per-job settings" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Disable for this job" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Inherit global setting" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Light context" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Minimum seconds between alerts." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Optional channel account ID for multi-account setups." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Optional recipient override for failure alerts." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Optional routing key for job delivery and wake routing." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Run if due" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Use lightweight bootstrap context for this agent job." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/cron.ts", - "text": "Webhook (HTTP POST)" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/debug.ts", - "text": "openclaw security audit --deep" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": ", then reload this tab." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Claims" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", + "path": "ui/src/components/file-preview-modal.ts", "text": "Close" }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Contradictions" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Copy archive path" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Corrections or revisions" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Dreams" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Enable" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Ended on:" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Id:" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Import details" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Imported Insights" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Imported Insights and Memory Palace are provided by the bundled" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Labels:" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Loading imported insights…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Loading memory palace…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Loading wiki page…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Memory Palace" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Memory palace is not populated yet" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Memory Wiki is not enabled" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "memory-wiki" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Messages:" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "No imported insights yet" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Open Config" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Open questions" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Open source page" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Open wiki page" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Page details" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "plugin." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "plugins.entries.memory-wiki.enabled = true" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Potentially useful signals" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Right now the wiki mostly has raw source imports and operational reports. This tab becomes useful once syntheses, entities, or concepts start getting written." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Risk reasons:" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Run a ChatGPT import with apply to surface clustered imported insights here." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Started with:" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "These are imported insights clustered from external history; use them to review what imports surfaced before any of it graduates into durable memory." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "This is the compiled memory wiki surface the system can search and reason over; use it to inspect actual memory pages, claims, open questions, and contradictions rather than raw imported source chats." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "This is the raw dream diary the system writes while replaying and consolidating memory; use it to inspect what the memory system is noticing, and where it still looks noisy or thin." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Wiki page:" - }, { "count": 2, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "z" + "path": "ui/src/components/file-preview-modal.ts", + "text": "esc" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/dreaming.ts", - "text": "Z" + "path": "ui/src/components/file-preview-modal.ts", + "text": "navigate" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/components/file-preview-modal.ts", + "text": "No files match." }, { "count": 1, "kind": "html-attribute", "name": "placeholder", - "path": "ui/src/ui/views/login-gate.ts", + "path": "ui/src/components/login-gate.ts", "text": "ws://127.0.0.1:18789" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/login-gate.ts", + "path": "ui/src/components/login-gate.ts", "text": "OpenClaw" }, { "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/markdown-sidebar.ts", - "text": "Close sidebar" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Control canvases" }, { "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/markdown-sidebar.ts", - "text": "Close sidebar" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Control web browser" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/markdown-sidebar.ts", - "text": "No content available" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Create or overwrite files" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/markdown-sidebar.ts", - "text": "No previewable markdown content." + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Fetch web content" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/markdown-sidebar.ts", - "text": "Rendered Markdown" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Gateway control" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/markdown-sidebar.ts", - "text": "Sanitized rich-text preview for quick reading." - }, - { - "count": 4, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/markdown-sidebar.ts", - "text": "View Raw Text" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Image understanding" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "Configured servers" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "List agents" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "Enabled" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "List sessions" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "Filtered" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Make precise edits" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "MCP operator commands" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Manage background processes" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "No MCP servers configured." + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Nodes + devices" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "OAuth" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Patch files (OpenAI)" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "openclaw mcp doctor --probe" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Read file contents" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "openclaw mcp login <name>" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Read memory files" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "openclaw mcp reload" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Run shell commands" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "openclaw mcp status --verbose" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Schedule tasks" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "parallel" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Search the web" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "Runtime changes apply after save and publish; active agents rebuild MCP runtimes on next use." + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Semantic search" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "Save" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Send messages" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "Servers" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Send to session" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "Status, diagnostics, auth, probing, and runtime reload." + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Session history" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/mcp.ts", - "text": "tool filter" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Session status" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Add pattern" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Allowlist" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Allowlist and approval policy for" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Ask" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Ask fallback" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Auto-allow skill CLIs" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Case-insensitive glob patterns." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Defaults" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Enabled" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Exec approvals" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "exec host=gateway/node" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Fallback" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Gateway" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Gateway edits local approvals; node edits the selected node." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Host" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Load exec approvals to edit allowlists." - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Mode" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "No allowlist entries yet." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "No nodes advertise exec approvals yet." - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Node" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Pattern" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Remove" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Scope" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Security" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Select node" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Target" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Use default" + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/agents/display.ts", + "text": "Spawn sub-agent" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Allowlist" + "path": "ui/src/lib/agents/display.ts", + "text": "Agents" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Always" + "path": "ui/src/lib/agents/display.ts", + "text": "agents_list" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Deny" + "path": "ui/src/lib/agents/display.ts", + "text": "apply_patch" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", + "path": "ui/src/lib/agents/display.ts", + "text": "Automation" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "browser" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "canvas" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Coding" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "cron" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "edit" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "exec" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Files" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", "text": "Full" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "Off" + "path": "ui/src/lib/agents/display.ts", + "text": "gateway" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/nodes-exec-approvals.ts", - "text": "On miss" + "path": "ui/src/lib/agents/display.ts", + "text": "image" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Any node" + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Media" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Approve" + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Memory" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Binding" + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "memory_get" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Devices" + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "memory_search" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "No agents found." + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "message" + }, + { + "count": 2, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Messaging" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "No nodes found." + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Minimal" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "No nodes with system.run available." + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "nodes" }, { "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "No paired devices." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", "text": "Nodes" }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Paired" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Paired devices and live links." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Pairing requests + role tokens." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Pending" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Reject" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Revoke" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Rotate" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Tokens" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Tokens: none" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/nodes.ts", - "text": "Use default" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/overview.ts", - "text": "OPENCLAW_GATEWAY_TOKEN" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/overview.ts", - "text": "ws://100.x.y.z:18789" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "?token=" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": ". Query parameters (" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": ") may appear in server logs." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "#token=<token>" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "→ set token" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "→ tokenized URL" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "Auth token must be passed as a URL fragment:" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "openclaw dashboard --no-open" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "openclaw devices list" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/overview.ts", - "text": "openclaw doctor --generate-gateway-token" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/sessions.ts", - "text": "Session filters" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/sessions.ts", - "text": "Previous" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Close" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "No Skill Workshop proposals" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "aria-label", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Resize proposal list" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Example: Make this use Gmail labels instead of unread search, and add a safer dry-run step." - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Search proposals…" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Close" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Next" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Previous" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "· click to preview" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "0 support files" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Add to your skills" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Ask the agent to change something" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Browse what's already applied." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Cancel" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Drafted by" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Loading proposal…" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Manage →" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "New proposals will appear here for review." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "No proposals yet" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Not for me" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Nothing waiting today" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Preparing revision handoff" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "See all proposals →" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Skill Workshop" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Support files" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Tell the agent what should change. The proposal stays pending and the workshop will create a revised version." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Your agent hasn't drafted anything new. Switch to Board to browse history." - }, { "count": 1, "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "No matching proposals" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "No pending proposals" - }, - { - "count": 2, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "No proposals here" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "No rejected proposals" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "No stale proposals" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Nothing applied yet" - }, - { - "count": 1, - "kind": "object-property", - "name": "title", - "path": "ui/src/ui/views/skill-workshop.ts", - "text": "Nothing quarantined" + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "process" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills-grouping.ts", + "path": "ui/src/lib/agents/display.ts", + "text": "read" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Runtime" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "session_status" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Sessions" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "sessions_history" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "sessions_list" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "sessions_send" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "sessions_spawn" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "UI" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "Web" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "web_fetch" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "web_search" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/agents/display.ts", + "text": "write" + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/chat/commands.ts", + "text": "Abort and restart with a new message" + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/lib/chat/commands.ts", + "text": "Clear chat history" + }, + { + "count": 1, + "kind": "object-property", + "name": "help", + "path": "ui/src/lib/chat/commands.ts", + "text": "book" + }, + { + "count": 1, + "kind": "object-property", + "name": "help", + "path": "ui/src/lib/chat/commands.ts", + "text": "tools" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/chat/model-select-state.ts", + "text": "Auto" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/chat/model-select-state.ts", + "text": "Default" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/chat/model-select-state.ts", + "text": "Fast" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/chat/model-select-state.ts", + "text": "Standard" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/lib/skills-grouping.ts", "text": "Built-in Skills" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills-grouping.ts", + "path": "ui/src/lib/skills-grouping.ts", "text": "Extra Skills" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills-grouping.ts", + "path": "ui/src/lib/skills-grouping.ts", "text": "Installed Skills" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills-grouping.ts", + "path": "ui/src/lib/skills-grouping.ts", "text": "Other Skills" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills-grouping.ts", + "path": "ui/src/lib/skills-grouping.ts", "text": "Workspace Skills" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills-shared.ts", + "path": "ui/src/lib/skills-shared.ts", "text": "bundled" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills-shared.ts", + "path": "ui/src/lib/skills-shared.ts", "text": "disabled" }, { "count": 1, "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/skills.ts", - "text": "Filter installed skills" - }, - { - "count": 1, - "kind": "html-attribute", - "name": "placeholder", - "path": "ui/src/ui/views/skills.ts", - "text": "Search ClawHub skills…" + "name": "aria-label", + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Open Files tab" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "API key" + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "×" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "ClawHub" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "ClawHub link invalid" - }, - { - "count": 2, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "Close" + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Fallbacks" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "Full security report" + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Model Selection" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "Get your key:" + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Not set" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "Installed skills and their status." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "Missing requirements" - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skills.ts", - "text": "No skills found on ClawHub." - }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/agents/panels-overview.ts", "text": "Overview" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Primary Model" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Runtime" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Skills Filter" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Workspace" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "Workspace paths and identity metadata." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-overview.ts", + "text": "You have unsaved config changes." + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Tool preview" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Search skills" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Access" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "All skills are enabled. Disabling any skill will create a per-agent allowlist." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Available Right Now" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Could not load available tools for this session." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Could not load runtime tool catalog. Showing built-in fallback list instead." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Current Session" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Default Presets" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Disable All" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Enable All" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Enabled" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "enabled." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Filter" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Inherit" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Link to This Tool" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Live" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Load skills for this agent to view workspace-specific entries." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Load the gateway config to adjust tool profiles." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Load the gateway config to set per-agent skills." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Loading available tools…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Loading runtime tool catalog…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "No skills found." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "No tools are available for this session right now." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Profile" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Profile + per-tool overrides for this agent." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Quick Presets" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Reset" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Session" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Skills" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Source" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Status" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Switch chat to this agent to view its live runtime tools." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "This agent uses a custom skill allowlist." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "Tool Access" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/agents/panels-tools-skills.ts", + "text": "What this agent can use in the current chat session." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.config.ts", + "text": "Channel config schema unavailable." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.config.ts", + "text": "Loading config schema…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.config.ts", + "text": "Schema unavailable. Use Raw." + }, + { + "count": 1, + "kind": "object-property", + "name": "subtitle", + "path": "ui/src/pages/channels/view.discord.ts", + "text": "Bot status and channel configuration." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/channels/view.discord.ts", + "text": "Discord" + }, + { + "count": 1, + "kind": "object-property", + "name": "subtitle", + "path": "ui/src/pages/channels/view.googlechat.ts", + "text": "Chat API webhook status and channel configuration." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/channels/view.googlechat.ts", + "text": "Google Chat" + }, + { + "count": 1, + "kind": "object-property", + "name": "subtitle", + "path": "ui/src/pages/channels/view.imessage.ts", + "text": "macOS bridge status and channel configuration." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/channels/view.imessage.ts", + "text": "iMessage" + }, + { + "count": 1, + "kind": "object-property", + "name": "placeholder", + "path": "ui/src/pages/channels/view.nostr-profile-form.ts", + "text": "https://example.com" + }, + { + "count": 1, + "kind": "object-property", + "name": "placeholder", + "path": "ui/src/pages/channels/view.nostr-profile-form.ts", + "text": "https://example.com/avatar.jpg" + }, + { + "count": 1, + "kind": "object-property", + "name": "placeholder", + "path": "ui/src/pages/channels/view.nostr-profile-form.ts", + "text": "https://example.com/banner.jpg" + }, + { + "count": 1, + "kind": "object-property", + "name": "placeholder", + "path": "ui/src/pages/channels/view.nostr-profile-form.ts", + "text": "satoshi" + }, + { + "count": 1, + "kind": "object-property", + "name": "placeholder", + "path": "ui/src/pages/channels/view.nostr-profile-form.ts", + "text": "Satoshi Nakamoto" + }, + { + "count": 1, + "kind": "object-property", + "name": "placeholder", + "path": "ui/src/pages/channels/view.nostr-profile-form.ts", + "text": "you@example.com" + }, + { + "count": 1, + "kind": "object-property", + "name": "placeholder", + "path": "ui/src/pages/channels/view.nostr-profile-form.ts", + "text": "you@getalby.com" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.nostr.ts", + "text": "Decentralized DMs via Nostr relays (NIP-04)." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.nostr.ts", + "text": "NIP-05" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.nostr.ts", + "text": "Nostr" + }, + { + "count": 1, + "kind": "object-property", + "name": "subtitle", + "path": "ui/src/pages/channels/view.signal.ts", + "text": "signal-cli status and channel configuration." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/channels/view.signal.ts", + "text": "Signal" + }, + { + "count": 1, + "kind": "object-property", + "name": "subtitle", + "path": "ui/src/pages/channels/view.slack.ts", + "text": "Socket mode status and channel configuration." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/channels/view.slack.ts", + "text": "Slack" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.telegram.ts", + "text": "Bot status and channel configuration." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.telegram.ts", + "text": "Telegram" + }, + { + "count": 1, + "kind": "object-property", + "name": "subtitle", + "path": "ui/src/pages/channels/view.telegram.ts", + "text": "Bot status and channel configuration." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/channels/view.telegram.ts", + "text": "Telegram" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/channels/view.ts", + "text": "Refreshing channel status in the background; showing the last successful snapshot." + }, + { + "count": 1, + "kind": "object-property", + "name": "subtitle", + "path": "ui/src/pages/channels/view.whatsapp.ts", + "text": "Link WhatsApp Web and monitor connection health." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/channels/view.whatsapp.ts", + "text": "WhatsApp" + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/chat/chat-thread.ts", + "text": "The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view." + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/chat-thread.ts", + "text": "Compacted history" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/chat-thread.ts", + "text": "Open checkpoints" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/chat-view.ts", + "text": "Dismiss error" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/chat-view.ts", + "text": "Exit focus mode" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "BTW side result" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Cancel reply" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Command arguments" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Compact recommended session context" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Dismiss BTW result" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Remove attachment" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Remove queued message" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Slash commands" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Steer queued message" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Talk settings" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "title", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Cancel reply" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "×" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "BTW" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "close" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Enter" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Esc" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "fill" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "instant" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "navigate" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Not saved to chat history" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "run" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "select" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Steer" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Steered" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Tab" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer.ts", + "text": "Talk settings" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Delete message" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Open in canvas" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Tool returned an error" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Activity" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Context" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Error" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "JSON" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Voice note" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Tool output" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Unknown date" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/chat/components/chat-message.ts", + "text": "Unknown date" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-model-controls.ts", + "text": "Faster" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-model-controls.ts", + "text": "Model" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-model-controls.ts", + "text": "Reasoning" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-model-controls.ts", + "text": "Smarter" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-model-controls.ts", + "text": "Speed" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Talk options" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Auto" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Advanced" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Exact VAD" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Lead-in" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Model" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Pause before send" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Alloy" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Ash" + }, + { + "count": 3, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Auto" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Ballad" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Cedar" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Coral" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Custom" + }, + { + "count": 3, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Default" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Echo" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Gateway relay" + }, + { + "count": 2, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "High" + }, + { + "count": 2, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Low" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Marin" + }, + { + "count": 2, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Medium" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Minimal" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Provider" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Provider WebSocket" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Reasoning" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Sage" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Sensitivity" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Shimmer" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Transport" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Verse" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "Voice" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-realtime-controls.ts", + "text": "WebRTC" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-sidebar.ts", + "text": "Close sidebar" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-sidebar.ts", + "text": "No content available" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-sidebar.ts", + "text": "No previewable markdown content." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-sidebar.ts", + "text": "Rendered Markdown" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-sidebar.ts", + "text": "Sanitized rich-text preview for quick reading." + }, + { + "count": 4, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-sidebar.ts", + "text": "View Raw Text" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-thread.ts", + "text": "Close search" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-thread.ts", + "text": "Loading chat" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-thread.ts", + "text": "Search messages" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-thread.ts", + "text": "Unpin" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/chat/components/chat-thread.ts", + "text": "Search messages..." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-thread.ts", + "text": "No matching messages" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-tool-cards.ts", + "text": "Open tool details in side panel" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/chat/components/chat-tool-cards.ts", + "text": "Tool returned an error" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-tool-cards.ts", + "text": "Error" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-tool-cards.ts", + "text": "Raw details" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-tool-cards.ts", + "text": "Tool input" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/components/chat-tool-cards.ts", + "text": "Tool output" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/realtime-talk-catalog.ts", + "text": "Google" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/chat/realtime-talk-catalog.ts", + "text": "OpenAI" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/config-page.ts", + "text": "Discord" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/config-page.ts", + "text": "iMessage" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/config-page.ts", + "text": "Signal" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/config-page.ts", + "text": "Slack" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/config-page.ts", + "text": "Telegram" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/config-page.ts", + "text": "WhatsApp" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "Configured servers" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "Enabled" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "Filtered" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "MCP operator commands" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "No MCP servers configured." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "OAuth" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "openclaw mcp doctor --probe" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "openclaw mcp login <name>" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "openclaw mcp reload" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "openclaw mcp status --verbose" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "parallel" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "Runtime changes apply after save and publish; active agents rebuild MCP runtimes on next use." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "Save" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "Servers" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "Status, diagnostics, auth, probing, and runtime reload." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/mcp.ts", + "text": "tool filter" + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/config/presets.ts", + "text": "Balanced default for daily use." + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/config/presets.ts", + "text": "Highest context budget for repo work." + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/config/presets.ts", + "text": "Lean follow-ups for shared bots." + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/config/presets.ts", + "text": "Smallest context budget and lowest cost." + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/presets.ts", + "text": "Code Agent" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/presets.ts", + "text": "Minimal" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/presets.ts", + "text": "Personal Assistant" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/presets.ts", + "text": "Team Bot" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/config/quick.ts", + "text": "Assistant identity" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/config/quick.ts", + "text": "Your local chat identity" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/config/quick.ts", + "text": "JD or 🦞" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Assistant" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Avatar is browser-local" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Avatar text / emoji" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Bootstrap Context" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Browse →" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Choose a built-in profile to replace the current custom values." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Choose how much workspace context OpenClaw injects into each run. These profiles do not change your model, tools, channels, or theme." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Choose image" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Clear avatar" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Clear override" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Configure →" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Connect →" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Current" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Custom" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Custom bootstrap settings are active." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Device auth" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Discard" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Exec policy" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Fast mode" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Gateway auth" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Manage →" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Mode" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Model" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "No channels configured" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Pending" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Profiles only change bootstrap size and follow-up reinjection behavior." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Roundness" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Save Profile writes it as the default. Apply Now writes it and reloads the current session." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Saved" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Selected" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Stored in this browser only." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Stores a Control UI override. Clear it to return to IDENTITY.md." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Text size" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Theme" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "Thinking" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/quick.ts", + "text": "User" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Bootstrap Per File" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Bootstrap Total" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Claw" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Dash" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Default" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Follow-up Turns" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Full" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Knot" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "L" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "M" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "None" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Round" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "S" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "Slight" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "XL" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/quick.ts", + "text": "XXL" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/config/view.ts", + "text": "Clear search" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/config/view.ts", + "text": "Search settings" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/config/view.ts", + "text": "Toggle raw config redaction" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/config/view.ts", + "text": "https://tweakcn.com/editor/theme?theme=... or amethyst-haze" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/config/view.ts", + "text": "Raw config (JSON/JSON5)" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/config/view.ts", + "text": "Search settings..." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Adjust corner radius across the UI." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Assistant" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Browser support" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Changes detected (JSON diff not available)" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Choose a theme family." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Clear" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Click" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Connection" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Don't remind again" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Form" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Gateway" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Import" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Import from tweakcn" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Loaded" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Loading schema…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "No changes" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Not available in this browser." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Notifications are blocked. Update your browser site permissions to allow notifications." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Open tweakcn.com, choose or create a theme, click Share, then paste the copied theme link here. Share links, editor URLs, registry URLs, theme IDs, and default theme names like amethyst-haze are accepted." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Peek" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Permission" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Push notifications" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Quick Settings" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Raw" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Raw mode disabled (snapshot cannot safely round-trip raw text)." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Receive browser push notifications from your gateway." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Roundness" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Status" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Text size" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Theme" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Theme link or ID" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "to add one browser-local tweakcn theme. In tweakcn, use Share and paste the copied link here." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Unavailable" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "View pending changes" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Your config contains fields the form editor can't safely represent. Use Raw mode to edit those entries." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/config/view.ts", + "text": "Your configuration is invalid. Some settings may not work as expected." + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/config/view.ts", + "text": "Black & red" + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/config/view.ts", + "text": "Chocolate blueprint" + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/config/view.ts", + "text": "Chroma family" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Acp" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Agents" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "AI & Agents" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Approvals" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Audio" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Authentication" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Automation" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Bindings" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Broadcast" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Browser" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "CanvasHost" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Channels" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Claw" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Cli" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Commands" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Communication" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Core" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Cron" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Dash" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Diagnostics" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Discovery" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Environment" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Gateway" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Hooks" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Infrastructure" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Knot" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Logging" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Mcp" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Media" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Memory" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Messages" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Meta" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Models" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "NodeHost" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Notifications" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Other" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Plugins" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Secrets" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Session" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Setup Wizard" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Skills" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Talk" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Theme" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Tools" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "UI" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Updates" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/config/view.ts", + "text": "Web" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/cron/view.ts", + "text": "+1555... or chat id" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/cron/view.ts", + "text": "Account ID for multi-account setups" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/cron/view.ts", + "text": "agent:main:main" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/cron/view.ts", + "text": "default" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Announce (via channel)" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Consecutive errors before alerting." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Control when this job sends repeated-failure alerts." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Custom per-job settings" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Disable for this job" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Inherit global setting" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Light context" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Minimum seconds between alerts." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Optional channel account ID for multi-account setups." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Optional recipient override for failure alerts." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Optional routing key for job delivery and wake routing." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Run if due" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Use lightweight bootstrap context for this agent job." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/cron/view.ts", + "text": "Webhook (HTTP POST)" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/debug/view.ts", + "text": "openclaw security audit --deep" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": ", then reload this tab." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Claims" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Close" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Contradictions" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Copy archive path" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Corrections or revisions" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Dreams" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Enable" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Ended on:" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Id:" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Import details" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Imported Insights" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Imported Insights and Memory Palace are provided by the bundled" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Labels:" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Loading imported insights…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Loading memory palace…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Loading wiki page…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Memory Palace" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Memory palace is not populated yet" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Memory Wiki is not enabled" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "memory-wiki" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Messages:" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "No imported insights yet" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Open Config" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Open questions" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Open source page" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Open wiki page" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Page details" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "plugin." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "plugins.entries.memory-wiki.enabled = true" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Potentially useful signals" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Right now the wiki mostly has raw source imports and operational reports. This tab becomes useful once syntheses, entities, or concepts start getting written." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Risk reasons:" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Run a ChatGPT import with apply to surface clustered imported insights here." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Started with:" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "These are imported insights clustered from external history; use them to review what imports surfaced before any of it graduates into durable memory." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "This is the compiled memory wiki surface the system can search and reason over; use it to inspect actual memory pages, claims, open questions, and contradictions rather than raw imported source chats." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "This is the raw dream diary the system writes while replaying and consolidating memory; use it to inspect what the memory system is noticing, and where it still looks noisy or thin." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Wiki page:" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "z" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/dreams/view.ts", + "text": "Z" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Add pattern" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Allowlist" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Allowlist and approval policy for" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Ask" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Ask fallback" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Auto-allow skill CLIs" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Case-insensitive glob patterns." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Defaults" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Enabled" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Exec approvals" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "exec host=gateway/node" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Fallback" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Gateway" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Gateway edits local approvals; node edits the selected node." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Host" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Load exec approvals to edit allowlists." + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Mode" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "No allowlist entries yet." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "No nodes advertise exec approvals yet." + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Node" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Pattern" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Remove" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Scope" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Security" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Select node" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Target" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Use default" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Allowlist" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Always" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Deny" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Full" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "Off" + }, + { + "count": 1, + "kind": "object-property", + "name": "label", + "path": "ui/src/pages/nodes/view-exec-approvals.ts", + "text": "On miss" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Any node" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Approve" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Binding" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Devices" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "No agents found." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "No nodes found." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "No nodes with system.run available." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "No paired devices." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Nodes" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Paired" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Paired devices and live links." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Pairing requests + role tokens." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Pending" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Reject" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Revoke" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Rotate" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Tokens" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Tokens: none" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/nodes/view.ts", + "text": "Use default" + }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/overview/overview-page.ts", + "text": "This connection does not have the operator.read scope. Some features may be unavailable." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/overview/overview-page.ts", + "text": "Gateway Error" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/overview/overview-page.ts", + "text": "Missing operator.read scope" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/overview/overview-page.ts", + "text": "Skills with missing dependencies" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/overview/view.ts", + "text": "OPENCLAW_GATEWAY_TOKEN" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/overview/view.ts", + "text": "ws://100.x.y.z:18789" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/sessions/view.ts", + "text": "Session filters" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/sessions/view.ts", + "text": "Previous" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/skill-workshop/skill-workshop-page.ts", + "text": "Workshop view" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/skill-workshop-page.ts", + "text": "Board" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/skill-workshop-page.ts", + "text": "Today" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Close" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Next" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "No Skill Workshop proposals" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Previous" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Resize proposal list" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Example: Make this use Gmail labels instead of unread search, and add a safer dry-run step." + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Search proposals…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "· click to preview" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "0 support files" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Add to your skills" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Ask the agent to change something" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Browse what's already applied." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Cancel" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Drafted by" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Loading proposal…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Manage →" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "New proposals will appear here for review." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "No proposals yet" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Not for me" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Nothing waiting today" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Preparing revision handoff" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "See all proposals →" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Skill Workshop" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Support files" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Tell the agent what should change. The proposal stays pending and the workshop will create a revised version." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Your agent hasn't drafted anything new. Switch to Board to browse history." + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "No matching proposals" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "No pending proposals" + }, + { + "count": 2, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "No proposals here" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "No rejected proposals" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "No stale proposals" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Nothing applied yet" + }, + { + "count": 1, + "kind": "object-property", + "name": "title", + "path": "ui/src/pages/skill-workshop/view.ts", + "text": "Nothing quarantined" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/skills/view.ts", + "text": "Filter installed skills" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "placeholder", + "path": "ui/src/pages/skills/view.ts", + "text": "Search ClawHub skills…" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "API key" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "ClawHub" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "ClawHub link invalid" + }, + { + "count": 2, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "Close" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "Full security report" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "Get your key:" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "Installed skills and their status." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "Missing requirements" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "No skills found on ClawHub." + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", + "text": "Overview" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/skills/view.ts", "text": "Refreshing…" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Save key" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Search and install skills from the registry" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Searching…" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Skill Card" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Skill not found." }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Skills" }, { "count": 1, "kind": "html-text", "name": "text", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Source:" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "All" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Disabled" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Needs Setup" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/skills.ts", + "path": "ui/src/pages/skills/view.ts", "text": "Ready" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "agent:" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "channel:" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "has:errors" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "has:tools" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "maxCost:" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "minTokens:" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "model:" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "provider:" }, { "count": 1, "kind": "object-property", "name": "label", - "path": "ui/src/ui/views/usage-query.ts", + "path": "ui/src/pages/usage/query.ts", "text": "tool:" }, { "count": 1, "kind": "html-attribute", "name": "aria-label", - "path": "ui/src/ui/views/usage-render-details.ts", + "path": "ui/src/pages/usage/view-details.ts", "text": "Filter by role" }, { "count": 1, "kind": "html-attribute", "name": "aria-label", - "path": "ui/src/ui/views/usage-render-details.ts", + "path": "ui/src/pages/usage/view-details.ts", "text": "Filter by tool" }, { "count": 1, "kind": "html-attribute", "name": "aria-label", - "path": "ui/src/ui/views/usage-render-overview.ts", + "path": "ui/src/pages/usage/view-overview.ts", "text": "Remove days filter" }, { "count": 1, "kind": "html-attribute", "name": "aria-label", - "path": "ui/src/ui/views/usage-render-overview.ts", + "path": "ui/src/pages/usage/view-overview.ts", "text": "Remove hours filter" }, { "count": 1, "kind": "html-attribute", "name": "aria-label", - "path": "ui/src/ui/views/usage-render-overview.ts", + "path": "ui/src/pages/usage/view-overview.ts", "text": "Remove session filter" }, { "count": 1, "kind": "object-property", "name": "title", - "path": "ui/src/ui/views/workboard.ts", + "path": "ui/src/pages/workboard/view.ts", "text": "Docs:" }, { "count": 1, "kind": "object-property", "name": "title", - "path": "ui/src/ui/views/workboard.ts", + "path": "ui/src/pages/workboard/view.ts", "text": "Fix:" }, { "count": 1, "kind": "object-property", "name": "title", - "path": "ui/src/ui/views/workboard.ts", + "path": "ui/src/pages/workboard/view.ts", "text": "Plugin:" }, { "count": 1, "kind": "object-property", "name": "title", - "path": "ui/src/ui/views/workboard.ts", + "path": "ui/src/pages/workboard/view.ts", "text": "Release:" }, { "count": 1, "kind": "object-property", "name": "title", - "path": "ui/src/ui/views/workboard.ts", + "path": "ui/src/pages/workboard/view.ts", "text": "Review PR" } ] diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/lib/agents/display.test.ts similarity index 99% rename from ui/src/ui/views/agents-utils.test.ts rename to ui/src/lib/agents/display.test.ts index 497a91a1d051..6ef46b2032c0 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/lib/agents/display.test.ts @@ -1,16 +1,18 @@ // Control UI tests cover agents utils behavior. import { describe, expect, it } from "vitest"; +import { + resolveAgentAvatarUrl, + resolveAssistantTextAvatar, + resolveChatAvatarRenderUrl, +} from "../avatar.ts"; import { agentLogoUrl, assistantAvatarFallbackUrl, buildAgentContext, resolveConfiguredCronModelSuggestions, - resolveAgentAvatarUrl, - resolveAssistantTextAvatar, - resolveChatAvatarRenderUrl, resolveEffectiveModelFallbacks, sortLocaleStrings, -} from "./agents-utils.ts"; +} from "./display.ts"; describe("resolveEffectiveModelFallbacks", () => { it("inherits defaults when no entry fallbacks are configured", () => { diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/lib/agents/display.ts similarity index 89% rename from ui/src/ui/views/agents-utils.ts rename to ui/src/lib/agents/display.ts index 813f7bbe76a6..aa5f1df388c3 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/lib/agents/display.ts @@ -5,10 +5,6 @@ import { normalizeToolName, resolveToolProfilePolicy, } from "../../../../src/agents/tool-policy-shared.js"; -import { DEFAULT_ASSISTANT_AVATAR } from "../assistant-identity.ts"; -import { buildQualifiedChatModelValue } from "../chat-model-ref.ts"; -import { controlUiPublicAssetPath } from "../public-assets.ts"; -import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; import type { AgentIdentityResult, AgentsFilesListResult, @@ -16,7 +12,11 @@ import type { ModelCatalogEntry, ToolCatalogProfile, ToolsCatalogResult, -} from "../types.ts"; +} from "../../api/types.ts"; +import { controlUiPublicAssetPath } from "../../app/public-assets.ts"; +import { resolveAgentAvatarUrl, resolveAssistantTextAvatar } from "../avatar.ts"; +import { buildQualifiedChatModelValue } from "../chat/model-ref.ts"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; export type AgentToolEntry = { id: string; @@ -203,48 +203,6 @@ export function normalizeAgentLabel(agent: { ); } -const CONTROL_UI_AVATAR_URL_RE = /^(data:image\/|\/(?!\/))/i; - -export function isRenderableControlUiAvatarUrl(value: string): boolean { - return CONTROL_UI_AVATAR_URL_RE.test(value); -} - -export function resolveAgentAvatarUrl( - agent: { identity?: { avatar?: string; avatarUrl?: string } }, - agentIdentity?: AgentIdentityResult | null, -): string | null { - const candidates = [ - normalizeOptionalString(agentIdentity?.avatar), - normalizeOptionalString(agent.identity?.avatarUrl), - normalizeOptionalString(agent.identity?.avatar), - ]; - for (const candidate of candidates) { - if (!candidate) { - continue; - } - if (isRenderableControlUiAvatarUrl(candidate)) { - return candidate; - } - } - return null; -} - -// Chat-render variant: accept `blob:` URLs (produced locally by -// `URL.createObjectURL` after an authenticated avatar fetch) in addition to -// config-sanitized candidates. The config path still gates untrusted -// http(s)/data sources through `resolveAgentAvatarUrl`. -export function resolveChatAvatarRenderUrl( - candidate: string | null | undefined, - agent: { identity?: { avatar?: string; avatarUrl?: string } }, - agentIdentity?: AgentIdentityResult | null, -): string | null { - const trimmed = normalizeOptionalString(candidate); - if (trimmed?.startsWith("blob:")) { - return trimmed; - } - return resolveAgentAvatarUrl(agent, agentIdentity); -} - export function agentLogoUrl(basePath: string): string { return controlUiPublicAssetPath("favicon.svg", basePath); } @@ -253,31 +211,7 @@ export function assistantAvatarFallbackUrl(basePath: string): string { return controlUiPublicAssetPath("apple-touch-icon.png", basePath); } -function isAvatarUrl(value: string): boolean { - const trimmed = value.trim(); - return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed); -} - -const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u; - -export function resolveAssistantTextAvatar(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) { - return null; - } - if (isAvatarUrl(trimmed)) { - return null; - } - if ( - trimmed.length > 8 || - /\s/.test(trimmed) || - /[\\/.:]/.test(trimmed) || - UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed) - ) { - return null; - } - return trimmed; -} +export { resolveAssistantTextAvatar }; function resolveAgentTextAvatar( agent: { identity?: { emoji?: string; avatar?: string } }, diff --git a/ui/src/lib/agents/identity.ts b/ui/src/lib/agents/identity.ts new file mode 100644 index 000000000000..db03e0b62bff --- /dev/null +++ b/ui/src/lib/agents/identity.ts @@ -0,0 +1,118 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { AgentIdentityResult } from "../../api/types.ts"; + +type AgentIdentityGatewaySnapshot = { + client: GatewayBrowserClient | null; + connected: boolean; +}; + +type AgentIdentityGateway = { + readonly snapshot: AgentIdentityGatewaySnapshot; + subscribe: (listener: (snapshot: AgentIdentityGatewaySnapshot) => void) => () => void; +}; + +export type AgentIdentityCapability = { + get: (agentId: string | null | undefined) => AgentIdentityResult | null; + entries: () => AgentIdentityResult[]; + ensure: (agentIds: readonly (string | null | undefined)[]) => Promise; + subscribe: (listener: () => void) => () => void; +}; + +export function createAgentIdentityCapability( + gateway: AgentIdentityGateway, +): AgentIdentityCapability { + let cachedClient: GatewayBrowserClient | null = gateway.snapshot.client; + const identities = new Map(); + const inFlight = new Map>(); + const listeners = new Set<() => void>(); + + const publish = () => { + for (const listener of listeners) { + listener(); + } + }; + + const resetForClient = (client: GatewayBrowserClient | null) => { + if (client === cachedClient) { + return; + } + const hadIdentities = identities.size > 0; + cachedClient = client; + identities.clear(); + inFlight.clear(); + if (hadIdentities) { + publish(); + } + }; + + gateway.subscribe((snapshot) => resetForClient(snapshot.client)); + + const normalizeIds = (agentIds: readonly (string | null | undefined)[]) => [ + ...new Set( + agentIds + .map((agentId) => agentId?.trim()) + .filter((agentId): agentId is string => Boolean(agentId)), + ), + ]; + + const fetchIdentity = ( + client: GatewayBrowserClient, + agentId: string, + ): Promise => { + const active = inFlight.get(agentId); + if (active) { + return active; + } + const request = client + .request("agent.identity.get", { agentId }) + .catch(() => null) + .finally(() => { + if (inFlight.get(agentId) === request) { + inFlight.delete(agentId); + } + }); + inFlight.set(agentId, request); + return request; + }; + + return { + get(agentId) { + const normalized = agentId?.trim(); + return normalized ? (identities.get(normalized) ?? null) : null; + }, + entries() { + return [...identities.values()]; + }, + async ensure(agentIds) { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected) { + return; + } + resetForClient(client); + const missing = normalizeIds(agentIds).filter((agentId) => !identities.has(agentId)); + if (missing.length === 0) { + return; + } + const results = await Promise.all( + missing.map(async (agentId) => [agentId, await fetchIdentity(client, agentId)] as const), + ); + if (gateway.snapshot.client !== client) { + return; + } + let changed = false; + for (const [agentId, identity] of results) { + if (identity) { + identities.set(agentId, identity); + changed = true; + } + } + if (changed) { + publish(); + } + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/lib/agents/index.test.ts similarity index 68% rename from ui/src/ui/controllers/agents.test.ts rename to ui/src/lib/agents/index.test.ts index 8a103056c800..25855318a0ca 100644 --- a/ui/src/ui/controllers/agents.test.ts +++ b/ui/src/lib/agents/index.test.ts @@ -6,8 +6,8 @@ import { loadToolsEffective, saveAgentsConfig, setDefaultAgent, -} from "./agents.ts"; -import type { AgentsConfigSaveState, AgentsState } from "./agents.ts"; +} from "./index.ts"; +import type { AgentsConfigCapability, AgentsState } from "./index.ts"; type TestRequest = (method: string, payload?: unknown) => Promise; @@ -22,6 +22,16 @@ function createState(): { state: AgentsState; request: ReturnType; + configFormOriginal: Record; + }; + }; request: ReturnType>; } { const { state, request } = createState(); + const configState = { + configFormDirty: true, + configForm: { agents: { list: [{ id: "main" }] } }, + configFormOriginal: { agents: { list: [{ id: "main" }] } }, + }; + const config = { + state: configState, + save: vi.fn(async () => true), + stageDefaultAgent: vi.fn(() => false), + } satisfies AgentsConfigCapability; return { - state: { - ...state, - applySessionKey: "session-1", - configLoading: false, - configRawOriginal: "{}", - configValid: true, - configIssues: [], - configSaving: false, - configApplying: false, - updateRunning: false, - configSnapshot: { hash: "hash-1" }, - configFormDirty: true, - configFormMode: "form", - configForm: { agents: { list: [{ id: "main" }] } }, - configRaw: "{}", - configSchema: null, - configSchemaVersion: null, - configSchemaLoading: false, - configUiHints: {}, - configFormOriginal: { agents: { list: [{ id: "main" }] } }, - configSearchQuery: "", - configActiveSection: null, - configActiveSubsection: null, - pendingUpdateExpectedVersion: null, - pendingUpdateHandoff: false, - updateStatusBanner: null, - lastError: null, - }, + state, + config, request, }; } -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected a non-array record"); - } - return value as Record; -} - -function requireFirstRequestCall(request: ReturnType): unknown[] { - const [call] = request.mock.calls; - if (!call) { - throw new Error("Expected client request call"); - } - return call; -} - describe("loadAgents", () => { it("preserves selected agent when it still exists in the list", async () => { const { state, request } = createState(); @@ -363,138 +348,112 @@ describe("loadToolsEffective", () => { describe("saveAgentsConfig", () => { it("restores the pre-save agent after reload when it still exists", async () => { - const { state, request } = createSaveState(); + const { state, config, request } = createSaveState(); state.agentsSelectedId = "kimi"; - request - .mockImplementationOnce(async () => undefined) - .mockImplementationOnce(async () => { - state.agentsSelectedId = null; - return { - hash: "hash-2", - raw: '{"agents":{"list":[{"id":"main"},{"id":"kimi"}]}}', - config: { - agents: { - list: [{ id: "main" }, { id: "kimi" }], - }, - }, - valid: true, - issues: [], - }; - }) - .mockImplementationOnce(async () => { - state.agentsSelectedId = null; - return { - defaultId: "main", - mainKey: "main", - scope: "per-sender", - agents: [ - { id: "main", name: "main" }, - { id: "kimi", name: "kimi" }, - ], - }; - }); - - await saveAgentsConfig(state); - - const [method, params] = requireFirstRequestCall(request); - const requestParams = requireRecord(params); - expect(method).toBe("config.set"); - expect(requestParams.baseHash).toBe("hash-1"); - expect(JSON.parse(String(requestParams.raw))).toEqual({ - agents: { list: [{ id: "main" }] }, - }); - expect(request).toHaveBeenNthCalledWith(2, "config.get", {}); - expect(request).toHaveBeenNthCalledWith(3, "agents.list", {}); - expect(state.agentsSelectedId).toBe("kimi"); - }); - - it("falls back to the default agent when the saved agent disappears", async () => { - const { state, request } = createSaveState(); - state.agentsSelectedId = "kimi"; - request - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ - hash: "hash-2", - raw: '{"agents":{"list":[{"id":"main"}]}}', - config: { - agents: { - list: [{ id: "main" }], - }, - }, - valid: true, - issues: [], - }) - .mockResolvedValueOnce({ + request.mockImplementationOnce(async () => { + state.agentsSelectedId = null; + return { defaultId: "main", mainKey: "main", scope: "per-sender", - agents: [{ id: "main", name: "main" }], - }); - - await saveAgentsConfig(state); - - expect(state.agentsSelectedId).toBe("main"); - }); -}); - -describe("setDefaultAgent", () => { - it("stages the canonical default flag and persists it through config.set", async () => { - const { state, request } = createSaveState(); - state.configForm = { agents: { list: [{ id: "main" }, { id: "kimi" }] } }; - state.configFormOriginal = { agents: { list: [{ id: "main" }, { id: "kimi" }] } }; - state.configFormDirty = false; - request - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ - hash: "hash-2", - raw: '{"agents":{"list":[{"id":"main"},{"id":"kimi","default":true}]}}', - config: { agents: { list: [{ id: "main" }, { id: "kimi", default: true }] } }, - valid: true, - issues: [], - }) - .mockResolvedValueOnce({ - defaultId: "kimi", - mainKey: "main", - scope: "per-sender", agents: [ { id: "main", name: "main" }, { id: "kimi", name: "kimi" }, ], - }); - - await setDefaultAgent(state, "kimi"); - - const [method, params] = requireFirstRequestCall(request); - const requestParams = requireRecord(params); - expect(method).toBe("config.set"); - expect(JSON.parse(String(requestParams.raw))).toEqual({ - agents: { list: [{ id: "main" }, { id: "kimi", default: true }] }, + }; }); + + await saveAgentsConfig(state, config); + + expect(config.save).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenNthCalledWith(1, "agents.list", {}); + expect(state.agentsSelectedId).toBe("kimi"); + }); + + it("falls back to the default agent when the saved agent disappears", async () => { + const { state, config, request } = createSaveState(); + state.agentsSelectedId = "kimi"; + request.mockResolvedValueOnce({ + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [{ id: "main", name: "main" }], + }); + + await saveAgentsConfig(state, config); + + expect(config.save).toHaveBeenCalledTimes(1); + expect(state.agentsSelectedId).toBe("main"); + }); +}); + +describe("setDefaultAgent", () => { + it("stages the default agent and persists a clean draft", async () => { + const { state, config, request } = createSaveState(); + config.state.configForm = { agents: { list: [{ id: "main" }, { id: "kimi" }] } }; + config.state.configFormOriginal = { agents: { list: [{ id: "main" }, { id: "kimi" }] } }; + config.state.configFormDirty = false; + vi.mocked(config.stageDefaultAgent).mockImplementation(() => { + config.state.configFormDirty = true; + return true; + }); + request.mockResolvedValueOnce({ + defaultId: "kimi", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", name: "main" }, + { id: "kimi", name: "kimi" }, + ], + }); + + await setDefaultAgent(state, config, "kimi"); + + expect(config.stageDefaultAgent).toHaveBeenCalledWith("kimi"); + expect(config.save).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith("agents.list", {}); }); it("does not persist when the agent is absent from the config list", async () => { - const { state, request } = createSaveState(); - state.configForm = { agents: { list: [{ id: "main" }] } }; + const { state, config, request } = createSaveState(); + config.state.configForm = { agents: { list: [{ id: "main" }] } }; + vi.mocked(config.stageDefaultAgent).mockReturnValue(false); - await setDefaultAgent(state, "ghost"); + await setDefaultAgent(state, config, "ghost"); + expect(config.stageDefaultAgent).toHaveBeenCalledWith("ghost"); + expect(config.save).not.toHaveBeenCalled(); expect(request).not.toHaveBeenCalled(); }); it("does not persist unrelated dirty agent config drafts", async () => { - const { state, request } = createSaveState(); - state.configFormDirty = true; - state.configFormOriginal = { agents: { list: [{ id: "main" }, { id: "kimi" }] } }; - state.configForm = { + const { state, config, request } = createSaveState(); + config.state.configFormDirty = true; + config.state.configFormOriginal = { agents: { list: [{ id: "main" }, { id: "kimi" }] } }; + config.state.configForm = { agents: { list: [{ id: "main", model: "gpt-5.5" }, { id: "kimi" }], }, }; + vi.mocked(config.stageDefaultAgent).mockImplementation(() => { + config.state.configForm = { + agents: { + list: [ + { id: "main", model: "gpt-5.5" }, + { id: "kimi", default: true }, + ], + }, + }; + config.state.configFormDirty = true; + return true; + }); - await setDefaultAgent(state, "kimi"); + await setDefaultAgent(state, config, "kimi"); + expect(config.stageDefaultAgent).toHaveBeenCalledWith("kimi"); + expect(config.save).not.toHaveBeenCalled(); expect(request).not.toHaveBeenCalled(); - expect(state.configForm).toEqual({ + expect(config.state.configForm).toEqual({ agents: { list: [ { id: "main", model: "gpt-5.5" }, @@ -502,6 +461,6 @@ describe("setDefaultAgent", () => { ], }, }); - expect(state.configFormDirty).toBe(true); + expect(config.state.configFormDirty).toBe(true); }); }); diff --git a/ui/src/lib/agents/index.ts b/ui/src/lib/agents/index.ts new file mode 100644 index 000000000000..9105763abd08 --- /dev/null +++ b/ui/src/lib/agents/index.ts @@ -0,0 +1,400 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + AgentsFilesListResult, + AgentsListResult, + ModelCatalogEntry, + SessionsListResult, + ToolsCatalogResult, + ToolsEffectiveResult, +} from "../../api/types.ts"; +import { + formatMissingOperatorReadScopeMessage, + isMissingOperatorReadScopeError, +} from "../gateway-errors.ts"; +import type { SessionCapability } from "../sessions/index.ts"; +import { + buildToolsEffectiveRequestKey, + loadToolsEffective as loadToolsEffectiveShared, + refreshVisibleToolsEffectiveForCurrentSession, + resetToolsEffectiveState, +} from "./tools-effective.ts"; + +export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; + +export type AgentsState = { + client: GatewayBrowserClient | null; + connected: boolean; + agentsLoading: boolean; + agentsError: string | null; + agentsList: AgentsListResult | null; + agentsSelectedId: string | null; + sessions: Pick; + toolsCatalogLoading: boolean; + toolsCatalogLoadingAgentId?: string | null; + toolsCatalogError: string | null; + toolsCatalogResult: ToolsCatalogResult | null; + toolsEffectiveLoading: boolean; + toolsEffectiveLoadingKey?: string | null; + toolsEffectiveResultKey?: string | null; + toolsEffectiveError: string | null; + toolsEffectiveResult: ToolsEffectiveResult | null; + sessionKey?: string; + sessionsResult?: SessionsListResult | null; + chatModelCatalog?: ModelCatalogEntry[]; + agentsPanel?: AgentsPanel; +}; + +export type AgentsConfigCapability = { + readonly state: { configFormDirty: boolean }; + save: () => Promise; + stageDefaultAgent: (agentId: string) => boolean; +}; + +type AgentGatewaySnapshot = { + client: GatewayBrowserClient | null; + connected: boolean; +}; + +type AgentGateway = { + readonly snapshot: AgentGatewaySnapshot; + subscribe: (listener: (snapshot: AgentGatewaySnapshot) => void) => () => void; +}; + +export type AgentFilesStatus = { + list: AgentsFilesListResult | null; + loading: boolean; + error: string | null; +}; + +export type AgentCapabilityState = { + client: GatewayBrowserClient | null; + connected: boolean; + agentsLoading: boolean; + agentsError: string | null; + agentsList: AgentsListResult | null; +}; + +export type AgentCapability = { + readonly state: AgentCapabilityState; + adoptList: (result: AgentsListResult, client: GatewayBrowserClient) => void; + ensureList: () => Promise; + refreshList: () => Promise; + files: (agentId: string | null | undefined) => AgentFilesStatus; + ensureFiles: (agentId: string) => Promise; + refreshFiles: (agentId: string) => Promise; + subscribe: (listener: (state: AgentCapabilityState) => void) => () => void; + dispose: () => void; +}; + +export async function loadAgentsList(client: GatewayBrowserClient): Promise { + return client.request("agents.list", {}); +} + +export async function loadAgentFilesList( + client: GatewayBrowserClient, + agentId: string, +): Promise { + return client.request("agents.files.list", { agentId }); +} + +function hasSelectedAgentMismatch(state: AgentsState, agentId: string): boolean { + return Boolean(state.agentsSelectedId && state.agentsSelectedId !== agentId); +} + +function resolveToolsErrorMessage( + err: unknown, + target: "tools catalog" | "effective tools", +): string { + return isMissingOperatorReadScopeError(err) + ? formatMissingOperatorReadScopeMessage(target) + : String(err); +} + +export async function loadAgents(state: AgentsState) { + if (!state.client || !state.connected || state.agentsLoading) { + return; + } + state.agentsLoading = true; + state.agentsError = null; + try { + const res = await loadAgentsList(state.client); + state.agentsList = res; + const selected = state.agentsSelectedId; + if (!selected || !res.agents.some((entry) => entry.id === selected)) { + state.agentsSelectedId = res.defaultId ?? res.agents[0]?.id ?? null; + } + } catch (err) { + if (isMissingOperatorReadScopeError(err)) { + state.agentsList = null; + state.agentsError = formatMissingOperatorReadScopeMessage("agent list"); + } else { + state.agentsError = String(err); + } + } finally { + state.agentsLoading = false; + } +} + +export async function loadToolsCatalog(state: AgentsState, agentId: string) { + const resolvedAgentId = agentId.trim(); + if ( + !state.client || + !state.connected || + !resolvedAgentId || + (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) + ) { + return; + } + const shouldIgnoreResponse = () => + state.toolsCatalogLoadingAgentId !== resolvedAgentId || + hasSelectedAgentMismatch(state, resolvedAgentId); + state.toolsCatalogLoading = true; + state.toolsCatalogLoadingAgentId = resolvedAgentId; + state.toolsCatalogError = null; + state.toolsCatalogResult = null; + try { + const res = await state.client.request("tools.catalog", { + agentId: resolvedAgentId, + includePlugins: true, + }); + if (shouldIgnoreResponse()) { + return; + } + state.toolsCatalogResult = res; + } catch (err) { + if (shouldIgnoreResponse()) { + return; + } + state.toolsCatalogError = resolveToolsErrorMessage(err, "tools catalog"); + } finally { + if (state.toolsCatalogLoadingAgentId === resolvedAgentId) { + state.toolsCatalogLoadingAgentId = null; + state.toolsCatalogLoading = false; + } + } +} + +export { + buildToolsEffectiveRequestKey, + refreshVisibleToolsEffectiveForCurrentSession, + resetToolsEffectiveState, +}; + +export async function loadToolsEffective( + state: AgentsState, + params: { agentId: string; sessionKey: string }, +) { + await loadToolsEffectiveShared(state, params, { + ignoreResponse: (agentId, requestKey) => + state.toolsEffectiveLoadingKey !== requestKey || hasSelectedAgentMismatch(state, agentId), + onError: (err) => resolveToolsErrorMessage(err, "effective tools"), + }); +} + +export async function saveAgentsConfig(state: AgentsState, config: AgentsConfigCapability) { + const selectedBefore = state.agentsSelectedId; + await config.save(); + await loadAgents(state); + if (selectedBefore && state.agentsList?.agents.some((entry) => entry.id === selectedBefore)) { + state.agentsSelectedId = selectedBefore; + } +} + +export async function setDefaultAgent( + state: AgentsState, + config: AgentsConfigCapability, + agentId: string, +): Promise { + const hadPendingConfigDraft = config.state.configFormDirty; + if (config.stageDefaultAgent(agentId)) { + if (!hadPendingConfigDraft && config.state.configFormDirty) { + await saveAgentsConfig(state, config); + } + } +} + +function emptyAgentFilesStatus(): AgentFilesStatus { + return { list: null, loading: false, error: null }; +} + +function normalizeAgentId(agentId: string | null | undefined): string | null { + const normalized = agentId?.trim(); + return normalized ? normalized : null; +} + +export function createAgentCapability(gateway: AgentGateway): AgentCapability { + const state: AgentCapabilityState = { + client: gateway.snapshot.client, + connected: gateway.snapshot.connected, + agentsLoading: false, + agentsError: null, + agentsList: null, + }; + const files = new Map(); + const fileRequests = new Map>(); + const listeners = new Set<(state: AgentCapabilityState) => void>(); + let disposed = false; + let agentsRequest: Promise | null = null; + + const publish = () => { + if (disposed) { + return; + } + for (const listener of listeners) { + listener(state); + } + }; + + const fileStatus = (agentId: string): AgentFilesStatus => { + const existing = files.get(agentId); + if (existing) { + return existing; + } + const next = emptyAgentFilesStatus(); + files.set(agentId, next); + return next; + }; + + const loadList = async (force: boolean): Promise => { + const client = state.client; + if (!client || !state.connected) { + return state.agentsList; + } + if (agentsRequest && !force) { + return agentsRequest; + } + state.agentsLoading = true; + state.agentsError = null; + publish(); + const request = loadAgentsList(client) + .then((result) => { + if (state.client === client) { + state.agentsList = result; + state.agentsError = null; + } + return state.client === client ? result : state.agentsList; + }) + .catch((err: unknown) => { + if (state.client === client) { + state.agentsError = isMissingOperatorReadScopeError(err) + ? formatMissingOperatorReadScopeMessage("agent list") + : String(err); + } + return null; + }) + .finally(() => { + if (agentsRequest === request) { + agentsRequest = null; + } + if (state.client === client) { + state.agentsLoading = false; + publish(); + } + }); + agentsRequest = request; + return request; + }; + + const loadFiles = async ( + rawAgentId: string, + force: boolean, + ): Promise => { + const agentId = normalizeAgentId(rawAgentId); + const client = state.client; + if (!agentId || !client || !state.connected) { + return agentId ? (files.get(agentId)?.list ?? null) : null; + } + const status = fileStatus(agentId); + if (status.list && !force) { + return status.list; + } + const activeRequest = fileRequests.get(agentId); + if (activeRequest && !force) { + return activeRequest; + } + status.loading = true; + status.error = null; + publish(); + const request = loadAgentFilesList(client, agentId) + .then((result) => { + if (state.client === client && result) { + status.list = result; + status.error = null; + } + return state.client === client ? status.list : null; + }) + .catch((err: unknown) => { + if (state.client === client) { + status.error = String(err); + } + return null; + }) + .finally(() => { + if (fileRequests.get(agentId) === request) { + fileRequests.delete(agentId); + } + if (state.client === client) { + status.loading = false; + publish(); + } + }); + fileRequests.set(agentId, request); + return request; + }; + + const stopGateway = gateway.subscribe((snapshot) => { + const clientChanged = state.client !== snapshot.client; + state.client = snapshot.client; + state.connected = snapshot.connected; + if (clientChanged) { + agentsRequest = null; + fileRequests.clear(); + files.clear(); + state.agentsList = null; + state.agentsError = null; + } + if (clientChanged || !snapshot.connected) { + state.agentsLoading = false; + for (const status of files.values()) { + status.loading = false; + } + } + publish(); + }); + + return { + get state() { + return state; + }, + adoptList(result, client) { + if (state.client !== client || !state.connected) { + return; + } + state.agentsList = result; + state.agentsError = null; + publish(); + }, + ensureList: () => loadList(false), + refreshList: () => loadList(true), + files(agentId) { + const normalized = normalizeAgentId(agentId); + return normalized + ? (files.get(normalized) ?? emptyAgentFilesStatus()) + : emptyAgentFilesStatus(); + }, + ensureFiles: (agentId) => loadFiles(agentId, false), + refreshFiles: (agentId) => loadFiles(agentId, true), + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + disposed = true; + stopGateway(); + listeners.clear(); + fileRequests.clear(); + files.clear(); + agentsRequest = null; + }, + }; +} diff --git a/ui/src/lib/agents/tools-effective.ts b/ui/src/lib/agents/tools-effective.ts new file mode 100644 index 000000000000..6d536518128d --- /dev/null +++ b/ui/src/lib/agents/tools-effective.ts @@ -0,0 +1,148 @@ +// Shared effective-tools loading for agent and Chat model changes. +import type { + ModelCatalogEntry, + SessionsListResult, + ToolsEffectiveResult, +} from "../../api/types.ts"; +import { + createChatModelOverride, + normalizeChatModelOverrideValue, + resolvePreferredServerChatModelValue, +} from "../chat/model-ref.ts"; +import type { SessionCapability } from "../sessions/index.ts"; +import { resolveAgentIdFromSessionKey } from "../sessions/session-key.ts"; + +export type ToolsEffectiveState = { + chatModelCatalog?: ModelCatalogEntry[]; + client: { + request(method: string, params?: Record): Promise; + } | null; + connected: boolean; + sessions: Pick; + sessionsResult?: SessionsListResult | null; + toolsEffectiveError: string | null; + toolsEffectiveLoading: boolean; + toolsEffectiveLoadingKey?: string | null; + toolsEffectiveResult: ToolsEffectiveResult | null; + toolsEffectiveResultKey?: string | null; +}; + +export function buildToolsEffectiveRequestKey( + state: Pick, + params: { agentId: string; sessionKey: string }, +): string { + const resolvedAgentId = params.agentId.trim(); + const resolvedSessionKey = params.sessionKey.trim(); + const modelKey = resolveEffectiveToolsModelKey(state, resolvedSessionKey); + return `${resolvedAgentId}:${resolvedSessionKey}:model=${modelKey || "(default)"}`; +} + +export async function loadToolsEffective( + state: ToolsEffectiveState, + params: { agentId: string; sessionKey: string }, + options: { + ignoreResponse?: (agentId: string, requestKey: string) => boolean; + onError?: (error: unknown) => string; + } = {}, +) { + const resolvedAgentId = params.agentId.trim(); + const resolvedSessionKey = params.sessionKey.trim(); + const requestKey = buildToolsEffectiveRequestKey(state, { + agentId: resolvedAgentId, + sessionKey: resolvedSessionKey, + }); + if ( + !state.client || + !state.connected || + !resolvedAgentId || + !resolvedSessionKey || + (state.toolsEffectiveLoading && state.toolsEffectiveLoadingKey === requestKey) + ) { + return; + } + const shouldIgnoreResponse = () => options.ignoreResponse?.(resolvedAgentId, requestKey) ?? false; + state.toolsEffectiveLoading = true; + state.toolsEffectiveLoadingKey = requestKey; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = null; + state.toolsEffectiveResult = null; + try { + const result = await state.client.request("tools.effective", { + agentId: resolvedAgentId, + sessionKey: resolvedSessionKey, + }); + if (shouldIgnoreResponse()) { + return; + } + state.toolsEffectiveResultKey = requestKey; + state.toolsEffectiveResult = result; + } catch (error) { + if (shouldIgnoreResponse()) { + return; + } + state.toolsEffectiveError = options.onError?.(error) ?? String(error); + } finally { + if (state.toolsEffectiveLoadingKey === requestKey) { + state.toolsEffectiveLoadingKey = null; + state.toolsEffectiveLoading = false; + } + } +} + +export function resetToolsEffectiveState(state: ToolsEffectiveState) { + state.toolsEffectiveResult = null; + state.toolsEffectiveResultKey = null; + state.toolsEffectiveError = null; + state.toolsEffectiveLoading = false; + state.toolsEffectiveLoadingKey = null; +} + +export function refreshVisibleToolsEffectiveForCurrentSession( + state: ToolsEffectiveState & { + agentsPanel?: string; + agentsSelectedId?: string | null; + sessionKey?: string; + }, +): Promise | undefined { + const resolvedSessionKey = state.sessionKey?.trim(); + if (!resolvedSessionKey || state.agentsPanel !== "tools" || !state.agentsSelectedId) { + return undefined; + } + const sessionAgentId = resolveAgentIdFromSessionKey(resolvedSessionKey); + if (!sessionAgentId || state.agentsSelectedId !== sessionAgentId) { + return undefined; + } + return loadToolsEffective(state, { + agentId: sessionAgentId, + sessionKey: resolvedSessionKey, + }); +} + +function resolveEffectiveToolsModelKey( + state: Pick, + sessionKey: string, +): string { + const resolvedSessionKey = sessionKey.trim(); + if (!resolvedSessionKey) { + return ""; + } + const catalog = state.chatModelCatalog ?? []; + const cachedOverride = state.sessions.state.modelOverrides[resolvedSessionKey]; + const defaults = state.sessionsResult?.defaults; + const defaultModel = resolvePreferredServerChatModelValue( + defaults?.model, + defaults?.modelProvider, + catalog, + ); + if (cachedOverride === null) { + return defaultModel; + } + if (cachedOverride) { + return normalizeChatModelOverrideValue(createChatModelOverride(cachedOverride), catalog); + } + const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === resolvedSessionKey); + if (activeRow?.model) { + return resolvePreferredServerChatModelValue(activeRow.model, activeRow.modelProvider, catalog); + } + return defaultModel; +} diff --git a/ui/src/ui/assistant-identity.test.ts b/ui/src/lib/assistant-identity.test.ts similarity index 100% rename from ui/src/ui/assistant-identity.test.ts rename to ui/src/lib/assistant-identity.test.ts diff --git a/ui/src/ui/assistant-identity.ts b/ui/src/lib/assistant-identity.ts similarity index 93% rename from ui/src/ui/assistant-identity.ts rename to ui/src/lib/assistant-identity.ts index 8c14be38d86e..0573da77b6e6 100644 --- a/ui/src/ui/assistant-identity.ts +++ b/ui/src/lib/assistant-identity.ts @@ -11,8 +11,8 @@ const MAX_ASSISTANT_TEXT_AVATAR = 64; const MAX_ASSISTANT_IMAGE_AVATAR = 2_000_000; const MAX_ASSISTANT_AVATAR_SOURCE = 500; const MAX_ASSISTANT_AVATAR_REASON = 200; -// Mirrors agents-utils.CONTROL_UI_AVATAR_URL_RE — duplicated locally to keep -// this module free of UI view imports (avoids an import cycle). +// Mirrors lib/agents/display avatar URL handling. Keep this local so assistant +// identity loading does not import agent display helpers or Lit templates. const RENDERABLE_AVATAR_URL_RE = /^(data:image\/|\/(?!\/))/i; const DEFAULT_ASSISTANT_NAME = "Assistant"; diff --git a/ui/src/lib/avatar.ts b/ui/src/lib/avatar.ts new file mode 100644 index 000000000000..c2188ea50ccc --- /dev/null +++ b/ui/src/lib/avatar.ts @@ -0,0 +1,62 @@ +import type { AgentIdentityResult } from "../api/types.ts"; +import { DEFAULT_ASSISTANT_AVATAR } from "./assistant-identity.ts"; +import { normalizeOptionalString } from "./string-coerce.ts"; + +const CONTROL_UI_AVATAR_URL_RE = /^(data:image\/|\/(?!\/))/i; +const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u; + +export function isRenderableControlUiAvatarUrl(value: string): boolean { + return CONTROL_UI_AVATAR_URL_RE.test(value); +} + +export function resolveAgentAvatarUrl( + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + agentIdentity?: AgentIdentityResult | null, +): string | null { + const candidates = [ + normalizeOptionalString(agentIdentity?.avatar), + normalizeOptionalString(agent.identity?.avatarUrl), + normalizeOptionalString(agent.identity?.avatar), + ]; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + if (isRenderableControlUiAvatarUrl(candidate)) { + return candidate; + } + } + return null; +} + +// Chat-render variant: accept blob URLs produced by authenticated avatar fetches. +export function resolveChatAvatarRenderUrl( + candidate: string | null | undefined, + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + agentIdentity?: AgentIdentityResult | null, +): string | null { + const trimmed = normalizeOptionalString(candidate); + if (trimmed?.startsWith("blob:")) { + return trimmed; + } + return resolveAgentAvatarUrl(agent, agentIdentity); +} + +export function resolveAssistantTextAvatar(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) { + return null; + } + if (trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed)) { + return null; + } + if ( + trimmed.length > 8 || + /\s/.test(trimmed) || + /[\\/.:]/.test(trimmed) || + UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed) + ) { + return null; + } + return trimmed; +} diff --git a/ui/src/ui/browser-redact.test.ts b/ui/src/lib/browser-redact.test.ts similarity index 100% rename from ui/src/ui/browser-redact.test.ts rename to ui/src/lib/browser-redact.test.ts diff --git a/ui/src/ui/browser-redact.ts b/ui/src/lib/browser-redact.ts similarity index 98% rename from ui/src/ui/browser-redact.ts rename to ui/src/lib/browser-redact.ts index 06ff9119cf19..4d166662fae1 100644 --- a/ui/src/ui/browser-redact.ts +++ b/ui/src/lib/browser-redact.ts @@ -1,4 +1,4 @@ -// Control UI module implements browser redact behavior. +// Browser-safe redaction for tool details rendered by the Control UI. const PAYMENT_CREDENTIAL_KEYS = "card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|securityCode|payment[-_]?credential|paymentCredential|shared[-_]?payment[-_]?token|sharedPaymentToken"; diff --git a/ui/src/ui/controllers/channels.test.ts b/ui/src/lib/channels/index.test.ts similarity index 97% rename from ui/src/ui/controllers/channels.test.ts rename to ui/src/lib/channels/index.test.ts index c60fd129d40e..cf5f6a096688 100644 --- a/ui/src/ui/controllers/channels.test.ts +++ b/ui/src/lib/channels/index.test.ts @@ -1,7 +1,7 @@ -// Control UI tests cover channels behavior. +// Channels domain tests. import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChannelsStatusSnapshot } from "../types.ts"; -import { loadChannels, waitWhatsAppLogin, type ChannelsState } from "./channels.ts"; +import type { ChannelsStatusSnapshot } from "../../api/types.ts"; +import { loadChannels, waitWhatsAppLogin, type ChannelsState } from "./index.ts"; function createDeferred() { let resolve: ((value: T) => void) | undefined; diff --git a/ui/src/lib/channels/index.ts b/ui/src/lib/channels/index.ts new file mode 100644 index 000000000000..ec052368f894 --- /dev/null +++ b/ui/src/lib/channels/index.ts @@ -0,0 +1,328 @@ +import type { ChannelsStatusSnapshot } from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; +import { + formatMissingOperatorReadScopeMessage, + isMissingOperatorReadScopeError, +} from "../gateway-errors.ts"; + +type ChannelGatewayClient = { + request(method: string, params?: unknown): Promise; +}; + +type ChannelGatewaySnapshot = { + client: ChannelGatewayClient | null; + connected: boolean; +}; + +type ChannelGateway = { + readonly snapshot: ChannelGatewaySnapshot; + subscribe: (listener: (snapshot: ChannelGatewaySnapshot) => void) => () => void; +}; + +export type ChannelsState = { + client: ChannelGatewayClient | null; + connected: boolean; + channelsLoading: boolean; + channelsLoadingProbe?: boolean | null; + channelsRefreshSeq?: number; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; +}; + +export type LoadChannelsOptions = { + softTimeoutMs?: number; +}; + +export type ChannelCapability = { + readonly state: ChannelsState; + refresh: (probe?: boolean, options?: LoadChannelsOptions) => Promise; + startWhatsApp: (force: boolean) => Promise; + waitWhatsApp: () => Promise; + logoutWhatsApp: () => Promise; + subscribe: (listener: (state: ChannelsState) => void) => () => void; + dispose: () => void; +}; + +export function createInitialChannelsState( + snapshot: Partial = {}, +): ChannelsState { + return { + client: snapshot.client ?? null, + connected: snapshot.connected ?? false, + channelsLoading: false, + channelsLoadingProbe: null, + channelsRefreshSeq: 0, + channelsSnapshot: null, + channelsError: null, + channelsLastSuccess: null, + whatsappLoginMessage: null, + whatsappLoginQrDataUrl: null, + whatsappLoginConnected: null, + whatsappBusy: false, + }; +} + +function delay(ms: number): Promise<"timeout"> { + return new Promise((resolve) => { + setTimeout(() => resolve("timeout"), ms); + }); +} + +function isCurrentChannelRefresh( + state: ChannelsState, + client: ChannelGatewayClient, + refreshSeq: number, +): boolean { + return state.client === client && state.channelsRefreshSeq === refreshSeq; +} + +export async function loadChannels( + state: ChannelsState, + probe: boolean, + options: LoadChannelsOptions = {}, +) { + const client = state.client; + if (!client || !state.connected) { + return; + } + if (state.channelsLoading && (!state.channelsLoadingProbe || probe)) { + return; + } + const refreshSeq = (state.channelsRefreshSeq ?? 0) + 1; + state.channelsRefreshSeq = refreshSeq; + state.channelsLoading = true; + state.channelsLoadingProbe = probe; + state.channelsError = null; + const refresh = (async () => { + try { + const res = await client.request("channels.status", { + probe, + timeoutMs: 8000, + }); + if (!isCurrentChannelRefresh(state, client, refreshSeq)) { + return; + } + state.channelsSnapshot = res; + state.channelsLastSuccess = Date.now(); + } catch (err) { + if (!isCurrentChannelRefresh(state, client, refreshSeq)) { + return; + } + if (isMissingOperatorReadScopeError(err)) { + state.channelsSnapshot = null; + state.channelsError = formatMissingOperatorReadScopeMessage("channel status"); + } else { + state.channelsError = String(err); + } + } finally { + if (isCurrentChannelRefresh(state, client, refreshSeq)) { + state.channelsLoading = false; + state.channelsLoadingProbe = null; + } + } + })(); + + const softTimeoutMs = options.softTimeoutMs; + if (typeof softTimeoutMs === "number" && softTimeoutMs > 0) { + const outcome = await Promise.race([refresh.then(() => "done" as const), delay(softTimeoutMs)]); + if (outcome === "timeout") { + return; + } + return; + } + await refresh; +} + +export async function startWhatsAppLogin(state: ChannelsState, force: boolean) { + if (!state.client || !state.connected || state.whatsappBusy) { + return; + } + state.whatsappBusy = true; + try { + const res = await state.client.request<{ + message?: string; + qrDataUrl?: string; + connected?: boolean; + }>("web.login.start", { + force, + timeoutMs: 30000, + }); + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; + state.whatsappLoginConnected = typeof res.connected === "boolean" ? res.connected : null; + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function waitWhatsAppLogin(state: ChannelsState) { + if (!state.client || !state.connected || state.whatsappBusy) { + return; + } + state.whatsappBusy = true; + try { + const res = await state.client.request<{ + message?: string; + connected?: boolean; + qrDataUrl?: string; + }>("web.login.wait", { + timeoutMs: 120000, + currentQrDataUrl: state.whatsappLoginQrDataUrl ?? undefined, + }); + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginConnected = res.connected ?? null; + if (res.qrDataUrl) { + state.whatsappLoginQrDataUrl = res.qrDataUrl; + } else if (res.connected) { + state.whatsappLoginQrDataUrl = null; + } + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function logoutWhatsApp(state: ChannelsState) { + if (!state.client || !state.connected || state.whatsappBusy) { + return; + } + state.whatsappBusy = true; + try { + await state.client.request("channels.logout", { channel: "whatsapp" }); + state.whatsappLoginMessage = "Logged out."; + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + } finally { + state.whatsappBusy = false; + } +} + +export function resolveChannelConfigValue( + configForm: Record | null | undefined, + channelId: string, +): Record | null { + if (!configForm) { + return null; + } + const channels = (configForm.channels ?? {}) as Record; + const fromChannels = channels[channelId]; + if (fromChannels && typeof fromChannels === "object") { + return fromChannels as Record; + } + const fallback = configForm[channelId]; + if (fallback && typeof fallback === "object") { + return fallback as Record; + } + return null; +} + +export function formatChannelExtraValue(raw: unknown): string { + if (raw == null) { + return t("common.na"); + } + if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") { + return String(raw); + } + try { + return JSON.stringify(raw); + } catch { + return t("common.na"); + } +} + +export function resolveChannelExtras(params: { + configForm: Record | null | undefined; + channelId: string; + fields: readonly string[]; +}): Array<{ label: string; value: string }> { + const value = resolveChannelConfigValue(params.configForm, params.channelId); + if (!value) { + return []; + } + return params.fields.flatMap((field) => { + if (!(field in value)) { + return []; + } + return [{ label: field, value: formatChannelExtraValue(value[field]) }]; + }); +} + +export function createChannelCapability(gateway: ChannelGateway): ChannelCapability { + const state = createInitialChannelsState(gateway.snapshot); + const listeners = new Set<(state: ChannelsState) => void>(); + let disposed = false; + + const publish = () => { + if (disposed) { + return; + } + for (const listener of listeners) { + listener(state); + } + }; + const run = async (task: () => Promise): Promise => { + const result = task(); + publish(); + try { + return await result; + } finally { + publish(); + } + }; + const stopGateway = gateway.subscribe((snapshot) => { + const clientChanged = state.client !== snapshot.client; + state.client = snapshot.client; + state.connected = snapshot.connected; + if (clientChanged || !snapshot.connected) { + state.channelsLoading = false; + state.channelsLoadingProbe = null; + state.whatsappBusy = false; + state.channelsRefreshSeq = (state.channelsRefreshSeq ?? 0) + 1; + } + publish(); + }); + + return { + get state() { + return state; + }, + refresh: (probe, options) => run(() => loadChannels(state, probe ?? false, options)), + startWhatsApp: (force) => + run(async () => { + await startWhatsAppLogin(state, force); + await loadChannels(state, true); + }), + waitWhatsApp: () => + run(async () => { + await waitWhatsAppLogin(state); + await loadChannels(state, true); + }), + logoutWhatsApp: () => + run(async () => { + await logoutWhatsApp(state); + await loadChannels(state, true); + }), + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + disposed = true; + stopGateway(); + listeners.clear(); + }, + }; +} diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/lib/chat/chat-types.ts similarity index 59% rename from ui/src/ui/types/chat-types.ts rename to ui/src/lib/chat/chat-types.ts index 749a8859cff4..495583d26496 100644 --- a/ui/src/ui/types/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -2,6 +2,38 @@ * Chat message types for the UI layer. */ +export type ChatAttachment = { + id: string; + dataUrl?: string; + previewUrl?: string; + mimeType: string; + fileName?: string; + sizeBytes?: number; +}; + +export type ChatQueueSkillWorkshopRevision = { proposalId: string; agentId?: string }; + +export type ChatQueueItem = { + id: string; + text: string; + createdAt: number; + kind?: "queued" | "steered"; + attachments?: ChatAttachment[]; + refreshSessions?: boolean; + localCommandArgs?: string; + localCommandName?: string; + pendingRunId?: string; + sendAttempts?: number; + sendError?: string; + sendRunId?: string; + sendState?: "waiting-model" | "sending" | "waiting-reconnect" | "failed"; + sendSubmittedAtMs?: number; + sendRequestStartedAtMs?: number; + sessionKey?: string; + agentId?: string; + skillWorkshopRevision?: ChatQueueSkillWorkshopRevision; +}; + /** Union type for items in the chat thread */ export type ChatItem = | { kind: "message"; key: string; message: unknown; duplicateCount?: number } @@ -16,6 +48,31 @@ export type ChatItem = | { kind: "stream"; key: string; text: string; startedAt: number; isStreaming: boolean } | { kind: "reading-indicator"; key: string }; +export const CHAT_HISTORY_RENDER_LIMIT = 100; +export const CHAT_HISTORY_RENDER_CHAR_BUDGET = 240_000; + +export type ChatStreamSegment = { + text: string; + ts: number; + toolCallId?: string; + itemId?: string; +}; + +export function streamSegmentHasItemId(segment: { itemId?: unknown }): boolean { + return typeof segment.itemId === "string" && segment.itemId.trim().length > 0; +} + +export function streamSegmentUsesAccumulatedText(segment: { itemId?: unknown }): boolean { + return !streamSegmentHasItemId(segment); +} + +export function trimAccumulatedStreamPrefix(text: string, previousText: string | null): string { + if (!previousText || !text.startsWith(previousText)) { + return text; + } + return text.slice(previousText.length).trimStart(); +} + /** A group of consecutive messages from the same role (Slack-style layout) */ export type MessageGroup = { kind: "group"; @@ -25,10 +82,6 @@ export type MessageGroup = { messages: Array<{ message: unknown; key: string; duplicateCount?: number }>; timestamp: number; isStreaming: boolean; - // Tool groups only: true when the turn still produced a successful assistant - // reply, so a failed internal tool (Codex marks any non-zero exit as failed) - // renders collapsed instead of as a primary red error banner. Undefined for - // non-tool groups and for terminal/in-progress tool failures. turnSucceeded?: boolean; }; diff --git a/ui/src/ui/chat/slash-commands.browser-import.test.ts b/ui/src/lib/chat/commands.browser-import.test.ts similarity index 73% rename from ui/src/ui/chat/slash-commands.browser-import.test.ts rename to ui/src/lib/chat/commands.browser-import.test.ts index cd4515b6e414..00387c15356c 100644 --- a/ui/src/ui/chat/slash-commands.browser-import.test.ts +++ b/ui/src/lib/chat/commands.browser-import.test.ts @@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; -type SlashCommandsModule = typeof import("./slash-commands.js"); -const browserImportPath = "./slash-commands.ts?browser-import"; +type CommandsModule = typeof import("./commands.js"); +const browserImportPath = "./commands.ts?browser-import"; function importDeclarations(source: string): string[] { return (source.match(/^import[\s\S]*?;$/gmu) ?? []).map((declaration) => @@ -18,7 +18,7 @@ function importDeclarations(source: string): string[] { describe("slash command browser import", () => { it("builds fallback commands from the browser-safe shared registry", async () => { - const mod = (await import(browserImportPath)) as SlashCommandsModule; + const mod = (await import(browserImportPath)) as CommandsModule; const thinkCommand = mod.SLASH_COMMANDS.find((command) => command.name === "think"); expect(thinkCommand).toEqual({ @@ -36,7 +36,7 @@ describe("slash command browser import", () => { }); it("keeps provider thinking runtime out of the Control UI import path", async () => { - const slashCommands = await readFile(new URL("./slash-commands.ts", import.meta.url), "utf8"); + const commands = await readFile(new URL("./commands.ts", import.meta.url), "utf8"); const sharedRegistry = await readFile( new URL("../../../../src/auto-reply/commands-registry.shared.ts", import.meta.url), "utf8", @@ -45,25 +45,10 @@ describe("slash command browser import", () => { new URL("../../../../src/auto-reply/commands-registry.data.ts", import.meta.url), "utf8", ); - const mod = (await import(browserImportPath)) as SlashCommandsModule; - expect(mod.SLASH_COMMANDS.find((command) => command.name === "think")).toEqual({ - key: "think", - name: "think", - aliases: ["thinking", "t"], - description: "Set thinking level.", - category: "model", - args: "[level]", - icon: "brain", - executeLocal: true, - argOptions: undefined, - tier: "essential", - }); - expect(importDeclarations(slashCommands)).toEqual([ - 'import type { CommandEntry, CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js";', + expect(importDeclarations(commands)).toEqual([ + 'import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js";', 'import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";', - 'import type { GatewayBrowserClient } from "../gateway.ts";', - 'import type { IconName } from "../icons.ts";', 'import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";', ]); expect(importDeclarations(sharedRegistry)).toEqual([ diff --git a/ui/src/lib/chat/commands.test.ts b/ui/src/lib/chat/commands.test.ts new file mode 100644 index 000000000000..fc3ace24d633 --- /dev/null +++ b/ui/src/lib/chat/commands.test.ts @@ -0,0 +1,318 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { + buildSlashCommandsFromEntries, + getRemoteCommandEntries, + parseSlashCommand, + replaceSlashCommands, + resetSlashCommandsForTest, + SLASH_COMMANDS, +} from "./commands.ts"; + +afterEach(() => { + resetSlashCommandsForTest(); +}); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`expected ${label} to be an object`); + } + return value; +} + +function requireArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`expected ${label} to be an array`); + } + return value; +} + +function expectRecordFields(value: unknown, label: string, expected: Record) { + const record = requireRecord(value, label); + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key]).toEqual(expectedValue); + } +} + +function requireCommandByName(name: string): Record { + return requireRecord( + SLASH_COMMANDS.find((entry) => entry.name === name), + `slash command ${name}`, + ); +} + +function requireCommandByKey(key: string): Record { + return requireRecord( + SLASH_COMMANDS.find((entry) => entry.key === key), + `slash command ${key}`, + ); +} + +function applyRemoteEntries(entries: Parameters[0]) { + replaceSlashCommands(buildSlashCommandsFromEntries(entries)); +} + +function applyCommandsListResult(result: { commands?: unknown }) { + applyRemoteEntries(getRemoteCommandEntries(result)); +} + +function expectParsedSlash(input: string, commandFields: Record, args: string) { + const parsed = requireRecord(parseSlashCommand(input), `parsed ${input}`); + expectRecordFields(parsed.command, `parsed ${input} command`, commandFields); + expect(parsed.args).toBe(args); +} + +describe("parseSlashCommand", () => { + it("parses commands with an optional colon separator", () => { + expectParsedSlash("/think: high", { name: "think" }, "high"); + expectParsedSlash("/think:high", { name: "think" }, "high"); + expectParsedSlash("/help:", { name: "help" }, ""); + }); + + it("still parses space-delimited commands", () => { + expectParsedSlash("/verbose full", { name: "verbose" }, "full"); + }); + + it("parses fast commands", () => { + expectParsedSlash("/fast:on", { name: "fast" }, "on"); + }); + + it("keeps /status on the agent path", () => { + const status = SLASH_COMMANDS.find((entry) => entry.name === "status"); + expect(status?.executeLocal).not.toBe(true); + expectParsedSlash("/status", { name: "status" }, ""); + }); + + it("includes shared /tools with shared arg hints", () => { + const tools = requireCommandByName("tools"); + expectRecordFields(tools, "tools command", { + key: "tools", + description: "List available runtime tools.", + argOptions: ["compact", "verbose"], + executeLocal: false, + }); + expectParsedSlash("/tools verbose", { name: "tools" }, "verbose"); + }); + + it("parses slash aliases through the shared registry", () => { + const exportCommand = requireCommandByKey("export-session"); + expectRecordFields(exportCommand, "export-session command", { + name: "export-session", + aliases: ["export"], + executeLocal: true, + }); + expectParsedSlash("/export", { key: "export-session" }, ""); + expectParsedSlash("/export-session", { key: "export-session" }, ""); + const side = requireRecord(parseSlashCommand("/side what changed?"), "parsed /side"); + expectRecordFields(side.command, "side command", { key: "btw", name: "btw" }); + expect( + requireArray(requireRecord(side.command, "side command").aliases, "side aliases"), + ).toEqual(["side"]); + expect(side.args).toBe("what changed?"); + }); + + it("keeps canonical long-form slash names as the primary menu command", () => { + expectRecordFields(requireCommandByKey("verbose"), "verbose command", { + name: "verbose", + aliases: ["v"], + }); + const think = requireCommandByKey("think"); + expectRecordFields(think, "think command", { + name: "think", + }); + expect(requireArray(think.aliases, "think aliases")).toEqual(["thinking", "t"]); + }); + + it("keeps a single local /steer entry with the control-ui metadata", () => { + const steerEntries = SLASH_COMMANDS.filter((entry) => entry.name === "steer"); + expect(steerEntries).toHaveLength(1); + const steer = requireRecord(steerEntries[0], "steer command"); + expectRecordFields(steer, "steer command", { + key: "steer", + description: "Inject a message into the active run", + args: "", + executeLocal: true, + }); + expect(requireArray(steer.aliases, "steer aliases")).toEqual(["tell"]); + }); + + it("builds runtime commands from command entries so docks, plugins, and direct skills appear", () => { + applyRemoteEntries([ + { + name: "dock-discord", + textAliases: ["/dock-discord", "/dock_discord"], + description: "Switch to discord for replies.", + source: "native", + scope: "both", + acceptsArgs: false, + category: "docks", + }, + { + name: "dreaming", + textAliases: ["/dreaming"], + description: "Enable or disable memory dreaming.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + { + name: "prose", + textAliases: ["/prose"], + description: "Draft polished prose.", + source: "skill", + scope: "both", + acceptsArgs: true, + }, + ]); + + expectRecordFields(requireCommandByName("dock-discord"), "dock-discord command", { + aliases: ["dock_discord"], + category: "tools", + executeLocal: false, + }); + expectRecordFields(requireCommandByName("dreaming"), "dreaming command", { + key: "dreaming", + executeLocal: false, + }); + expectRecordFields(requireCommandByName("prose"), "prose command", { + key: "prose", + executeLocal: false, + }); + expectParsedSlash("/dock_discord", { name: "dock-discord" }, ""); + }); + + it("does not let remote commands collide with reserved local commands", () => { + applyRemoteEntries([ + { + name: "redirect", + textAliases: ["/redirect"], + description: "Remote redirect impostor.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ]); + + expectRecordFields(requireCommandByName("redirect"), "redirect command", { + key: "redirect", + executeLocal: true, + description: "Abort and restart with a new message", + }); + }); + + it("drops remote commands with unsafe identifiers before they reach the palette/parser", () => { + applyRemoteEntries([ + { + name: "prose now", + textAliases: ["/prose now", "/safe-name"], + description: "Unsafe injected command.", + source: "skill", + scope: "both", + acceptsArgs: true, + }, + { + name: "bad:alias", + textAliases: ["/bad:alias"], + description: "Unsafe alias command.", + source: "plugin", + scope: "both", + acceptsArgs: false, + }, + ]); + + expectRecordFields(requireCommandByName("safe-name"), "safe-name command", { + name: "safe-name", + }); + expect(SLASH_COMMANDS.find((entry) => entry.name === "prose now")).toBeUndefined(); + expect(SLASH_COMMANDS.find((entry) => entry.name === "bad:alias")).toBeUndefined(); + expectParsedSlash("/safe-name", { name: "safe-name" }, ""); + }); + + it("caps remote command payload size and long metadata before it reaches UI state", () => { + const longName = "x".repeat(260); + const longDescription = "d".repeat(2_500); + const oversizedCommand = { + name: "plugin-0", + textAliases: Array.from({ length: 25 }, (_, aliasIndex) => `/plugin-0-${aliasIndex}`), + description: longDescription, + source: "plugin" as const, + scope: "both" as const, + acceptsArgs: true, + args: Array.from({ length: 25 }, (_, argIndex) => ({ + name: `${longName}-${argIndex}`, + description: longDescription, + type: "string" as const, + choices: Array.from({ length: 55 }, (_Local, choiceIndex) => ({ + value: `${longName}-${choiceIndex}`, + label: `${longName}-${choiceIndex}`, + })), + })), + }; + applyRemoteEntries([ + oversizedCommand, + ...Array.from({ length: 519 }, (_, index) => ({ + name: `plugin-${index + 1}`, + textAliases: [`/plugin-${index + 1}`], + description: "Plugin command.", + source: "plugin" as const, + scope: "both" as const, + acceptsArgs: false, + })), + ]); + + const remoteCommands = SLASH_COMMANDS.filter((entry) => entry.name.startsWith("plugin-")); + expect(remoteCommands).toHaveLength(500); + const first = remoteCommands[0]; + expect(first.aliases).toHaveLength(19); + expect(first.description.length).toBeLessThanOrEqual(2_000); + expect(first.args?.split(" ")).toHaveLength(20); + expect(first.argOptions).toHaveLength(50); + }); + + it("falls back safely when command payload shapes are malformed", () => { + applyCommandsListResult({ commands: { bad: "shape" } }); + expect(SLASH_COMMANDS.find((entry) => entry.name === "pair")).toBeUndefined(); + expectRecordFields(requireCommandByName("help"), "help command", { + key: "help", + name: "help", + executeLocal: true, + }); + + applyCommandsListResult({ + commands: [ + { + name: "valid", + textAliases: ["/valid"], + description: 42, + args: { nope: true }, + }, + { + name: "pair", + textAliases: ["/pair"], + description: "Generate setup codes.", + source: "plugin", + scope: "both", + acceptsArgs: true, + args: [ + { + name: "mode", + required: "yes", + choices: { broken: true }, + }, + ], + }, + ], + }); + expectRecordFields(requireCommandByName("valid"), "valid command", { + name: "valid", + description: "", + }); + expectRecordFields(requireCommandByName("pair"), "pair command", { + name: "pair", + }); + }); +}); diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/lib/chat/commands.ts similarity index 76% rename from ui/src/ui/chat/slash-commands.ts rename to ui/src/lib/chat/commands.ts index 3588913b0914..6aa99cb337c8 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/lib/chat/commands.ts @@ -1,16 +1,12 @@ -// Control UI chat module implements slash commands behavior. -import type { - CommandEntry, - CommandsListResult, -} from "../../../../packages/gateway-protocol/src/index.js"; +// Control UI chat domain owns pure slash command rules. +import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js"; import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { IconName } from "../icons.ts"; import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; export type SlashCommandTier = "essential" | "standard" | "power"; +export type ChatIconName = string; export type SlashCommandDef = { key: string; @@ -18,7 +14,7 @@ export type SlashCommandDef = { aliases?: string[]; description: string; args?: string; - icon?: IconName; + icon?: ChatIconName; category?: SlashCommandCategory; /** When true, the command is executed client-side via RPC instead of sent to the agent. */ executeLocal?: boolean; @@ -55,7 +51,7 @@ const MAX_REMOTE_NAME_LENGTH = 200; const MAX_REMOTE_DESCRIPTION_LENGTH = 2_000; const MAX_REMOTE_ARG_NAME_LENGTH = 200; -const COMMAND_ICON_OVERRIDES: Partial> = { +const COMMAND_ICON_OVERRIDES: Partial> = { help: "book", status: "barChart", usage: "barChart", @@ -212,7 +208,7 @@ function mapCategory(command: CommandLike): SlashCommandCategory { } } -function mapIcon(command: CommandLike): IconName | undefined { +function mapIcon(command: CommandLike): ChatIconName | undefined { return COMMAND_ICON_OVERRIDES[normalizeUiKey(command)] ?? "terminal"; } @@ -382,11 +378,11 @@ function normalizeCommandEntry( }; } -function replaceSlashCommands(next: SlashCommandDef[]) { +export function replaceSlashCommands(next: SlashCommandDef[]) { SLASH_COMMANDS.splice(0, SLASH_COMMANDS.length, ...next); } -function buildSlashCommandsFromEntries(entries: CommandEntry[]): SlashCommandDef[] { +export function buildSlashCommandsFromEntries(entries: CommandEntry[]): SlashCommandDef[] { const local = buildLocalSlashCommands(); const reservedLocalNames = buildReservedLocalSlashNames(local); const mapped = entries @@ -406,7 +402,9 @@ function buildSlashCommandsFromEntries(entries: CommandEntry[]): SlashCommandDef return Array.from(deduped.values()); } -function getRemoteCommandEntries(result: CommandsListResult | null | undefined): CommandEntry[] { +export function getRemoteCommandEntries( + result: { commands?: unknown } | null | undefined, +): CommandEntry[] { const commands = result?.commands; if (!Array.isArray(commands)) { return []; @@ -416,140 +414,13 @@ function getRemoteCommandEntries(result: CommandsListResult | null | undefined): .filter((entry): entry is CommandEntry => entry !== null); } -function buildFallbackSlashCommands(): SlashCommandDef[] { +export function buildFallbackSlashCommands(): SlashCommandDef[] { return buildLocalSlashCommands(); } export const SLASH_COMMANDS: SlashCommandDef[] = buildFallbackSlashCommands(); -let refreshSeq = 0; -const REMOTE_SLASH_COMMAND_CACHE_TTL_MS = 60_000; - -type RemoteSlashCommandCacheEntry = { - commands?: SlashCommandDef[]; - expiresAt: number; - inFlight?: Promise; -}; - -let remoteSlashCommandCache = new WeakMap< - GatewayBrowserClient, - Map ->(); - -function remoteSlashCommandCacheKey(agentId: string | undefined): string { - return agentId ?? ""; -} - -function getRemoteSlashCommandCache( - client: GatewayBrowserClient, -): Map { - let cache = remoteSlashCommandCache.get(client); - if (!cache) { - cache = new Map(); - remoteSlashCommandCache.set(client, cache); - } - return cache; -} - -async function requestRemoteSlashCommands( - client: GatewayBrowserClient, - agentId: string | undefined, - fallback: SlashCommandDef[] | undefined, -): Promise { - try { - const result = await client.request("commands.list", { - ...(agentId ? { agentId } : {}), - includeArgs: true, - scope: "text", - }); - if (!Array.isArray(result?.commands)) { - return buildFallbackSlashCommands(); - } - const commands = buildSlashCommandsFromEntries(getRemoteCommandEntries(result)); - const cache = getRemoteSlashCommandCache(client); - cache.set(remoteSlashCommandCacheKey(agentId), { - commands, - expiresAt: Date.now() + REMOTE_SLASH_COMMAND_CACHE_TTL_MS, - }); - return commands; - } catch { - return fallback ?? buildFallbackSlashCommands(); - } -} - -function loadRemoteSlashCommands( - client: GatewayBrowserClient, - agentId: string | undefined, -): Promise { - const cache = getRemoteSlashCommandCache(client); - const key = remoteSlashCommandCacheKey(agentId); - const cached = cache.get(key); - const now = Date.now(); - if (cached?.commands && cached.expiresAt > now) { - return Promise.resolve(cached.commands); - } - if (cached?.inFlight) { - return cached.inFlight; - } - const inFlight = requestRemoteSlashCommands(client, agentId, cached?.commands).finally(() => { - const latest = cache.get(key); - if (latest?.inFlight === inFlight) { - delete latest.inFlight; - } - }); - cache.set(key, { - ...(cached?.commands ? { commands: cached.commands } : {}), - expiresAt: cached?.expiresAt ?? 0, - inFlight, - }); - return inFlight; -} - -export function applyRemoteSlashCommandsResult(params: { - client: GatewayBrowserClient | null; - agentId?: string | null; - result: CommandsListResult | null | undefined; -}): boolean { - if (!Array.isArray(params.result?.commands)) { - return false; - } - const agentId = params.agentId?.trim(); - const commands = buildSlashCommandsFromEntries(getRemoteCommandEntries(params.result)); - if (params.client) { - const cache = getRemoteSlashCommandCache(params.client); - cache.set(remoteSlashCommandCacheKey(agentId), { - commands, - expiresAt: Date.now() + REMOTE_SLASH_COMMAND_CACHE_TTL_MS, - }); - } - refreshSeq += 1; - replaceSlashCommands(commands); - return true; -} - -export async function refreshSlashCommands(params: { - client: GatewayBrowserClient | null; - agentId?: string | null; -}): Promise { - const seq = ++refreshSeq; - const agentId = params.agentId?.trim(); - if (!params.client) { - if (seq !== refreshSeq) { - return; - } - replaceSlashCommands(buildFallbackSlashCommands()); - return; - } - const commands = await loadRemoteSlashCommands(params.client, agentId); - if (seq !== refreshSeq) { - return; - } - replaceSlashCommands(commands); -} - export function resetSlashCommandsForTest(): void { - refreshSeq = 0; - remoteSlashCommandCache = new WeakMap(); replaceSlashCommands(buildFallbackSlashCommands()); } diff --git a/ui/src/ui/chat/heartbeat-display.ts b/ui/src/lib/chat/heartbeat-display.ts similarity index 100% rename from ui/src/ui/chat/heartbeat-display.ts rename to ui/src/lib/chat/heartbeat-display.ts diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/lib/chat/message-extract.test.ts similarity index 100% rename from ui/src/ui/chat/message-extract.test.ts rename to ui/src/lib/chat/message-extract.test.ts diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/lib/chat/message-extract.ts similarity index 100% rename from ui/src/ui/chat/message-extract.ts rename to ui/src/lib/chat/message-extract.ts diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/lib/chat/message-normalizer.test.ts similarity index 100% rename from ui/src/ui/chat/message-normalizer.test.ts rename to ui/src/lib/chat/message-normalizer.test.ts diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts similarity index 95% rename from ui/src/ui/chat/message-normalizer.ts rename to ui/src/lib/chat/message-normalizer.ts index a19e73150705..1c62560d5c56 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -12,8 +12,35 @@ import { } from "../../../../src/chat/tool-content.js"; import { splitMediaFromOutput } from "../../../../src/media/parse.js"; import { parseInlineDirectives } from "../../../../src/utils/directive-tags.js"; -import type { NormalizedMessage, MessageContentItem } from "../types/chat-types.ts"; -export { isToolResultMessage, normalizeRoleForGrouping } from "./role-normalizer.ts"; +import type { NormalizedMessage, MessageContentItem } from "./chat-types.ts"; + +export function normalizeRoleForGrouping(role: string): string { + const lower = role.toLowerCase(); + if (lower === "user") { + return "user"; + } + if (lower === "assistant") { + return "assistant"; + } + if (lower === "system") { + return "system"; + } + if ( + lower === "toolresult" || + lower === "tool_result" || + lower === "tool" || + lower === "function" + ) { + return "tool"; + } + return role; +} + +export function isToolResultMessage(message: unknown): boolean { + const m = message as Record; + const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; + return role === "toolresult" || role === "tool_result"; +} function isTextContentBlock( item: Record, diff --git a/ui/src/ui/chat-model-ref.test.ts b/ui/src/lib/chat/model-ref.test.ts similarity index 99% rename from ui/src/ui/chat-model-ref.test.ts rename to ui/src/lib/chat/model-ref.test.ts index 8bbf7a2f0445..e3c6b804b84d 100644 --- a/ui/src/ui/chat-model-ref.test.ts +++ b/ui/src/lib/chat/model-ref.test.ts @@ -1,5 +1,11 @@ // Control UI tests cover chat model ref behavior. import { describe, expect, it } from "vitest"; +import { + createAmbiguousModelCatalog, + createModelCatalog, + DEEPSEEK_CHAT_MODEL, + OPENAI_GPT5_MINI_MODEL, +} from "../../test-helpers/chat-model.ts"; import { buildChatModelOption, buildQualifiedChatModelValue, @@ -9,13 +15,7 @@ import { normalizeChatModelOverrideValue, resolvePreferredServerChatModelValue, resolveServerChatModelValue, -} from "./chat-model-ref.ts"; -import { - createAmbiguousModelCatalog, - createModelCatalog, - DEEPSEEK_CHAT_MODEL, - OPENAI_GPT5_MINI_MODEL, -} from "./chat-model.test-helpers.ts"; +} from "./model-ref.ts"; const catalog = createModelCatalog(OPENAI_GPT5_MINI_MODEL, { id: "claude-sonnet-4-5", diff --git a/ui/src/ui/chat-model-ref.ts b/ui/src/lib/chat/model-ref.ts similarity index 97% rename from ui/src/ui/chat-model-ref.ts rename to ui/src/lib/chat/model-ref.ts index 52427c48be1c..57e893e5a9fb 100644 --- a/ui/src/ui/chat-model-ref.ts +++ b/ui/src/lib/chat/model-ref.ts @@ -1,7 +1,15 @@ -// Control UI module implements chat model ref behavior. -import type { ChatModelOverride } from "./chat-model-ref.types.ts"; -import type { ModelCatalogEntry } from "./types.ts"; -export type { ChatModelOverride } from "./chat-model-ref.types.ts"; +// Chat model reference normalization. +import type { ModelCatalogEntry } from "../../api/types.ts"; + +export type ChatModelOverride = + | { + kind: "qualified"; + value: string; + } + | { + kind: "raw"; + value: string; + }; export function buildQualifiedChatModelValue(model: string, provider?: string | null): string { const trimmedModel = model.trim(); diff --git a/ui/src/ui/chat-model-select-state.test.ts b/ui/src/lib/chat/model-select-state.test.ts similarity index 97% rename from ui/src/ui/chat-model-select-state.test.ts rename to ui/src/lib/chat/model-select-state.test.ts index c499cf507265..50bd9d313089 100644 --- a/ui/src/ui/chat-model-select-state.test.ts +++ b/ui/src/lib/chat/model-select-state.test.ts @@ -1,15 +1,15 @@ // Control UI tests cover chat model select state behavior. import { describe, expect, it } from "vitest"; -import { - resolveChatModelOverrideValue, - resolveChatModelSelectState, -} from "./chat-model-select-state.ts"; import { createModelCatalog, createSessionsListResult, DEEPSEEK_CHAT_MODEL, DEFAULT_CHAT_MODEL_CATALOG, -} from "./chat-model.test-helpers.ts"; +} from "../../test-helpers/chat-model.ts"; +import { + resolveChatModelOverrideValue, + resolveChatModelSelectState, +} from "./model-select-state.ts"; type ChatModelStateInput = Parameters[0]; @@ -18,7 +18,7 @@ function createChatModelState( ): ChatModelStateInput { return { sessionKey: "main", - chatModelOverrides: {}, + modelOverrides: {}, chatModelCatalog: [], sessionsResult: createSessionsListResult({ model: null, modelProvider: null }), ...params, @@ -51,7 +51,7 @@ describe("chat-model-select-state", () => { it("normalizes cached bare overrides to the matching catalog option", () => { const state = createChatModelState({ - chatModelOverrides: { main: { kind: "raw", value: "gpt-5-mini" } }, + modelOverrides: { main: "gpt-5-mini" }, chatModelCatalog: createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG), }); diff --git a/ui/src/lib/chat/model-select-state.ts b/ui/src/lib/chat/model-select-state.ts new file mode 100644 index 000000000000..2829fbca70b2 --- /dev/null +++ b/ui/src/lib/chat/model-select-state.ts @@ -0,0 +1,222 @@ +// Chat model select state derivation. +import { formatFastModeCurrentStatus } from "../../../../src/shared/fast-mode.js"; +import type { + FastMode, + GatewaySessionRow, + ModelCatalogEntry, + SessionsListResult, +} from "../../api/types.ts"; +import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; +import { + buildCatalogDisplayLookup, + buildChatModelOptionFromLookup, + createChatModelOverride, + formatCatalogChatModelDisplayFromLookup, + normalizeChatModelOverrideValue, + resolvePreferredServerChatModelValue, +} from "./model-ref.ts"; + +type ChatModelSelectStateInput = { + chatModelCatalog: ModelCatalogEntry[]; + modelOverrides: Readonly>; + sessionKey: string; + sessionsResult: SessionsListResult | null; +}; + +export type ChatModelSelectOption = { + value: string; + label: string; +}; + +export type ChatModelSelectState = { + currentOverride: string; + defaultModel: string; + defaultDisplay: string; + defaultLabel: string; + options: ChatModelSelectOption[]; +}; + +export type ChatFastModeSelectValue = "" | "on" | "off" | "auto"; + +export type ChatFastModeSelectState = { + currentOverride: ChatFastModeSelectValue; + disabled: boolean; + options: ChatModelSelectOption[]; + supported: boolean; +}; + +type ChatFastModeSelectStateInput = { + activeRunId: string | null; + catalog: ModelCatalogEntry[]; + connected: boolean; + currentModelOverride: string; + gatewayAvailable: boolean; + loading: boolean; + sending: boolean; + sessionKey: string; + sessionsResult: SessionsListResult | null; + stream: string | null; +}; + +const FAST_MODE_PROVIDER_IDS = new Set([ + "anthropic", + "minimax", + "minimax-portal", + "openai", + "openrouter", + "xai", +]); + +function resolveActiveSessionRow(state: ChatModelSelectStateInput) { + return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); +} + +export function resolveChatModelOverrideValue(state: ChatModelSelectStateInput): string { + const catalog = state.chatModelCatalog ?? []; + + const sharedOverrides = state.modelOverrides; + if (Object.hasOwn(sharedOverrides, state.sessionKey)) { + const shared = sharedOverrides[state.sessionKey]; + return shared == null + ? "" + : normalizeChatModelOverrideValue(createChatModelOverride(shared), catalog); + } + + const activeRow = resolveActiveSessionRow(state); + return resolvePreferredServerChatModelValue(activeRow?.model, activeRow?.modelProvider, catalog); +} + +function resolveDefaultModelValue(state: ChatModelSelectStateInput): string { + return resolvePreferredServerChatModelValue( + state.sessionsResult?.defaults?.model, + state.sessionsResult?.defaults?.modelProvider, + state.chatModelCatalog ?? [], + ); +} + +function buildChatModelOptions( + catalog: ModelCatalogEntry[], + displayLookup: ReturnType, + currentOverride: string, + defaultModel: string, +): ChatModelSelectOption[] { + const seen = new Set(); + const options: ChatModelSelectOption[] = []; + + const addOption = (value: string, label?: string) => { + pushUniqueTrimmedSelectOption(options, seen, value, (trimmed) => label ?? trimmed); + }; + + for (const entry of catalog) { + const option = buildChatModelOptionFromLookup(entry, displayLookup); + addOption(option.value, option.label); + } + + if (currentOverride) { + addOption( + currentOverride, + formatCatalogChatModelDisplayFromLookup(currentOverride, displayLookup), + ); + } + if (defaultModel) { + addOption(defaultModel, formatCatalogChatModelDisplayFromLookup(defaultModel, displayLookup)); + } + return options; +} + +export function resolveChatModelSelectState( + state: ChatModelSelectStateInput, +): ChatModelSelectState { + const catalog = state.chatModelCatalog ?? []; + const displayLookup = buildCatalogDisplayLookup(catalog); + const currentOverride = resolveChatModelOverrideValue(state); + const defaultModel = resolveDefaultModelValue(state); + const defaultDisplay = formatCatalogChatModelDisplayFromLookup(defaultModel, displayLookup); + + return { + currentOverride, + defaultModel, + defaultDisplay, + defaultLabel: defaultModel ? `Default (${defaultDisplay})` : "Default model", + options: buildChatModelOptions(catalog, displayLookup, currentOverride, defaultModel), + }; +} + +export function normalizeChatFastModeInput(raw: string): FastMode | undefined { + if (raw === "auto") { + return "auto"; + } + if (raw === "on") { + return true; + } + if (raw === "off") { + return false; + } + return undefined; +} + +export function resolveChatFastModeStatus(session: GatewaySessionRow | undefined): string { + return formatFastModeCurrentStatus({ + mode: session?.effectiveFastMode ?? session?.fastMode, + source: session?.effectiveFastModeSource, + fastAutoOnSeconds: session?.fastAutoOnSeconds, + }); +} + +function resolveProviderFromModelValue(value: string, catalog: ModelCatalogEntry[]): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const separator = trimmed.indexOf("/"); + if (separator > 0) { + return trimmed.slice(0, separator).toLowerCase(); + } + return ( + catalog + .find((entry) => entry.id.trim().toLowerCase() === trimmed.toLowerCase()) + ?.provider.trim() + .toLowerCase() || null + ); +} + +export function resolveChatFastModeSelectState( + input: ChatFastModeSelectStateInput, +): ChatFastModeSelectState { + const activeRow = input.sessionsResult?.sessions?.find((row) => row.key === input.sessionKey); + const defaultProvider = input.sessionsResult?.defaults?.modelProvider; + const effectiveProvider = + resolveProviderFromModelValue(input.currentModelOverride, input.catalog) ?? + activeRow?.modelProvider?.trim().toLowerCase() ?? + defaultProvider?.trim().toLowerCase() ?? + null; + const currentOverride = + activeRow?.fastMode === "auto" + ? "auto" + : activeRow?.fastMode === true + ? "on" + : activeRow?.fastMode === false + ? "off" + : ""; + const supported = Boolean( + (effectiveProvider && FAST_MODE_PROVIDER_IDS.has(effectiveProvider)) || currentOverride, + ); + return { + currentOverride, + disabled: + !supported || + !input.connected || + input.loading || + input.sending || + Boolean(input.activeRunId) || + input.stream !== null || + !input.gatewayAvailable, + options: [ + { value: "", label: "Default" }, + { value: "on", label: "Fast" }, + { value: "off", label: "Standard" }, + { value: "auto", label: "Auto" }, + ], + supported, + }; +} diff --git a/ui/src/ui/chat/side-result.ts b/ui/src/lib/chat/side-result.ts similarity index 84% rename from ui/src/ui/chat/side-result.ts rename to ui/src/lib/chat/side-result.ts index d6a6c53dc0aa..34825fe0f6d1 100644 --- a/ui/src/ui/chat/side-result.ts +++ b/ui/src/lib/chat/side-result.ts @@ -1,4 +1,3 @@ -// Control UI chat module implements side result behavior. import { normalizeOptionalString } from "../string-coerce.ts"; export type ChatSideResult = { @@ -27,13 +26,12 @@ export function parseChatSideResult(payload: unknown): ChatSideResult | null { if (!(runId && sessionKey && question && text)) { return null; } + const agentId = normalizeOptionalString(candidate.agentId); return { kind: "btw", runId, sessionKey, - ...(normalizeOptionalString(candidate.agentId) - ? { agentId: normalizeOptionalString(candidate.agentId) } - : {}), + ...(agentId ? { agentId } : {}), question, text, isError: candidate.isError === true, diff --git a/ui/src/lib/chat/thinking.ts b/ui/src/lib/chat/thinking.ts new file mode 100644 index 000000000000..709dc0ac3e05 --- /dev/null +++ b/ui/src/lib/chat/thinking.ts @@ -0,0 +1,342 @@ +// Control UI module implements thinking behavior. +import type { + GatewaySessionRow, + GatewayThinkingLevelOption, + ModelCatalogEntry, + SessionsListResult, +} from "../../api/types.ts"; +import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; +import { sessionModelMatchesDefaults } from "../session-model-defaults.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; + +export type ThinkingCatalogEntry = { + provider: string; + id: string; + reasoning?: boolean; +}; + +const BASE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; + +export function normalizeThinkLevel(raw?: string | null): string | undefined { + if (!raw) { + return undefined; + } + const key = normalizeLowercaseStringOrEmpty(raw); + const collapsed = key.replace(/[\s_-]+/g, ""); + if (collapsed === "adaptive" || collapsed === "auto") { + return "adaptive"; + } + if (collapsed === "max") { + return "max"; + } + if (collapsed === "xhigh" || collapsed === "extrahigh") { + return "xhigh"; + } + if (key === "off" || key === "none") { + return "off"; + } + if (["on", "enable", "enabled"].includes(key)) { + return "low"; + } + if (["min", "minimal"].includes(key)) { + return "minimal"; + } + if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) { + return "low"; + } + if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) { + return "medium"; + } + if (["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest"].includes(key)) { + return "high"; + } + if (key === "think") { + return "minimal"; + } + return undefined; +} + +export function listThinkingLevelLabels( + provider?: string | null, + model?: string | null, +): readonly string[] { + void provider; + void model; + return BASE_THINKING_LEVELS; +} + +export function resolveThinkingDefaultForModel(params: { + provider: string; + model: string; + catalog?: readonly ThinkingCatalogEntry[]; +}): string { + const candidate = params.catalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + return candidate?.reasoning ? "low" : "off"; +} + +type ThinkingSessionDefaults = SessionsListResult["defaults"] | undefined; + +export type ChatThinkingSelectState = { + currentOverride: string; + defaultLabel: string; + defaultValue: string; + options: Array<{ value: string; label: string }>; +}; + +export function resolveThinkingLevelOptionsForSession( + session: GatewaySessionRow | undefined, + defaults: ThinkingSessionDefaults, +): GatewayThinkingLevelOption[] { + const { provider, model } = resolveThinkingTargetModel({ defaults, session }); + return resolveThinkingLevelOptions({ catalog: [], defaults, model, provider, session }); +} + +export function formatThinkingCommandOptionsForSession( + session: GatewaySessionRow | undefined, + defaults?: SessionsListResult["defaults"], +): string { + const options = resolveThinkingLevelOptionsForSession(session, defaults) + .map((level) => level.label) + .join(", "); + return options.split(", ").includes("default") ? options : `default, ${options}`; +} + +export function resolveThinkingLevelInput( + rawLevel: string, + session: GatewaySessionRow | undefined, + defaults: ThinkingSessionDefaults, +): string | undefined { + const normalized = normalizeThinkLevel(rawLevel); + if (normalized) { + return normalized; + } + const rawKey = normalizeLowercaseStringOrEmpty(rawLevel); + return resolveThinkingLevelOptionsForSession(session, defaults) + .map((option) => ({ + id: normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id), + label: normalizeLowercaseStringOrEmpty(option.label), + })) + .find((option) => option.id === rawKey || option.label === rawKey)?.id; +} + +export function isThinkingLevelOptionForSession( + session: GatewaySessionRow | undefined, + defaults: ThinkingSessionDefaults, + level: string, +): boolean { + return resolveThinkingLevelOptionsForSession(session, defaults).some((option) => { + const id = normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id); + return id === level || normalizeThinkLevel(option.label) === level; + }); +} + +export function resolveCurrentThinkingLevel( + session: GatewaySessionRow | undefined, + defaults: ThinkingSessionDefaults, + models: ModelCatalogEntry[], +): string { + const persisted = normalizeThinkLevel(session?.thinkingLevel); + if (persisted) { + return ( + resolveThinkingLevelOptionsForSession(session, defaults).find( + (level) => normalizeThinkLevel(level.id) === persisted, + )?.label ?? persisted + ); + } + if (session?.thinkingDefault) { + return session.thinkingDefault; + } + if ((!session || sessionModelMatchesDefaults(session, defaults)) && defaults?.thinkingDefault) { + return defaults.thinkingDefault; + } + const provider = session?.modelProvider ?? defaults?.modelProvider; + const model = session?.model ?? defaults?.model; + if (!provider || !model) { + return "off"; + } + return resolveThinkingDefaultForModel({ + provider, + model, + catalog: models, + }); +} + +function buildThinkingOptions( + levels: readonly GatewayThinkingLevelOption[], + currentOverride: string, +): Array<{ value: string; label: string }> { + const seen = new Set(); + const options: Array<{ value: string; label: string }> = []; + const addOption = (value: string, label?: string) => { + const normalizedValue = normalizeThinkingOptionValue(value); + pushUniqueTrimmedSelectOption(options, seen, normalizedValue, () => + formatThinkingOverrideLabel(normalizedValue, label), + ); + }; + + for (const level of levels) { + addOption(level.id, level.label); + } + if (currentOverride) { + addOption(currentOverride); + } + return options; +} + +function isOffThinkingOption(value: string | null | undefined): boolean { + return normalizeThinkingOptionValue(value ?? "") === "off"; +} + +function isOffOnlyThinkingLevels(levels: readonly GatewayThinkingLevelOption[]): boolean { + return levels.every((level) => isOffThinkingOption(level.id || level.label)); +} + +function resolveThinkingTargetModel(params: { + defaults: ThinkingSessionDefaults; + session: GatewaySessionRow | undefined; +}): { provider: string | null; model: string | null } { + return { + provider: params.session?.modelProvider ?? params.defaults?.modelProvider ?? null, + model: params.session?.model ?? params.defaults?.model ?? null, + }; +} + +function resolveThinkingLevelOptions(params: { + catalog: readonly ThinkingCatalogEntry[]; + defaults: ThinkingSessionDefaults; + hideUnsupportedOffOnly?: boolean; + model: string | null; + provider: string | null; + session: GatewaySessionRow | undefined; +}): GatewayThinkingLevelOption[] { + const modelMatchesDefaults = sessionModelMatchesDefaults(params.session, params.defaults); + const catalogEntry = + params.provider && params.model + ? params.catalog.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ) + : undefined; + const explicitLevels = + (params.session?.thinkingLevels?.length ? params.session.thinkingLevels : null) ?? + (modelMatchesDefaults && params.defaults?.thinkingLevels?.length + ? params.defaults.thinkingLevels + : null); + if (explicitLevels) { + if ( + params.hideUnsupportedOffOnly && + catalogEntry?.reasoning === false && + isOffOnlyThinkingLevels(explicitLevels) + ) { + return []; + } + return explicitLevels; + } + const explicitLabels = + (params.session?.thinkingOptions?.length ? params.session.thinkingOptions : null) ?? + (modelMatchesDefaults && params.defaults?.thinkingOptions?.length + ? params.defaults.thinkingOptions + : null); + if (params.hideUnsupportedOffOnly && catalogEntry?.reasoning === false) { + if (!explicitLabels || explicitLabels.every(isOffThinkingOption)) { + return []; + } + } + const labels = + explicitLabels ?? + (params.provider && params.model + ? listThinkingLevelLabels(params.provider, params.model) + : listThinkingLevelLabels()); + return labels.map((label) => ({ + id: normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label), + label, + })); +} + +export function resolveChatThinkingSelectState(params: { + catalog: readonly ThinkingCatalogEntry[]; + sessionKey: string; + sessionsResult: SessionsListResult | null; +}): ChatThinkingSelectState { + const session = params.sessionsResult?.sessions?.find((row) => row.key === params.sessionKey); + const persisted = session?.thinkingLevel; + const currentOverride = + typeof persisted === "string" && persisted.trim() + ? (normalizeThinkLevel(persisted) ?? persisted.trim()) + : ""; + const defaults = params.sessionsResult?.defaults; + const { provider, model } = resolveThinkingTargetModel({ defaults, session }); + const levels = resolveThinkingLevelOptions({ + catalog: params.catalog, + defaults, + hideUnsupportedOffOnly: true, + model, + provider, + session, + }); + const defaultFromSessionDefaults = + (!session || sessionModelMatchesDefaults(session, defaults)) && defaults?.thinkingDefault + ? defaults.thinkingDefault + : undefined; + const defaultLevel = + session?.thinkingDefault ?? + defaultFromSessionDefaults ?? + (provider && model + ? resolveThinkingDefaultForModel({ + provider, + model, + catalog: params.catalog, + }) + : "off"); + const effectiveOverride = levels.length === 0 && currentOverride === "off" ? "" : currentOverride; + return { + currentOverride: effectiveOverride, + defaultLabel: formatInheritedThinkingLabel(defaultLevel), + defaultValue: normalizeThinkingOptionValue(defaultLevel), + options: buildThinkingOptions(levels, effectiveOverride), + }; +} + +export function normalizeThinkingOptionValue(raw: string): string { + return normalizeThinkLevel(raw) ?? normalizeLowercaseStringOrEmpty(raw); +} + +export function formatInheritedThinkingLabel(effectiveLevel: string | null | undefined): string { + const normalized = effectiveLevel ? normalizeThinkingOptionValue(effectiveLevel) : "off"; + return `Inherited: ${formatThinkingLevelDisplayLabel(normalized)}`; +} + +export function formatThinkingOverrideLabel(value: string, label?: string | null): string { + const normalized = normalizeThinkingOptionValue(value); + if (!normalized || normalized === "off") { + return "Off"; + } + return formatThinkingLevelDisplayLabel(label?.trim() || normalized); +} + +function formatThinkingLevelDisplayLabel(value: string): string { + const raw = normalizeLowercaseStringOrEmpty(value); + if (["on", "enable", "enabled"].includes(raw)) { + return "On"; + } + const normalized = normalizeThinkingOptionValue(value); + switch (normalized) { + case "adaptive": + return "Adaptive"; + case "minimal": + return "Minimal"; + case "low": + return "Low"; + case "medium": + return "Medium"; + case "high": + return "High"; + case "xhigh": + return "Extra high"; + case "max": + return "Maximum"; + default: + return value.charAt(0).toUpperCase() + value.slice(1); + } +} diff --git a/ui/src/lib/chat/tool-cards.ts b/ui/src/lib/chat/tool-cards.ts new file mode 100644 index 000000000000..6d20dcf9c67c --- /dev/null +++ b/ui/src/lib/chat/tool-cards.ts @@ -0,0 +1,329 @@ +// Control UI chat domain owns pure tool-card extraction rules. +import { extractCanvasFromText } from "../../../../src/chat/canvas-render.js"; +import type { ToolCard } from "./chat-types.ts"; +import { extractTextCached } from "./message-extract.ts"; +import { isToolResultMessage } from "./message-normalizer.ts"; + +export type ToolPreview = NonNullable; + +function resolveTranscriptMessageId(message: Record): string | undefined { + if (typeof message.messageId === "string" && message.messageId.trim()) { + return message.messageId; + } + const openClawMeta = message["__openclaw"]; + const transcriptMeta = + openClawMeta && typeof openClawMeta === "object" && !Array.isArray(openClawMeta) + ? (openClawMeta as Record) + : null; + return typeof transcriptMeta?.id === "string" && transcriptMeta.id.trim() + ? transcriptMeta.id + : undefined; +} + +function normalizeContent(content: unknown): Array> { + if (!Array.isArray(content)) { + return []; + } + return content.filter( + (entry): entry is Record => Boolean(entry) && typeof entry === "object", + ); +} + +function coerceArgs(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + const trimmed = value.trim(); + if (!trimmed) { + return value; + } + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + return value; + } + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function extractToolText(item: Record): string | undefined { + if (typeof item.text === "string") { + return item.text; + } + if (typeof item.content === "string") { + return item.content; + } + if (Array.isArray(item.content)) { + const parts = item.content.flatMap((entry) => { + if (!entry || typeof entry !== "object") { + return []; + } + const text = (entry as { text?: unknown }).text; + return typeof text === "string" ? [text] : []; + }); + if (parts.length > 0) { + return parts.join("\n"); + } + } + return undefined; +} + +function readToolErrorFlag(value: Record): boolean | undefined { + const raw = value.isError ?? value.is_error; + return typeof raw === "boolean" ? raw : undefined; +} + +const TOOL_NOT_FOUND_PATTERN = /^tool not found\.?$/i; +const MAX_ERROR_DETECT_CHARS = 20_000; +const TOOL_ERROR_STATUSES = new Set(["error", "failed", "timeout"]); + +function hasToolErrorStatus(value: unknown): boolean { + return typeof value === "string" && TOOL_ERROR_STATUSES.has(value.trim().toLowerCase()); +} + +export function isToolErrorOutput(outputText: string | undefined): boolean { + if (!outputText) { + return false; + } + const trimmed = outputText.trim(); + if (!trimmed) { + return false; + } + if (TOOL_NOT_FOUND_PATTERN.test(trimmed)) { + return true; + } + if (trimmed.length > MAX_ERROR_DETECT_CHARS) { + return false; + } + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return false; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return false; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return false; + } + const obj = parsed as Record; + const explicitErrorFlag = readToolErrorFlag(obj); + if (explicitErrorFlag !== undefined) { + return explicitErrorFlag; + } + if ("error" in obj) { + const value = obj.error; + if (typeof value === "string") { + return value.trim().length > 0; + } + if (typeof value === "boolean") { + return value; + } + if (value && typeof value === "object") { + return true; + } + } + return hasToolErrorStatus(obj.status); +} + +export function isToolCardError(card: ToolCard): boolean { + if (card.isError !== undefined) { + return card.isError; + } + return isToolErrorOutput(card.outputText); +} + +export function extractToolPreview( + outputText: string | undefined, + toolName: string | undefined, +): ToolCard["preview"] | undefined { + return extractCanvasFromText(outputText, toolName); +} + +function resolveToolCardId( + item: Record, + message: Record, + index: number, + prefix = "tool", +): string { + const explicitId = + (typeof item.id === "string" && item.id.trim()) || + (typeof item.toolCallId === "string" && item.toolCallId.trim()) || + (typeof item.tool_call_id === "string" && item.tool_call_id.trim()) || + (typeof item.callId === "string" && item.callId.trim()) || + (typeof message.toolCallId === "string" && message.toolCallId.trim()) || + (typeof message.tool_call_id === "string" && message.tool_call_id.trim()) || + ""; + if (explicitId) { + return `${prefix}:${explicitId}`; + } + const name = + (typeof item.name === "string" && item.name.trim()) || + (typeof message.toolName === "string" && message.toolName.trim()) || + (typeof message.tool_name === "string" && message.tool_name.trim()) || + "tool"; + return `${prefix}:${name}:${index}`; +} + +function serializeToolInput(args: unknown): string | undefined { + if (args === undefined || args === null) { + return undefined; + } + if (typeof args === "string") { + return args; + } + try { + return JSON.stringify(args, null, 2); + } catch { + if (typeof args === "number" || typeof args === "boolean" || typeof args === "bigint") { + return String(args); + } + if (typeof args === "symbol") { + return args.description ? `Symbol(${args.description})` : "Symbol()"; + } + return Object.prototype.toString.call(args); + } +} + +export function formatCollapsedToolSummaryText(value: string | undefined): string | undefined { + const normalized = value?.trim().replace(/\s+/g, " "); + if (!normalized) { + return undefined; + } + const withoutConnector = normalized.replace(/^with\s+/i, "").trim(); + return withoutConnector || normalized; +} + +export function formatCollapsedToolPreviewText(value: string | undefined): string | undefined { + const normalized = formatCollapsedToolSummaryText(value); + if (!normalized) { + return undefined; + } + return normalized.slice(0, 120); +} + +function findFirstUnmatchedCard( + cards: ToolCard[], + id: string, + name: string, + fallbackMatchedCards: WeakSet, +): ToolCard | undefined { + let nameOnlyCandidate: ToolCard | undefined; + for (const card of cards) { + if (card.id === id) { + return card; + } + if ( + !nameOnlyCandidate && + card.name === name && + card.outputText === undefined && + !fallbackMatchedCards.has(card) + ) { + nameOnlyCandidate = card; + } + } + return nameOnlyCandidate; +} + +export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] { + const m = message as Record; + const content = normalizeContent(m.content); + const messageIsError = readToolErrorFlag(m); + const cards: ToolCard[] = []; + const fallbackMatchedCards = new WeakSet(); + const transcriptMessageId = resolveTranscriptMessageId(m); + + for (let index = 0; index < content.length; index++) { + const item = content[index] ?? {}; + const kind = (typeof item.type === "string" ? item.type : "").toLowerCase(); + const isToolCall = + ["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) || + (typeof item.name === "string" && + (item.arguments != null || item.args != null || item.input != null)); + if (isToolCall) { + const args = coerceArgs(item.arguments ?? item.args ?? item.input); + cards.push({ + id: resolveToolCardId(item, m, index, prefix), + name: typeof item.name === "string" ? item.name : "tool", + args, + inputText: serializeToolInput(args), + messageId: transcriptMessageId, + }); + continue; + } + + if (kind === "toolresult" || kind === "tool_result") { + const name = typeof item.name === "string" ? item.name : "tool"; + const cardId = resolveToolCardId(item, m, index, prefix); + const existing = findFirstUnmatchedCard(cards, cardId, name, fallbackMatchedCards); + const text = extractToolText(item); + const preview = extractToolPreview(text, name); + const isError = readToolErrorFlag(item) ?? messageIsError; + if (existing) { + fallbackMatchedCards.add(existing); + existing.outputText = text; + existing.preview = preview; + if (isError !== undefined) { + existing.isError = isError; + } + continue; + } + cards.push({ + id: cardId, + name, + outputText: text, + messageId: transcriptMessageId, + ...(isError !== undefined ? { isError } : {}), + preview, + }); + } + } + + const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; + const isStandaloneToolMessage = + isToolResultMessage(message) || + role === "tool" || + role === "function" || + typeof m.toolName === "string" || + typeof m.tool_name === "string"; + + if (isStandaloneToolMessage && cards.length === 0) { + const name = + (typeof m.toolName === "string" && m.toolName) || + (typeof m.tool_name === "string" && m.tool_name) || + "tool"; + const text = extractTextCached(message) ?? undefined; + cards.push({ + id: resolveToolCardId({}, m, 0, prefix), + name, + outputText: text, + messageId: transcriptMessageId, + ...(messageIsError !== undefined ? { isError: messageIsError } : {}), + preview: extractToolPreview(text, name), + }); + } + + return cards; +} + +const toolCardsByMessage = new WeakMap>(); + +export function extractToolCardsCached(message: unknown, prefix = "tool"): ToolCard[] { + if (!message || typeof message !== "object") { + return extractToolCards(message, prefix); + } + let byPrefix = toolCardsByMessage.get(message); + if (!byPrefix) { + byPrefix = new Map(); + toolCardsByMessage.set(message, byPrefix); + } + const cached = byPrefix.get(prefix); + if (cached) { + return cached; + } + const cards = extractToolCards(message, prefix); + byPrefix.set(prefix, cards); + return cards; +} diff --git a/ui/src/ui/tool-display.ts b/ui/src/lib/chat/tool-display.ts similarity index 50% rename from ui/src/ui/tool-display.ts rename to ui/src/lib/chat/tool-display.ts index dba2e7ff7a8f..a5f6bddea624 100644 --- a/ui/src/ui/tool-display.ts +++ b/ui/src/lib/chat/tool-display.ts @@ -1,15 +1,19 @@ // Control UI module implements tool display behavior. -import SHARED_TOOL_DISPLAY_JSON from "../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json" with { type: "json" }; +import SHARED_TOOL_DISPLAY_JSON from "../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json" with { type: "json" }; import { defaultTitle, formatToolDetailText, normalizeToolName, resolveToolVerbAndDetailForArgs, type ToolDisplaySpec as ToolDisplaySpecBase, -} from "../../../src/agents/tool-display-common.js"; -import type { ToolDetailMode } from "../../../src/agents/tool-display-exec.js"; -import type { IconName } from "./icons.ts"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; +} from "../../../../src/agents/tool-display-common.js"; +import type { ToolDetailMode } from "../../../../src/agents/tool-display-exec.js"; +import type { ControlUiEmbedSandboxMode } from "../../../../src/gateway/control-ui-contract.js"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; + +const A2UI_PATH = "/__openclaw__/a2ui"; +const CANVAS_HOST_PATH = "/__openclaw__/canvas"; +const CANVAS_CAPABILITY_PATH_PREFIX = "/__openclaw__/cap"; type ToolDisplaySpec = ToolDisplaySpecBase & { icon?: string; @@ -27,14 +31,17 @@ type SharedToolDisplayConfig = { export type ToolDisplay = { name: string; - icon: IconName; + icon: ChatToolIconName; title: string; label: string; verb?: string; detail?: string; }; -const EMOJI_ICON_MAP: Record = { +export type EmbedSandboxMode = ControlUiEmbedSandboxMode; +export type ChatToolIconName = string; + +const EMOJI_ICON_MAP: Record = { "🧩": "puzzle", "🛠️": "wrench", "🧰": "wrench", @@ -51,7 +58,7 @@ const EMOJI_ICON_MAP: Record = { "💬": "messageSquare", }; -function iconForEmoji(emoji?: string): IconName { +function iconForEmoji(emoji?: string): ChatToolIconName { if (!emoji) { return "puzzle"; } @@ -107,7 +114,7 @@ export function resolveToolDisplay(params: { const name = normalizeToolName(params.name); const key = normalizeLowercaseStringOrEmpty(name); const spec = TOOL_MAP[key]; - const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName; + const icon = spec?.icon ?? FALLBACK.icon ?? "puzzle"; const title = spec?.title ?? defaultTitle(name); const label = spec?.label ?? title; const toolDisplayParts = resolveToolVerbAndDetailForArgs({ @@ -140,3 +147,85 @@ export function resolveToolDisplay(params: { export function formatToolDetail(display: ToolDisplay): string | undefined { return formatToolDetailText(display.detail, { prefixWithWith: true }); } + +function isCanvasHttpPath(pathname: string): boolean { + return ( + pathname === CANVAS_HOST_PATH || + pathname.startsWith(`${CANVAS_HOST_PATH}/`) || + pathname === A2UI_PATH || + pathname.startsWith(`${A2UI_PATH}/`) + ); +} + +function isExternalHttpUrl(entry: URL): boolean { + return entry.protocol === "http:" || entry.protocol === "https:"; +} + +function sanitizeCanvasEntryUrl( + rawEntryUrl: string, + allowExternalEmbedUrls = false, +): string | undefined { + try { + const entry = new URL(rawEntryUrl, "http://localhost"); + if (entry.origin !== "http://localhost") { + if (!allowExternalEmbedUrls || !isExternalHttpUrl(entry)) { + return undefined; + } + return entry.toString(); + } + if (!isCanvasHttpPath(entry.pathname)) { + return undefined; + } + return `${entry.pathname}${entry.search}${entry.hash}`; + } catch { + return undefined; + } +} + +export function resolveCanvasIframeUrl( + entryUrl: string | undefined, + canvasPluginSurfaceUrl?: string | null, + allowExternalEmbedUrls = false, +): string | undefined { + const rawEntryUrl = entryUrl?.trim(); + if (!rawEntryUrl) { + return undefined; + } + const safeEntryUrl = sanitizeCanvasEntryUrl(rawEntryUrl, allowExternalEmbedUrls); + if (!safeEntryUrl) { + return undefined; + } + if (!canvasPluginSurfaceUrl?.trim()) { + return safeEntryUrl; + } + try { + const scopedHostUrl = new URL(canvasPluginSurfaceUrl); + const scopedPrefix = scopedHostUrl.pathname.replace(/\/+$/, ""); + if (!scopedPrefix.startsWith(CANVAS_CAPABILITY_PATH_PREFIX)) { + return safeEntryUrl; + } + const entry = new URL(safeEntryUrl, scopedHostUrl.origin); + if (!isCanvasHttpPath(entry.pathname)) { + return safeEntryUrl; + } + entry.protocol = scopedHostUrl.protocol; + entry.username = scopedHostUrl.username; + entry.password = scopedHostUrl.password; + entry.host = scopedHostUrl.host; + entry.pathname = `${scopedPrefix}${entry.pathname}`; + return entry.toString(); + } catch { + return safeEntryUrl; + } +} + +export function resolveEmbedSandbox(mode: EmbedSandboxMode | null | undefined): string { + switch (mode) { + case "strict": + return ""; + case "trusted": + return "allow-scripts allow-same-origin"; + default: + return "allow-scripts"; + } +} diff --git a/ui/src/ui/chat/clipboard.test.ts b/ui/src/lib/clipboard.test.ts similarity index 100% rename from ui/src/ui/chat/clipboard.test.ts rename to ui/src/lib/clipboard.test.ts diff --git a/ui/src/ui/chat/clipboard.ts b/ui/src/lib/clipboard.ts similarity index 92% rename from ui/src/ui/chat/clipboard.ts rename to ui/src/lib/clipboard.ts index a57705ab2caf..74bd4a20cdf3 100644 --- a/ui/src/ui/chat/clipboard.ts +++ b/ui/src/lib/clipboard.ts @@ -40,10 +40,7 @@ function copyWithExecCommand(text: string): boolean { if (previouslyFocused?.isConnected) { window.setTimeout(() => { const activeElement = document.activeElement; - if ( - previouslyFocused.isConnected && - (!activeElement || activeElement === document.body) - ) { + if (previouslyFocused.isConnected && (!activeElement || activeElement === document.body)) { previouslyFocused.focus(); } }, 0); diff --git a/ui/src/ui/controllers/config/form-utils.node.test.ts b/ui/src/lib/config-form-utils.node.test.ts similarity index 99% rename from ui/src/ui/controllers/config/form-utils.node.test.ts rename to ui/src/lib/config-form-utils.node.test.ts index b198ac9aa5f9..0945f64add9a 100644 --- a/ui/src/ui/controllers/config/form-utils.node.test.ts +++ b/ui/src/lib/config-form-utils.node.test.ts @@ -1,14 +1,14 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import type { JsonSchema } from "../../views/config-form.shared.ts"; -import { coerceFormValues } from "./form-coerce.ts"; +import type { JsonSchema } from "../components/config-form.shared.ts"; import { cloneConfigObject, removePathValue, sanitizeRedactedFormForSubmit, serializeConfigForm, setPathValue, -} from "./form-utils.ts"; +} from "./config-form-utils.ts"; +import { coerceFormValues } from "./config/index.ts"; /** * Minimal model provider schema matching the Zod-generated JSON Schema for diff --git a/ui/src/ui/controllers/config/form-utils.ts b/ui/src/lib/config-form-utils.ts similarity index 100% rename from ui/src/ui/controllers/config/form-utils.ts rename to ui/src/lib/config-form-utils.ts diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/lib/config/index.test.ts similarity index 89% rename from ui/src/ui/controllers/config.test.ts rename to ui/src/lib/config/index.test.ts index dbc585a6eeae..490e5c6cade3 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/lib/config/index.test.ts @@ -8,7 +8,6 @@ import { loadConfig, openConfigFile, resetConfigPendingChanges, - runUpdate, saveConfig, stageDefaultAgentConfigEntry, stageConfigPreset, @@ -16,7 +15,7 @@ import { updateConfigFormValue, updateConfigRawValue, type ConfigState, -} from "./config.ts"; +} from "./index.ts"; function createState(): ConfigState { return { @@ -44,10 +43,6 @@ function createState(): ConfigState { configValid: null, connected: false, lastError: null, - pendingUpdateExpectedVersion: null, - pendingUpdateHandoff: false, - updateStatusBanner: null, - updateRunning: false, }; } @@ -1092,116 +1087,3 @@ describe("saveConfig", () => { expect(params.sessionKey).toBe("agent:main:web:dm:test"); }); }); - -describe("runUpdate", () => { - it("sends update.run with session key", async () => { - const request = vi.fn().mockResolvedValue({}); - const state = createState(); - state.connected = true; - state.client = { request } as unknown as ConfigState["client"]; - state.applySessionKey = "agent:main:whatsapp:dm:+15555550123"; - - await runUpdate(state); - - expect(request).toHaveBeenCalledWith("update.run", { - sessionKey: "agent:main:whatsapp:dm:+15555550123", - }); - }); - - it("surfaces update errors returned in response payload", async () => { - const request = vi.fn().mockResolvedValue({ - ok: false, - result: { status: "error", reason: "network unavailable" }, - }); - const state = createState(); - state.connected = true; - state.client = { request } as unknown as ConfigState["client"]; - state.applySessionKey = "main"; - - await runUpdate(state); - - expect(state.updateStatusBanner).toEqual({ - tone: "danger", - text: "Update error: network unavailable. See the gateway logs for the exact failure and retry once the cause is fixed.", - }); - }); - - it("surfaces skipped updates with actionable guidance", async () => { - const request = vi.fn().mockResolvedValue({ - ok: false, - result: { status: "skipped", reason: "dirty" }, - }); - const state = createState(); - state.connected = true; - state.client = { request } as unknown as ConfigState["client"]; - - await runUpdate(state); - - expect(state.updateStatusBanner).toEqual({ - tone: "warn", - text: "Update skipped: dirty. Commit or stash changes, then retry.", - }); - }); - - it("surfaces managed-service handoff command when the gateway cannot start it", async () => { - const request = vi.fn().mockResolvedValue({ - ok: false, - result: { status: "skipped", reason: "managed-service-handoff-unavailable" }, - handoff: { - status: "unavailable", - command: "openclaw update --yes", - message: - "OpenClaw updates cannot safely run inside the live gateway process without a managed-service handoff.", - }, - }); - const state = createState(); - state.connected = true; - state.client = { request } as unknown as ConfigState["client"]; - - await runUpdate(state); - - expect(state.updateStatusBanner).toEqual({ - tone: "warn", - text: "Update skipped: managed-service-handoff-unavailable. Run `openclaw update --yes` from a shell outside the Gateway process.", - }); - }); - - it("stores the expected post-update version when update.run succeeds", async () => { - const request = vi.fn().mockResolvedValue({ - ok: true, - result: { - status: "ok", - after: { version: "2.0.0" }, - }, - }); - const state = createState(); - state.connected = true; - state.client = { request } as unknown as ConfigState["client"]; - - await runUpdate(state); - - expect(state.pendingUpdateExpectedVersion).toBe("2.0.0"); - expect(state.pendingUpdateHandoff).toBe(false); - expect(state.updateStatusBanner).toBeNull(); - }); - - it("tracks managed-service handoff updates for reconnect verification", async () => { - const request = vi.fn().mockResolvedValue({ - ok: true, - result: { - status: "skipped", - reason: "managed-service-handoff-started", - }, - handoff: { status: "started" }, - }); - const state = createState(); - state.connected = true; - state.client = { request } as unknown as ConfigState["client"]; - - await runUpdate(state); - - expect(state.pendingUpdateExpectedVersion).toBeNull(); - expect(state.pendingUpdateHandoff).toBe(true); - expect(state.updateStatusBanner).toBeNull(); - }); -}); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/lib/config/index.ts similarity index 53% rename from ui/src/ui/controllers/config.ts rename to ui/src/lib/config/index.ts index e8ca95bbfc3e..b3ed545c34b7 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/lib/config/index.ts @@ -1,16 +1,15 @@ -// Control UI controller manages config gateway state. +// Control UI runtime config capability and shared config-domain mutations. import { applyMergePatch } from "../../../../src/config/merge-patch.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts"; -import type { JsonSchema } from "../views/config-form.shared.ts"; -import { coerceFormValues } from "./config/form-coerce.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../../api/types.ts"; +import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts"; import { cloneConfigObject, removePathValue, sanitizeRedactedFormForSubmit, serializeConfigForm, setPathValue, -} from "./config/form-utils.ts"; +} from "../config-form-utils.ts"; export type ConfigState = { client: GatewayBrowserClient | null; @@ -23,7 +22,6 @@ export type ConfigState = { configIssues: unknown[]; configSaving: boolean; configApplying: boolean; - updateRunning: boolean; configSnapshot: ConfigSnapshot | null; configDraftBaseHash?: string | null; configSchema: unknown; @@ -37,52 +35,162 @@ export type ConfigState = { configSearchQuery: string; configActiveSection: string | null; configActiveSubsection: string | null; - pendingUpdateExpectedVersion: string | null; - pendingUpdateHandoff: boolean; - updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null; lastError: string | null; chatError?: string | null; }; const autoAllowlistedPluginIdsByState = new WeakMap>(); -const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started"; +const requestVersionsByState = new WeakMap(); + +type RuntimeConfigGatewaySnapshot = { + client: GatewayBrowserClient | null; + connected: boolean; + sessionKey: string; +}; + +type RuntimeConfigGateway = { + readonly snapshot: RuntimeConfigGatewaySnapshot; + subscribe: (listener: (snapshot: RuntimeConfigGatewaySnapshot) => void) => () => void; +}; + +export type RuntimeConfigCapability = { + readonly state: ConfigState; + ensureLoaded: () => Promise; + ensureSchemaLoaded: () => Promise; + refresh: (options?: LoadConfigOptions) => Promise; + refreshSchema: () => Promise; + patchForm: (path: Array, value: unknown) => void; + removeFormValue: (path: Array) => void; + setRaw: (value: string) => void; + resetDraft: () => void; + stagePreset: (patch: Record) => void; + save: () => Promise; + apply: () => Promise; + openFile: () => Promise; + setMcpServerEnabled: (name: string, enabled: boolean) => void; + ensureAgentEntry: (agentId: string) => number; + stageDefaultAgent: (agentId: string) => boolean; + patch: (options: ConfigPatchOptions) => Promise; + lookupSchemaPath: (path: string) => Promise; + subscribe: (listener: (state: ConfigState) => void) => () => void; + dispose: () => void; +}; export type LoadConfigOptions = { discardPendingChanges?: boolean; }; +export type ConfigPatchOptions = { + raw: string | Record; + note: string; +}; + +type ConfigGatewayClient = { + request(method: string, params?: unknown): Promise; +}; + +type ConfigGatewayState = Pick< + ConfigState, + "connected" | "applySessionKey" | "configSnapshot" | "lastError" | "chatError" +> & { + client: ConfigGatewayClient | null; +}; + +function createInitialConfigState(snapshot?: Partial): ConfigState { + return { + client: snapshot?.client ?? null, + connected: snapshot?.connected ?? false, + applySessionKey: snapshot?.sessionKey ?? "main", + configLoading: false, + configRaw: "{\n}\n", + configRawOriginal: "", + configValid: null, + configIssues: [], + configSaving: false, + configApplying: false, + configSnapshot: null, + configDraftBaseHash: null, + configSchema: null, + configSchemaVersion: null, + configSchemaLoading: false, + configUiHints: {}, + configForm: null, + configFormOriginal: null, + configFormDirty: false, + configFormMode: "form", + configSearchQuery: "", + configActiveSection: null, + configActiveSubsection: null, + lastError: null, + }; +} + +function nextRequestVersion(state: ConfigState, key: "config" | "schema"): number { + const current = requestVersionsByState.get(state) ?? { config: 0, schema: 0 }; + const next = { ...current, [key]: current[key] + 1 }; + requestVersionsByState.set(state, next); + return next[key]; +} + +function isCurrentRequest( + state: ConfigState, + key: "config" | "schema", + version: number, + client: GatewayBrowserClient, +): boolean { + return state.client === client && requestVersionsByState.get(state)?.[key] === version; +} + export async function loadConfig(state: ConfigState, options: LoadConfigOptions = {}) { - if (!state.client || !state.connected) { + const client = state.client; + if (!client || !state.connected) { return; } + const version = nextRequestVersion(state, "config"); state.configLoading = true; state.lastError = null; state.chatError = null; try { - const res = await state.client.request("config.get", {}); + const res = await client.request("config.get", {}); + if (!isCurrentRequest(state, "config", version, client)) { + return; + } applyConfigSnapshot(state, res, options); } catch (err) { - state.lastError = String(err); + if (isCurrentRequest(state, "config", version, client)) { + state.lastError = String(err); + } } finally { - state.configLoading = false; + if (isCurrentRequest(state, "config", version, client)) { + state.configLoading = false; + } } } export async function loadConfigSchema(state: ConfigState) { - if (!state.client || !state.connected) { + const client = state.client; + if (!client || !state.connected) { return; } if (state.configSchemaLoading) { return; } + const version = nextRequestVersion(state, "schema"); state.configSchemaLoading = true; try { - const res = await state.client.request("config.schema", {}); + const res = await client.request("config.schema", {}); + if (!isCurrentRequest(state, "schema", version, client)) { + return; + } applyConfigSchema(state, res); } catch (err) { - state.lastError = String(err); + if (isCurrentRequest(state, "schema", version, client)) { + state.lastError = String(err); + } } finally { - state.configSchemaLoading = false; + if (isCurrentRequest(state, "schema", version, client)) { + state.configSchemaLoading = false; + } } } @@ -109,6 +217,12 @@ function resolveEditableSnapshotConfig( ); } +export function currentConfigObject( + state: Pick, +): Record | null { + return state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot); +} + export function applyConfigSnapshot( state: ConfigState, snapshot: ConfigSnapshot, @@ -158,6 +272,145 @@ function asJsonSchema(value: unknown): JsonSchema | null { return value as JsonSchema; } +function coerceNumberString(value: string, integer: boolean): number | undefined | string { + const trimmed = value.trim(); + if (trimmed === "") { + return undefined; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + return value; + } + if (integer && !Number.isInteger(parsed)) { + return value; + } + return parsed; +} + +function coerceBooleanString(value: string): boolean | string { + const trimmed = value.trim(); + if (trimmed === "true") { + return true; + } + if (trimmed === "false") { + return false; + } + return value; +} + +export function coerceFormValues(value: unknown, schema: JsonSchema): unknown { + if (value === null || value === undefined) { + return value; + } + + if (schema.allOf && schema.allOf.length > 0) { + let next: unknown = value; + for (const segment of schema.allOf) { + next = coerceFormValues(next, segment); + } + return next; + } + + const type = schemaType(schema); + if (schema.anyOf || schema.oneOf) { + const variants = (schema.anyOf ?? schema.oneOf ?? []).filter( + (variant) => + !( + variant.type === "null" || + (Array.isArray(variant.type) && variant.type.includes("null")) + ), + ); + + if (variants.length === 1) { + return coerceFormValues(value, variants[0]); + } + if (typeof value === "string") { + for (const variant of variants) { + const variantType = schemaType(variant); + if (variantType === "number" || variantType === "integer") { + const coerced = coerceNumberString(value, variantType === "integer"); + if (coerced === undefined || typeof coerced === "number") { + return coerced; + } + } + if (variantType === "boolean") { + const coerced = coerceBooleanString(value); + if (typeof coerced === "boolean") { + return coerced; + } + } + } + } + for (const variant of variants) { + const variantType = schemaType(variant); + if (variantType === "object" && typeof value === "object" && !Array.isArray(value)) { + return coerceFormValues(value, variant); + } + if (variantType === "array" && Array.isArray(value)) { + return coerceFormValues(value, variant); + } + } + return value; + } + + if (type === "number" || type === "integer") { + if (typeof value === "string") { + const coerced = coerceNumberString(value, type === "integer"); + if (coerced === undefined || typeof coerced === "number") { + return coerced; + } + } + return value; + } + if (type === "boolean") { + if (typeof value === "string") { + const coerced = coerceBooleanString(value); + if (typeof coerced === "boolean") { + return coerced; + } + } + return value; + } + if (type === "string") { + return typeof value === "string" && value.length === 0 && schema.minLength ? undefined : value; + } + if (type === "object") { + if (typeof value !== "object" || Array.isArray(value)) { + return value; + } + const props = schema.properties ?? {}; + const additional = + schema.additionalProperties && typeof schema.additionalProperties === "object" + ? schema.additionalProperties + : null; + const result: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + const propSchema = props[key] ?? additional; + const coerced = propSchema ? coerceFormValues(val, propSchema) : val; + if (coerced !== undefined) { + result[key] = coerced; + } + } + return result; + } + if (type === "array") { + if (!Array.isArray(value)) { + return value; + } + const items = schema.items; + if (Array.isArray(items)) { + return value.map((item, index) => { + const itemSchema = index < items.length ? items[index] : undefined; + return itemSchema ? coerceFormValues(item, itemSchema) : item; + }); + } + return items + ? value.map((item) => coerceFormValues(item, items)).filter((item) => item !== undefined) + : value; + } + return value; +} + /** * Serialize the form state for submission to `config.set` / `config.apply`. * @@ -185,51 +438,6 @@ function serializeFormForSubmit(state: ConfigState): string { type ConfigSubmitMethod = "config.set" | "config.apply"; type ConfigSubmitBusyKey = "configSaving" | "configApplying"; -function resolveUpdateStatusBanner(params: { - status?: string; - reason?: string; - handoff?: { command?: string; message?: string }; -}): { - tone: "danger" | "warn" | "info"; - text: string; -} { - const status = (params.status ?? "error").trim() || "error"; - const reason = (params.reason ?? "unexpected-error").trim() || "unexpected-error"; - const tone = status === "skipped" ? "warn" : "danger"; - const handoffCommand = params.handoff?.command?.trim(); - const handoffMessage = params.handoff?.message?.trim(); - const handoffUnavailableGuidance = handoffCommand - ? `Run \`${handoffCommand}\` from a shell outside the Gateway process.` - : (handoffMessage ?? - "OpenClaw could not find a safe supervisor handoff. Run `openclaw update` from a shell outside the Gateway process."); - const guidance = - { - dirty: "Commit or stash changes, then retry.", - "no-upstream": "Set an upstream branch, then retry.", - "not-git-install": - "Not a git checkout. Run `openclaw update` from the CLI for a global reinstall.", - "not-openclaw-root": - "Run the update from an OpenClaw checkout or use the CLI global reinstall path.", - "deps-install-failed": "Dependency install failed. Fix the install error and retry.", - "build-failed": "Build failed. Fix the build error and retry.", - "ui-build-failed": "The control UI rebuild failed. Fix the UI build error and retry.", - "global-install-failed": - "The global package install did not verify on disk. Retry or reinstall from the CLI.", - "restart-disabled": - "The update was not applied because gateway restarts are disabled. Enable restarts in config, then retry — or run `openclaw update` from the CLI.", - "restart-unavailable": - "This global install cannot be safely replaced while restarts are disabled and no supervisor is present.", - "managed-service-handoff-unavailable": handoffUnavailableGuidance, - "restart-unhealthy": - "The replacement process never became healthy. The previous process stayed up so you can recover.", - "doctor-failed": "Doctor repair failed. Run `openclaw doctor --non-interactive` and retry.", - }[reason] ?? "See the gateway logs for the exact failure and retry once the cause is fixed."; - return { - tone, - text: `Update ${status}: ${reason}. ${guidance}`, - }; -} - async function submitConfigChange( state: ConfigState, method: ConfigSubmitMethod, @@ -284,54 +492,46 @@ export async function applyConfig(state: ConfigState): Promise { }); } -export async function runUpdate(state: ConfigState) { - if (!state.client || !state.connected) { - return; +export async function patchConfig( + state: ConfigGatewayState, + options: ConfigPatchOptions, +): Promise { + const client = state.client; + if (!client || !state.connected) { + return false; + } + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.lastError = "Config hash missing; refresh and retry."; + return false; } - state.updateRunning = true; state.lastError = null; state.chatError = null; - state.updateStatusBanner = null; try { - const res = await state.client.request<{ - ok?: boolean; - result?: { status?: string; reason?: string; after?: { version?: string | null } }; - handoff?: { status?: string; command?: string; message?: string }; - }>("update.run", { + await client.request("config.patch", { + baseHash, + raw: typeof options.raw === "string" ? options.raw : JSON.stringify(options.raw), sessionKey: state.applySessionKey, + note: options.note, }); - const status = res.result?.status ?? (res.ok === true ? "ok" : "error"); - const handoffStarted = - res.ok === true && - status === "skipped" && - res.result?.reason === UPDATE_HANDOFF_STARTED_REASON && - res.handoff?.status === "started"; - if (handoffStarted) { - state.pendingUpdateExpectedVersion = res.result?.after?.version ?? null; - state.pendingUpdateHandoff = true; - return; - } - if (status === "ok" && res.ok === true) { - state.pendingUpdateExpectedVersion = res.result?.after?.version ?? null; - state.pendingUpdateHandoff = false; - return; - } - state.pendingUpdateExpectedVersion = null; - state.pendingUpdateHandoff = false; - state.updateStatusBanner = resolveUpdateStatusBanner({ - status, - reason: res.result?.reason, - handoff: res.handoff, - }); + return true; } catch (err) { state.lastError = String(err); - state.pendingUpdateExpectedVersion = null; - state.pendingUpdateHandoff = false; - } finally { - state.updateRunning = false; + return false; } } +export async function lookupConfigSchemaPath( + state: { client: ConfigGatewayClient | null; connected: boolean }, + path: string, +): Promise { + const client = state.client; + if (!client || !state.connected) { + return null; + } + return client.request("config.schema.lookup", { path }); +} + function mutateConfigForm(state: ConfigState, mutate: (draft: Record) => void) { const base = cloneConfigObject( state.configForm ?? resolveEditableSnapshotConfig(state.configSnapshot) ?? {}, @@ -585,3 +785,121 @@ export async function openConfigFile(state: ConfigState): Promise { state.lastError = String(err); } } + +export function createRuntimeConfigCapability( + gateway: RuntimeConfigGateway, +): RuntimeConfigCapability { + const state = createInitialConfigState(gateway.snapshot); + const listeners = new Set<(state: ConfigState) => void>(); + let configLoad: Promise | null = null; + let schemaLoad: Promise | null = null; + let disposed = false; + + const publish = () => { + if (disposed) { + return; + } + for (const listener of listeners) { + listener(state); + } + }; + const run = async (task: () => Promise): Promise => { + try { + return await task(); + } finally { + publish(); + } + }; + const mutate = (task: () => void) => { + task(); + publish(); + }; + const trackLoad = (key: "config" | "schema", promise: Promise): Promise => { + const next = promise.finally(() => { + if (key === "config" && configLoad === next) { + configLoad = null; + } else if (key === "schema" && schemaLoad === next) { + schemaLoad = null; + } + }); + if (key === "config") { + configLoad = next; + } else { + schemaLoad = next; + } + return next; + }; + const loadOnce = (key: "config" | "schema", task: () => Promise): Promise => { + const current = key === "config" ? configLoad : schemaLoad; + return current ?? trackLoad(key, run(task)); + }; + const ensureLoaded = () => + state.configSnapshot ? Promise.resolve() : loadOnce("config", () => loadConfig(state)); + const ensureSchemaLoaded = () => + state.configSchema ? Promise.resolve() : loadOnce("schema", () => loadConfigSchema(state)); + const stopGateway = gateway.subscribe((snapshot) => { + const clientChanged = state.client !== snapshot.client; + state.client = snapshot.client; + state.connected = snapshot.connected; + state.applySessionKey = snapshot.sessionKey; + if (clientChanged) { + configLoad = null; + schemaLoad = null; + requestVersionsByState.delete(state); + state.configLoading = false; + state.configSchemaLoading = false; + } + publish(); + }); + + return { + get state() { + return state; + }, + ensureLoaded, + ensureSchemaLoaded, + refresh: (options) => + trackLoad( + "config", + run(() => loadConfig(state, options)), + ), + refreshSchema: () => + trackLoad( + "schema", + run(() => loadConfigSchema(state)), + ), + patchForm: (path, value) => mutate(() => updateConfigFormValue(state, path, value)), + removeFormValue: (path) => mutate(() => removeConfigFormValue(state, path)), + setRaw: (value) => mutate(() => updateConfigRawValue(state, value)), + resetDraft: () => mutate(() => resetConfigPendingChanges(state)), + stagePreset: (patch) => mutate(() => stageConfigPreset(state, patch)), + save: () => run(() => saveConfig(state)), + apply: () => run(() => applyConfig(state)), + openFile: () => run(() => openConfigFile(state)), + setMcpServerEnabled: (name, enabled) => + mutate(() => updateMcpServerEnabled(state, name, enabled)), + ensureAgentEntry: (agentId) => { + const index = ensureAgentConfigEntry(state, agentId); + publish(); + return index; + }, + stageDefaultAgent: (agentId) => { + const changed = stageDefaultAgentConfigEntry(state, agentId); + publish(); + return changed; + }, + patch: (options) => run(() => patchConfig(state, options)), + lookupSchemaPath: (path) => run(() => lookupConfigSchemaPath(state, path)), + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + disposed = true; + stopGateway(); + listeners.clear(); + requestVersionsByState.delete(state); + autoAllowlistedPluginIdsByState.delete(state); + }, + }; +} diff --git a/ui/src/ui/cron-status.ts b/ui/src/lib/cron-status.ts similarity index 91% rename from ui/src/ui/cron-status.ts rename to ui/src/lib/cron-status.ts index 616b83cb9060..faa83756860d 100644 --- a/ui/src/ui/cron-status.ts +++ b/ui/src/lib/cron-status.ts @@ -1,5 +1,5 @@ // Control UI module implements cron status behavior. -import type { CronJob, CronRunStatus } from "./types.ts"; +import type { CronJob, CronRunStatus } from "../api/types.ts"; export type CronJobLastRunStatus = CronRunStatus | "unknown"; diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/lib/cron/index.test.ts similarity index 99% rename from ui/src/ui/controllers/cron.test.ts rename to ui/src/lib/cron/index.test.ts index fd96cf5c44ca..c7a3f33ab926 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/lib/cron/index.test.ts @@ -1,6 +1,6 @@ // Control UI tests cover cron behavior. import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; +import { DEFAULT_CRON_FORM } from "../../lib/cron/index.ts"; import { addCronJob, cancelCronEdit, @@ -15,16 +15,13 @@ import { updateCronJobsFilter, validateCronForm, type CronState, -} from "./cron.ts"; +} from "../../lib/cron/index.ts"; function createState(overrides: Partial = {}): CronState { return { client: null, connected: true, cronLoading: false, - cronQuickCreateOpen: false, - cronQuickCreateStep: "what", - cronQuickCreateDraft: null, cronJobsLoadingMore: false, cronJobsReloadPending: false, cronJobsReloadPendingTableFilters: false, diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/lib/cron/index.ts similarity index 83% rename from ui/src/ui/controllers/cron.ts rename to ui/src/lib/cron/index.ts index 3f6c7bf93cf8..8e0a6fca3b82 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/lib/cron/index.ts @@ -1,11 +1,4 @@ -// Control UI controller manages cron gateway state. -import { t } from "../../i18n/index.ts"; -import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; -import { getCronJobPayload, hasCronJobPayload } from "../cron-payload.ts"; -import { resolveCronJobLastRunStatus } from "../cron-status.ts"; -import { toNumber } from "../format.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { normalizeLowercaseStringOrEmpty, sortUniqueStrings } from "../string-coerce.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { CronJob, CronDeliveryStatus, @@ -20,13 +13,129 @@ import type { CronRunsStatusValue, CronSortDir, CronStatus, -} from "../types.ts"; -import { CRON_CHANNEL_LAST } from "../ui-types.ts"; -import type { CronFormState } from "../ui-types.ts"; + CronPayload, +} from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; +import { resolveCronJobLastRunStatus } from "../cron-status.ts"; +import { toNumber } from "../format.ts"; import { formatMissingOperatorReadScopeMessage, isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; +} from "../gateway-errors.ts"; +import { normalizeLowercaseStringOrEmpty, sortUniqueStrings } from "../string-coerce.ts"; + +export const CRON_CHANNEL_LAST = "last"; + +export type CronFormState = { + name: string; + description: string; + agentId: string; + sessionKey: string; + clearAgent: boolean; + enabled: boolean; + deleteAfterRun: boolean; + // on-exit jobs are read-only because the form cannot edit a watched command. + // Preserve their schedule verbatim on save instead of rebuilding it. + scheduleKind: "at" | "every" | "cron" | "on-exit"; + scheduleAt: string; + everyAmount: string; + everyUnit: "minutes" | "hours" | "days"; + cronExpr: string; + cronTz: string; + scheduleExact: boolean; + staggerAmount: string; + staggerUnit: "seconds" | "minutes"; + sessionTarget: "main" | "isolated" | "current" | `session:${string}`; + wakeMode: "next-heartbeat" | "now"; + payloadKind: "systemEvent" | "agentTurn"; + payloadLocked: boolean; + payloadText: string; + payloadModel: string; + payloadThinking: string; + payloadLightContext: boolean; + deliveryMode: "none" | "announce" | "webhook"; + deliveryChannel: string; + deliveryTo: string; + deliveryAccountId: string; + deliveryBestEffort: boolean; + failureAlertMode: "inherit" | "disabled" | "custom"; + failureAlertAfter: string; + failureAlertCooldownSeconds: string; + failureAlertChannel: string; + failureAlertTo: string; + failureAlertDeliveryMode: "announce" | "webhook"; + failureAlertAccountId: string; + timeoutSeconds: string; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object"); +} + +export function isCronPayload(value: unknown): value is CronPayload { + if (!isRecord(value)) { + return false; + } + if (value.kind === "systemEvent") { + return typeof value.text === "string"; + } + if (value.kind === "agentTurn") { + return typeof value.message === "string"; + } + if (value.kind === "command") { + return Array.isArray(value.argv) && value.argv.every((arg) => typeof arg === "string"); + } + return false; +} + +export function getCronJobPayload(job: CronJob): CronPayload | null { + const payload = (job as { payload?: unknown }).payload; + return isCronPayload(payload) ? payload : null; +} + +export function hasCronJobPayload(job: CronJob): boolean { + return getCronJobPayload(job) !== null; +} + +export const DEFAULT_CRON_FORM: CronFormState = { + name: "", + description: "", + agentId: "", + sessionKey: "", + clearAgent: false, + enabled: true, + deleteAfterRun: true, + scheduleKind: "every", + scheduleAt: "", + everyAmount: "30", + everyUnit: "minutes", + cronExpr: "0 7 * * *", + cronTz: "", + scheduleExact: false, + staggerAmount: "", + staggerUnit: "seconds", + sessionTarget: "isolated", + wakeMode: "now", + payloadKind: "agentTurn", + payloadLocked: false, + payloadText: "", + payloadModel: "", + payloadThinking: "", + payloadLightContext: false, + deliveryMode: "announce", + deliveryChannel: "last", + deliveryTo: "", + deliveryAccountId: "", + deliveryBestEffort: false, + failureAlertMode: "inherit", + failureAlertAfter: "2", + failureAlertCooldownSeconds: "3600", + failureAlertChannel: "last", + failureAlertTo: "", + failureAlertDeliveryMode: "announce", + failureAlertAccountId: "", + timeoutSeconds: "", +}; export type CronFieldKey = | "name" @@ -52,9 +161,6 @@ export type CronState = { client: GatewayBrowserClient | null; connected: boolean; cronLoading: boolean; - cronQuickCreateOpen: boolean; - cronQuickCreateStep: import("../views/cron-quick-create.ts").CronQuickCreateStep; - cronQuickCreateDraft: import("../views/cron-quick-create.ts").CronQuickCreateDraft | null; cronJobsLoadingMore: boolean; cronJobsReloadPending: boolean; cronJobsReloadPendingTableFilters: boolean; @@ -97,6 +203,50 @@ export type CronModelSuggestionsState = { cronModelSuggestions: string[]; }; +export function createInitialCronState( + snapshot: Partial> = {}, +): CronState { + return { + client: snapshot.client ?? null, + connected: snapshot.connected ?? false, + cronLoading: false, + cronJobsLoadingMore: false, + cronJobsReloadPending: false, + cronJobsReloadPendingTableFilters: false, + cronJobs: [], + cronJobsTotal: 0, + cronJobsHasMore: false, + cronJobsNextOffset: null, + cronJobsLimit: 50, + cronJobsQuery: "", + cronJobsEnabledFilter: "all", + cronJobsScheduleKindFilter: "all", + cronJobsLastStatusFilter: "all", + cronJobsSortBy: "nextRunAtMs", + cronJobsSortDir: "asc", + cronStatus: null, + cronError: null, + cronForm: { ...DEFAULT_CRON_FORM }, + cronFormCollapsed: true, + cronFieldErrors: {}, + cronEditingJobId: null, + cronRunsJobId: null, + cronRunsLoadingMore: false, + cronRuns: [], + cronRunsTotal: 0, + cronRunsHasMore: false, + cronRunsNextOffset: null, + cronRunsLimit: 50, + cronRunsScope: "all", + cronRunsStatuses: [], + cronRunsDeliveryStatuses: [], + cronRunsStatusFilter: "all", + cronRunsQuery: "", + cronRunsSortDir: "desc", + cronBusy: false, + }; +} + function supportsAnnounceDelivery( form: Pick, ) { @@ -234,6 +384,75 @@ export async function loadCronModelSuggestions(state: CronModelSuggestionsState) } } +function addModelId(target: Set, value: unknown) { + if (typeof value !== "string") { + return; + } + const trimmed = value.trim(); + if (trimmed) { + target.add(trimmed); + } +} + +function addModelConfigIds(target: Set, modelConfig: unknown) { + if (!modelConfig) { + return; + } + if (typeof modelConfig === "string") { + addModelId(target, modelConfig); + return; + } + if (typeof modelConfig !== "object") { + return; + } + const record = modelConfig as Record; + addModelId(target, record.primary); + addModelId(target, record.model); + addModelId(target, record.id); + addModelId(target, record.value); + const fallbacks = Array.isArray(record.fallbacks) + ? record.fallbacks + : Array.isArray(record.fallback) + ? record.fallback + : []; + for (const fallback of fallbacks) { + addModelId(target, fallback); + } +} + +export function resolveConfiguredCronModelSuggestions( + configForm: Record | null | undefined, +): string[] { + if (!configForm || typeof configForm !== "object") { + return []; + } + const agents = configForm.agents; + if (!agents || typeof agents !== "object") { + return []; + } + const out = new Set(); + const defaults = (agents as { defaults?: unknown }).defaults; + if (defaults && typeof defaults === "object") { + const defaultsRecord = defaults as Record; + addModelConfigIds(out, defaultsRecord.model); + const defaultsModels = defaultsRecord.models; + if (defaultsModels && typeof defaultsModels === "object") { + for (const modelId of Object.keys(defaultsModels as Record)) { + addModelId(out, modelId); + } + } + } + const list = (agents as { list?: unknown }).list; + if (list && typeof list === "object") { + for (const entry of Object.values(list as Record)) { + if (entry && typeof entry === "object") { + addModelConfigIds(out, (entry as Record).model); + } + } + } + return sortUniqueStrings([...out]); +} + async function withCronBusy( state: CronState, run: (client: GatewayBrowserClient) => Promise, diff --git a/ui/src/ui/external-link.test.ts b/ui/src/lib/external-link.test.ts similarity index 100% rename from ui/src/ui/external-link.test.ts rename to ui/src/lib/external-link.test.ts diff --git a/ui/src/ui/external-link.ts b/ui/src/lib/external-link.ts similarity index 100% rename from ui/src/ui/external-link.ts rename to ui/src/lib/external-link.ts diff --git a/ui/src/ui/format.test.ts b/ui/src/lib/format.test.ts similarity index 83% rename from ui/src/ui/format.test.ts rename to ui/src/lib/format.test.ts index 3f4cd0a2544a..faa9369463a0 100644 --- a/ui/src/ui/format.test.ts +++ b/ui/src/lib/format.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { formatDateTimeMs, formatDateMs, + formatCompactTokenCount, formatMs, formatRelativeTimestamp, formatTimeMs, @@ -219,6 +220,46 @@ describe("parseSessionKeyParts", () => { }); }); +describe("formatCompactTokenCount", () => { + it("formats values under 1,000 as-is", () => { + expect(formatCompactTokenCount(0)).toBe("0"); + expect(formatCompactTokenCount(999)).toBe("999"); + }); + + it("formats thousands with one decimal, trimming a trailing .0", () => { + expect(formatCompactTokenCount(1_000)).toBe("1k"); + expect(formatCompactTokenCount(214_500)).toBe("214.5k"); + expect(formatCompactTokenCount(99_950)).toBe("100k"); + }); + + it("formats millions with one decimal, trimming a trailing .0", () => { + expect(formatCompactTokenCount(1_000_000)).toBe("1M"); + expect(formatCompactTokenCount(1_500_000)).toBe("1.5M"); + }); + + it("rolls values that round up to 1000.0k into the M branch", () => { + expect(formatCompactTokenCount(999_999)).toBe("1M"); + expect(formatCompactTokenCount(999_950)).toBe("1M"); + expect(formatCompactTokenCount(999_500)).toBe("999.5k"); + }); + + it("does not roll over values just below the rounding boundary", () => { + expect(formatCompactTokenCount(999_949)).toBe("999.9k"); + expect(formatCompactTokenCount(999_499)).toBe("999.5k"); + }); + + it("supports uppercase thousands labels for Usage surfaces", () => { + expect(formatCompactTokenCount(12_500, { thousandsSuffix: "K" })).toBe("12.5K"); + }); + + it("can preserve trailing decimals for Usage surfaces", () => { + expect(formatCompactTokenCount(1_000, { thousandsSuffix: "K", trimTrailingZero: false })).toBe( + "1.0K", + ); + expect(formatCompactTokenCount(1_000_000, { trimTrailingZero: false })).toBe("1.0M"); + }); +}); + describe("formatTokens", () => { it("rolls a value that rounds up to 1000k over into the M branch", () => { expect(formatTokens(999_500)).toBe("1.0M"); diff --git a/ui/src/ui/format.ts b/ui/src/lib/format.ts similarity index 86% rename from ui/src/ui/format.ts rename to ui/src/lib/format.ts index 86e8a9c10a6d..daea567db95c 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/lib/format.ts @@ -5,7 +5,7 @@ import { formatRelativeTimestamp } from "../../../src/infra/format-time/format-r import { t } from "../i18n/index.ts"; export { formatRelativeTimestamp, formatDurationHuman }; -export { stripThinkingTags } from "./strip-thinking-tags.ts"; +export { stripThinkingTags } from "../lib/strip-thinking-tags.ts"; export function formatUnknownText( value: unknown, @@ -175,6 +175,27 @@ export function formatTokens(tokens: number | null | undefined, fallback = "0"): return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`; } +export function formatCompactTokenCount( + tokens: number, + options: { thousandsSuffix?: string; millionsSuffix?: string; trimTrailingZero?: boolean } = {}, +): string { + const thousandsSuffix = options.thousandsSuffix ?? "k"; + const millionsSuffix = options.millionsSuffix ?? "M"; + const trimTrailingZero = options.trimTrailingZero ?? true; + const trim = (value: string) => (trimTrailingZero ? value.replace(/\.0$/, "") : value); + if (tokens >= 1_000_000) { + return `${trim((tokens / 1_000_000).toFixed(1))}${millionsSuffix}`; + } + if (tokens >= 1_000) { + const thousands = (tokens / 1_000).toFixed(1); + if (Number(thousands) >= 1_000) { + return `${trim((tokens / 1_000_000).toFixed(1))}${millionsSuffix}`; + } + return `${trim(thousands)}${thousandsSuffix}`; + } + return String(tokens); +} + export function parseSessionKeyParts( key: string, ): { agentId: string; channel: string; accountId: string } | null { diff --git a/ui/src/lib/gateway-diagnostics.ts b/ui/src/lib/gateway-diagnostics.ts new file mode 100644 index 000000000000..dfbbc38b1307 --- /dev/null +++ b/ui/src/lib/gateway-diagnostics.ts @@ -0,0 +1,27 @@ +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { HealthSnapshot, StatusSummary } from "../api/types.ts"; + +export type GatewayDiagnosticsSnapshot = { + status: StatusSummary; + health: HealthSnapshot; + models: unknown[]; + heartbeat: unknown; +}; + +export async function loadGatewayDiagnostics( + client: GatewayBrowserClient, +): Promise { + const [status, health, models, heartbeat] = await Promise.all([ + client.request("status", {}), + client.request("health", {}), + client.request("models.list", {}), + client.request("last-heartbeat", {}), + ]); + const modelPayload = models as { models?: unknown[] } | undefined; + return { + status: status as StatusSummary, + health: health as HealthSnapshot, + models: Array.isArray(modelPayload?.models) ? modelPayload.models : [], + heartbeat, + }; +} diff --git a/ui/src/lib/gateway-errors.ts b/ui/src/lib/gateway-errors.ts new file mode 100644 index 000000000000..e94e0a6f75d9 --- /dev/null +++ b/ui/src/lib/gateway-errors.ts @@ -0,0 +1,20 @@ +// Control UI shared Gateway error helpers. +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { GatewayRequestError, resolveGatewayErrorDetailCode } from "../api/gateway.ts"; + +export function isMissingOperatorReadScopeError(err: unknown): boolean { + if (!(err instanceof GatewayRequestError)) { + return false; + } + const detailCode = resolveGatewayErrorDetailCode(err); + // AUTH_UNAUTHORIZED is the current server signal for scope failures in RPC responses. + // The message-based branch catches responses that do not include a structured detail code yet. + return ( + detailCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED || + err.message.includes("missing scope: operator.read") + ); +} + +export function formatMissingOperatorReadScopeMessage(feature: string): string { + return `This connection is missing operator.read, so ${feature} cannot be loaded yet.`; +} diff --git a/ui/src/ui/gateway-methods.ts b/ui/src/lib/gateway-methods.ts similarity index 57% rename from ui/src/ui/gateway-methods.ts rename to ui/src/lib/gateway-methods.ts index 3ae5ce0e5762..ae076192d251 100644 --- a/ui/src/ui/gateway-methods.ts +++ b/ui/src/lib/gateway-methods.ts @@ -1,8 +1,9 @@ -// Shared Gateway hello method lookup for feature-gated UI calls. -import type { GatewayHelloOk } from "./gateway.ts"; - export function isGatewayMethodAdvertised( - host: { hello?: GatewayHelloOk | null }, + host: { + hello?: { + features?: { methods?: string[] } | null; + } | null; + }, method: string, ): boolean | null { const methods = host.hello?.features?.methods; diff --git a/ui/src/lib/model-auth.ts b/ui/src/lib/model-auth.ts new file mode 100644 index 000000000000..1212a5584fa2 --- /dev/null +++ b/ui/src/lib/model-auth.ts @@ -0,0 +1,79 @@ +// Control UI module implements model auth behavior. +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { ModelAuthStatusProvider, ModelAuthStatusResult } from "../api/types.ts"; + +const EMPTY_AUTH_STATUS: ModelAuthStatusResult = { ts: 0, providers: [] }; + +export type ModelAuthStatusState = { + client: GatewayBrowserClient | null; + connected: boolean; + modelAuthStatusLoading: boolean; + modelAuthStatusResult: ModelAuthStatusResult | null; + modelAuthStatusError: string | null; +}; + +/** + * True when a provider's auth should be actively monitored on the dashboard. + * + * Includes: + * - Providers with at least one OAuth or bearer-token profile (refreshable + * credentials that can expire and need rotation) + * - Providers with status="missing" (configured-but-not-logged-in — the + * server synthesizes these so the UI can prompt for login) + * + * Excludes API-key-only providers — their credentials don't expire on a + * schedule the dashboard can meaningfully monitor. + * + * Single source of truth for both the Overview card and the attention-items + * panel. Keep the two in sync by always routing through this helper. + */ +export function isMonitoredAuthProvider(p: ModelAuthStatusProvider): boolean { + if (p.status === "missing") { + return true; + } + if (!Array.isArray(p.profiles)) { + return false; + } + return p.profiles.some((prof) => prof.type === "oauth" || prof.type === "token"); +} + +export async function loadModelAuthStatus( + client: GatewayBrowserClient, + opts?: { refresh?: boolean }, +): Promise { + const params = opts?.refresh ? { refresh: true } : {}; + return ( + (await client.request("models.authStatus", params)) ?? EMPTY_AUTH_STATUS + ); +} + +export async function loadModelAuthStatusState( + state: ModelAuthStatusState, + opts?: { refresh?: boolean }, +): Promise { + const client = state.client; + if (!client || !state.connected) { + state.modelAuthStatusLoading = false; + return; + } + if (state.modelAuthStatusLoading) { + return; + } + state.modelAuthStatusLoading = true; + state.modelAuthStatusError = null; + try { + const result = await loadModelAuthStatus(client, opts); + if (state.client !== client || !state.connected) { + return; + } + state.modelAuthStatusResult = result; + } catch (err) { + if (state.client !== client || !state.connected) { + return; + } + state.modelAuthStatusError = err instanceof Error ? err.message : String(err); + state.modelAuthStatusResult = EMPTY_AUTH_STATUS; + } finally { + state.modelAuthStatusLoading = false; + } +} diff --git a/ui/src/ui/controllers/devices.test.ts b/ui/src/lib/nodes/index.test.ts similarity index 87% rename from ui/src/ui/controllers/devices.test.ts rename to ui/src/lib/nodes/index.test.ts index 4bb73caa9b90..4c6eaa7e8132 100644 --- a/ui/src/ui/controllers/devices.test.ts +++ b/ui/src/lib/nodes/index.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../gateway.ts"; import { closeDevicePairSetup, refreshDevicePairSetup, type DevicePairSetup, - type DevicesState, -} from "./devices.ts"; + type DevicePairSetupState, +} from "./index.ts"; function deferred() { let resolve!: (value: T) => void; @@ -24,13 +23,10 @@ function setupResult(setupCode: string): DevicePairSetup { }; } -function stateWithClient(client: GatewayBrowserClient): DevicesState { +function stateWithClient(client: DevicePairSetupState["client"]): DevicePairSetupState { return { client, connected: true, - devicesLoading: false, - devicesError: null, - devicesList: null, devicePairSetupOpen: true, devicePairSetupLoading: false, devicePairSetupError: null, @@ -44,10 +40,10 @@ describe("device pairing setup state", () => { const newResponse = deferred(); const oldClient = { request: vi.fn(() => oldResponse.promise), - } as unknown as GatewayBrowserClient; + } as unknown as DevicePairSetupState["client"]; const newClient = { request: vi.fn(() => newResponse.promise), - } as unknown as GatewayBrowserClient; + } as unknown as DevicePairSetupState["client"]; const state = stateWithClient(oldClient); const oldRequest = refreshDevicePairSetup(state); @@ -76,7 +72,7 @@ describe("device pairing setup state", () => { .fn() .mockReturnValueOnce(oldResponse.promise) .mockReturnValueOnce(newResponse.promise), - } as unknown as GatewayBrowserClient; + } as unknown as DevicePairSetupState["client"]; const state = stateWithClient(client); const oldRequest = refreshDevicePairSetup(state); @@ -95,7 +91,7 @@ describe("device pairing setup state", () => { }); it("clears setup credentials and loading state when the dialog closes", () => { - const state = stateWithClient({} as GatewayBrowserClient); + const state = stateWithClient(null); state.devicePairSetupLoading = true; state.devicePairSetupError = "failed"; state.devicePairSetup = setupResult("SECRET"); diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts new file mode 100644 index 000000000000..9ceebfb786f2 --- /dev/null +++ b/ui/src/lib/nodes/index.ts @@ -0,0 +1,655 @@ +// Shared Nodes operations used by the Control UI page and Gateway event hooks. +import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; +import type { DevicePairSetupCodeResult } from "../../../../packages/gateway-protocol/src/index.js"; +import { + clearDeviceAuthTokenFromStore, + type DeviceAuthEntry, + loadDeviceAuthTokenFromStore, + storeDeviceAuthTokenInStore, +} from "../../../../src/shared/device-auth-store.js"; +import type { DeviceAuthStore } from "../../../../src/shared/device-auth.js"; +import { getSafeLocalStorage } from "../../local-storage.ts"; +import { cloneConfigObject, removePathValue, setPathValue } from "../config-form-utils.ts"; + +type GatewayRequestClient = { + request(method: string, params?: unknown): Promise; +}; + +export type NodesGatewaySnapshot = { + client: GatewayRequestClient | null; + connected: boolean; +}; + +export type DeviceTokenSummary = { + role: string; + scopes?: string[]; + createdAtMs?: number; + rotatedAtMs?: number; + revokedAtMs?: number; + lastUsedAtMs?: number; +}; + +export type PendingDevice = { + requestId: string; + deviceId: string; + publicKey?: string; + displayName?: string; + role?: string; + roles?: string[]; + scopes?: string[]; + remoteIp?: string; + isRepair?: boolean; + ts?: number; +}; + +export type PairedDevice = { + deviceId: string; + publicKey?: string; + displayName?: string; + roles?: string[]; + scopes?: string[]; + remoteIp?: string; + tokens?: DeviceTokenSummary[]; + createdAtMs?: number; + approvedAtMs?: number; +}; + +export type DevicePairingList = { + pending: PendingDevice[]; + paired: PairedDevice[]; +}; + +export type DevicePairSetup = DevicePairSetupCodeResult; + +export type DevicePairSetupState = NodesGatewaySnapshot & { + devicePairSetupOpen: boolean; + devicePairSetupLoading: boolean; + devicePairSetupError: string | null; + devicePairSetup: DevicePairSetup | null; +}; + +export type ExecApprovalsDefaults = { + security?: string; + ask?: string; + askFallback?: string; + autoAllowSkills?: boolean; +}; + +export type ExecApprovalsAllowlistEntry = { + id?: string; + pattern: string; + source?: "allow-always"; + commandText?: string; + argPattern?: string; + lastUsedAt?: number; + lastUsedCommand?: string; + lastResolvedPath?: string; +}; + +export type ExecApprovalsAgent = ExecApprovalsDefaults & { + allowlist?: ExecApprovalsAllowlistEntry[]; +}; + +export type ExecApprovalsFile = { + version?: number; + socket?: { path?: string }; + defaults?: ExecApprovalsDefaults; + agents?: Record; +}; + +export type ExecApprovalsSnapshot = { + path: string; + exists: boolean; + hash: string; + file: ExecApprovalsFile; +}; + +export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string }; + +export type NodesState = { + client: GatewayRequestClient | null; + connected: boolean; + nodesLoading: boolean; + nodes: Array>; + lastError: string | null; + chatError?: string | null; +}; + +export type DevicesState = { + client: GatewayRequestClient | null; + connected: boolean; + devicesLoading: boolean; + devicesError: string | null; + devicesList: DevicePairingList | null; +}; + +export type ExecApprovalsState = { + client: GatewayRequestClient | null; + connected: boolean; + execApprovalsLoading: boolean; + execApprovalsSaving: boolean; + execApprovalsDirty: boolean; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + execApprovalsForm: ExecApprovalsFile | null; + execApprovalsSelectedAgent: string | null; + lastError: string | null; + chatError?: string | null; +}; + +export type NodesPageDataState = NodesState & DevicesState & ExecApprovalsState; + +type StoredIdentity = { + version: 1; + deviceId: string; + publicKey: string; + privateKey: string; + createdAtMs: number; +}; + +export type DeviceIdentity = { + deviceId: string; + publicKey: string; + privateKey: string; +}; + +const DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1"; +const DEVICE_IDENTITY_STORAGE_KEY = "openclaw-device-identity-v1"; +const devicePairSetupRequests = new WeakMap(); + +export function createInitialNodesState( + snapshot: Partial = {}, +): NodesPageDataState { + return { + client: snapshot.client ?? null, + connected: snapshot.connected ?? false, + nodesLoading: false, + nodes: [], + lastError: null, + devicesLoading: false, + devicesError: null, + devicesList: null, + execApprovalsLoading: false, + execApprovalsSaving: false, + execApprovalsDirty: false, + execApprovalsSnapshot: null, + execApprovalsForm: null, + execApprovalsSelectedAgent: null, + }; +} + +export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) { + const client = state.client; + if (!client || !state.connected || state.nodesLoading) { + return; + } + state.nodesLoading = true; + if (!opts?.quiet) { + state.lastError = null; + state.chatError = null; + } + try { + const res = await client.request<{ nodes?: unknown }>("node.list", {}); + if (state.client === client) { + state.nodes = Array.isArray(res.nodes) ? (res.nodes as Array>) : []; + } + } catch (err) { + if (!opts?.quiet && state.client === client) { + state.lastError = String(err); + } + } finally { + if (state.client === client) { + state.nodesLoading = false; + } + } +} + +export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) { + const client = state.client; + if (!client || !state.connected || state.devicesLoading) { + return; + } + state.devicesLoading = true; + if (!opts?.quiet) { + state.devicesError = null; + } + try { + const res = await client.request<{ + pending?: Array; + paired?: Array; + }>("device.pair.list", {}); + if (state.client === client) { + state.devicesList = { + pending: Array.isArray(res?.pending) ? res.pending : [], + paired: Array.isArray(res?.paired) ? res.paired : [], + }; + } + } catch (err) { + if (!opts?.quiet && state.client === client) { + state.devicesError = String(err); + } + } finally { + if (state.client === client) { + state.devicesLoading = false; + } + } +} + +export async function openDevicePairSetup(state: DevicePairSetupState) { + state.devicePairSetupOpen = true; + await refreshDevicePairSetup(state); +} + +export async function refreshDevicePairSetup(state: DevicePairSetupState) { + const client = state.client; + if (!client || !state.connected || state.devicePairSetupLoading) { + return; + } + const requestToken = {}; + devicePairSetupRequests.set(state, requestToken); + state.devicePairSetupLoading = true; + state.devicePairSetupError = null; + try { + const result = await client.request("device.pair.setupCode", {}); + if ( + devicePairSetupRequests.get(state) !== requestToken || + state.client !== client || + !state.connected || + !state.devicePairSetupOpen + ) { + return; + } + state.devicePairSetup = result; + } catch (err) { + if ( + devicePairSetupRequests.get(state) === requestToken && + state.client === client && + state.devicePairSetupOpen + ) { + state.devicePairSetupError = String(err); + } + } finally { + // A retired request must not clear the loading state of a replacement request. + if (devicePairSetupRequests.get(state) === requestToken) { + devicePairSetupRequests.delete(state); + state.devicePairSetupLoading = false; + } + } +} + +export function closeDevicePairSetup(state: DevicePairSetupState) { + devicePairSetupRequests.delete(state); + state.devicePairSetupOpen = false; + state.devicePairSetupLoading = false; + state.devicePairSetupError = null; + state.devicePairSetup = null; +} + +export async function approveDevicePairing(state: DevicesState, requestId: string) { + if (!state.client || !state.connected) { + return; + } + try { + await state.client.request("device.pair.approve", { requestId }); + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function rejectDevicePairing(state: DevicesState, requestId: string) { + if (!state.client || !state.connected) { + return; + } + const confirmed = window.confirm("Reject this device pairing request?"); + if (!confirmed) { + return; + } + try { + await state.client.request("device.pair.reject", { requestId }); + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function rotateDeviceToken( + state: DevicesState, + params: { deviceId: string; role: string; scopes?: string[] }, +) { + if (!state.client || !state.connected) { + return; + } + try { + const res = await state.client.request<{ + token?: string; + role?: string; + deviceId?: string; + scopes?: Array; + }>("device.token.rotate", params); + if (res?.token) { + const identity = await loadOrCreateDeviceIdentity(); + const role = res.role ?? params.role; + if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) { + storeDeviceAuthToken({ + deviceId: identity.deviceId, + role, + token: res.token, + scopes: res.scopes ?? params.scopes ?? [], + }); + } + window.prompt("New device token (copy and store securely):", res.token); + } + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function revokeDeviceToken( + state: DevicesState, + params: { deviceId: string; role: string }, +) { + if (!state.client || !state.connected) { + return; + } + const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`); + if (!confirmed) { + return; + } + try { + await state.client.request("device.token.revoke", params); + const identity = await loadOrCreateDeviceIdentity(); + if (params.deviceId === identity.deviceId) { + clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role }); + } + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): { + method: string; + params: Record; +} | null { + if (!target || target.kind === "gateway") { + return { method: "exec.approvals.get", params: {} }; + } + const nodeId = target.nodeId.trim(); + return nodeId ? { method: "exec.approvals.node.get", params: { nodeId } } : null; +} + +function resolveExecApprovalsSaveRpc( + target: ExecApprovalsTarget | null | undefined, + params: { file: ExecApprovalsFile; baseHash: string }, +): { method: string; params: Record } | null { + if (!target || target.kind === "gateway") { + return { method: "exec.approvals.set", params }; + } + const nodeId = target.nodeId.trim(); + return nodeId ? { method: "exec.approvals.node.set", params: { ...params, nodeId } } : null; +} + +export async function loadExecApprovals( + state: ExecApprovalsState, + target?: ExecApprovalsTarget | null, +) { + const client = state.client; + if (!client || !state.connected || state.execApprovalsLoading) { + return; + } + state.execApprovalsLoading = true; + state.lastError = null; + state.chatError = null; + try { + const rpc = resolveExecApprovalsRpc(target); + if (!rpc) { + state.lastError = "Select a node before loading exec approvals."; + return; + } + const res = await client.request(rpc.method, rpc.params); + if (state.client === client) { + applyExecApprovalsSnapshot(state, res); + } + } catch (err) { + if (state.client === client) { + state.lastError = String(err); + } + } finally { + if (state.client === client) { + state.execApprovalsLoading = false; + } + } +} + +function applyExecApprovalsSnapshot(state: ExecApprovalsState, snapshot: ExecApprovalsSnapshot) { + state.execApprovalsSnapshot = snapshot; + if (!state.execApprovalsDirty) { + state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {}); + } +} + +export async function saveExecApprovals( + state: ExecApprovalsState, + target?: ExecApprovalsTarget | null, +) { + const client = state.client; + if (!client || !state.connected) { + return; + } + state.execApprovalsSaving = true; + state.lastError = null; + state.chatError = null; + try { + const baseHash = state.execApprovalsSnapshot?.hash; + if (!baseHash) { + state.lastError = "Exec approvals hash missing; reload and retry."; + return; + } + const file = state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}; + const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash }); + if (!rpc) { + state.lastError = "Select a node before saving exec approvals."; + return; + } + await client.request(rpc.method, rpc.params); + if (state.client !== client) { + return; + } + state.execApprovalsDirty = false; + await loadExecApprovals(state, target); + } catch (err) { + if (state.client === client) { + state.lastError = String(err); + } + } finally { + if (state.client === client) { + state.execApprovalsSaving = false; + } + } +} + +export function updateExecApprovalsFormValue( + state: ExecApprovalsState, + path: Array, + value: unknown, +) { + const base = cloneConfigObject( + state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, + ); + setPathValue(base, path, value); + state.execApprovalsForm = base; + state.execApprovalsDirty = true; +} + +export function removeExecApprovalsFormValue( + state: ExecApprovalsState, + path: Array, +) { + const base = cloneConfigObject( + state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, + ); + removePathValue(base, path); + state.execApprovalsForm = base; + state.execApprovalsDirty = true; +} + +function readStore(): DeviceAuthStore | null { + try { + const raw = getSafeLocalStorage()?.getItem(DEVICE_AUTH_STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as DeviceAuthStore; + if (!parsed || parsed.version !== 1) { + return null; + } + if (!parsed.deviceId || typeof parsed.deviceId !== "string") { + return null; + } + if (!parsed.tokens || typeof parsed.tokens !== "object") { + return null; + } + return parsed; + } catch { + return null; + } +} + +function writeStore(store: DeviceAuthStore) { + try { + getSafeLocalStorage()?.setItem(DEVICE_AUTH_STORAGE_KEY, JSON.stringify(store)); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} + +export function loadDeviceAuthToken(params: { + deviceId: string; + role: string; +}): DeviceAuthEntry | null { + return loadDeviceAuthTokenFromStore({ + adapter: { readStore, writeStore }, + deviceId: params.deviceId, + role: params.role, + }); +} + +export function storeDeviceAuthToken(params: { + deviceId: string; + role: string; + token: string; + scopes?: string[]; +}): DeviceAuthEntry { + return storeDeviceAuthTokenInStore({ + adapter: { readStore, writeStore }, + deviceId: params.deviceId, + role: params.role, + token: params.token, + scopes: params.scopes, + }); +} + +export function clearDeviceAuthToken(params: { deviceId: string; role: string }) { + clearDeviceAuthTokenFromStore({ + adapter: { readStore, writeStore }, + deviceId: params.deviceId, + role: params.role, + }); +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function base64UrlDecode(input: string): Uint8Array { + const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function fingerprintPublicKey(publicKey: Uint8Array): Promise { + const hash = await crypto.subtle.digest("SHA-256", publicKey.slice().buffer); + return bytesToHex(new Uint8Array(hash)); +} + +async function generateIdentity(): Promise { + const privateKey = utils.randomSecretKey(); + const publicKey = await getPublicKeyAsync(privateKey); + const deviceId = await fingerprintPublicKey(publicKey); + return { + deviceId, + publicKey: base64UrlEncode(publicKey), + privateKey: base64UrlEncode(privateKey), + }; +} + +export async function loadOrCreateDeviceIdentity(): Promise { + const storage = getSafeLocalStorage(); + try { + const raw = storage?.getItem(DEVICE_IDENTITY_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as StoredIdentity; + if ( + parsed?.version === 1 && + typeof parsed.deviceId === "string" && + typeof parsed.publicKey === "string" && + typeof parsed.privateKey === "string" + ) { + const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); + if (derivedId !== parsed.deviceId) { + const updated: StoredIdentity = { + ...parsed, + deviceId: derivedId, + }; + storage?.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(updated)); + return { + deviceId: derivedId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + return { + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + } + } catch { + // Invalid local identity is replaced below. + } + + const identity = await generateIdentity(); + const stored: StoredIdentity = { + version: 1, + deviceId: identity.deviceId, + publicKey: identity.publicKey, + privateKey: identity.privateKey, + createdAtMs: Date.now(), + }; + storage?.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored)); + return identity; +} + +export async function signDevicePayload(privateKeyBase64Url: string, payload: string) { + const key = base64UrlDecode(privateKeyBase64Url); + const data = new TextEncoder().encode(payload); + const sig = await signAsync(data, key); + return base64UrlEncode(sig); +} diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/lib/open-external-url.test.ts similarity index 100% rename from ui/src/ui/open-external-url.test.ts rename to ui/src/lib/open-external-url.test.ts diff --git a/ui/src/ui/open-external-url.ts b/ui/src/lib/open-external-url.ts similarity index 100% rename from ui/src/ui/open-external-url.ts rename to ui/src/lib/open-external-url.ts diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/lib/overview-hints.node.test.ts similarity index 95% rename from ui/src/ui/views/overview.node.test.ts rename to ui/src/lib/overview-hints.node.test.ts index ad9925220626..8136108d3ede 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/lib/overview-hints.node.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import { afterEach, describe, expect, it, vi } from "vitest"; -import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; -import { createStorageMock } from "../../test-helpers/storage.ts"; -import { resolveGatewayTokenForUrlEdit } from "../storage.ts"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { resolveGatewayTokenForUrlEdit } from "../app/settings.ts"; +import { createStorageMock } from "../test-helpers/storage.ts"; import { resolveAuthHintKind, resolvePairingHint, diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/lib/overview-hints.ts similarity index 96% rename from ui/src/ui/views/overview-hints.ts rename to ui/src/lib/overview-hints.ts index 04c50e0e3f3c..333bca593476 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/lib/overview-hints.ts @@ -2,8 +2,8 @@ import { ConnectErrorDetailCodes, readConnectPairingRequiredMessage, -} from "../../../../packages/gateway-protocol/src/connect-error-details.js"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +} from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; const AUTH_REQUIRED_CODES = new Set([ ConnectErrorDetailCodes.AUTH_REQUIRED, diff --git a/ui/src/ui/plugin-activation.test.ts b/ui/src/lib/plugin-activation.test.ts similarity index 100% rename from ui/src/ui/plugin-activation.test.ts rename to ui/src/lib/plugin-activation.test.ts diff --git a/ui/src/ui/plugin-activation.ts b/ui/src/lib/plugin-activation.ts similarity index 96% rename from ui/src/ui/plugin-activation.ts rename to ui/src/lib/plugin-activation.ts index 70fc64975fa7..6a8695a722b4 100644 --- a/ui/src/ui/plugin-activation.ts +++ b/ui/src/lib/plugin-activation.ts @@ -1,5 +1,5 @@ // Control UI module implements plugin activation behavior. -import type { ConfigSnapshot } from "./types.ts"; +import type { ConfigSnapshot } from "../api/types.ts"; type PluginActivationOptions = { enabledByDefault?: boolean; diff --git a/ui/src/ui/presenter.test.ts b/ui/src/lib/presenter.test.ts similarity index 96% rename from ui/src/ui/presenter.test.ts rename to ui/src/lib/presenter.test.ts index 39d5ef81b063..f55dc71f4240 100644 --- a/ui/src/ui/presenter.test.ts +++ b/ui/src/lib/presenter.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover cron schedule presentation. import { describe, expect, it } from "vitest"; import { formatCronSchedule } from "./presenter.ts"; -import type { CronJob } from "./types.ts"; +import type { CronJob } from "../api/types.ts"; function job(schedule: CronJob["schedule"]): CronJob { return { diff --git a/ui/src/ui/presenter.ts b/ui/src/lib/presenter.ts similarity index 96% rename from ui/src/ui/presenter.ts rename to ui/src/lib/presenter.ts index 793c17008bb7..3cae79befdad 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/lib/presenter.ts @@ -1,14 +1,14 @@ +import type { CronJob, GatewaySessionRow, PresenceEntry } from "../api/types.ts"; // Control UI module implements presenter behavior. import { t } from "../i18n/index.ts"; -import { resolveCronJobLastRunStatus } from "./cron-status.ts"; +import { resolveCronJobLastRunStatus } from "../lib/cron-status.ts"; import { formatDateMs, formatRelativeTimestamp, formatDurationHuman, formatMs, formatUnknownText, -} from "./format.ts"; -import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts"; +} from "../lib/format.ts"; export function formatPresenceAge(entry: PresenceEntry): string { const ts = entry.ts ?? null; diff --git a/ui/src/ui/provider-quota-summary.test.ts b/ui/src/lib/provider-quota-summary.test.ts similarity index 100% rename from ui/src/ui/provider-quota-summary.test.ts rename to ui/src/lib/provider-quota-summary.test.ts diff --git a/ui/src/ui/provider-quota-summary.ts b/ui/src/lib/provider-quota-summary.ts similarity index 98% rename from ui/src/ui/provider-quota-summary.ts rename to ui/src/lib/provider-quota-summary.ts index e72904509238..4335042fd764 100644 --- a/ui/src/ui/provider-quota-summary.ts +++ b/ui/src/lib/provider-quota-summary.ts @@ -1,6 +1,6 @@ // Control UI module implements provider quota summary behavior. import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; -import type { ModelAuthStatusProvider, ModelAuthStatusResult } from "./types.ts"; +import type { ModelAuthStatusProvider, ModelAuthStatusResult } from "../api/types.ts"; export type QuotaWindowSummary = { displayName: string; diff --git a/ui/src/ui/select-options.ts b/ui/src/lib/select-options.ts similarity index 100% rename from ui/src/ui/select-options.ts rename to ui/src/lib/select-options.ts diff --git a/ui/src/ui/session-display.ts b/ui/src/lib/session-display.ts similarity index 95% rename from ui/src/ui/session-display.ts rename to ui/src/lib/session-display.ts index d034567ec351..e199d3c54260 100644 --- a/ui/src/ui/session-display.ts +++ b/ui/src/lib/session-display.ts @@ -1,6 +1,5 @@ // Control UI module implements session display behavior. import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; -import type { SessionsListResult } from "./types.ts"; const CHANNEL_LABELS: Record = { imessage: "iMessage", @@ -24,6 +23,11 @@ export type SessionKeyInfo = { fallbackName: string; }; +type SessionDisplayRow = { + label?: string; + displayName?: string; +}; + function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } @@ -78,10 +82,7 @@ export function parseSessionKey(key: string): SessionKeyInfo { return { prefix: "", fallbackName: key }; } -export function resolveSessionDisplayName( - key: string, - row?: SessionsListResult["sessions"][number], -): string { +export function resolveSessionDisplayName(key: string, row?: SessionDisplayRow): string { const label = normalizeOptionalString(row?.label) ?? ""; const displayName = normalizeOptionalString(row?.displayName) ?? ""; const { prefix, fallbackName } = parseSessionKey(key); diff --git a/ui/src/ui/session-goal.test.ts b/ui/src/lib/session-goal.test.ts similarity index 96% rename from ui/src/ui/session-goal.test.ts rename to ui/src/lib/session-goal.test.ts index c743628ce10a..10dacae17248 100644 --- a/ui/src/ui/session-goal.test.ts +++ b/ui/src/lib/session-goal.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover session goal behavior. import { describe, expect, it } from "vitest"; +import type { SessionGoal } from "../api/types.ts"; import { formatGoalDetail, formatGoalSummary, formatGoalTokenCount } from "./session-goal.ts"; -import type { SessionGoal } from "./types.ts"; function buildGoal(overrides: Partial = {}): SessionGoal { return { diff --git a/ui/src/ui/session-goal.ts b/ui/src/lib/session-goal.ts similarity index 97% rename from ui/src/ui/session-goal.ts rename to ui/src/lib/session-goal.ts index 9c0f565c3c5e..d73f95cabaf3 100644 --- a/ui/src/ui/session-goal.ts +++ b/ui/src/lib/session-goal.ts @@ -1,5 +1,5 @@ // Control UI module implements session goal behavior. -import type { SessionGoal } from "./types.ts"; +import type { SessionGoal } from "../api/types.ts"; export function formatGoalTokenCount(value: number): string { if (!Number.isFinite(value) || value <= 0) { diff --git a/ui/src/ui/session-model-defaults.ts b/ui/src/lib/session-model-defaults.ts similarity index 85% rename from ui/src/ui/session-model-defaults.ts rename to ui/src/lib/session-model-defaults.ts index b39a562c2c87..dacc42be330a 100644 --- a/ui/src/ui/session-model-defaults.ts +++ b/ui/src/lib/session-model-defaults.ts @@ -1,5 +1,5 @@ // Shared helpers for comparing session rows against list defaults. -import type { GatewaySessionRow, SessionsListResult } from "./types.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts"; type SessionModelFields = Pick; diff --git a/ui/src/ui/session-run-state.test.ts b/ui/src/lib/session-run-state.test.ts similarity index 100% rename from ui/src/ui/session-run-state.test.ts rename to ui/src/lib/session-run-state.test.ts diff --git a/ui/src/ui/session-run-state.ts b/ui/src/lib/session-run-state.ts similarity index 87% rename from ui/src/ui/session-run-state.ts rename to ui/src/lib/session-run-state.ts index 539e22e1fdd9..b01722ef4d5a 100644 --- a/ui/src/ui/session-run-state.ts +++ b/ui/src/lib/session-run-state.ts @@ -1,5 +1,5 @@ // Control UI module implements session run state behavior. -import type { SessionRunStatus } from "./types.ts"; +import type { SessionRunStatus } from "../api/types.ts"; type SessionRunState = { hasActiveRun?: boolean; diff --git a/ui/src/lib/sessions/create.ts b/ui/src/lib/sessions/create.ts new file mode 100644 index 000000000000..c66a32b1ffb9 --- /dev/null +++ b/ui/src/lib/sessions/create.ts @@ -0,0 +1,35 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; + +export type SessionCreateParams = { + agentId?: string; + currentSessionKey?: string; + label?: string; + model?: string; +}; + +export function resolveSessionCreateParams(sessionKey = "", agentId?: string) { + const normalizedSessionKey = sessionKey.trim(); + const parentSessionKey = + normalizedSessionKey && normalizedSessionKey.toLowerCase() !== "unknown" + ? normalizedSessionKey + : undefined; + return { + ...(agentId?.trim() ? { agentId: agentId.trim() } : {}), + ...(parentSessionKey ? { parentSessionKey, emitCommandHooks: true } : {}), + }; +} + +export async function requestSessionCreate( + client: Pick, + params: Omit & { + parentSessionKey?: string; + emitCommandHooks?: boolean; + } = {}, +): Promise { + const result = await client.request<{ key?: unknown }>("sessions.create", params); + const key = typeof result?.key === "string" ? result.key.trim() : ""; + if (!key) { + throw new Error("sessions.create returned no key"); + } + return key; +} diff --git a/ui/src/lib/sessions/index.test.ts b/ui/src/lib/sessions/index.test.ts new file mode 100644 index 000000000000..4a0543330aee --- /dev/null +++ b/ui/src/lib/sessions/index.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { SessionsListResult } from "../../api/types.ts"; +import { createSessionCapability } from "./index.ts"; + +function sessionsResult(sessions: SessionsListResult["sessions"], ts: number): SessionsListResult { + return { + ts, + path: "(multiple)", + count: sessions.length, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions, + }; +} + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("createSessionCapability", () => { + it("keeps background hydration non-blocking and retains an omitted selected row", async () => { + const secondList = deferred(); + let listCalls = 0; + const request = vi.fn(async (method: string) => { + if (method !== "sessions.list") { + throw new Error(`Unexpected request: ${method}`); + } + listCalls += 1; + if (listCalls === 1) { + return sessionsResult( + [ + { + key: "agent:main:oldest", + kind: "direct", + updatedAt: 1, + label: "Oldest", + }, + ], + 1, + ); + } + return await secondList.promise; + }); + const client = { request } as unknown as GatewayBrowserClient; + const gateway = { + snapshot: { + client, + connected: true, + sessionKey: "agent:main:oldest", + assistantAgentId: "main", + hello: null, + }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }; + const sessions = createSessionCapability(gateway); + await sessions.refresh({ agentId: "main", force: true }); + const loadingStates: boolean[] = []; + const stop = sessions.subscribe((state) => loadingStates.push(state.loading)); + + const hydration = sessions.refresh({ + agentId: "main", + backgroundHydrate: true, + force: true, + }); + expect(sessions.state.loading).toBe(false); + secondList.resolve(sessionsResult([], 2)); + await hydration; + + expect(loadingStates).not.toContain(true); + expect(sessions.state.result?.sessions).toEqual([ + expect.objectContaining({ key: "agent:main:oldest", label: "Oldest" }), + ]); + stop(); + sessions.dispose(); + }); +}); diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts new file mode 100644 index 000000000000..9910c34444a2 --- /dev/null +++ b/ui/src/lib/sessions/index.ts @@ -0,0 +1,1033 @@ +import type { GatewayBrowserClient, GatewayEventFrame, GatewayHelloOk } from "../../api/gateway.ts"; +import type { + FastMode, + GatewaySessionRow, + SessionCompactionCheckpoint, + SessionsCompactionBranchResult, + SessionsCompactionListResult, + SessionsCompactionRestoreResult, + SessionsListResult, + SessionsPatchResult, + SessionWorkspaceGetResult, + SessionWorkspaceListResult, +} from "../../api/types.ts"; +import { + requestSessionCreate, + resolveSessionCreateParams, + type SessionCreateParams, +} from "./create.ts"; +import { scopedAgentListParamsForSession } from "./navigation.ts"; +import { + reconcileSessionChanged, + reconcileSessionHistory, + type SessionChangedResult, + type SessionReconcileOptions, +} from "./reconcile.ts"; +import { + areUiSessionKeysEquivalent, + normalizeAgentId, + parseAgentSessionKey, + resolveUiSelectedGlobalAgentId, + uiSessionRowMatchesSelectedChat, +} from "./session-key.ts"; +export { + buildSessionUsageDateParams, + requestSessionUsage, + requestSessionUsageLogs, + requestSessionUsageTimeSeries, +} from "./usage.ts"; +export type { SessionUsageQuery } from "./usage.ts"; + +export type SessionState = { + result: SessionsListResult | null; + agentId: string | null; + modelOverrides: Readonly>; + loading: boolean; + error: string | null; + deletedSessions: readonly SessionDeleteTarget[]; +}; + +export type SessionListOptions = { + agentId?: string; + activeMinutes?: number; + search?: string; + offset?: number; + limit?: number; + includeGlobal?: boolean; + includeUnknown?: boolean; + configuredAgentsOnly?: boolean; + showArchived?: boolean; + append?: boolean; +}; + +export type SessionRefreshOptions = SessionListOptions & { + force?: boolean; + // Sidebar startup hydration must not block session creation or drop the open session. + backgroundHydrate?: boolean; +}; + +export type SessionPatch = { + label?: string | null; + model?: string | null; + thinkingLevel?: string | null; + fastMode?: FastMode | null; + verboseLevel?: string | null; + reasoningLevel?: string | null; + archived?: boolean; + pinned?: boolean; +}; + +export type SessionDeleteOptions = { + agentId?: string; + deleteTranscript?: boolean; +}; + +export type SessionDeleteTarget = { + key: string; + agentId?: string; +}; + +export type SessionDeleteBatchResult = { + deleted: string[]; + errors: string[]; +}; + +export type SessionCompactResult = { + ok?: boolean; + compacted?: boolean; + reason?: string; + result?: { tokensBefore?: number; tokensAfter?: number }; +}; + +export type SessionSteerResult = { + runId?: string; + status?: unknown; +}; + +export type SessionResetOptions = { + agentId?: string | null; +}; + +export type SessionGateway = { + readonly snapshot: { + client: GatewayBrowserClient | null; + connected: boolean; + hello: GatewayHelloOk | null; + assistantAgentId?: string | null; + sessionKey?: string; + }; + subscribe: (listener: (snapshot: SessionGateway["snapshot"]) => void) => () => void; + subscribeEvents: (listener: (event: GatewayEventFrame) => void) => () => void; +}; + +type SessionRequestClient = Pick; + +export type SessionMessageSubscription = { + key: string; + agentId?: string | null; +}; + +export type SessionCapability = { + readonly state: SessionState; + list: (options?: SessionListOptions) => Promise; + reconcile: ( + row: GatewaySessionRow | undefined, + defaults?: SessionsListResult["defaults"], + options?: SessionReconcileOptions, + ) => boolean; + reconcileChanged: (payload: unknown, options?: SessionReconcileOptions) => SessionChangedResult; + refresh: (options?: SessionRefreshOptions) => Promise; + create: (params?: SessionCreateParams) => Promise; + patch: ( + key: string, + patch: SessionPatch, + options?: { agentId?: string }, + ) => Promise; + setModelOverride: (key: string, value: string | null | undefined) => void; + delete: (key: string, options?: SessionDeleteOptions) => Promise; + deleteMany: (targets: readonly SessionDeleteTarget[]) => Promise; + reset: (key: string, options?: SessionResetOptions) => Promise; + compact: (key: string, options?: { agentId?: string | null }) => Promise; + steer: ( + key: string, + message: string, + options?: { agentId?: string | null }, + ) => Promise; + listFiles: ( + key: string, + options?: { agentId?: string | null; path?: string; search?: string }, + ) => Promise; + getFile: ( + key: string, + path: string, + options?: { agentId?: string | null }, + ) => Promise; + subscribeMessages: ( + key: string, + options?: { agentId?: string | null }, + ) => Promise; + unsubscribeMessages: (subscription: SessionMessageSubscription) => Promise; + listCheckpoints: ( + key: string, + options?: { agentId?: string | null }, + ) => Promise; + branchCheckpoint: ( + key: string, + checkpointId: string, + options?: { agentId?: string | null }, + ) => Promise; + restoreCheckpoint: ( + key: string, + checkpointId: string, + options?: { agentId?: string | null }, + ) => Promise; + subscribe: (listener: (state: SessionState) => void) => () => void; + dispose: () => void; +}; + +export { requestSessionCreate } from "./create.ts"; +export type { SessionCreateParams } from "./create.ts"; +export { resolveSessionKey } from "./navigation.ts"; +export { + compareSessionRowsByUpdatedAt, + filterSessionRows, + getVisibleSessionRows, + resolveSessionNavigation, + scopedAgentIdForSession, + scopedAgentListParamsForRefreshTarget, + scopedAgentListParamsForSession, + scopedAgentParamsForSession, + searchForSession, + visibleSessionMatches, +} from "./navigation.ts"; +export { reconcileSessionHistory } from "./reconcile.ts"; +export type { SessionChangedResult, SessionReconcileOptions } from "./reconcile.ts"; +export type { + SessionNavigation, + SessionNavigationInput, + SessionRefreshTarget, + SessionScopeHost, + SessionScopeHostWithKey, +} from "./navigation.ts"; + +const SESSION_LIST_PARAMS = { + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, +} as const; + +function buildSessionRequestParams( + key: string, + agentId?: string | null, +): { key: string; agentId?: string } { + const normalizedKey = key.trim(); + const normalizedAgentId = agentId?.trim(); + return { + key: normalizedKey, + ...(normalizedAgentId ? { agentId: normalizedAgentId } : {}), + }; +} + +function buildSessionListParams(options: SessionListOptions = {}): Record { + const params: Record = { + ...SESSION_LIST_PARAMS, + }; + if (options.limit === undefined) { + params.limit = 50; + } else if (options.limit > 0) { + params.limit = Math.floor(options.limit); + } + if (options.includeGlobal !== undefined) { + params.includeGlobal = options.includeGlobal; + } + if (options.includeUnknown !== undefined) { + params.includeUnknown = options.includeUnknown; + } + if (options.configuredAgentsOnly !== undefined) { + params.configuredAgentsOnly = options.configuredAgentsOnly; + } + if (options.showArchived === true) { + params.archived = true; + } + const activeMinutes = + options.showArchived === true + ? 0 + : typeof options.activeMinutes === "number" && options.activeMinutes > 0 + ? Math.floor(options.activeMinutes) + : 0; + if (activeMinutes > 0) { + params.activeMinutes = activeMinutes; + } + const agentId = options.agentId?.trim(); + const search = options.search?.trim(); + if (agentId) { + params.agentId = agentId; + } + if (search) { + params.search = search; + } + if (typeof options.offset === "number" && options.offset > 0) { + params.offset = Math.floor(options.offset); + } + return params; +} + +export async function requestSessionList( + client: SessionRequestClient, + options: SessionListOptions = {}, +): Promise { + const result = await client.request( + "sessions.list", + buildSessionListParams(options), + ); + return result ?? null; +} + +export function requestSessionPatch( + client: SessionRequestClient, + key: string, + patch: SessionPatch, + options: { agentId?: string | null } = {}, +): Promise { + return client.request("sessions.patch", { + ...buildSessionRequestParams(key, options.agentId), + ...patch, + }); +} + +export function requestSessionDelete( + client: SessionRequestClient, + key: string, + options: SessionDeleteOptions = {}, +): Promise<{ deleted?: boolean }> { + return client.request<{ deleted?: boolean }>("sessions.delete", { + ...buildSessionRequestParams(key, options.agentId), + deleteTranscript: options.deleteTranscript ?? true, + }); +} + +export function requestSessionReset( + client: SessionRequestClient, + key: string, + options: SessionResetOptions = {}, +): Promise { + return client + .request("sessions.reset", { + ...buildSessionRequestParams(key, options.agentId), + }) + .then(() => undefined); +} + +export function requestSessionCompact( + client: SessionRequestClient, + key: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request("sessions.compact", { + ...buildSessionRequestParams(key, options.agentId), + }); +} + +export function requestSessionSteer( + client: SessionRequestClient, + key: string, + message: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request("sessions.steer", { + ...buildSessionRequestParams(key, options.agentId), + message, + }); +} + +export function requestSessionFilesList( + client: SessionRequestClient, + key: string, + options: { agentId?: string | null; path?: string; search?: string } = {}, +): Promise { + return client.request("sessions.files.list", { + sessionKey: key, + path: options.path ?? "", + search: options.search ?? "", + ...(options.agentId?.trim() ? { agentId: options.agentId.trim() } : {}), + }); +} + +export function requestSessionFile( + client: SessionRequestClient, + key: string, + path: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request("sessions.files.get", { + sessionKey: key, + path, + ...(options.agentId?.trim() ? { agentId: options.agentId.trim() } : {}), + }); +} + +export function subscribeSessionGateway(client: SessionRequestClient): Promise { + return client.request("sessions.subscribe", {}).then(() => undefined); +} + +export async function subscribeSessionMessages( + client: SessionRequestClient, + key: string, + options: { agentId?: string | null } = {}, +): Promise { + const result = await client.request("sessions.messages.subscribe", { + ...buildSessionRequestParams(key, options.agentId), + }); + const subscribedKey = + result && typeof result === "object" && typeof (result as { key?: unknown }).key === "string" + ? (result as { key: string }).key.trim() + : ""; + return { + key: subscribedKey || key.trim(), + agentId: options.agentId?.trim() || null, + }; +} + +export function unsubscribeSessionMessages( + client: SessionRequestClient, + subscription: SessionMessageSubscription, +): Promise { + return client + .request( + "sessions.messages.unsubscribe", + buildSessionRequestParams(subscription.key, subscription.agentId), + ) + .then(() => undefined); +} + +export async function listSessionCheckpoints( + client: SessionRequestClient, + key: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request( + "sessions.compaction.list", + buildSessionRequestParams(key, options.agentId), + ); +} + +export function branchSessionCheckpoint( + client: SessionRequestClient, + key: string, + checkpointId: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request("sessions.compaction.branch", { + ...buildSessionRequestParams(key, options.agentId), + checkpointId, + }); +} + +export function restoreSessionCheckpoint( + client: SessionRequestClient, + key: string, + checkpointId: string, + options: { agentId?: string | null } = {}, +): Promise { + return client.request("sessions.compaction.restore", { + ...buildSessionRequestParams(key, options.agentId), + checkpointId, + }); +} + +function appendSessionResults( + previous: SessionsListResult, + page: SessionsListResult, +): SessionsListResult { + const seen = new Set(); + const sessions = [...previous.sessions, ...page.sessions].filter((row) => { + if (!row.key || seen.has(row.key)) { + return false; + } + seen.add(row.key); + return true; + }); + const totalCount = page.totalCount ?? previous.totalCount; + const hasMore = + page.hasMore ?? + (typeof totalCount === "number" && Number.isFinite(totalCount) + ? sessions.length < totalCount + : false); + return { + ...page, + count: sessions.length, + totalCount, + hasMore, + nextOffset: page.nextOffset ?? (hasMore ? sessions.length : null), + sessions, + }; +} + +function isSessionEvent(event: GatewayEventFrame): boolean { + return event.event === "sessions.changed"; +} + +function canReconcileSessionEvent(options: SessionListOptions): boolean { + return ( + options.activeMinutes === undefined && + options.search === undefined && + options.offset === undefined && + options.limit === undefined && + options.includeGlobal !== false && + options.includeUnknown !== false && + options.configuredAgentsOnly !== true + ); +} + +export function createSessionCapability(gateway: SessionGateway): SessionCapability { + let state: SessionState = { + result: null, + agentId: null, + modelOverrides: {}, + loading: false, + error: null, + deletedSessions: [], + }; + let inFlight: Promise | null = null; + let queuedRefresh: SessionRefreshOptions | null = null; + let disposed = false; + let subscribedClient: GatewayBrowserClient | null = null; + let lastListOptions: SessionListOptions = {}; + const listeners = new Set<(next: SessionState) => void>(); + + const requestList = async ( + options: SessionListOptions = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return null; + } + const result = await requestSessionList(client, options); + return disposed || gateway.snapshot.client !== client ? null : (result ?? null); + }; + + const publish = (next: SessionState) => { + state = next; + for (const listener of listeners) { + listener(state); + } + }; + + const setModelOverride = (key: string, value: string | null | undefined) => { + const normalizedKey = key.trim(); + if (!normalizedKey) { + return; + } + const modelOverrides = { ...state.modelOverrides }; + if (value === undefined) { + if (!Object.hasOwn(state.modelOverrides, normalizedKey)) { + return; + } + delete modelOverrides[normalizedKey]; + } else { + const normalizedValue = value === null ? null : value.trim(); + if ( + modelOverrides[normalizedKey] === normalizedValue && + Object.hasOwn(modelOverrides, normalizedKey) + ) { + return; + } + modelOverrides[normalizedKey] = normalizedValue; + } + publish({ ...state, modelOverrides }); + }; + + const load = async (options: SessionRefreshOptions) => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return; + } + const { append = false, force: _force, backgroundHydrate = false, ...requestOptions } = options; + lastListOptions = requestOptions; + if (!backgroundHydrate) { + publish({ ...state, loading: true, error: null, deletedSessions: [] }); + } + try { + const result = await requestList(requestOptions); + if (disposed || gateway.snapshot.client !== client) { + return; + } + let nextResult = + result && append && requestOptions.offset && state.result + ? appendSessionResults(state.result, result) + : result; + if (backgroundHydrate && nextResult) { + const currentKey = gateway.snapshot.sessionKey?.trim(); + if (currentKey) { + const currentAgentId = normalizeAgentId( + parseAgentSessionKey(currentKey)?.agentId ?? + resolveUiSelectedGlobalAgentId(gateway.snapshot), + ); + const previousCurrentRow = + state.result?.sessions.find((row) => areUiSessionKeysEquivalent(row.key, currentKey)) ?? + (state.agentId === currentAgentId + ? state.result?.sessions.find((row) => + uiSessionRowMatchesSelectedChat(gateway.snapshot, row.key, currentKey), + ) + : undefined); + if ( + previousCurrentRow && + !nextResult.sessions.some((row) => + uiSessionRowMatchesSelectedChat(gateway.snapshot, row.key, currentKey), + ) + ) { + const sessions = [...nextResult.sessions, previousCurrentRow]; + nextResult = { ...nextResult, count: sessions.length, sessions }; + } + } + } + publish({ + result: nextResult, + agentId: requestOptions.agentId?.trim() ? normalizeAgentId(requestOptions.agentId) : null, + modelOverrides: state.modelOverrides, + loading: backgroundHydrate ? state.loading : false, + error: null, + deletedSessions: [], + }); + } catch (error) { + if (!disposed && gateway.snapshot.client === client) { + publish({ + ...state, + loading: backgroundHydrate ? state.loading : false, + error: String(error), + deletedSessions: [], + }); + } + } + }; + + const drainRefreshQueue = async (options: SessionRefreshOptions) => { + let next: SessionRefreshOptions | null = options; + while (next) { + await load(next); + next = queuedRefresh; + queuedRefresh = null; + } + }; + + const refresh = (options: SessionRefreshOptions = {}) => { + if (!gateway.snapshot.connected || !gateway.snapshot.client || disposed) { + return Promise.resolve(); + } + if (inFlight) { + queuedRefresh = options; + return inFlight; + } + const hasListOverrides = Object.entries(options).some( + ([key, value]) => key !== "force" && key !== "backgroundHydrate" && value !== undefined, + ); + if (state.result && !options.force && !hasListOverrides) { + return Promise.resolve(); + } + const request = drainRefreshQueue(options).finally(() => { + inFlight = null; + }); + inFlight = request; + return request; + }; + + const create = async (params: SessionCreateParams = {}) => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || state.loading || disposed) { + return null; + } + try { + const { currentSessionKey, ...requestParams } = params; + const key = await requestSessionCreate(client, { + ...requestParams, + ...resolveSessionCreateParams(currentSessionKey, params.agentId), + }); + if (disposed || gateway.snapshot.client !== client) { + return null; + } + await refresh({ agentId: params.agentId, force: true }); + return key; + } catch (error) { + publish({ ...state, error: String(error) }); + return null; + } + }; + + const patch = async ( + key: string, + patchParams: SessionPatch, + options: { agentId?: string } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return null; + } + const hasModelPatch = Object.hasOwn(patchParams, "model"); + const previousModelOverride = state.modelOverrides[key.trim()]; + if (hasModelPatch) { + setModelOverride(key, patchParams.model); + } + try { + const result = await requestSessionPatch(client, key, patchParams, options); + if (disposed || gateway.snapshot.client !== client) { + if (hasModelPatch) { + setModelOverride(key, previousModelOverride); + } + return null; + } + await refresh({ agentId: options.agentId, force: true }); + if (hasModelPatch) { + setModelOverride(key, patchParams.model); + } + return result; + } catch (error) { + if (hasModelPatch) { + setModelOverride(key, previousModelOverride); + } + publish({ ...state, error: String(error) }); + throw error; + } + }; + + const reconcile = ( + row: GatewaySessionRow | undefined, + defaults?: SessionsListResult["defaults"], + options?: SessionReconcileOptions, + ): boolean => { + const result = reconcileSessionHistory(state.result, row, defaults, options); + if (result === state.result) { + return false; + } + publish({ + ...state, + result, + agentId: options?.resultAgentId?.trim() + ? normalizeAgentId(options.resultAgentId) + : state.agentId, + }); + return true; + }; + + const reconcileChanged = ( + payload: unknown, + options?: SessionReconcileOptions, + ): SessionChangedResult => { + const reconciled = reconcileSessionChanged(state.result, payload, options); + if (reconciled.applied && (reconciled.result !== state.result || reconciled.deletedKey)) { + publish({ + ...state, + result: reconciled.result, + agentId: options?.resultAgentId?.trim() + ? normalizeAgentId(options.resultAgentId) + : state.agentId, + error: null, + deletedSessions: reconciled.deletedKey + ? [{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined }] + : [], + }); + } + return reconciled; + }; + + const remove = async (key: string, options: SessionDeleteOptions = {}): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return false; + } + try { + await requestSessionDelete(client, key, options); + if (disposed || gateway.snapshot.client !== client) { + return false; + } + publish({ ...state, deletedSessions: [{ key, agentId: options.agentId }] }); + setModelOverride(key, undefined); + await refresh({ agentId: options.agentId, force: true }); + return true; + } catch (error) { + publish({ ...state, error: String(error) }); + throw error; + } + }; + + const removeMany = async ( + targets: readonly SessionDeleteTarget[], + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed || targets.length === 0) { + return { deleted: [], errors: [] }; + } + const deleted: string[] = []; + const errors: string[] = []; + for (const target of targets) { + if (disposed || gateway.snapshot.client !== client) { + break; + } + try { + await requestSessionDelete(client, target.key, target); + if (disposed || gateway.snapshot.client !== client) { + break; + } + deleted.push(target.key); + } catch (error) { + errors.push(String(error)); + } + } + if (deleted.length > 0 && !disposed && gateway.snapshot.client === client) { + publish({ + ...state, + deletedSessions: targets.filter((target) => deleted.includes(target.key)), + }); + for (const key of deleted) { + setModelOverride(key, undefined); + } + await refresh({ force: true }); + } + return { deleted, errors }; + }; + + const reset = async (key: string, options: SessionResetOptions = {}): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return; + } + try { + await requestSessionReset(client, key, options); + } catch (error) { + publish({ ...state, error: String(error) }); + throw error; + } + }; + + const compact = async ( + key: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + throw new Error("Session compaction requires an active Gateway connection"); + } + const result = await requestSessionCompact(client, key, options); + if (disposed || gateway.snapshot.client !== client) { + throw new Error("Session compaction completed on a replaced Gateway client"); + } + return result; + }; + + const steer = async ( + key: string, + message: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + throw new Error("Session steering requires an active Gateway connection"); + } + const result = await requestSessionSteer(client, key, message, options); + if (disposed || gateway.snapshot.client !== client) { + throw new Error("Session steering completed on a replaced Gateway client"); + } + return result; + }; + + const listFiles = async ( + key: string, + options: { agentId?: string | null; path?: string; search?: string } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return null; + } + const result = await requestSessionFilesList(client, key, options); + return disposed || gateway.snapshot.client !== client ? null : result; + }; + + const getFile = async ( + key: string, + path: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return null; + } + const result = await requestSessionFile(client, key, path, options); + return disposed || gateway.snapshot.client !== client ? null : result; + }; + + const subscribeMessages = async ( + key: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + throw new Error("Session message subscription requires an active Gateway connection"); + } + const subscription = await subscribeSessionMessages(client, key, options); + if (disposed || gateway.snapshot.client !== client) { + throw new Error("Session message subscription completed on a replaced Gateway client"); + } + return subscription; + }; + + const unsubscribeMessages = async (subscription: SessionMessageSubscription) => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return; + } + await unsubscribeSessionMessages(client, subscription); + }; + + const listCheckpoints = async ( + key: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + return []; + } + const result = await listSessionCheckpoints(client, key, options); + return disposed || gateway.snapshot.client !== client ? [] : (result.checkpoints ?? []); + }; + + const branchCheckpoint = async ( + key: string, + checkpointId: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + throw new Error("Session checkpoint operation requires an active Gateway connection"); + } + const result = await branchSessionCheckpoint(client, key, checkpointId, options); + if (disposed || gateway.snapshot.client !== client) { + throw new Error("Session checkpoint operation completed on a replaced Gateway client"); + } + await refresh({ + agentId: options.agentId ?? state.agentId ?? undefined, + force: true, + }); + return result; + }; + + const restoreCheckpoint = async ( + key: string, + checkpointId: string, + options: { agentId?: string | null } = {}, + ): Promise => { + const client = gateway.snapshot.client; + if (!client || !gateway.snapshot.connected || disposed) { + throw new Error("Session checkpoint operation requires an active Gateway connection"); + } + const result = await restoreSessionCheckpoint(client, key, checkpointId, options); + if (disposed || gateway.snapshot.client !== client) { + throw new Error("Session checkpoint operation completed on a replaced Gateway client"); + } + await refresh({ + agentId: options.agentId ?? state.agentId ?? undefined, + force: true, + }); + return result; + }; + + const stopGateway = gateway.subscribe((next) => { + if (!next.connected || !next.client) { + subscribedClient = null; + publish({ + result: null, + agentId: null, + modelOverrides: state.modelOverrides, + loading: false, + error: null, + deletedSessions: [], + }); + return; + } + if (subscribedClient !== next.client) { + const client = next.client; + subscribedClient = client; + void (async () => { + try { + await subscribeSessionGateway(client); + } catch (error) { + if (!disposed && gateway.snapshot.client === client) { + publish({ ...state, error: String(error) }); + } + } finally { + if (!disposed && gateway.snapshot.client === client) { + const sessionKey = gateway.snapshot.sessionKey?.trim(); + await refresh({ + ...(sessionKey ? scopedAgentListParamsForSession(gateway.snapshot, sessionKey) : {}), + backgroundHydrate: true, + force: true, + }); + } + } + })(); + return; + } + void refresh(); + }); + const stopEvents = gateway.subscribeEvents((event) => { + if (isSessionEvent(event)) { + if (!canReconcileSessionEvent(lastListOptions)) { + void refresh({ ...lastListOptions, force: true }); + return; + } + const reconciled = reconcileSessionChanged(state.result, event.payload, { + resultAgentId: state.agentId, + showArchived: lastListOptions.showArchived, + }); + if (reconciled.applied) { + if (reconciled.result !== state.result || reconciled.deletedKey) { + publish({ + ...state, + result: reconciled.result, + error: null, + deletedSessions: reconciled.deletedKey + ? [{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined }] + : [], + }); + } + return; + } + void refresh({ ...lastListOptions, force: true }); + } + }); + + return { + get state() { + return state; + }, + list: requestList, + reconcile, + reconcileChanged, + refresh, + create, + patch, + setModelOverride, + delete: remove, + deleteMany: removeMany, + reset, + compact, + steer, + listFiles, + getFile, + subscribeMessages, + unsubscribeMessages, + listCheckpoints, + branchCheckpoint, + restoreCheckpoint, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + disposed = true; + stopGateway(); + stopEvents(); + listeners.clear(); + inFlight = null; + queuedRefresh = null; + }, + }; +} diff --git a/ui/src/lib/sessions/navigation.test.ts b/ui/src/lib/sessions/navigation.test.ts new file mode 100644 index 000000000000..85b4d109f738 --- /dev/null +++ b/ui/src/lib/sessions/navigation.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import { resolveSessionNavigation } from "./navigation.ts"; + +function sessionsResult(sessions: GatewaySessionRow[]): SessionsListResult { + return { + ts: 1, + path: "(multiple)", + count: sessions.length, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions, + }; +} + +describe("resolveSessionNavigation", () => { + it("pins the selected session ahead of the nine most recent rows when the list omits it", () => { + const navigation = resolveSessionNavigation({ + result: sessionsResult( + Array.from({ length: 11 }, (_, index) => ({ + key: `agent:main:recent-${index}`, + kind: "direct", + updatedAt: 100 - index, + })), + ), + resultAgentId: "main", + sessionKey: "agent:main:oldest", + }); + + expect(navigation.recentSessions).toHaveLength(10); + expect(navigation.recentSessions[0]).toMatchObject({ + key: "agent:main:oldest", + kind: "direct", + updatedAt: null, + }); + expect(navigation.recentSessions.slice(1).map((row) => row.key)).toEqual( + Array.from({ length: 9 }, (_, index) => `agent:main:recent-${index}`), + ); + }); +}); diff --git a/ui/src/lib/sessions/navigation.ts b/ui/src/lib/sessions/navigation.ts new file mode 100644 index 000000000000..db7fb78ab03c --- /dev/null +++ b/ui/src/lib/sessions/navigation.ts @@ -0,0 +1,260 @@ +import type { GatewayHelloOk } from "../../api/gateway.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import { isCronSessionKey } from "../session-display.ts"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "../string-coerce.ts"; +import { + areUiSessionKeysEquivalent, + isUiGlobalSessionKey, + isSessionKeyTiedToAgent, + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, + resolveUiDefaultAgentId, + resolveUiGlobalAliasAgentId, + resolveUiKnownSelectedGlobalAgentId, + resolveUiSelectedGlobalAgentId, + uiSessionRowMatchesSelectedChat, +} from "./session-key.ts"; +export type SessionNavigationInput = { + result: SessionsListResult | null; + resultAgentId?: string | null; + sessionKey: string; + assistantAgentId?: string | null; + hello?: GatewayHelloOk | null; +}; + +export type SessionNavigation = { + currentSessionKey: string; + selectedAgentId: string; + defaultAgentId: string; + selectedSession?: GatewaySessionRow; + recentSessions: GatewaySessionRow[]; +}; + +export type SessionScopeHost = { + assistantAgentId?: string | null; + agentsList?: { + defaultId?: string | null; + mainKey?: string | null; + agents?: Array<{ id: string }>; + } | null; + hello: GatewayHelloOk | null; +}; + +export type SessionScopeHostWithKey = SessionScopeHost & { + sessionKey: string; +}; + +export type SessionRefreshTarget = { sessionKey: string; agentId?: string }; + +type SessionDefaults = { + defaultAgentId?: string | null; + mainKey?: string | null; + mainSessionKey?: string | null; +}; + +function readSessionDefaults( + host: Pick, +): SessionDefaults | undefined { + const snapshot = host.hello?.snapshot; + if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) { + return undefined; + } + const defaults = snapshot.sessionDefaults; + return defaults && typeof defaults === "object" ? (defaults as SessionDefaults) : undefined; +} + +export function resolveSessionKey( + sessionKey: string | undefined | null, + hello: GatewayHelloOk | null | undefined, +): string { + const raw = normalizeOptionalString(sessionKey) ?? ""; + const defaults = readSessionDefaults({ hello }); + const mainSessionKey = normalizeOptionalString(defaults?.mainSessionKey); + if (!mainSessionKey) { + return raw; + } + if (!raw) { + return mainSessionKey; + } + const mainKey = normalizeOptionalLowercaseString(defaults?.mainKey) ?? "main"; + const defaultAgentId = normalizeOptionalString(defaults?.defaultAgentId); + const isAlias = + raw === "main" || + raw === mainKey || + (defaultAgentId && + (raw === `agent:${defaultAgentId}:main` || raw === `agent:${defaultAgentId}:${mainKey}`)); + return isAlias ? mainSessionKey : raw; +} + +function readHelloDefaultAgentId(host: Pick): string | undefined { + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: { defaultAgentId?: string } } + | undefined; + return snapshot?.sessionDefaults?.defaultAgentId?.trim() || undefined; +} + +export function scopedAgentIdForSession( + host: SessionScopeHost, + sessionKey: string | undefined | null, +): string | undefined { + return isUiGlobalSessionKey(sessionKey) + ? resolveUiKnownSelectedGlobalAgentId(host) + : (resolveUiGlobalAliasAgentId(host, sessionKey) ?? undefined); +} + +export function scopedAgentParamsForSession( + host: SessionScopeHost, + sessionKey: string, +): { agentId?: string } { + const agentId = isUiGlobalSessionKey(sessionKey) + ? resolveUiKnownSelectedGlobalAgentId(host) + : resolveUiGlobalAliasAgentId(host, sessionKey); + return agentId ? { agentId: normalizeAgentId(agentId) } : {}; +} + +export function scopedAgentListParamsForSession( + host: SessionScopeHost, + sessionKey: string, +): { agentId?: string } { + const parsed = parseAgentSessionKey(sessionKey); + const normalizedSessionKey = normalizeLowercaseStringOrEmpty(sessionKey); + const agentId = + parsed?.agentId ?? + (normalizedSessionKey === "global" + ? resolveUiKnownSelectedGlobalAgentId(host) + : normalizedSessionKey === "unknown" + ? undefined + : resolveUiDefaultAgentId(host)); + return agentId ? { agentId: normalizeAgentId(agentId) } : {}; +} + +export function scopedAgentListParamsForRefreshTarget( + host: SessionScopeHost, + target: SessionRefreshTarget, +): { agentId?: string } { + const agentId = + normalizeOptionalString(target.agentId) ?? + scopedAgentListParamsForSession(host, target.sessionKey).agentId; + return agentId ? { agentId } : {}; +} + +export function visibleSessionMatches( + host: SessionScopeHostWithKey, + sessionKey: string, + agentId: string | undefined, +): boolean { + if (host.sessionKey !== sessionKey) { + const hostAliasAgentId = resolveUiGlobalAliasAgentId(host, host.sessionKey); + if (!hostAliasAgentId || !isUiGlobalSessionKey(sessionKey)) { + return false; + } + const expectedAgentId = agentId ?? host.agentsList?.defaultId ?? readHelloDefaultAgentId(host); + return expectedAgentId + ? normalizeAgentId(hostAliasAgentId) === normalizeAgentId(expectedAgentId) + : normalizeAgentId(hostAliasAgentId) === resolveUiDefaultAgentId(host); + } + if (!isUiGlobalSessionKey(sessionKey)) { + return true; + } + const selectedAgentId = resolveUiKnownSelectedGlobalAgentId(host); + const expectedAgentId = agentId + ? normalizeAgentId(agentId) + : host.agentsList?.defaultId + ? normalizeAgentId(host.agentsList.defaultId) + : readHelloDefaultAgentId(host); + return expectedAgentId + ? normalizeAgentId(selectedAgentId ?? "") === normalizeAgentId(expectedAgentId) + : selectedAgentId === undefined; +} + +export function filterSessionRows( + result: SessionsListResult, + options: { showArchived: boolean }, +): SessionsListResult { + const sessions = result.sessions.filter( + (row) => row.key && (row.archived === true) === options.showArchived, + ); + return { + ...result, + count: sessions.length, + sessions, + }; +} + +export function getVisibleSessionRows( + result: SessionsListResult | null, + options: { + currentSessionKey?: string; + agentId: string; + defaultAgentId: string; + filterByAgent?: boolean; + hideCron?: boolean; + }, +): GatewaySessionRow[] { + return (result?.sessions ?? []).filter((row) => { + if (row.key === options.currentSessionKey) { + return true; + } + return ( + !row.archived && + row.kind !== "global" && + row.kind !== "unknown" && + (options.hideCron === false || (row.kind !== "cron" && !isCronSessionKey(row.key))) && + !isSubagentSessionKey(row.key) && + !row.spawnedBy && + (!options.filterByAgent || + isSessionKeyTiedToAgent(row.key, options.agentId, options.defaultAgentId)) + ); + }); +} + +export function compareSessionRowsByUpdatedAt(a: GatewaySessionRow, b: GatewaySessionRow): number { + const pinnedDiff = (b.pinnedAt ?? 0) - (a.pinnedAt ?? 0); + return pinnedDiff !== 0 ? pinnedDiff : (b.updatedAt ?? 0) - (a.updatedAt ?? 0); +} + +export function resolveSessionNavigation(input: SessionNavigationInput): SessionNavigation { + const currentSessionKey = resolveSessionKey(input.sessionKey, input.hello); + const defaultAgentId = resolveUiSelectedGlobalAgentId({ + assistantAgentId: input.assistantAgentId, + hello: input.hello, + }); + const selectedAgentId = parseAgentSessionKey(currentSessionKey)?.agentId ?? defaultAgentId; + const shouldFilterByAgent = currentSessionKey.toLowerCase() !== "unknown"; + const resultScopeMatches = + normalizeOptionalString(input.resultAgentId) !== undefined && + normalizeAgentId(input.resultAgentId) === normalizeAgentId(selectedAgentId); + const matchesCurrentSession = (row: GatewaySessionRow) => + areUiSessionKeysEquivalent(row.key, currentSessionKey) || + (resultScopeMatches && uiSessionRowMatchesSelectedChat(input, row.key, currentSessionKey)); + const selectedSession = input.result?.sessions.find(matchesCurrentSession); + const activeSession = + currentSessionKey && currentSessionKey.toLowerCase() !== "unknown" + ? { ...(selectedSession ?? { kind: "direct", updatedAt: null }), key: currentSessionKey } + : undefined; + const recentSessions = getVisibleSessionRows(input.result, { + currentSessionKey: currentSessionKey || undefined, + agentId: selectedAgentId, + defaultAgentId, + filterByAgent: shouldFilterByAgent, + }) + .filter((row) => !matchesCurrentSession(row)) + .toSorted(compareSessionRowsByUpdatedAt) + .slice(0, 9); + return { + currentSessionKey, + selectedAgentId, + defaultAgentId, + selectedSession: activeSession, + recentSessions: activeSession ? [activeSession, ...recentSessions] : recentSessions, + }; +} + +export function searchForSession(sessionKey: string): string { + return `?session=${encodeURIComponent(sessionKey)}`; +} diff --git a/ui/src/lib/sessions/reconcile.ts b/ui/src/lib/sessions/reconcile.ts new file mode 100644 index 000000000000..f141c9bc7b78 --- /dev/null +++ b/ui/src/lib/sessions/reconcile.ts @@ -0,0 +1,420 @@ +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import { isSessionRunActive } from "../session-run-state.ts"; +import { compareSessionRowsByUpdatedAt } from "./navigation.ts"; +import { + areUiSessionKeysEquivalent, + isUiGlobalSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "./session-key.ts"; + +export type SessionReconcileOptions = { + resultAgentId?: string | null; + selectedGlobalAgentId?: string | null; + showArchived?: boolean; +}; + +export type SessionChangedResult = { + applied: boolean; + key?: string; + agentId?: string | null; + runId?: string | null; + clientRunId?: string | null; + hasActiveRun?: boolean | null; + isChatTurn?: boolean; + row?: GatewaySessionRow; + deletedKey?: string; + result: SessionsListResult | null; +}; + +export type SessionChangedEventInfo = { + key: string; + agentId: string | null; + runId: string | null; + clientRunId: string | null; + hasActiveRun: boolean | null; + archived: boolean | null; + isChatTurn: boolean; +}; + +type ThinkingMetadataCarrier = { + modelProvider?: string | null; + model?: string | null; + thinkingLevels?: Array<{ id: string; label: string }>; + thinkingOptions?: string[]; + thinkingDefault?: string; +}; + +function sanitizeSessionRow(row: GatewaySessionRow): GatewaySessionRow { + const next: Partial = {}; + for (const [key, value] of Object.entries(row) as Array<[keyof GatewaySessionRow, unknown]>) { + if (value === undefined) { + continue; + } + if (key === "totalTokensFresh" && value === false && row.totalTokens === undefined) { + continue; + } + next[key] = value as never; + } + return next as GatewaySessionRow; +} + +function isPersistedSessionRow(row: GatewaySessionRow): boolean { + const sessionId = typeof row.sessionId === "string" ? row.sessionId.trim() : ""; + return Boolean(sessionId || typeof row.updatedAt === "number"); +} + +function thinkingMetadataModelMatches( + incoming: ThinkingMetadataCarrier, + existing: ThinkingMetadataCarrier, +): boolean { + return !( + (incoming.modelProvider && + existing.modelProvider && + incoming.modelProvider !== existing.modelProvider) || + (incoming.model && existing.model && incoming.model !== existing.model) + ); +} + +function preserveRicherThinkingMetadata( + incoming: T, + existing: ThinkingMetadataCarrier | undefined, +): T { + if (existing && !thinkingMetadataModelMatches(incoming, existing)) { + return incoming; + } + const existingLevels = existing?.thinkingLevels; + if (!existingLevels?.length || (incoming.thinkingLevels?.length ?? 0) >= existingLevels.length) { + return incoming; + } + return { + ...incoming, + thinkingLevels: existingLevels, + ...(existing?.thinkingOptions ? { thinkingOptions: existing.thinkingOptions } : {}), + ...(incoming.thinkingDefault === undefined && existing?.thinkingDefault !== undefined + ? { thinkingDefault: existing.thinkingDefault } + : {}), + }; +} + +function isStaleForActiveSession( + incoming: GatewaySessionRow, + existing: GatewaySessionRow | undefined, +): boolean { + if (!existing || !isSessionRunActive(existing) || isSessionRunActive(incoming)) { + return false; + } + const incomingUpdatedAt = incoming.updatedAt ?? 0; + return ( + (existing.updatedAt ?? 0) >= incomingUpdatedAt || + (typeof existing.startedAt === "number" && existing.startedAt >= incomingUpdatedAt) + ); +} + +function matchesExistingSession( + existing: GatewaySessionRow, + incoming: GatewaySessionRow, + selectedGlobalAgentId: string | null, +): boolean { + if (areUiSessionKeysEquivalent(existing.key, incoming.key)) { + return true; + } + if (!isUiGlobalSessionKey(incoming.key) || existing.kind !== "global") { + return false; + } + const parsed = parseAgentSessionKey(existing.key); + return ( + parsed?.agentId !== undefined && + normalizeAgentId(parsed.agentId) === normalizeAgentId(selectedGlobalAgentId ?? "") + ); +} + +function sessionAgentId( + row: GatewaySessionRow, + selectedGlobalAgentId: string | null, +): string | null { + const parsed = parseAgentSessionKey(row.key); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId); + } + if (row.kind === "global" && selectedGlobalAgentId?.trim()) { + return normalizeAgentId(selectedGlobalAgentId); + } + return null; +} + +function recordValue(record: Record, key: string): unknown { + return Object.hasOwn(record, key) ? record[key] : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function recordOrNull(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +type ParsedSessionChangedEvent = SessionChangedEventInfo & { + event: Record; + source: Record; + reason: string | null; +}; + +function parseSessionChangedEvent(payload: unknown): ParsedSessionChangedEvent | null { + const event = recordOrNull(payload); + if (!event) { + return null; + } + const source = recordOrNull(event.session) ?? event; + const key = + stringValue(recordValue(source, "key")) ?? stringValue(recordValue(event, "sessionKey")); + if (!key) { + return null; + } + const reason = + stringValue(recordValue(event, "reason")) ?? stringValue(recordValue(source, "reason")) ?? null; + const phase = + stringValue(recordValue(event, "phase")) ?? stringValue(recordValue(source, "phase")); + const hasActiveRun = + typeof recordValue(source, "hasActiveRun") === "boolean" + ? (recordValue(source, "hasActiveRun") as boolean) + : typeof recordValue(event, "hasActiveRun") === "boolean" + ? (recordValue(event, "hasActiveRun") as boolean) + : null; + return { + event, + source, + key, + reason, + agentId: stringValue(recordValue(event, "agentId")) ?? null, + runId: + stringValue(recordValue(event, "runId")) ?? stringValue(recordValue(source, "runId")) ?? null, + clientRunId: + stringValue(recordValue(event, "clientRunId")) ?? + stringValue(recordValue(source, "clientRunId")) ?? + null, + hasActiveRun, + archived: + typeof recordValue(source, "archived") === "boolean" + ? (recordValue(source, "archived") as boolean) + : null, + isChatTurn: + phase === "start" || + phase === "message" || + phase === "end" || + phase === "error" || + reason === "send" || + reason === "steer", + }; +} + +export function readSessionChangedEvent(payload: unknown): SessionChangedEventInfo | null { + const parsed = parseSessionChangedEvent(payload); + if (!parsed) { + return null; + } + return { + key: parsed.key, + agentId: parsed.agentId, + runId: parsed.runId, + clientRunId: parsed.clientRunId, + hasActiveRun: parsed.hasActiveRun, + archived: parsed.archived, + isChatTurn: parsed.isChatTurn, + }; +} + +export function reconcileSessionChanged( + result: SessionsListResult | null, + payload: unknown, + options: SessionReconcileOptions = {}, +): SessionChangedResult { + const parsed = parseSessionChangedEvent(payload); + if (!parsed) { + return { applied: false, result }; + } + const { event, source, key, reason } = parsed; + if (reason === "delete" && !result) { + return { + applied: true, + key, + agentId: parsed.agentId, + deletedKey: key, + result, + }; + } + if (!result) { + return { applied: false, result }; + } + const selectedGlobalAgentId = parsed.agentId ?? options.selectedGlobalAgentId ?? null; + const existing = result.sessions.find((candidate) => + matchesExistingSession( + candidate, + { key, kind: "global", updatedAt: null }, + selectedGlobalAgentId, + ), + ); + + if (reason === "delete") { + if (!existing) { + return { applied: true, result, key, agentId: parsed.agentId, deletedKey: key }; + } + const sessions = result.sessions.filter((candidate) => candidate !== existing); + return { + applied: true, + key, + agentId: parsed.agentId, + result: { + ...result, + count: sessions.length, + sessions, + }, + deletedKey: existing.key, + }; + } + + const { + agentId: _agentId, + clientRunId: _clientRunId, + compacted: _compacted, + key: _key, + phase: _phase, + reason: _reason, + runId: _runId, + session: _session, + sessionKey: _sessionKey, + ts: _ts, + ...rowFields + } = source; + const kind = + rowFields.kind === "cron" || + rowFields.kind === "direct" || + rowFields.kind === "group" || + rowFields.kind === "global" || + rowFields.kind === "unknown" + ? rowFields.kind + : existing?.kind; + const updatedAt = + typeof rowFields.updatedAt === "number" ? rowFields.updatedAt : existing?.updatedAt; + const sessionId = stringValue(rowFields.sessionId) ?? existing?.sessionId; + if (!kind || (!existing && sessionId === undefined && typeof updatedAt !== "number")) { + return { applied: false, result }; + } + const row = { + ...existing, + ...rowFields, + key: existing?.key ?? key, + kind, + updatedAt: updatedAt ?? null, + ...(sessionId ? { sessionId } : {}), + } as GatewaySessionRow; + if (rowFields.archivedAt === null) { + delete row.archivedAt; + } + if (rowFields.pinnedAt === null) { + delete row.pinnedAt; + } + const next = reconcileSessionHistory(result, row, undefined, { + ...options, + selectedGlobalAgentId, + }); + if (!next) { + return { applied: false, result }; + } + const eventTs = typeof event.ts === "number" && Number.isFinite(event.ts) ? event.ts : null; + const reconciledResult = eventTs === null ? next : { ...next, ts: Math.max(next.ts, eventTs) }; + const reconciledRow = reconciledResult.sessions.find((candidate) => + matchesExistingSession( + candidate, + { key, kind: "global", updatedAt: null }, + selectedGlobalAgentId, + ), + ); + return { + applied: true, + key, + agentId: parsed.agentId, + runId: parsed.runId, + clientRunId: parsed.clientRunId, + hasActiveRun: parsed.hasActiveRun, + isChatTurn: parsed.isChatTurn, + row: reconciledRow, + result: reconciledResult, + }; +} + +export function reconcileSessionHistory( + result: SessionsListResult | null, + row: GatewaySessionRow | undefined, + defaults: SessionsListResult["defaults"] | undefined, + options: SessionReconcileOptions = {}, +): SessionsListResult | null { + if (!row?.key) { + return result; + } + const session = sanitizeSessionRow(row); + const showArchived = options.showArchived === true; + const selectedGlobalAgentId = options.selectedGlobalAgentId ?? null; + const resultAgentId = options.resultAgentId?.trim() + ? normalizeAgentId(options.resultAgentId) + : null; + const incomingAgentId = sessionAgentId(session, selectedGlobalAgentId); + const isOutsideResultScope = + resultAgentId !== null && incomingAgentId !== null && incomingAgentId !== resultAgentId; + if (!result) { + if ((!isPersistedSessionRow(session) || isOutsideResultScope) && !defaults) { + return null; + } + const sessions = + isPersistedSessionRow(session) && + !isOutsideResultScope && + (session.archived === true) === showArchived + ? [session] + : []; + return { + ts: Date.now(), + path: "", + count: sessions.length, + defaults: defaults ?? { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions, + }; + } + + const existing = result.sessions.find((candidate) => + matchesExistingSession(candidate, session, selectedGlobalAgentId), + ); + const nextDefaults = defaults + ? preserveRicherThinkingMetadata(defaults, result.defaults) + : result.defaults; + if (isOutsideResultScope || (!existing && !isPersistedSessionRow(session))) { + return defaults ? { ...result, defaults: nextDefaults } : result; + } + const visibleKey = existing?.key ?? session.key; + const visibleSession = preserveRicherThinkingMetadata( + visibleKey === session.key ? session : { ...session, key: visibleKey }, + existing, + ); + if (isStaleForActiveSession(visibleSession, existing)) { + return { ...result, defaults: nextDefaults }; + } + const sessions = + (visibleSession.archived === true) === showArchived + ? [ + ...result.sessions.filter((candidate) => candidate.key !== visibleKey), + visibleSession, + ].toSorted(compareSessionRowsByUpdatedAt) + : result.sessions.filter((candidate) => candidate.key !== visibleKey); + return { + ...result, + defaults: nextDefaults, + count: sessions.length, + sessions, + }; +} diff --git a/ui/src/ui/session-key.ts b/ui/src/lib/sessions/session-key.ts similarity index 68% rename from ui/src/ui/session-key.ts rename to ui/src/lib/sessions/session-key.ts index 33bcf53b30cd..c4d139e9ab40 100644 --- a/ui/src/ui/session-key.ts +++ b/ui/src/lib/sessions/session-key.ts @@ -3,7 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, -} from "./string-coerce.ts"; +} from "../string-coerce.ts"; export type ParsedAgentSessionKey = { agentId: string; @@ -19,6 +19,12 @@ export type UiSessionDefaultsHost = { hello?: { snapshot?: unknown } | null; }; +type UiSessionDefaults = { + defaultAgentId?: string | null; + mainKey?: string | null; + mainSessionKey?: string | null; +}; + const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; const INVALID_CHARS_RE = /[^a-z0-9_-]+/g; const LEADING_DASH_RE = /^-+/; @@ -90,15 +96,13 @@ function normalizeSessionKeyForUiComparison(sessionKey: string | undefined | nul function readSessionDefaults( host: Pick, -): { defaultAgentId?: string | null; mainKey?: string | null } | undefined { +): UiSessionDefaults | undefined { const snapshot = host.hello?.snapshot; if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) { return undefined; } const defaults = snapshot.sessionDefaults; - return defaults && typeof defaults === "object" - ? (defaults as { defaultAgentId?: string | null; mainKey?: string | null }) - : undefined; + return defaults && typeof defaults === "object" ? (defaults as UiSessionDefaults) : undefined; } export function resolveUiConfiguredMainKey( @@ -157,6 +161,109 @@ export function isUiGlobalSessionKey(sessionKey: string | undefined | null): boo return normalizeLowercaseStringOrEmpty(sessionKey) === "global"; } +function resolveUiMainAliasAgentId( + host: Pick, + sessionKey: string | undefined | null, +): string | null { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return null; + } + const rest = normalizeLowercaseStringOrEmpty(parsed.rest); + const mainKey = resolveUiConfiguredMainKey(host); + return rest === DEFAULT_MAIN_KEY || rest === mainKey ? normalizeAgentId(parsed.agentId) : null; +} + +function resolveUiCanonicalMainSessionKey( + host: Pick, +): string { + const defaults = readSessionDefaults(host); + return ( + normalizeOptionalString(defaults?.mainSessionKey) ?? + buildAgentMainSessionKey({ + agentId: resolveUiDefaultAgentId(host), + mainKey: resolveUiConfiguredMainKey(host), + }) + ); +} + +function normalizeUiSessionEventKey( + host: Pick, + sessionKey: string | undefined | null, +): string | null { + const raw = normalizeOptionalString(sessionKey); + if (!raw) { + return null; + } + const mainKey = resolveUiConfiguredMainKey(host); + const defaultAgentId = resolveUiDefaultAgentId(host); + const canonicalMain = resolveUiCanonicalMainSessionKey(host); + const aliases = new Set( + [ + DEFAULT_MAIN_KEY, + mainKey, + canonicalMain, + buildAgentMainSessionKey({ agentId: defaultAgentId, mainKey: DEFAULT_MAIN_KEY }), + buildAgentMainSessionKey({ agentId: defaultAgentId, mainKey }), + ] + .filter((value): value is string => Boolean(value)) + .map(normalizeLowercaseStringOrEmpty), + ); + const normalized = normalizeLowercaseStringOrEmpty(raw); + return aliases.has(normalized) ? normalizeLowercaseStringOrEmpty(canonicalMain) : normalized; +} + +export function uiSessionEventMatches( + host: UiSessionDefaultsHost & { sessionKey: string }, + eventSessionKey: string | undefined | null, + eventAgentId?: string | null, +): boolean { + const eventKey = normalizeOptionalString(eventSessionKey); + if (!eventKey) { + return true; + } + const keysMatch = + normalizeUiSessionEventKey(host, eventKey) === + normalizeUiSessionEventKey(host, host.sessionKey); + const selectedAliasAgentId = resolveUiMainAliasAgentId(host, host.sessionKey); + const globalAliasMatches = + selectedAliasAgentId !== null && + isUiGlobalSessionKey(eventKey) && + selectedAliasAgentId === normalizeAgentId(eventAgentId ?? resolveUiDefaultAgentId(host)); + if (!keysMatch && !globalAliasMatches) { + return false; + } + if (!isUiGlobalSessionKey(host.sessionKey) || !isUiGlobalSessionKey(eventKey)) { + return true; + } + const selectedAgentId = resolveUiSelectedGlobalAgentId(host); + const normalizedEventAgentId = normalizeOptionalString(eventAgentId); + return normalizedEventAgentId + ? normalizeAgentId(normalizedEventAgentId) === selectedAgentId + : selectedAgentId === resolveUiDefaultAgentId(host); +} + +export function isUiSelectedGlobalSessionKey(sessionKey: string | undefined | null): boolean { + if (isUiGlobalSessionKey(sessionKey)) { + return true; + } + const parsed = parseAgentSessionKey(sessionKey); + return normalizeLowercaseStringOrEmpty(parsed?.rest) === DEFAULT_MAIN_KEY; +} + +export function resolveUiSelectedSessionAgentId( + host: Pick & { + sessionKey?: string | null; + }, + sessionKey: string | undefined | null = host.sessionKey, +): string | undefined { + const parsed = parseAgentSessionKey(sessionKey); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId); + } + return resolveUiKnownSelectedGlobalAgentId(host); +} + export function uiSessionRowMatchesSelectedChat( host: Pick, rowKey: string | undefined | null, diff --git a/ui/src/lib/sessions/session-options.ts b/ui/src/lib/sessions/session-options.ts new file mode 100644 index 000000000000..64dcc7e28974 --- /dev/null +++ b/ui/src/lib/sessions/session-options.ts @@ -0,0 +1,342 @@ +import type { SessionsListResult } from "../../api/types.ts"; +import { isCronSessionKey, resolveSessionDisplayName } from "../session-display.ts"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; +import { getVisibleSessionRows } from "./index.ts"; +import { + buildAgentMainSessionKey, + isSessionKeyTiedToAgent, + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "./session-key.ts"; + +type SessionAgentOptionsState = { + agentsList?: { + defaultId?: string | null; + agents?: Array<{ + id: string; + name?: string | null; + identity?: { name?: string | null } | null; + }> | null; + } | null; + chatAgentSessionRowsByAgent?: Record; + sessionsHideCron?: boolean; + sessionsResult?: SessionsListResult | null; + sessionsResultAgentId?: string | null; + sessionKey: string; +}; + +export type SessionOptionGroup = { + id: string; + label: string; + options: Array<{ + key: string; + label: string; + scopeLabel: string; + title: string; + }>; +}; + +export type SessionAgentFilterOption = { + id: string; + label: string; +}; + +export function resolveSessionAgentFilterId( + state: SessionAgentOptionsState, + sessionKey: string, +): string { + const parsed = parseAgentSessionKey(sessionKey); + return normalizeAgentId(parsed?.agentId ?? state.agentsList?.defaultId ?? "main"); +} + +function resolvePreferredSessionCandidateAgentId( + row: SessionsListResult["sessions"][number], + defaultAgentId: string, +): string | null { + if (row.kind === "global" || row.kind === "unknown" || isCronSessionKey(row.key)) { + return null; + } + if (isSubagentSessionKey(row.key) || row.spawnedBy) { + return null; + } + const parsed = parseAgentSessionKey(row.key); + return normalizeAgentId(parsed?.agentId ?? defaultAgentId); +} + +export function rememberSessionAgentRows( + state: SessionAgentOptionsState, + sessions: SessionsListResult | null, +): void { + if (!sessions) { + return; + } + const refreshedAgentId = normalizeOptionalString(state.sessionsResultAgentId); + const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); + const grouped = new Map(); + for (const row of sessions.sessions) { + const agentId = resolvePreferredSessionCandidateAgentId(row, defaultAgentId); + if (!agentId) { + continue; + } + grouped.set(agentId, [...(grouped.get(agentId) ?? []), row]); + } + if (grouped.size === 0 && !refreshedAgentId) { + return; + } + state.chatAgentSessionRowsByAgent ??= {}; + if (refreshedAgentId) { + state.chatAgentSessionRowsByAgent[refreshedAgentId] = grouped.get(refreshedAgentId) ?? []; + } + for (const [agentId, agentRows] of grouped) { + state.chatAgentSessionRowsByAgent[agentId] = agentRows; + } +} + +function rowsForPreferredAgentSession( + state: SessionAgentOptionsState, + normalizedAgentId: string, + defaultAgentId: string, +): SessionsListResult["sessions"] { + const byKey = new Map(); + for (const row of state.chatAgentSessionRowsByAgent?.[normalizedAgentId] ?? []) { + byKey.set(row.key, row); + } + for (const row of state.sessionsResult?.sessions ?? []) { + if (resolvePreferredSessionCandidateAgentId(row, defaultAgentId) === normalizedAgentId) { + byKey.set(row.key, row); + } + } + return [...byKey.values()]; +} + +export function resolvePreferredSessionForAgent( + state: SessionAgentOptionsState, + agentId: string, +): string { + const normalizedAgentId = normalizeAgentId(agentId); + if (resolveSessionAgentFilterId(state, state.sessionKey) === normalizedAgentId) { + return state.sessionKey; + } + const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); + const eligible = rowsForPreferredAgentSession(state, normalizedAgentId, defaultAgentId) + .filter((row) => { + if (!isSessionKeyTiedToAgent(row.key, normalizedAgentId, defaultAgentId)) { + return false; + } + return resolvePreferredSessionCandidateAgentId(row, defaultAgentId) === normalizedAgentId; + }) + .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + if (eligible[0]?.key) { + return eligible[0].key; + } + return buildAgentMainSessionKey({ agentId: normalizedAgentId }); +} + +export function resolveSessionAgentFilterOptions( + state: SessionAgentOptionsState, +): SessionAgentFilterOption[] { + const seen = new Set(); + const options: SessionAgentFilterOption[] = []; + const add = (agentId: string) => { + const normalized = normalizeAgentId(agentId); + if (seen.has(normalized)) { + return; + } + seen.add(normalized); + options.push({ + id: normalized, + label: resolveAgentGroupLabel(state, normalized), + }); + }; + + add(resolveSessionAgentFilterId(state, state.sessionKey)); + add(state.agentsList?.defaultId ?? "main"); + for (const agent of state.agentsList?.agents ?? []) { + add(agent.id); + } + for (const row of state.sessionsResult?.sessions ?? []) { + const parsed = parseAgentSessionKey(row.key); + if (parsed) { + add(parsed.agentId); + } + } + + return options; +} + +export function resolveSessionOptionGroups( + state: SessionAgentOptionsState, + sessionKey: string, + sessions: SessionsListResult | null, +): SessionOptionGroup[] { + const rows = sessions?.sessions ?? []; + const hideCron = state.sessionsHideCron ?? true; + const activeAgentId = resolveSessionAgentFilterId(state, sessionKey); + const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); + const byKey = new Map(); + for (const row of rows) { + byKey.set(row.key, row); + } + + const seenKeys = new Set(); + const groups = new Map(); + const ensureGroup = (groupId: string, label: string): SessionOptionGroup => { + const existing = groups.get(groupId); + if (existing) { + return existing; + } + const created: SessionOptionGroup = { + id: groupId, + label, + options: [], + }; + groups.set(groupId, created); + return created; + }; + + const addOption = (key: string) => { + if (!key || seenKeys.has(key)) { + return; + } + seenKeys.add(key); + const row = byKey.get(key); + const parsed = parseAgentSessionKey(key); + const group = parsed + ? ensureGroup( + `agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`, + resolveAgentGroupLabel(state, parsed.agentId), + ) + : ensureGroup("other", "Other Sessions"); + const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key; + group.options.push({ + key, + label: resolveSessionScopedOptionLabel(key, row, parsed?.rest), + scopeLabel, + title: key, + }); + }; + + for (const row of getVisibleSessionRows(sessions, { + currentSessionKey: sessionKey, + agentId: activeAgentId, + defaultAgentId, + filterByAgent: true, + hideCron, + })) { + addOption(row.key); + } + if (byKey.has(sessionKey)) { + addOption(sessionKey); + } else if (sessionKey) { + addOption(sessionKey); + } + + disambiguateSessionOptionLabels(groups); + return Array.from(groups.values()); +} + +function disambiguateSessionOptionLabels(groups: Map) { + for (const group of groups.values()) { + const counts = new Map(); + for (const option of group.options) { + counts.set(option.label, (counts.get(option.label) ?? 0) + 1); + } + for (const option of group.options) { + if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) { + option.label = `${option.label} · ${option.scopeLabel}`; + } + } + } + + const allOptions = Array.from(groups.values()).flatMap((group) => + group.options.map((option) => ({ groupLabel: group.label, option })), + ); + const labels = new Map(allOptions.map(({ option }) => [option, option.label])); + const countAssignedLabels = () => { + const counts = new Map(); + for (const { option } of allOptions) { + const label = labels.get(option) ?? option.label; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return counts; + }; + const labelIncludesScopeLabel = (label: string, scopeLabel: string) => { + const trimmedScope = scopeLabel.trim(); + if (!trimmedScope) { + return false; + } + return ( + label === trimmedScope || + label.endsWith(` · ${trimmedScope}`) || + label.endsWith(` / ${trimmedScope}`) + ); + }; + + const globalCounts = countAssignedLabels(); + for (const { groupLabel, option } of allOptions) { + const currentLabel = labels.get(option) ?? option.label; + if ((globalCounts.get(currentLabel) ?? 0) <= 1) { + continue; + } + const scopedPrefix = `${groupLabel} / `; + if (currentLabel.startsWith(scopedPrefix)) { + continue; + } + labels.set(option, `${groupLabel} / ${currentLabel}`); + } + + const scopedCounts = countAssignedLabels(); + for (const { option } of allOptions) { + const currentLabel = labels.get(option) ?? option.label; + if ((scopedCounts.get(currentLabel) ?? 0) <= 1) { + continue; + } + if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) { + continue; + } + labels.set(option, `${currentLabel} · ${option.scopeLabel}`); + } + + const finalCounts = countAssignedLabels(); + for (const { option } of allOptions) { + const currentLabel = labels.get(option) ?? option.label; + if ((finalCounts.get(currentLabel) ?? 0) <= 1) { + continue; + } + labels.set(option, `${currentLabel} · ${option.key}`); + } + + for (const { option } of allOptions) { + option.label = labels.get(option) ?? option.label; + } +} + +function resolveAgentGroupLabel(state: SessionAgentOptionsState, agentIdRaw: string): string { + const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw); + const agent = (state.agentsList?.agents ?? []).find( + (entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized, + ); + const name = + normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? ""; + return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw; +} + +function resolveSessionScopedOptionLabel( + key: string, + row?: SessionsListResult["sessions"][number], + rest?: string, +) { + const base = normalizeOptionalString(rest) ?? key; + if (!row) { + return base; + } + + const label = normalizeOptionalString(row.label) ?? ""; + const displayName = normalizeOptionalString(row.displayName) ?? ""; + if ((label && label !== key) || (displayName && displayName !== key)) { + return resolveSessionDisplayName(key, row); + } + + return base; +} diff --git a/ui/src/lib/sessions/usage.ts b/ui/src/lib/sessions/usage.ts new file mode 100644 index 000000000000..eae33ac552ff --- /dev/null +++ b/ui/src/lib/sessions/usage.ts @@ -0,0 +1,72 @@ +import type { SessionUsageTimeSeries } from "../../../../src/shared/session-usage-timeseries-types.js"; +import type { SessionsUsageResult } from "../../../../src/shared/usage-types.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; + +type SessionRequestClient = Pick; + +export type SessionUsageQuery = { + startDate: string; + endDate: string; + scope: "instance" | "family"; + timeZone: "local" | "utc"; + agentId?: string; +}; + +function formatUtcOffset(timezoneOffsetMinutes: number): string { + const offsetFromUtcMinutes = -timezoneOffsetMinutes; + const sign = offsetFromUtcMinutes >= 0 ? "+" : "-"; + const absMinutes = Math.abs(offsetFromUtcMinutes); + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + return minutes === 0 + ? `UTC${sign}${hours}` + : `UTC${sign}${hours}:${minutes.toString().padStart(2, "0")}`; +} + +export function buildSessionUsageDateParams(timeZone: "local" | "utc") { + return timeZone === "utc" + ? { mode: "utc" } + : { + mode: "specific", + utcOffset: formatUtcOffset(new Date().getTimezoneOffset()), + }; +} + +function buildSessionUsageParams(query: SessionUsageQuery): Record { + return { + startDate: query.startDate, + endDate: query.endDate, + ...(query.agentId ? { agentId: query.agentId } : { agentScope: "all" }), + ...buildSessionUsageDateParams(query.timeZone), + groupBy: query.scope, + includeHistorical: query.scope === "family", + limit: 1000, + includeContextWeight: true, + }; +} + +export function requestSessionUsage( + client: SessionRequestClient, + query: SessionUsageQuery, +): Promise { + return client.request("sessions.usage", buildSessionUsageParams(query)); +} + +export function requestSessionUsageTimeSeries( + client: SessionRequestClient, + key: string, +): Promise { + return client + .request("sessions.usage.timeseries", { key }) + .then((result) => result ?? null); +} + +export function requestSessionUsageLogs( + client: SessionRequestClient, + key: string, +): Promise<{ logs?: unknown }> { + return client.request<{ logs?: unknown }>("sessions.usage.logs", { + key, + limit: 1000, + }); +} diff --git a/ui/src/lib/skill-workshop/index.ts b/ui/src/lib/skill-workshop/index.ts new file mode 100644 index 000000000000..d14269b8b35c --- /dev/null +++ b/ui/src/lib/skill-workshop/index.ts @@ -0,0 +1,69 @@ +export type SkillWorkshopProposalStatus = + | "pending" + | "applied" + | "rejected" + | "quarantined" + | "stale"; + +export type SkillWorkshopFile = { + path: string; + size: string; + contents: string; +}; + +export type SkillWorkshopProposal = { + key: string; + slug: string; + name: string; + oneLine: string; + body: string; + status: SkillWorkshopProposalStatus; + origin?: { + agentId?: string; + sessionKey?: string; + runId?: string; + messageId?: string; + }; + version: number; + createdAt: number; + updatedAt?: number; + recencyGroup: "today" | "yesterday" | "earlier"; + ageLabel: string; + supportFiles: SkillWorkshopFile[]; + isNew: boolean; +}; + +export type SkillWorkshopStatusFilter = "all" | SkillWorkshopProposalStatus; +export type SkillWorkshopAction = "apply" | "revise" | "reject"; +export type SkillWorkshopMode = "board" | "today"; + +export type SkillWorkshopActionBusy = { + key: string; + action: SkillWorkshopAction; +}; + +export type SkillWorkshopActionNotice = { + key: string; + label: string; + slug: string; +}; + +export function filterSkillWorkshopProposals( + proposals: SkillWorkshopProposal[], + statusFilter: SkillWorkshopStatusFilter, + query: string, +): SkillWorkshopProposal[] { + const q = query.trim().toLowerCase(); + return proposals.filter((p) => { + if (statusFilter !== "all" && p.status !== statusFilter) { + return false; + } + if (q) { + const hay = `${p.name} ${p.oneLine} ${p.slug}`.toLowerCase(); + if (!hay.includes(q)) { + return false; + } + } + return true; + }); +} diff --git a/ui/src/ui/views/skills-grouping.ts b/ui/src/lib/skills-grouping.ts similarity index 92% rename from ui/src/ui/views/skills-grouping.ts rename to ui/src/lib/skills-grouping.ts index 7211146a8514..fdd3026be4dd 100644 --- a/ui/src/ui/views/skills-grouping.ts +++ b/ui/src/lib/skills-grouping.ts @@ -1,5 +1,5 @@ -// Control UI view renders skills grouping screen content. -import type { SkillStatusEntry } from "../types.ts"; +// Shared pure skill grouping helper. +import type { SkillStatusEntry } from "../api/types.ts"; export type SkillGroup = { id: string; diff --git a/ui/src/ui/views/skills-shared.ts b/ui/src/lib/skills-shared.ts similarity index 92% rename from ui/src/ui/views/skills-shared.ts rename to ui/src/lib/skills-shared.ts index 088f3b91087e..65382d0f9209 100644 --- a/ui/src/ui/views/skills-shared.ts +++ b/ui/src/lib/skills-shared.ts @@ -1,6 +1,6 @@ -// Control UI view renders skills shared screen content. +// Shared skill status rendering and classification helpers. import { html, nothing } from "lit"; -import type { SkillStatusEntry } from "../types.ts"; +import type { SkillStatusEntry } from "../api/types.ts"; export function computeSkillMissing(skill: SkillStatusEntry): string[] { return [ diff --git a/ui/src/ui/controllers/skills.test.ts b/ui/src/lib/skills/index.test.ts similarity index 99% rename from ui/src/ui/controllers/skills.test.ts rename to ui/src/lib/skills/index.test.ts index de9c74d9b7c2..62dc22afb34c 100644 --- a/ui/src/ui/controllers/skills.test.ts +++ b/ui/src/lib/skills/index.test.ts @@ -13,7 +13,7 @@ import { setSkillsAgentId, updateSkillEnabled, type SkillsState, -} from "./skills.ts"; +} from "./index.ts"; type TestRequest = (method: string, payload?: unknown) => Promise; @@ -32,8 +32,6 @@ function createState(): { state: SkillsState; request: ReturnType; skillMessages: SkillMessageMap; - skillsDetailKey: string | null; - skillsDetailTab: "overview" | "card"; clawhubSearchQuery: string; clawhubSearchResults: ClawHubSearchResult[] | null; clawhubSearchLoading: boolean; @@ -128,25 +126,11 @@ function setSkillMessage(state: SkillsState, key: string, message: SkillMessage) const getErrorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err)); -function getClawHubTrustWarningFromError(err: unknown): string | undefined { +function getClawHubTrustDetailsFromError(err: unknown) { if (!err || typeof err !== "object" || !("details" in err)) { return undefined; } - return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.warning; -} - -function getClawHubTrustCodeFromError(err: unknown) { - if (!err || typeof err !== "object" || !("details" in err)) { - return undefined; - } - return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.clawhubTrustCode; -} - -function getClawHubTrustVersionFromError(err: unknown): string | undefined { - if (!err || typeof err !== "object" || !("details" in err)) { - return undefined; - } - return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.version; + return readClawHubTrustErrorDetails((err as { details?: unknown }).details); } function formatClawHubInstallMessage(message: string, warning?: string): string { @@ -192,11 +176,23 @@ function currentSkillCardCacheKey(state: SkillsState, skillKey: string): string return skill ? skillCardCacheKey(skill) : undefined; } -function skillsAgentParams(state: Pick): { agentId?: string } { +export function skillsAgentParams(agentId: string | null | undefined): { agentId?: string } { + const normalized = agentId?.trim(); + return normalized ? { agentId: normalized } : {}; +} + +function stateSkillsAgentParams(state: Pick): { agentId?: string } { const agentId = state.skillsAgentId?.trim(); return agentId ? { agentId } : {}; } +export async function loadSkillStatusReport( + client: GatewayBrowserClient, + agentId: string | null | undefined, +): Promise { + return client.request("skills.status", skillsAgentParams(agentId)); +} + type SkillsAgentScope = { agentId: string | null; revision: number; @@ -261,8 +257,6 @@ export function setSkillsAgentId(state: SkillsState, agentId: string | null) { state.skillsBusyKey = null; state.skillEdits = {}; state.skillMessages = {}; - state.skillsDetailKey = null; - state.skillsDetailTab = "overview"; state.clawhubInstallSlug = null; state.clawhubInstallMessage = null; state.clawhubVerdicts = {}; @@ -295,14 +289,10 @@ export async function loadSkills(state: SkillsState, options?: { clearMessages?: return; } const agentScope = captureSkillsAgentScope(state); - const requestParams = skillsAgentParams(state); state.skillsLoading = true; state.skillsError = null; try { - const res = await state.client.request( - "skills.status", - requestParams, - ); + const res = await loadSkillStatusReport(state.client, state.skillsAgentId); if (!isSkillsAgentScopeCurrent(state, agentScope)) { return; } @@ -362,7 +352,7 @@ export async function loadSkillCard(state: SkillsState, skillKey: string) { return; } const agentScope = captureSkillsAgentScope(state); - const requestParams = { ...skillsAgentParams(state), skillKey }; + const requestParams = { ...stateSkillsAgentParams(state), skillKey }; state.skillCardLoadingKey = skillKey; const { [skillKey]: _previousError, ...nextErrors } = state.skillCardErrors; state.skillCardErrors = nextErrors; @@ -409,7 +399,7 @@ async function loadClawHubSecurityVerdicts(state: SkillsState, report: SkillStat const response = await client.request<{ schema: "openclaw.skills.security-verdicts.v1"; items: ClawHubSkillSecurityVerdict[]; - }>("skills.securityVerdicts", skillsAgentParams(state)); + }>("skills.securityVerdicts", stateSkillsAgentParams(state)); if (!isSkillsAgentScopeCurrent(state, agentScope)) { return; } @@ -523,7 +513,7 @@ export async function installSkill( ) { await runSkillMutation(state, skillKey, async (client) => { const result = await client.request<{ message?: string }>("skills.install", { - ...skillsAgentParams(state), + ...stateSkillsAgentParams(state), name, installId, dangerouslyForceUnsafeInstall, @@ -618,7 +608,7 @@ export async function installFromClawHub( const result = await state.client.request<{ message?: string; warning?: string }>( "skills.install", { - ...skillsAgentParams(state), + ...stateSkillsAgentParams(state), source: "clawhub", slug, ...(version ? { version } : {}), @@ -638,16 +628,18 @@ export async function installFromClawHub( }; } catch (err) { if (isSkillsAgentScopeCurrent(state, agentScope)) { + const trustDetails = getClawHubTrustDetailsFromError(err); const needsAcknowledgement = - getClawHubTrustCodeFromError(err) === ClawHubTrustErrorCodes.RISK_ACKNOWLEDGEMENT_REQUIRED; - const acknowledgeVersion = getClawHubTrustVersionFromError(err); + trustDetails?.clawhubTrustCode === ClawHubTrustErrorCodes.RISK_ACKNOWLEDGEMENT_REQUIRED; state.clawhubInstallMessage = { kind: "error", text: needsAcknowledgement - ? formatClawHubAcknowledgementMessage(getClawHubTrustWarningFromError(err)) - : formatClawHubInstallMessage(getErrorMessage(err), getClawHubTrustWarningFromError(err)), + ? formatClawHubAcknowledgementMessage(trustDetails?.warning) + : formatClawHubInstallMessage(getErrorMessage(err), trustDetails?.warning), ...(needsAcknowledgement ? { acknowledgeSlug: slug } : {}), - ...(needsAcknowledgement && acknowledgeVersion ? { acknowledgeVersion } : {}), + ...(needsAcknowledgement && trustDetails?.version + ? { acknowledgeVersion: trustDetails.version } + : {}), ...(needsAcknowledgement ? { acknowledgeLabel: "Acknowledge risk and install" } : {}), }; } diff --git a/ui/src/ui/string-coerce.ts b/ui/src/lib/string-coerce.ts similarity index 100% rename from ui/src/ui/string-coerce.ts rename to ui/src/lib/string-coerce.ts diff --git a/ui/src/ui/strip-thinking-tags.ts b/ui/src/lib/strip-thinking-tags.ts similarity index 100% rename from ui/src/ui/strip-thinking-tags.ts rename to ui/src/lib/strip-thinking-tags.ts diff --git a/ui/src/ui/text-direction.test.ts b/ui/src/lib/text-direction.test.ts similarity index 100% rename from ui/src/ui/text-direction.test.ts rename to ui/src/lib/text-direction.test.ts diff --git a/ui/src/ui/text-direction.ts b/ui/src/lib/text-direction.ts similarity index 100% rename from ui/src/ui/text-direction.ts rename to ui/src/lib/text-direction.ts diff --git a/ui/src/ui/uuid.test.ts b/ui/src/lib/uuid.test.ts similarity index 100% rename from ui/src/ui/uuid.test.ts rename to ui/src/lib/uuid.test.ts diff --git a/ui/src/ui/uuid.ts b/ui/src/lib/uuid.ts similarity index 100% rename from ui/src/ui/uuid.ts rename to ui/src/lib/uuid.ts diff --git a/ui/src/lib/workboard/capability.ts b/ui/src/lib/workboard/capability.ts new file mode 100644 index 000000000000..a2a67c661614 --- /dev/null +++ b/ui/src/lib/workboard/capability.ts @@ -0,0 +1,42 @@ +import { + getWorkboardState, + stopWorkboardLifecycleRefresh, + stopWorkboardPolling, + type WorkboardUiState, +} from "./index.ts"; + +export type WorkboardCapability = { + readonly state: WorkboardUiState; + notify: () => void; + subscribe: (listener: () => void) => () => void; + dispose: () => void; +}; + +export function createWorkboardCapability(): WorkboardCapability { + const listeners = new Set<() => void>(); + let disposed = false; + const capability: WorkboardCapability = { + get state() { + return getWorkboardState(capability); + }, + notify() { + if (disposed) { + return; + } + for (const listener of listeners) { + listener(); + } + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + disposed = true; + stopWorkboardPolling(capability); + stopWorkboardLifecycleRefresh(capability); + listeners.clear(); + }, + }; + return capability; +} diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/lib/workboard/index.ts similarity index 99% rename from ui/src/ui/controllers/workboard.ts rename to ui/src/lib/workboard/index.ts index 17a0675e6b2f..fbc4677a35c9 100644 --- a/ui/src/ui/controllers/workboard.ts +++ b/ui/src/lib/workboard/index.ts @@ -1,6 +1,7 @@ +import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; +import { requestSessionCreate } from "../sessions/index.ts"; // Control UI controller manages workboard gateway state. -import { GatewayRequestError, type GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow } from "../types.ts"; export const WORKBOARD_STATUSES = [ "triage", @@ -3918,11 +3919,13 @@ export async function startWorkboardCard(params: { bootstrapContextMode: "lightweight", idempotencyKey: buildCardRunIdempotencyKey(card), }) - : await params.client.request("sessions.create", { - ...(card.agentId ? { agentId: card.agentId } : {}), - label: buildCardSessionLabel(card), - ...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}), - }); + : { + key: await requestSessionCreate(params.client, { + ...(card.agentId ? { agentId: card.agentId } : {}), + label: buildCardSessionLabel(card), + ...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}), + }), + }; const sessionKey = isRecord(created) && typeof created.sessionKey === "string" && created.sessionKey.trim() ? created.sessionKey.trim() diff --git a/ui/src/main.ts b/ui/src/main.ts index 24f4757273df..af6ca91c2034 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,7 +1,7 @@ // Control UI module implements main behavior. import "./styles.css"; -import "./ui/app.ts"; -import { inferControlUiPublicAssetPath } from "./ui/public-assets.ts"; +import "./app/app-host.ts"; +import { inferControlUiPublicAssetPath } from "./app/public-assets.ts"; type ViteImportMeta = ImportMeta & { readonly env?: { diff --git a/ui/src/pages/activity/activity-page.ts b/ui/src/pages/activity/activity-page.ts new file mode 100644 index 000000000000..d63cf96e2e45 --- /dev/null +++ b/ui/src/pages/activity/activity-page.ts @@ -0,0 +1,247 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { state } from "lit/decorators.js"; +import type { EventLogEntry } from "../../api/event-log.ts"; +import type { GatewayEventFrame } from "../../api/gateway.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { loadSettings } from "../../app/settings.ts"; +import { resolveSessionKey } from "../../lib/sessions/index.ts"; +import { uiSessionEventMatches } from "../../lib/sessions/session-key.ts"; +import { + parseToolActivityEvent, + updateToolActivity, + type ActivityEntry, + type ActivityStatus, +} from "./tool-activity.ts"; +import { renderActivity } from "./view.ts"; + +let activityClearBoundary: EventLogEntry | undefined; + +export class ActivityPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @state() private entries: ActivityEntry[] = []; + @state() private filterText = ""; + @state() private statusFilters: Record = { + running: true, + done: true, + error: true, + }; + @state() private toolFilter = ""; + @state() private expandedIds = new Set(); + @state() private autoFollow = true; + @state() private atBottom = true; + + private sessionKey = ""; + private replayFrame: number | null = null; + private scrollFrame: number | null = null; + private stopGatewaySubscription?: () => void; + private stopGatewayEvents?: () => void; + + override connectedCallback() { + super.connectedCallback(); + this.syncSessionKey(); + this.stopGatewayEvents = this.context.gateway.subscribeEvents((event) => { + this.applyGatewayEvent(event, Date.now()); + }); + this.stopGatewaySubscription = this.context.gateway.subscribe(() => { + const previousSessionKey = this.sessionKey; + this.syncSessionKey(); + if (this.sessionKey !== previousSessionKey) { + this.rebuildEntries(); + } + }); + } + + override firstUpdated() { + this.replayFrame = requestAnimationFrame(() => { + this.replayFrame = null; + if (this.isConnected) { + this.rebuildEntries(); + } + }); + } + + override updated(changed: Map) { + if (this.autoFollow && this.atBottom && (changed.has("entries") || changed.has("autoFollow"))) { + this.scheduleScroll(changed.has("autoFollow")); + } + } + + override disconnectedCallback() { + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopGatewayEvents?.(); + this.stopGatewayEvents = undefined; + if (this.replayFrame !== null) { + cancelAnimationFrame(this.replayFrame); + this.replayFrame = null; + } + if (this.scrollFrame !== null) { + cancelAnimationFrame(this.scrollFrame); + this.scrollFrame = null; + } + super.disconnectedCallback(); + } + + private syncSessionKey() { + const snapshot = this.context.gateway.snapshot; + this.sessionKey = resolveSessionKey(loadSettings().sessionKey, snapshot.hello); + } + + private rebuildEntries() { + let entries: ActivityEntry[] = []; + const eventLog = this.context.gateway.eventLog; + const clearIndex = activityClearBoundary ? eventLog.indexOf(activityClearBoundary) : -1; + const visibleEvents = clearIndex < 0 ? eventLog : eventLog.slice(0, clearIndex); + for (const event of visibleEvents.toReversed()) { + entries = this.reduceGatewayEvent(entries, event.event, event.payload, event.ts); + } + if (entries.length > 0 || this.entries.length > 0) { + this.entries = entries; + } + if (this.expandedIds.size > 0) { + this.expandedIds = new Set(); + } + this.atBottom = true; + } + + private applyGatewayEvent(event: GatewayEventFrame, receivedAt: number) { + const nextEntries = this.reduceGatewayEvent( + this.entries, + event.event, + event.payload, + receivedAt, + ); + if (nextEntries !== this.entries) { + this.entries = nextEntries; + } + } + + private reduceGatewayEvent( + entries: ActivityEntry[], + eventName: string, + payload: unknown, + receivedAt: number, + ): ActivityEntry[] { + if (eventName !== "agent" && eventName !== "session.tool") { + return entries; + } + const event = parseToolActivityEvent(payload, receivedAt); + if (!event) { + return entries; + } + const gateway = this.context.gateway.snapshot; + if ( + !uiSessionEventMatches( + { + sessionKey: this.sessionKey, + assistantAgentId: gateway.assistantAgentId, + hello: gateway.hello, + }, + event.sessionKey, + event.agentId, + ) + ) { + return entries; + } + return updateToolActivity(entries, event); + } + + private scheduleScroll(force = false) { + if (this.scrollFrame !== null) { + cancelAnimationFrame(this.scrollFrame); + } + void this.updateComplete.then(() => { + if (!this.isConnected) { + return; + } + this.scrollFrame = requestAnimationFrame(() => { + this.scrollFrame = null; + const container = this.querySelector(".activity-stream"); + if (!container) { + return; + } + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + if (!force && (!this.autoFollow || (!this.atBottom && distanceFromBottom >= 120))) { + return; + } + container.scrollTop = container.scrollHeight; + this.atBottom = true; + }); + }); + } + + private handleScroll(event: Event) { + const container = event.currentTarget as HTMLElement | null; + if (!container) { + return; + } + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + this.atBottom = distanceFromBottom < 120; + } + + private clearEntries() { + activityClearBoundary = this.context.gateway.eventLog[0]; + this.entries = []; + this.expandedIds = new Set(); + this.atBottom = true; + } + + override render() { + return html` +
+
+
${titleForRoute("activity")}
+
${subtitleForRoute("activity")}
+
+
+ ${renderActivity({ + entries: this.entries, + filterText: this.filterText, + statusFilters: this.statusFilters, + toolFilter: this.toolFilter, + expandedIds: this.expandedIds, + autoFollow: this.autoFollow, + onFilterTextChange: (next) => (this.filterText = next), + onToolFilterChange: (next) => (this.toolFilter = next), + onStatusToggle: (status, enabled) => { + this.statusFilters = { ...this.statusFilters, [status]: enabled }; + }, + onToggleAutoFollow: (next) => { + this.autoFollow = next; + if (next) { + this.scheduleScroll(true); + } + }, + onClear: () => this.clearEntries(), + onExpandAll: () => { + this.expandedIds = new Set(this.entries.map((entry) => entry.id)); + }, + onCollapseAll: () => { + this.expandedIds = new Set(); + }, + onEntryToggle: (id, open) => { + const next = new Set(this.expandedIds); + if (open) { + next.add(id); + } else { + next.delete(id); + } + this.expandedIds = next; + }, + onScroll: (event) => this.handleScroll(event), + })} + `; + } +} + +customElements.define("openclaw-activity-page", ActivityPage); diff --git a/ui/src/pages/activity/route.ts b/ui/src/pages/activity/route.ts new file mode 100644 index 000000000000..8fb1b4c9c78c --- /dev/null +++ b/ui/src/pages/activity/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; + +export const page = definePage({ + id: "activity", + path: "/activity", + component: () => + import("./activity-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/activity-model.test.ts b/ui/src/pages/activity/tool-activity.test.ts similarity index 87% rename from ui/src/ui/activity-model.test.ts rename to ui/src/pages/activity/tool-activity.test.ts index 2ddc2a734155..593e18614da4 100644 --- a/ui/src/ui/activity-model.test.ts +++ b/ui/src/pages/activity/tool-activity.test.ts @@ -1,13 +1,12 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { updateActivityFromToolEvent, type ActivityEntry } from "./activity-model.ts"; +import { updateToolActivity, type ActivityEntry } from "./tool-activity.ts"; function buildResultPreview(result: unknown): string { - const host = { activityEntries: [] as ActivityEntry[] }; - - updateActivityFromToolEvent(host, { + const entries: ActivityEntry[] = updateToolActivity([], { runId: "run-1", ts: 1, + receivedAt: 1, data: { toolCallId: "tool-1", name: "bash", @@ -16,7 +15,7 @@ function buildResultPreview(result: unknown): string { }, }); - const entry = host.activityEntries[0]; + const entry = entries[0]; if (!entry?.outputPreview) { throw new Error("Expected activity output preview"); } diff --git a/ui/src/ui/activity-model.ts b/ui/src/pages/activity/tool-activity.ts similarity index 84% rename from ui/src/ui/activity-model.ts rename to ui/src/pages/activity/tool-activity.ts index 331ff6e1ae79..9d78e5246ca5 100644 --- a/ui/src/ui/activity-model.ts +++ b/ui/src/pages/activity/tool-activity.ts @@ -1,5 +1,5 @@ // Control UI module implements activity model behavior. -import { formatUnknownText, truncateText } from "./format.ts"; +import { formatUnknownText, truncateText } from "../../lib/format.ts"; export const ACTIVITY_ENTRY_LIMIT = 100; export const ACTIVITY_OUTPUT_PREVIEW_LIMIT = 2_000; @@ -28,14 +28,12 @@ const ACTIVITY_STATUS_SUMMARY_LABELS: Record = { error: "failed", }; -type ActivityHost = { - activityEntries?: ActivityEntry[]; -}; - -type ToolEventPayload = { +export type ToolActivityEvent = { runId: string; ts: number; + receivedAt: number; sessionKey?: string; + agentId?: string; data: Record; }; @@ -76,6 +74,28 @@ function readRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } +export function parseToolActivityEvent( + payload: unknown, + receivedAt = Date.now(), +): ToolActivityEvent | null { + const record = readRecord(payload); + const runId = toTrimmedString(record?.runId); + const data = readRecord(record?.data); + if (!record || record.stream !== "tool" || !runId || !data) { + return null; + } + const sessionKey = toTrimmedString(record.sessionKey); + const agentId = toTrimmedString(record.agentId); + return { + runId, + ts: typeof record.ts === "number" ? record.ts : receivedAt, + receivedAt, + ...(sessionKey ? { sessionKey } : {}), + ...(agentId ? { agentId } : {}), + data, + }; +} + function extractText(value: unknown): string | null { if (typeof value === "string") { return value; @@ -182,24 +202,24 @@ function buildSummary(toolName: string, status: ActivityStatus, hiddenArgCount: return `${toolName} ${statusLabel(status)}; ${argText}`; } -export function updateActivityFromToolEvent(host: ActivityHost, payload: ToolEventPayload) { - if (!Array.isArray(host.activityEntries)) { - return; - } +export function updateToolActivity( + entries: ActivityEntry[], + payload: ToolActivityEvent, +): ActivityEntry[] { const data = payload.data ?? {}; const toolCallId = toTrimmedString(data.toolCallId); if (!toolCallId) { - return; + return entries; } const toolName = toTrimmedString(data.name) ?? "tool"; const id = `${payload.runId}:${toolCallId}`; - const now = Date.now(); + const now = payload.receivedAt; const startedAt = typeof payload.ts === "number" ? payload.ts : now; const status = resolveStatus(data); const outputValue = data.phase === "update" ? data.partialResult : data.phase === "result" ? data.result : null; const preview = buildOutputPreview(outputValue); - const existing = host.activityEntries.find((entry) => entry.id === id); + const existing = entries.find((entry) => entry.id === id); const hiddenArgCount = data.args !== undefined ? countArgumentFields(data.args) : (existing?.hiddenArgumentCount ?? 0); const outputPreview = preview.text ?? existing?.outputPreview; @@ -219,7 +239,7 @@ export function updateActivityFromToolEvent(host: ActivityHost, payload: ToolEve ...(outputPreview ? { outputPreview } : {}), }; const next = existing - ? host.activityEntries.map((entry) => (entry.id === id ? nextEntry : entry)) - : [...host.activityEntries, nextEntry]; - host.activityEntries = next.slice(-ACTIVITY_ENTRY_LIMIT); + ? entries.map((entry) => (entry.id === id ? nextEntry : entry)) + : [...entries, nextEntry]; + return next.slice(-ACTIVITY_ENTRY_LIMIT); } diff --git a/ui/src/ui/views/activity.test.ts b/ui/src/pages/activity/view.test.ts similarity index 95% rename from ui/src/ui/views/activity.test.ts rename to ui/src/pages/activity/view.test.ts index 93c3bc246eb6..20fd67a5861c 100644 --- a/ui/src/ui/views/activity.test.ts +++ b/ui/src/pages/activity/view.test.ts @@ -3,8 +3,8 @@ import { render } from "lit"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../../i18n/index.ts"; -import type { ActivityEntry, ActivityStatus } from "../activity-model.ts"; -import { renderActivity, type ActivityProps } from "./activity.ts"; +import type { ActivityEntry, ActivityStatus } from "./tool-activity.ts"; +import { renderActivity, type ActivityProps } from "./view.ts"; function createEntry(overrides: Partial = {}): ActivityEntry { return { diff --git a/ui/src/ui/views/activity.ts b/ui/src/pages/activity/view.ts similarity index 97% rename from ui/src/ui/views/activity.ts rename to ui/src/pages/activity/view.ts index 6df29dfc4212..ca391f64d144 100644 --- a/ui/src/ui/views/activity.ts +++ b/ui/src/pages/activity/view.ts @@ -1,10 +1,10 @@ // Control UI view renders activity screen content. import { html, nothing } from "lit"; +import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import type { ActivityEntry, ActivityStatus } from "../activity-model.ts"; -import { formatTimeMs } from "../format.ts"; -import { icons } from "../icons.ts"; -import { normalizeLowercaseStringOrEmpty, sortUniqueStrings } from "../string-coerce.ts"; +import { formatTimeMs } from "../../lib/format.ts"; +import { normalizeLowercaseStringOrEmpty, sortUniqueStrings } from "../../lib/string-coerce.ts"; +import type { ActivityEntry, ActivityStatus } from "./tool-activity.ts"; const STATUS_ORDER: ActivityStatus[] = ["running", "done", "error"]; diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts new file mode 100644 index 000000000000..80f9426327ad --- /dev/null +++ b/ui/src/pages/agents/agents-page.ts @@ -0,0 +1,747 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + AgentIdentityResult, + AgentsFilesListResult, + AgentsListResult, + ModelCatalogEntry, + SkillStatusReport, + ToolsCatalogResult, + ToolsEffectiveResult, +} from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { + resolveAgentConfig, + resolveEffectiveModelFallbacks, + resolveModelPrimary, +} from "../../lib/agents/display.ts"; +import { + loadToolsCatalog, + loadToolsEffective, + buildToolsEffectiveRequestKey, + refreshVisibleToolsEffectiveForCurrentSession, + resetToolsEffectiveState, + type AgentsPanel, + type AgentsState, +} from "../../lib/agents/index.ts"; +import { currentConfigObject, findAgentConfigEntryIndex } from "../../lib/config/index.ts"; +import { + createInitialCronState, + loadCronJobsPage, + loadCronStatus, + runCronJob, +} from "../../lib/cron/index.ts"; +import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; +import { normalizeStringEntries } from "../../lib/string-coerce.ts"; +import { loadAgentFileContent, saveAgentFile } from "./files.ts"; +import { loadAgentSkills } from "./skills.ts"; +import { renderAgents } from "./view.ts"; + +export type AgentsRouteData = { + connected: boolean; + agentsList: AgentsListResult | null; + selectedAgentId: string | null; + error: string | null; +}; + +export class AgentsPage extends LitElement implements AgentsState { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @property({ attribute: false }) routeData?: AgentsRouteData; + + @state() client: GatewayBrowserClient | null = null; + @state() connected = false; + @state() agentsLoading = false; + @state() agentsError: string | null = null; + @state() agentsList: AgentsListResult | null = null; + @state() agentsSelectedId: string | null = null; + @state() agentsPanel: AgentsPanel = "files"; + @state() toolsCatalogLoading = false; + @state() toolsCatalogLoadingAgentId: string | null = null; + @state() toolsCatalogError: string | null = null; + @state() toolsCatalogResult: ToolsCatalogResult | null = null; + @state() toolsEffectiveLoading = false; + @state() toolsEffectiveLoadingKey: string | null = null; + @state() toolsEffectiveResultKey: string | null = null; + @state() toolsEffectiveError: string | null = null; + @state() toolsEffectiveResult: ToolsEffectiveResult | null = null; + @state() chatModelCatalog: ModelCatalogEntry[] = []; + @state() agentFilesLoading = false; + @state() agentFilesError: string | null = null; + @state() agentFilesList: AgentsFilesListResult | null = null; + @state() agentFileContents: Record = {}; + @state() agentFileDrafts: Record = {}; + @state() agentFileActive: string | null = null; + @state() agentFileSaving = false; + @state() agentIdentityLoading = false; + @state() agentIdentityError: string | null = null; + @state() agentSkillsLoading = false; + @state() agentSkillsError: string | null = null; + @state() agentSkillsReport: SkillStatusReport | null = null; + @state() agentSkillsAgentId: string | null = null; + @state() skillsFilter = ""; + @state() private cron = createInitialCronState(); + + private routeDataInitialized = false; + private stopGatewaySubscription?: () => void; + private stopAgentsSubscription?: () => void; + private stopAgentIdentitySubscription?: () => void; + private stopChannelsSubscription?: () => void; + private stopConfigSubscription?: () => void; + private stopSessionsSubscription?: () => void; + + get sessions() { + return this.context.sessions; + } + + get sessionsResult() { + return this.context.sessions.state.result; + } + + get sessionKey() { + return this.context.gateway.snapshot.sessionKey; + } + + override connectedCallback() { + super.connectedCallback(); + this.syncGatewayState(); + this.syncAgentState(); + this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => { + const previousClient = this.client; + this.syncGatewayState(); + if (previousClient !== snapshot.client) { + this.resetForClientChange(); + } + this.ensureInitialData(); + }); + this.stopAgentsSubscription = this.context.agents.subscribe(() => { + this.syncAgentState(); + this.ensureAgentIdentities(); + this.loadActivePanelData(); + this.requestUpdate(); + }); + this.stopAgentIdentitySubscription = this.context.agentIdentity.subscribe(() => + this.requestUpdate(), + ); + this.stopChannelsSubscription = this.context.channels.subscribe(() => this.requestUpdate()); + this.stopConfigSubscription = this.context.runtimeConfig.subscribe(() => this.requestUpdate()); + this.stopSessionsSubscription = this.context.sessions.subscribe(() => { + void refreshVisibleToolsEffectiveForCurrentSession(this); + this.requestUpdate(); + }); + this.ensureInitialData(); + } + + override willUpdate(changed: Map) { + if (changed.has("routeData")) { + this.applyRouteData(); + this.ensureInitialData(); + } + } + + override disconnectedCallback() { + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopAgentsSubscription?.(); + this.stopAgentsSubscription = undefined; + this.stopAgentIdentitySubscription?.(); + this.stopAgentIdentitySubscription = undefined; + this.stopChannelsSubscription?.(); + this.stopChannelsSubscription = undefined; + this.stopConfigSubscription?.(); + this.stopConfigSubscription = undefined; + this.stopSessionsSubscription?.(); + this.stopSessionsSubscription = undefined; + super.disconnectedCallback(); + } + + private syncGatewayState() { + const gateway = this.context.gateway.snapshot; + this.client = gateway.client; + this.connected = gateway.connected; + this.cron = { + ...this.cron, + client: gateway.client, + connected: gateway.connected, + }; + } + + private syncAgentState() { + const agentState = this.context.agents.state; + this.agentsLoading = agentState.agentsLoading; + this.agentsError = agentState.agentsError; + this.agentsList = agentState.agentsList; + if (agentState.agentsList) { + this.ensureSelectedAgentInList(agentState.agentsList); + } + this.syncCurrentAgentFiles(); + } + + private ensureSelectedAgentInList(agentsList: AgentsListResult) { + const selected = this.agentsSelectedId; + if (!selected || !agentsList.agents.some((entry) => entry.id === selected)) { + this.agentsSelectedId = agentsList.defaultId ?? agentsList.agents[0]?.id ?? null; + } + } + + private syncCurrentAgentFiles() { + const agentId = this.resolveSelectedAgentId(); + if (!agentId || this.agentsPanel !== "files") { + return; + } + const status = this.context.agents.files(agentId); + if (!status.list) { + return; + } + this.agentFilesList = status.list; + this.agentFilesError = status.error; + if ( + this.agentFileActive && + !status.list.files.some((file) => file.name === this.agentFileActive) + ) { + this.agentFileActive = null; + } + } + + private resetForClientChange() { + this.agentsLoading = false; + this.agentsError = null; + this.agentsList = null; + this.agentsSelectedId = null; + this.resetSelectionState(); + this.cron = createInitialCronState({ + client: this.client, + connected: this.connected, + }); + } + + private applyRouteData() { + const data = this.routeData; + if (!data) { + return; + } + this.routeDataInitialized = true; + this.agentsLoading = false; + this.agentsError = data.error; + if (data.agentsList) { + this.agentsList = data.agentsList; + this.agentsSelectedId = data.selectedAgentId ?? this.resolveSelectedAgentId(); + } + } + + private resolveSelectedAgentId() { + return ( + this.agentsSelectedId ?? + this.agentsList?.defaultId ?? + this.agentsList?.agents?.[0]?.id ?? + null + ); + } + + private chatAgentId() { + return ( + parseAgentSessionKey(this.sessionKey)?.agentId ?? + this.context.gateway.snapshot.assistantAgentId ?? + this.agentsList?.defaultId ?? + "main" + ); + } + + private agentIdentityById(): Record { + return Object.fromEntries( + this.context.agentIdentity.entries().map((entry) => [entry.agentId, entry]), + ); + } + + private ensureInitialData() { + if (!this.connected || !this.client || !this.routeDataInitialized) { + return; + } + if (!this.agentsList && !this.agentsLoading) { + void this.loadAgentsAndCommit(); + return; + } + this.ensureAgentIdentities(); + this.loadActivePanelData(); + } + + private ensureAgentIdentities() { + const ids = + this.agentsList?.agents + .map((entry) => entry.id) + .filter((id) => !this.context.agentIdentity.get(id)) ?? []; + if (ids.length === 0 || this.agentIdentityLoading) { + return; + } + this.agentIdentityLoading = true; + this.agentIdentityError = null; + void this.context.agentIdentity + .ensure(ids) + .catch((err: unknown) => { + this.agentIdentityError = String(err); + }) + .finally(() => { + this.agentIdentityLoading = false; + }); + } + + private loadActivePanelData() { + const agentId = this.resolveSelectedAgentId(); + if (!agentId) { + return; + } + if (this.agentsPanel === "files" && this.agentFilesList?.agentId !== agentId) { + void this.loadAgentFiles(agentId); + return; + } + if (this.agentsPanel === "skills" && this.agentSkillsAgentId !== agentId) { + void loadAgentSkills(this, agentId); + return; + } + if (this.agentsPanel === "tools") { + if (this.toolsCatalogResult?.agentId !== agentId && !this.toolsCatalogLoading) { + void loadToolsCatalog(this, agentId); + } + this.loadEffectiveToolsForAgent(agentId); + return; + } + if (this.agentsPanel === "channels" && !this.context.channels.state.channelsSnapshot) { + void this.context.channels.refresh(false); + return; + } + if (this.agentsPanel === "cron" && !this.cron.cronLoading && !this.cron.cronStatus) { + void this.refreshCron(); + } + } + + private async loadAgentsAndCommit() { + await this.context.agents.ensureList(); + this.syncAgentState(); + this.ensureAgentIdentities(); + this.loadActivePanelData(); + } + + private async loadAgentFiles(agentId: string, force = false) { + if (!this.client || !this.connected || this.agentFilesLoading) { + return; + } + const cached = this.context.agents.files(agentId); + if (cached.list && !force) { + this.syncCurrentAgentFiles(); + return; + } + this.agentFilesLoading = true; + this.agentFilesError = null; + try { + const list = force + ? await this.context.agents.refreshFiles(agentId) + : await this.context.agents.ensureFiles(agentId); + if (this.resolveSelectedAgentId() !== agentId) { + return; + } + this.agentFilesList = list ?? this.context.agents.files(agentId).list; + this.agentFilesError = this.context.agents.files(agentId).error; + if ( + this.agentFileActive && + !this.agentFilesList?.files.some((file) => file.name === this.agentFileActive) + ) { + this.agentFileActive = null; + } + } finally { + if (this.resolveSelectedAgentId() === agentId) { + this.agentFilesLoading = false; + } + } + } + + private async refreshCron() { + const cronState = this.cron; + if (!cronState.connected || !cronState.client) { + return; + } + await Promise.all([ + loadCronStatus(cronState), + loadCronJobsPage(cronState, { tableFilters: true }), + ]); + if (this.cron === cronState) { + this.cron = { ...cronState, cronJobs: [...cronState.cronJobs] }; + } + } + + private resetSelectionState() { + this.agentFilesList = null; + this.agentFilesError = null; + this.agentFileActive = null; + this.agentFileContents = {}; + this.agentFileDrafts = {}; + this.agentFilesLoading = false; + this.agentSkillsReport = null; + this.agentSkillsError = null; + this.agentSkillsAgentId = null; + this.toolsCatalogResult = null; + this.toolsCatalogError = null; + this.toolsCatalogLoading = false; + resetToolsEffectiveState(this); + } + + private findAgentIndex(agentId: string) { + return findAgentConfigEntryIndex( + currentConfigObject(this.context.runtimeConfig.state), + agentId, + ); + } + + private ensureAgentIndex(agentId: string) { + return this.context.runtimeConfig.ensureAgentEntry(agentId); + } + + private toolsPath(agentId: string, ensure: boolean) { + const index = ensure ? this.ensureAgentIndex(agentId) : this.findAgentIndex(agentId); + return index >= 0 ? (["agents", "list", index, "tools"] as Array) : null; + } + + private modelEntry(index: number) { + const list = ( + currentConfigObject(this.context.runtimeConfig.state) as { + agents?: { list?: unknown[] }; + } | null + )?.agents?.list; + const existing = Array.isArray(list) + ? (list[index] as { model?: unknown } | undefined)?.model + : undefined; + return { path: ["agents", "list", index, "model"] as Array, existing }; + } + + private loadEffectiveToolsForAgent(agentId: string) { + if (agentId !== this.chatAgentId()) { + resetToolsEffectiveState(this); + return; + } + const requestKey = buildToolsEffectiveRequestKey(this, { + agentId, + sessionKey: this.sessionKey, + }); + if (this.toolsEffectiveResultKey === requestKey && !this.toolsEffectiveError) { + return; + } + void loadToolsEffective(this, { agentId, sessionKey: this.sessionKey }); + } + + private selectAgent(agentId: string) { + if (this.agentsSelectedId === agentId) { + return; + } + this.agentsSelectedId = agentId; + this.resetSelectionState(); + void this.context.agentIdentity.ensure([agentId]); + this.loadActivePanelData(); + } + + private selectPanel(panel: AgentsPanel) { + this.agentsPanel = panel; + this.loadActivePanelData(); + } + + private refreshAgents() { + void (async () => { + await this.context.agents.refreshList(); + this.syncAgentState(); + this.loadActivePanelData(); + })(); + } + + private saveAgentConfig() { + const selectedBefore = this.agentsSelectedId; + void (async () => { + await this.context.runtimeConfig.save(); + await this.context.agents.refreshList(); + this.syncAgentState(); + if (selectedBefore && this.agentsList?.agents.some((entry) => entry.id === selectedBefore)) { + this.agentsSelectedId = selectedBefore; + } + this.ensureAgentIdentities(); + this.loadActivePanelData(); + })(); + } + + private reloadConfig() { + void this.context.runtimeConfig.refresh({ discardPendingChanges: true }); + } + + private runCronJobNow(jobId: string) { + const job = this.cron.cronJobs.find((entry) => entry.id === jobId); + if (!job) { + return; + } + void runCronJob(this.cron, job, "force").finally(() => { + this.cron = { ...this.cron, cronJobs: [...this.cron.cronJobs] }; + }); + } + + override render() { + const configState = this.context.runtimeConfig.state; + const selectedAgentId = this.resolveSelectedAgentId(); + const config = currentConfigObject(configState); + return html` +
+
+
${titleForRoute("agents")}
+
${subtitleForRoute("agents")}
+
+
+ ${renderSettingsWorkspace( + this.context.basePath, + renderAgents({ + basePath: this.context.basePath, + loading: this.agentsLoading, + error: this.agentsError, + agentsList: this.agentsList, + selectedAgentId, + activePanel: this.agentsPanel, + config: { + form: config, + loading: configState.configLoading, + saving: configState.configSaving, + dirty: configState.configFormDirty, + }, + channels: { + snapshot: this.context.channels.state.channelsSnapshot, + loading: this.context.channels.state.channelsLoading, + error: this.context.channels.state.channelsError, + lastSuccess: this.context.channels.state.channelsLastSuccess, + }, + cron: { + status: this.cron.cronStatus, + jobs: this.cron.cronJobs, + loading: this.cron.cronLoading, + error: this.cron.cronError, + }, + agentFiles: { + list: this.agentFilesList, + loading: this.agentFilesLoading, + error: this.agentFilesError, + active: this.agentFileActive, + contents: this.agentFileContents, + drafts: this.agentFileDrafts, + saving: this.agentFileSaving, + }, + agentIdentityLoading: this.agentIdentityLoading, + agentIdentityError: this.agentIdentityError, + agentIdentityById: this.agentIdentityById(), + agentSkills: { + report: this.agentSkillsReport, + loading: this.agentSkillsLoading, + error: this.agentSkillsError, + agentId: this.agentSkillsAgentId, + filter: this.skillsFilter, + }, + toolsCatalog: { + loading: this.toolsCatalogLoading, + error: this.toolsCatalogError, + result: this.toolsCatalogResult, + }, + toolsEffective: { + loading: this.toolsEffectiveLoading, + error: this.toolsEffectiveError, + result: this.toolsEffectiveResult, + }, + runtimeSessionKey: this.sessionKey, + runtimeSessionMatchesSelectedAgent: selectedAgentId === this.chatAgentId(), + modelCatalog: this.chatModelCatalog, + onRefresh: () => this.refreshAgents(), + onSelectAgent: (agentId) => this.selectAgent(agentId), + onSelectPanel: (panel) => this.selectPanel(panel), + onLoadFiles: (agentId) => void this.loadAgentFiles(agentId, true), + onSelectFile: (name) => { + this.agentFileActive = name; + if (selectedAgentId) { + void loadAgentFileContent(this, selectedAgentId, name); + } + }, + onFileDraftChange: (name, content) => { + this.agentFileDrafts = { ...this.agentFileDrafts, [name]: content }; + }, + onFileReset: (name) => { + this.agentFileDrafts = { + ...this.agentFileDrafts, + [name]: this.agentFileContents[name] ?? "", + }; + }, + onFileSave: (name) => { + if (selectedAgentId) { + void saveAgentFile( + this, + selectedAgentId, + name, + this.agentFileDrafts[name] ?? this.agentFileContents[name] ?? "", + ).then(() => this.loadAgentFiles(selectedAgentId, true)); + } + }, + onToolsProfileChange: (agentId, profile, clearAllow) => { + const path = this.toolsPath(agentId, Boolean(profile || clearAllow)); + if (!path) { + return; + } + if (profile) { + this.context.runtimeConfig.patchForm([...path, "profile"], profile); + } else { + this.context.runtimeConfig.removeFormValue([...path, "profile"]); + } + if (clearAllow) { + this.context.runtimeConfig.removeFormValue([...path, "allow"]); + } + }, + onToolsOverridesChange: (agentId, alsoAllow, deny) => { + const path = this.toolsPath(agentId, alsoAllow.length > 0 || deny.length > 0); + if (!path) { + return; + } + if (alsoAllow.length) { + this.context.runtimeConfig.patchForm([...path, "alsoAllow"], alsoAllow); + } else { + this.context.runtimeConfig.removeFormValue([...path, "alsoAllow"]); + } + if (deny.length) { + this.context.runtimeConfig.patchForm([...path, "deny"], deny); + } else { + this.context.runtimeConfig.removeFormValue([...path, "deny"]); + } + }, + onConfigReload: () => this.reloadConfig(), + onConfigSave: () => this.saveAgentConfig(), + onChannelsRefresh: () => void this.context.channels.refresh(false), + onCronRefresh: () => void this.refreshCron(), + onCronRunNow: (jobId) => this.runCronJobNow(jobId), + onSkillsFilterChange: (next) => (this.skillsFilter = next), + onSkillsRefresh: () => { + if (selectedAgentId) { + void loadAgentSkills(this, selectedAgentId); + } + }, + onAgentSkillToggle: (agentId, skillName, enabled) => { + const index = this.ensureAgentIndex(agentId); + if (index < 0 || !skillName.trim()) { + return; + } + const list = ( + currentConfigObject(configState) as { + agents?: { list?: unknown[] }; + } | null + )?.agents?.list; + const entry = Array.isArray(list) + ? (list[index] as { skills?: unknown } | undefined) + : undefined; + const base = Array.isArray(entry?.skills) + ? normalizeStringEntries(entry.skills) + : (this.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ?? []); + const next = new Set(base); + if (enabled) { + next.add(skillName.trim()); + } else { + next.delete(skillName.trim()); + } + this.context.runtimeConfig.patchForm(["agents", "list", index, "skills"], [...next]); + }, + onAgentSkillsClear: (agentId) => { + const index = this.findAgentIndex(agentId); + if (index >= 0) { + this.context.runtimeConfig.removeFormValue(["agents", "list", index, "skills"]); + } + }, + onAgentSkillsDisableAll: (agentId) => { + const index = this.ensureAgentIndex(agentId); + if (index >= 0) { + this.context.runtimeConfig.patchForm(["agents", "list", index, "skills"], []); + } + }, + onModelChange: (agentId, modelId) => { + const index = modelId ? this.ensureAgentIndex(agentId) : this.findAgentIndex(agentId); + if (index < 0) { + return; + } + const entry = this.modelEntry(index); + if (!modelId) { + this.context.runtimeConfig.removeFormValue(entry.path); + } else if (entry.existing && typeof entry.existing === "object") { + const fallbacks = (entry.existing as { fallbacks?: unknown }).fallbacks; + this.context.runtimeConfig.patchForm(entry.path, { + primary: modelId, + ...(Array.isArray(fallbacks) ? { fallbacks } : {}), + }); + } else { + this.context.runtimeConfig.patchForm(entry.path, modelId); + } + void refreshVisibleToolsEffectiveForCurrentSession(this); + }, + onModelFallbacksChange: (agentId, fallbacks) => { + const normalized = normalizeStringEntries(fallbacks); + const resolved = resolveAgentConfig(config, agentId); + const primary = + resolveModelPrimary(resolved.entry?.model) ?? + resolveModelPrimary(resolved.defaults?.model); + const effective = resolveEffectiveModelFallbacks( + resolved.entry?.model, + resolved.defaults?.model, + ); + const index = + normalized.length > 0 + ? primary + ? this.ensureAgentIndex(agentId) + : -1 + : (effective?.length ?? 0) > 0 || this.findAgentIndex(agentId) >= 0 + ? this.ensureAgentIndex(agentId) + : -1; + if (index < 0) { + return; + } + const entry = this.modelEntry(index); + const currentPrimary = + typeof entry.existing === "string" + ? entry.existing.trim() + : entry.existing && + typeof entry.existing === "object" && + typeof (entry.existing as { primary?: unknown }).primary === "string" + ? (entry.existing as { primary: string }).primary.trim() + : ""; + if (normalized.length === 0) { + if (currentPrimary || primary) { + this.context.runtimeConfig.patchForm(entry.path, currentPrimary || primary); + } else { + this.context.runtimeConfig.removeFormValue(entry.path); + } + } else if (currentPrimary || primary) { + this.context.runtimeConfig.patchForm(entry.path, { + primary: currentPrimary || primary, + fallbacks: normalized, + }); + } + }, + onSetDefault: (agentId) => { + const hadPendingConfigDraft = this.context.runtimeConfig.state.configFormDirty; + if (this.context.runtimeConfig.stageDefaultAgent(agentId)) { + if (!hadPendingConfigDraft && this.context.runtimeConfig.state.configFormDirty) { + this.saveAgentConfig(); + } + } + }, + }), + "agents", + (routeId) => this.context.navigate(routeId), + (routeId) => this.context.preload(routeId), + )} + `; + } +} + +if (!customElements.get("openclaw-agents-page")) { + customElements.define("openclaw-agents-page", AgentsPage); +} diff --git a/ui/src/ui/controllers/agent-files.ts b/ui/src/pages/agents/files.ts similarity index 80% rename from ui/src/ui/controllers/agent-files.ts rename to ui/src/pages/agents/files.ts index 47a4e75a35b3..85ef70a9a554 100644 --- a/ui/src/ui/controllers/agent-files.ts +++ b/ui/src/pages/agents/files.ts @@ -1,11 +1,11 @@ // Control UI controller manages agent files gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { AgentFileEntry, AgentsFilesGetResult, AgentsFilesListResult, AgentsFilesSetResult, -} from "../types.ts"; +} from "../../api/types.ts"; export type AgentFilesState = { client: GatewayBrowserClient | null; @@ -33,29 +33,6 @@ function mergeFileEntry( return { ...list, files: nextFiles }; } -export async function loadAgentFiles(state: AgentFilesState, agentId: string) { - if (!state.client || !state.connected || state.agentFilesLoading) { - return; - } - state.agentFilesLoading = true; - state.agentFilesError = null; - try { - const res = await state.client.request("agents.files.list", { - agentId, - }); - if (res) { - state.agentFilesList = res; - if (state.agentFileActive && !res.files.some((file) => file.name === state.agentFileActive)) { - state.agentFileActive = null; - } - } - } catch (err) { - state.agentFilesError = String(err); - } finally { - state.agentFilesLoading = false; - } -} - export async function loadAgentFileContent( state: AgentFilesState, agentId: string, diff --git a/ui/src/ui/views/agents-panels-overview.ts b/ui/src/pages/agents/panels-overview.ts similarity index 93% rename from ui/src/ui/views/agents-panels-overview.ts rename to ui/src/pages/agents/panels-overview.ts index 037eb62c8978..a0ad1bdb614c 100644 --- a/ui/src/ui/views/agents-panels-overview.ts +++ b/ui/src/pages/agents/panels-overview.ts @@ -1,12 +1,13 @@ // Control UI view renders agents panels overview screen content. import { html, nothing } from "lit"; -import { t } from "../../i18n/index.ts"; import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult, ModelCatalogEntry, -} from "../types.ts"; +} from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; +import "../../components/tooltip.ts"; import { buildModelOptions, normalizeModelValue, @@ -16,8 +17,8 @@ import { resolveModelFallbacks, resolveModelLabel, resolveModelPrimary, -} from "./agents-utils.ts"; -import type { AgentsPanel } from "./agents.types.ts"; +} from "../../lib/agents/display.ts"; +import type { AgentsPanel } from "../../lib/agents/index.ts"; export function renderAgentOverview(params: { agent: AgentsListResult["agents"][number]; @@ -112,14 +113,16 @@ export function renderAgentOverview(params: {
Workspace
- + + +
diff --git a/ui/src/ui/views/agents-panels-status-files.ts b/ui/src/pages/agents/panels-status-files.ts similarity index 88% rename from ui/src/ui/views/agents-panels-status-files.ts rename to ui/src/pages/agents/panels-status-files.ts index 3dcb8e47b638..930cd29e84f4 100644 --- a/ui/src/ui/views/agents-panels-status-files.ts +++ b/ui/src/pages/agents/panels-status-files.ts @@ -4,25 +4,26 @@ import DOMPurify from "dompurify"; import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { marked } from "marked"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import { icons } from "../icons.ts"; -import { - formatCronPayload, - formatCronSchedule, - formatCronState, - formatNextRun, -} from "../presenter.ts"; import type { AgentsFilesListResult, ChannelAccountSnapshot, ChannelsStatusSnapshot, CronJob, CronStatus, -} from "../types.ts"; -import { formatBytes, type AgentContext } from "./agents-utils.ts"; -import type { AgentsPanel } from "./agents.types.ts"; -import { resolveChannelExtras as resolveChannelExtrasFromConfig } from "./channel-config-extras.ts"; +} from "../../api/types.ts"; +import { icons } from "../../components/icons.ts"; +import "../../components/tooltip.ts"; +import { t } from "../../i18n/index.ts"; +import { formatBytes, type AgentContext } from "../../lib/agents/display.ts"; +import type { AgentsPanel } from "../../lib/agents/index.ts"; +import { resolveChannelExtras as resolveChannelExtrasFromConfig } from "../../lib/channels/index.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { + formatCronPayload, + formatCronSchedule, + formatCronState, + formatNextRun, +} from "../../lib/presenter.ts"; function countWords(text: string) { const normalized = text.trim(); @@ -105,7 +106,6 @@ function renderAgentContextCard( type="button" class="workspace-link mono" @click=${() => onSelectPanel("files")} - title=${t("agents.context.openFilesTab")} > ${context.workspace} @@ -531,7 +531,6 @@ export function renderAgentFiles(params: {
- - - + + + + + + + + +
diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/pages/agents/panels-tools-skills.browser.test.ts similarity index 99% rename from ui/src/ui/views/agents-panels-tools-skills.browser.test.ts rename to ui/src/pages/agents/panels-tools-skills.browser.test.ts index f0b0dc08532b..12a2bb71b951 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/pages/agents/panels-tools-skills.browser.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover agents panels tools skills behavior. import { render } from "lit"; import { describe, expect, it } from "vitest"; -import { renderAgentTools } from "./agents-panels-tools-skills.ts"; +import { renderAgentTools } from "./panels-tools-skills.ts"; function createBaseParams(overrides: Partial[0]> = {}) { return { diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/pages/agents/panels-tools-skills.ts similarity index 98% rename from ui/src/ui/views/agents-panels-tools-skills.ts rename to ui/src/pages/agents/panels-tools-skills.ts index 66c734b14643..2ccff71c6313 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/pages/agents/panels-tools-skills.ts @@ -1,15 +1,14 @@ // Control UI view renders agents panels tools skills screen content. import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; -import { t } from "../../i18n/index.ts"; -import { normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "../string-coerce.ts"; import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult, ToolsEffectiveEntry, ToolsEffectiveResult, -} from "../types.ts"; +} from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; import { type AgentToolEntry, type AgentToolSection, @@ -19,14 +18,18 @@ import { resolveToolProfileOptions, resolveToolProfile, resolveToolSections, -} from "./agents-utils.ts"; -import type { SkillGroup } from "./skills-grouping.ts"; -import { groupSkills } from "./skills-grouping.ts"; +} from "../../lib/agents/display.ts"; +import type { SkillGroup } from "../../lib/skills-grouping.ts"; +import { groupSkills } from "../../lib/skills-grouping.ts"; import { computeSkillMissing, computeSkillReasons, renderSkillStatusChips, -} from "./skills-shared.ts"; +} from "../../lib/skills-shared.ts"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "../../lib/string-coerce.ts"; function renderToolMetaBadges(labels: string[]) { if (labels.length === 0) { @@ -763,7 +766,6 @@ export function renderAgentSkills(params: { class="btn btn--sm" ?disabled=${!editable || !usingAllowlist} @click=${() => params.onClear(params.agentId)} - title="Remove per-agent allowlist and use all skills" > Reset diff --git a/ui/src/pages/agents/route.ts b/ui/src/pages/agents/route.ts new file mode 100644 index 000000000000..60243f5d7568 --- /dev/null +++ b/ui/src/pages/agents/route.ts @@ -0,0 +1,27 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; +import type { AgentsRouteData } from "./agents-page.ts"; + +async function loadAgentsRouteData(context: ApplicationContext): Promise { + const gateway = context.gateway.snapshot; + const agentsList = context.agents.state.agentsList; + return { + connected: gateway.connected, + agentsList, + selectedAgentId: agentsList?.defaultId ?? agentsList?.agents[0]?.id ?? null, + error: context.agents.state.agentsError, + }; +} + +export const page = definePage({ + id: "agents", + path: "/agents", + loader: loadAgentsRouteData, + component: () => + import("./agents-page.ts").then(() => ({ + header: true, + render: (data: AgentsRouteData | undefined) => + html``, + })), +}); diff --git a/ui/src/ui/controllers/agent-skills.ts b/ui/src/pages/agents/skills.ts similarity index 71% rename from ui/src/ui/controllers/agent-skills.ts rename to ui/src/pages/agents/skills.ts index 4a785f60d754..d64dfae4a22e 100644 --- a/ui/src/ui/controllers/agent-skills.ts +++ b/ui/src/pages/agents/skills.ts @@ -1,6 +1,7 @@ // Control UI controller manages agent skills gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { SkillStatusReport } from "../types.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { SkillStatusReport } from "../../api/types.ts"; +import { loadSkillStatusReport } from "../../lib/skills/index.ts"; export type AgentSkillsState = { client: GatewayBrowserClient | null; @@ -21,9 +22,9 @@ export async function loadAgentSkills(state: AgentSkillsState, agentId: string) state.agentSkillsLoading = true; state.agentSkillsError = null; try { - const res = await state.client.request("skills.status", { agentId }); + const res = await loadSkillStatusReport(state.client, agentId); if (res) { - state.agentSkillsReport = res as SkillStatusReport; + state.agentSkillsReport = res; state.agentSkillsAgentId = agentId; } } catch (err) { diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/pages/agents/view.test.ts similarity index 99% rename from ui/src/ui/views/agents.test.ts rename to ui/src/pages/agents/view.test.ts index 2c165609d68c..9c006e7f4665 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -3,8 +3,8 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { i18n, t } from "../../i18n/index.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; -import { renderAgentFiles } from "./agents-panels-status-files.ts"; -import { renderAgents, type AgentsProps } from "./agents.ts"; +import { renderAgentFiles } from "./panels-status-files.ts"; +import { renderAgents, type AgentsProps } from "./view.ts"; function createSkill() { return { diff --git a/ui/src/ui/views/agents.ts b/ui/src/pages/agents/view.ts similarity index 95% rename from ui/src/ui/views/agents.ts rename to ui/src/pages/agents/view.ts index 65e3c80013a8..4a242323de2a 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/pages/agents/view.ts @@ -1,7 +1,6 @@ // Control UI view renders agents screen content. import { html, nothing } from "lit"; import { keyed } from "lit/directives/keyed.js"; -import { t } from "../../i18n/index.ts"; import type { AgentIdentityResult, AgentsFilesListResult, @@ -13,17 +12,17 @@ import type { SkillStatusReport, ToolsCatalogResult, ToolsEffectiveResult, -} from "../types.ts"; -import { renderAgentOverview } from "./agents-panels-overview.ts"; +} from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; import { - renderAgentFiles, - renderAgentChannels, - renderAgentCron, -} from "./agents-panels-status-files.ts"; -export type { AgentsPanel } from "./agents.types.ts"; -import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; -import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts"; -import type { AgentsPanel } from "./agents.types.ts"; + agentBadgeText, + buildAgentContext, + normalizeAgentLabel, +} from "../../lib/agents/display.ts"; +import type { AgentsPanel } from "../../lib/agents/index.ts"; +import { renderAgentOverview } from "./panels-overview.ts"; +import { renderAgentFiles, renderAgentChannels, renderAgentCron } from "./panels-status-files.ts"; +import { renderAgentTools, renderAgentSkills } from "./panels-tools-skills.ts"; export type ConfigState = { form: Record | null; @@ -177,7 +176,6 @@ export function renderAgents(props: AgentsProps) { type="button" class="btn btn--sm btn--ghost" @click=${() => void navigator.clipboard.writeText(selectedAgent.id)} - title=${t("agents.copyIdTitle")} > ${t("agents.copyId")} @@ -186,9 +184,6 @@ export function renderAgents(props: AgentsProps) { class="btn btn--sm btn--ghost" ?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)} @click=${() => props.onSetDefault(selectedAgent.id)} - title=${defaultId && selectedAgent.id === defaultId - ? t("agents.alreadyDefaultTitle") - : t("agents.setDefaultTitle")} > ${defaultId && selectedAgent.id === defaultId ? t("agents.default") diff --git a/ui/src/pages/channels/channels-page.ts b/ui/src/pages/channels/channels-page.ts new file mode 100644 index 000000000000..2fc177167b23 --- /dev/null +++ b/ui/src/pages/channels/channels-page.ts @@ -0,0 +1,385 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { state } from "lit/decorators.js"; +import type { NostrProfile } from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { resolveControlUiAuthHeader } from "../../app/control-ui-auth.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { createNostrProfileFormState } from "./view.nostr-profile-form.ts"; +import { renderChannels } from "./view.ts"; + +type NostrProfileFormState = ReturnType | null; + +function parseValidationErrors(details: unknown): Record { + if (!Array.isArray(details)) { + return {}; + } + const errors: Record = {}; + for (const entry of details) { + if (typeof entry !== "string") { + continue; + } + const [rawField, ...rest] = entry.split(":"); + if (!rawField || rest.length === 0) { + continue; + } + const field = rawField.trim(); + const message = rest.join(":").trim(); + if (field && message) { + errors[field] = message; + } + } + return errors; +} + +function buildNostrProfileUrl(accountId: string, suffix = ""): string { + return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`; +} + +export class ChannelsPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @state() + private nostrProfileFormState: NostrProfileFormState = null; + + @state() + private nostrProfileAccountId: string | null = null; + + private stopChannelsSubscription?: () => void; + private stopConfigSubscription?: () => void; + private stopGatewaySubscription?: () => void; + private schemaLoadStarted = false; + + private readonly requestPageUpdate = () => this.requestUpdate(); + + override connectedCallback() { + super.connectedCallback(); + this.ensureSubscriptions(); + this.ensureInitialData(); + } + + private ensureSubscriptions() { + const context = this.context; + if (!context || this.stopChannelsSubscription) { + return; + } + this.stopChannelsSubscription = context.channels.subscribe(this.requestPageUpdate); + this.stopConfigSubscription = context.runtimeConfig.subscribe(() => { + this.requestPageUpdate(); + this.ensureInitialData(); + }); + this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { + if (snapshot.connected && snapshot.client) { + this.ensureInitialData(); + } else { + this.schemaLoadStarted = false; + } + }); + } + + private ensureInitialData() { + const context = this.context; + const gateway = context.gateway.snapshot; + const client = gateway.client; + if (!gateway.connected || !client) { + return; + } + + const channels = context.channels.state; + const config = context.runtimeConfig.state; + if (!channels.channelsSnapshot && !channels.channelsLoading) { + void context.channels.refresh(false); + } + if (!config.configSnapshot && !config.configLoading) { + void context.runtimeConfig.ensureLoaded(); + } + if (!config.configSchema && !config.configSchemaLoading && !this.schemaLoadStarted) { + this.schemaLoadStarted = true; + void context.runtimeConfig.ensureSchemaLoaded(); + } + } + + override disconnectedCallback() { + this.stopChannelsSubscription?.(); + this.stopChannelsSubscription = undefined; + this.stopConfigSubscription?.(); + this.stopConfigSubscription = undefined; + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.schemaLoadStarted = false; + super.disconnectedCallback(); + } + + private async saveChannelConfig() { + const context = this.context; + if (!context) { + return; + } + const saved = await context.runtimeConfig.save(); + const saveError = context.runtimeConfig.state.lastError; + if (!saved) { + await context.runtimeConfig.refresh(); + if (saveError && !context.runtimeConfig.state.lastError) { + context.runtimeConfig.state.lastError = saveError; + } + this.requestUpdate(); + return; + } + await context.channels.refresh(true); + } + + private async reloadChannelConfig() { + const context = this.context; + if (!context) { + return; + } + await context.runtimeConfig.refresh({ discardPendingChanges: true }); + await context.channels.refresh(true); + } + + private resolveNostrAccountId(): string { + const accounts = this.context?.channels.state.channelsSnapshot?.channelAccounts?.nostr ?? []; + return accounts[0]?.accountId ?? this.nostrProfileAccountId ?? "default"; + } + + private buildGatewayHttpHeaders(): Record { + const context = this.context; + if (!context) { + return {}; + } + const authorization = resolveControlUiAuthHeader({ + hello: context.gateway.snapshot.hello, + settings: { token: context.gateway.connection.token }, + password: context.gateway.connection.password, + }); + return authorization ? { Authorization: authorization } : {}; + } + + private editNostrProfile(accountId: string, profile: NostrProfile | null) { + this.nostrProfileAccountId = accountId; + this.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined); + } + + private cancelNostrProfile() { + this.nostrProfileFormState = null; + this.nostrProfileAccountId = null; + } + + private changeNostrProfileField(field: keyof NostrProfile, value: string) { + const form = this.nostrProfileFormState; + if (!form) { + return; + } + this.nostrProfileFormState = { + ...form, + values: { ...form.values, [field]: value }, + fieldErrors: { ...form.fieldErrors, [field]: "" }, + }; + } + + private toggleNostrProfileAdvanced() { + const form = this.nostrProfileFormState; + if (!form) { + return; + } + this.nostrProfileFormState = { ...form, showAdvanced: !form.showAdvanced }; + } + + private async saveNostrProfile() { + const form = this.nostrProfileFormState; + if (!form || form.saving) { + return; + } + const accountId = this.resolveNostrAccountId(); + this.nostrProfileFormState = { + ...form, + saving: true, + error: null, + success: null, + fieldErrors: {}, + }; + + try { + const response = await fetch(buildNostrProfileUrl(accountId), { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...this.buildGatewayHttpHeaders(), + }, + body: JSON.stringify(form.values), + }); + const data = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + details?: unknown; + persisted?: boolean; + } | null; + + if (!response.ok || data?.ok === false || !data) { + this.nostrProfileFormState = { + ...form, + saving: false, + error: data?.error ?? `Profile update failed (${response.status})`, + success: null, + fieldErrors: parseValidationErrors(data?.details), + }; + return; + } + + if (!data.persisted) { + this.nostrProfileFormState = { + ...form, + saving: false, + error: "Profile publish failed on all relays.", + success: null, + }; + return; + } + + this.nostrProfileFormState = { + ...form, + saving: false, + error: null, + success: "Profile published to relays.", + fieldErrors: {}, + original: { ...form.values }, + }; + await this.context?.channels.refresh(true); + } catch (err) { + this.nostrProfileFormState = { + ...form, + saving: false, + error: `Profile update failed: ${String(err)}`, + success: null, + }; + } + } + + private async importNostrProfile() { + const form = this.nostrProfileFormState; + if (!form || form.importing) { + return; + } + const accountId = this.resolveNostrAccountId(); + this.nostrProfileFormState = { + ...form, + importing: true, + error: null, + success: null, + }; + + try { + const response = await fetch(buildNostrProfileUrl(accountId, "/import"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.buildGatewayHttpHeaders(), + }, + body: JSON.stringify({ autoMerge: true }), + }); + const data = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + imported?: NostrProfile; + merged?: NostrProfile; + saved?: boolean; + } | null; + + if (!response.ok || data?.ok === false || !data) { + this.nostrProfileFormState = { + ...form, + importing: false, + error: data?.error ?? `Profile import failed (${response.status})`, + success: null, + }; + return; + } + + const merged = data.merged ?? data.imported ?? null; + const values = merged ? { ...form.values, ...merged } : form.values; + this.nostrProfileFormState = { + ...form, + importing: false, + values, + error: null, + success: data.saved + ? "Profile imported from relays. Review and publish." + : "Profile imported. Review and publish.", + showAdvanced: Boolean(values.banner || values.website || values.nip05 || values.lud16), + }; + + if (data.saved) { + await this.context?.channels.refresh(true); + } + } catch (err) { + this.nostrProfileFormState = { + ...form, + importing: false, + error: `Profile import failed: ${String(err)}`, + success: null, + }; + } + } + + override render() { + const context = this.context; + const channels = context.channels.state; + const config = context.runtimeConfig.state; + return html` +
+
+
${titleForRoute("channels")}
+
${subtitleForRoute("channels")}
+
+
+ ${renderSettingsWorkspace( + context.basePath, + renderChannels({ + connected: channels.connected, + loading: channels.channelsLoading, + snapshot: channels.channelsSnapshot, + lastError: channels.channelsError, + lastSuccessAt: channels.channelsLastSuccess, + whatsappMessage: channels.whatsappLoginMessage, + whatsappQrDataUrl: channels.whatsappLoginQrDataUrl, + whatsappConnected: channels.whatsappLoginConnected, + whatsappBusy: channels.whatsappBusy, + configSchema: config.configSchema, + configSchemaLoading: config.configSchemaLoading, + configForm: config.configForm, + configUiHints: config.configUiHints, + configSaving: config.configSaving, + configFormDirty: config.configFormDirty, + nostrProfileFormState: this.nostrProfileFormState, + nostrProfileAccountId: this.nostrProfileAccountId, + onRefresh: (probe) => void context.channels.refresh(probe), + onWhatsAppStart: (force) => void context.channels.startWhatsApp(force), + onWhatsAppWait: () => void context.channels.waitWhatsApp(), + onWhatsAppLogout: () => void context.channels.logoutWhatsApp(), + onConfigPatch: (path, value) => context.runtimeConfig.patchForm(path, value), + onConfigSave: () => void this.saveChannelConfig(), + onConfigReload: () => void this.reloadChannelConfig(), + onNostrProfileEdit: (accountId, profile) => this.editNostrProfile(accountId, profile), + onNostrProfileCancel: () => this.cancelNostrProfile(), + onNostrProfileFieldChange: (field, value) => this.changeNostrProfileField(field, value), + onNostrProfileSave: () => void this.saveNostrProfile(), + onNostrProfileImport: () => void this.importNostrProfile(), + onNostrProfileToggleAdvanced: () => this.toggleNostrProfileAdvanced(), + }), + "channels", + (routeId) => context.navigate(routeId), + (routeId) => context.preload(routeId), + )} + `; + } +} + +if (!customElements.get("openclaw-channels-page")) { + customElements.define("openclaw-channels-page", ChannelsPage); +} diff --git a/ui/src/pages/channels/route.ts b/ui/src/pages/channels/route.ts new file mode 100644 index 000000000000..742bb2fafda2 --- /dev/null +++ b/ui/src/pages/channels/route.ts @@ -0,0 +1,27 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; + +function loadChannelsRoute(context: ApplicationContext) { + const primaryRefresh = Promise.all([ + context.channels.refresh(false), + context.runtimeConfig.ensureLoaded(), + ]); + void primaryRefresh.then( + () => { + void context.runtimeConfig.ensureSchemaLoaded(); + }, + () => undefined, + ); +} + +export const page = definePage({ + id: "channels", + path: "/channels", + loader: (context: ApplicationContext) => loadChannelsRoute(context), + component: () => + import("./channels-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/pages/channels/view.config.ts similarity index 94% rename from ui/src/ui/views/channels.config.ts rename to ui/src/pages/channels/view.config.ts index 9c4dc4d604ea..5ce2bcb438db 100644 --- a/ui/src/ui/views/channels.config.ts +++ b/ui/src/pages/channels/view.config.ts @@ -1,10 +1,15 @@ // Control UI view renders channels screen content. import { html } from "lit"; +import type { ConfigUiHints } from "../../api/types.ts"; +import { + analyzeConfigSchema, + renderNode, + schemaType, + type JsonSchema, +} from "../../components/config-form.ts"; import { t } from "../../i18n/index.ts"; -import type { ConfigUiHints } from "../types.ts"; -import { formatChannelExtraValue, resolveChannelConfigValue } from "./channel-config-extras.ts"; -import type { ChannelsProps } from "./channels.types.ts"; -import { analyzeConfigSchema, renderNode, schemaType, type JsonSchema } from "./config-form.ts"; +import { formatChannelExtraValue, resolveChannelConfigValue } from "../../lib/channels/index.ts"; +import type { ChannelsProps } from "./view.types.ts"; type ChannelConfigFormProps = { channelId: string; diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/pages/channels/view.discord.ts similarity index 83% rename from ui/src/ui/views/channels.discord.ts rename to ui/src/pages/channels/view.discord.ts index 4ffdb131d2a7..87f6f85f67f6 100644 --- a/ui/src/ui/views/channels.discord.ts +++ b/ui/src/pages/channels/view.discord.ts @@ -1,15 +1,15 @@ -// Control UI view renders channelsiscord screen content. +// Channels page renders Discord status. import { html, nothing } from "lit"; +import type { DiscordStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import type { DiscordStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { formatNullableBoolean, renderSingleAccountChannelCard, resolveChannelConfigured, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; export function renderDiscordCard(params: { props: ChannelsProps; diff --git a/ui/src/ui/views/channels.googlechat.ts b/ui/src/pages/channels/view.googlechat.ts similarity index 86% rename from ui/src/ui/views/channels.googlechat.ts rename to ui/src/pages/channels/view.googlechat.ts index b6157dd8842c..9a46722d6d34 100644 --- a/ui/src/ui/views/channels.googlechat.ts +++ b/ui/src/pages/channels/view.googlechat.ts @@ -1,15 +1,15 @@ -// Control UI view renders channels.googlechat screen content. +// Channels page renders Google Chat status. import { html, nothing } from "lit"; +import type { GoogleChatStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import type { GoogleChatStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { formatNullableBoolean, renderSingleAccountChannelCard, resolveChannelConfigured, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; export function renderGoogleChatCard(params: { props: ChannelsProps; diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/pages/channels/view.imessage.ts similarity index 84% rename from ui/src/ui/views/channels.imessage.ts rename to ui/src/pages/channels/view.imessage.ts index 0a5493ebc12e..3426b0279aa6 100644 --- a/ui/src/ui/views/channels.imessage.ts +++ b/ui/src/pages/channels/view.imessage.ts @@ -1,15 +1,15 @@ -// Control UI view renders channels.imessage screen content. +// Channels page renders iMessage status. import { html, nothing } from "lit"; +import type { IMessageStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import type { IMessageStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { formatNullableBoolean, renderSingleAccountChannelCard, resolveChannelConfigured, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; export function renderIMessageCard(params: { props: ChannelsProps; diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/pages/channels/view.nostr-profile-form.ts similarity index 99% rename from ui/src/ui/views/channels.nostr-profile-form.ts rename to ui/src/pages/channels/view.nostr-profile-form.ts index 47e236a4ee90..4226f097a9d2 100644 --- a/ui/src/ui/views/channels.nostr-profile-form.ts +++ b/ui/src/pages/channels/view.nostr-profile-form.ts @@ -5,8 +5,8 @@ */ import { html, nothing, type TemplateResult } from "lit"; +import type { NostrProfile as NostrProfileType } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import type { NostrProfile as NostrProfileType } from "../types.ts"; // ============================================================================ // Types diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/pages/channels/view.nostr.ts similarity index 96% rename from ui/src/ui/views/channels.nostr.ts rename to ui/src/pages/channels/view.nostr.ts index e9e53d3146f1..c13b1b1d68a0 100644 --- a/ui/src/ui/views/channels.nostr.ts +++ b/ui/src/pages/channels/view.nostr.ts @@ -1,15 +1,15 @@ -// Control UI view renders channels.nostr screen content. +// Channels page renders Nostr status. import { html, nothing } from "lit"; +import type { ChannelAccountSnapshot, NostrStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import type { ChannelAccountSnapshot, NostrStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { renderNostrProfileForm, type NostrProfileFormState, type NostrProfileFormCallbacks, -} from "./channels.nostr-profile-form.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.nostr-profile-form.ts"; +import type { ChannelsProps } from "./view.types.ts"; /** * Truncate a pubkey for display (shows first and last 8 chars) diff --git a/ui/src/ui/views/channels.shared.ts b/ui/src/pages/channels/view.shared.ts similarity index 95% rename from ui/src/ui/views/channels.shared.ts rename to ui/src/pages/channels/view.shared.ts index 6fffd690d64b..8d0507f9f9d4 100644 --- a/ui/src/ui/views/channels.shared.ts +++ b/ui/src/pages/channels/view.shared.ts @@ -1,8 +1,8 @@ -// Control UI view renders channels.shared screen content. +// Channels page shared view helpers. import { html, nothing } from "lit"; +import type { ChannelAccountSnapshot } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import type { ChannelAccountSnapshot } from "../types.ts"; -import type { ChannelKey, ChannelsProps } from "./channels.types.ts"; +import type { ChannelKey, ChannelsProps } from "./view.types.ts"; type ChannelDisplayState = { configured: boolean | null; diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/pages/channels/view.signal.ts similarity index 84% rename from ui/src/ui/views/channels.signal.ts rename to ui/src/pages/channels/view.signal.ts index 5d681917ce3c..188234bafc77 100644 --- a/ui/src/ui/views/channels.signal.ts +++ b/ui/src/pages/channels/view.signal.ts @@ -1,15 +1,15 @@ -// Control UI view renders channels.signal screen content. +// Channels page renders Signal status. import { html, nothing } from "lit"; +import type { SignalStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import type { SignalStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { formatNullableBoolean, renderSingleAccountChannelCard, resolveChannelConfigured, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; export function renderSignalCard(params: { props: ChannelsProps; diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/pages/channels/view.slack.ts similarity index 83% rename from ui/src/ui/views/channels.slack.ts rename to ui/src/pages/channels/view.slack.ts index 99d111bc7b30..268ae0d86c13 100644 --- a/ui/src/ui/views/channels.slack.ts +++ b/ui/src/pages/channels/view.slack.ts @@ -1,15 +1,15 @@ -// Control UI view renders channels.slack screen content. +// Channels page renders Slack status. import { html, nothing } from "lit"; +import type { SlackStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import type { SlackStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { formatNullableBoolean, renderSingleAccountChannelCard, resolveChannelConfigured, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; export function renderSlackCard(params: { props: ChannelsProps; diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/pages/channels/view.telegram.ts similarity index 93% rename from ui/src/ui/views/channels.telegram.ts rename to ui/src/pages/channels/view.telegram.ts index 30d9994f098b..ed25947c3db7 100644 --- a/ui/src/ui/views/channels.telegram.ts +++ b/ui/src/pages/channels/view.telegram.ts @@ -1,15 +1,15 @@ -// Control UI view renders channels.telegram screen content. +// Channels page renders Telegram status. import { html, nothing } from "lit"; +import type { ChannelAccountSnapshot, TelegramStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; -import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { formatNullableBoolean, renderSingleAccountChannelCard, resolveChannelConfigured, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; export function renderTelegramCard(params: { props: ChannelsProps; diff --git a/ui/src/ui/views/channels.test.ts b/ui/src/pages/channels/view.test.ts similarity index 96% rename from ui/src/ui/views/channels.test.ts rename to ui/src/pages/channels/view.test.ts index b40011c3d711..acdd5f17098d 100644 --- a/ui/src/ui/views/channels.test.ts +++ b/ui/src/pages/channels/view.test.ts @@ -1,14 +1,14 @@ -// Control UI tests cover channels behavior. +// Channels page view tests. import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { WhatsAppStatus } from "../types.ts"; +import type { WhatsAppStatus } from "../../api/types.ts"; import { channelEnabled, resolveChannelConfigured, resolveChannelDisplayState, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; -import { renderWhatsAppCard } from "./channels.whatsapp.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; +import { renderWhatsAppCard } from "./view.whatsapp.ts"; function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps { return { diff --git a/ui/src/ui/views/channels.ts b/ui/src/pages/channels/view.ts similarity index 93% rename from ui/src/ui/views/channels.ts rename to ui/src/pages/channels/view.ts index e64ff8714bcf..98cd5a7e5513 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/pages/channels/view.ts @@ -1,7 +1,5 @@ -// Control UI view renders channels screen content. +// Channels page renders its screen content. import { html, nothing } from "lit"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../format.ts"; import type { ChannelAccountSnapshot, ChannelUiMetaEntry, @@ -15,23 +13,25 @@ import type { SlackStatus, TelegramStatus, WhatsAppStatus, -} from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; -import { renderDiscordCard } from "./channels.discord.ts"; -import { renderGoogleChatCard } from "./channels.googlechat.ts"; -import { renderIMessageCard } from "./channels.imessage.ts"; -import { renderNostrCard } from "./channels.nostr.ts"; +} from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; +import { renderDiscordCard } from "./view.discord.ts"; +import { renderGoogleChatCard } from "./view.googlechat.ts"; +import { renderIMessageCard } from "./view.imessage.ts"; +import { renderNostrCard } from "./view.nostr.ts"; import { channelEnabled, formatNullableBoolean, renderChannelAccountCount, resolveChannelDisplayState, -} from "./channels.shared.ts"; -import { renderSignalCard } from "./channels.signal.ts"; -import { renderSlackCard } from "./channels.slack.ts"; -import { renderTelegramCard } from "./channels.telegram.ts"; -import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types.ts"; -import { renderWhatsAppCard } from "./channels.whatsapp.ts"; +} from "./view.shared.ts"; +import { renderSignalCard } from "./view.signal.ts"; +import { renderSlackCard } from "./view.slack.ts"; +import { renderTelegramCard } from "./view.telegram.ts"; +import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./view.types.ts"; +import { renderWhatsAppCard } from "./view.whatsapp.ts"; export function renderChannels(props: ChannelsProps) { const channels = props.snapshot?.channels as Record | null; diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/pages/channels/view.types.ts similarity index 91% rename from ui/src/ui/views/channels.types.ts rename to ui/src/pages/channels/view.types.ts index 8b656c4ac982..faad2342d31d 100644 --- a/ui/src/ui/views/channels.types.ts +++ b/ui/src/pages/channels/view.types.ts @@ -1,4 +1,4 @@ -// Control UI type declarations define channels contracts. +// Channels page view contracts. import type { ChannelAccountSnapshot, ChannelsStatusSnapshot, @@ -12,8 +12,8 @@ import type { SlackStatus, TelegramStatus, WhatsAppStatus, -} from "../types.ts"; -import type { NostrProfileFormState } from "./channels.nostr-profile-form.ts"; +} from "../../api/types.ts"; +import type { NostrProfileFormState } from "./view.nostr-profile-form.ts"; export type ChannelKey = string; diff --git a/ui/src/ui/views/channels.whatsapp.ts b/ui/src/pages/channels/view.whatsapp.ts similarity index 92% rename from ui/src/ui/views/channels.whatsapp.ts rename to ui/src/pages/channels/view.whatsapp.ts index 26f6908ac8ce..fe52b5316177 100644 --- a/ui/src/ui/views/channels.whatsapp.ts +++ b/ui/src/pages/channels/view.whatsapp.ts @@ -1,15 +1,15 @@ -// Control UI view renders channels.whatsapp screen content. +// Channels page renders WhatsApp status. import { html, nothing } from "lit"; +import type { WhatsAppStatus } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; -import type { WhatsAppStatus } from "../types.ts"; -import { renderChannelConfigSection } from "./channels.config.ts"; +import { formatRelativeTimestamp, formatDurationHuman } from "../../lib/format.ts"; +import { renderChannelConfigSection } from "./view.config.ts"; import { formatNullableBoolean, renderSingleAccountChannelCard, resolveChannelConfigured, -} from "./channels.shared.ts"; -import type { ChannelsProps } from "./channels.types.ts"; +} from "./view.shared.ts"; +import type { ChannelsProps } from "./view.types.ts"; export function renderWhatsAppCard(params: { props: ChannelsProps; diff --git a/ui/src/ui/chat/attachment-payload-store.ts b/ui/src/pages/chat/attachment-payload-store.ts similarity index 97% rename from ui/src/ui/chat/attachment-payload-store.ts rename to ui/src/pages/chat/attachment-payload-store.ts index cde8971d4a22..bc16d7d694c6 100644 --- a/ui/src/ui/chat/attachment-payload-store.ts +++ b/ui/src/pages/chat/attachment-payload-store.ts @@ -1,5 +1,5 @@ // Control UI chat module implements attachment payload store behavior. -import type { ChatAttachment } from "../ui-types.ts"; +import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; type AttachmentPayload = { dataUrl?: string; diff --git a/ui/src/ui/chat/chat-avatar.test.ts b/ui/src/pages/chat/chat-avatar.test.ts similarity index 100% rename from ui/src/ui/chat/chat-avatar.test.ts rename to ui/src/pages/chat/chat-avatar.test.ts diff --git a/ui/src/pages/chat/chat-avatar.ts b/ui/src/pages/chat/chat-avatar.ts new file mode 100644 index 000000000000..c7ab8c1c7bfd --- /dev/null +++ b/ui/src/pages/chat/chat-avatar.ts @@ -0,0 +1,332 @@ +// Control UI chat module implements chat avatar behavior. +import { html } from "lit"; +import type { GatewayHelloOk } from "../../api/gateway.ts"; +import { normalizeBasePath } from "../../app-route-paths.ts"; +import { resolveControlUiAuthHeader } from "../../app/control-ui-auth.ts"; +import { + resolveLocalUserAvatarText, + resolveLocalUserAvatarUrl, + resolveLocalUserName, +} from "../../app/user-identity.ts"; +import { + assistantAvatarFallbackUrl, + resolveAssistantTextAvatar, +} from "../../lib/agents/display.ts"; +import type { AssistantIdentity } from "../../lib/assistant-identity.ts"; +import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts"; +import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts"; +import { + DEFAULT_AGENT_ID, + isUiGlobalSessionKey, + parseAgentSessionKey, + resolveUiSelectedGlobalAgentId, +} from "../../lib/sessions/session-key.ts"; + +export function renderChatAvatar( + role: string, + assistant?: Pick, + user?: { name?: string | null; avatar?: string | null }, + basePath?: string, + authToken?: string | null, +) { + const normalized = normalizeRoleForGrouping(role); + const assistantName = assistant?.name?.trim() || "Assistant"; + const assistantAvatar = assistant?.avatar?.trim() || ""; + const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar); + const assistantFallbackAvatar = assistantAvatarFallbackUrl(basePath ?? ""); + const userName = resolveLocalUserName(user); + const userAvatarUrl = resolveLocalUserAvatarUrl(user); + const userAvatarText = resolveLocalUserAvatarText(user); + const initial = + normalized === "user" + ? html` + + + + + ` + : normalized === "assistant" + ? html` + + + + ` + : normalized === "tool" + ? html` + + + + ` + : html` + + + + ? + + + `; + const className = + normalized === "user" + ? "user" + : normalized === "assistant" + ? "assistant" + : normalized === "tool" + ? "tool" + : "other"; + + if (normalized === "user" && userAvatarUrl) { + return html`${userName}`; + } + + if (normalized === "user" && userAvatarText) { + return html`
+ ${userAvatarText} +
`; + } + + if (assistantAvatar && normalized === "assistant") { + if (isAvatarUrl(assistantAvatar)) { + if (authToken?.trim() && assistantAvatar.startsWith("/")) { + return html``; + } + return html`${assistantName}`; + } + if (assistantAvatarText) { + return html`
+ ${assistantAvatarText} +
`; + } + return html``; + } + + if (normalized === "assistant") { + return html``; + } + + return html`
${initial}
`; +} + +function isAvatarUrl(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed); +} + +export type ChatAvatarHost = { + assistantAgentId?: string | null; + agentsList?: { defaultId?: string | null } | null; + basePath: string; + chatAvatarReason?: string | null; + chatAvatarSource?: string | null; + chatAvatarStatus?: "none" | "local" | "remote" | "data" | null; + chatAvatarUrl: string | null; + connected: boolean; + hello: GatewayHelloOk | null; + password?: string | null; + sessionKey: string; + settings?: { token?: string | null } | null; +}; + +const chatAvatarRequestVersions = new WeakMap(); +const chatAvatarObjectUrls = new WeakMap(); + +function readHelloDefaultAgentId(host: Pick): string | undefined { + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: { defaultAgentId?: string } } + | undefined; + return snapshot?.sessionDefaults?.defaultAgentId?.trim() || undefined; +} + +export function resolveAgentIdForSession(host: ChatAvatarHost): string | null { + const parsed = parseAgentSessionKey(host.sessionKey); + if (parsed?.agentId) { + return parsed.agentId; + } + if (isUiGlobalSessionKey(host.sessionKey)) { + return resolveUiSelectedGlobalAgentId(host) || DEFAULT_AGENT_ID; + } + return readHelloDefaultAgentId(host) || DEFAULT_AGENT_ID; +} + +function beginChatAvatarRequest(host: ChatAvatarHost): number { + const key = host as object; + const nextVersion = (chatAvatarRequestVersions.get(key) ?? 0) + 1; + chatAvatarRequestVersions.set(key, nextVersion); + return nextVersion; +} + +function shouldApplyChatAvatarResult( + host: ChatAvatarHost, + version: number, + sessionKey: string, + agentId: string | null, +): boolean { + return ( + chatAvatarRequestVersions.get(host as object) === version && + host.sessionKey === sessionKey && + resolveAgentIdForSession(host) === agentId + ); +} + +function buildAvatarMetaUrl(basePath: string, agentId: string): string { + const base = normalizeBasePath(basePath); + const encoded = encodeURIComponent(agentId); + return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`; +} + +function clearChatAvatarUrl(host: ChatAvatarHost) { + const key = host as object; + const previousBlobUrl = chatAvatarObjectUrls.get(key); + if (previousBlobUrl) { + URL.revokeObjectURL(previousBlobUrl); + chatAvatarObjectUrls.delete(key); + } + host.chatAvatarUrl = null; +} + +function clearChatAvatarState(host: ChatAvatarHost) { + clearChatAvatarUrl(host); + host.chatAvatarSource = null; + host.chatAvatarStatus = null; + host.chatAvatarReason = null; +} + +function setChatAvatarUrl(host: ChatAvatarHost, nextUrl: string | null) { + const key = host as object; + const previousBlobUrl = chatAvatarObjectUrls.get(key); + if (previousBlobUrl && previousBlobUrl !== nextUrl) { + URL.revokeObjectURL(previousBlobUrl); + chatAvatarObjectUrls.delete(key); + } + if (nextUrl?.startsWith("blob:")) { + chatAvatarObjectUrls.set(key, nextUrl); + } + host.chatAvatarUrl = nextUrl; +} + +function setChatAvatarMeta( + host: ChatAvatarHost, + data: { + avatarSource?: unknown; + avatarStatus?: unknown; + avatarReason?: unknown; + }, +) { + const status = + data.avatarStatus === "none" || + data.avatarStatus === "local" || + data.avatarStatus === "remote" || + data.avatarStatus === "data" + ? data.avatarStatus + : null; + host.chatAvatarSource = + typeof data.avatarSource === "string" && data.avatarSource.trim() + ? data.avatarSource.trim() + : null; + host.chatAvatarStatus = status; + host.chatAvatarReason = + typeof data.avatarReason === "string" && data.avatarReason.trim() + ? data.avatarReason.trim() + : null; +} + +function buildControlUiAuthHeaders(authHeader: string | null): Record | undefined { + return authHeader ? { Authorization: authHeader } : undefined; +} + +function isLocalControlUiAvatarUrl(avatarUrl: string): boolean { + return avatarUrl.startsWith("/"); +} + +export async function refreshChatAvatar(host: ChatAvatarHost) { + if (!host.connected) { + clearChatAvatarState(host); + return; + } + const sessionKey = host.sessionKey; + const requestVersion = beginChatAvatarRequest(host); + const agentId = resolveAgentIdForSession(host); + if (!agentId) { + if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey, agentId)) { + clearChatAvatarState(host); + } + return; + } + clearChatAvatarState(host); + const authHeader = resolveControlUiAuthHeader(host); + const headers = buildControlUiAuthHeaders(authHeader); + const url = buildAvatarMetaUrl(host.basePath, agentId); + try { + const res = await fetch(url, { method: "GET", ...(headers ? { headers } : {}) }); + if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey, agentId)) { + return; + } + if (!res.ok) { + clearChatAvatarState(host); + return; + } + const data = (await res.json()) as { + avatarUrl?: unknown; + avatarSource?: unknown; + avatarStatus?: unknown; + avatarReason?: unknown; + }; + if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey, agentId)) { + return; + } + setChatAvatarMeta(host, data); + const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : ""; + if (!avatarUrl || !isRenderableControlUiAvatarUrl(avatarUrl)) { + clearChatAvatarUrl(host); + return; + } + if (!isLocalControlUiAvatarUrl(avatarUrl)) { + setChatAvatarUrl(host, avatarUrl); + return; + } + const avatarRes = await fetch(avatarUrl, { + method: "GET", + ...(headers ? { headers } : {}), + }); + if (!avatarRes.ok) { + if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey, agentId)) { + clearChatAvatarUrl(host); + } + return; + } + const blobUrl = URL.createObjectURL(await avatarRes.blob()); + if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey, agentId)) { + URL.revokeObjectURL(blobUrl); + return; + } + setChatAvatarUrl(host, blobUrl); + } catch { + if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey, agentId)) { + clearChatAvatarState(host); + } + } +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/pages/chat/chat-command-executor.test.ts similarity index 96% rename from ui/src/ui/chat/slash-command-executor.node.test.ts rename to ui/src/pages/chat/chat-command-executor.test.ts index a92158848482..117bafb31ac4 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/pages/chat/chat-command-executor.test.ts @@ -1,14 +1,56 @@ // @vitest-environment node import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import type { SessionCapability, SessionPatch } from "../../lib/sessions/index.ts"; import { createResolvedModelPatch, createModelCatalog, DEEPSEEK_CHAT_MODEL, OPENAI_GPT5_MINI_MODEL, -} from "../chat-model.test-helpers.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; -import { executeSlashCommand } from "./slash-command-executor.ts"; +} from "../../test-helpers/chat-model.ts"; +import { executeSlashCommand as executeSlashCommandImpl } from "./chat-command-executor.ts"; + +function createSessionCapability(client: GatewayBrowserClient): SessionCapability { + const request = client.request.bind(client); + return { + state: { + result: null, + agentId: null, + loading: false, + error: null, + }, + list: (options = {}) => request("sessions.list", options), + refresh: async () => undefined, + create: async () => null, + patch: (key: string, patch: SessionPatch, options: { agentId?: string | null } = {}) => + request("sessions.patch", { key, ...options, ...patch }), + delete: async () => false, + deleteMany: async () => ({ deleted: [], errors: [] }), + reset: async () => undefined, + compact: (key: string, options: { agentId?: string | null } = {}) => + request("sessions.compact", { key, ...options }), + steer: (key: string, message: string, options: { agentId?: string | null } = {}) => + request("sessions.steer", { key, ...options, message }), + listFiles: async () => null, + getFile: async () => null, + subscribe: () => () => undefined, + dispose: () => undefined, + } as unknown as SessionCapability; +} + +function executeSlashCommand( + client: GatewayBrowserClient, + sessionKey: string, + commandName: string, + args: string, + context: Omit[4], "sessions"> = {}, +) { + return executeSlashCommandImpl(client, sessionKey, commandName, args, { + sessions: createSessionCapability(client), + ...context, + }); +} function row(key: string, overrides?: Partial): GatewaySessionRow { return { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/pages/chat/chat-command-executor.ts similarity index 74% rename from ui/src/ui/chat/slash-command-executor.ts rename to ui/src/pages/chat/chat-command-executor.ts index fb7eee370984..44600696a03a 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/pages/chat/chat-command-executor.ts @@ -3,39 +3,42 @@ * Calls gateway RPC methods and returns formatted results. */ +import { formatFastModeCommandOptions } from "../../../../src/shared/fast-mode.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + AgentsListResult, + GatewaySessionRow, + ModelCatalogEntry, + SessionsListResult, +} from "../../api/types.ts"; +import { SLASH_COMMANDS } from "../../lib/chat/commands.ts"; import { - formatFastModeCommandOptions, - formatFastModeCurrentStatus, -} from "../../../../src/shared/fast-mode.js"; -import { + type ChatModelOverride, createChatModelOverride, resolvePreferredServerChatModelValue, -} from "../chat-model-ref.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, parseAgentSessionKey } from "../session-key.ts"; -import { sessionModelMatchesDefaults } from "../session-model-defaults.ts"; +} from "../../lib/chat/model-ref.ts"; +import { + normalizeChatFastModeInput, + resolveChatFastModeStatus, +} from "../../lib/chat/model-select-state.ts"; +import { + formatThinkingCommandOptionsForSession, + isThinkingLevelOptionForSession, + resolveCurrentThinkingLevel, + resolveThinkingLevelInput, +} from "../../lib/chat/thinking.ts"; +import { formatCompactTokenCount } from "../../lib/format.ts"; +import type { SessionCapability, SessionPatch } from "../../lib/sessions/index.ts"; +import { + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + parseAgentSessionKey, +} from "../../lib/sessions/session-key.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, -} from "../string-coerce.ts"; -import { - formatThinkingLevels, - normalizeThinkLevel, - resolveThinkingDefaultForModel, -} from "../thinking.ts"; -import type { - AgentsListResult, - ChatModelOverride, - GatewaySessionRow, - GatewayThinkingLevelOption, - FastMode, - ModelCatalogEntry, - SessionsListResult, - SessionsPatchResult, -} from "../types.ts"; -import { generateUUID } from "../uuid.ts"; -import { SLASH_COMMANDS } from "./slash-commands.ts"; -import { formatCompactTokenCount } from "./token-format.ts"; +} from "../../lib/string-coerce.ts"; +import { generateUUID } from "../../lib/uuid.ts"; export type SlashCommandResult = { /** Markdown-formatted result to display in chat. */ @@ -53,6 +56,7 @@ export type SlashCommandResult = { }; export type SlashCommandContext = { + sessions: SessionCapability; chatModelCatalog?: ModelCatalogEntry[]; modelCatalog?: ModelCatalogEntry[]; sessionsResult?: SessionsListResult | null; @@ -89,7 +93,7 @@ export async function executeSlashCommand( sessionKey: string, commandName: string, args: string, - context: SlashCommandContext = {}, + context: SlashCommandContext, ): Promise { switch (commandName) { case "help": @@ -103,7 +107,7 @@ export async function executeSlashCommand( case "clear": return { content: "Chat history cleared.", action: "clear" }; case "compact": - return await executeCompact(client, sessionKey, context); + return await executeCompact(sessionKey, context); case "model": return await executeModel(client, sessionKey, args, context); case "think": @@ -115,7 +119,7 @@ export async function executeSlashCommand( case "export-session": return { content: "Exporting session...", action: "export" }; case "usage": - return await executeUsage(client, sessionKey); + return await executeUsage(sessionKey, context); case "agents": return await executeAgents(client); case "steer": @@ -149,17 +153,14 @@ function executeHelp(): SlashCommandResult { } async function executeCompact( - client: GatewayBrowserClient, sessionKey: string, context: SlashCommandContext, ): Promise { try { - const result = await client.request<{ - ok?: boolean; - compacted?: boolean; - reason?: string; - result?: { tokensBefore?: number; tokensAfter?: number }; - }>("sessions.compact", { key: sessionKey, ...selectedGlobalScope(sessionKey, context) }); + const result = await context.sessions.compact( + sessionKey, + selectedGlobalScope(sessionKey, context), + ); if (result?.ok !== true) { const reason = typeof result?.reason === "string" ? result.reason.trim() : ""; return { content: reason ? `Compaction failed: ${reason}` : "Compaction failed." }; @@ -192,7 +193,7 @@ async function executeModel( if (!args) { try { const [sessions, models] = await Promise.all([ - client.request("sessions.list", {}), + listSessions(context), modelCatalog ? Promise.resolve(modelCatalog) : loadModelCatalog(client), ]); const session = resolveCurrentSession(sessions, sessionKey); @@ -216,9 +217,7 @@ async function executeModel( try { const requestedModel = args.trim(); const [patched, resolvedModelCatalog] = await Promise.all([ - client.request("sessions.patch", { - key: sessionKey, - ...selectedGlobalScope(sessionKey, context), + patchSession(context, sessionKey, { model: requestedModel, }), modelCatalog @@ -262,7 +261,11 @@ async function executeThink( if (!rawLevel) { try { - const { session, defaults, models } = await loadThinkingCommandState(client, sessionKey); + const { session, defaults, models } = await loadThinkingCommandState( + client, + context, + sessionKey, + ); return { content: formatDirectiveOptions( `Current thinking level: ${resolveCurrentThinkingLevel(session, defaults, models)}.`, @@ -276,9 +279,7 @@ async function executeThink( if (isSessionDefaultDirectiveValue(rawLevel)) { try { - await client.request("sessions.patch", { - key: sessionKey, - ...selectedGlobalScope(sessionKey, context), + await patchSession(context, sessionKey, { thinkingLevel: null, }); return { @@ -291,7 +292,7 @@ async function executeThink( } try { - const { session, defaults } = await loadCurrentSessionState(client, sessionKey); + const { session, defaults } = await loadCurrentSessionState(context, sessionKey); const level = resolveThinkingLevelInput(rawLevel, session, defaults); if (!level) { return { @@ -303,9 +304,7 @@ async function executeThink( content: `Unsupported thinking level "${rawLevel}" for this model. Valid levels: ${formatThinkingCommandOptionsForSession(session, defaults)}.`, }; } - await client.request("sessions.patch", { - key: sessionKey, - ...selectedGlobalScope(sessionKey, context), + await patchSession(context, sessionKey, { thinkingLevel: level, }); return { @@ -318,7 +317,7 @@ async function executeThink( } async function executeVerbose( - client: GatewayBrowserClient, + _client: GatewayBrowserClient, sessionKey: string, args: string, context: SlashCommandContext, @@ -327,7 +326,7 @@ async function executeVerbose( if (!rawLevel) { try { - const session = await loadCurrentSession(client, sessionKey); + const session = await loadCurrentSession(context, sessionKey); return { content: formatDirectiveOptions( `Current verbose level: ${normalizeVerboseLevel(session?.verboseLevel) ?? "off"}.`, @@ -347,9 +346,7 @@ async function executeVerbose( } try { - await client.request("sessions.patch", { - key: sessionKey, - ...selectedGlobalScope(sessionKey, context), + await patchSession(context, sessionKey, { verboseLevel: level, }); return { @@ -361,21 +358,8 @@ async function executeVerbose( } } -function normalizeFastMode(raw: string): FastMode | undefined { - if (raw === "auto") { - return "auto"; - } - if (raw === "on") { - return true; - } - if (raw === "off") { - return false; - } - return undefined; -} - async function executeFast( - client: GatewayBrowserClient, + _client: GatewayBrowserClient, sessionKey: string, args: string, context: SlashCommandContext, @@ -384,10 +368,10 @@ async function executeFast( if (!rawMode || rawMode === "status") { try { - const session = await loadCurrentSession(client, sessionKey); + const session = await loadCurrentSession(context, sessionKey); return { content: formatDirectiveOptions( - resolveCurrentFastModeStatus(session), + resolveChatFastModeStatus(session), formatFastModeCommandOptions({ fastAutoOnSeconds: session?.fastAutoOnSeconds, }), @@ -400,9 +384,7 @@ async function executeFast( if (isSessionDefaultDirectiveValue(rawMode)) { try { - await client.request("sessions.patch", { - key: sessionKey, - ...selectedGlobalScope(sessionKey, context), + await patchSession(context, sessionKey, { fastMode: null, }); return { @@ -414,7 +396,7 @@ async function executeFast( } } - const nextMode = normalizeFastMode(rawMode); + const nextMode = normalizeChatFastModeInput(rawMode); if (nextMode === undefined) { return { content: `Unrecognized fast mode "${args.trim()}". Valid levels: on, off, auto, default, status.`, @@ -422,9 +404,7 @@ async function executeFast( } try { - await client.request("sessions.patch", { - key: sessionKey, - ...selectedGlobalScope(sessionKey, context), + await patchSession(context, sessionKey, { fastMode: nextMode, }); return { @@ -440,11 +420,11 @@ async function executeFast( } async function executeUsage( - client: GatewayBrowserClient, sessionKey: string, + context: SlashCommandContext, ): Promise { try { - const sessions = await client.request("sessions.list", {}); + const sessions = await listSessions(context); const session = resolveCurrentSession(sessions, sessionKey); if (!session) { return { content: "No active session." }; @@ -554,92 +534,48 @@ function formatDirectiveOptions(text: string, options: string): string { return `${text}\nOptions: ${options}.`; } -function formatThinkingOptionsForSession( - session: GatewaySessionRow | undefined, - defaults?: SessionsListResult["defaults"], - separator = ", ", -): string { - return resolveThinkingLevelOptionsForSession(session, defaults) - .map((level) => level.label) - .join(separator); -} - -function formatThinkingCommandOptionsForSession( - session: GatewaySessionRow | undefined, - defaults?: SessionsListResult["defaults"], -): string { - const options = formatThinkingOptionsForSession(session, defaults); - return options.split(", ").includes("default") ? options : `default, ${options}`; -} - -function resolveThinkingLevelInput( - rawLevel: string, - session: GatewaySessionRow | undefined, - defaults: SessionsListResult["defaults"] | undefined, -): string | undefined { - const normalized = normalizeThinkLevel(rawLevel); - if (normalized) { - return normalized; +async function listSessions( + context: SlashCommandContext, + options?: Parameters[0], +): Promise { + const result = await context.sessions.list(options); + if (!result) { + throw new Error("Session capability is unavailable"); } - const rawKey = normalizeLowercaseStringOrEmpty(rawLevel); - return resolveThinkingLevelOptionsForSession(session, defaults) - .map((option) => ({ - id: normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id), - label: normalizeLowercaseStringOrEmpty(option.label), - })) - .find((option) => option.id === rawKey || option.label === rawKey)?.id; + return result; } -function isThinkingLevelOptionForSession( - session: GatewaySessionRow | undefined, - defaults: SessionsListResult["defaults"] | undefined, - level: string, -): boolean { - return resolveThinkingLevelOptionsForSession(session, defaults).some((option) => { - const id = normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id); - return id === level || normalizeThinkLevel(option.label) === level; - }); -} - -function resolveThinkingLevelOptionsForSession( - session: GatewaySessionRow | undefined, - defaults: SessionsListResult["defaults"] | undefined, -): GatewayThinkingLevelOption[] { - if (session?.thinkingLevels?.length) { - return session.thinkingLevels; +async function patchSession( + context: SlashCommandContext, + sessionKey: string, + patch: SessionPatch, +): Promise>>> { + const result = await context.sessions.patch( + sessionKey, + patch, + selectedGlobalScope(sessionKey, context), + ); + if (!result) { + throw new Error("Session capability is unavailable"); } - const matchesDefaults = sessionModelMatchesDefaults(session, defaults); - if (matchesDefaults && defaults?.thinkingLevels?.length) { - return defaults.thinkingLevels; - } - const labels = - (session?.thinkingOptions?.length ? session.thinkingOptions : null) ?? - (matchesDefaults && defaults?.thinkingOptions?.length ? defaults.thinkingOptions : null) ?? - formatThinkingLevels( - session?.modelProvider ?? defaults?.modelProvider, - session?.model ?? defaults?.model, - ).split(/\s*,\s*/); - return labels.filter(Boolean).map((label) => ({ - id: normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label), - label, - })); + return result; } async function loadCurrentSession( - client: GatewayBrowserClient, + context: SlashCommandContext, sessionKey: string, ): Promise { - return (await loadCurrentSessionState(client, sessionKey)).session; + return (await loadCurrentSessionState(context, sessionKey)).session; } async function loadCurrentSessionState( - client: GatewayBrowserClient, + context: SlashCommandContext, sessionKey: string, ): Promise<{ session: GatewaySessionRow | undefined; defaults: SessionsListResult["defaults"] | undefined; }> { - const sessions = await client.request("sessions.list", {}); + const sessions = await listSessions(context); return { session: resolveCurrentSession(sessions, sessionKey), defaults: sessions?.defaults, @@ -663,11 +599,12 @@ function resolveCurrentSession( }); } -async function loadThinkingCommandState(client: GatewayBrowserClient, sessionKey: string) { - const [sessions, models] = await Promise.all([ - client.request("sessions.list", {}), - loadModelCatalog(client), - ]); +async function loadThinkingCommandState( + client: GatewayBrowserClient, + context: SlashCommandContext, + sessionKey: string, +) { + const [sessions, models] = await Promise.all([listSessions(context), loadModelCatalog(client)]); return { session: resolveCurrentSession(sessions, sessionKey), defaults: sessions?.defaults, @@ -692,46 +629,6 @@ async function loadModelCatalog( } } -function resolveCurrentThinkingLevel( - session: GatewaySessionRow | undefined, - defaults: SessionsListResult["defaults"] | undefined, - models: ModelCatalogEntry[], -): string { - const persisted = normalizeThinkLevel(session?.thinkingLevel); - if (persisted) { - return ( - resolveThinkingLevelOptionsForSession(session, defaults).find( - (level) => normalizeThinkLevel(level.id) === persisted, - )?.label ?? persisted - ); - } - if (session?.thinkingDefault) { - return session.thinkingDefault; - } - if ((!session || sessionModelMatchesDefaults(session, defaults)) && defaults?.thinkingDefault) { - return defaults.thinkingDefault; - } - const provider = session?.modelProvider ?? defaults?.modelProvider; - const model = session?.model ?? defaults?.model; - if (!provider || !model) { - return "off"; - } - return resolveThinkingDefaultForModel({ - provider, - model, - catalog: models, - }); -} - -function resolveCurrentFastModeStatus(session: GatewaySessionRow | undefined): string { - const mode = session?.effectiveFastMode ?? session?.fastMode; - return formatFastModeCurrentStatus({ - mode, - source: session?.effectiveFastModeSource, - fastAutoOnSeconds: session?.fastAutoOnSeconds, - }); -} - async function resolveSteerTarget( sessionKey: string, args: string, @@ -798,10 +695,7 @@ async function executeSteer( } const sessions = context.sessionsResult ?? - (await client.request( - "sessions.list", - selectedGlobalScope(sessionKey, context), - )); + (await listSessions(context, selectedGlobalScope(sessionKey, context))); const targetSession = resolveCurrentSession(sessions, resolved.key); if (!isActiveSteerSession(targetSession)) { return { @@ -833,7 +727,7 @@ async function executeSteer( /** Hard redirect — aborts the active run and restarts with a new message. */ async function executeRedirect( - client: GatewayBrowserClient, + _client: GatewayBrowserClient, sessionKey: string, args: string, context: SlashCommandContext, @@ -845,11 +739,11 @@ async function executeRedirect( content: resolved.error === "empty" ? "Usage: `/redirect `" : resolved.error, }; } - const resp = await client.request<{ runId?: string; status?: unknown }>("sessions.steer", { - key: resolved.key, - ...selectedGlobalScope(resolved.key, context), - message: resolved.message, - }); + const resp = await context.sessions.steer( + resolved.key, + resolved.message, + selectedGlobalScope(resolved.key, context), + ); const ackStatus = normalizeSteerChatSendAckStatus(resp); const terminalAckContent = formatTerminalRedirectAckContent(ackStatus); if (terminalAckContent) { diff --git a/ui/src/pages/chat/chat-commands.test.ts b/ui/src/pages/chat/chat-commands.test.ts new file mode 100644 index 000000000000..b606ced98146 --- /dev/null +++ b/ui/src/pages/chat/chat-commands.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SLASH_COMMANDS } from "../../lib/chat/commands.ts"; +import { refreshSlashCommands, resetChatSlashCommandMetadataForTest } from "./chat-commands.ts"; + +afterEach(() => { + resetChatSlashCommandMetadataForTest(); +}); + +function requireCommandByName(name: string): Record { + const command = SLASH_COMMANDS.find((entry) => entry.name === name); + if (!command) { + throw new Error(`expected slash command ${name}`); + } + return command as unknown as Record; +} + +function expectRecordFields(value: unknown, label: string, expected: Record) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`expected ${label} to be an object`); + } + const record = value as Record; + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key]).toEqual(expectedValue); + } +} + +describe("refreshSlashCommands", () => { + it("refreshes runtime commands from commands.list", async () => { + const request = vi.fn().mockImplementation(async (method: string) => { + expect(method).toBe("commands.list"); + return { + commands: [ + { + name: "pair", + textAliases: ["/pair"], + description: "Generate setup codes.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ], + }; + }); + + await refreshSlashCommands({ + client: { request } as never, + agentId: "main", + }); + + expect(request).toHaveBeenCalledWith("commands.list", { + agentId: "main", + includeArgs: true, + scope: "text", + }); + expectRecordFields(requireCommandByName("pair"), "pair command", { + name: "pair", + description: "Generate setup codes.", + executeLocal: false, + tier: "standard", + }); + }); + + it("requests the gateway default agent when no explicit agentId is available", async () => { + const request = vi.fn().mockResolvedValue({ + commands: [ + { + name: "pair", + textAliases: ["/pair"], + description: "Generate setup codes.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ], + }); + + await refreshSlashCommands({ + client: { request } as never, + agentId: undefined, + }); + + expect(request).toHaveBeenCalledWith("commands.list", { + includeArgs: true, + scope: "text", + }); + expectRecordFields(requireCommandByName("pair"), "pair command", { + name: "pair", + description: "Generate setup codes.", + executeLocal: false, + tier: "standard", + }); + }); + + it("keeps local fallback commands after repeated gateway failures", async () => { + const request = vi.fn().mockRejectedValue(new Error("offline")); + const client = { request } as never; + + await refreshSlashCommands({ client, agentId: "main" }); + expectRecordFields(requireCommandByName("help"), "first fallback help command", { + key: "help", + executeLocal: true, + }); + + await refreshSlashCommands({ client, agentId: "main" }); + expect(request).toHaveBeenCalledTimes(2); + expectRecordFields(requireCommandByName("help"), "second fallback help command", { + key: "help", + executeLocal: true, + }); + }); + + it("coalesces duplicate refreshes for the same agent", async () => { + let resolveFirst: ((value: unknown) => void) | undefined; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const request = vi.fn().mockImplementationOnce(async () => await first); + const client = { request } as never; + + const pending = refreshSlashCommands({ + client, + agentId: "main", + }); + const duplicate = refreshSlashCommands({ + client, + agentId: "main", + }); + resolveFirst?.({ + commands: [ + { + name: "pair", + textAliases: ["/pair"], + description: "Generate setup codes.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ], + }); + await pending; + await duplicate; + + expect(request).toHaveBeenCalledTimes(1); + expectRecordFields(requireCommandByName("pair"), "pair command", { + name: "pair", + description: "Generate setup codes.", + executeLocal: false, + tier: "standard", + }); + }); + + it("ignores stale refresh responses after switching agents", async () => { + let resolveFirst: ((value: unknown) => void) | undefined; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const request = vi.fn((_: string, params: { agentId?: string }) => { + if (params.agentId === "main") { + return first; + } + return Promise.resolve({ + commands: [ + { + name: "pair", + textAliases: ["/pair"], + description: "Generate setup codes.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ], + }); + }); + const client = { request } as never; + + const pending = refreshSlashCommands({ client, agentId: "main" }); + await refreshSlashCommands({ client, agentId: "other" }); + resolveFirst?.({ + commands: [ + { + name: "dreaming", + textAliases: ["/dreaming"], + description: "Enable or disable memory dreaming.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ], + }); + await pending; + + expectRecordFields(requireCommandByName("pair"), "pair command", { + name: "pair", + description: "Generate setup codes.", + }); + expect(SLASH_COMMANDS.find((entry) => entry.name === "dreaming")).toBeUndefined(); + }); + + it("uses the fresh remote command cache for repeated refreshes", async () => { + const request = vi.fn().mockResolvedValue({ + commands: [ + { + name: "pair", + textAliases: ["/pair"], + description: "Generate setup codes.", + source: "plugin", + scope: "both", + acceptsArgs: true, + }, + ], + }); + const client = { request } as never; + + await refreshSlashCommands({ client, agentId: "main" }); + await refreshSlashCommands({ client, agentId: "main" }); + + expect(request).toHaveBeenCalledTimes(1); + expectRecordFields(requireCommandByName("pair"), "pair command", { + name: "pair", + description: "Generate setup codes.", + }); + }); +}); diff --git a/ui/src/pages/chat/chat-commands.ts b/ui/src/pages/chat/chat-commands.ts new file mode 100644 index 000000000000..4f703bf49519 --- /dev/null +++ b/ui/src/pages/chat/chat-commands.ts @@ -0,0 +1,279 @@ +// Control UI Chat page owns slash command metadata loading. +import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ModelCatalogEntry, SessionsListResult } from "../../api/types.ts"; +import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { + buildFallbackSlashCommands, + buildSlashCommandsFromEntries, + getRemoteCommandEntries, + replaceSlashCommands, + type SlashCommandDef, +} from "../../lib/chat/commands.ts"; +import { scopedAgentIdForSession, type SessionCapability } from "../../lib/sessions/index.ts"; +import { executeSlashCommand } from "./chat-command-executor.ts"; +import { clearChatHistory } from "./chat-history.ts"; +import { enqueuePendingRunMessage } from "./chat-queue.ts"; +import { handleAbortChat } from "./run-lifecycle.ts"; +import { scheduleChatScroll } from "./scroll.ts"; + +let refreshSeq = 0; +const REMOTE_SLASH_COMMAND_CACHE_TTL_MS = 60_000; + +type RemoteSlashCommandCacheEntry = { + commands?: SlashCommandDef[]; + expiresAt: number; + inFlight?: Promise; +}; + +let remoteSlashCommandCache = new WeakMap< + GatewayBrowserClient, + Map +>(); + +export type ChatCommandResetOptions = { + previousDraft?: string; + restoreDraft?: boolean; +}; + +export type ChatCommandSendOptions = ChatCommandResetOptions & { + sendResetMessage: (message: string, opts: ChatCommandResetOptions) => Promise; +}; + +export type ChatCommandHost = Parameters[0] & + Parameters[0] & { + sessions: SessionCapability; + chatQueue: ChatQueueItem[]; + chatModelCatalog: ModelCatalogEntry[]; + sessionsResult?: SessionsListResult | null; + createChatSession?: () => Promise; + exportCurrentChat?: () => Promise | void; + refreshCurrentSessionTools?: () => Promise; + refreshCurrentChat?: () => Promise; + }; + +function setChatCommandError( + host: { lastError?: string | null; chatError?: string | null }, + error: string | null, +) { + host.lastError = error; + host.chatError = error; +} + +function remoteSlashCommandCacheKey(agentId: string | undefined): string { + return agentId ?? ""; +} + +function getRemoteSlashCommandCache( + client: GatewayBrowserClient, +): Map { + let cache = remoteSlashCommandCache.get(client); + if (!cache) { + cache = new Map(); + remoteSlashCommandCache.set(client, cache); + } + return cache; +} + +function storeRemoteSlashCommands( + client: GatewayBrowserClient, + agentId: string | undefined, + commands: SlashCommandDef[], +) { + getRemoteSlashCommandCache(client).set(remoteSlashCommandCacheKey(agentId), { + commands, + expiresAt: Date.now() + REMOTE_SLASH_COMMAND_CACHE_TTL_MS, + }); +} + +async function requestRemoteSlashCommands( + client: GatewayBrowserClient, + agentId: string | undefined, + fallback: SlashCommandDef[] | undefined, +): Promise { + try { + const result = await client.request("commands.list", { + ...(agentId ? { agentId } : {}), + includeArgs: true, + scope: "text", + }); + if (!Array.isArray(result?.commands)) { + return buildFallbackSlashCommands(); + } + const commands = buildSlashCommandsFromEntries(getRemoteCommandEntries(result)); + storeRemoteSlashCommands(client, agentId, commands); + return commands; + } catch { + return fallback ?? buildFallbackSlashCommands(); + } +} + +function loadRemoteSlashCommands( + client: GatewayBrowserClient, + agentId: string | undefined, +): Promise { + const cache = getRemoteSlashCommandCache(client); + const key = remoteSlashCommandCacheKey(agentId); + const cached = cache.get(key); + const now = Date.now(); + if (cached?.commands && cached.expiresAt > now) { + return Promise.resolve(cached.commands); + } + if (cached?.inFlight) { + return cached.inFlight; + } + const inFlight = requestRemoteSlashCommands(client, agentId, cached?.commands).finally(() => { + const latest = cache.get(key); + if (latest?.inFlight === inFlight) { + delete latest.inFlight; + } + }); + cache.set(key, { + ...(cached?.commands ? { commands: cached.commands } : {}), + expiresAt: cached?.expiresAt ?? 0, + inFlight, + }); + return inFlight; +} + +export function applyRemoteSlashCommandsResult(params: { + client: GatewayBrowserClient | null; + agentId?: string | null; + result: CommandsListResult | null | undefined; +}): boolean { + if (!Array.isArray(params.result?.commands)) { + return false; + } + const agentId = params.agentId?.trim(); + const commands = buildSlashCommandsFromEntries(getRemoteCommandEntries(params.result)); + if (params.client) { + storeRemoteSlashCommands(params.client, agentId, commands); + } + refreshSeq += 1; + replaceSlashCommands(commands); + return true; +} + +export async function refreshSlashCommands(params: { + client: GatewayBrowserClient | null; + agentId?: string | null; +}): Promise { + const seq = ++refreshSeq; + const agentId = params.agentId?.trim(); + if (!params.client) { + if (seq !== refreshSeq) { + return; + } + replaceSlashCommands(buildFallbackSlashCommands()); + return; + } + const commands = await loadRemoteSlashCommands(params.client, agentId); + if (seq !== refreshSeq) { + return; + } + replaceSlashCommands(commands); +} + +export function resetChatSlashCommandMetadataForTest(): void { + refreshSeq = 0; + remoteSlashCommandCache = new WeakMap(); + replaceSlashCommands(buildFallbackSlashCommands()); +} + +export function shouldQueueLocalSlashCommand(name: string): boolean { + return !["stop", "export-session", "steer", "redirect", "new"].includes(name); +} + +export async function dispatchChatSlashCommand( + host: ChatCommandHost, + name: string, + args: string, + opts: ChatCommandSendOptions, +) { + switch (name) { + case "stop": + await handleAbortChat(host); + return; + case "new": + if (!host.createChatSession) { + setChatCommandError(host, "New Chat is unavailable."); + return; + } + await host.createChatSession(); + return; + case "reset": + await opts.sendResetMessage(args ? `/reset ${args}` : "/reset", opts); + return; + case "clear": + await clearChatHistory(host); + return; + case "export-session": + await host.exportCurrentChat?.(); + return; + } + + if (!host.client || !host.connected) { + setChatCommandError(host, "Gateway not connected"); + injectCommandResult( + host, + `Cannot run \`/${name}\`: Control UI is not connected to the Gateway.`, + ); + scheduleChatScroll(host as unknown as Parameters[0]); + return; + } + + const targetSessionKey = host.sessionKey; + let result: Awaited>; + try { + result = await executeSlashCommand(host.client, targetSessionKey, name, args, { + sessions: host.sessions, + chatModelCatalog: host.chatModelCatalog, + sessionsResult: host.sessionsResult, + agentId: scopedAgentIdForSession(host, targetSessionKey), + }); + } catch (err) { + setChatCommandError(host, String(err)); + injectCommandResult(host, `Command \`/${name}\` failed unexpectedly.`); + scheduleChatScroll(host as unknown as Parameters[0]); + return; + } + + if (result.content) { + injectCommandResult(host, result.content); + } + + if (result.trackRunId) { + host.chatRunId = result.trackRunId; + host.chatStream = ""; + host.chatSending = false; + } + + if (result.pendingCurrentRun && host.chatRunId) { + enqueuePendingRunMessage(host, `/${name} ${args}`.trim(), host.chatRunId); + } + + if (result.sessionPatch && "modelOverride" in result.sessionPatch) { + host.sessions.setModelOverride( + targetSessionKey, + result.sessionPatch.modelOverride?.value ?? null, + ); + await host.refreshCurrentSessionTools?.(); + } + + if (result.action === "refresh") { + await host.refreshCurrentChat?.(); + } + + scheduleChatScroll(host as unknown as Parameters[0]); +} + +function injectCommandResult(host: ChatCommandHost, content: string) { + host.chatMessages = [ + ...host.chatMessages, + { + role: "system", + content, + timestamp: Date.now(), + }, + ]; +} diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/pages/chat/chat-composer.test.ts similarity index 87% rename from ui/src/ui/chat/run-controls.test.ts rename to ui/src/pages/chat/chat-composer.test.ts index 3c0b963e26bd..3247e3818822 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -2,26 +2,25 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewaySessionRow } from "../../api/types.ts"; import { i18n, t } from "../../i18n/index.ts"; -import type { GatewaySessionRow } from "../types.ts"; import { getContextNoticeViewModel, - renderContextNotice, - resetContextNoticeThemeCacheForTest, -} from "./context-notice.ts"; -import { renderChatRunControls, type ChatRunControlsProps } from "./run-controls.ts"; -import { renderSideResult } from "./side-result-render.ts"; -import { + renderChatRunControls, renderChatRunStatusIndicator, renderCompactionIndicator, + renderContextNotice, renderFallbackIndicator, -} from "./status-indicators.ts"; + renderSideResult, + resetContextNoticeThemeCacheForTest, + type ChatRunControlsProps, +} from "./components/chat-composer.ts"; -vi.mock("../icons.ts", () => ({ +vi.mock("../../components/icons.ts", () => ({ icons: {}, })); -vi.mock("../markdown.ts", () => ({ +vi.mock("../../components/markdown.ts", () => ({ toSanitizedMarkdownHtml: (value: string) => value, })); @@ -75,13 +74,19 @@ describe("chat run controls", () => { container, ); - const queueButton = getButton(container, 'button[title="Queue"]'); - const stopButton = getButton(container, 'button[title="Stop"]'); + const queueButton = getButton( + container, + `button[aria-label="${t("chat.runControls.queueMessage")}"]`, + ); + const stopButton = getButton( + container, + `button[aria-label="${t("chat.runControls.stopGenerating")}"]`, + ); expect(queueButton.disabled).toBe(true); - expect(stopButton.title).toBe("Stop"); + expect(stopButton.getAttribute("aria-label")).toBe(t("chat.runControls.stopGenerating")); stopButton.click(); expect(onAbort).toHaveBeenCalledTimes(1); - expect(container.querySelector('button[title="New session"]')).toBeNull(); + expect(container.querySelector('button[aria-label="New session"]')).toBeNull(); const onNewSession = vi.fn(); const onSend = vi.fn(); @@ -99,14 +104,20 @@ describe("chat run controls", () => { container, ); - const newSessionButton = getButton(container, 'button[title="New session"]'); - expect(newSessionButton.title).toBe("New session"); + const newSessionButton = getButton( + container, + `button[aria-label="${t("chat.runControls.newSession")}"]`, + ); + expect(newSessionButton.getAttribute("aria-label")).toBe(t("chat.runControls.newSession")); expect(newSessionButton.textContent).toContain("New session"); newSessionButton.click(); expect(onNewSession).toHaveBeenCalledTimes(1); - const sendButton = getButton(container, 'button[title="Send"]'); - expect(sendButton.title).toBe("Send"); + const sendButton = getButton( + container, + `button[aria-label="${t("chat.runControls.sendMessage")}"]`, + ); + expect(sendButton.getAttribute("aria-label")).toBe(t("chat.runControls.sendMessage")); expect(sendButton.textContent).toContain("Send"); sendButton.click(); expect(onStoreDraft).toHaveBeenCalledWith(" run this "); @@ -130,7 +141,10 @@ describe("chat run controls", () => { container, ); - const queueButton = getButton(container, 'button[title="Queue"]'); + const queueButton = getButton( + container, + `button[aria-label="${t("chat.runControls.queueMessage")}"]`, + ); expect(queueButton.disabled).toBe(false); queueButton.click(); expect(onStoreDraft).toHaveBeenCalledWith(" follow up "); @@ -151,7 +165,10 @@ describe("chat run controls", () => { container, ); - const stopButton = getButton(container, 'button[title="Stop"]'); + const stopButton = getButton( + container, + `button[aria-label="${t("chat.runControls.stopGenerating")}"]`, + ); expect(stopButton.disabled).toBe(false); stopButton.click(); expect(onAbort).toHaveBeenCalledTimes(1); @@ -163,15 +180,15 @@ describe("chat run controls", () => { render(renderChatRunControls(createProps({ hasMessages: true })), container); expect( - getButton(container, `button[title="${t("chat.runControls.newSession")}"]`).textContent, + getButton(container, `button[aria-label="${t("chat.runControls.newSession")}"]`).textContent, ).toContain(t("chat.runControls.newSession")); expect( - getButton(container, `button[title="${t("chat.runControls.export")}"]`).textContent, + getButton(container, `button[aria-label="${t("chat.runControls.exportChat")}"]`).textContent, ).toContain(t("chat.runControls.export")); expect( - getButton(container, `button[title="${t("chat.runControls.send")}"]`).textContent, + getButton(container, `button[aria-label="${t("chat.runControls.sendMessage")}"]`).textContent, ).toContain(t("chat.runControls.send")); - expect(container.querySelector('button[title="New session"]')).toBeNull(); + expect(container.querySelector('button[aria-label="New session"]')).toBeNull(); }); }); @@ -343,7 +360,7 @@ describe("context notice", () => { expect(lowNotice).toBeInstanceOf(HTMLElement); expect([...lowNotice!.classList]).toEqual(["context-ring"]); expect(lowNotice!.textContent?.replace(/\s+/gu, " ").trim()).toBe("23%"); - expect(lowNotice!.getAttribute("title")).toBe("Session context usage: 46k / 200k (23%)"); + expect(lowNotice!.getAttribute("aria-label")).toBe("Session context usage: 46k / 200k (23%)"); const lowFill = lowNotice!.querySelector(".context-ring__fill"); expect(lowFill?.tagName.toLowerCase()).toBe("circle"); // 23% of the 40.84 circumference stays hidden via dashoffset. @@ -367,7 +384,9 @@ describe("context notice", () => { expect(notice).toBeInstanceOf(HTMLElement); expect(notice!.textContent?.replace(/\s+/gu, " ").trim()).toBe("95%"); expect([...notice!.classList]).toEqual(["context-ring", "context-ring--warning"]); - expect(notice!.getAttribute("title")).toBe("Session context usage: 190k / 200k (95%)"); + expect(notice!.getAttribute("aria-label")).toBe( + "Session context usage: 190k / 200k (95%)", + ); expect(notice!.style.getPropertyValue("--ctx-color")).toBe("rgb(4, 5, 6)"); expect(notice!.style.getPropertyValue("--ctx-bg")).toBe("rgba(4, 5, 6, 0.15999999999999998)"); diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/pages/chat/chat-gateway.test.ts similarity index 94% rename from ui/src/ui/controllers/chat.test.ts rename to ui/src/pages/chat/chat-gateway.test.ts index d7293a16133b..b8b12e834870 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -3,22 +3,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { registerChatAttachmentPayload, resetChatAttachmentPayloadStoreForTest, -} from "../chat/attachment-payload-store.ts"; -import { GatewayRequestError } from "../gateway.ts"; +} from "./attachment-payload-store.ts"; import { - abortChatRun, handleChatEvent, - loadChatHistory, + handleChatGatewayEvent, + handleChatSideResultGatewayEvent, + type ChatEventPayload, +} from "./chat-gateway.ts"; +import { GatewayRequestError, loadChatHistory, type ChatState } from "./chat-history.ts"; +import { requestChatSend, requestSkillWorkshopRevisionChatSend, - sendChatMessage, sendDetachedChatMessage, sendSteerChatMessage, - type ChatEventPayload, - type ChatState, -} from "./chat.ts"; - -const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +} from "./chat-send.ts"; +import { abortChatRun } from "./run-lifecycle.ts"; function createState(overrides: Partial = {}): ChatState { return { @@ -26,14 +25,18 @@ function createState(overrides: Partial = {}): ChatState { chatLoading: false, chatMessage: "", chatMessages: [], + chatQueue: [], chatRunId: null, chatSending: false, chatStream: null, chatStreamStartedAt: null, + chatSideResult: null, + chatSideResultTerminalRuns: new Set(), chatThinkingLevel: null, chatVerboseLevel: null, client: null, connected: true, + hello: null, lastError: null, sessionKey: "main", ...overrides, @@ -134,6 +137,108 @@ function createOtherRunNoReplyFinalPayload(): ChatEventPayload { return createOtherRunSilentFinalPayload("NO_REPLY"); } +describe("chat side result gateway events", () => { + it("stores BTW side results for the active session", () => { + const state = createState(); + + expect( + handleChatSideResultGatewayEvent(state, { + kind: "btw", + runId: "btw-run-1", + sessionKey: "main", + question: "what changed?", + text: "Only the UI layer was missing support.", + ts: 123, + }), + ).toBe(true); + + expect(state.chatSideResult).toMatchObject({ + kind: "btw", + runId: "btw-run-1", + sessionKey: "main", + question: "what changed?", + text: "Only the UI layer was missing support.", + }); + expect(state.chatSideResultTerminalRuns?.has("btw-run-1")).toBe(true); + }); + + it("stores selected-global BTW side results for agent main aliases", () => { + const state = createState({ + sessionKey: "agent:work:main", + agentsList: { defaultId: "main" }, + }); + + expect( + handleChatSideResultGatewayEvent(state, { + kind: "btw", + runId: "btw-work-global", + sessionKey: "global", + agentId: "work", + question: "what changed?", + text: "The alias receives canonical global side results.", + ts: 123, + }), + ).toBe(true); + + expect(state.chatSideResult).toMatchObject({ + kind: "btw", + runId: "btw-work-global", + sessionKey: "global", + agentId: "work", + text: "The alias receives canonical global side results.", + }); + expect(state.chatSideResultTerminalRuns?.has("btw-work-global")).toBe(true); + }); + + it("ignores selected-global BTW side results from another agent", () => { + const state = createState({ + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + + expect( + handleChatSideResultGatewayEvent(state, { + kind: "btw", + runId: "btw-main-global", + sessionKey: "global", + agentId: "main", + question: "what changed?", + text: "This belongs to another selected agent.", + ts: 123, + }), + ).toBe(false); + + expect(state.chatSideResult).toBeNull(); + expect(state.chatSideResultTerminalRuns?.has("btw-main-global")).toBe(false); + }); + + it("ignores tracked BTW terminal events without touching the active run", () => { + const state = createState({ + chatRunId: "main-run-1", + chatStream: "still streaming", + chatMessages: [{ role: "assistant", content: [{ type: "text", text: "existing" }] }], + }); + state.chatSideResultTerminalRuns?.add("btw-run-2"); + + expect( + handleChatGatewayEvent(state, { + runId: "btw-run-2", + sessionKey: "main", + state: "final", + }), + ).toBe(null); + + expect(state.chatSideResultTerminalRuns?.has("btw-run-2")).toBe(false); + expect(state.chatRunId).toBe("main-run-1"); + expect(state.chatStream).toBe("still streaming"); + expect(state.chatMessages).toEqual([ + { role: "assistant", content: [{ type: "text", text: "existing" }] }, + ]); + expect(state.lastError).toBeNull(); + }); +}); + describe("handleChatEvent", () => { it("returns null when payload is missing", () => { const state = createState(); @@ -1792,6 +1897,7 @@ describe("loadChatHistory filtering", () => { messages: [], sessionId: "legacy-session", thinkingLevel: "low", + verboseLevel: "full", sessionInfo: { key: "main", sessionId: "session-main", @@ -1811,6 +1917,7 @@ describe("loadChatHistory filtering", () => { expect(result?.sessionInfo?.sessionId).toBe("session-main"); expect(state.currentSessionId).toBe("session-main"); expect(state.chatThinkingLevel).toBe("medium"); + expect(state.chatVerboseLevel).toBe("full"); }); it("omits literal global agentId until selected/default agent is known", async () => { @@ -1922,29 +2029,7 @@ describe("loadChatHistory filtering", () => { }); }); -describe("sendChatMessage", () => { - it("does not start a second chat.send while the first send is awaiting ack", async () => { - const sent = createDeferred(); - const request = vi.fn(() => sent.promise); - const state = createState({ - connected: true, - client: { request } as unknown as ChatState["client"], - }); - - const first = sendChatMessage(state, "hello"); - const activeRunId = state.chatRunId; - const second = sendChatMessage(state, "hello"); - - expect(request).toHaveBeenCalledTimes(1); - expect(state.chatMessages).toHaveLength(1); - await expect(second).resolves.toBe(activeRunId); - - sent.resolve({ runId: activeRunId, status: "started" }); - await expect(first).resolves.toBe(activeRunId); - expect(request).toHaveBeenCalledTimes(1); - expect(state.chatMessages).toHaveLength(1); - }); - +describe("chat send Gateway requests", () => { it("passes the backing session id from history without resume for ordinary sends", async () => { const request = vi .fn() @@ -1961,9 +2046,12 @@ describe("sendChatMessage", () => { }); await loadChatHistory(state); - const result = await sendChatMessage(state, "continue"); + const result = await requestChatSend(state, { + message: "continue", + runId: "run-continue", + }); - expect(result).toMatch(UUID_V4_RE); + expect(result).toEqual({ runId: "run-continue", status: "started" }); expect(state.currentSessionId).toBe("session-before-reconnect"); const sendRequest = request.mock.calls[request.mock.calls.length - 1]; expect(sendRequest?.[0]).toBe("chat.send"); @@ -2002,6 +2090,23 @@ describe("sendChatMessage", () => { expect(state.reconnectResumeSessionId).toBeNull(); }); + it("clears reconnect resume when history returns a different backing session", async () => { + const request = vi.fn().mockResolvedValue({ + sessionId: "session-after-reconnect", + messages: [], + }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + reconnectResumeSessionId: "session-before-reconnect", + }); + + await loadChatHistory(state); + + expect(state.currentSessionId).toBe("session-after-reconnect"); + expect(state.reconnectResumeSessionId).toBeNull(); + }); + it("does not reuse another global agent's visible session id for queued sends", async () => { const request = vi.fn().mockResolvedValue({ runId: "run-work", status: "started" }); const state = createState({ @@ -2139,69 +2244,6 @@ describe("sendChatMessage", () => { }); }); - it("adopts the run id and terminal status from the chat.send ack", async () => { - const request = vi.fn().mockResolvedValue({ runId: "gateway-complete-run", status: "ok" }); - const state = createState({ - connected: true, - client: { request } as unknown as ChatState["client"], - }); - - const result = await sendChatMessage(state, "already handled"); - - expect(result).toBe("gateway-complete-run"); - expect(state.chatRunId).toBeNull(); - expect(state.chatStream).toBeNull(); - expect(state.chatStreamStartedAt).toBeNull(); - const runState = state as ChatState & { - chatRunStatus?: unknown; - lastLocalTerminalReconcile?: unknown; - }; - expect(runState.chatRunStatus).toMatchObject({ - phase: "done", - runId: "gateway-complete-run", - sessionKey: "main", - }); - expect(runState.lastLocalTerminalReconcile).toMatchObject({ - phase: "done", - runId: "gateway-complete-run", - sessionKey: "main", - }); - }); - - it("clears the local run and rejects acceptance when the send acks a terminal timeout", async () => { - const request = vi.fn((_method: string, params: { idempotencyKey: string }) => - Promise.resolve({ runId: params.idempotencyKey, status: "timeout" }), - ); - const state = createState({ - connected: true, - client: { request } as unknown as ChatState["client"], - }); - - const result = await sendChatMessage(state, "aborted before dispatch"); - - expect(result).toBeNull(); - expect(state.chatMessages).toStrictEqual([]); - expect(state.lastError).toBe("The run ended before the message was accepted."); - expect(state.chatRunId).toBeNull(); - expect(state.chatStream).toBeNull(); - expect(state.chatStreamStartedAt).toBeNull(); - const runState = state as ChatState & { - chatRunStatus?: unknown; - lastLocalTerminalReconcile?: unknown; - }; - expect(runState.chatRunStatus).toMatchObject({ - phase: "interrupted", - runId: expect.stringMatching(UUID_V4_RE), - sessionKey: "main", - }); - expect(runState.lastLocalTerminalReconcile).toMatchObject({ - phase: "interrupted", - runId: expect.stringMatching(UUID_V4_RE), - sessionKey: "main", - sessionStatus: "killed", - }); - }); - it("preserves terminal timeout acks from Skill Workshop revision sends", async () => { const request = vi.fn().mockResolvedValue({ runId: "run-revision", status: "timeout" }); const state = createState({ @@ -2255,16 +2297,20 @@ describe("sendChatMessage", () => { client: { request } as unknown as ChatState["client"], }); - const result = await sendChatMessage(state, "summarize", [ - { - id: "att-1", - dataUrl: `data:application/pdf;base64,${Buffer.from("%PDF-1.4\n").toString("base64")}`, - mimeType: "application/pdf", - fileName: "brief.pdf", - }, - ]); + const result = await requestChatSend(state, { + message: "summarize", + runId: "run-file", + attachments: [ + { + id: "att-1", + dataUrl: `data:application/pdf;base64,${Buffer.from("%PDF-1.4\n").toString("base64")}`, + mimeType: "application/pdf", + fileName: "brief.pdf", + }, + ], + }); - expect(result).toMatch(UUID_V4_RE); + expect(result).toEqual({ runId: "run-file", status: "started" }); expect(request).toHaveBeenCalledTimes(1); const [requestMethod, requestParams] = requireFirstRequestCall(request); expect(requestMethod).toBe("chat.send"); @@ -2278,19 +2324,6 @@ describe("sendChatMessage", () => { content: Buffer.from("%PDF-1.4\n").toString("base64"), }, ]); - const userMessage = requireRecord(state.chatMessages[0]); - expect(userMessage.role).toBe("user"); - const content = userMessage.content; - expect(Array.isArray(content)).toBe(true); - const contentParts = content as unknown[]; - expect(contentParts).toHaveLength(2); - expect(contentParts[0]).toEqual({ type: "text", text: "summarize" }); - const attachmentPart = requireRecord(contentParts[1]); - expect(attachmentPart.type).toBe("attachment"); - const attachmentPreview = requireRecord(attachmentPart.attachment); - expect(attachmentPreview.kind).toBe("document"); - expect(attachmentPreview.label).toBe("brief.pdf"); - expect(attachmentPreview.mimeType).toBe("application/pdf"); }); it("serializes attachments from the side payload store without copying data URLs into chat state", async () => { @@ -2316,9 +2349,13 @@ describe("sendChatMessage", () => { const previewUrl = attachment.previewUrl; expect(previewUrl).toMatch(/^blob:nodedata:/u); - const result = await sendChatMessage(state, "summarize", [attachment]); + const result = await requestChatSend(state, { + message: "summarize", + runId: "run-side-store", + attachments: [attachment], + }); - expect(result).toMatch(UUID_V4_RE); + expect(result).toEqual({ runId: "run-side-store", status: "started" }); expect(request).toHaveBeenCalledTimes(1); const [requestMethod, requestParams] = requireFirstRequestCall(request); expect(requestMethod).toBe("chat.send"); @@ -2329,27 +2366,10 @@ describe("sendChatMessage", () => { const attachmentRecord = requireRecord(attachmentParam); expect(attachmentRecord.type).toBe("file"); expect(attachmentRecord.content).toBe(Buffer.from(pdfBytes).toString("base64")); - expect(state.chatMessages).toStrictEqual([ - { - role: "user", - content: [ - { type: "text", text: "summarize" }, - { - type: "attachment", - attachment: { - url: previewUrl, - kind: "document", - label: "brief.pdf", - mimeType: "application/pdf", - }, - }, - ], - timestamp: expect.any(Number), - }, - ]); + expect(JSON.stringify(state.chatMessages)).not.toContain(previewUrl); }); - it("sends inline image payloads without copying data URLs into optimistic chat state", async () => { + it("sends inline image payloads without copying data URLs into chat state", async () => { const request = vi.fn((_method: string, params?: unknown) => Promise.resolve(createStartedChatSendAck(params)), ); @@ -2360,16 +2380,20 @@ describe("sendChatMessage", () => { const imageBase64 = "A".repeat(1024 * 1024); const imageDataUrl = `data:image/png;base64,${imageBase64}`; - const result = await sendChatMessage(state, "", [ - { - id: "att-image", - dataUrl: imageDataUrl, - mimeType: "image/png", - fileName: "photo.png", - }, - ]); + const result = await requestChatSend(state, { + message: "", + runId: "run-image", + attachments: [ + { + id: "att-image", + dataUrl: imageDataUrl, + mimeType: "image/png", + fileName: "photo.png", + }, + ], + }); - expect(result).toMatch(UUID_V4_RE); + expect(result).toEqual({ runId: "run-image", status: "started" }); expect(request).toHaveBeenCalledTimes(1); const [requestMethod, requestParams] = requireFirstRequestCall(request); expect(requestMethod).toBe("chat.send"); @@ -2383,13 +2407,6 @@ describe("sendChatMessage", () => { content: imageBase64, }, ]); - expect(state.chatMessages).toStrictEqual([ - { - role: "user", - content: [{ type: "text", text: "Attached image: photo.png" }], - timestamp: expect.any(Number), - }, - ]); expect(JSON.stringify(state.chatMessages)).not.toContain("data:image/png;base64"); const captionedRequest = vi.fn((_method: string, params?: unknown) => @@ -2401,55 +2418,20 @@ describe("sendChatMessage", () => { }); await expect( - sendChatMessage(captionedState, "describe", [ - { - id: "att-captioned-image", - dataUrl: imageDataUrl, - mimeType: "image/png", - fileName: "photo.png", - }, - ]), - ).resolves.toMatch(UUID_V4_RE); - expect(captionedState.chatMessages).toStrictEqual([ - { - role: "user", - content: [ - { type: "text", text: "describe" }, - { type: "text", text: "Attached image: photo.png" }, + requestChatSend(captionedState, { + message: "describe", + runId: "run-captioned-image", + attachments: [ + { + id: "att-captioned-image", + dataUrl: imageDataUrl, + mimeType: "image/png", + fileName: "photo.png", + }, ], - timestamp: expect.any(Number), - }, - ]); - expect(JSON.stringify(captionedState.chatMessages)).not.toContain("data:image/png;base64"); - }); - - it("formats structured non-auth connect failures for chat send", async () => { - const request = vi.fn().mockRejectedValue( - new GatewayRequestError({ - code: "INVALID_REQUEST", - message: "Fetch failed", - details: { code: "CONTROL_UI_ORIGIN_NOT_ALLOWED" }, }), - ); - const state = createState({ - connected: true, - client: { request } as unknown as ChatState["client"], - }); - - const result = await sendChatMessage(state, "hello"); - - const expectedError = - "origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)"; - expect(result).toBeNull(); - expect(state.lastError).toBe(expectedError); - const assistantMessage = requireRecord(state.chatMessages.at(-1)); - expect(assistantMessage.role).toBe("assistant"); - const content = assistantMessage.content; - expect(Array.isArray(content)).toBe(true); - const [textPart] = content as unknown[]; - const textRecord = requireRecord(textPart); - expect(textRecord.type).toBe("text"); - expect(textRecord.text).toBe(`Error: ${expectedError}`); + ).resolves.toEqual({ runId: "run-captioned-image", status: "started" }); + expect(JSON.stringify(captionedState.chatMessages)).not.toContain("data:image/png;base64"); }); }); diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts new file mode 100644 index 000000000000..ca1ac46994cd --- /dev/null +++ b/ui/src/pages/chat/chat-gateway.ts @@ -0,0 +1,313 @@ +import { isAssistantHeartbeatAckForDisplay } from "../../lib/chat/heartbeat-display.ts"; +import { extractText } from "../../lib/chat/message-extract.ts"; +import { parseChatSideResult } from "../../lib/chat/side-result.ts"; +// Control UI page module reconciles Chat Gateway events into Chat state. +import { isUiGlobalSessionKey, resolveUiDefaultAgentId } from "../../lib/sessions/session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { + chatScopedEventSessionMatches, + isHiddenAssistantStreamText, + isSilentReplyStream, + materializeVisibleAssistantStreamMessages, + shouldHideAssistantChatMessage, + type ChatEventPayload, + type ChatState, +} from "./chat-history.ts"; +import { clearPendingQueueItemsForRun } from "./chat-queue.ts"; +import { reconcileChatRunLifecycle } from "./run-lifecycle.ts"; +import { appendChatMessageToCache } from "./session-message-cache.ts"; +import { + appendTerminalAssistantMessage, + clearToolStreamSegments, + hasVisibleStreamParts, +} from "./stream-reconciliation.ts"; + +export type { ChatEventPayload, ChatState } from "./chat-history.ts"; + +type AssistantMessageNormalizationOptions = { + roleRequirement: "required" | "optional"; + roleCaseSensitive?: boolean; + requireContentArray?: boolean; + allowTextField?: boolean; +}; + +function setChatError(state: ChatState, error: string | null) { + state.lastError = error; + state.chatError = error; +} + +function chatEventSessionMatches(state: ChatState, payload: ChatEventPayload): boolean { + return chatScopedEventSessionMatches(state, payload.sessionKey, payload.agentId); +} + +function isTerminalChatState(value: unknown): boolean { + return value === "final" || value === "aborted" || value === "error"; +} + +function isEventForDifferentActiveRun( + payload: ChatEventPayload | undefined, + activeRunId: string | null, +): boolean { + return Boolean(activeRunId && payload && payload.runId !== activeRunId); +} + +function resolveDeltaChatStreamText( + currentStream: string | null, + payload: ChatEventPayload, +): string | null { + const snapshot = payload.message == null ? null : extractText(payload.message); + if (typeof payload.deltaText === "string") { + if (payload.replace === true) { + return payload.deltaText; + } + if (currentStream === null) { + return typeof snapshot === "string" ? snapshot : payload.deltaText; + } + if (typeof snapshot === "string") { + const prefixLength = snapshot.length - payload.deltaText.length; + if ( + prefixLength !== currentStream.length || + snapshot.slice(0, prefixLength) !== currentStream + ) { + return snapshot; + } + } + return `${currentStream}${payload.deltaText}`; + } + return typeof snapshot === "string" ? snapshot : null; +} + +function normalizeAssistantMessage( + message: unknown, + options: AssistantMessageNormalizationOptions, +): Record | null { + if (!message || typeof message !== "object") { + return null; + } + const candidate = message as Record; + const roleValue = candidate.role; + if (typeof roleValue === "string") { + const role = options.roleCaseSensitive ? roleValue : normalizeLowercaseStringOrEmpty(roleValue); + if (role !== "assistant") { + return null; + } + } else if (options.roleRequirement === "required") { + return null; + } + + if (options.requireContentArray) { + return Array.isArray(candidate.content) ? candidate : null; + } + if (!("content" in candidate) && !(options.allowTextField && "text" in candidate)) { + return null; + } + return candidate; +} + +function normalizeAbortedAssistantMessage(message: unknown): Record | null { + return normalizeAssistantMessage(message, { + roleRequirement: "required", + roleCaseSensitive: true, + requireContentArray: true, + }); +} + +function normalizeFinalAssistantMessage(message: unknown): Record | null { + return normalizeAssistantMessage(message, { + roleRequirement: "optional", + allowTextField: true, + }); +} + +function buildErrorAssistantMessage(payload: ChatEventPayload): Record | null { + const normalized = normalizeFinalAssistantMessage(payload.message); + if (normalized && !shouldHideAssistantChatMessage(normalized)) { + return normalized; + } + const error = payload.errorMessage?.trim(); + if (!error) { + return null; + } + return { + role: "assistant", + content: [ + { + type: "text", + text: error.startsWith("⚠️") || error.startsWith("Error:") ? error : `Error: ${error}`, + }, + ], + timestamp: Date.now(), + }; +} + +function appendCachedChatMessage( + state: ChatState, + sessionKey: string, + message: unknown, + agentId?: string, +) { + if (!state.chatMessagesBySession) { + return; + } + appendChatMessageToCache(state.chatMessagesBySession, state, { sessionKey, agentId }, message); +} + +export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { + if (!payload) { + return null; + } + const hadActiveRunBeforeEvent = state.chatRunId !== null; + const sessionMatches = chatEventSessionMatches(state, payload); + const activeRunMatches = + state.chatRunId !== null && + typeof payload.runId === "string" && + payload.runId === state.chatRunId; + if (!sessionMatches && !activeRunMatches) { + if (payload.state === "final") { + const finalMessage = normalizeFinalAssistantMessage(payload.message); + if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) { + const cacheAgentId = isUiGlobalSessionKey(payload.sessionKey) + ? (payload.agentId ?? resolveUiDefaultAgentId(state)) + : payload.agentId; + appendCachedChatMessage(state, payload.sessionKey, finalMessage, cacheAgentId); + } + } + return null; + } + if (!state.chatRunId && sessionMatches && typeof payload.runId === "string") { + state.chatRunId = payload.runId; + state.chatStreamStartedAt ??= Date.now(); + } + + // Terminal events for the active client run carry runId; missing-runId events are unowned. + // Final from another run (e.g. sub-agent announce): refresh history to show new message. + // See https://github.com/openclaw/openclaw/issues/1909 + if (state.chatRunId && payload.runId !== state.chatRunId) { + if (payload.state === "final") { + const finalMessage = normalizeFinalAssistantMessage(payload.message); + if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) { + state.chatMessages = [...state.chatMessages, finalMessage]; + return null; + } + return "final"; + } + return null; + } + + const terminalRunId = payload.runId ?? state.chatRunId; + const reconcileTerminalRun = ( + outcome: "done" | "interrupted", + sessionStatus: "done" | "failed" | "killed", + ) => + reconcileChatRunLifecycle(state as unknown as Parameters[0], { + outcome, + sessionStatus, + runId: terminalRunId, + sessionKey: state.sessionKey, + sessionKeys: sessionMatches ? [state.sessionKey, payload.sessionKey] : [], + clearLocalRun: true, + clearChatStream: true, + armLocalTerminalReconcile: hadActiveRunBeforeEvent && activeRunMatches, + }); + + if (payload.state === "delta") { + const next = resolveDeltaChatStreamText(state.chatStream, payload); + if ( + typeof next === "string" && + !isSilentReplyStream(next) && + !isAssistantHeartbeatAckForDisplay(payload.message) + ) { + state.chatStream = next; + } + } else if (payload.state === "final") { + const finalMessage = normalizeFinalAssistantMessage(payload.message); + if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) { + if ( + hasVisibleStreamParts(state, { + includeCurrent: false, + isHiddenStreamText: isHiddenAssistantStreamText, + }) + ) { + state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { + includeCurrent: false, + }); + clearToolStreamSegments(state); + } + state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, finalMessage); + } else { + state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state); + } + reconcileTerminalRun("done", "done"); + } else if (payload.state === "aborted") { + const normalizedMessage = normalizeAbortedAssistantMessage(payload.message); + if (normalizedMessage && !shouldHideAssistantChatMessage(normalizedMessage)) { + state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { + replacementMessages: [normalizedMessage], + includeCurrent: false, + }); + state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, normalizedMessage); + } else { + state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state); + } + reconcileTerminalRun("interrupted", "killed"); + } else if (payload.state === "error") { + const payloadMessage = hadActiveRunBeforeEvent + ? normalizeFinalAssistantMessage(payload.message) + : null; + const visiblePayloadMessage = + payloadMessage && !shouldHideAssistantChatMessage(payloadMessage) ? payloadMessage : null; + if (visiblePayloadMessage) { + state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { + replacementMessages: [visiblePayloadMessage], + }); + state.chatMessages = appendTerminalAssistantMessage( + state.chatMessages, + visiblePayloadMessage, + ); + } else { + const errorMessage = hadActiveRunBeforeEvent ? buildErrorAssistantMessage(payload) : null; + if (hadActiveRunBeforeEvent) { + state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state); + } + if (errorMessage) { + state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, errorMessage); + } + } + reconcileTerminalRun("interrupted", "failed"); + setChatError(state, payload.errorMessage ?? "chat error"); + } + return payload.state; +} + +export function handleChatGatewayEvent(state: ChatState, payload?: ChatEventPayload) { + if ( + isTerminalChatState(payload?.state) && + typeof payload?.runId === "string" && + state.chatSideResultTerminalRuns?.has(payload.runId) === true + ) { + state.chatSideResultTerminalRuns.delete(payload.runId); + return null; + } + const activeRunIdBeforeEvent = state.chatRunId; + const result = handleChatEvent(state, payload); + if ( + isTerminalChatState(result) && + !isEventForDifferentActiveRun(payload, activeRunIdBeforeEvent) + ) { + clearPendingQueueItemsForRun(state, payload?.runId); + } + return result; +} + +export function handleChatSideResultGatewayEvent(state: ChatState, payload: unknown): boolean { + const sideResult = parseChatSideResult(payload); + if (!sideResult) { + return false; + } + if (!chatScopedEventSessionMatches(state, sideResult.sessionKey, sideResult.agentId)) { + return false; + } + state.chatSideResult = sideResult; + state.chatSideResultTerminalRuns?.add(sideResult.runId); + return true; +} diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/pages/chat/chat-history.ts similarity index 51% rename from ui/src/ui/controllers/chat.ts rename to ui/src/pages/chat/chat-history.ts index bcb0ea4d7920..4b10644fafd6 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -1,20 +1,62 @@ -// Control UI controller manages chat gateway state. +// Control UI page module owns Chat transcript loading and selected-session message subscription. import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js"; -import { isNonTerminalAgentRunStatus } from "../../../../src/shared/agent-run-status.js"; -import { getChatAttachmentDataUrl } from "../chat/attachment-payload-store.ts"; +import { + GatewayRequestError, + type GatewayBrowserClient, + type GatewayHelloOk, +} from "../../api/gateway.ts"; + +export { GatewayRequestError }; +import type { + AgentsListResult, + GatewaySessionRow, + GatewaySessionsDefaults, + ModelCatalogEntry, + SessionsListResult, +} from "../../api/types.ts"; +import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { isAssistantHeartbeatAckForDisplay, stripHeartbeatTokenForDisplay, -} from "../chat/heartbeat-display.ts"; -import { extractText } from "../chat/message-extract.ts"; -import { reconcileChatRunLifecycle } from "../chat/run-lifecycle.ts"; +} from "../../lib/chat/heartbeat-display.ts"; +import { extractText } from "../../lib/chat/message-extract.ts"; +import type { ChatSideResult } from "../../lib/chat/side-result.ts"; +import { + formatMissingOperatorReadScopeMessage, + isMissingOperatorReadScopeError, +} from "../../lib/gateway-errors.ts"; +import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { isSessionRunActive } from "../../lib/session-run-state.ts"; +import { + scopedAgentParamsForSession, + unsubscribeSessionMessages, + type SessionCapability, +} from "../../lib/sessions/index.ts"; +import { + areUiSessionKeysEquivalent, + isUiSelectedGlobalSessionKey, + isUiGlobalSessionKey, + normalizeAgentId, + parseAgentSessionKey, + resolveUiDefaultAgentId, + resolveUiGlobalAliasAgentId, + resolveUiSelectedGlobalAgentId, + resolveUiSelectedSessionAgentId, +} from "../../lib/sessions/session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { + controlUiNowMs, + recordControlUiPerformanceEvent, + roundedControlUiDurationMs, +} from "./performance.ts"; +import { reconcileChatRunLifecycle } from "./run-lifecycle.ts"; +import { scheduleChatScroll } from "./scroll.ts"; import { - appendChatMessageToCache, cacheChatMessages, + clearChatMessagesFromCache, type ChatMessageCache, -} from "../chat/session-message-cache.ts"; +} from "./session-message-cache.ts"; import { - appendTerminalAssistantMessage, clearToolStreamSegments, currentLiveToolCallIds, hasVisibleStreamParts, @@ -25,37 +67,7 @@ import { persistedCurrentToolStreamIds, prunePersistedToolStreamMessages, visibleCurrentAssistantStreamTail, -} from "../chat/stream-reconciliation.ts"; -import { buildUserChatMessageContentBlocks } from "../chat/user-message-content.ts"; -import { formatConnectError } from "../connect-error.ts"; -import { - controlUiNowMs, - recordControlUiPerformanceEvent, - roundedControlUiDurationMs, -} from "../control-ui-performance.ts"; -import { isGatewayMethodAdvertised } from "../gateway-methods.ts"; -import { GatewayRequestError, type GatewayBrowserClient, type GatewayHelloOk } from "../gateway.ts"; -import { - areUiSessionKeysEquivalent, - DEFAULT_AGENT_ID, - normalizeAgentId, - parseAgentSessionKey, -} from "../session-key.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { - AgentsListResult, - GatewaySessionRow, - GatewaySessionsDefaults, - ModelCatalogEntry, -} from "../types.ts"; -import type { ChatAttachment } from "../ui-types.ts"; -import { generateUUID } from "../uuid.ts"; -import { - formatMissingOperatorReadScopeMessage, - isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; - -export { isGatewayMethodAdvertised } from "../gateway-methods.ts"; +} from "./stream-reconciliation.ts"; const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/; const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT = @@ -65,6 +77,7 @@ const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000; const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500; const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5_000; const chatHistoryRequestVersions = new WeakMap(); +const selectedSessionMessageSubscriptionGenerations = new WeakMap(); function beginChatHistoryRequest(state: ChatState): number { const key = state as object; @@ -86,10 +99,12 @@ function shouldApplyChatHistoryResult( if (!isLatestChatHistoryRequest(state, version) || state.sessionKey !== sessionKey) { return false; } - return !isSelectedGlobalEventSessionKey(sessionKey) || resolveSelectedAgentId(state) === agentId; + return ( + !isUiSelectedGlobalSessionKey(sessionKey) || resolveUiSelectedSessionAgentId(state) === agentId + ); } -function isSilentReplyStream(text: string): boolean { +export function isSilentReplyStream(text: string): boolean { return SILENT_REPLY_PATTERN.test(text); } @@ -177,11 +192,11 @@ function isHeartbeatAckStream(text: string): boolean { return stripHeartbeatTokenForDisplay(text).shouldSkip; } -function isHiddenAssistantStreamText(text: string): boolean { +export function isHiddenAssistantStreamText(text: string): boolean { return isSilentReplyStream(text) || isHeartbeatAckStream(text); } -function shouldHideAssistantChatMessage(message: unknown): boolean { +export function shouldHideAssistantChatMessage(message: unknown): boolean { return isAssistantSilentReply(message) || isAssistantHeartbeatAckForDisplay(message); } @@ -193,7 +208,7 @@ function shouldHideHistoryMessage(message: unknown): boolean { ); } -function materializeVisibleAssistantStreamMessages( +export function materializeVisibleAssistantStreamMessages( messages: unknown[], state: ChatState, opts: { @@ -395,26 +410,40 @@ export type ChatState = { chatMessages: unknown[]; chatMessagesBySession?: ChatMessageCache; chatThinkingLevel: string | null; + chatVerboseLevel: string | null; chatSending: boolean; chatMessage: string; chatAttachments: ChatAttachment[]; + chatQueue: ChatQueueItem[]; chatRunId: string | null; chatStream: string | null; chatStreamStartedAt: number | null; - chatVerboseLevel: string | null; lastError: string | null; chatError?: string | null; + chatSideResult?: ChatSideResult | null; + chatSideResultTerminalRuns?: Set; + chatReplyTarget?: unknown; agentsError?: string | null; + onAgentsList?: (agentsList: AgentsListResult, client: GatewayBrowserClient) => void; resetChatInputHistoryNavigation?: () => void; assistantAgentId?: string | null; agentsList?: ChatAgentsListSnapshot | null; agentsSelectedId?: string | null; - hello?: GatewayHelloOk | null; - settings?: { chatPersistCommentary?: boolean }; + hello: GatewayHelloOk | null; + settings?: { chatPersistCommentary?: boolean; gatewayUrl?: string | null }; }; type ChatAgentsListSnapshot = Partial> & { - agents?: Array<{ id: string }>; + agents?: AgentsListResult["agents"]; +}; + +type ChatSessionMessageSubscriptionState = ChatState & { + sessions: Pick; + sessionsResult?: SessionsListResult | null; + sessionsError?: string | null; + chatSessionMessageSubscriptionRequestedKey?: string | null; + chatSessionMessageSubscriptionKey?: string | null; + chatSessionMessageSubscriptionAgentId?: string | null; }; export type ChatHistoryResult = { @@ -448,110 +477,200 @@ function setChatError(state: ChatState, error: string | null) { state.chatError = error; } -function isGlobalSessionKey(sessionKey: string | undefined | null): boolean { - const normalized = normalizeLowercaseStringOrEmpty(sessionKey); - return normalized === "global"; -} - -function isSelectedGlobalEventSessionKey(sessionKey: string | undefined | null): boolean { - if (isGlobalSessionKey(sessionKey)) { +function chatScopedEventAgentScopeMatches( + state: ChatState, + sessionKey: string, + agentId?: string | null, +): boolean { + if (!isUiSelectedGlobalSessionKey(state.sessionKey) || !isUiGlobalSessionKey(sessionKey)) { return true; } - const parsed = parseAgentSessionKey(sessionKey); - return normalizeLowercaseStringOrEmpty(parsed?.rest) === "main"; + const payloadAgentId = + typeof agentId === "string" && agentId.trim() ? normalizeAgentId(agentId) : undefined; + const selectedAgentId = resolveUiSelectedSessionAgentId(state); + return payloadAgentId + ? selectedAgentId !== undefined && payloadAgentId === selectedAgentId + : selectedAgentId === undefined || selectedAgentId === resolveUiDefaultAgentId(state); } -function resolveSelectedAgentId(state: ChatState): string | undefined { +export function chatScopedEventSessionMatches( + state: ChatState, + sessionKey: string, + agentId?: string | null, +): boolean { + if (areUiSessionKeysEquivalent(sessionKey, state.sessionKey)) { + return chatScopedEventAgentScopeMatches(state, sessionKey, agentId); + } + return ( + isUiGlobalSessionKey(sessionKey) && + isUiSelectedGlobalSessionKey(state.sessionKey) && + chatScopedEventAgentScopeMatches(state, sessionKey, agentId) + ); +} + +function normalizeSubscriptionKey(value: string | null | undefined): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return normalized ? normalized : null; +} + +function resolveSelectedGlobalAliasAgentId( + state: ChatSessionMessageSubscriptionState, + key: string | null | undefined, +): string | null { + const row = state.sessionsResult?.sessions.find((session) => session.key === key); + return resolveUiGlobalAliasAgentId(state, key, { + rowKind: row?.kind, + requireGlobalRowForMainAlias: true, + }); +} + +function resolveSelectedGlobalAgentId(state: ChatSessionMessageSubscriptionState): string { const parsed = parseAgentSessionKey(state.sessionKey); if (parsed?.agentId) { return normalizeAgentId(parsed.agentId); } - const snapshot = state.hello?.snapshot as - | { sessionDefaults?: { defaultAgentId?: string } } - | undefined; - const assistantAgentId = - typeof state.assistantAgentId === "string" && state.assistantAgentId.trim() - ? state.assistantAgentId - : undefined; - const defaultAgentId = - typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() - ? state.agentsList.defaultId - : undefined; - const helloDefaultAgentId = - typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && - snapshot.sessionDefaults.defaultAgentId.trim() - ? snapshot.sessionDefaults.defaultAgentId - : undefined; - const selectedAgentId = assistantAgentId ?? defaultAgentId ?? helloDefaultAgentId; - return selectedAgentId ? normalizeAgentId(selectedAgentId) : undefined; + return resolveUiSelectedGlobalAgentId(state); } -function resolveDefaultAgentId(state: ChatState): string | undefined { - const snapshot = state.hello?.snapshot as - | { sessionDefaults?: { defaultAgentId?: string } } - | undefined; - const agentId = - typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() - ? state.agentsList.defaultId - : typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && - snapshot.sessionDefaults.defaultAgentId.trim() - ? snapshot.sessionDefaults.defaultAgentId - : undefined; - return agentId ? normalizeAgentId(agentId) : undefined; -} - -function chatEventAgentScopeMatches(state: ChatState, payload: ChatEventPayload): boolean { - if ( - !isSelectedGlobalEventSessionKey(state.sessionKey) || - !isGlobalSessionKey(payload.sessionKey) - ) { - return true; +function resolveSelectedSessionMessageSubscriptionAgentId( + state: ChatSessionMessageSubscriptionState, + key: string, +): string | null { + if (isUiGlobalSessionKey(key)) { + return resolveSelectedGlobalAgentId(state); } - const payloadAgentId = - typeof payload.agentId === "string" && payload.agentId.trim() - ? normalizeAgentId(payload.agentId) - : undefined; - const selectedAgentId = resolveSelectedAgentId(state); - return payloadAgentId - ? selectedAgentId !== undefined && payloadAgentId === selectedAgentId - : selectedAgentId === undefined || selectedAgentId === resolveDefaultAgentId(state); + return resolveSelectedGlobalAliasAgentId(state, key); } -function chatEventSessionMatches(state: ChatState, payload: ChatEventPayload): boolean { - if (areUiSessionKeysEquivalent(payload.sessionKey, state.sessionKey)) { - return chatEventAgentScopeMatches(state, payload); - } +function beginSelectedSessionMessageSubscriptionSync( + state: ChatSessionMessageSubscriptionState, +): number { + const key = state as object; + const next = (selectedSessionMessageSubscriptionGenerations.get(key) ?? 0) + 1; + selectedSessionMessageSubscriptionGenerations.set(key, next); + return next; +} + +function isCurrentSelectedSessionMessageSubscriptionSync( + state: ChatSessionMessageSubscriptionState, + params: { + generation: number; + client: GatewayBrowserClient; + requestedKey: string; + requestedAgentId?: string | null; + }, +): boolean { return ( - isGlobalSessionKey(payload.sessionKey) && - isSelectedGlobalEventSessionKey(state.sessionKey) && - chatEventAgentScopeMatches(state, payload) + selectedSessionMessageSubscriptionGenerations.get(state as object) === params.generation && + state.client === params.client && + state.connected && + state.sessionKey.trim() === params.requestedKey && + resolveSelectedSessionMessageSubscriptionAgentId(state, params.requestedKey) === + (params.requestedAgentId ?? null) ); } -function resolveDeltaChatStreamText( - currentStream: string | null, - payload: ChatEventPayload, -): string | null { - const snapshot = payload.message == null ? null : extractText(payload.message); - if (typeof payload.deltaText === "string") { - if (payload.replace === true) { - return payload.deltaText; - } - if (currentStream === null) { - return typeof snapshot === "string" ? snapshot : payload.deltaText; - } - if (typeof snapshot === "string") { - const prefixLength = snapshot.length - payload.deltaText.length; - if ( - prefixLength !== currentStream.length || - snapshot.slice(0, prefixLength) !== currentStream - ) { - return snapshot; +async function unsubscribeSelectedSessionMessageBestEffort( + client: GatewayBrowserClient, + key: string, + agentId?: string | null, +): Promise { + try { + await unsubscribeSessionMessages(client, { + key, + agentId: isUiGlobalSessionKey(key) ? agentId : null, + }); + } catch { + // Cleanup is best effort when a stale subscription completion loses ownership. + } +} + +export async function syncSelectedSessionMessageSubscription( + state: ChatSessionMessageSubscriptionState, + opts?: { force?: boolean }, +) { + if (!state.client || !state.connected) { + return; + } + const client = state.client; + const nextKey = state.sessionKey.trim(); + if (!nextKey) { + return; + } + const generation = beginSelectedSessionMessageSubscriptionSync(state); + const previousRequestedKey = normalizeSubscriptionKey( + state.chatSessionMessageSubscriptionRequestedKey, + ); + const previousCanonicalKey = normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey); + const previousSelectedKey = previousRequestedKey ?? previousCanonicalKey; + const nextSubscriptionAgentId = resolveSelectedSessionMessageSubscriptionAgentId(state, nextKey); + const selectedAgentChanged = + nextSubscriptionAgentId !== null && + previousSelectedKey === nextKey && + (state.chatSessionMessageSubscriptionAgentId ?? null) !== nextSubscriptionAgentId; + const selectedKeyChanged = previousSelectedKey !== null && previousSelectedKey !== nextKey; + const shouldUnsubscribePrevious = + previousCanonicalKey !== null && (selectedKeyChanged || selectedAgentChanged); + const shouldSubscribe = + opts?.force === true || + selectedKeyChanged || + selectedAgentChanged || + previousCanonicalKey === null || + previousRequestedKey === null; + if (!shouldUnsubscribePrevious && !shouldSubscribe) { + return; + } + const isCurrent = () => + isCurrentSelectedSessionMessageSubscriptionSync(state, { + generation, + client, + requestedKey: nextKey, + requestedAgentId: nextSubscriptionAgentId, + }); + try { + if (shouldUnsubscribePrevious && previousCanonicalKey) { + await unsubscribeSessionMessages(client, { + key: previousCanonicalKey, + agentId: + isUiGlobalSessionKey(previousCanonicalKey) && state.chatSessionMessageSubscriptionAgentId + ? state.chatSessionMessageSubscriptionAgentId + : null, + }); + if (isCurrent()) { + state.chatSessionMessageSubscriptionKey = null; + state.chatSessionMessageSubscriptionRequestedKey = null; + state.chatSessionMessageSubscriptionAgentId = null; } } - return `${currentStream}${payload.deltaText}`; + if (!shouldSubscribe || !isCurrent()) { + return; + } + const subscribed = await state.sessions.subscribeMessages(nextKey, { + agentId: nextSubscriptionAgentId ?? undefined, + }); + if (!isCurrent()) { + const staleKeyChanged = + normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey) !== subscribed.key; + const staleAgentChanged = + isUiGlobalSessionKey(subscribed.key) && + (state.chatSessionMessageSubscriptionAgentId ?? null) !== subscribed.agentId; + if (staleKeyChanged || staleAgentChanged) { + await unsubscribeSelectedSessionMessageBestEffort( + client, + subscribed.key, + subscribed.agentId, + ); + } + return; + } + state.chatSessionMessageSubscriptionRequestedKey = nextKey; + state.chatSessionMessageSubscriptionKey = subscribed.key; + state.chatSessionMessageSubscriptionAgentId = subscribed.agentId; + } catch (err) { + if (isCurrent()) { + state.sessionsError = String(err); + } } - return typeof snapshot === "string" ? snapshot : null; } type InFlightChatHistoryRequest = { @@ -587,18 +706,6 @@ function recordChatHistoryTiming( ); } -function appendCachedChatMessage( - state: ChatState, - sessionKey: string, - message: unknown, - agentId?: string, -) { - if (!state.chatMessagesBySession) { - return; - } - appendChatMessageToCache(state.chatMessagesBySession, state, { sessionKey, agentId }, message); -} - function replaceCachedChatMessages( state: ChatState, sessionKey: string, @@ -611,6 +718,62 @@ function replaceCachedChatMessages( cacheChatMessages(state.chatMessagesBySession, state, { sessionKey, agentId }, messages); } +type ClearChatHistoryState = ChatState & + Parameters[0] & + Parameters[0] & { + sessions: Pick; + }; + +function hasAbortableChatSessionRun(state: ClearChatHistoryState): boolean { + if (state.chatRunId) { + return true; + } + return Boolean( + state.sessionsResult?.sessions.some( + (session) => session.key === state.sessionKey && isSessionRunActive(session), + ), + ); +} + +function clearCachedChatMessagesForSession(state: ClearChatHistoryState, sessionKey: string) { + if (!state.chatMessagesBySession) { + return; + } + clearChatMessagesFromCache(state.chatMessagesBySession, state, { sessionKey }); +} + +export async function clearChatHistory(state: ClearChatHistoryState) { + if (!state.client || !state.connected) { + return; + } + const hadActiveRun = hasAbortableChatSessionRun(state); + try { + await state.sessions.reset( + state.sessionKey, + scopedAgentParamsForSession(state, state.sessionKey), + ); + state.chatMessages = []; + clearCachedChatMessagesForSession(state, state.sessionKey); + state.chatSideResult = null; + state.chatReplyTarget = null; + reconcileChatRunLifecycle(state, { + outcome: hadActiveRun ? "interrupted" : undefined, + sessionStatus: "killed", + runId: state.chatRunId, + sessionKey: state.sessionKey, + clearLocalRun: true, + clearChatStream: true, + clearToolStream: true, + clearSideResultTerminalRuns: true, + clearRunStatus: !hadActiveRun, + }); + await loadChatHistory(state); + } catch (err) { + setChatError(state, String(err)); + } + scheduleChatScroll(state); +} + export async function loadChatHistory( state: ChatState, opts: LoadChatHistoryOptions = {}, @@ -619,8 +782,8 @@ export async function loadChatHistory( return undefined; } const sessionKey = state.sessionKey; - const requestAgentId = isSelectedGlobalEventSessionKey(sessionKey) - ? resolveSelectedAgentId(state) + const requestAgentId = isUiSelectedGlobalSessionKey(sessionKey) + ? resolveUiSelectedSessionAgentId(state) : undefined; const startupAdvertised = isGatewayMethodAdvertised(state, "chat.startup"); const method = @@ -654,12 +817,17 @@ export async function loadChatHistory( return promise; } -function applyChatStartupAgentsList(state: ChatState, agentsList: AgentsListResult | undefined) { - if (!agentsList) { +export function applyChatAgentsList( + state: ChatState, + agentsList: AgentsListResult | undefined, + client: GatewayBrowserClient, +) { + if (!agentsList || state.client !== client || !state.connected) { return; } state.agentsList = agentsList; state.agentsError = null; + state.onAgentsList?.(agentsList, client); const selectedId = typeof state.agentsSelectedId === "string" && state.agentsSelectedId.trim() ? normalizeAgentId(state.agentsSelectedId) @@ -745,7 +913,7 @@ async function loadChatHistoryUncached( return undefined; } const messages = Array.isArray(res.messages) ? res.messages : []; - applyChatStartupAgentsList(state, res.agentsList); + applyChatAgentsList(state, res.agentsList, client); const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message)); const lateOptimisticTail = collectLateOptimisticTailMessages( previousMessages, @@ -880,551 +1048,3 @@ async function loadChatHistoryUncached( } return undefined; } - -function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null { - const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl); - if (!match) { - return null; - } - return { mimeType: match[1], content: match[2] }; -} - -function buildApiAttachments(attachments?: ChatAttachment[]) { - const hasAttachments = attachments && attachments.length > 0; - return hasAttachments - ? attachments - .map((att) => { - const dataUrl = getChatAttachmentDataUrl(att); - const parsed = dataUrl ? dataUrlToBase64(dataUrl) : null; - if (!parsed) { - return null; - } - return { - type: parsed.mimeType.startsWith("image/") ? "image" : "file", - mimeType: parsed.mimeType, - fileName: att.fileName, - content: parsed.content, - }; - }) - .filter((a): a is NonNullable => a !== null) - : undefined; -} - -export type ChatSendAckStatus = "started" | "in_flight" | "ok" | "timeout" | "error"; - -export type ChatSendAckServerTiming = { - receivedToAckMs?: number; - loadSessionMs?: number; - prepareAttachmentsMs?: number; -}; - -export type ChatSendAck = { - runId: string; - status: ChatSendAckStatus; - serverTiming?: ChatSendAckServerTiming; -}; - -function normalizeAckTimingValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - -function normalizeChatSendAckServerTiming(value: unknown): ChatSendAckServerTiming | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const record = value as Record; - const receivedToAckMs = normalizeAckTimingValue(record.receivedToAckMs); - const loadSessionMs = normalizeAckTimingValue(record.loadSessionMs); - const prepareAttachmentsMs = normalizeAckTimingValue(record.prepareAttachmentsMs); - const timing: ChatSendAckServerTiming = { - ...(receivedToAckMs !== undefined ? { receivedToAckMs } : {}), - ...(loadSessionMs !== undefined ? { loadSessionMs } : {}), - ...(prepareAttachmentsMs !== undefined ? { prepareAttachmentsMs } : {}), - }; - return Object.keys(timing).length > 0 ? timing : undefined; -} - -function normalizeChatSendAck(payload: unknown, fallbackRunId: string): ChatSendAck { - if (!payload || typeof payload !== "object") { - return { runId: fallbackRunId, status: "started" }; - } - const record = payload as Record; - const runId = - typeof record.runId === "string" && record.runId.trim() ? record.runId.trim() : fallbackRunId; - const status = record.status; - const serverTiming = normalizeChatSendAckServerTiming(record.serverTiming); - return { - runId, - status: - status === "in_flight" || status === "ok" || status === "timeout" || status === "error" - ? status - : "started", - ...(serverTiming ? { serverTiming } : {}), - }; -} - -export async function requestChatSend( - state: ChatState, - params: { - message: string; - attachments?: ChatAttachment[]; - runId: string; - sessionKey?: string; - agentId?: string; - }, -): Promise { - const routing = resolveChatSendRouting(state, params); - const controlUiReconnectResume = Boolean( - routing.sessionId && state.reconnectResumeSessionId === routing.sessionId, - ); - const payload = await state.client!.request("chat.send", { - sessionKey: routing.sessionKey, - ...(isGlobalSessionKey(routing.sessionKey) && routing.selectedAgentId - ? { agentId: routing.selectedAgentId } - : {}), - ...(routing.sessionId ? { sessionId: routing.sessionId } : {}), - ...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}), - message: params.message, - deliver: false, - idempotencyKey: params.runId, - attachments: buildApiAttachments(params.attachments), - }); - if (controlUiReconnectResume) { - state.reconnectResumeSessionId = null; - } - return normalizeChatSendAck(payload, params.runId); -} - -function resolveChatSendRouting( - state: ChatState, - params: { - sessionKey?: string; - agentId?: string; - }, -): { selectedAgentId?: string; sessionId?: string; sessionKey: string } { - const sessionKey = params.sessionKey ?? state.sessionKey; - const selectedAgentId = params.agentId - ? normalizeAgentId(params.agentId) - : resolveSelectedAgentId(state); - const currentSessionId = state.currentSessionId; - const canReuseCurrentSessionId = - sessionKey === state.sessionKey && - (!isGlobalSessionKey(sessionKey) || - (selectedAgentId !== undefined && selectedAgentId === resolveSelectedAgentId(state))); - const sessionId = - canReuseCurrentSessionId && typeof currentSessionId === "string" && currentSessionId.trim() - ? currentSessionId.trim() - : undefined; - return { - sessionKey, - ...(selectedAgentId ? { selectedAgentId } : {}), - ...(sessionId ? { sessionId } : {}), - }; -} - -export async function requestSkillWorkshopRevisionChatSend( - state: ChatState, - params: { - proposalId: string; - instructions: string; - runId: string; - sessionKey?: string; - agentId?: string; - targetAgentId?: string; - }, -): Promise { - const routing = resolveChatSendRouting(state, { - sessionKey: params.sessionKey, - agentId: params.targetAgentId, - }); - const payload = await state.client!.request("skills.proposals.requestRevision", { - ...(params.agentId ? { agentId: normalizeAgentId(params.agentId) } : {}), - ...(routing.selectedAgentId ? { targetAgentId: routing.selectedAgentId } : {}), - proposalId: params.proposalId, - instructions: params.instructions, - sessionKey: routing.sessionKey, - ...(routing.sessionId ? { sessionId: routing.sessionId } : {}), - idempotencyKey: params.runId, - }); - return normalizeChatSendAck(payload, params.runId); -} - -type AssistantMessageNormalizationOptions = { - roleRequirement: "required" | "optional"; - roleCaseSensitive?: boolean; - requireContentArray?: boolean; - allowTextField?: boolean; -}; - -function normalizeAssistantMessage( - message: unknown, - options: AssistantMessageNormalizationOptions, -): Record | null { - if (!message || typeof message !== "object") { - return null; - } - const candidate = message as Record; - const roleValue = candidate.role; - if (typeof roleValue === "string") { - const role = options.roleCaseSensitive ? roleValue : normalizeLowercaseStringOrEmpty(roleValue); - if (role !== "assistant") { - return null; - } - } else if (options.roleRequirement === "required") { - return null; - } - - if (options.requireContentArray) { - return Array.isArray(candidate.content) ? candidate : null; - } - if (!("content" in candidate) && !(options.allowTextField && "text" in candidate)) { - return null; - } - return candidate; -} - -function normalizeAbortedAssistantMessage(message: unknown): Record | null { - return normalizeAssistantMessage(message, { - roleRequirement: "required", - roleCaseSensitive: true, - requireContentArray: true, - }); -} - -function normalizeFinalAssistantMessage(message: unknown): Record | null { - return normalizeAssistantMessage(message, { - roleRequirement: "optional", - allowTextField: true, - }); -} - -function buildErrorAssistantMessage(payload: ChatEventPayload): Record | null { - const normalized = normalizeFinalAssistantMessage(payload.message); - if (normalized && !shouldHideAssistantChatMessage(normalized)) { - return normalized; - } - const error = payload.errorMessage?.trim(); - if (!error) { - return null; - } - return { - role: "assistant", - content: [ - { - type: "text", - text: error.startsWith("⚠️") || error.startsWith("Error:") ? error : `Error: ${error}`, - }, - ], - timestamp: Date.now(), - }; -} - -export async function sendChatMessage( - state: ChatState, - message: string, - attachments?: ChatAttachment[], -): Promise { - if (!state.client || !state.connected) { - return null; - } - const msg = message.trim(); - const hasAttachments = attachments && attachments.length > 0; - if (!msg && !hasAttachments) { - return null; - } - if (state.chatSending) { - return state.chatRunId; - } - - const now = Date.now(); - const optimisticMessage = appendUserChatMessage(state, msg, attachments, now); - - state.chatSending = true; - setChatError(state, null); - reconcileChatRunLifecycle(state as unknown as Parameters[0], { - clearRunStatus: true, - }); - const runId = generateUUID(); - state.chatRunId = runId; - state.chatStream = ""; - state.chatStreamStartedAt = now; - - try { - const ack = await requestChatSend(state, { message: msg, attachments, runId }); - if (ack.status === "ok") { - reconcileChatRunLifecycle( - state as unknown as Parameters[0], - { - outcome: "done", - sessionStatus: "done", - runId: ack.runId, - sessionKey: state.sessionKey, - clearLocalRun: true, - clearChatStream: true, - armLocalTerminalReconcile: true, - }, - ); - } else if (isNonTerminalAgentRunStatus(ack.status)) { - state.chatRunId = ack.runId; - } else { - state.chatMessages = state.chatMessages.filter( - (messageEntry) => messageEntry !== optimisticMessage, - ); - reconcileChatRunLifecycle( - state as unknown as Parameters[0], - { - outcome: "interrupted", - sessionStatus: ack.status === "error" ? "failed" : "killed", - runId: ack.runId, - sessionKey: state.sessionKey, - clearLocalRun: true, - clearChatStream: true, - armLocalTerminalReconcile: ack.runId === runId, - }, - ); - setChatError( - state, - ack.status === "error" - ? "Chat failed before the run started; try again." - : "The run ended before the message was accepted.", - ); - return null; - } - return ack.runId; - } catch (err) { - const error = formatConnectError(err); - reconcileChatRunLifecycle(state as unknown as Parameters[0], { - outcome: "interrupted", - sessionStatus: "failed", - runId, - sessionKey: state.sessionKey, - clearLocalRun: true, - clearChatStream: true, - }); - setChatError(state, error); - state.chatMessages = [ - ...state.chatMessages, - { - role: "assistant", - content: [{ type: "text", text: "Error: " + error }], - timestamp: Date.now(), - }, - ]; - return null; - } finally { - state.chatSending = false; - } -} - -export function appendUserChatMessage( - state: ChatState, - message: string, - attachments?: ChatAttachment[], - timestamp = Date.now(), -) { - const entry = { - role: "user" as const, - content: buildUserChatMessageContentBlocks(message, attachments), - timestamp, - }; - state.chatMessages = [...state.chatMessages, entry]; - return entry; -} - -async function sendChatMessageWithGeneratedRunId( - state: ChatState, - message: string, - attachments?: ChatAttachment[], -): Promise { - if (!state.client || !state.connected) { - return null; - } - const msg = message.trim(); - const hasAttachments = attachments && attachments.length > 0; - if (!msg && !hasAttachments) { - return null; - } - setChatError(state, null); - const runId = generateUUID(); - try { - return await requestChatSend(state, { message: msg, attachments, runId }); - } catch (err) { - setChatError(state, formatConnectError(err)); - return null; - } -} - -export async function sendDetachedChatMessage( - state: ChatState, - message: string, - attachments?: ChatAttachment[], -): Promise { - return sendChatMessageWithGeneratedRunId(state, message, attachments); -} - -export async function sendSteerChatMessage( - state: ChatState, - message: string, - attachments?: ChatAttachment[], -): Promise { - return sendChatMessageWithGeneratedRunId(state, message, attachments); -} - -export async function abortChatRun(state: ChatState): Promise { - if (!state.client || !state.connected) { - return false; - } - const runId = state.chatRunId; - try { - await state.client.request( - "chat.abort", - runId - ? { - sessionKey: state.sessionKey, - ...(() => { - const agentId = resolveSelectedAgentId(state); - return isGlobalSessionKey(state.sessionKey) && agentId ? { agentId } : {}; - })(), - runId, - } - : { - sessionKey: state.sessionKey, - ...(() => { - const agentId = resolveSelectedAgentId(state); - return isGlobalSessionKey(state.sessionKey) && agentId ? { agentId } : {}; - })(), - }, - ); - return true; - } catch (err) { - setChatError(state, formatConnectError(err)); - return false; - } -} - -export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { - if (!payload) { - return null; - } - const hadActiveRunBeforeEvent = state.chatRunId !== null; - const sessionMatches = chatEventSessionMatches(state, payload); - const activeRunMatches = - state.chatRunId !== null && - typeof payload.runId === "string" && - payload.runId === state.chatRunId; - if (!sessionMatches && !activeRunMatches) { - if (payload.state === "final") { - const finalMessage = normalizeFinalAssistantMessage(payload.message); - if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) { - const cacheAgentId = isGlobalSessionKey(payload.sessionKey) - ? (payload.agentId ?? resolveDefaultAgentId(state) ?? DEFAULT_AGENT_ID) - : payload.agentId; - appendCachedChatMessage(state, payload.sessionKey, finalMessage, cacheAgentId); - } - } - return null; - } - if (!state.chatRunId && sessionMatches && typeof payload.runId === "string") { - state.chatRunId = payload.runId; - state.chatStreamStartedAt ??= Date.now(); - } - - // Terminal events for the active client run carry runId; missing-runId events are unowned. - // Final from another run (e.g. sub-agent announce): refresh history to show new message. - // See https://github.com/openclaw/openclaw/issues/1909 - if (state.chatRunId && payload.runId !== state.chatRunId) { - if (payload.state === "final") { - const finalMessage = normalizeFinalAssistantMessage(payload.message); - if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) { - state.chatMessages = [...state.chatMessages, finalMessage]; - return null; - } - return "final"; - } - return null; - } - - const terminalRunId = payload.runId ?? state.chatRunId; - const reconcileTerminalRun = ( - outcome: "done" | "interrupted", - sessionStatus: "done" | "failed" | "killed", - ) => - reconcileChatRunLifecycle(state as unknown as Parameters[0], { - outcome, - sessionStatus, - runId: terminalRunId, - sessionKey: state.sessionKey, - sessionKeys: sessionMatches ? [state.sessionKey, payload.sessionKey] : [], - clearLocalRun: true, - clearChatStream: true, - armLocalTerminalReconcile: hadActiveRunBeforeEvent && activeRunMatches, - }); - - if (payload.state === "delta") { - const next = resolveDeltaChatStreamText(state.chatStream, payload); - if ( - typeof next === "string" && - !isSilentReplyStream(next) && - !isAssistantHeartbeatAckForDisplay(payload.message) - ) { - state.chatStream = next; - } - } else if (payload.state === "final") { - const finalMessage = normalizeFinalAssistantMessage(payload.message); - if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) { - if ( - hasVisibleStreamParts(state, { - includeCurrent: false, - isHiddenStreamText: isHiddenAssistantStreamText, - }) - ) { - state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { - includeCurrent: false, - }); - clearToolStreamSegments(state); - } - state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, finalMessage); - } else { - state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state); - } - reconcileTerminalRun("done", "done"); - } else if (payload.state === "aborted") { - const normalizedMessage = normalizeAbortedAssistantMessage(payload.message); - if (normalizedMessage && !shouldHideAssistantChatMessage(normalizedMessage)) { - state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { - replacementMessages: [normalizedMessage], - includeCurrent: false, - }); - state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, normalizedMessage); - } else { - state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state); - } - reconcileTerminalRun("interrupted", "killed"); - } else if (payload.state === "error") { - const payloadMessage = hadActiveRunBeforeEvent - ? normalizeFinalAssistantMessage(payload.message) - : null; - const visiblePayloadMessage = - payloadMessage && !shouldHideAssistantChatMessage(payloadMessage) ? payloadMessage : null; - if (visiblePayloadMessage) { - state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { - replacementMessages: [visiblePayloadMessage], - }); - state.chatMessages = appendTerminalAssistantMessage( - state.chatMessages, - visiblePayloadMessage, - ); - } else { - const errorMessage = hadActiveRunBeforeEvent ? buildErrorAssistantMessage(payload) : null; - if (hadActiveRunBeforeEvent) { - state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state); - } - if (errorMessage) { - state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, errorMessage); - } - } - reconcileTerminalRun("interrupted", "failed"); - setChatError(state, payload.errorMessage ?? "chat error"); - } - return payload.state; -} diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts new file mode 100644 index 000000000000..eb72a5efb3c7 --- /dev/null +++ b/ui/src/pages/chat/chat-page.ts @@ -0,0 +1,817 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationGatewaySnapshot, +} from "../../app/context.ts"; +import { + COMMAND_PALETTE_TARGET_EVENT, + type CommandPaletteTargetDetail, +} from "../../components/command-palette.ts"; +import "../../components/tooltip.ts"; +import { t } from "../../i18n/index.ts"; +import { resolveSessionDisplayName } from "../../lib/session-display.ts"; +import { + resolveSessionKey, + searchForSession, + scopedAgentParamsForSession, +} from "../../lib/sessions/index.ts"; +import { + areUiSessionKeysEquivalent, + buildAgentMainSessionKey, + parseAgentSessionKey, + resolveAgentIdFromSessionKey, + resolveUiConfiguredMainKey, + uiSessionEventMatches, +} from "../../lib/sessions/session-key.ts"; +import { refreshChatAvatar } from "./chat-avatar.ts"; +import { refreshSlashCommands } from "./chat-commands.ts"; +import { + applyChatAgentsList, + clearChatHistory, + loadChatHistory, + syncSelectedSessionMessageSubscription, +} from "./chat-history.ts"; +import { markQueuedChatSendsWaitingForReconnect } from "./chat-queue.ts"; +import { dismissRealtimeTalkError } from "./chat-realtime.ts"; +import { flushChatQueueForEvent, retryReconnectableQueuedChatSends } from "./chat-send.ts"; +import { + flushChatQueueAfterIdleSessionReconciliation, + switchChatFastMode, + switchChatModel, + switchChatThinkingLevel, +} from "./chat-session.ts"; +import { + canCreateChatSession, + ChatStateController, + createPageState, + dismissChatError, + handleChatManualRefresh, + handlePageGatewayEvent, + refreshChatCommands, + refreshChatModelAuthStatus, + refreshPageChat, + refreshRouteSessionOptions, + resetChatStateForRouteSession, + resolveAssistantAttachmentAuthToken, + resolveChatAgentId, + resolveChatAvatarUrl, + saveRouteSessionSettings, + type ChatPageHost, +} from "./chat-state.ts"; +import { renderChat, resetChatViewState, type ChatProps } from "./chat-view.ts"; +import { renderChatControls } from "./components/chat-controls.ts"; +import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts"; +import { + CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS, + type DetailFullMessageResult, + type SidebarFullMessageRequest, +} from "./components/chat-sidebar.ts"; +import { exportChatMarkdown } from "./export.ts"; +import { hasAbortableSessionRun } from "./run-lifecycle.ts"; +import { scheduleChatScroll } from "./scroll.ts"; +import { clearChatMessagesFromCache } from "./session-message-cache.ts"; + +type ChatRouteData = { + sessionKey: string; + draft?: string; +}; + +type ChatPageContext = ApplicationContext; + +const CHAT_OPEN_DETAILS_SELECTOR = + ".chat-controls__inline-select[open], .agent-chat__talk-select[open], .agent-chat__talk-options-advanced[open]"; + +const NEW_SESSION_ACTIVE_RUN_MESSAGE = + "Start a new session after the active run or queued messages finish."; +const NEW_SESSION_LIST_LOADING_MESSAGE = + "Session list is still refreshing. Try New Chat again in a moment."; +const NEW_SESSION_CREATE_FAILED_MESSAGE = + "New Chat could not create a new session. Try again in a moment."; + +export class ChatPage extends LitElement { + @consume({ context: applicationContext, subscribe: false }) + private context!: ChatPageContext; + @property({ attribute: false }) data!: ChatRouteData; + + private readonly chatState = new ChatStateController(this); + private state: ChatPageHost | undefined; + private connectedClient: GatewayBrowserClient | null = null; + private connectionGeneration = 0; + + private applyRouteSessionKey(sessionKey: string) { + const state = this.state; + if (!state) { + return; + } + const nextSessionKey = resolveSessionKey(sessionKey, this.context.gateway.snapshot.hello); + if (!nextSessionKey) { + return; + } + state.sessionKey = nextSessionKey; + saveRouteSessionSettings(state, nextSessionKey); + this.context.gateway.setSessionKey(nextSessionKey); + const agentId = parseAgentSessionKey(nextSessionKey)?.agentId; + if (agentId) { + this.context.agentSelection.set(agentId); + } + } + + private switchRouteSession(nextSessionKey: string) { + const state = this.state; + if (!state) { + return; + } + const previousSessionKey = state.sessionKey; + const previousSessionsResult = state.sessionsResult; + const nextSessionRow = state.sessionsResult?.sessions.find((row) => row.key === nextSessionKey); + const nextSessionLabel = resolveSessionDisplayName(nextSessionKey, nextSessionRow); + resetChatStateForRouteSession(state, nextSessionKey); + this.context.gateway.setSessionKey(nextSessionKey); + if (previousSessionKey !== nextSessionKey) { + state.announceSessionSwitch?.(nextSessionKey, nextSessionLabel); + } + void state.loadAssistantIdentity(); + void refreshChatAvatar(state); + void refreshSlashCommands({ + client: state.client, + agentId: parseAgentSessionKey(nextSessionKey)?.agentId, + }); + const subscriptionSync = syncSelectedSessionMessageSubscription(state); + const historyLoad = loadChatHistory(state); + state.requestUpdate(); + const scheduleHistoryScroll = () => { + if (state.sessionKey !== nextSessionKey) { + return; + } + state.requestUpdate(); + scheduleChatScroll(state, true); + }; + void historyLoad.then(scheduleHistoryScroll, scheduleHistoryScroll); + void historyLoad.then( + () => this.sendPendingSkillWorkshopRevision(nextSessionKey), + () => this.sendPendingSkillWorkshopRevision(nextSessionKey), + ); + const sessionsRefresh = refreshRouteSessionOptions(state); + flushChatQueueAfterIdleSessionReconciliation( + state, + nextSessionKey, + historyLoad, + sessionsRefresh, + previousSessionsResult, + () => void flushChatQueueForEvent(state), + ); + void subscriptionSync; + void historyLoad; + void sessionsRefresh; + } + + private readonly handleCommandPaletteSlashCommand = (command: string) => { + const state = this.state; + if (!state) { + return; + } + state.handleChatDraftChange(command.endsWith(" ") ? command : `${command} `); + state.requestUpdate?.(); + }; + + private announceCommandPaletteTarget( + onSlashCommand: CommandPaletteTargetDetail["onSlashCommand"], + ) { + this.dispatchEvent( + new CustomEvent(COMMAND_PALETTE_TARGET_EVENT, { + bubbles: true, + composed: true, + detail: { + owner: this, + onSlashCommand, + }, + }), + ); + } + + private readonly createSession = async (): Promise => { + const state = this.state; + if (!state || !state.client || !state.connected) { + return false; + } + if (!canCreateChatSession(state)) { + state.lastError = NEW_SESSION_ACTIVE_RUN_MESSAGE; + state.chatError = state.lastError; + state.requestUpdate?.(); + return false; + } + if (state.sessionsLoading) { + state.lastError = NEW_SESSION_LIST_LOADING_MESSAGE; + state.chatError = state.lastError; + state.requestUpdate?.(); + return false; + } + + state.lastError = null; + state.chatError = null; + const previousSessionKey = state.sessionKey; + const nextSessionKey = await this.context.sessions.create({ + currentSessionKey: previousSessionKey, + agentId: + scopedAgentParamsForSession(state, previousSessionKey).agentId ?? + resolveAgentIdFromSessionKey(previousSessionKey), + }); + if ( + !nextSessionKey || + state.sessionKey !== previousSessionKey || + !canCreateChatSession(state) + ) { + if (!nextSessionKey) { + state.lastError = + state.sessionsError ?? + (state.sessionsLoading + ? NEW_SESSION_LIST_LOADING_MESSAGE + : NEW_SESSION_CREATE_FAILED_MESSAGE); + state.chatError = state.lastError; + state.requestUpdate?.(); + } + return false; + } + this.chatState.captureCreatedSessionComposer(nextSessionKey); + this.context.navigate("chat", { + search: searchForSession(nextSessionKey), + }); + return true; + }; + + private sendPendingSkillWorkshopRevision(expectedSessionKey: string) { + const state = this.state; + if (!state || !state.connected || state.sessionKey !== expectedSessionKey) { + return; + } + const revision = this.context.skillWorkshopRevision.consume(expectedSessionKey); + if (!revision) { + return; + } + void state + .handleSendChat(revision.instructions, { + restoreDraft: true, + skillWorkshopRevision: { + proposalId: revision.proposalId, + agentId: revision.proposalAgentId, + }, + }) + .catch((error: unknown) => { + state.lastError = error instanceof Error ? error.message : String(error); + state.chatError = state.lastError; + state.requestUpdate?.(); + }); + } + + private readonly handleDocumentKeydown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.key !== "Escape") { + return; + } + const state = this.state; + if (!state) { + return; + } + const openDetails = this.querySelectorAll(CHAT_OPEN_DETAILS_SELECTOR); + if (openDetails.length > 0) { + event.preventDefault(); + openDetails.forEach((details) => { + details.open = false; + }); + return; + } + if (state.realtimeTalkOptionsOpen) { + event.preventDefault(); + state.realtimeTalkOptionsOpen = false; + state.requestUpdate(); + return; + } + if (!state.chatMobileControlsOpen) { + return; + } + event.preventDefault(); + state.setChatMobileControlsOpen(false, { restoreFocus: true }); + }; + + private readonly handleDocumentPointerdown = (event: PointerEvent) => { + const state = this.state; + if (!state) { + return; + } + const path = event.composedPath(); + let changed = false; + this.querySelectorAll(CHAT_OPEN_DETAILS_SELECTOR).forEach((details) => { + if (!path.includes(details)) { + details.open = false; + changed = true; + } + }); + if (state.realtimeTalkOptionsOpen) { + const insideTalkOptions = Array.from( + this.querySelectorAll( + ".agent-chat__talk-options, [aria-label='Talk settings'], [aria-label='Talk options']", + ), + ).some((node) => path.includes(node)); + if (!insideTalkOptions) { + state.realtimeTalkOptionsOpen = false; + changed = true; + } + } + if (changed) { + state.requestUpdate(); + } + if (!state.chatMobileControlsOpen) { + return; + } + const wrapper = + this.querySelector(".chat-settings-popover-wrapper") ?? + this.querySelector(".chat-mobile-controls-wrapper"); + if (wrapper && path.includes(wrapper)) { + return; + } + state.setChatMobileControlsOpen(false); + }; + + override createRenderRoot() { + return this; + } + + override connectedCallback() { + super.connectedCallback(); + document.addEventListener("keydown", this.handleDocumentKeydown, true); + document.addEventListener("pointerdown", this.handleDocumentPointerdown, true); + const chatState = this.chatState; + chatState.addCleanup(() => { + document.removeEventListener("keydown", this.handleDocumentKeydown, true); + document.removeEventListener("pointerdown", this.handleDocumentPointerdown, true); + }); + const pageState = createPageState(this.context, chatState.requestUpdate, this); + pageState.createChatSession = async () => { + await this.createSession(); + }; + pageState.exportCurrentChat = () => + exportChatMarkdown(pageState.chatMessages, pageState.assistantName); + pageState.refreshCurrentSessionTools = async () => { + await pageState.onModelChanged?.(); + pageState.requestUpdate?.(); + }; + pageState.refreshCurrentChat = async () => { + await refreshPageChat(pageState); + pageState.requestUpdate?.(); + }; + this.state = pageState; + chatState.attach(pageState); + this.announceCommandPaletteTarget(this.handleCommandPaletteSlashCommand); + if (this.data?.sessionKey) { + this.applyRouteSessionKey(this.data.sessionKey); + } + chatState.restoreComposer({ preserveCurrent: true }); + if (this.data?.draft !== undefined) { + this.state.handleChatDraftChange(this.data.draft); + } + chatState.addCleanup( + this.context.nativeChatDrafts.subscribe((draft) => { + const state = this.state; + if (!state) { + return; + } + state.handleChatDraftChange(draft); + state.requestUpdate?.(); + }), + ); + chatState.startComposerPersistence(); + chatState.addCleanup( + this.context.gateway.subscribe((snapshot) => { + this.applyGatewaySnapshot(snapshot); + }), + ); + chatState.addCleanup( + this.context.gateway.subscribeEvents((event) => { + const state = this.state; + if (state) { + handlePageGatewayEvent(state, event); + } + }), + ); + this.applyApplicationConfig(this.context.config.current); + chatState.addCleanup( + this.context.config.subscribe((config) => { + this.applyApplicationConfig(config); + }), + ); + this.applySessionsState(this.context.sessions.state); + chatState.addCleanup( + this.context.sessions.subscribe((state) => { + this.applySessionsState(state); + }), + ); + this.applyGatewaySnapshot(this.context.gateway.snapshot); + } + + override willUpdate(changedProperties: Map) { + if (changedProperties.has("data") && this.state && this.data) { + const nextSessionKey = resolveSessionKey( + this.data.sessionKey, + this.context.gateway.snapshot.hello, + ); + if (nextSessionKey && nextSessionKey !== this.state.sessionKey) { + this.switchRouteSession(nextSessionKey); + } else if (nextSessionKey) { + this.applyRouteSessionKey(nextSessionKey); + } + this.chatState.restoreCreatedSessionComposer(nextSessionKey); + if (this.data.draft !== undefined && this.data.draft !== this.state.chatMessage) { + this.state.handleChatDraftChange(this.data.draft); + } + } + } + + override disconnectedCallback() { + this.announceCommandPaletteTarget(null); + resetChatViewState(); + this.state = undefined; + this.connectedClient = null; + super.disconnectedCallback(); + } + + private applySessionsState(stateValue: ApplicationContext["sessions"]["state"]) { + const state = this.state; + if (!state) { + return; + } + const selectedSessionDeleted = stateValue.deletedSessions.some(({ key, agentId }) => + uiSessionEventMatches( + { + agentsList: this.context.agents.state.agentsList, + hello: this.context.gateway.snapshot.hello, + sessionKey: state.sessionKey, + }, + key, + agentId, + ), + ); + for (const { key } of stateValue.deletedSessions) { + clearChatMessagesFromCache(state.chatMessagesBySession, state, { sessionKey: key }); + } + state.sessionsResult = stateValue.result; + state.sessionsResultAgentId = stateValue.agentId; + state.sessionsLoading = stateValue.loading; + state.sessionsError = stateValue.error; + const selectedSession = stateValue.result?.sessions.find((row) => + areUiSessionKeysEquivalent(row.key, state.sessionKey), + ); + if (selectedSession) { + state.selectedChatSessionArchived = selectedSession.archived === true; + } + if (selectedSessionDeleted) { + const agentId = + parseAgentSessionKey(state.sessionKey)?.agentId ?? + this.context.agentSelection.state.selectedId ?? + "main"; + this.context.replace("chat", { + search: searchForSession( + buildAgentMainSessionKey({ + agentId, + mainKey: resolveUiConfiguredMainKey({ + agentsList: this.context.agents.state.agentsList, + hello: this.context.gateway.snapshot.hello, + }), + }), + ), + }); + return; + } + state.requestUpdate?.(); + } + + private applyApplicationConfig(config: ApplicationContext["config"]["current"]) { + const state = this.state; + if (!state) { + return; + } + const rootsChanged = + state.localMediaPreviewRoots.length !== config.localMediaPreviewRoots.length || + state.localMediaPreviewRoots.some( + (value, index) => value !== config.localMediaPreviewRoots[index], + ); + if ( + !rootsChanged && + state.embedSandboxMode === config.embedSandboxMode && + state.allowExternalEmbedUrls === config.allowExternalEmbedUrls && + state.chatMessageMaxWidth === config.chatMessageMaxWidth + ) { + return; + } + state.localMediaPreviewRoots = config.localMediaPreviewRoots; + state.embedSandboxMode = config.embedSandboxMode; + state.allowExternalEmbedUrls = config.allowExternalEmbedUrls; + state.chatMessageMaxWidth = config.chatMessageMaxWidth; + state.requestUpdate?.(); + } + + private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) { + const state = this.state; + if (!state) { + return; + } + const wasConnected = state.connected; + const clientChanged = this.connectedClient !== snapshot.client; + state.client = snapshot.client; + state.connected = snapshot.connected; + state.hello = snapshot.hello; + state.assistantAgentId = snapshot.assistantAgentId; + const routeSessionKey = this.data?.sessionKey?.trim(); + const canonicalRouteSessionKey = routeSessionKey + ? resolveSessionKey(routeSessionKey, snapshot.hello) + : null; + if ( + routeSessionKey && + canonicalRouteSessionKey && + canonicalRouteSessionKey !== routeSessionKey + ) { + this.context.replace("chat", { + search: searchForSession(canonicalRouteSessionKey), + }); + state.requestUpdate?.(); + return; + } + state.assistantName = this.context.config.current.assistantIdentity.name; + if (!snapshot.connected) { + if (wasConnected) { + this.connectionGeneration += 1; + const currentSessionId = + typeof state.currentSessionId === "string" ? state.currentSessionId.trim() : ""; + if (currentSessionId) { + state.reconnectResumeSessionId = currentSessionId; + } + markQueuedChatSendsWaitingForReconnect(state); + } + this.connectedClient = null; + state.realtimeTalkSession?.stop(); + state.realtimeTalkSession = null; + state.realtimeTalkActive = false; + state.realtimeTalkStatus = "idle"; + state.resetToolStream(); + state.requestUpdate?.(); + return; + } + if (clientChanged && snapshot.client) { + const startupClient = snapshot.client; + const startupGeneration = ++this.connectionGeneration; + const startupSessionKey = state.sessionKey; + const agentsListBeforeStartup = this.context.agents.state.agentsList; + const clientIsCurrent = () => + this.connectionGeneration === startupGeneration && + this.connectedClient === startupClient && + state.client === startupClient && + state.connected; + const finishStartup = async () => { + if (!clientIsCurrent()) { + return; + } + let agentsList = this.context.agents.state.agentsList; + if (agentsList === agentsListBeforeStartup) { + agentsList = await this.context.agents.ensureList(); + } + if (!clientIsCurrent()) { + return; + } + if (agentsList) { + applyChatAgentsList(state, agentsList, startupClient); + } + state.requestUpdate?.(); + if (state.sessionKey === startupSessionKey) { + this.sendPendingSkillWorkshopRevision(startupSessionKey); + } + }; + this.connectedClient = startupClient; + void syncSelectedSessionMessageSubscription(state, { force: true }); + void retryReconnectableQueuedChatSends(state); + void refreshPageChat(state, { startup: true, awaitHistory: true }).finally(() => { + void finishStartup(); + }); + void refreshChatModelAuthStatus(state).finally(() => state.requestUpdate?.()); + void state.loadAssistantIdentity(); + } + state.requestUpdate?.(); + } + + override render() { + const state = this.state; + if (!state) { + return html`
`; + } + const currentAgentId = resolveChatAgentId(state); + const selectedSessionArchived = + state.selectedChatSessionArchived || + state.sessionsResult?.sessions.some( + (row) => row.archived === true && areUiSessionKeysEquivalent(row.key, state.sessionKey), + ) === true; + const disabledReason = !state.connected + ? t("chat.disconnected") + : selectedSessionArchived + ? t("chat.archivedSessionDisabled") + : null; + const props: ChatProps = { + sessionKey: state.sessionKey, + onSessionKeyChange: (next) => { + this.context.navigate("chat", { + search: searchForSession(next), + }); + }, + thinkingLevel: state.chatThinkingLevel, + autoExpandToolCalls: state.chatVerboseLevel === "full", + showThinking: state.settings.chatShowThinking, + showToolCalls: state.settings.chatShowToolCalls, + loading: state.chatLoading, + sending: state.chatSending, + canAbort: hasAbortableSessionRun(state), + runStatus: state.chatRunStatus, + compactionStatus: state.compactionStatus, + fallbackStatus: state.fallbackStatus, + messages: state.chatMessages, + sideResult: state.chatSideResult, + toolMessages: state.chatToolMessages, + streamSegments: state.chatStreamSegments, + stream: state.chatStream, + streamStartedAt: state.chatStreamStartedAt, + assistantAvatarUrl: resolveChatAvatarUrl(state), + draft: state.chatMessage, + queue: state.chatQueue, + realtimeTalkActive: state.realtimeTalkActive, + realtimeTalkStatus: state.realtimeTalkStatus, + realtimeTalkDetail: state.realtimeTalkDetail, + realtimeTalkTranscript: state.realtimeTalkTranscript, + realtimeTalkConversation: state.realtimeTalkConversation, + realtimeTalkOptionsOpen: state.realtimeTalkOptionsOpen, + realtimeTalkCatalogProviders: state.realtimeTalkCatalogProviders, + realtimeTalkOptions: state.realtimeTalkOptions, + connected: state.connected, + canSend: state.connected && !selectedSessionArchived, + disabledReason, + error: state.lastError, + sessions: state.sessionsResult, + composerControls: renderChatControls({ + agentsList: state.agentsList, + connected: state.connected, + hideCronSessions: state.sessionsHideCron, + loading: state.chatLoading, + manualRefreshInFlight: state.chatManualRefreshInFlight, + model: { + activeRunId: state.chatRunId, + connected: state.connected, + gatewayAvailable: Boolean(state.client), + loading: state.chatLoading, + modelCatalog: state.chatModelCatalog, + modelOverrides: state.sessions.state.modelOverrides, + modelSwitching: Boolean(state.chatModelSwitchPromises[state.sessionKey]), + modelsLoading: state.chatModelsLoading, + sending: state.chatSending, + sessionKey: state.sessionKey, + sessionsResult: state.sessionsResult, + stream: state.chatStream, + onFastModeSelect: (next) => switchChatFastMode(state, next), + onModelSelect: (next) => switchChatModel(state, next), + onThinkingSelect: (next) => switchChatThinkingLevel(state, next), + }, + onboarding: state.onboarding, + quota: { + basePath: state.basePath, + modelAuthStatusResult: state.modelAuthStatusResult, + }, + runId: state.chatRunId, + sending: state.chatSending, + settings: state.settings, + settingsOpen: state.chatMobileControlsOpen, + sessionKey: state.sessionKey, + sessionsResult: state.sessionsResult, + stream: state.chatStream, + onRefresh: () => handleChatManualRefresh(state), + onSettingsChange: state.applySettings, + onSettingsOpenChange: state.setChatMobileControlsOpen, + onToggleCronSessions: () => { + state.sessionsHideCron = !state.sessionsHideCron; + state.requestUpdate?.(); + }, + }), + sessionWorkspace: createSessionWorkspaceProps(state), + onRefresh: () => { + state.chatSideResult = null; + state.resetToolStream(); + void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false }); + }, + onChatScroll: state.handleChatScroll, + getDraft: () => state.chatMessage, + onDraftChange: state.handleChatDraftChange, + onRequestUpdate: state.requestUpdate, + onHistoryKeydown: state.handleChatInputHistoryKey, + onSlashIntent: () => refreshChatCommands(state), + showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, + onScrollToBottom: state.scrollToBottom, + attachments: state.chatAttachments, + onAttachmentsChange: (next) => { + state.chatAttachments = next; + state.requestUpdate?.(); + }, + onSend: () => void state.handleSendChat(), + onCompact: () => void state.handleSendChat("/compact"), + onOpenSessionCheckpoints: () => { + const search = new URLSearchParams({ session: state.sessionKey }); + if (selectedSessionArchived) { + search.set("showArchived", "1"); + } + this.context.navigate("sessions", { search: `?${search.toString()}` }); + }, + onToggleRealtimeTalk: () => void state.toggleRealtimeTalk(), + onToggleRealtimeTalkOptions: () => { + state.realtimeTalkOptionsOpen = !state.realtimeTalkOptionsOpen; + if (state.realtimeTalkOptionsOpen) { + void state.fetchRealtimeTalkCatalog(); + } + state.requestUpdate?.(); + }, + onRealtimeTalkOptionsChange: state.updateRealtimeTalkOptions, + onDismissError: () => { + dismissChatError(state as never); + state.requestUpdate?.(); + }, + onDismissRealtimeTalkError: () => { + dismissRealtimeTalkError(state as never); + state.requestUpdate?.(); + }, + onAbort: () => void state.handleAbortChat({ preserveDraft: true }), + onQueueRemove: state.removeQueuedMessage, + onQueueRetry: (id) => void state.retryQueuedChatMessage(id), + onQueueSteer: (id) => void state.steerQueuedChatMessage(id), + onDismissSideResult: () => { + state.chatSideResult = null; + state.requestUpdate?.(); + }, + replyTarget: state.chatReplyTarget ?? null, + onClearReply: () => { + state.chatReplyTarget = null; + state.requestUpdate?.(); + }, + onSetReply: (target) => { + state.chatReplyTarget = target; + state.requestUpdate?.(); + }, + onNewSession: () => void this.createSession(), + onClearHistory: () => void clearChatHistory(state), + agentsList: state.agentsList, + currentAgentId, + fullMessageAgentId: scopedAgentParamsForSession(state, state.sessionKey).agentId, + onAgentChange: (agentId) => { + this.context.agentSelection.set(agentId); + const nextSessionKey = buildAgentMainSessionKey({ agentId }); + this.context.navigate("chat", { + search: searchForSession(nextSessionKey), + }); + }, + onSessionSelect: (next) => { + this.context.navigate("chat", { + search: searchForSession(next), + }); + }, + onLoadSidebarFullMessage: async ( + request: SidebarFullMessageRequest, + ): Promise => { + if (!state.client || !state.connected) { + return null; + } + return state.client.request("chat.message.get", { + sessionKey: request.sessionKey, + ...(request.agentId ? { agentId: request.agentId } : {}), + messageId: request.messageId, + maxChars: CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS, + }); + }, + sidebarOpen: state.sidebarOpen, + sidebarContent: state.sidebarContent, + splitRatio: state.splitRatio, + canvasPluginSurfaceUrl: state.hello?.pluginSurfaceUrls?.canvas ?? null, + onOpenSidebar: state.handleOpenSidebar, + onCloseSidebar: state.handleCloseSidebar, + onSplitRatioChange: state.handleSplitRatioChange, + assistantName: state.assistantName, + assistantAvatar: state.assistantAvatar, + userName: state.userName, + userAvatar: state.userAvatar, + localMediaPreviewRoots: state.localMediaPreviewRoots, + embedSandboxMode: state.embedSandboxMode, + allowExternalEmbedUrls: state.allowExternalEmbedUrls, + chatMessageMaxWidth: state.chatMessageMaxWidth, + assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never), + onAssistantAttachmentLoaded: () => state.scrollToBottom(), + basePath: state.basePath, + }; + return renderChat(props); + } +} + +if (!customElements.get("openclaw-chat-page")) { + customElements.define("openclaw-chat-page", ChatPage); +} diff --git a/ui/src/pages/chat/chat-queue.ts b/ui/src/pages/chat/chat-queue.ts new file mode 100644 index 000000000000..60c2c926e3e1 --- /dev/null +++ b/ui/src/pages/chat/chat-queue.ts @@ -0,0 +1,236 @@ +// Control UI page module owns Chat queue storage and queue item cleanup. +import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { scopedAgentIdForSession, type SessionScopeHost } from "../../lib/sessions/index.ts"; +import { generateUUID } from "../../lib/uuid.ts"; +import { releaseChatAttachmentPayloads } from "./attachment-payload-store.ts"; +import { cloneChatAttachmentsMetadata } from "./attachment-payload-store.ts"; +import { persistStoredChatComposerQueue, type ChatComposerScope } from "./composer-persistence.ts"; + +type ChatQueueStoreHost = { + chatQueue: ChatQueueItem[]; + chatQueueBySession?: Record; + chatAttachments?: ChatAttachment[]; + requestUpdate?: () => void; +}; + +type ChatQueueSessionHost = ChatQueueStoreHost & + ChatComposerScope & { + sessionKey: string; + }; + +type ChatQueueScopedSessionHost = ChatQueueSessionHost & SessionScopeHost; + +export function enqueueChatMessage( + host: ChatQueueScopedSessionHost, + text: string, + attachments?: ChatAttachment[], + refreshSessions?: boolean, + localCommand?: { args: string; name: string }, +): ChatQueueItem | null { + const trimmed = text.trim(); + const hasAttachments = Boolean(attachments && attachments.length > 0); + if (!trimmed && !hasAttachments) { + return null; + } + const item: ChatQueueItem = { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined, + refreshSessions, + localCommandArgs: localCommand?.args, + localCommandName: localCommand?.name, + sessionKey: host.sessionKey, + agentId: scopedAgentIdForSession(host, host.sessionKey), + }; + host.chatQueue = [...host.chatQueue, item]; + return item; +} + +export function enqueuePendingRunMessage( + host: ChatQueueSessionHost, + text: string, + pendingRunId: string, + attachments?: ChatAttachment[], +) { + const trimmed = text.trim(); + const hasAttachments = Boolean(attachments && attachments.length > 0); + if (!trimmed && !hasAttachments) { + return; + } + host.chatQueue = [ + ...host.chatQueue, + { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + kind: "steered", + attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined, + pendingRunId, + }, + ]; +} + +export function readChatQueueForSession( + host: ChatQueueSessionHost, + sessionKey: string, +): ChatQueueItem[] { + return sessionKey === host.sessionKey + ? host.chatQueue + : (host.chatQueueBySession?.[sessionKey] ?? []); +} + +export function writeChatQueueForSession( + host: ChatQueueSessionHost, + sessionKey: string, + queue: ChatQueueItem[], +) { + if (sessionKey === host.sessionKey) { + host.chatQueue = queue; + return; + } + const queueBySession = { ...host.chatQueueBySession }; + if (queue.length > 0) { + queueBySession[sessionKey] = queue; + } else { + delete queueBySession[sessionKey]; + } + host.chatQueueBySession = queueBySession; + host.requestUpdate?.(); +} + +export function updateQueuedMessage( + host: ChatQueueSessionHost, + id: string, + update: (item: ChatQueueItem) => ChatQueueItem, +): ChatQueueItem | null { + return updateQueuedMessageForSession(host, host.sessionKey, id, update); +} + +export function updateQueuedMessageForSession( + host: ChatQueueSessionHost, + sessionKey: string, + id: string, + update: (item: ChatQueueItem) => ChatQueueItem, +): ChatQueueItem | null { + let nextItem: ChatQueueItem | null = null; + const nextQueue = readChatQueueForSession(host, sessionKey).map((item) => { + if (item.id !== id) { + return item; + } + nextItem = update(item); + return nextItem; + }); + writeChatQueueForSession(host, sessionKey, nextQueue); + return nextItem; +} + +export function persistQueuedMessagesForSession(host: ChatQueueSessionHost, sessionKey: string) { + persistStoredChatComposerQueue(host, sessionKey, readChatQueueForSession(host, sessionKey)); +} + +export function removeQueuedMessageWithoutReleasing( + host: ChatQueueSessionHost, + id: string, + sessionKey = host.sessionKey, +): ChatQueueItem | null { + const queue = readChatQueueForSession(host, sessionKey); + const item = queue.find((entry) => entry.id === id) ?? null; + writeChatQueueForSession( + host, + sessionKey, + queue.filter((entry) => entry.id !== id), + ); + return item; +} + +export function removeVisibleOrScopedQueuedMessageWithoutReleasing( + host: ChatQueueSessionHost, + id: string, + sessionKey: string | undefined, +): ChatQueueItem | null { + return ( + removeQueuedMessageWithoutReleasing(host, id) ?? + (sessionKey ? removeQueuedMessageWithoutReleasing(host, id, sessionKey) : null) + ); +} + +export function excludeComposerAttachments( + host: { chatAttachments?: ChatAttachment[] }, + attachments: readonly ChatAttachment[] | undefined, +): ChatAttachment[] | undefined { + if (!attachments?.length) { + return attachments ? [] : undefined; + } + const retainedIds = new Set((host.chatAttachments ?? []).map((attachment) => attachment.id)); + return attachments.filter((attachment) => !retainedIds.has(attachment.id)); +} + +export function removeQueuedMessage(host: ChatQueueSessionHost, id: string) { + const removed = host.chatQueue.filter((item) => item.id === id); + host.chatQueue = host.chatQueue.filter((item) => item.id !== id); + for (const item of removed) { + releaseChatAttachmentPayloads(excludeComposerAttachments(host, item.attachments)); + } +} + +export function clearPendingQueueItemsForRun( + host: Pick, + runId: string | undefined, +) { + if (!runId) { + return; + } + const removed = host.chatQueue.filter((item) => item.pendingRunId === runId); + host.chatQueue = host.chatQueue.filter((item) => item.pendingRunId !== runId); + for (const item of removed) { + releaseChatAttachmentPayloads(excludeComposerAttachments(host, item.attachments)); + } +} + +export function markQueuedChatSendsWaitingForReconnect(host: ChatQueueStoreHost) { + const markQueue = (queue: ChatQueueItem[]): { changed: boolean; queue: ChatQueueItem[] } => { + let changed = false; + const nextQueue = queue.map((item) => { + if (!item.sendRunId || item.sendState !== "sending") { + return item; + } + changed = true; + return { + ...item, + sendState: "waiting-reconnect" as const, + }; + }); + return { changed, queue: nextQueue }; + }; + + const active = markQueue(host.chatQueue); + if (active.changed) { + host.chatQueue = active.queue; + } + + let changed = false; + const queueBySession = { ...host.chatQueueBySession }; + for (const [sessionKey, queue] of Object.entries(queueBySession)) { + const next = markQueue(queue); + if (next.changed) { + changed = true; + queueBySession[sessionKey] = next.queue; + } + } + if (changed) { + host.chatQueueBySession = queueBySession; + } +} + +export function hasReconnectableQueuedChatSends(host: ChatQueueStoreHost): boolean { + const matches = (item: ChatQueueItem) => + Boolean(item.sendRunId) && + item.sendState === "waiting-reconnect" && + !item.pendingRunId && + !item.localCommandName; + return ( + host.chatQueue.some(matches) || + Object.values(host.chatQueueBySession ?? {}).some((queue) => queue.some(matches)) + ); +} diff --git a/ui/src/pages/chat/chat-realtime.ts b/ui/src/pages/chat/chat-realtime.ts new file mode 100644 index 000000000000..4eed7631874e --- /dev/null +++ b/ui/src/pages/chat/chat-realtime.ts @@ -0,0 +1,165 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { RealtimeTalkOptions } from "./components/chat-realtime-controls.ts"; +import type { RealtimeTalkCatalogProvider } from "./realtime-talk-catalog.ts"; +import { + createRealtimeTalkConversationState, + updateRealtimeTalkConversation, + type RealtimeTalkConversationEntry, + type RealtimeTalkConversationState, +} from "./realtime-talk-conversation.ts"; +import { + RealtimeTalkSession, + type RealtimeTalkLaunchOptions, + type RealtimeTalkStatus, +} from "./realtime-talk.ts"; + +export type ChatRealtimeState = { + client: GatewayBrowserClient | null; + connected: boolean; + sessionKey: string; + lastError?: string | null; + chatError?: string | null; + realtimeTalkActive: boolean; + realtimeTalkStatus: RealtimeTalkStatus; + realtimeTalkDetail: string | null; + realtimeTalkTranscript: string | null; + realtimeTalkConversation: RealtimeTalkConversationEntry[]; + realtimeTalkOptionsOpen: boolean; + realtimeTalkCatalogProviders: RealtimeTalkCatalogProvider[] | null; + realtimeTalkOptions: RealtimeTalkOptions; + realtimeTalkSession: RealtimeTalkSession | null; + realtimeTalkConversationState: RealtimeTalkConversationState; + requestUpdate: () => void; + updateRealtimeTalkOptions: (next: Partial) => void; + resetRealtimeTalkConversation: () => void; + toggleRealtimeTalk: () => Promise; + fetchRealtimeTalkCatalog: () => Promise; +}; + +export function createInitialChatRealtimeState() { + return { + realtimeTalkActive: false, + realtimeTalkStatus: "idle" as RealtimeTalkStatus, + realtimeTalkDetail: null, + realtimeTalkTranscript: null, + realtimeTalkConversation: [], + realtimeTalkOptionsOpen: false, + realtimeTalkCatalogProviders: null, + realtimeTalkOptions: { + provider: "", + model: "", + voice: "", + transport: "", + vadThreshold: "", + silenceDurationMs: "", + prefixPaddingMs: "", + reasoningEffort: "", + }, + realtimeTalkSession: null, + realtimeTalkConversationState: createRealtimeTalkConversationState(), + }; +} + +export function resetChatRealtimeConversation(state: ChatRealtimeState) { + state.realtimeTalkConversationState = createRealtimeTalkConversationState(); + state.realtimeTalkConversation = []; +} + +export function dismissRealtimeTalkError(state: ChatRealtimeState) { + if (state.realtimeTalkStatus !== "error") { + return; + } + state.realtimeTalkSession?.stop(); + state.realtimeTalkSession = null; + state.realtimeTalkActive = false; + state.realtimeTalkStatus = "idle"; + state.realtimeTalkDetail = null; + state.realtimeTalkTranscript = null; + state.resetRealtimeTalkConversation(); +} + +export function attachChatRealtimeActions(state: ChatRealtimeState) { + state.resetRealtimeTalkConversation = () => { + resetChatRealtimeConversation(state); + }; + state.updateRealtimeTalkOptions = (next) => { + state.realtimeTalkOptions = { ...state.realtimeTalkOptions, ...next }; + state.requestUpdate(); + }; + state.fetchRealtimeTalkCatalog = async () => { + if (!state.client || !state.connected) { + return; + } + const result = await state.client.request<{ + realtime?: { providers?: RealtimeTalkCatalogProvider[] }; + }>("talk.catalog", {}); + state.realtimeTalkCatalogProviders = result.realtime?.providers ?? []; + state.requestUpdate(); + }; + state.toggleRealtimeTalk = async () => { + if (state.realtimeTalkSession) { + state.realtimeTalkSession.stop(); + state.realtimeTalkSession = null; + state.realtimeTalkActive = false; + state.realtimeTalkStatus = "idle"; + state.realtimeTalkDetail = null; + state.resetRealtimeTalkConversation(); + state.requestUpdate(); + return; + } + if (!state.client || !state.connected) { + state.lastError = "Gateway not connected"; + state.chatError = state.lastError; + state.requestUpdate(); + return; + } + const options = state.realtimeTalkOptions; + const launchOptions: RealtimeTalkLaunchOptions = { + provider: options.provider.trim() || undefined, + model: options.model.trim() || undefined, + voice: options.voice.trim() || undefined, + transport: (options.transport.trim() || undefined) as RealtimeTalkLaunchOptions["transport"], + vadThreshold: Number(options.vadThreshold) || undefined, + silenceDurationMs: Number(options.silenceDurationMs) || undefined, + prefixPaddingMs: Number(options.prefixPaddingMs) || undefined, + reasoningEffort: options.reasoningEffort.trim() || undefined, + }; + state.realtimeTalkActive = true; + state.realtimeTalkStatus = "connecting"; + state.realtimeTalkDetail = null; + state.resetRealtimeTalkConversation(); + const session = new RealtimeTalkSession( + state.client, + state.sessionKey, + { + onStatus: (status, detail) => { + state.realtimeTalkStatus = status; + state.realtimeTalkDetail = detail ?? null; + state.realtimeTalkActive = status !== "idle"; + state.requestUpdate(); + }, + onTranscript: (entry) => { + state.realtimeTalkTranscript = `${entry.role === "user" ? "You" : "OpenClaw"}: ${entry.text}`; + state.realtimeTalkConversationState = updateRealtimeTalkConversation( + state.realtimeTalkConversationState, + entry, + ); + state.realtimeTalkConversation = state.realtimeTalkConversationState.entries; + state.requestUpdate(); + }, + }, + launchOptions, + ); + state.realtimeTalkSession = session; + try { + await session.start(); + } catch (error) { + session.stop(); + state.realtimeTalkSession = null; + state.realtimeTalkActive = false; + state.realtimeTalkStatus = "error"; + state.realtimeTalkDetail = error instanceof Error ? error.message : String(error); + state.requestUpdate(); + } + }; +} diff --git a/ui/src/ui/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts similarity index 100% rename from ui/src/ui/chat/chat-responsive.browser.test.ts rename to ui/src/pages/chat/chat-responsive.browser.test.ts diff --git a/ui/src/pages/chat/chat-send-contract.ts b/ui/src/pages/chat/chat-send-contract.ts new file mode 100644 index 000000000000..78d264bcb405 --- /dev/null +++ b/ui/src/pages/chat/chat-send-contract.ts @@ -0,0 +1,28 @@ +import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; + +export type ChatSendAckStatus = "started" | "in_flight" | "ok" | "timeout" | "error"; + +export type ChatSendAckServerTiming = { + receivedToAckMs?: number; + loadSessionMs?: number; + prepareAttachmentsMs?: number; +}; + +export type ChatSendAck = { + runId: string; + status: ChatSendAckStatus; + serverTiming?: ChatSendAckServerTiming; +}; + +export type ChatSendTimingEntry = { + runId: string; + sessionKey?: string; + agentId?: string; + sendAttempts: number; + sendState?: ChatQueueItem["sendState"]; + submittedAtMs: number; + requestStartedAtMs?: number; + ackAtMs?: number; + ackStatus?: ChatSendAckStatus; + firstAssistantVisibleRecorded?: boolean; +}; diff --git a/ui/src/pages/chat/chat-send-timing.ts b/ui/src/pages/chat/chat-send-timing.ts new file mode 100644 index 000000000000..6b8c6de70064 --- /dev/null +++ b/ui/src/pages/chat/chat-send-timing.ts @@ -0,0 +1,363 @@ +import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { visibleSessionMatches, type SessionScopeHost } from "../../lib/sessions/index.ts"; +import type { ChatEventPayload } from "./chat-history.ts"; +import { readChatQueueForSession } from "./chat-queue.ts"; +import type { ChatSendAck, ChatSendTimingEntry } from "./chat-send-contract.ts"; +import { + controlUiNowMs, + recordControlUiPerformanceEvent, + roundedControlUiDurationMs, + scheduleControlUiAfterPaint, +} from "./performance.ts"; + +export type ChatSendTimingPhase = + | "pending-visible" + | "pending-painted" + | "request-start" + | "ack" + | "server-dispatch-started" + | "server-model-selected" + | "server-agent-run-started" + | "server-first-assistant-event" + | "server-dispatch-completed" + | "server-post-dispatch-completed" + | "first-assistant-visible" + | "terminal-before-delta" + | "queued-busy" + | "waiting-model" + | "waiting-reconnect" + | "failed"; + +type ChatSendTimingHost = SessionScopeHost & { + sessionKey: string; + chatStream: string | null; + chatQueue: ChatQueueItem[]; + chatQueueBySession?: Record; + chatSendTimingsByRun?: Map; + eventLogBuffer?: unknown[]; + updateComplete?: Promise; +}; + +type ChatSendServerTimingPhase = + | "dispatch-started" + | "model-selected" + | "agent-run-started" + | "first-assistant-event" + | "dispatch-completed" + | "post-dispatch-completed"; + +const CHAT_SEND_SERVER_TIMING_PHASES = new Set([ + "dispatch-started", + "model-selected", + "agent-run-started", + "first-assistant-event", + "dispatch-completed", + "post-dispatch-completed", +]); +const CHAT_SEND_SLOW_FIRST_ASSISTANT_MS = 1_500; + +export function recordChatSendTiming( + host: ChatSendTimingHost, + item: Pick< + ChatQueueItem, + "sendRunId" | "sessionKey" | "agentId" | "sendAttempts" | "sendState" | "sendSubmittedAtMs" + >, + phase: ChatSendTimingPhase, + startedAtMs = item.sendSubmittedAtMs, + extra: Record = {}, +) { + if (startedAtMs == null) { + return; + } + recordControlUiPerformanceEvent( + host as Parameters[0], + "control-ui.chat.send", + { + phase, + durationMs: roundedControlUiDurationMs(controlUiNowMs() - startedAtMs), + runId: item.sendRunId, + sessionKey: item.sessionKey, + agentId: item.agentId, + sendAttempts: item.sendAttempts ?? 0, + sendState: item.sendState, + ...extra, + }, + { console: false, maxBufferedEventsForType: 40 }, + ); +} + +function readChatSendServerTimingPhase(value: unknown): ChatSendServerTimingPhase | null { + return typeof value === "string" && + (CHAT_SEND_SERVER_TIMING_PHASES as ReadonlySet).has(value) + ? (value as ChatSendServerTimingPhase) + : null; +} + +function readChatSendTimingNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +export function recordChatSendServerTiming(host: ChatSendTimingHost, payload: unknown) { + if (!payload || typeof payload !== "object") { + return; + } + const record = payload as Record; + const phase = readChatSendServerTimingPhase(record.phase); + const runId = typeof record.runId === "string" && record.runId.trim() ? record.runId.trim() : ""; + if (!phase || !runId) { + return; + } + const entry = host.chatSendTimingsByRun?.get(runId); + const nowMs = controlUiNowMs(); + const serverAckToPhaseMs = readChatSendTimingNumber(record.ackToPhaseMs); + const serverReceivedToPhaseMs = readChatSendTimingNumber(record.receivedToPhaseMs); + const serverDispatchStartedToPhaseMs = readChatSendTimingNumber(record.dispatchStartedToPhaseMs); + const serverPostDispatchMs = readChatSendTimingNumber(record.postDispatchMs); + const durationMs = + entry?.submittedAtMs !== undefined + ? roundedControlUiDurationMs(nowMs - entry.submittedAtMs) + : serverAckToPhaseMs; + if (durationMs === undefined) { + return; + } + const slow = phase === "first-assistant-event" && durationMs >= CHAT_SEND_SLOW_FIRST_ASSISTANT_MS; + recordControlUiPerformanceEvent( + host as Parameters[0], + "control-ui.chat.send", + { + phase: `server-${phase}`, + durationMs, + runId, + sessionKey: + entry?.sessionKey ?? + (typeof record.sessionKey === "string" && record.sessionKey.trim() + ? record.sessionKey.trim() + : undefined), + agentId: + entry?.agentId ?? + (typeof record.agentId === "string" && record.agentId.trim() + ? record.agentId.trim() + : undefined), + sendAttempts: entry?.sendAttempts ?? 0, + sendState: entry?.sendState, + ackStatus: entry?.ackStatus, + serverPhase: phase, + ...(serverAckToPhaseMs !== undefined ? { serverAckToPhaseMs } : {}), + ...(serverReceivedToPhaseMs !== undefined ? { serverReceivedToPhaseMs } : {}), + ...(serverDispatchStartedToPhaseMs !== undefined ? { serverDispatchStartedToPhaseMs } : {}), + ...(serverPostDispatchMs !== undefined ? { serverPostDispatchMs } : {}), + ...(typeof record.provider === "string" && record.provider.trim() + ? { provider: record.provider.trim() } + : {}), + ...(typeof record.model === "string" && record.model.trim() + ? { model: record.model.trim() } + : {}), + ...(typeof record.agentRunId === "string" && record.agentRunId.trim() + ? { agentRunId: record.agentRunId.trim() } + : {}), + ...(slow ? { slow: true } : {}), + }, + { console: slow, warn: slow, maxBufferedEventsForType: 40 }, + ); +} + +function ensureChatSendTimingEntries(host: ChatSendTimingHost): Map { + if (host.chatSendTimingsByRun) { + return host.chatSendTimingsByRun; + } + const entries = new Map(); + host.chatSendTimingsByRun = entries; + return entries; +} + +export function registerChatSendTiming( + host: ChatSendTimingHost, + item: Pick< + ChatQueueItem, + "sendRunId" | "sessionKey" | "agentId" | "sendAttempts" | "sendState" | "sendSubmittedAtMs" + >, + runId: string, + requestStartedAtMs: number, +) { + ensureChatSendTimingEntries(host).set(runId, { + runId, + sessionKey: item.sessionKey, + agentId: item.agentId, + sendAttempts: item.sendAttempts ?? 0, + sendState: item.sendState, + submittedAtMs: item.sendSubmittedAtMs ?? requestStartedAtMs, + requestStartedAtMs, + }); +} + +export function updateChatSendAckTiming( + host: ChatSendTimingHost, + requestedRunId: string, + ack: ChatSendAck, + item: Pick< + ChatQueueItem, + "sessionKey" | "agentId" | "sendAttempts" | "sendState" | "sendSubmittedAtMs" + >, + requestStartedAtMs: number, +) { + const entries = ensureChatSendTimingEntries(host); + const existing = entries.get(requestedRunId); + const submittedAtMs = existing?.submittedAtMs ?? item.sendSubmittedAtMs ?? requestStartedAtMs; + const next: ChatSendTimingEntry = { + ...(existing ?? { + runId: ack.runId, + sessionKey: item.sessionKey, + agentId: item.agentId, + sendAttempts: item.sendAttempts ?? 0, + sendState: item.sendState, + submittedAtMs, + requestStartedAtMs, + }), + runId: ack.runId, + sessionKey: existing?.sessionKey ?? item.sessionKey, + agentId: existing?.agentId ?? item.agentId, + ackAtMs: controlUiNowMs(), + ackStatus: ack.status, + }; + if (ack.runId !== requestedRunId) { + entries.delete(requestedRunId); + } + entries.set(ack.runId, next); +} + +export function chatSendAckServerTimingEventFields(ack: ChatSendAck): Record { + const timing = ack.serverTiming; + return { + ...(typeof timing?.receivedToAckMs === "number" + ? { serverReceivedToAckMs: timing.receivedToAckMs } + : {}), + ...(typeof timing?.loadSessionMs === "number" + ? { serverLoadSessionMs: timing.loadSessionMs } + : {}), + ...(typeof timing?.prepareAttachmentsMs === "number" + ? { serverPrepareAttachmentsMs: timing.prepareAttachmentsMs } + : {}), + }; +} + +function chatEventHasVisibleTerminalPayload(payload: ChatEventPayload): boolean { + if (payload.state === "error" && payload.errorMessage?.trim()) { + return true; + } + return Boolean(payload.message && typeof payload.message === "object"); +} + +function resolveFirstAssistantTimingPhase( + host: ChatSendTimingHost, + payload: ChatEventPayload, + entry: ChatSendTimingEntry, +): Extract | null { + if (entry.firstAssistantVisibleRecorded) { + return null; + } + if (payload.state === "delta") { + return typeof host.chatStream === "string" && host.chatStream.trim() + ? "first-assistant-visible" + : null; + } + if (payload.state === "final" || payload.state === "aborted" || payload.state === "error") { + return chatEventHasVisibleTerminalPayload(payload) ? "terminal-before-delta" : null; + } + return null; +} + +export function recordFirstAssistantChatTiming( + host: ChatSendTimingHost, + payload: ChatEventPayload | undefined, + handledState: ChatEventPayload["state"] | null, +) { + if (!payload || !handledState || typeof payload.runId !== "string") { + return; + } + const runId = payload.runId.trim(); + const entry = runId ? host.chatSendTimingsByRun?.get(runId) : undefined; + if (!entry) { + return; + } + const phase = resolveFirstAssistantTimingPhase(host, payload, entry); + if (!phase) { + if (payload.state === "final" || payload.state === "aborted" || payload.state === "error") { + host.chatSendTimingsByRun?.delete(runId); + } + return; + } + + const eventAtMs = controlUiNowMs(); + entry.firstAssistantVisibleRecorded = true; + scheduleControlUiAfterPaint(host, () => { + const paintedAtMs = controlUiNowMs(); + const durationMs = roundedControlUiDurationMs(paintedAtMs - entry.submittedAtMs); + const slow = durationMs >= CHAT_SEND_SLOW_FIRST_ASSISTANT_MS; + recordControlUiPerformanceEvent( + host as Parameters[0], + "control-ui.chat.send", + { + phase, + durationMs, + runId, + sessionKey: entry.sessionKey ?? payload.sessionKey, + agentId: entry.agentId ?? payload.agentId, + sendAttempts: entry.sendAttempts, + sendState: entry.sendState, + ackStatus: entry.ackStatus, + eventState: payload.state, + firstAssistantPaintMs: roundedControlUiDurationMs(paintedAtMs - eventAtMs), + ...(entry.requestStartedAtMs != null + ? { + requestToFirstAssistantEventMs: roundedControlUiDurationMs( + eventAtMs - entry.requestStartedAtMs, + ), + } + : {}), + ...(entry.ackAtMs != null + ? { + ackToFirstAssistantEventMs: roundedControlUiDurationMs(eventAtMs - entry.ackAtMs), + } + : {}), + ...(slow ? { slow: true } : {}), + }, + { console: slow, warn: slow, maxBufferedEventsForType: 40 }, + ); + if (phase === "terminal-before-delta") { + host.chatSendTimingsByRun?.delete(runId); + } + }); +} + +function shouldRecordPendingSendPaint(item: ChatQueueItem): boolean { + return ( + typeof item.sendSubmittedAtMs === "number" && + (item.sendState === "waiting-model" || + item.sendState === "sending" || + item.sendState === "waiting-reconnect") + ); +} + +export function schedulePendingSendPaintTiming( + host: ChatSendTimingHost, + item: ChatQueueItem, + startedAtMs = item.sendSubmittedAtMs, +) { + const sessionKey = item.sessionKey ?? host.sessionKey; + const sendRunId = item.sendRunId; + if (!sendRunId || startedAtMs == null) { + return; + } + scheduleControlUiAfterPaint(host as Parameters[0], () => { + if (!visibleSessionMatches(host, sessionKey, item.agentId)) { + return; + } + const queued = readChatQueueForSession(host, sessionKey).find( + (entry) => entry.id === item.id && entry.sendRunId === sendRunId, + ); + if (!queued || !shouldRecordPendingSendPaint(queued)) { + return; + } + recordChatSendTiming(host, queued, "pending-painted", startedAtMs); + }); +} diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/pages/chat/chat-send.test.ts similarity index 89% rename from ui/src/ui/app-chat.test.ts rename to ui/src/pages/chat/chat-send.test.ts index be1bf84fa39c..9f6d659a6c36 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -1,19 +1,35 @@ /* @vitest-environment jsdom */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatHost } from "./app-chat.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import type { UiSettings } from "../../app/settings.ts"; +import { createSessionCapability } from "../../lib/sessions/index.ts"; import { getChatAttachmentDataUrl, getChatAttachmentPreviewUrl, registerChatAttachmentPayload, releaseChatAttachmentPayloads, resetChatAttachmentPayloadStoreForTest, -} from "./chat/attachment-payload-store.ts"; -import type { executeSlashCommand } from "./chat/slash-command-executor.ts"; -import { loadSessions } from "./controllers/sessions.ts"; -import type { GatewaySessionRow, SessionsListResult } from "./types.ts"; +} from "./attachment-payload-store.ts"; +import { refreshChatAvatar } from "./chat-avatar.ts"; +import type { executeSlashCommand } from "./chat-command-executor.ts"; +import type { ChatHost } from "./chat-send.ts"; +import { buildChatSessionListOptions } from "./chat-session.ts"; +import type { ChatPageHost } from "./chat-state.ts"; type ExecuteSlashCommand = typeof executeSlashCommand; +type TestChatHost = Omit & { + basePath: string; + chatAvatarUrl: string | null; + chatAvatarSource?: string | null; + chatAvatarStatus?: "none" | "local" | "remote" | "data" | null; + chatAvatarReason?: string | null; + sessionsError?: string | null; + sessionsResultAgentId?: string | null; + sessionsShowArchived?: boolean; + password?: string; + settings?: Partial; +}; const { executeSlashCommandMock, setLastActiveSessionKeyMock } = vi.hoisted(() => ({ executeSlashCommandMock: vi.fn(), @@ -22,12 +38,14 @@ const { executeSlashCommandMock, setLastActiveSessionKeyMock } = vi.hoisted(() = const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; -vi.mock("./app-last-active-session.ts", () => ({ +vi.mock("../../app/settings.ts", () => ({ + normalizeChatAutoScrollMode: (value: unknown) => + value === "always" || value === "off" ? value : "near-bottom", setLastActiveSessionKey: (...args: unknown[]) => setLastActiveSessionKeyMock(...args), })); -vi.mock("./chat/slash-command-executor.ts", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./chat-command-executor.ts", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, executeSlashCommand: (...args: Parameters) => { @@ -41,38 +59,36 @@ vi.mock("./chat/slash-command-executor.ts", async (importOriginal) => { }; }); -let handleSendChat: typeof import("./app-chat.ts").handleSendChat; -let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage; -let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHistory; -let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat; -let hasAbortableSessionRun: typeof import("./app-chat.ts").hasAbortableSessionRun; -let refreshChat: typeof import("./app-chat.ts").refreshChat; -let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar; -let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun; -let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage; -let markQueuedChatSendsWaitingForReconnect: typeof import("./app-chat.ts").markQueuedChatSendsWaitingForReconnect; -let retryReconnectableQueuedChatSends: typeof import("./app-chat.ts").retryReconnectableQueuedChatSends; -let recordChatSendServerTiming: typeof import("./app-chat.ts").recordChatSendServerTiming; -let recordFirstAssistantChatTiming: typeof import("./app-chat.ts").recordFirstAssistantChatTiming; -let createChatSessionsLoadOverrides: typeof import("./app-chat.ts").createChatSessionsLoadOverrides; +let handleSendChat: typeof import("./chat-send.ts").handleSendChat; +let steerQueuedChatMessage: typeof import("./chat-send.ts").steerQueuedChatMessage; +let navigateChatInputHistory: typeof import("./chat-send.ts").navigateChatInputHistory; +let handleAbortChat: typeof import("./run-lifecycle.ts").handleAbortChat; +let hasAbortableSessionRun: typeof import("./run-lifecycle.ts").hasAbortableSessionRun; +let refreshChat: ( + host: TestChatHost, + options?: Parameters[1], +) => Promise; +let clearPendingQueueItemsForRun: typeof import("./chat-queue.ts").clearPendingQueueItemsForRun; +let removeQueuedMessage: typeof import("./chat-queue.ts").removeQueuedMessage; +let markQueuedChatSendsWaitingForReconnect: typeof import("./chat-queue.ts").markQueuedChatSendsWaitingForReconnect; +let retryReconnectableQueuedChatSends: typeof import("./chat-send.ts").retryReconnectableQueuedChatSends; +let recordChatSendServerTiming: typeof import("./chat-send-timing.ts").recordChatSendServerTiming; +let recordFirstAssistantChatTiming: typeof import("./chat-send-timing.ts").recordFirstAssistantChatTiming; async function loadChatHelpers(): Promise { ({ handleSendChat, steerQueuedChatMessage, navigateChatInputHistory, - handleAbortChat, - hasAbortableSessionRun, - refreshChat, - refreshChatAvatar, - clearPendingQueueItemsForRun, - removeQueuedMessage, - markQueuedChatSendsWaitingForReconnect, retryReconnectableQueuedChatSends, - recordChatSendServerTiming, - recordFirstAssistantChatTiming, - createChatSessionsLoadOverrides, - } = await import("./app-chat.ts")); + } = await import("./chat-send.ts")); + ({ recordChatSendServerTiming, recordFirstAssistantChatTiming } = + await import("./chat-send-timing.ts")); + const chatState = await import("./chat-state.ts"); + refreshChat = (host, options) => chatState.refreshChat(host as unknown as ChatPageHost, options); + ({ handleAbortChat, hasAbortableSessionRun } = await import("./run-lifecycle.ts")); + ({ clearPendingQueueItemsForRun, removeQueuedMessage, markQueuedChatSendsWaitingForReconnect } = + await import("./chat-queue.ts")); } function requestUrl(input: string | URL | Request): string { @@ -114,7 +130,7 @@ function findRequestPayload(source: MockCallSource, method: string, label: strin return requireRecord(call[1], label); } -function eventPayloads(host: ChatHost, event: string): Array> { +function eventPayloads(host: TestChatHost, event: string): Array> { return (host.eventLogBuffer ?? []) .filter((entry): entry is { event: string; payload: Record } => { if (!entry || typeof entry !== "object") { @@ -141,7 +157,7 @@ function fetchUrl(source: MockCallSource, callIndex: number) { throw new Error(`expected fetch input ${callIndex}`); } -function makeHost(overrides?: Partial): ChatHost { +function makeHost(overrides?: Partial): TestChatHost { const host = { client: null, chatMessages: [], @@ -175,17 +191,7 @@ function makeHost(overrides?: Partial): ChatHost { sessionsResult: null, sessionsResultAgentId: null, sessionsError: null, - sessionsFilterActive: "0", - sessionsFilterLimit: "50", - sessionsIncludeGlobal: true, - sessionsIncludeUnknown: true, sessionsShowArchived: false, - sessionsExpandedCheckpointKey: null, - sessionsCheckpointItemsByKey: {}, - sessionsCheckpointLoadingKey: null, - sessionsCheckpointBusyKey: null, - sessionsCheckpointErrorByKey: {}, - chatModelOverrides: {}, chatModelSwitchPromises: {}, chatModelsLoading: false, chatModelCatalog: [], @@ -196,7 +202,22 @@ function makeHost(overrides?: Partial): ChatHost { updateComplete: Promise.resolve(), ...overrides, }; - return host as ChatHost; + const sessions = createSessionCapability({ + snapshot: { + client: host.client, + connected: host.connected, + hello: host.hello, + }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }); + for (const session of host.sessionsResult?.sessions ?? []) { + sessions.reconcile(session, host.sessionsResult?.defaults, { + selectedGlobalAgentId: host.assistantAgentId, + showArchived: host.sessionsShowArchived, + }); + } + return { ...host, sessions } as TestChatHost; } function createSessionsResult(sessions: GatewaySessionRow[]): SessionsListResult { @@ -252,15 +273,14 @@ describe("refreshChat", () => { }); it("keeps Chat session refreshes active-only when Sessions shows archived rows", () => { - expect(createChatSessionsLoadOverrides({ sessionsShowArchived: true })).toMatchObject({ + expect(buildChatSessionListOptions({ sessionsShowArchived: true })).toMatchObject({ activeMinutes: 0, limit: 50, showArchived: false, - preserveSessionsViewResult: true, }); }); - it("dispatches chat refresh work without waiting for slow history or metadata RPCs", async () => { + it("dispatches chat refresh work without waiting for slow history RPCs", async () => { const request = vi.fn(() => pendingPromise()); const requestUpdate = vi.fn(); const host = makeHost({ @@ -286,11 +306,6 @@ describe("refreshChat", () => { scope: "text", }); expect(requestUpdate).not.toHaveBeenCalled(); - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }), - ); - expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" }); - expect(request).not.toHaveBeenCalledWith("commands.list", expect.anything()); }); it("scopes global chat refresh session rows to the selected agent", async () => { @@ -312,9 +327,6 @@ describe("refreshChat", () => { limit: 100, }); expect(request).not.toHaveBeenCalledWith("sessions.list", expect.anything()); - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "work" }), - ); }); it("scopes agent main aliases as selected global chat refreshes", async () => { @@ -401,7 +413,7 @@ describe("refreshChat", () => { expect(request).not.toHaveBeenCalledWith("sessions.list", expect.anything()); }); - it("can wait for history without waiting for secondary metadata refreshes", async () => { + it("can wait for history without waiting for secondary work", async () => { const history = createDeferred(); const requestUpdate = vi.fn(); const request = vi.fn((method: string) => { @@ -428,11 +440,6 @@ describe("refreshChat", () => { expect(host.chatMessages).toEqual([ { role: "assistant", content: [{ type: "text", text: "ready" }] }, ]); - expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" }); - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }), - ); - expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" }); expect(requestUpdate).toHaveBeenCalled(); }); @@ -526,7 +533,8 @@ describe("refreshChat", () => { sessionsResult: previousSessionsResult, chatQueue: [{ id: "queued-1", text: "after scoped reload", createdAt: 1 }], }); - (host as ChatHost & { sessionsResultAgentId: string }).sessionsResultAgentId = "main"; + (host as unknown as ChatHost & { sessionsResultAgentId: string }).sessionsResultAgentId = + "main"; await refreshChat(host, { scheduleScroll: false }); await new Promise((resolve) => { @@ -568,7 +576,8 @@ describe("refreshChat", () => { sessionsResult: previousSessionsResult, chatQueue: [{ id: "queued-1", text: "after global alias reload", createdAt: 1 }], }); - (host as ChatHost & { sessionsResultAgentId: string }).sessionsResultAgentId = "main"; + (host as unknown as ChatHost & { sessionsResultAgentId: string }).sessionsResultAgentId = + "main"; await refreshChat(host, { scheduleScroll: false }); await new Promise((resolve) => { @@ -1053,7 +1062,7 @@ describe("refreshChat", () => { await loadChatHelpers(); }); - it("does not wait for secondary chat metadata refreshes before showing history", async () => { + it("does not wait for secondary session refreshes before showing history", async () => { const previousFetch = globalThis.fetch; globalThis.fetch = vi.fn(() => pendingPromise()) as never; try { @@ -1077,170 +1086,10 @@ describe("refreshChat", () => { { role: "assistant", content: [{ type: "text", text: "ready" }] }, ]); expect(request).not.toHaveBeenCalledWith("sessions.list", expect.anything()); - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "main" }), - ); - expect(request).not.toHaveBeenCalledWith("models.list", { view: "configured" }); - expect(request).not.toHaveBeenCalledWith("commands.list", expect.anything()); } finally { globalThis.fetch = previousFetch; } }); - - it("uses startup metadata without scheduling command or metadata follow-ups", async () => { - const { resetSlashCommandsForTest } = await import("./chat/slash-commands.ts"); - resetSlashCommandsForTest(); - const previousFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }) as never; - try { - const request = vi.fn((method: string) => { - if (method === "chat.startup") { - return Promise.resolve({ - messages: [], - metadata: { - models: [{ id: "gpt-fast", name: "GPT Fast", provider: "openai" }], - }, - }); - } - return pendingPromise(); - }); - const host = makeHost({ - client: { request } as unknown as ChatHost["client"], - sessionKey: "main", - }); - - await refreshChat(host, { startup: true }); - - await vi.waitFor(() => expect(host.chatModelCatalog).toHaveLength(1)); - await new Promise((resolve) => { - setTimeout(resolve, 75); - }); - expect(request).toHaveBeenCalledWith("chat.startup", { - sessionKey: "main", - limit: 100, - }); - expect(request).not.toHaveBeenCalledWith("chat.metadata", expect.anything()); - expect(request).not.toHaveBeenCalledWith("models.list", expect.anything()); - expect(request).not.toHaveBeenCalledWith("commands.list", expect.anything()); - expect(host.chatModelCatalog).toEqual([ - { id: "gpt-fast", name: "GPT Fast", provider: "openai" }, - ]); - } finally { - resetSlashCommandsForTest(); - globalThis.fetch = previousFetch; - } - }); - - it("falls back to separate metadata RPCs when chat.metadata is not advertised", async () => { - const request = vi.fn(() => pendingPromise()); - const host = makeHost({ - client: { request } as unknown as ChatHost["client"], - sessionKey: "main", - hello: { - type: "hello-ok", - protocol: 4, - auth: { role: "operator", scopes: [] }, - features: { events: [], methods: ["chat.history"] }, - }, - }); - - await refreshChat(host); - - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }), - ); - expect(request).toHaveBeenCalledWith("commands.list", { - agentId: "main", - includeArgs: true, - scope: "text", - }); - expect(request).not.toHaveBeenCalledWith("chat.metadata", expect.anything()); - }); - - it("falls back to separate metadata RPCs when an older gateway rejects chat.metadata", async () => { - const { GatewayRequestError } = await import("./gateway.ts"); - const request = vi.fn((method: string) => { - if (method === "chat.metadata") { - return Promise.reject( - new GatewayRequestError({ - code: "INVALID_REQUEST", - message: "unknown method: chat.metadata", - }), - ); - } - return pendingPromise(); - }); - const host = makeHost({ - client: { request } as unknown as ChatHost["client"], - sessionKey: "main", - }); - - await refreshChat(host); - - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("models.list", { view: "configured" }), - ); - expect(request).toHaveBeenCalledWith("commands.list", { - agentId: "main", - includeArgs: true, - scope: "text", - }); - }); - - it("ignores stale chat.metadata results after the selected global agent changes", async () => { - const { resetSlashCommandsForTest, SLASH_COMMANDS } = await import("./chat/slash-commands.ts"); - resetSlashCommandsForTest(); - const previousFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }) as never; - const metadata = createDeferred(); - const requestUpdate = vi.fn(); - try { - const request = vi.fn((method: string) => { - if (method === "chat.history") { - return Promise.resolve({ messages: [], thinkingLevel: null }); - } - if (method === "chat.metadata") { - return metadata.promise; - } - return pendingPromise(); - }); - const host = makeHost({ - client: { request } as unknown as ChatHost["client"], - sessionKey: "global", - assistantAgentId: "work", - requestUpdate, - }); - - await refreshChat(host); - await vi.waitFor(() => - expect(request).toHaveBeenCalledWith("chat.metadata", { agentId: "work" }), - ); - host.assistantAgentId = "ops"; - const updatesBeforeMetadata = requestUpdate.mock.calls.length; - metadata.resolve({ - models: [{ id: "stale-model", name: "Stale Model", provider: "stale-provider" }], - commands: [ - { - acceptsArgs: false, - description: "stale command", - name: "stale-command", - scope: "text", - source: "native", - textAliases: ["/stale-command"], - }, - ], - }); - - await vi.waitFor(() => - expect(requestUpdate.mock.calls.length).toBeGreaterThan(updatesBeforeMetadata), - ); - expect(host.chatModelCatalog).toEqual([]); - expect(SLASH_COMMANDS.some((command) => command.name === "stale-command")).toBe(false); - } finally { - resetSlashCommandsForTest(); - globalThis.fetch = previousFetch; - } - }); }); describe("handleSendChat", () => { @@ -1306,19 +1155,19 @@ describe("handleSendChat", () => { const request = vi.fn(async (method: string) => { throw new Error(`Unexpected request: ${method}`); }); - const onSlashAction = vi.fn(); + const createChatSession = vi.fn(); const host = makeHost({ client: { request } as unknown as ChatHost["client"], chatMessage: "restore me", sessionKey: "agent:main", - onSlashAction, + createChatSession, }); await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true }); expect(confirm).toHaveBeenCalledTimes(1); expect(request).not.toHaveBeenCalled(); - expect(onSlashAction).toHaveBeenCalledWith("new-session"); + expect(createChatSession).toHaveBeenCalledTimes(1); expect(host.chatMessage).toBe("restore me"); expect(host.refreshSessionsAfterChat.size).toBe(0); }); @@ -1329,34 +1178,34 @@ describe("handleSendChat", () => { const request = vi.fn(async (method: string) => { throw new Error(`Unexpected request: ${method}`); }); - const onSlashAction = vi.fn(); + const createChatSession = vi.fn(); const host = makeHost({ client: { request } as unknown as ChatHost["client"], chatMessage: "/new", sessionKey: "agent:main", - onSlashAction, + createChatSession, }); await handleSendChat(host); expect(confirm).not.toHaveBeenCalled(); expect(request).not.toHaveBeenCalled(); - expect(onSlashAction).toHaveBeenCalledWith("new-session"); + expect(createChatSession).toHaveBeenCalledTimes(1); expect(host.chatMessage).toBe(""); }); it("does not queue typed /new behind an active run", async () => { - const onSlashAction = vi.fn(); + const createChatSession = vi.fn(); const host = makeHost({ chatMessage: "/new", chatRunId: "run-main", chatStream: "Working...", - onSlashAction, + createChatSession, }); await handleSendChat(host); - expect(onSlashAction).toHaveBeenCalledWith("new-session"); + expect(createChatSession).toHaveBeenCalledTimes(1); expect(host.chatQueue).toStrictEqual([]); expect(host.chatRunId).toBe("run-main"); expect(host.chatStream).toBe("Working..."); @@ -1510,9 +1359,7 @@ describe("handleSendChat", () => { return { messages: [] }; } if (method === "sessions.list") { - return createSessionsResult([ - row("agent:main", { hasActiveRun: false, status: "done" }), - ]); + return createSessionsResult([row("agent:main", { hasActiveRun: false, status: "done" })]); } throw new Error(`Unexpected request: ${method}`); }); @@ -1523,7 +1370,6 @@ describe("handleSendChat", () => { client: { request } as unknown as ChatHost["client"], chatMessage: "/reset", sessionKey: "agent:main", - tab: "sessions", sessionsShowArchived: true, sessionsResult: archivedSessions, }); @@ -1588,7 +1434,6 @@ describe("handleSendChat", () => { client: { request } as unknown as ChatHost["client"], chatMessage: "measure first send", eventLogBuffer: [], - tab: "debug", }); await handleSendChat(host); @@ -1623,7 +1468,6 @@ describe("handleSendChat", () => { client: { request } as unknown as ChatHost["client"], chatMessage: "measure server milestone", eventLogBuffer: [], - tab: "debug", }); await handleSendChat(host); @@ -1673,7 +1517,6 @@ describe("handleSendChat", () => { const host = makeHost({ chatStream: "slow first token", eventLogBuffer: [], - tab: "debug", }); const timingHost = host as ChatHost & { chatSendTimingsByRun: Map< @@ -1756,7 +1599,6 @@ describe("handleSendChat", () => { client: { request } as unknown as ChatHost["client"], chatMessage: "measure painted pending send", eventLogBuffer: [], - tab: "debug", }); const send = handleSendChat(host); @@ -2193,7 +2035,6 @@ describe("handleSendChat", () => { chatMessage: "wait for selected model", chatModelSwitchPromises: { "agent:main": switchUpdate.promise }, eventLogBuffer: [], - tab: "debug", }); const send = handleSendChat(host); @@ -2264,12 +2105,12 @@ describe("handleSendChat", () => { } throw new Error(`Unexpected request: ${method}`); }); - const onSlashAction = vi.fn(); + const refreshCurrentSessionTools = vi.fn(); const host = makeHost({ client: { request } as unknown as ChatHost["client"], sessionKey: "main", chatMessage: "/model gpt-5-mini", - onSlashAction, + refreshCurrentSessionTools, }); await handleSendChat(host); @@ -2278,11 +2119,8 @@ describe("handleSendChat", () => { key: "main", model: "gpt-5-mini", }); - expect(host.chatModelOverrides.main).toEqual({ - kind: "qualified", - value: "openai/gpt-5-mini", - }); - expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective"); + expect(host.sessions.state.modelOverrides.main).toBe("openai/gpt-5-mini"); + expect(refreshCurrentSessionTools).toHaveBeenCalledTimes(1); }); it("shows local slash-command feedback when the gateway client is unavailable", async () => { @@ -2654,43 +2492,6 @@ describe("handleSendChat", () => { }); }); - it("keeps ACK-completed sends idle when sessions.list returns a stale active row", async () => { - const request = vi.fn(async (method: string, params?: unknown) => { - if (method === "chat.send") { - const payload = requireRecord(params, "chat send payload"); - return { runId: payload.idempotencyKey, status: "ok" }; - } - if (method === "chat.history") { - return { messages: [] }; - } - if (method === "sessions.list") { - return createSessionsResult([ - row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }), - ]); - } - throw new Error(`Unexpected request: ${method}`); - }); - const host = makeHost({ - client: { request } as unknown as ChatHost["client"], - chatMessage: "already done", - sessionsResult: createSessionsResult([ - row("agent:main", { hasActiveRun: true, status: "running", startedAt: 1 }), - ]), - }); - - await handleSendChat(host); - await Promise.resolve(); - await loadSessions(host as unknown as Parameters[0]); - - expect(host.chatRunId).toBeNull(); - expect(host.chatStream).toBeNull(); - expect(hasAbortableSessionRun(host)).toBe(false); - expect(host.sessionsResult?.sessions[0]).toMatchObject({ - hasActiveRun: false, - status: "done", - }); - }); - it("keeps delayed chat.send ACK effects scoped to the submitted session", async () => { const sent = createDeferred(); const request = vi.fn((method: string) => { @@ -2758,7 +2559,6 @@ describe("handleSendChat", () => { connected: false, chatMessage: "send after reconnect", eventLogBuffer: [], - tab: "debug", }); await handleSendChat(host); @@ -3411,6 +3211,6 @@ describe("handleAbortChat", () => { }); afterAll(() => { - vi.doUnmock("./app-last-active-session.ts"); + vi.doUnmock("../../app/settings.ts"); vi.resetModules(); }); diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts new file mode 100644 index 000000000000..e78e9bc024b3 --- /dev/null +++ b/ui/src/pages/chat/chat-send.ts @@ -0,0 +1,1330 @@ +// Control UI module implements app chat behavior. +import { isNonTerminalAgentRunStatus } from "../../../../src/shared/agent-run-status.js"; +import { + GatewayRequestError, + type GatewayBrowserClient, + type GatewayHelloOk, +} from "../../api/gateway.ts"; +import type { AgentsListResult } from "../../api/types.ts"; +import { setLastActiveSessionKey } from "../../app/settings.ts"; +import type { + ChatAttachment, + ChatQueueItem, + ChatQueueSkillWorkshopRevision, +} from "../../lib/chat/chat-types.ts"; +import { parseSlashCommand } from "../../lib/chat/commands.ts"; +import { + scopedAgentIdForSession, + visibleSessionMatches, + type SessionCapability, + type SessionRefreshTarget, +} from "../../lib/sessions/index.ts"; +import { + isUiGlobalSessionKey, + normalizeAgentId, + resolveUiSelectedSessionAgentId, +} from "../../lib/sessions/session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { generateUUID } from "../../lib/uuid.ts"; +import { + discardChatAttachmentDataUrls, + getChatAttachmentDataUrl, + releaseChatAttachmentPayloads, +} from "./attachment-payload-store.ts"; +import { + dispatchChatSlashCommand, + type ChatCommandHost, + type ChatCommandResetOptions, + shouldQueueLocalSlashCommand, +} from "./chat-commands.ts"; +import { loadChatHistory, type ChatState } from "./chat-history.ts"; +import { + enqueueChatMessage, + excludeComposerAttachments, + persistQueuedMessagesForSession, + readChatQueueForSession, + removeQueuedMessageWithoutReleasing, + removeVisibleOrScopedQueuedMessageWithoutReleasing, + updateQueuedMessage, + updateQueuedMessageForSession, +} from "./chat-queue.ts"; +import type { + ChatSendAck, + ChatSendAckServerTiming, + ChatSendTimingEntry, +} from "./chat-send-contract.ts"; +import { + chatSendAckServerTimingEventFields, + recordChatSendTiming, + registerChatSendTiming, + schedulePendingSendPaintTiming, + updateChatSendAckTiming, +} from "./chat-send-timing.ts"; +import { refreshChatSessionListForTarget } from "./chat-session.ts"; +import { + INTERRUPTED_MODEL_WAIT_ERROR, + removeStoredChatComposerQueueItem, +} from "./composer-persistence.ts"; +import { formatConnectError } from "./connect-error.ts"; +import { + handleChatDraftChange, + handleChatInputHistoryKey, + navigateChatInputHistory, + recordNonTranscriptInputHistory, + resetChatInputHistoryNavigation, + type ChatInputHistoryKeyInput, + type ChatInputHistoryKeyResult, + type ChatInputHistoryState, +} from "./input-history.ts"; +import { controlUiNowMs, roundedControlUiDurationMs } from "./performance.ts"; +import { + handleAbortChat, + isChatBusy, + isChatStopCommand, + reconcileChatRunLifecycle, +} from "./run-lifecycle.ts"; +import { scheduleChatScroll, resetChatScroll } from "./scroll.ts"; +import { resetToolStream } from "./tool-stream.ts"; +import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; + +export type ChatHost = ChatInputHistoryState & + ChatCommandHost & { + sessions: SessionCapability; + client: GatewayBrowserClient | null; + chatStream: string | null; + connected: boolean; + chatAttachments: ChatAttachment[]; + chatQueue: ChatQueueItem[]; + chatQueueBySession?: Record; + chatRunId: string | null; + chatSending: boolean; + lastError?: string | null; + chatError?: string | null; + hello: GatewayHelloOk | null; + chatModelSwitchPromises?: Record>; + updateComplete?: Promise; + requestUpdate?: () => void; + refreshSessionsAfterChat: Map; + chatSubmitGuards?: Map>; + chatSendTimingsByRun?: Map; + eventLogBuffer?: unknown[]; + assistantAgentId?: string | null; + agentsList?: ChatAgentsListSnapshot | null; + /** Selected message to reply to (right-click / keyboard shortcut). */ + chatReplyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null; + }; + +type ChatAgentsListSnapshot = Partial> & { + agents?: AgentsListResult["agents"]; +}; + +function setChatError( + host: { lastError?: string | null; chatError?: string | null }, + error: string | null, +) { + host.lastError = error; + host.chatError = error; +} + +function sendResetSlashCommand( + host: ChatHost, + message: string, + opts: ChatCommandResetOptions, +): Promise { + return sendChatMessageNow(host, message, { + refreshSessions: true, + previousDraft: opts.previousDraft, + restoreDraft: opts.restoreDraft, + }).then(() => undefined); +} + +type AcceptedChatSendAck = ChatSendAck & { status: "started" | "in_flight" | "ok" }; +type TerminalFailureChatSendAck = ChatSendAck & { status: "timeout" | "error" }; + +function isAcceptedChatSendAck(ack: ChatSendAck | null): ack is AcceptedChatSendAck { + return ack != null && (ack.status === "ok" || isNonTerminalAgentRunStatus(ack.status)); +} + +function isTerminalFailureChatSendAck(ack: ChatSendAck | null): ack is TerminalFailureChatSendAck { + return ack?.status === "timeout" || ack?.status === "error"; +} + +function formatTerminalChatSendAckError( + ack: TerminalFailureChatSendAck, + context: "chat" | "detached" | "steer", +): string { + if (ack.status === "error") { + if (context === "steer") { + return "Steer failed before it reached the run; try again."; + } + return "Chat failed before the run started; try again."; + } + if (context === "detached") { + return "The active run ended before the detached message was accepted."; + } + if (context === "steer") { + return "The active run ended before the steer message was accepted."; + } + return "The run ended before the message was accepted."; +} + +export type ChatSendOptions = { + confirmReset?: boolean; + restoreDraft?: boolean; + skillWorkshopRevision?: ChatQueueSkillWorkshopRevision; +}; + +function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null { + const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl); + if (!match) { + return null; + } + return { mimeType: match[1], content: match[2] }; +} + +function buildApiAttachments(attachments?: ChatAttachment[]) { + const hasAttachments = attachments && attachments.length > 0; + return hasAttachments + ? attachments + .map((att) => { + const dataUrl = getChatAttachmentDataUrl(att); + const parsed = dataUrl ? dataUrlToBase64(dataUrl) : null; + if (!parsed) { + return null; + } + return { + type: parsed.mimeType.startsWith("image/") ? "image" : "file", + mimeType: parsed.mimeType, + fileName: att.fileName, + content: parsed.content, + }; + }) + .filter((a): a is NonNullable => a !== null) + : undefined; +} + +function normalizeAckTimingValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +export type { + ChatSendAck, + ChatSendAckServerTiming, + ChatSendAckStatus, +} from "./chat-send-contract.ts"; + +function normalizeChatSendAckServerTiming(value: unknown): ChatSendAckServerTiming | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Record; + const receivedToAckMs = normalizeAckTimingValue(record.receivedToAckMs); + const loadSessionMs = normalizeAckTimingValue(record.loadSessionMs); + const prepareAttachmentsMs = normalizeAckTimingValue(record.prepareAttachmentsMs); + const timing: ChatSendAckServerTiming = { + ...(receivedToAckMs !== undefined ? { receivedToAckMs } : {}), + ...(loadSessionMs !== undefined ? { loadSessionMs } : {}), + ...(prepareAttachmentsMs !== undefined ? { prepareAttachmentsMs } : {}), + }; + return Object.keys(timing).length > 0 ? timing : undefined; +} + +function normalizeChatSendAck(payload: unknown, fallbackRunId: string): ChatSendAck { + if (!payload || typeof payload !== "object") { + return { runId: fallbackRunId, status: "started" }; + } + const record = payload as Record; + const runId = + typeof record.runId === "string" && record.runId.trim() ? record.runId.trim() : fallbackRunId; + const status = record.status; + const serverTiming = normalizeChatSendAckServerTiming(record.serverTiming); + return { + runId, + status: + status === "in_flight" || status === "ok" || status === "timeout" || status === "error" + ? status + : "started", + ...(serverTiming ? { serverTiming } : {}), + }; +} + +export async function requestChatSend( + state: ChatState, + params: { + message: string; + attachments?: ChatAttachment[]; + runId: string; + sessionKey?: string; + agentId?: string; + }, +): Promise { + const routing = resolveChatSendRouting(state, params); + const controlUiReconnectResume = Boolean( + routing.sessionId && state.reconnectResumeSessionId === routing.sessionId, + ); + const payload = await state.client!.request("chat.send", { + sessionKey: routing.sessionKey, + ...(isUiGlobalSessionKey(routing.sessionKey) && routing.selectedAgentId + ? { agentId: routing.selectedAgentId } + : {}), + ...(routing.sessionId ? { sessionId: routing.sessionId } : {}), + ...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}), + message: params.message, + deliver: false, + idempotencyKey: params.runId, + attachments: buildApiAttachments(params.attachments), + }); + if (controlUiReconnectResume) { + state.reconnectResumeSessionId = null; + } + return normalizeChatSendAck(payload, params.runId); +} + +function resolveChatSendRouting( + state: ChatState, + params: { + sessionKey?: string; + agentId?: string; + }, +): { selectedAgentId?: string; sessionId?: string; sessionKey: string } { + const sessionKey = params.sessionKey ?? state.sessionKey; + const selectedAgentId = params.agentId + ? normalizeAgentId(params.agentId) + : resolveUiSelectedSessionAgentId(state); + const currentSessionId = state.currentSessionId; + const canReuseCurrentSessionId = + sessionKey === state.sessionKey && + (!isUiGlobalSessionKey(sessionKey) || + (selectedAgentId !== undefined && + selectedAgentId === resolveUiSelectedSessionAgentId(state))); + const sessionId = + canReuseCurrentSessionId && typeof currentSessionId === "string" && currentSessionId.trim() + ? currentSessionId.trim() + : undefined; + return { + sessionKey, + ...(selectedAgentId ? { selectedAgentId } : {}), + ...(sessionId ? { sessionId } : {}), + }; +} + +export async function requestSkillWorkshopRevisionChatSend( + state: ChatState, + params: { + proposalId: string; + instructions: string; + runId: string; + sessionKey?: string; + agentId?: string; + targetAgentId?: string; + }, +): Promise { + const routing = resolveChatSendRouting(state, { + sessionKey: params.sessionKey, + agentId: params.targetAgentId, + }); + const payload = await state.client!.request("skills.proposals.requestRevision", { + ...(params.agentId ? { agentId: normalizeAgentId(params.agentId) } : {}), + ...(routing.selectedAgentId ? { targetAgentId: routing.selectedAgentId } : {}), + proposalId: params.proposalId, + instructions: params.instructions, + sessionKey: routing.sessionKey, + ...(routing.sessionId ? { sessionId: routing.sessionId } : {}), + idempotencyKey: params.runId, + }); + return normalizeChatSendAck(payload, params.runId); +} + +export function appendUserChatMessage( + state: ChatState, + message: string, + attachments?: ChatAttachment[], + timestamp = Date.now(), +) { + const entry = { + role: "user" as const, + content: buildUserChatMessageContentBlocks(message, attachments), + timestamp, + }; + state.chatMessages = [...state.chatMessages, entry]; + return entry; +} + +async function sendChatMessageWithGeneratedRunId( + state: ChatState, + message: string, + attachments?: ChatAttachment[], +): Promise { + if (!state.client || !state.connected) { + return null; + } + const msg = message.trim(); + const hasAttachments = attachments && attachments.length > 0; + if (!msg && !hasAttachments) { + return null; + } + setChatError(state, null); + const runId = generateUUID(); + try { + return await requestChatSend(state, { message: msg, attachments, runId }); + } catch (err) { + setChatError(state, formatConnectError(err)); + return null; + } +} + +export async function sendDetachedChatMessage( + state: ChatState, + message: string, + attachments?: ChatAttachment[], +): Promise { + return sendChatMessageWithGeneratedRunId(state, message, attachments); +} + +export async function sendSteerChatMessage( + state: ChatState, + message: string, + attachments?: ChatAttachment[], +): Promise { + return sendChatMessageWithGeneratedRunId(state, message, attachments); +} + +export { + handleChatDraftChange, + handleChatInputHistoryKey, + navigateChatInputHistory, + resetChatInputHistoryNavigation, +}; +export type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult }; + +function isChatResetCommand(text: string) { + const parsed = parseSlashCommand(text); + if (!parsed || (parsed.command.key !== "new" && parsed.command.key !== "reset")) { + return false; + } + if (parsed.command.key === "new") { + return true; + } + if (/^soft(?:\s|$)/.test(normalizeLowercaseStringOrEmpty(parsed.args))) { + return false; + } + return true; +} + +function confirmChatResetCommand(text: string) { + if (!isChatResetCommand(text)) { + return true; + } + if (typeof globalThis.confirm !== "function") { + return false; + } + return globalThis.confirm("Start a new session? This will reset the current chat."); +} + +function isBtwCommand(text: string) { + return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim()); +} + +function enqueuePendingSendMessage( + host: ChatHost, + text: string, + attachments?: ChatAttachment[], + refreshSessions?: boolean, + submittedAtMs = controlUiNowMs(), + sendState: ChatQueueItem["sendState"] = host.connected && host.client + ? "sending" + : "waiting-reconnect", + skillWorkshopRevision?: ChatQueueSkillWorkshopRevision, +): ChatQueueItem | null { + const trimmed = text.trim(); + const hasAttachments = Boolean(attachments && attachments.length > 0); + if (!trimmed && !hasAttachments) { + return null; + } + const pending: ChatQueueItem = { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + attachments: hasAttachments ? attachments : undefined, + refreshSessions, + sendAttempts: 0, + sendRunId: generateUUID(), + sendState, + sendSubmittedAtMs: submittedAtMs, + sessionKey: host.sessionKey, + agentId: scopedAgentIdForSession(host, host.sessionKey), + ...(skillWorkshopRevision ? { skillWorkshopRevision } : {}), + }; + host.chatQueue = [...host.chatQueue, pending]; + recordChatSendTiming(host, pending, "pending-visible", submittedAtMs); + if (sendState === "waiting-model" || sendState === "waiting-reconnect") { + recordChatSendTiming(host, pending, sendState, submittedAtMs); + } + schedulePendingSendPaintTiming(host, pending, submittedAtMs); + scheduleChatScroll(host as unknown as Parameters[0], true, false, { + source: "manual", + }); + return pending; +} + +function isRecoverableChatSendError(err: unknown, formattedError: string): boolean { + if (err instanceof GatewayRequestError) { + return err.retryable; + } + return /gateway (?:not connected|closed)|websocket|disconnected/i.test(formattedError); +} + +function restoreComposerAfterFailedSend( + host: ChatHost, + opts: { + previousAttachments?: ChatAttachment[]; + previousDraft?: string; + }, +) { + if (opts.previousDraft != null && !host.chatMessage.trim()) { + host.chatMessage = opts.previousDraft; + } + if (opts.previousAttachments?.length && host.chatAttachments.length === 0) { + host.chatAttachments = opts.previousAttachments; + } +} + +function cancelPendingSendBeforeRequest( + host: ChatHost, + queued: ChatQueueItem, + opts: { + previousAttachments?: ChatAttachment[]; + previousDraft?: string; + restoreComposer?: boolean; + }, +) { + const removed = removeVisibleOrScopedQueuedMessageWithoutReleasing( + host, + queued.id, + queued.sessionKey, + ); + const restoreComposer = opts.restoreComposer !== false && removed != null; + const willRestoreDraft = + restoreComposer && opts.previousDraft != null && !host.chatMessage.trim(); + const willRestoreAttachments = Boolean( + restoreComposer && + opts.previousAttachments?.length && + host.chatAttachments.length === 0 && + (willRestoreDraft || !host.chatMessage.trim()), + ); + if (restoreComposer) { + if (willRestoreDraft) { + host.chatMessage = opts.previousDraft ?? ""; + } + if (willRestoreAttachments) { + host.chatAttachments = opts.previousAttachments ?? []; + } + } + if (removed?.sessionKey) { + removeStoredChatComposerQueueItem(host, removed.sessionKey, removed.id); + } + if (removed && !willRestoreAttachments) { + releaseChatAttachmentPayloads(excludeComposerAttachments(host, removed.attachments)); + } +} + +type QueuedChatSendResult = "sent" | "pending" | "failed"; + +function ensureQueuedSendState( + host: ChatHost, + item: ChatQueueItem, + fallbackSessionKey = host.sessionKey, +): ChatQueueItem { + if (item.sendRunId && item.sendState) { + return item; + } + const sessionKey = item.sessionKey ?? fallbackSessionKey; + const agentId = item.agentId ?? scopedAgentIdForSession(host, sessionKey); + const prepared: ChatQueueItem = { + ...item, + sendAttempts: item.sendAttempts ?? 0, + sendRunId: item.sendRunId ?? generateUUID(), + sendState: host.connected && host.client ? "sending" : "waiting-reconnect", + sessionKey, + agentId, + }; + updateQueuedMessageForSession(host, sessionKey, item.id, () => prepared); + return prepared; +} + +async function sendQueuedChatMessage( + host: ChatHost, + id: string, + opts?: { + previousAttachments?: ChatAttachment[]; + previousDraft?: string; + }, + queuedSessionKey = host.sessionKey, +): Promise { + const queued = readChatQueueForSession(host, queuedSessionKey).find((item) => item.id === id); + if (!queued || queued.pendingRunId || queued.localCommandName) { + return "failed"; + } + const prepared = ensureQueuedSendState(host, queued, queuedSessionKey); + const message = prepared.text.trim(); + const attachments = prepared.attachments ?? []; + const hasAttachments = attachments.length > 0; + if (!message && !hasAttachments) { + removeQueuedMessageWithoutReleasing(host, id, prepared.sessionKey ?? host.sessionKey); + return "sent"; + } + if (prepared.skillWorkshopRevision && hasAttachments) { + updateQueuedMessageForSession(host, prepared.sessionKey ?? host.sessionKey, id, (item) => ({ + ...item, + sendError: "Skill Workshop revision requests do not support attachments.", + sendState: "failed", + })); + return "failed"; + } + const sessionKey = prepared.sessionKey ?? host.sessionKey; + if (!host.connected || !host.client) { + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendState: "waiting-reconnect", + sendError: undefined, + })); + return "pending"; + } + + const runId = prepared.sendRunId ?? generateUUID(); + const startedAt = Date.now(); + const requestStartedAtMs = controlUiNowMs(); + const sendingItem = + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendAttempts: (item.sendAttempts ?? 0) + 1, + sendError: undefined, + sendRunId: runId, + sendState: "sending", + sendRequestStartedAtMs: requestStartedAtMs, + sessionKey, + agentId: prepared.agentId, + })) ?? prepared; + registerChatSendTiming(host, sendingItem, runId, requestStartedAtMs); + recordChatSendTiming(host, sendingItem, "request-start", sendingItem.sendSubmittedAtMs); + host.chatSending = true; + const isVisibleSession = () => visibleSessionMatches(host, sessionKey, prepared.agentId); + if (isVisibleSession()) { + setChatError(host, null); + reconcileChatRunLifecycle(host as unknown as Parameters[0], { + clearRunStatus: true, + }); + } + + try { + const ack = prepared.skillWorkshopRevision + ? await requestSkillWorkshopRevisionChatSend(host as unknown as ChatState, { + proposalId: prepared.skillWorkshopRevision.proposalId, + ...(prepared.skillWorkshopRevision.agentId + ? { agentId: prepared.skillWorkshopRevision.agentId } + : {}), + ...(prepared.agentId ? { targetAgentId: prepared.agentId } : {}), + instructions: message, + runId, + sessionKey, + }) + : await requestChatSend(host as unknown as ChatState, { + message, + attachments: hasAttachments ? attachments : undefined, + runId, + sessionKey, + agentId: prepared.agentId, + }); + updateChatSendAckTiming(host, runId, ack, sendingItem, requestStartedAtMs); + recordChatSendTiming(host, sendingItem, "ack", sendingItem.sendSubmittedAtMs, { + ackStatus: ack.status, + requestDurationMs: roundedControlUiDurationMs(controlUiNowMs() - requestStartedAtMs), + ...chatSendAckServerTimingEventFields(ack), + }); + if (isTerminalFailureChatSendAck(ack)) { + const error = formatTerminalChatSendAckError(ack, "chat"); + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendError: error, + sendState: "failed", + })); + if (isVisibleSession()) { + reconcileChatRunLifecycle( + host as unknown as Parameters[0], + { + outcome: "interrupted", + sessionStatus: ack.status === "error" ? "failed" : "killed", + runId: ack.runId, + sessionKey, + clearLocalRun: true, + clearChatStream: true, + clearToolStream: true, + clearSideResultTerminalRuns: true, + publishRunStatus: false, + armLocalTerminalReconcile: ack.runId === runId, + }, + ); + setChatError(host, error); + restoreComposerAfterFailedSend(host, opts ?? {}); + } + recordChatSendTiming(host, sendingItem, "failed", sendingItem.sendSubmittedAtMs, { + error, + ackStatus: ack.status, + }); + return "failed"; + } + removeQueuedMessageWithoutReleasing(host, id, sessionKey); + if (isVisibleSession()) { + appendUserChatMessage( + host as unknown as ChatState, + message, + hasAttachments ? attachments : undefined, + startedAt, + ); + if (ack.status === "ok") { + reconcileChatRunLifecycle( + host as unknown as Parameters[0], + { + outcome: "done", + sessionStatus: "done", + runId: ack.runId, + sessionKey, + clearLocalRun: true, + clearChatStream: true, + clearToolStream: true, + clearSideResultTerminalRuns: true, + publishRunStatus: false, + armLocalTerminalReconcile: true, + }, + ); + void loadChatHistory(host as unknown as ChatState); + } else if (isNonTerminalAgentRunStatus(ack.status)) { + const hasAlreadyAdoptedRunStream = + host.chatRunId === ack.runId && typeof host.chatStream === "string"; + host.chatRunId = ack.runId; + // Gateway can deliver the first delta before the chat.send ACK resolves. + // Preserve that adopted stream; resetting here makes first replies vanish + // until a later delta or final event arrives. + if (!hasAlreadyAdoptedRunStream) { + host.chatStream = ""; + (host as ChatHost & { chatStreamStartedAt?: number | null }).chatStreamStartedAt = + startedAt; + } + } else { + reconcileChatRunLifecycle( + host as unknown as Parameters[0], + { + outcome: "interrupted", + sessionStatus: ack.status === "error" ? "failed" : "killed", + runId: ack.runId, + sessionKey, + clearLocalRun: true, + clearChatStream: true, + clearToolStream: true, + clearSideResultTerminalRuns: true, + publishRunStatus: false, + armLocalTerminalReconcile: ack.runId === runId, + }, + ); + } + } + if (prepared.refreshSessions) { + const refreshTarget = { + sessionKey, + agentId: prepared.agentId, + }; + if (ack.status === "ok") { + void refreshChatSessionListForTarget(host, refreshTarget); + } else if (isNonTerminalAgentRunStatus(ack.status)) { + host.refreshSessionsAfterChat.set(ack.runId, refreshTarget); + } + } + discardChatAttachmentDataUrls(excludeComposerAttachments(host, attachments)); + return "sent"; + } catch (err) { + const error = formatConnectError(err); + if (isRecoverableChatSendError(err, error)) { + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendError: error, + sendState: "waiting-reconnect", + })); + if (isVisibleSession()) { + setChatError(host, "Message will send when the Gateway reconnects."); + } + recordChatSendTiming(host, prepared, "waiting-reconnect", prepared.sendSubmittedAtMs, { + error, + }); + return "pending"; + } + updateQueuedMessageForSession(host, sessionKey, id, (item) => ({ + ...item, + sendError: error, + sendState: "failed", + })); + if (isVisibleSession()) { + setChatError(host, error); + restoreComposerAfterFailedSend(host, opts ?? {}); + } + recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, { error }); + return "failed"; + } finally { + host.chatSending = false; + } +} + +async function sendChatMessageNow( + host: ChatHost, + message: string, + opts?: { + queueItemId?: string; + previousDraft?: string; + restoreDraft?: boolean; + attachments?: ChatAttachment[]; + previousAttachments?: ChatAttachment[]; + restoreAttachments?: boolean; + refreshSessions?: boolean; + submittedAtMs?: number; + }, +) { + resetToolStream(host as unknown as Parameters[0]); + // Reset scroll state before sending to ensure auto-scroll works for the response + resetChatScroll(host as unknown as Parameters[0]); + const queued = + opts?.queueItemId != null + ? (host.chatQueue.find((item) => item.id === opts.queueItemId) ?? null) + : enqueuePendingSendMessage( + host, + message, + opts?.attachments, + opts?.refreshSessions, + opts?.submittedAtMs, + ); + if (!queued) { + return false; + } + const queuedSessionKey = queued.sessionKey ?? host.sessionKey; + const result = await sendQueuedChatMessage(host, queued.id, { + previousDraft: opts?.previousDraft, + previousAttachments: opts?.previousAttachments, + }); + const ok = result === "sent"; + if (ok && host.sessionKey === queuedSessionKey) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + queuedSessionKey, + ); + resetChatInputHistoryNavigation(host); + } + if ( + ok && + host.sessionKey === queuedSessionKey && + opts?.restoreDraft && + opts.previousDraft?.trim() + ) { + host.chatMessage = opts.previousDraft; + } + if ( + ok && + host.sessionKey === queuedSessionKey && + opts?.restoreAttachments && + opts.previousAttachments?.length + ) { + host.chatAttachments = opts.previousAttachments; + } + // Force scroll after sending to ensure viewport is at bottom for incoming stream + if (host.sessionKey === queuedSessionKey) { + scheduleChatScroll(host as unknown as Parameters[0], true); + } + if (ok && host.sessionKey === queuedSessionKey && !host.chatRunId) { + void flushChatQueue(host); + } + return ok; +} + +function attachmentSubmitSignature(attachment: ChatAttachment): string { + const dataUrl = getChatAttachmentDataUrl(attachment); + return JSON.stringify([ + attachment.id, + attachment.mimeType, + attachment.fileName ?? "", + attachment.sizeBytes ?? 0, + dataUrl?.length ?? 0, + dataUrl?.slice(0, 64) ?? "", + ]); +} + +function chatSubmitKey( + host: ChatHost, + kind: "btw" | "message", + message: string, + attachments: ChatAttachment[], + skillWorkshopRevision?: ChatQueueSkillWorkshopRevision, +): string { + return JSON.stringify([ + kind, + host.sessionKey, + message.trim(), + skillWorkshopRevision?.proposalId ?? "", + skillWorkshopRevision?.agentId ?? "", + attachments.map(attachmentSubmitSignature), + ]); +} + +async function withChatSubmitGuard( + host: ChatHost, + key: string, + run: () => Promise, +): Promise { + const guards = (host.chatSubmitGuards ??= new Map>()); + if (guards.has(key)) { + return undefined; + } + let releaseGuard!: () => void; + const guard = new Promise((resolve) => { + releaseGuard = resolve; + }); + guards.set(key, guard); + try { + return await run(); + } finally { + releaseGuard(); + if (guards.get(key) === guard) { + guards.delete(key); + } + } +} + +function waitForPendingChatModelSwitch( + host: ChatHost, + sessionKey: string, +): Promise | true { + const pending = host.chatModelSwitchPromises?.[sessionKey]; + if (!pending) { + return true; + } + return pending; +} + +function clearSubmittedComposerState( + host: ChatHost, + submittedDraft: string, + submittedAttachments: ChatAttachment[], +): { + previousAttachments?: ChatAttachment[]; + previousDraft?: string; +} { + const attachmentsUnchanged = + host.chatAttachments.length === submittedAttachments.length && + host.chatAttachments.every( + (attachment, index) => + attachmentSubmitSignature(attachment) === + attachmentSubmitSignature(submittedAttachments[index]), + ); + const clearedDraft = host.chatMessage === submittedDraft && attachmentsUnchanged; + const clearedAttachments = clearedDraft; + if (clearedDraft) { + host.chatMessage = ""; + } + if (clearedAttachments) { + host.chatAttachments = []; + } + if (clearedDraft || clearedAttachments) { + resetChatInputHistoryNavigation(host); + } + return { + previousAttachments: clearedAttachments ? submittedAttachments : undefined, + previousDraft: clearedDraft ? submittedDraft : undefined, + }; +} + +function snapshotChatAttachments(attachments: readonly ChatAttachment[]): ChatAttachment[] { + return attachments.map((attachment) => { + const dataUrl = getChatAttachmentDataUrl(attachment); + return { + ...attachment, + ...(dataUrl ? { dataUrl } : {}), + }; + }); +} + +async function sendDetachedBtwMessage( + host: ChatHost, + message: string, + opts?: { + previousDraft?: string; + attachments?: ChatAttachment[]; + previousAttachments?: ChatAttachment[]; + }, +) { + const ack = await sendDetachedChatMessage( + host as unknown as ChatState, + message, + opts?.attachments, + ); + const ok = isAcceptedChatSendAck(ack); + if (!ok && opts?.previousDraft != null) { + host.chatMessage = opts.previousDraft; + } + if (!ok && opts?.previousAttachments) { + host.chatAttachments = opts.previousAttachments; + } + if (isTerminalFailureChatSendAck(ack)) { + setChatError(host, formatTerminalChatSendAckError(ack, "detached")); + } + if (ok) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + host.sessionKey, + ); + releaseChatAttachmentPayloads(excludeComposerAttachments(host, opts?.attachments)); + } + return ok; +} + +export async function steerQueuedChatMessage(host: ChatHost, id: string) { + if (!host.connected || !host.chatRunId) { + return; + } + const activeRunId = host.chatRunId; + const item = host.chatQueue.find( + (entry) => entry.id === id && !entry.pendingRunId && !entry.localCommandName, + ); + if (!item) { + return; + } + const message = item.text.trim(); + const attachments = item.attachments ?? []; + const hasAttachments = attachments.length > 0; + if (!message && !hasAttachments) { + return; + } + + host.chatQueue = host.chatQueue.map((entry) => + entry.id === id ? { ...entry, kind: "steered", pendingRunId: activeRunId } : entry, + ); + const ack = await sendSteerChatMessage( + host as unknown as ChatState, + message, + hasAttachments ? attachments : undefined, + ); + if (!ack || isTerminalFailureChatSendAck(ack)) { + host.chatQueue = host.chatQueue.map((entry) => (entry.id === id ? item : entry)); + if (isTerminalFailureChatSendAck(ack)) { + setChatError(host, formatTerminalChatSendAckError(ack, "steer")); + } + return; + } + if (ack.status === "ok") { + removeQueuedMessageWithoutReleasing(host, id, host.sessionKey); + } + releaseChatAttachmentPayloads(attachments); + setLastActiveSessionKey( + host as unknown as Parameters[0], + host.sessionKey, + ); + scheduleChatScroll(host as unknown as Parameters[0]); +} + +async function flushChatQueue(host: ChatHost) { + if (!host.connected || isChatBusy(host)) { + return; + } + const nextIndex = host.chatQueue.findIndex( + (item) => + !item.pendingRunId && + item.sendState !== "sending" && + item.sendState !== "waiting-model" && + item.sendState !== "failed" && + (item.sessionKey == null || item.sessionKey === host.sessionKey), + ); + if (nextIndex < 0) { + return; + } + const next = host.chatQueue[nextIndex]; + let ok = false; + try { + if (next.localCommandName) { + host.chatQueue = host.chatQueue.filter((_, index) => index !== nextIndex); + await dispatchChatSlashCommand(host, next.localCommandName, next.localCommandArgs ?? "", { + sendResetMessage: (message, resetOpts) => sendResetSlashCommand(host, message, resetOpts), + }); + ok = true; + } else { + ok = await sendChatMessageNow(host, next.text, { + queueItemId: next.id, + attachments: next.attachments, + refreshSessions: next.refreshSessions, + }); + } + } catch (err) { + setChatError(host, String(err)); + } + if (!ok && next.localCommandName) { + host.chatQueue = [next, ...host.chatQueue]; + } else if (ok && host.chatQueue.length > 0) { + // Continue draining — local commands don't block on server response + void flushChatQueue(host); + } +} + +export async function retryReconnectableQueuedChatSends(host: ChatHost) { + if (!host.connected || !host.client || host.chatSending) { + return; + } + const sessionKeys = [ + host.sessionKey, + ...Object.keys(host.chatQueueBySession ?? {}).filter( + (sessionKey) => sessionKey !== host.sessionKey, + ), + ]; + for (const sessionKey of sessionKeys) { + const item = readChatQueueForSession(host, sessionKey).find( + (entry) => + entry.sendRunId && + entry.sendState === "waiting-reconnect" && + !entry.pendingRunId && + !entry.localCommandName, + ); + if (!item) { + continue; + } + await sendQueuedChatMessage(host, item.id, undefined, sessionKey); + if (host.chatRunId) { + return; + } + } + if (!host.chatRunId) { + void flushChatQueue(host); + } +} + +export async function retryQueuedChatMessage(host: ChatHost, id: string) { + const item = host.chatQueue.find((entry) => entry.id === id); + if ( + !item || + item.localCommandName || + item.pendingRunId || + item.sendState === "sending" || + item.sendState === "waiting-model" + ) { + return; + } + updateQueuedMessage(host, id, (entry) => ({ + ...entry, + sendError: undefined, + sendState: host.connected && host.client ? "sending" : "waiting-reconnect", + })); + await sendQueuedChatMessage(host, id); + if (!host.chatRunId) { + void flushChatQueue(host); + } +} + +export async function handleSendChat( + host: ChatHost, + messageOverride?: string, + opts?: ChatSendOptions, +) { + const previousDraft = host.chatMessage; + const message = (messageOverride ?? host.chatMessage).trim(); + const submittedAtMs = controlUiNowMs(); + const submittedSessionKey = host.sessionKey; + const attachments = host.chatAttachments ?? []; + const attachmentsToSend = messageOverride == null ? snapshotChatAttachments(attachments) : []; + const hasAttachments = attachmentsToSend.length > 0; + const skillWorkshopRevision = opts?.skillWorkshopRevision; + const shouldInterpretChatCommands = !skillWorkshopRevision; + + if (!message && !hasAttachments) { + return; + } + + if (messageOverride != null && opts?.confirmReset && !confirmChatResetCommand(message)) { + return; + } + + if (shouldInterpretChatCommands) { + if (isChatStopCommand(message)) { + if (messageOverride == null) { + recordNonTranscriptInputHistory(host, message); + } + await handleAbortChat(host); + return; + } + + if (isBtwCommand(message)) { + const submitKey = chatSubmitKey(host, "btw", message, attachmentsToSend); + await withChatSubmitGuard(host, submitKey, async () => { + const modelSwitchReady = waitForPendingChatModelSwitch(host, submittedSessionKey); + if (modelSwitchReady !== true && !(await modelSwitchReady)) { + return; + } + if (host.sessionKey !== submittedSessionKey) { + return; + } + const cleared = + messageOverride == null + ? clearSubmittedComposerState(host, previousDraft, attachmentsToSend) + : {}; + if (messageOverride == null) { + recordNonTranscriptInputHistory(host, message); + } + await sendDetachedBtwMessage(host, message, { + previousDraft: cleared.previousDraft, + attachments: hasAttachments ? attachmentsToSend : undefined, + previousAttachments: cleared.previousAttachments, + }); + }); + return; + } + + // Intercept local slash commands (/status, /model, /compact, etc.) + const parsed = parseSlashCommand(message); + if (parsed?.command.executeLocal) { + if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.key)) { + if (messageOverride == null) { + recordNonTranscriptInputHistory(host, message); + host.chatMessage = ""; + host.chatAttachments = []; + resetChatInputHistoryNavigation(host); + } + enqueueChatMessage(host, message, undefined, isChatResetCommand(message), { + args: parsed.args, + name: parsed.command.key, + }); + return; + } + const prevDraft = messageOverride == null ? previousDraft : undefined; + if (messageOverride == null) { + recordNonTranscriptInputHistory(host, message); + host.chatMessage = ""; + host.chatAttachments = []; + resetChatInputHistoryNavigation(host); + } + await dispatchChatSlashCommand(host, parsed.command.key, parsed.args, { + previousDraft: prevDraft, + restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + sendResetMessage: (resetMessage, resetOpts) => + sendResetSlashCommand(host, resetMessage, resetOpts), + }); + return; + } + } + + const replyTarget = host.chatReplyTarget; + const effectiveMessage = replyTarget ? prependReplyQuote(message, replyTarget) : message; + + const refreshSessions = shouldInterpretChatCommands && isChatResetCommand(message); + const submitKey = chatSubmitKey( + host, + "message", + effectiveMessage, + attachmentsToSend, + skillWorkshopRevision, + ); + await withChatSubmitGuard(host, submitKey, async () => { + if (host.sessionKey !== submittedSessionKey) { + return; + } + const cleared = + messageOverride == null + ? clearSubmittedComposerState(host, previousDraft, attachmentsToSend) + : {}; + if (messageOverride == null) { + recordNonTranscriptInputHistory(host, message); + } + + const modelSwitchReady = waitForPendingChatModelSwitch(host, submittedSessionKey); + const waitingForModel = modelSwitchReady !== true; + const queued = enqueuePendingSendMessage( + host, + effectiveMessage, + hasAttachments ? attachmentsToSend : undefined, + refreshSessions, + submittedAtMs, + waitingForModel ? "waiting-model" : undefined, + skillWorkshopRevision, + ); + if (!queued) { + return; + } + + if (modelSwitchReady !== true && !(await modelSwitchReady)) { + if (host.sessionKey === submittedSessionKey) { + cancelPendingSendBeforeRequest(host, queued, { + previousDraft: cleared.previousDraft, + previousAttachments: cleared.previousAttachments, + }); + } else { + updateQueuedMessageForSession(host, submittedSessionKey, queued.id, (item) => ({ + ...item, + sendError: INTERRUPTED_MODEL_WAIT_ERROR, + sendState: "failed", + })); + persistQueuedMessagesForSession(host, submittedSessionKey); + } + return; + } + if (host.sessionKey !== submittedSessionKey) { + updateQueuedMessageForSession(host, submittedSessionKey, queued.id, (item) => ({ + ...item, + sendError: undefined, + sendState: undefined, + })); + persistQueuedMessagesForSession(host, submittedSessionKey); + return; + } + + if (isChatBusy(host)) { + updateQueuedMessage(host, queued.id, (item) => ({ + ...item, + sendError: undefined, + sendState: undefined, + })); + recordChatSendTiming(host, queued, "queued-busy", submittedAtMs); + return; + } + + const accepted = await sendChatMessageNow(host, effectiveMessage, { + queueItemId: queued.id, + previousDraft: cleared.previousDraft, + restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + attachments: hasAttachments ? attachmentsToSend : undefined, + previousAttachments: cleared.previousAttachments, + restoreAttachments: Boolean(messageOverride && opts?.restoreDraft), + refreshSessions, + submittedAtMs, + }); + if ( + accepted && + replyTarget && + host.chatReplyTarget?.messageId === replyTarget.messageId && + host.sessionKey === submittedSessionKey + ) { + host.chatReplyTarget = null; + } + }); +} + +function prependReplyQuote( + message: string, + replyTarget: NonNullable, +): string { + const label = escapeMarkdownInline(replyTarget.senderLabel ?? "User"); + const text = replyTarget.text.trim(); + if (!text.includes("\n")) { + return `> **${label}:** ${text}\n\n${message}`; + } + const quoted = text + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + return `> **${label}:**\n${quoted}\n\n${message}`; +} + +function escapeMarkdownInline(value: string): string { + return value.replace(/([\\`*_{}[\]()#+\-.!|>])/g, "\\$1"); +} + +export const flushChatQueueForEvent = flushChatQueue; diff --git a/ui/src/pages/chat/chat-session.ts b/ui/src/pages/chat/chat-session.ts new file mode 100644 index 000000000000..6560cc1bc03f --- /dev/null +++ b/ui/src/pages/chat/chat-session.ts @@ -0,0 +1,375 @@ +import type { FastMode, GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import { resolveChatModelOverrideValue } from "../../lib/chat/model-select-state.ts"; +import { normalizeThinkLevel } from "../../lib/chat/thinking.ts"; +import { isSessionRunActive } from "../../lib/session-run-state.ts"; +import { + scopedAgentParamsForSession, + scopedAgentListParamsForRefreshTarget, + scopedAgentListParamsForSession, + type SessionCapability, + type SessionListOptions, + type SessionRefreshTarget, + type SessionScopeHost, +} from "../../lib/sessions/index.ts"; +import { + areUiSessionKeysEquivalent, + isUiGlobalSessionKey, + resolveUiGlobalAliasAgentId, +} from "../../lib/sessions/session-key.ts"; +import { normalizeOptionalString } from "../../lib/string-coerce.ts"; +import type { ChatHistoryResult } from "./chat-history.ts"; + +const CHAT_SESSION_LIST_ACTIVE_MINUTES = 0; +const CHAT_SESSION_LIST_LIMIT = 50; + +type ChatSessionListHost = { + sessionsShowArchived?: boolean; +}; + +type ChatSessionRefreshHost = ChatSessionListHost & + SessionScopeHost & { + sessionKey: string; + sessions: Pick; + }; + +type ChatModelSettingsHost = ChatSessionRefreshHost & { + client: unknown; + connected: boolean; + lastError?: string | null; + chatError?: string | null; + chatModelCatalog: Parameters[0]["chatModelCatalog"]; + chatModelSwitchPromises?: Record>; + chatThinkingLevel: string | null; + onModelChanged?: () => unknown; + sessions: SessionCapability; + sessionsResult?: SessionsListResult | null; + requestUpdate?: () => void; +}; + +type ChatIdleSessionReconciliationHost = SessionScopeHost & { + chatQueue: unknown[]; + sessionKey: string; + sessionsError?: string | null; + sessionsResult?: SessionsListResult | null; +}; + +export function buildChatSessionListOptions( + _state: ChatSessionListHost, + options: { offset?: number; append?: boolean; search?: string | null } = {}, +): SessionListOptions { + const result: SessionListOptions = { + activeMinutes: CHAT_SESSION_LIST_ACTIVE_MINUTES, + limit: CHAT_SESSION_LIST_LIMIT, + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, + showArchived: false, + }; + const search = normalizeOptionalString(options.search ?? undefined); + if (search) { + result.search = search; + } + const offset = + typeof options.offset === "number" && Number.isFinite(options.offset) + ? Math.max(0, Math.floor(options.offset)) + : 0; + if (offset > 0) { + result.offset = offset; + } + if (options.append === true) { + result.append = true; + } + return result; +} + +export function refreshCurrentChatSessionList(host: ChatSessionRefreshHost): Promise { + return host.sessions.refresh({ + ...buildChatSessionListOptions(host), + ...scopedAgentListParamsForSession(host, host.sessionKey), + force: true, + }); +} + +export function refreshChatSessionListForTarget( + host: ChatSessionListHost & + SessionScopeHost & { + sessions: Pick; + }, + target: SessionRefreshTarget, +): Promise { + return host.sessions.refresh({ + ...buildChatSessionListOptions(host), + ...scopedAgentListParamsForRefreshTarget(host, target), + force: true, + }); +} + +function isSelectedSessionKnownIdle( + sessionsResult: SessionsListResult, + sessionKey: string, +): boolean { + const row = sessionsResult.sessions.find((session) => + areUiSessionKeysEquivalent(session.key, sessionKey), + ); + return Boolean(row && !isSessionRunActive(row)); +} + +function isHistorySessionInfoForRequestedSession( + host: ChatIdleSessionReconciliationHost, + historySessionKey: string | undefined, + requestedSessionKey: string, +): boolean { + if (areUiSessionKeysEquivalent(historySessionKey, requestedSessionKey)) { + return true; + } + return Boolean( + historySessionKey && + isUiGlobalSessionKey(historySessionKey) && + resolveUiGlobalAliasAgentId(host, requestedSessionKey), + ); +} + +function findSelectedSessionRow( + host: ChatIdleSessionReconciliationHost, + sessionsResult: SessionsListResult | null | undefined, + sessionKey: string, + historySessionKey: string | undefined, +): GatewaySessionRow | undefined { + const requestedGlobalAgentId = + historySessionKey && isUiGlobalSessionKey(historySessionKey) + ? resolveUiGlobalAliasAgentId(host, sessionKey) + : undefined; + return sessionsResult?.sessions.find((session) => { + if (areUiSessionKeysEquivalent(session.key, sessionKey)) { + return true; + } + return ( + requestedGlobalAgentId != null && + resolveUiGlobalAliasAgentId(host, session.key) === requestedGlobalAgentId + ); + }); +} + +function historyIdleProofIsStaleForSelectedRow( + historySessionInfo: GatewaySessionRow, + selectedRow: GatewaySessionRow | undefined, +): boolean { + if (!selectedRow || !isSessionRunActive(selectedRow) || isSessionRunActive(historySessionInfo)) { + return false; + } + const historyUpdatedAt = + typeof historySessionInfo.updatedAt === "number" ? historySessionInfo.updatedAt : null; + if (historyUpdatedAt == null) { + return true; + } + const selectedUpdatedAt = typeof selectedRow.updatedAt === "number" ? selectedRow.updatedAt : 0; + if (selectedUpdatedAt >= historyUpdatedAt) { + return true; + } + const selectedStartedAt = typeof selectedRow.startedAt === "number" ? selectedRow.startedAt : 0; + return selectedStartedAt >= historyUpdatedAt; +} + +export function flushChatQueueAfterIdleSessionReconciliation( + host: ChatIdleSessionReconciliationHost, + sessionKey: string, + historyRefresh: Promise, + sessionsRefresh: Promise, + previousSessionsResult: SessionsListResult | null | undefined, + flushQueue: () => void, +) { + if (host.chatQueue.length === 0) { + return; + } + void Promise.allSettled([historyRefresh, sessionsRefresh]).then((results) => { + const historyRefreshSettled = results[0]; + const sessionsRefreshSettled = results[1]; + const freshSessionsResult = host.sessionsResult; + const historySessionInfo = + historyRefreshSettled.status === "fulfilled" + ? historyRefreshSettled.value?.sessionInfo + : null; + const selectedSessionRow = findSelectedSessionRow( + host, + freshSessionsResult, + sessionKey, + historySessionInfo?.key, + ); + const historySessionKnownIdle = Boolean( + historySessionInfo && + isHistorySessionInfoForRequestedSession(host, historySessionInfo.key, sessionKey) && + !isSessionRunActive(historySessionInfo) && + !historyIdleProofIsStaleForSelectedRow(historySessionInfo, selectedSessionRow), + ); + const sessionsResultKnownIdle = freshSessionsResult + ? isSelectedSessionKnownIdle(freshSessionsResult, sessionKey) + : false; + if ( + sessionsRefreshSettled.status !== "fulfilled" || + host.chatQueue.length === 0 || + !areUiSessionKeysEquivalent(host.sessionKey, sessionKey) || + (!freshSessionsResult && !historySessionKnownIdle) || + (freshSessionsResult === previousSessionsResult && !historySessionKnownIdle) || + (host.sessionsError && !historySessionKnownIdle) || + !(historySessionKnownIdle || sessionsResultKnownIdle) + ) { + return; + } + flushQueue(); + }); +} + +function setChatError(host: ChatModelSettingsHost, error: string | null, requestUpdate = false) { + host.lastError = error; + host.chatError = error; + if (requestUpdate) { + host.requestUpdate?.(); + } +} + +function patchSessionRow( + host: ChatModelSettingsHost, + sessionKey: string, + patch: Partial, +) { + const current = host.sessionsResult; + if (!current) { + return; + } + host.sessionsResult = { + ...current, + sessions: current.sessions.map((row) => + row.key === sessionKey ? Object.assign({}, row, patch) : row, + ), + }; +} + +export async function switchChatFastMode( + host: ChatModelSettingsHost, + nextFastMode: "" | "on" | "off" | "auto", +) { + if (!host.client || !host.connected) { + return; + } + const targetSessionKey = host.sessionKey; + const activeRow = host.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey); + const previousFastMode = activeRow?.fastMode; + const next: FastMode | undefined = + nextFastMode === "" ? undefined : nextFastMode === "auto" ? "auto" : nextFastMode === "on"; + if (previousFastMode === next) { + return; + } + setChatError(host, null, true); + patchSessionRow(host, targetSessionKey, { fastMode: next }); + try { + await host.sessions.patch( + targetSessionKey, + { + fastMode: next ?? null, + }, + scopedAgentParamsForSession(host, targetSessionKey), + ); + await refreshCurrentChatSessionList(host); + patchSessionRow(host, targetSessionKey, { fastMode: next }); + } catch (err) { + patchSessionRow(host, targetSessionKey, { fastMode: previousFastMode }); + setChatError(host, `Failed to set speed: ${String(err)}`, true); + } +} + +export async function switchChatModel( + host: ChatModelSettingsHost, + nextModel: string, +): Promise { + if (!host.client || !host.connected) { + return false; + } + const currentOverride = resolveChatModelOverrideValue({ + chatModelCatalog: host.chatModelCatalog, + modelOverrides: host.sessions.state.modelOverrides, + sessionKey: host.sessionKey, + sessionsResult: host.sessionsResult ?? null, + }); + if (currentOverride === nextModel) { + return true; + } + const targetSessionKey = host.sessionKey; + const previousModelOverride = host.sessions.state.modelOverrides[targetSessionKey]; + setChatError(host, null, true); + const switchPromiseRef: { current?: Promise } = {}; + const clearPendingSwitch = () => { + if (host.chatModelSwitchPromises?.[targetSessionKey] === switchPromiseRef.current) { + const nextSwitches = { ...host.chatModelSwitchPromises }; + delete nextSwitches[targetSessionKey]; + host.chatModelSwitchPromises = nextSwitches; + } + }; + const switchPromise: Promise = (async () => { + try { + await host.sessions.patch( + targetSessionKey, + { + model: nextModel || null, + }, + scopedAgentParamsForSession(host, targetSessionKey), + ); + await host.onModelChanged?.(); + await refreshCurrentChatSessionList(host); + return true; + } catch (err) { + host.sessions.setModelOverride(targetSessionKey, previousModelOverride); + setChatError(host, `Failed to set model: ${String(err)}`, true); + return false; + } finally { + clearPendingSwitch(); + host.requestUpdate?.(); + } + })(); + switchPromiseRef.current = switchPromise; + host.chatModelSwitchPromises = { + ...host.chatModelSwitchPromises, + [targetSessionKey]: switchPromise, + }; + host.requestUpdate?.(); + return switchPromise; +} + +export async function switchChatThinkingLevel( + host: ChatModelSettingsHost, + nextThinkingLevel: string, +) { + if (!host.client || !host.connected) { + return; + } + const targetSessionKey = host.sessionKey; + const activeRow = host.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey); + const previousThinkingLevel = activeRow?.thinkingLevel; + const normalizedNext = + (normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined; + const normalizedPrev = + typeof previousThinkingLevel === "string" && previousThinkingLevel.trim() + ? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim()) + : undefined; + if ((normalizedPrev ?? "") === (normalizedNext ?? "")) { + return; + } + setChatError(host, null, true); + patchSessionRow(host, targetSessionKey, { thinkingLevel: normalizedNext }); + host.chatThinkingLevel = normalizedNext ?? null; + try { + await host.sessions.patch( + targetSessionKey, + { + thinkingLevel: normalizedNext ?? null, + }, + scopedAgentParamsForSession(host, targetSessionKey), + ); + await refreshCurrentChatSessionList(host); + patchSessionRow(host, targetSessionKey, { thinkingLevel: normalizedNext }); + host.chatThinkingLevel = normalizedNext ?? null; + } catch (err) { + patchSessionRow(host, targetSessionKey, { thinkingLevel: previousThinkingLevel }); + host.chatThinkingLevel = normalizedPrev ?? null; + setChatError(host, `Failed to set thinking level: ${String(err)}`, true); + } +} diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts new file mode 100644 index 000000000000..97fe3b79e2f7 --- /dev/null +++ b/ui/src/pages/chat/chat-state.ts @@ -0,0 +1,1264 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { GatewayBrowserClient, GatewayEventFrame } from "../../api/gateway.ts"; +import type { + AgentsListResult, + GatewaySessionRow, + ModelAuthStatusResult, + ModelCatalogEntry, + SessionsListResult, +} from "../../api/types.ts"; +import { + fetchAssistantIdentity, + loadLocalAssistantIdentity, +} from "../../app/assistant-identity.ts"; +import type { ApplicationContext } from "../../app/context.ts"; +import { resolveControlUiAuthToken } from "../../app/control-ui-auth.ts"; +import { + loadLocalUserIdentity, + loadSettings, + patchSettings, + type UiSettings, +} from "../../app/settings.ts"; +import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts"; +import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts"; +import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { loadModelAuthStatus } from "../../lib/model-auth.ts"; +import { scopedAgentParamsForSession, type SessionCapability } from "../../lib/sessions/index.ts"; +import { + readSessionChangedEvent, + type SessionChangedResult, +} from "../../lib/sessions/reconcile.ts"; +import { + areUiSessionKeysEquivalent, + isUiGlobalSessionKey, + normalizeAgentId, + parseAgentSessionKey, + resolveUiDefaultAgentId, + resolveUiGlobalAliasAgentId, + resolveUiSelectedGlobalAgentId, +} from "../../lib/sessions/session-key.ts"; +import { refreshChatAvatar, resolveAgentIdForSession } from "./chat-avatar.ts"; +import { applyRemoteSlashCommandsResult, refreshSlashCommands } from "./chat-commands.ts"; +import { + handleChatGatewayEvent, + handleChatSideResultGatewayEvent, + type ChatEventPayload, +} from "./chat-gateway.ts"; +import { + chatScopedEventSessionMatches, + loadChatHistory, + type ChatMetadataResult, + type ChatState, +} from "./chat-history.ts"; +import { clearPendingQueueItemsForRun, removeQueuedMessage } from "./chat-queue.ts"; +import { + attachChatRealtimeActions, + createInitialChatRealtimeState, + resetChatRealtimeConversation, + type ChatRealtimeState, +} from "./chat-realtime.ts"; +import type { ChatSendTimingEntry } from "./chat-send-contract.ts"; +import { recordChatSendServerTiming } from "./chat-send-timing.ts"; +import { + flushChatQueueForEvent, + handleSendChat, + retryQueuedChatMessage, + steerQueuedChatMessage, + type ChatHost, +} from "./chat-send.ts"; +import { + flushChatQueueAfterIdleSessionReconciliation, + refreshCurrentChatSessionList, +} from "./chat-session.ts"; +import type { ChatProps } from "./chat-view.ts"; +import { + clearSessionWorkspaceTimers, + type SessionWorkspaceHost, +} from "./components/chat-session-workspace.ts"; +import type { SidebarContent } from "./components/chat-sidebar.ts"; +import { + ChatComposerPersistenceController, + persistChatComposerState, + restoreChatComposerState, +} from "./composer-persistence.ts"; +import { + handleChatDraftChange, + handleChatInputHistoryKey, + resetChatInputHistoryNavigation, + type ChatInputHistoryKeyInput, + type ChatInputHistoryKeyResult, +} from "./input-history.ts"; +import { applyModelCatalogResult, loadModels } from "./models.ts"; +import { + handleAbortChat, + reconcileChatRunFromCurrentSessionRow, + reconcileChatRunFromSessionRow, + reconcileChatRunLifecycle, +} from "./run-lifecycle.ts"; +import { scheduleChatScroll, handleChatScroll, resetChatScroll } from "./scroll.ts"; +import { cacheChatMessages, readChatMessagesFromCache } from "./session-message-cache.ts"; +import { + handleAgentEvent, + handleSessionOperationEvent, + resetToolStream, + type CompactionStatus, + type FallbackStatus, + type ToolStreamEntry, +} from "./tool-stream.ts"; + +type ChatPageElement = { + querySelector: (selectors: string) => Element | null; + readonly updateComplete: Promise; +}; + +export type ChatPageHost = ChatHost & + ChatState & + ChatRealtimeState & + SessionWorkspaceHost & { + sessions: SessionCapability; + settings: UiSettings; + password: string; + onboarding: boolean; + assistantName: string; + assistantAvatar: string | null; + assistantAvatarStatus: "none" | "local" | "remote" | "data" | null; + assistantAvatarReason: string | null; + assistantAvatarSource: string | null; + assistantIdentityRequestVersion: number; + userName: string | null; + userAvatar: string | null; + localMediaPreviewRoots: string[]; + embedSandboxMode: EmbedSandboxMode; + allowExternalEmbedUrls: boolean; + chatMessageMaxWidth: string | null; + chatToolMessages: Record[]; + chatAttachments: ChatAttachment[]; + chatQueue: ChatQueueItem[]; + chatQueueBySession: Record; + chatMessagesBySession: Map; + basePath: string; + chatAvatarUrl: string | null; + chatAvatarSource: string | null; + chatAvatarStatus: "none" | "local" | "remote" | "data" | null; + chatAvatarReason: string | null; + chatSideResultTerminalRuns: Set; + chatModelSwitchPromises: Record>; + chatModelCatalog: ModelCatalogEntry[]; + modelAuthStatusResult: ModelAuthStatusResult | null; + modelAuthStatusError: string | null; + sessionsResult: SessionsListResult | null; + sessionsResultAgentId: string | null; + sessionsError: string | null; + sessionsShowArchived: boolean; + selectedChatSessionArchived: boolean; + agentsList: AgentsListResult | null; + agentsSelectedId: string | null; + refreshSessionsAfterChat: Map; + pendingAbort: { runId?: string | null; sessionKey: string; agentId?: string } | null; + pendingSessionMessageReloadSessionKey: string | null; + chatSubmitGuards: Map>; + chatSendTimingsByRun: Map; + chatStreamSegments: Array<{ text: string; ts: number }>; + toolStreamById: Map; + toolStreamOrder: string[]; + toolStreamSyncTimer: number | null; + compactionStatus: CompactionStatus | null; + fallbackStatus: FallbackStatus | null; + chatRunStatus: ChatProps["runStatus"]; + chatNewMessagesBelow: boolean; + chatManualRefreshInFlight: boolean; + chatModelsLoading: boolean; + chatMobileControlsOpen: boolean; + chatMobileControlsTrigger: HTMLElement | null; + sessionsHideCron: boolean; + sessionsLoading: boolean; + lastErrorCode: string | null; + chatLocalInputHistoryBySession: Record>; + chatInputHistorySessionKey: string | null; + chatInputHistoryItems: string[] | null; + chatInputHistoryIndex: number; + chatDraftBeforeHistory: string | null; + chatScrollFrame: number | null; + chatScrollTimeout: number | null; + chatLastScrollTop: number; + chatHasAutoScrolled: boolean; + chatUserNearBottom: boolean; + chatFollowLocked: boolean; + chatHeaderControlsHidden: boolean; + chatIsProgrammaticScroll: boolean; + chatProgrammaticScrollTarget: number; + sidebarOpen: boolean; + sidebarContent: SidebarContent | null; + splitRatio: number; + querySelector: (selectors: string) => Element | null; + updateComplete: Promise; + requestUpdate: () => void; + onModelChanged: () => Promise | void; + resetToolStream: () => void; + resetChatScroll: () => void; + resetChatInputHistoryNavigation: () => void; + scrollToBottom: (opts?: { smooth?: boolean }) => void; + setChatMobileControlsOpen: ( + open: boolean, + options?: { trigger?: HTMLElement | null; restoreFocus?: boolean }, + ) => void; + loadAssistantIdentity: () => Promise; + applySettings: (next: UiSettings) => void; + handleChatScroll: (event: Event) => void; + handleChatDraftChange: (next: string) => void; + handleChatInputHistoryKey: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; + handleSendChat: (messageOverride?: string, options?: unknown) => Promise; + handleAbortChat: (options?: unknown) => Promise; + removeQueuedMessage: (id: string) => void; + retryQueuedChatMessage: (id: string) => Promise; + steerQueuedChatMessage: (id: string) => Promise; + handleOpenSidebar: (content: Parameters[0]) => void; + handleCloseSidebar: () => void; + handleSplitRatioChange: (ratio: number) => void; + announceSessionSwitch?: (sessionKey: string, label: string) => void; + createChatSession?: () => Promise; + exportCurrentChat?: () => Promise | void; + refreshCurrentSessionTools?: () => Promise; + refreshCurrentChat?: () => Promise; + }; + +type PendingCreatedSessionComposer = { + sessionKey: string; + chatMessage: string; + chatAttachments: ChatAttachment[]; +}; + +export function canCreateChatSession( + state: Pick< + ChatPageHost, + "chatLoading" | "chatSending" | "chatRunId" | "chatStream" | "chatQueue" + >, +) { + return ( + !state.chatLoading && + !state.chatSending && + !state.chatRunId && + state.chatStream === null && + state.chatQueue.length === 0 + ); +} + +export async function handleChatManualRefresh(state: ChatPageHost): Promise { + state.chatManualRefreshInFlight = true; + state.chatNewMessagesBelow = false; + await state.updateComplete; + state.resetToolStream(); + try { + await Promise.allSettled([ + refreshPageChat(state, { awaitHistory: true, scheduleScroll: false }), + refreshChatModelAuthStatus(state, { refresh: true }), + ]); + state.scrollToBottom({ smooth: true }); + } finally { + requestAnimationFrame(() => { + state.chatManualRefreshInFlight = false; + state.chatNewMessagesBelow = false; + state.requestUpdate(); + }); + } +} + +export function resolveAssistantAttachmentAuthToken(state: ChatPageHost) { + return resolveControlUiAuthToken(state); +} + +export function dismissChatError(state: ChatPageHost) { + state.lastError = null; + state.lastErrorCode = null; + state.chatError = null; +} + +function saveChatQueueForSession(state: ChatPageHost, sessionKey: string) { + const queueBySession = state.chatQueueBySession; + if (state.chatQueue.length > 0) { + state.chatQueueBySession = { + ...queueBySession, + [sessionKey]: [...state.chatQueue], + }; + return; + } + if (!Object.hasOwn(queueBySession, sessionKey)) { + return; + } + const nextQueueBySession = { ...queueBySession }; + delete nextQueueBySession[sessionKey]; + state.chatQueueBySession = nextQueueBySession; +} + +function restoreChatQueueForSession(state: ChatPageHost, sessionKey: string): ChatQueueItem[] { + return [...(state.chatQueueBySession[sessionKey] ?? [])]; +} + +function saveChatMessagesForSession(state: ChatPageHost, sessionKey: string) { + cacheChatMessages(state.chatMessagesBySession, state, { sessionKey }, state.chatMessages); +} + +function restoreChatMessagesForSession(state: ChatPageHost, sessionKey: string): unknown[] { + return readChatMessagesFromCache(state.chatMessagesBySession, state, { sessionKey }); +} + +export function saveRouteSessionSettings(state: ChatPageHost, sessionKey: string) { + if ( + state.settings.sessionKey === sessionKey && + state.settings.lastActiveSessionKey === sessionKey + ) { + return; + } + state.settings = patchSettings({ + sessionKey, + lastActiveSessionKey: sessionKey, + }); +} + +export function resetChatStateForRouteSession(state: ChatPageHost, sessionKey: string) { + const previousSessionKey = state.sessionKey; + persistChatComposerState(state, previousSessionKey); + saveChatQueueForSession(state, previousSessionKey); + saveChatMessagesForSession(state, previousSessionKey); + state.sessionKey = sessionKey; + state.selectedChatSessionArchived = + state.sessionsResult?.sessions.some( + (row) => row.archived === true && areUiSessionKeysEquivalent(row.key, sessionKey), + ) === true; + state.currentSessionId = null; + state.reconnectResumeSessionId = null; + state.chatMessage = ""; + state.chatAttachments = []; + state.chatReplyTarget = null; + state.chatMessages = restoreChatMessagesForSession(state, sessionKey); + state.chatToolMessages = []; + state.chatStreamSegments = []; + state.chatThinkingLevel = null; + state.chatVerboseLevel = null; + state.chatStream = null; + state.chatSideResult = null; + state.lastError = null; + state.chatError = null; + state.chatAvatarUrl = null; + state.chatAvatarSource = null; + state.chatAvatarStatus = null; + state.chatAvatarReason = null; + state.realtimeTalkTranscript = null; + resetChatRealtimeConversation(state); + state.chatQueue = restoreChatQueueForSession(state, sessionKey); + restoreChatComposerState(state); + state.resetChatInputHistoryNavigation(); + state.chatStreamStartedAt = null; + reconcileChatRunLifecycle(state, { + clearLocalRun: true, + clearChatStream: true, + clearToolStream: true, + clearSideResultTerminalRuns: true, + clearRunStatus: true, + }); + state.resetChatScroll(); + saveRouteSessionSettings(state, sessionKey); +} + +export async function refreshRouteSessionOptions(state: ChatPageHost) { + await refreshCurrentChatSessionList(state); +} + +export function resolveChatAgentId( + state: Pick, +) { + return normalizeAgentId( + parseAgentSessionKey(state.sessionKey)?.agentId ?? + scopedAgentParamsForSession(state, state.sessionKey).agentId ?? + resolveUiSelectedGlobalAgentId(state), + ); +} + +export function resolveChatAvatarUrl( + state: Pick< + ChatPageHost, + | "sessionKey" + | "agentsList" + | "assistantAgentId" + | "hello" + | "assistantAvatar" + | "assistantAvatarStatus" + | "assistantAvatarReason" + | "chatAvatarUrl" + | "chatAvatarStatus" + | "chatAvatarReason" + >, +): string | null { + const agentId = resolveChatAgentId(state); + const localAvatar = loadLocalAssistantIdentity({ agentId }).avatar; + if (localAvatar) { + return localAvatar; + } + const avatarMissing = + (state.chatAvatarStatus ?? state.assistantAvatarStatus) === "none" && + (state.chatAvatarReason ?? state.assistantAvatarReason) === "missing"; + const assistantAvatar = state.assistantAvatar; + if (!avatarMissing && assistantAvatar && isRenderableControlUiAvatarUrl(assistantAvatar)) { + if (state.assistantAgentId === agentId) { + return assistantAvatar; + } + } + if (state.chatAvatarUrl) { + return state.chatAvatarUrl; + } + const agent = state.agentsList?.agents?.find((candidate) => candidate.id === agentId) as + | { identity?: { avatar?: string; avatarUrl?: string } } + | undefined; + const identity = agent?.identity; + const avatar = identity?.avatarUrl ?? identity?.avatar; + return typeof avatar === "string" && isRenderableControlUiAvatarUrl(avatar) ? avatar : null; +} + +type ChatMetadataApplyResult = { + commands: boolean; + models: boolean; +}; + +type ChatRefreshOptions = { + scheduleScroll?: boolean; + awaitHistory?: boolean; + startup?: boolean; +}; + +type ChatStartupMetadataHandler = (params: { + client: GatewayBrowserClient; + agentId: string | null | undefined; + metadata: ChatMetadataResult | undefined; +}) => void | Promise; + +function scheduleChatMetadataRefresh(callback: () => void) { + const requestIdleCallback = + typeof globalThis.requestIdleCallback === "function" ? globalThis.requestIdleCallback : null; + if (requestIdleCallback) { + requestIdleCallback(callback, { timeout: 750 }); + return; + } + globalThis.setTimeout(callback, 50); +} + +async function refreshChatModels(host: ChatPageHost) { + if (!host.client || !host.connected) { + host.chatModelsLoading = false; + host.chatModelCatalog = []; + return; + } + host.chatModelsLoading = true; + try { + host.chatModelCatalog = await loadModels(host.client); + } finally { + host.chatModelsLoading = false; + } +} + +export async function refreshChatCommands(host: ChatPageHost) { + await refreshSlashCommands({ + client: host.client, + agentId: resolveChatAgentId(host), + }); +} + +function applyChatMetadataResult( + host: ChatPageHost, + client: GatewayBrowserClient, + agentId: string | null | undefined, + result: ChatMetadataResult, +): ChatMetadataApplyResult { + const models = applyModelCatalogResult(result.models); + if (models) { + host.chatModelCatalog = models; + } + const commandsApplied = applyRemoteSlashCommandsResult({ + client, + agentId, + result, + }); + return { commands: commandsApplied, models: Boolean(models) }; +} + +async function refreshChatMetadata(host: ChatPageHost) { + if (!host.client || !host.connected) { + host.chatModelsLoading = false; + host.chatModelCatalog = []; + return; + } + const client = host.client; + const sessionKey = host.sessionKey; + const agentId = resolveChatAgentId(host); + if (isGatewayMethodAdvertised(host as unknown as ChatState, "chat.metadata") === false) { + await Promise.allSettled([refreshChatModels(host), refreshChatCommands(host)]); + return; + } + + host.chatModelsLoading = true; + try { + const result = await client.request( + "chat.metadata", + agentId ? { agentId } : {}, + ); + if ( + host.client !== client || + !host.connected || + host.sessionKey !== sessionKey || + resolveChatAgentId(host) !== agentId + ) { + return; + } + const metadataApplied = applyChatMetadataResult(host, client, agentId, result); + if (!metadataApplied.models || !metadataApplied.commands) { + await Promise.allSettled([ + ...(metadataApplied.models ? [] : [refreshChatModels(host)]), + ...(metadataApplied.commands ? [] : [refreshChatCommands(host)]), + ]); + } + } catch { + await Promise.allSettled([refreshChatModels(host), refreshChatCommands(host)]); + } finally { + if (host.client === client) { + host.chatModelsLoading = false; + } + } +} + +export async function refreshChatModelAuthStatus(host: ChatPageHost, opts?: { refresh?: boolean }) { + if (!host.client || !host.connected) { + return; + } + const client = host.client; + try { + const result = await loadModelAuthStatus(client, opts); + if (host.client !== client || !host.connected) { + return; + } + host.modelAuthStatusResult = result; + host.modelAuthStatusError = null; + } catch (err) { + if (host.client !== client || !host.connected) { + return; + } + host.modelAuthStatusResult = { ts: 0, providers: [] }; + host.modelAuthStatusError = err instanceof Error ? err.message : String(err); + } +} + +export async function refreshChat( + host: ChatPageHost, + opts?: ChatRefreshOptions & { + onStartupMetadata?: ChatStartupMetadataHandler; + }, +) { + const refreshedSessionKey = host.sessionKey; + const refreshedClient = host.client; + const refreshedAgentId = resolveAgentIdForSession(host); + const requestUpdate = () => host.requestUpdate?.(); + const previousSessionsResult = host.sessionsResult; + const historyLoad = loadChatHistory(host as unknown as ChatState, { + startup: opts?.startup === true, + }); + const historyRefresh = historyLoad.finally(() => { + if (opts?.scheduleScroll !== false) { + scheduleChatScroll(host); + } + requestUpdate(); + }); + const sessionsRefresh = historyLoad.then((history) => { + if (!history?.sessionInfo) { + return; + } + if (areUiSessionKeysEquivalent(history.sessionInfo.key, refreshedSessionKey)) { + host.selectedChatSessionArchived = history.sessionInfo.archived === true; + } + const reconciled = host.sessions.reconcile(history.sessionInfo, history.defaults, { + resultAgentId: host.sessionsResultAgentId ?? refreshedAgentId, + selectedGlobalAgentId: refreshedAgentId, + showArchived: host.sessionsShowArchived, + }); + const sessionsResult = reconciled ? host.sessions.state.result : host.sessionsResult; + if (reconciled) { + host.sessionsResult = sessionsResult; + } + const sessionInfo = sessionsResult?.sessions.find( + (row: GatewaySessionRow) => + areUiSessionKeysEquivalent(row.key, history.sessionInfo?.key) || + row.key === refreshedSessionKey, + ); + if (!sessionInfo) { + return; + } + const runReconciled = reconcileChatRunFromSessionRow(host, sessionInfo, { + publishRunStatus: true, + }); + if (!runReconciled) { + reconcileChatRunFromCurrentSessionRow(host, { publishRunStatus: true }); + } + }); + const startupMetadataRefresh = + opts?.startup === true && opts.onStartupMetadata && refreshedClient + ? historyLoad.then((history) => { + if ( + host.client !== refreshedClient || + !host.connected || + host.sessionKey !== refreshedSessionKey || + resolveAgentIdForSession(host) !== refreshedAgentId + ) { + return; + } + return opts.onStartupMetadata?.({ + client: refreshedClient, + agentId: refreshedAgentId, + metadata: history?.metadata, + }); + }) + : Promise.resolve(); + flushChatQueueAfterIdleSessionReconciliation( + host, + refreshedSessionKey, + historyRefresh, + sessionsRefresh, + previousSessionsResult, + () => void flushChatQueueForEvent(host), + ); + const secondaryRefresh = Promise.allSettled([sessionsRefresh, startupMetadataRefresh]).finally( + requestUpdate, + ); + void historyRefresh; + void secondaryRefresh; + if (opts?.awaitHistory === true) { + await historyRefresh; + return; + } + await Promise.resolve(); +} + +export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) { + let resolveStartupMetadata: (result: ChatMetadataApplyResult) => void = () => {}; + const startupMetadataApplied = + opts?.startup && host.client && host.connected + ? new Promise((resolve) => { + resolveStartupMetadata = resolve; + }) + : Promise.resolve({ commands: false, models: false }); + + const refresh = refreshChat(host, { + ...opts, + onStartupMetadata: ({ client, agentId, metadata }) => { + const applied = metadata + ? applyChatMetadataResult(host, client, agentId, metadata) + : { commands: false, models: false }; + resolveStartupMetadata(applied); + }, + }); + + const refreshedSessionKey = host.sessionKey; + scheduleChatMetadataRefresh(() => { + if (host.sessionKey !== refreshedSessionKey || !host.connected) { + return; + } + void startupMetadataApplied + .catch(() => ({ commands: false, models: false })) + .then((metadataApplied) => { + const metadataRefresh = + opts?.startup && (metadataApplied.commands || metadataApplied.models) + ? metadataApplied.models + ? Promise.allSettled([]) + : Promise.allSettled([refreshChatModels(host)]) + : Promise.allSettled([refreshChatMetadata(host)]); + return Promise.allSettled([refreshChatAvatar(host), metadataRefresh]); + }) + .finally(() => host.requestUpdate?.()); + }); + return refresh; +} + +function sessionMessageMatchesChat( + state: ChatPageHost, + event: NonNullable>, +): boolean { + return chatScopedEventSessionMatches(state, event.key, event.agentId ?? undefined); +} + +function selectedGlobalEventAgentId(state: ChatPageHost, agentId: string | null): string { + return agentId ? normalizeAgentId(agentId) : resolveUiDefaultAgentId(state); +} + +function globalSessionEventMatchesChat( + state: ChatPageHost, + event: NonNullable>, +): boolean { + if (!isUiGlobalSessionKey(event.key)) { + return true; + } + const selectedAgentId = isUiGlobalSessionKey(state.sessionKey) + ? resolveUiSelectedGlobalAgentId(state) + : resolveUiGlobalAliasAgentId(state, state.sessionKey); + return selectedAgentId + ? selectedGlobalEventAgentId(state, event.agentId) === selectedAgentId + : true; +} + +function reconcileSessionEvent(state: ChatPageHost, payload: unknown): SessionChangedResult { + const selectedAgentId = resolveChatAgentId(state); + const reconciled = state.sessions.reconcileChanged(payload, { + resultAgentId: state.sessionsResultAgentId ?? selectedAgentId, + selectedGlobalAgentId: selectedAgentId, + showArchived: state.sessionsShowArchived, + }); + if (reconciled.applied) { + state.sessionsResult = state.sessions.state.result; + state.sessionsResultAgentId = state.sessions.state.agentId; + state.sessionsError = state.sessions.state.error; + } + return reconciled; +} + +function finishSessionMessageRunReconcile( + state: ChatPageHost, + sessionKey: string, + runId: string | null, + row: SessionChangedResult["row"] | undefined, +): boolean { + const cleared = row + ? reconcileChatRunFromSessionRow(state, row, { publishRunStatus: true }) + : reconcileChatRunFromCurrentSessionRow(state, { publishRunStatus: true }); + if (!cleared) { + return false; + } + clearPendingQueueItemsForRun(state, runId ?? undefined); + void loadChatHistory(state) + .finally(() => { + if (!areUiSessionKeysEquivalent(state.sessionKey, sessionKey)) { + return; + } + void flushChatQueueForEvent(state); + state.requestUpdate?.(); + }) + .catch(() => undefined); + return true; +} + +function handleSessionMessageEvent(state: ChatPageHost, payload: unknown) { + const event = readSessionChangedEvent(payload); + if (!event || !globalSessionEventMatchesChat(state, event)) { + return; + } + const matchesChat = sessionMessageMatchesChat(state, event); + if (matchesChat && event.archived !== null) { + state.selectedChatSessionArchived = event.archived; + } + const runIdBeforeApply = state.chatRunId; + const result = reconcileSessionEvent(state, payload); + if (runIdBeforeApply && matchesChat) { + const runId = event.clientRunId ?? event.runId ?? runIdBeforeApply; + state.pendingSessionMessageReloadSessionKey = event.key; + if (event.hasActiveRun === true) { + return; + } + if (finishSessionMessageRunReconcile(state, event.key, runId, result.row)) { + state.pendingSessionMessageReloadSessionKey = null; + return; + } + void refreshCurrentChatSessionList(state).then(() => { + if (!state.pendingSessionMessageReloadSessionKey || state.chatRunId !== runIdBeforeApply) { + return; + } + if ( + finishSessionMessageRunReconcile( + state, + state.pendingSessionMessageReloadSessionKey, + runId, + undefined, + ) + ) { + state.pendingSessionMessageReloadSessionKey = null; + } + }); + return; + } + if (matchesChat) { + state.pendingSessionMessageReloadSessionKey = null; + void loadChatHistory(state).finally(() => state.requestUpdate?.()); + } +} + +function replayPendingSessionMessageReload( + state: ChatPageHost, + payload: ChatEventPayload | undefined, +) { + const pendingSessionKey = state.pendingSessionMessageReloadSessionKey; + const payloadSessionKey = payload?.sessionKey?.trim(); + if ( + !pendingSessionKey || + !payloadSessionKey || + !areUiSessionKeysEquivalent(pendingSessionKey, payloadSessionKey) || + !areUiSessionKeysEquivalent(payloadSessionKey, state.sessionKey) || + state.chatRunId + ) { + return; + } + state.pendingSessionMessageReloadSessionKey = null; + void loadChatHistory(state).finally(() => state.requestUpdate?.()); +} + +function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) { + const runIdBeforeApply = state.chatRunId; + const event = readSessionChangedEvent(payload); + if ( + event && + globalSessionEventMatchesChat(state, event) && + sessionMessageMatchesChat(state, event) && + event.archived !== null + ) { + state.selectedChatSessionArchived = event.archived; + } + const result = reconcileSessionEvent(state, payload); + if ( + result.applied && + event && + runIdBeforeApply && + sessionMessageMatchesChat(state, event) && + finishSessionMessageRunReconcile( + state, + event.key, + event.clientRunId ?? event.runId ?? runIdBeforeApply, + result.row, + ) + ) { + return; + } + if (!result.applied && event?.isChatTurn !== true) { + void refreshCurrentChatSessionList(state); + } +} + +async function loadPageAssistantIdentity( + state: ChatPageHost, + opts?: { sessionKey?: string; expectedSessionKey?: string }, +) { + if (!state.client || !state.connected) { + return; + } + const client = state.client; + const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim(); + const expectedSessionKey = opts?.expectedSessionKey?.trim() || sessionKey; + const requestVersion = ++state.assistantIdentityRequestVersion; + try { + const identity = await fetchAssistantIdentity(client, sessionKey); + if ( + state.client !== client || + !state.connected || + state.assistantIdentityRequestVersion !== requestVersion || + state.sessionKey.trim() !== expectedSessionKey || + !identity + ) { + return; + } + state.assistantName = identity.name; + state.assistantAvatar = identity.avatar; + state.assistantAvatarSource = identity.avatarSource ?? null; + state.assistantAvatarStatus = identity.avatarStatus ?? null; + state.assistantAvatarReason = identity.avatarReason ?? null; + state.assistantAgentId = identity.agentId ?? null; + state.requestUpdate?.(); + } catch { + // Keep the last known identity when the Gateway cannot answer. + } +} + +export function createPageState( + context: ApplicationContext, + requestUpdate: () => void, + page: ChatPageElement, +): ChatPageHost { + const settings = loadSettings(); + const identity = loadLocalUserIdentity(); + const appConfig = context.config.current; + const state = { + sessions: context.sessions, + settings, + password: "", + onboarding: false, + assistantName: appConfig.assistantIdentity.name, + assistantAvatar: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, + assistantAvatarSource: null, + assistantIdentityRequestVersion: 0, + userName: identity.name, + userAvatar: identity.avatar, + localMediaPreviewRoots: appConfig.localMediaPreviewRoots, + embedSandboxMode: appConfig.embedSandboxMode, + allowExternalEmbedUrls: appConfig.allowExternalEmbedUrls, + chatMessageMaxWidth: appConfig.chatMessageMaxWidth, + client: null, + connected: false, + hello: null, + assistantAgentId: context.agentSelection.state.selectedId, + sessionKey: settings.sessionKey, + chatLoading: false, + chatSending: false, + chatMessage: "", + chatMessages: [] as unknown[], + chatToolMessages: [] as Record[], + chatThinkingLevel: null, + chatVerboseLevel: null, + chatAttachments: [] as ChatAttachment[], + chatRunId: null, + chatStream: null, + chatStreamStartedAt: null, + lastError: null, + chatError: null, + agentsError: null, + chatStreamSegments: [] as Array<{ text: string; ts: number }>, + chatSideResult: null, + chatSideResultTerminalRuns: new Set(), + chatRunStatus: null, + compactionStatus: null, + fallbackStatus: null, + chatAvatarUrl: null, + chatAvatarStatus: null, + chatAvatarReason: null, + chatModelSwitchPromises: {} as Record>, + chatModelsLoading: false, + chatModelCatalog: [] as ModelCatalogEntry[], + modelAuthStatusResult: null, + modelAuthStatusError: null, + sessionsResult: null, + sessionsResultAgentId: null, + sessionsLoading: false, + sessionsError: null, + sessionsShowArchived: false, + selectedChatSessionArchived: false, + agentsList: context.agents.state.agentsList, + agentsSelectedId: context.agentSelection.state.selectedId, + onAgentsList: (agentsList: AgentsListResult, client: GatewayBrowserClient) => { + context.agents.adoptList(agentsList, client); + }, + refreshSessionsAfterChat: new Map(), + pendingAbort: null, + pendingSessionMessageReloadSessionKey: null, + chatSubmitGuards: new Map>(), + chatSendTimingsByRun: new Map(), + chatQueue: [] as ChatQueueItem[], + chatQueueBySession: {} as Record, + chatMessagesBySession: new Map(), + eventLogBuffer: [] as unknown[], + basePath: context.basePath, + chatNewMessagesBelow: false, + chatManualRefreshInFlight: false, + chatMobileControlsOpen: false, + chatMobileControlsTrigger: null, + sessionsHideCron: true, + chatLocalInputHistoryBySession: {} as Record>, + chatInputHistorySessionKey: null, + chatInputHistoryItems: null, + chatInputHistoryIndex: -1, + chatDraftBeforeHistory: null, + chatScrollFrame: null, + chatScrollTimeout: null, + chatLastScrollTop: 0, + chatHasAutoScrolled: false, + chatUserNearBottom: true, + chatFollowLocked: false, + chatHeaderControlsHidden: false, + chatIsProgrammaticScroll: false, + chatProgrammaticScrollTarget: 0, + sidebarOpen: false, + sidebarContent: null, + splitRatio: settings.splitRatio, + toolStreamById: new Map(), + toolStreamOrder: [] as string[], + toolStreamSyncTimer: null, + ...createInitialChatRealtimeState(), + requestUpdate, + sessionWorkspaceState: undefined, + sessionWorkspaceOpenRequest: undefined, + querySelector: page.querySelector.bind(page), + } as unknown as ChatPageHost; + Object.defineProperty(state, "updateComplete", { + configurable: true, + enumerable: false, + get: () => page.updateComplete, + }); + + state.resetToolStream = () => resetToolStream(state as never); + state.onModelChanged = () => undefined; + state.resetChatInputHistoryNavigation = () => resetChatInputHistoryNavigation(state); + state.resetChatScroll = () => resetChatScroll(state); + state.scrollToBottom = (options) => { + resetChatScroll(state); + scheduleChatScroll(state, true, Boolean(options?.smooth), { source: "manual" }); + }; + state.handleChatScroll = (event) => handleChatScroll(state, event); + state.handleChatDraftChange = (next) => handleChatDraftChange(state, next); + state.handleChatInputHistoryKey = (input) => handleChatInputHistoryKey(state, input); + state.applySettings = (next) => { + state.settings = patchSettings({ + chatShowThinking: next.chatShowThinking, + chatShowToolCalls: next.chatShowToolCalls, + chatPersistCommentary: next.chatPersistCommentary, + chatAutoScroll: next.chatAutoScroll, + splitRatio: next.splitRatio, + }); + state.splitRatio = state.settings.splitRatio; + requestUpdate(); + }; + state.setChatMobileControlsOpen = (open, options) => { + if (open) { + state.chatMobileControlsTrigger = options?.trigger ?? state.chatMobileControlsTrigger; + state.chatMobileControlsOpen = true; + requestUpdate(); + return; + } + const focusTarget = options?.restoreFocus ? state.chatMobileControlsTrigger : null; + state.chatMobileControlsOpen = false; + state.chatMobileControlsTrigger = null; + requestUpdate(); + if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) { + return; + } + requestAnimationFrame(() => { + if (focusTarget.isConnected) { + focusTarget.focus(); + } + }); + }; + attachChatRealtimeActions(state); + state.loadAssistantIdentity = async () => { + await loadPageAssistantIdentity(state); + }; + state.handleSendChat = (messageOverride, options) => + handleSendChat(state, messageOverride, options as never); + state.handleAbortChat = async (options) => { + await handleAbortChat(state, options as never); + requestUpdate(); + }; + state.removeQueuedMessage = (id) => { + removeQueuedMessage(state, id); + requestUpdate(); + }; + state.retryQueuedChatMessage = async (id) => { + await retryQueuedChatMessage(state, id); + requestUpdate(); + }; + state.steerQueuedChatMessage = async (id) => { + await steerQueuedChatMessage(state, id); + requestUpdate(); + }; + state.handleOpenSidebar = (content) => { + state.sidebarContent = content; + state.sidebarOpen = true; + requestUpdate(); + }; + state.handleCloseSidebar = () => { + state.sidebarOpen = false; + requestUpdate(); + }; + state.handleSplitRatioChange = (ratio) => { + const next = Math.max(0.4, Math.min(0.7, ratio)); + state.applySettings({ ...state.settings, splitRatio: next }); + }; + return state; +} + +export function handlePageGatewayEvent(state: ChatPageHost, event: GatewayEventFrame) { + if (event.event === "chat") { + handleChatGatewayEvent( + state as unknown as ChatState, + event.payload as ChatEventPayload | undefined, + ); + replayPendingSessionMessageReload(state, event.payload as ChatEventPayload | undefined); + requestPageUpdate(state); + return; + } + if (event.event === "chat.side_result") { + if (handleChatSideResultGatewayEvent(state as unknown as ChatState, event.payload)) { + requestPageUpdate(state); + } + return; + } + if (event.event === "agent" || event.event === "session.tool") { + handleAgentEvent(state as never, event.payload as never); + requestPageUpdate(state); + return; + } + if (event.event === "session.operation") { + handleSessionOperationEvent(state as never, event.payload as never); + requestPageUpdate(state); + return; + } + if (event.event === "chat.send_timing") { + recordChatSendServerTiming(state, event.payload); + return; + } + if (event.event === "session.message") { + handleSessionMessageEvent(state, event.payload); + requestPageUpdate(state); + return; + } + if (event.event === "sessions.changed") { + handleSessionsChangedEvent(state, event.payload); + requestPageUpdate(state); + } +} + +function requestPageUpdate(state: ChatPageHost) { + state.requestUpdate?.(); +} + +export class ChatStateController implements ReactiveController { + private readonly composerPersistence: ChatComposerPersistenceController; + private stateValue: TState | undefined; + private previousChatLoading = false; + private previousChatMessages: unknown[] = []; + private previousChatToolMessages: Record[] = []; + private previousChatStream: string | null = null; + private previousRealtimeConversation: ChatPageHost["realtimeTalkConversation"] = []; + private scrollAfterUpdate = false; + private forceScrollAfterUpdate = false; + private pendingCreatedSessionComposer: PendingCreatedSessionComposer | null = null; + private readonly cleanups: Array<() => void> = []; + + constructor(private readonly host: ReactiveControllerHost) { + host.addController(this); + this.composerPersistence = new ChatComposerPersistenceController(host, () => this.stateValue); + } + + get state(): TState | undefined { + return this.stateValue; + } + + attach(state: TState) { + this.stateValue = state; + this.previousChatLoading = state.chatLoading; + this.previousChatMessages = state.chatMessages; + this.previousChatToolMessages = state.chatToolMessages; + this.previousChatStream = state.chatStream; + this.previousRealtimeConversation = state.realtimeTalkConversation; + state.requestUpdate = this.requestUpdate; + const sendChat = state.handleSendChat; + state.handleSendChat = async (messageOverride, options) => { + const pending = sendChat(messageOverride, options); + this.requestUpdate(); + try { + await pending; + } finally { + this.requestUpdate(); + } + }; + const commitDraftChange = state.handleChatDraftChange; + state.handleChatDraftChange = (next) => { + commitDraftChange(next); + this.composerPersistence.schedule(); + }; + } + + addCleanup(cleanup: () => void) { + this.cleanups.push(cleanup); + } + + readonly requestUpdate = () => { + this.composerPersistence.persistQueueIfChanged(); + this.captureRenderLifecycleChanges(); + this.host.requestUpdate(); + }; + + private captureRenderLifecycleChanges() { + const state = this.stateValue; + if (!state) { + return; + } + const messagesChanged = + this.previousChatMessages !== state.chatMessages || + this.previousChatToolMessages !== state.chatToolMessages || + this.previousRealtimeConversation !== state.realtimeTalkConversation; + const streamChanged = this.previousChatStream !== state.chatStream; + const loadingChanged = this.previousChatLoading !== state.chatLoading; + const loadFinished = this.previousChatLoading && !state.chatLoading; + const streamStarted = this.previousChatStream == null && typeof state.chatStream === "string"; + this.previousChatLoading = state.chatLoading; + this.previousChatMessages = state.chatMessages; + this.previousChatToolMessages = state.chatToolMessages; + this.previousChatStream = state.chatStream; + this.previousRealtimeConversation = state.realtimeTalkConversation; + if (!messagesChanged && !streamChanged && !loadingChanged) { + return; + } + this.scrollAfterUpdate = true; + this.forceScrollAfterUpdate ||= loadFinished || streamStarted || !state.chatHasAutoScrolled; + } + + hostUpdated() { + if (!this.scrollAfterUpdate) { + return; + } + const state = this.stateValue; + const force = this.forceScrollAfterUpdate; + this.scrollAfterUpdate = false; + this.forceScrollAfterUpdate = false; + if (!state || state.chatManualRefreshInFlight) { + return; + } + scheduleChatScroll(state, force); + } + + restoreComposer(options: { preserveCurrent?: boolean } = {}) { + this.composerPersistence.restore(options); + } + + startComposerPersistence() { + this.composerPersistence.start(); + } + + captureCreatedSessionComposer(sessionKey: string) { + const state = this.stateValue; + if (!state) { + return; + } + this.pendingCreatedSessionComposer = { + sessionKey, + chatMessage: state.chatMessage, + chatAttachments: state.chatAttachments, + }; + } + + restoreCreatedSessionComposer(sessionKey: string | null | undefined): boolean { + const state = this.stateValue; + const pending = this.pendingCreatedSessionComposer; + if (!state || !pending || pending.sessionKey !== sessionKey) { + return false; + } + this.pendingCreatedSessionComposer = null; + state.chatMessage = pending.chatMessage; + state.chatAttachments = pending.chatAttachments; + this.composerPersistence.persistNow(); + return true; + } + + private stopChatEffects() { + while (this.cleanups.length > 0) { + this.cleanups.pop()?.(); + } + const state = this.stateValue; + if (state) { + clearSessionWorkspaceTimers(state); + } + state?.realtimeTalkSession?.stop(); + if (state) { + state.realtimeTalkSession = null; + state.resetToolStream?.(); + } + } + + hostDisconnected() { + this.stopChatEffects(); + this.stateValue = undefined; + this.scrollAfterUpdate = false; + this.forceScrollAfterUpdate = false; + this.pendingCreatedSessionComposer = null; + } +} diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/pages/chat/chat-thread.test.ts similarity index 93% rename from ui/src/ui/chat/build-chat-items.test.ts rename to ui/src/pages/chat/chat-thread.test.ts index e4dd8a13dc7b..26c13f6dd8d8 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -1,7 +1,14 @@ // Control UI tests cover build chat items behavior. import { describe, expect, it } from "vitest"; -import type { MessageGroup } from "../types/chat-types.ts"; -import { buildChatItems, type BuildChatItemsProps } from "./build-chat-items.ts"; +import type { MessageGroup } from "../../lib/chat/chat-types.ts"; +import { + buildCachedChatItems, + buildChatItems, + getExpandedToolCards, + resetChatThreadState, + syncToolCardExpansionState, + type BuildChatItemsProps, +} from "./chat-thread.ts"; const SENDER_METADATA_BLOCK = 'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui","id":"openclaw-control-ui"}\n```'; @@ -110,6 +117,35 @@ describe("buildChatItems", () => { expect(groups.map((group) => group.senderLabel)).toEqual([null, "Forwarded from main"]); }); + it("marks earlier tool groups as succeeded when the same turn has an assistant reply", () => { + const groups = messageGroups({ + messages: [ + { role: "user", content: "search", timestamp: 1000 }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "web_search", + isError: true, + content: JSON.stringify({ error: "No matches" }), + timestamp: 1001, + }, + { role: "assistant", content: "I found another route.", timestamp: 1002 }, + { role: "user", content: "again", timestamp: 1003 }, + { + role: "toolResult", + toolCallId: "call-2", + toolName: "web_search", + isError: true, + content: JSON.stringify({ error: "No matches" }), + timestamp: 1004, + }, + ], + }); + + const toolGroups = groups.filter((group) => group.role === "tool"); + expect(toolGroups.map((group) => group.turnSucceeded)).toEqual([true, false]); + }); + it("keeps empty forwarded assistant display groups", () => { const groups = messageGroups({ messages: [ @@ -1169,6 +1205,56 @@ describe("buildChatItems", () => { }); }); +describe("tool expansion state", () => { + it("expands already-visible tool cards when auto-expand turns on", () => { + resetChatThreadState(); + const group: MessageGroup = { + kind: "group", + key: "assistant-1", + role: "assistant", + messages: [ + { + key: "assistant-1", + message: { + role: "assistant", + content: [ + { + type: "toolcall", + id: "call-1", + name: "browser.open", + arguments: { url: "https://example.com" }, + }, + ], + }, + }, + ], + timestamp: 1, + isStreaming: false, + }; + + syncToolCardExpansionState("main", [group], false); + expect(getExpandedToolCards("main").get("assistant-1:toolcard:0")).toBe(false); + + syncToolCardExpansionState("main", [group], true); + expect(getExpandedToolCards("main").get("assistant-1:toolcard:0")).toBe(true); + }); +}); + +describe("thread item cache", () => { + it("reuses transcript items when thread inputs keep the same references", () => { + resetChatThreadState(); + const messages = [{ role: "assistant", content: "ready" }]; + const toolMessages: unknown[] = []; + const streamSegments: BuildChatItemsProps["streamSegments"] = []; + const queue: NonNullable = []; + const input = createProps({ messages, toolMessages, streamSegments, queue }); + + const first = buildCachedChatItems(input); + expect(buildCachedChatItems({ ...input })).toBe(first); + expect(buildCachedChatItems({ ...input, messages: [...messages] })).not.toBe(first); + }); +}); + function canvasBlocksIn(group: MessageGroup): unknown[] { return firstMessageContent(group).filter((block) => isCanvasBlock(block)); } diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/pages/chat/chat-thread.ts similarity index 78% rename from ui/src/ui/chat/build-chat-items.ts rename to ui/src/pages/chat/chat-thread.ts index aaf8810a24bb..e64f354d735f 100644 --- a/ui/src/ui/chat/build-chat-items.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -1,22 +1,35 @@ -// Control UI chat module implements build chat items behavior. -import type { ChatItem, MessageGroup, NormalizedMessage, ToolCard } from "../types/chat-types.ts"; -import type { ChatQueueItem } from "../ui-types.ts"; +// Control UI chat module owns Chat thread item derivation and thread-local caches. +import type { + ChatItem, + MessageGroup, + NormalizedMessage, + ToolCard, +} from "../../lib/chat/chat-types.ts"; import { - isAssistantHeartbeatAckForDisplay, - stripHeartbeatTokenForDisplay, -} from "./heartbeat-display.ts"; -import { CHAT_HISTORY_RENDER_CHAR_BUDGET, CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts"; -import { extractTextCached } from "./message-extract.ts"; -import { normalizeMessage, stripMessageDisplayMetadataText } from "./message-normalizer.ts"; -import { normalizeRoleForGrouping } from "./role-normalizer.ts"; -import { messageMatchesSearchQuery } from "./search-match.ts"; + CHAT_HISTORY_RENDER_CHAR_BUDGET, + CHAT_HISTORY_RENDER_LIMIT, +} from "../../lib/chat/chat-types.ts"; import { streamSegmentHasItemId, streamSegmentUsesAccumulatedText, trimAccumulatedStreamPrefix, type ChatStreamSegment, -} from "./stream-text.ts"; -import { extractToolCardsCached, extractToolPreview } from "./tool-cards.ts"; +} from "../../lib/chat/chat-types.ts"; +import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { + isAssistantHeartbeatAckForDisplay, + stripHeartbeatTokenForDisplay, +} from "../../lib/chat/heartbeat-display.ts"; +import { extractTextCached } from "../../lib/chat/message-extract.ts"; +import { + isToolResultMessage, + normalizeMessage, + stripMessageDisplayMetadataText, +} from "../../lib/chat/message-normalizer.ts"; +import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts"; +import { extractToolCardsCached, extractToolPreview } from "../../lib/chat/tool-cards.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { getOrCreateSessionCacheValue } from "./session-cache.ts"; import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; export type BuildChatItemsProps = { @@ -33,6 +46,30 @@ export type BuildChatItemsProps = { historyRenderLimit?: number; }; +type CachedChatItems = { + input: BuildChatItemsProps | null; + items: ReturnType; +}; + +export type RenderChatItem = ReturnType[number]; +export type StreamRunRenderItem = { + kind: "stream-run"; + key: string; + parts: Array>; +}; + +const chatItemsBySession = new Map(); +const expandedToolCardsBySession = new Map>(); +const initializedToolCardsBySession = new Map>(); +const lastAutoExpandPrefBySession = new Map(); + +export function resetChatThreadState(): void { + chatItemsBySession.clear(); + expandedToolCardsBySession.clear(); + initializedToolCardsBySession.clear(); + lastAutoExpandPrefBySession.clear(); +} + function appendCanvasBlockToAssistantMessage( message: unknown, preview: Extract, { kind: "canvas" }>, @@ -94,6 +131,15 @@ function safeNormalizeMessage(message: unknown): NormalizedMessage | null { } } +function messageMatchesSearchQuery(message: unknown, query: string): boolean { + const normalizedQuery = normalizeLowercaseStringOrEmpty(query); + if (!normalizedQuery) { + return true; + } + const text = normalizeLowercaseStringOrEmpty(extractTextCached(message)); + return text.includes(normalizedQuery); +} + function extractChatMessagePreview(toolMessage: unknown): { preview: Extract, { kind: "canvas" }>; text: string | null; @@ -237,6 +283,33 @@ function groupMessages(items: ChatItem[]): Array { return result; } +function assistantGroupHasReplyText(group: MessageGroup): boolean { + return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim())); +} + +function annotateToolTurnOutcome( + items: Array, +): Array { + let sawAssistantReply = false; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind !== "group") { + continue; + } + const role = item.role.toLowerCase(); + if (role === "user") { + sawAssistantReply = false; + } else if (role === "assistant") { + if (assistantGroupHasReplyText(item)) { + sawAssistantReply = true; + } + } else if (role === "tool") { + item.turnSucceeded = sawAssistantReply; + } + } + return items; +} + function isPendingSendMessage(message: unknown): boolean { return asRecord(asRecord(message)?.["__openclaw"])?.kind === "pending-send"; } @@ -343,41 +416,6 @@ function isSameSourceRelayNativeDuplicate(previousMessage: unknown, nextMessage: ); } -function assistantGroupHasReplyText(group: MessageGroup): boolean { - // A real reply is assistant text; a tool-only assistant group does not count. - return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim())); -} - -// Stamp each tool group with whether its turn ended in a successful assistant -// reply. Codex marks any non-zero exec exit as failed, so a benign internal tool -// failure (e.g. a no-match search) must not render as a primary error banner -// once a clean reply exists. Backward pass: a user group ends the turn -// downstream; an assistant reply marks success for earlier tool groups in the -// same turn. turnSucceeded stays undefined for terminal or in-progress failures, -// preserving the existing error banner. -function annotateToolTurnOutcome( - items: Array, -): Array { - let sawAssistantReply = false; - for (let i = items.length - 1; i >= 0; i -= 1) { - const item = items[i]; - if (item.kind !== "group") { - continue; - } - const role = item.role.toLowerCase(); - if (role === "user") { - sawAssistantReply = false; - } else if (role === "assistant") { - if (assistantGroupHasReplyText(item)) { - sawAssistantReply = true; - } - } else if (role === "tool") { - item.turnSucceeded = sawAssistantReply; - } - } - return items; -} - function collapseDuplicateDisplaySignature(message: unknown): string | null { if (isPendingSendMessage(message)) { return null; @@ -871,7 +909,17 @@ export function buildChatItems(props: BuildChatItemsProps): Array item.sendState === "sending" && shouldRenderQueuedSendInThread(item), + ); + if (hasPendingResponse) { + items.push({ + kind: "reading-indicator", + key: `stream:${props.sessionKey}:pending`, + }); + } else if (props.stream !== null) { const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`; const text = sanitizeStreamText(props.stream); const visibleText = trimAccumulatedStreamPrefix(text, previousAccumulatedStreamText); @@ -896,6 +944,147 @@ export function buildChatItems(props: BuildChatItemsProps): Array { + const cached = getOrCreateSessionCacheValue(chatItemsBySession, input.sessionKey, () => ({ + input: null, + items: [], + })); + if (cached.input && sameChatItemsInput(cached.input, input)) { + return cached.items; + } + const items = buildChatItems(input); + cached.input = input; + cached.items = items; + return items; +} + +export function coalesceStreamRuns( + items: ReturnType, +): Array { + const result: Array = []; + let run: StreamRunRenderItem["parts"] = []; + // Contiguous in-flight stream and reading-indicator items render under one + // assistant avatar; messages, groups, and dividers intentionally break the run. + const flush = () => { + const [first] = run; + if (first) { + result.push({ kind: "stream-run", key: `stream-run:${first.key}`, parts: run }); + run = []; + } + }; + for (const item of items) { + if (item.kind === "stream" || item.kind === "reading-indicator") { + run.push(item); + continue; + } + flush(); + result.push(item); + } + flush(); + return result; +} + +export function deletedChatItemsSignature( + deleted: { has: (key: string) => boolean }, + chatItems: ReturnType, +): string { + const deletedKeys = chatItems + .map((item) => item.key) + .filter((key) => deleted.has(key)) + .toSorted(); + return deletedKeys.length === 0 ? "" : deletedKeys.join("\u0000"); +} + +export function stableBooleanMapSignature(values: ReadonlyMap): string { + if (values.size === 0) { + return ""; + } + return Array.from(values) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}:${value ? "1" : "0"}`) + .join("\u0000"); +} + +export function getExpandedToolCards(sessionKey: string): Map { + return getOrCreateSessionCacheValue(expandedToolCardsBySession, sessionKey, () => new Map()); +} + +function getInitializedToolCards(sessionKey: string): Set { + return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set()); +} + +export function syncToolCardExpansionState( + sessionKey: string, + items: Array, + autoExpandToolCalls: boolean, +): void { + const expanded = getExpandedToolCards(sessionKey); + const initialized = getInitializedToolCards(sessionKey); + const previousAutoExpand = lastAutoExpandPrefBySession.get(sessionKey) ?? false; + const currentToolCardIds = new Set(); + for (const item of items) { + if (item.kind !== "group") { + continue; + } + for (const entry of item.messages) { + const cards = extractToolCardsCached(entry.message, entry.key); + for (let cardIndex = 0; cardIndex < cards.length; cardIndex++) { + const disclosureId = `${entry.key}:toolcard:${cardIndex}`; + currentToolCardIds.add(disclosureId); + if (initialized.has(disclosureId)) { + continue; + } + expanded.set(disclosureId, autoExpandToolCalls); + initialized.add(disclosureId); + } + const messageRecord = entry.message as Record; + const role = typeof messageRecord.role === "string" ? messageRecord.role : "unknown"; + const normalizedRole = normalizeRoleForGrouping(role); + const isToolMessage = + isToolResultMessage(entry.message) || + normalizedRole === "tool" || + role.toLowerCase() === "toolresult" || + role.toLowerCase() === "tool_result" || + typeof messageRecord.toolCallId === "string" || + typeof messageRecord.tool_call_id === "string"; + if (!isToolMessage) { + continue; + } + const disclosureId = `toolmsg:${entry.key}`; + currentToolCardIds.add(disclosureId); + if (initialized.has(disclosureId)) { + continue; + } + expanded.set(disclosureId, autoExpandToolCalls); + initialized.add(disclosureId); + } + } + if (autoExpandToolCalls && !previousAutoExpand) { + for (const toolCardId of currentToolCardIds) { + expanded.set(toolCardId, true); + } + } + lastAutoExpandPrefBySession.set(sessionKey, autoExpandToolCalls); +} + function messageKey(message: unknown, index: number): string { const m = asRecord(message) ?? {}; const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/pages/chat/chat-view.test.ts similarity index 59% rename from ui/src/ui/views/chat.test.ts rename to ui/src/pages/chat/chat-view.test.ts index b49ecb37d2d7..78e1aad9d8f2 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -2,55 +2,52 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + GatewaySessionRow, + ModelAuthStatusResult, + ModelCatalogEntry, + SessionsListResult, +} from "../../api/types.ts"; +import type { UiSettings } from "../../app/settings.ts"; +import { + blockArtCodeBlockCopyPayloadEncoding, + encodeBlockArtCodeBlockCopyPayload, +} from "../../components/markdown.ts"; +import { renderProviderQuotaPill } from "../../components/provider-quota-pill.ts"; import { i18n, t } from "../../i18n/index.ts"; -import { switchChatSession } from "../app-render.helpers.ts"; -import type { AppViewState } from "../app-view-state.ts"; +import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { createSessionCapability, type SessionCapability } from "../../lib/sessions/index.ts"; import { createModelCatalog, createSessionsListResult, DEFAULT_CHAT_MODEL_CATALOG, -} from "../chat-model.test-helpers.ts"; +} from "../../test-helpers/chat-model.ts"; import { getChatAttachmentDataUrl, resetChatAttachmentPayloadStoreForTest, -} from "../chat/attachment-payload-store.ts"; -import { renderChatQueue } from "../chat/chat-queue.ts"; -import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts"; -import { renderWelcomeState } from "../chat/chat-welcome.ts"; +} from "./attachment-payload-store.ts"; +import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts"; +import { renderChat, resetChatViewState } from "./chat-view.ts"; +import { renderChatQueue } from "./components/chat-composer.ts"; import { - blockArtCodeBlockCopyPayloadEncoding, - encodeBlockArtCodeBlockCopyPayload, -} from "../chat/code-block-copy-payload.ts"; -import { renderChatSessionSelect } from "../chat/session-controls.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow, ModelCatalogEntry, SessionsListResult } from "../types.ts"; -import type { ChatItem, MessageGroup } from "../types/chat-types.ts"; -import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; -import { renderChat, resetChatViewState } from "./chat.ts"; -import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; + renderChatModelControls, + type ChatModelControlsProps, +} from "./components/chat-model-controls.ts"; +import { renderMarkdownSidebar } from "./components/chat-sidebar.ts"; +import { buildRawSidebarContent } from "./components/chat-sidebar.ts"; +import { renderWelcomeState } from "./components/chat-welcome.ts"; const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() => - vi.fn(async (state: AppViewState) => { + vi.fn(async (state: ChatHeaderTestState) => { const agentId = state.agentsSelectedId ?? "main"; const sessionKey = state.sessionKey; await state.client?.request("tools.effective", { agentId, sessionKey }); - const override = state.chatModelOverrides[sessionKey]; - state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override?.value ?? "(default)"}`; + const override = state.sessions.state.modelOverrides[sessionKey]; + state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override ?? "(default)"}`; state.toolsEffectiveResult = { agentId, profile: "coding", groups: [] }; }), ); -const loadSessionsMock = vi.hoisted(() => - vi.fn(async (state: AppViewState) => { - const res = await state.client?.request("sessions.list", { - includeGlobal: true, - includeUnknown: true, - }); - if (res) { - state.sessionsResult = res as AppViewState["sessionsResult"]; - } - }), -); -const patchSessionMock = vi.hoisted(() => vi.fn(async () => true)); const buildChatItemsMock = vi.hoisted(() => vi.fn((props: { messages: unknown[]; stream: string | null; streamStartedAt: number | null }) => { if ( @@ -133,6 +130,47 @@ const renderMessageGroupMock = vi.hoisted(() => ); const assistantAttachmentRenderVersionMock = vi.hoisted(() => ({ value: 0 })); +type ChatHeaderTestState = { + basePath?: string; + chatLoading: boolean; + chatMessage: string; + chatMessages: unknown[]; + chatModelCatalog: ModelCatalogEntry[]; + chatModelsLoading?: boolean; + chatQueue: ChatQueueItem[]; + chatRunId: string | null; + chatSending: boolean; + chatStream: string | null; + chatStreamStartedAt: number | null; + chatThinkingLevel: string | null; + chatVerboseLevel: string | null; + chatAvatarUrl: string | null; + client: GatewayBrowserClient; + connected: boolean; + hello: null; + lastError: string | null; + modelAuthStatusResult?: ModelAuthStatusResult | null; + sessionKey: string; + sessionsResult: SessionsListResult | null; + agentsList: null; + agentsPanel: string; + agentsSelectedId: string | null; + settings: UiSettings; + sessions: SessionCapability; + setRoute: ReturnType; + toolsEffectiveLoading: boolean; + toolsEffectiveLoadingKey: string | null; + toolsEffectiveError: string | null; + toolsEffectiveResultKey: string | null; + toolsEffectiveResult: unknown; + applySettings(next: UiSettings): void; + loadAssistantIdentity(): void; + onModelChanged(): void | Promise; + resetChatInputHistoryNavigation(): void; + resetChatScroll(): void; + resetToolStream(): void; +}; + function requireFirstAttachmentsChange( onAttachmentsChange: ReturnType, ): ChatAttachment[] { @@ -147,15 +185,21 @@ function requireFirstAttachmentsChange( return attachments as ChatAttachment[]; } -vi.mock("../icons.ts", () => ({ +vi.mock("../../components/icons.ts", () => ({ icons: {}, })); -vi.mock("../chat/build-chat-items.ts", () => ({ - buildChatItems: buildChatItemsMock, -})); +vi.mock("./chat-thread.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildCachedChatItems: buildChatItemsMock, + getExpandedToolCards: () => new Map(), + syncToolCardExpansionState: () => undefined, + }; +}); -vi.mock("../chat/grouped-render.ts", () => ({ +vi.mock("./components/chat-message.ts", () => ({ getAssistantAttachmentAvailabilityRenderVersion: () => assistantAttachmentRenderVersionMock.value, renderMessageGroup: renderMessageGroupMock, renderStreamGroup: (parts: Array<{ kind: string; text?: string }>) => { @@ -175,30 +219,11 @@ vi.mock("../chat/grouped-render.ts", () => ({ }, })); -vi.mock("../markdown.ts", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - toSanitizedMarkdownHtml: (value: string) => value, - }; -}); - -vi.mock("../chat/tool-expansion-state.ts", () => ({ - getExpandedToolCards: () => new Map(), - syncToolCardExpansionState: () => undefined, -})); - -vi.mock("../controllers/agents.ts", () => ({ +vi.mock("../../lib/agents/tools-effective.ts", () => ({ refreshVisibleToolsEffectiveForCurrentSession: refreshVisibleToolsEffectiveForCurrentSessionMock, })); -vi.mock("../controllers/sessions.ts", () => ({ - loadSessions: loadSessionsMock, - patchSession: patchSessionMock, - syncSelectedSessionMessageSubscription: vi.fn(async () => undefined), -})); - -vi.mock("./agents-utils.ts", () => ({ +vi.mock("../../lib/agents/display.ts", () => ({ isRenderableControlUiAvatarUrl: (value: string) => /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")), agentLogoUrl: () => "/openclaw-logo.svg", @@ -272,7 +297,7 @@ function createChatHeaderState( thinkingDefault?: string; omitSessionFromList?: boolean; } = {}, -): { state: AppViewState; request: ReturnType } { +): { state: ChatHeaderTestState; request: ReturnType } { let currentModel = overrides.model ?? null; let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null); const omitSessionFromList = overrides.omitSessionFromList ?? false; @@ -366,31 +391,26 @@ function createChatHeaderState( } throw new Error(`Unexpected request: ${method}`); }); - const state = { + const client = { request } as unknown as GatewayBrowserClient; + const sessions = createSessionCapability({ + snapshot: { client, connected: true, hello: null }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }); + const initialSessionsResult = createSessionsListResult({ + model: currentModel, + modelProvider: currentModelProvider, + defaultsThinkingDefault: overrides.defaultsThinkingDefault, + thinkingDefault: overrides.thinkingDefault, + omitSessionFromList, + }); + const state: ChatHeaderTestState = { sessionKey: "main", connected: true, - sessionsHideCron: true, - sessionsIncludeGlobal: true, - sessionsIncludeUnknown: false, - sessionsShowArchived: false, - sessionsResult: createSessionsListResult({ - model: currentModel, - modelProvider: currentModelProvider, - defaultsThinkingDefault: overrides.defaultsThinkingDefault, - thinkingDefault: overrides.thinkingDefault, - omitSessionFromList, - }), - chatModelOverrides: {}, + sessionsResult: initialSessionsResult, chatModelCatalog: catalog, chatModelsLoading: false, - chatSessionPickerOpen: false, - chatSessionPickerSurface: null, - chatSessionPickerQuery: "", - chatSessionPickerAppliedQuery: "", - chatSessionPickerLoading: false, - chatSessionPickerError: null, - chatSessionPickerResult: null, - client: { request } as unknown as GatewayBrowserClient, + client, settings: { gatewayUrl: "", token: "", @@ -401,9 +421,11 @@ function createChatHeaderState( themeMode: "dark", splitRatio: 0.6, navCollapsed: false, + navWidth: 280, navGroupsCollapsed: {}, borderRadius: 50, chatShowThinking: false, + chatShowToolCalls: true, }, chatMessage: "", chatStream: null, @@ -412,6 +434,7 @@ function createChatHeaderState( chatQueue: [], chatMessages: [], chatLoading: false, + chatSending: false, chatThinkingLevel: null, chatVerboseLevel: null, lastError: null, @@ -421,30 +444,28 @@ function createChatHeaderState( agentsList: null, agentsPanel: "overview", agentsSelectedId: null, + sessions, toolsEffectiveLoading: false, toolsEffectiveLoadingKey: null, toolsEffectiveResultKey: null, toolsEffectiveError: null, toolsEffectiveResult: null, - applySettings(next: AppViewState["settings"]) { + applySettings(next: UiSettings) { state.settings = next; }, - setTab: vi.fn(), + setRoute: vi.fn(), loadAssistantIdentity: vi.fn(), resetChatInputHistoryNavigation: vi.fn(), resetToolStream: vi.fn(), resetChatScroll: vi.fn(), - } as unknown as AppViewState & { - client: GatewayBrowserClient; - settings: AppViewState["settings"]; + onModelChanged: (): Promise => refreshVisibleToolsEffectiveForCurrentSessionMock(state), }; + sessions.subscribe((next) => { + state.sessionsResult = next.result; + }); return { state, request }; } -async function flushTasks() { - await vi.dynamicImportSettled(); -} - function getChatModelSelect(container: Element): HTMLElement { const select = container.querySelector('[data-chat-model-select="true"]'); expect(select).toBeInstanceOf(HTMLElement); @@ -454,30 +475,36 @@ function getChatModelSelect(container: Element): HTMLElement { return select; } -function getChatSelectValue(control: HTMLElement): string { - return control.dataset.chatSelectValue ?? ""; +function createChatModelControlsProps(state: ChatHeaderTestState): ChatModelControlsProps { + return { + activeRunId: state.chatRunId, + connected: state.connected, + gatewayAvailable: Boolean(state.client), + loading: state.chatLoading, + modelCatalog: state.chatModelCatalog, + modelOverrides: state.sessions.state.modelOverrides, + modelSwitching: false, + modelsLoading: state.chatModelsLoading, + sending: state.chatSending, + sessionKey: state.sessionKey, + sessionsResult: state.sessionsResult, + stream: state.chatStream, + onFastModeSelect: (value) => + switchChatFastMode(state as unknown as Parameters[0], value), + onModelSelect: (value) => + switchChatModel(state as unknown as Parameters[0], value), + onThinkingSelect: (value) => + switchChatThinkingLevel( + state as unknown as Parameters[0], + value, + ), + }; } function getChatThinkingValue(control: HTMLElement): string { return control.dataset.chatThinkingValue ?? ""; } -function clickChatModelOption(container: Element, value: string) { - const option = Array.from( - container.querySelectorAll("[data-chat-model-option]"), - ).find((button) => button.dataset.chatModelOption === value); - expect(option).toBeInstanceOf(HTMLButtonElement); - option?.click(); -} - -function clickChatSpeedOption(container: Element, value: string) { - const option = Array.from( - container.querySelectorAll("[data-chat-speed-option]"), - ).find((button) => button.dataset.chatSpeedOption === value); - expect(option).toBeInstanceOf(HTMLButtonElement); - option?.click(); -} - function getThinkingSelect(container: Element): HTMLElement { const select = container.querySelector('[data-chat-thinking-select="true"]'); expect(select).toBeInstanceOf(HTMLElement); @@ -496,23 +523,10 @@ function getThinkingSliderValues(container: Element): string[] { return values ? values.split(",") : []; } -function setThinkingSliderLevel(container: Element, value: string) { - const slider = getThinkingSlider(container); - expect(slider).toBeInstanceOf(HTMLInputElement); - if (!slider) { - return; - } - const index = getThinkingSliderValues(container).indexOf(value); - expect(index).toBeGreaterThanOrEqual(0); - slider.value = String(index); - slider.dispatchEvent(new Event("change", { bubbles: true })); -} - function getThinkingReasoningValueLabel(container: Element): string { return container.querySelector(".chat-controls__reasoning-value")?.textContent?.trim() ?? ""; } -/** The "" (use default) reset button; the only remaining [data-chat-thinking-option]. */ function getThinkingResetButton(container: Element): HTMLButtonElement | null { return container.querySelector('[data-chat-thinking-option=""]'); } @@ -576,7 +590,6 @@ function createChatProps( sessions: null, sidebarOpen: false, sidebarContent: null, - sidebarError: null, splitRatio: 0.6, canvasPluginSurfaceUrl: null, embedSandboxMode: "scripts", @@ -979,44 +992,6 @@ describe("chat goal status", () => { }); describe("chat composer workbench", () => { - it("keeps archived sessions read-only across composer interactions", () => { - const onSend = vi.fn(); - const onAttachmentsChange = vi.fn(); - const disabledReason = "Restore this session to send messages."; - const container = renderChatView({ - canSend: false, - disabledReason, - draft: "unsent draft", - onSend, - onAttachmentsChange, - }); - const textarea = container.querySelector("textarea"); - const fileInput = container.querySelector(".agent-chat__file-input"); - const attachButton = container.querySelector( - `[aria-label="${t("chat.composer.attachFile")}"]`, - ); - const sendButton = container.querySelector(".chat-send-btn"); - const chat = container.querySelector(".card.chat"); - - expect(textarea?.disabled).toBe(true); - expect(textarea?.placeholder).toBe(disabledReason); - expect(fileInput?.disabled).toBe(true); - expect(attachButton?.disabled).toBe(true); - expect(sendButton?.disabled).toBe(true); - - textarea?.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), - ); - sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - fileInput?.dispatchEvent(new Event("change", { bubbles: true })); - const drop = new Event("drop", { bubbles: true, cancelable: true }); - chat?.dispatchEvent(drop); - - expect(onSend).not.toHaveBeenCalled(); - expect(onAttachmentsChange).not.toHaveBeenCalled(); - expect(drop.defaultPrevented).toBe(true); - }); - it("renders session controls in the composer and workspace files in the expanded rail", () => { const onToggleCollapsed = vi.fn(); const onRefresh = vi.fn(); @@ -1219,8 +1194,6 @@ afterEach(() => { buildChatItemsMock.mockClear(); renderMessageGroupMock.mockClear(); assistantAttachmentRenderVersionMock.value = 0; - loadSessionsMock.mockClear(); - patchSessionMock.mockClear(); refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear(); resetChatViewState(); resetChatAttachmentPayloadStoreForTest(); @@ -1228,44 +1201,6 @@ afterEach(() => { }); describe("chat transcript rendering cache", () => { - it("does not rebuild transcript items for draft-only rerenders", () => { - const messages = [{ role: "assistant", content: "ready" }]; - const toolMessages: unknown[] = []; - const streamSegments: Array<{ text: string; ts: number }> = []; - const queue: ChatQueueItem[] = []; - - renderChatView({ messages, toolMessages, streamSegments, queue, draft: "" }); - renderChatView({ messages, toolMessages, streamSegments, queue, draft: "h" }); - renderChatView({ messages, toolMessages, streamSegments, queue, draft: "hello" }); - - expect(buildChatItemsMock).toHaveBeenCalledTimes(1); - }); - - it("does not rerender transcript groups for draft-only rerenders", () => { - const messages = [{ role: "assistant", content: "ready" }]; - const toolMessages: unknown[] = []; - const streamSegments: Array<{ text: string; ts: number }> = []; - const queue: ChatQueueItem[] = []; - const container = document.createElement("div"); - - render( - renderChat(createChatProps({ messages, toolMessages, streamSegments, queue })), - container, - ); - render( - renderChat(createChatProps({ messages, toolMessages, streamSegments, queue, draft: "h" })), - container, - ); - render( - renderChat( - createChatProps({ messages, toolMessages, streamSegments, queue, draft: "hello" }), - ), - container, - ); - - expect(renderMessageGroupMock).toHaveBeenCalledTimes(1); - }); - it("rerenders transcript groups when assistant attachment availability changes", () => { const messages = [{ role: "assistant", content: "ready" }]; const toolMessages: unknown[] = []; @@ -1429,46 +1364,6 @@ describe("chat loading skeleton", () => { expect(container.querySelectorAll(".chat-reading-indicator")).toHaveLength(1); }); - it("folds adjacent streaming items into one group and lets a message group break the run (#63956)", () => { - const items: Array = [ - { - kind: "stream", - key: "stream-seg:main:0", - text: "alpha", - startedAt: 10, - isStreaming: false, - }, - { kind: "stream", key: "stream-seg:main:1", text: "beta", startedAt: 20, isStreaming: false }, - { kind: "reading-indicator", key: "reading:main" }, - { - kind: "group", - key: "group:assistant:test", - role: "assistant", - messages: [{ key: "m0", message: { content: "tool break" } }], - timestamp: 25, - isStreaming: false, - }, - { kind: "stream", key: "stream:main:live", text: "gamma", startedAt: 30, isStreaming: true }, - ]; - buildChatItemsMock.mockReturnValueOnce( - items as unknown as ReturnType, - ); - - const container = renderChatView({ stream: "gamma", streamStartedAt: 30 }); - - // Two contiguous streaming runs, split by the message group between them. - const runs = container.querySelectorAll(".chat-stream-run"); - expect(runs).toHaveLength(2); - // First run folds both committed segments plus the trailing reading indicator. - expect(runs[0]?.querySelectorAll(".chat-stream")).toHaveLength(2); - expect(runs[0]?.querySelectorAll(".chat-reading-indicator")).toHaveLength(1); - // The message group stays its own group and breaks the run. - expect(container.querySelectorAll(".chat-group")).toHaveLength(1); - // The live segment after the group forms the second run. - expect(runs[1]?.querySelectorAll(".chat-stream")).toHaveLength(1); - expect(container.querySelectorAll(".chat-stream")).toHaveLength(3); - }); - it("shows prompt-bar progress while the current session send is awaiting acknowledgement", () => { const container = renderChatView({ sending: true, @@ -1631,9 +1526,6 @@ describe("chat loading skeleton", () => { expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull(); expect(container.querySelector(".chat-reading-indicator")).toBeNull(); expect(container.querySelector(".chat-send-btn--stop")).toBeNull(); - expect(container.querySelector(".context-ring__action")?.disabled).toBe( - false, - ); } finally { nowSpy.mockRestore(); } @@ -1868,7 +1760,10 @@ describe("chat voice controls", () => { `[aria-label="${startTalkLabel}"]`, "localized Start Talk button", ); - expect(talkButton.getAttribute("title")).toBe(startTalkLabel); + const tooltip = talkButton.parentElement as (HTMLElement & { content?: string }) | null; + expect(talkButton.getAttribute("title")).toBeNull(); + expect(tooltip?.localName).toBe("openclaw-tooltip"); + expect(tooltip?.content).toBe(startTalkLabel); expect(talkButton.textContent?.trim()).toBe(startTalkLabel); expect(container.querySelector('[aria-label="Start Talk"]')).toBeNull(); requireElement( @@ -2080,23 +1975,6 @@ describe("chat slash menu accessibility", () => { textarea!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); } - it("keeps plain draft input local until send while suggestions are closed", () => { - const onDraftChange = vi.fn(); - const onRequestUpdate = vi.fn(); - const onSend = vi.fn(); - const container = renderChatView({ onDraftChange, onRequestUpdate, onSend }); - - inputDraft(container, "plain first message"); - - expect(onDraftChange).not.toHaveBeenCalled(); - expect(onRequestUpdate).not.toHaveBeenCalled(); - - container.querySelector(".chat-send-btn")!.click(); - - expect(onDraftChange).toHaveBeenCalledWith("plain first message"); - expect(onSend).toHaveBeenCalledTimes(1); - }); - it("requests slash command hydration only after slash intent", () => { const onSlashIntent = vi.fn(async () => undefined); const container = renderChatView({ onSlashIntent }); @@ -2257,7 +2135,6 @@ describe("chat slash menu accessibility", () => { ); expect(textarea?.value).toBe("new draft"); - expect(onDraftChange).toHaveBeenCalledTimes(1); }); it("does not apply a stale submitted draft replay to another session", () => { @@ -2310,7 +2187,7 @@ describe("chat slash menu accessibility", () => { expect(onDraftChange).toHaveBeenCalledTimes(1); }); - it("keeps an intervening session draft when a delayed stale replay arrives", () => { + it("does not overwrite an intervening session draft with a delayed stale replay", () => { const drafts: Record = { "delayed-replay-a": "", "delayed-replay-b": "", @@ -2373,8 +2250,7 @@ describe("chat slash menu accessibility", () => { ); expect(textarea?.value).toBe("session b draft"); - expect(drafts["delayed-replay-b"]).toBe(""); - expect(onDraftChange).toHaveBeenCalledTimes(1); + expect(drafts["delayed-replay-b"]).toBe("session b draft"); }); it("commits local draft input before Enter sends", () => { @@ -2421,7 +2297,6 @@ describe("chat slash menu accessibility", () => { expect(container.querySelector("textarea")?.value).toBe( "still typing locally", ); - expect(onDraftChange).not.toHaveBeenCalled(); }); it("replaces local draft input when the host draft changes", () => { @@ -2838,1195 +2713,11 @@ describe("chat welcome", () => { }); }); -describe("chat session controls", () => { +describe("chat model controls", () => { afterEach(async () => { await i18n.setLocale("en"); }); - it("filters chat sessions by agent and switches to that agent's latest eligible session", () => { - const { state } = createChatHeaderState(); - const onSwitchSession = vi.fn(); - state.sessionKey = "agent:alpha:main"; - state.agentsList = { - defaultId: "alpha", - mainKey: "agent:alpha:main", - scope: "all", - agents: [ - { id: "alpha", name: "Deep Chat" }, - { id: "beta", name: "Coding" }, - ], - }; - state.sessionsResult = { - ts: 0, - path: "", - count: 6, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [ - { key: "agent:alpha:main", kind: "direct", updatedAt: 4 }, - { key: "agent:alpha:dashboard:alpha-recent", kind: "direct", updatedAt: 3 }, - { - key: "agent:alpha:subagent:worker", - kind: "direct", - updatedAt: 5, - spawnedBy: "agent:alpha:main", - }, - { key: "agent:beta:dashboard:beta-recent", kind: "direct", updatedAt: 2 }, - { key: "agent:beta:main", kind: "direct", updatedAt: 1 }, - { - key: "agent:beta:subagent:worker", - kind: "direct", - updatedAt: 6, - spawnedBy: "agent:beta:main", - }, - ], - }; - - const container = document.createElement("div"); - render(renderChatSessionSelect(state, onSwitchSession), container); - - const agentSelect = container.querySelector( - 'select[data-chat-agent-filter="true"]', - ); - const sessionTrigger = container.querySelector( - 'button[data-chat-session-select="true"]', - ); - - expect(agentSelect?.value).toBe("alpha"); - expect(sessionTrigger?.textContent).toContain("main"); - - agentSelect!.value = "beta"; - agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); - - expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:dashboard:beta-recent"); - }); - - it("keeps agent switch targets after scoped session refreshes", () => { - const { state } = createChatHeaderState(); - const onSwitchSession = vi.fn(); - state.sessionKey = "agent:alpha:main"; - state.agentsList = { - defaultId: "alpha", - mainKey: "agent:alpha:main", - scope: "all", - agents: [ - { id: "alpha", name: "Deep Chat" }, - { id: "beta", name: "Coding" }, - ], - }; - state.sessionsResult = { - ts: 0, - path: "", - count: 3, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [ - { key: "agent:alpha:main", kind: "direct", updatedAt: 4 }, - { key: "agent:beta:dashboard:beta-recent", kind: "direct", updatedAt: 3 }, - { key: "agent:beta:main", kind: "direct", updatedAt: 2 }, - ], - }; - const container = document.createElement("div"); - render(renderChatSessionSelect(state, onSwitchSession), container); - - state.sessionsResultAgentId = "alpha"; - state.sessionsResult = { - ts: 1, - path: "", - count: 1, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [{ key: "agent:alpha:main", kind: "direct", updatedAt: 5 }], - }; - render(renderChatSessionSelect(state, onSwitchSession), container); - - const agentSelect = container.querySelector( - 'select[data-chat-agent-filter="true"]', - ); - agentSelect!.value = "beta"; - agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); - - expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:dashboard:beta-recent"); - }); - - it("clears cached agent switch targets after a scoped empty refresh", () => { - const { state } = createChatHeaderState(); - const onSwitchSession = vi.fn(); - state.sessionKey = "agent:alpha:main"; - state.agentsList = { - defaultId: "alpha", - mainKey: "agent:alpha:main", - scope: "all", - agents: [ - { id: "alpha", name: "Deep Chat" }, - { id: "beta", name: "Coding" }, - ], - }; - state.sessionsResult = { - ts: 0, - path: "", - count: 2, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [ - { key: "agent:alpha:main", kind: "direct", updatedAt: 4 }, - { key: "agent:beta:dashboard:deleted", kind: "direct", updatedAt: 3 }, - ], - }; - const container = document.createElement("div"); - render(renderChatSessionSelect(state, onSwitchSession), container); - - state.sessionsResultAgentId = "beta"; - state.sessionsResult = { - ts: 1, - path: "", - count: 0, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [], - }; - render(renderChatSessionSelect(state, onSwitchSession), container); - - const agentSelect = container.querySelector( - 'select[data-chat-agent-filter="true"]', - ); - agentSelect!.value = "beta"; - agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); - - expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:main"); - }); - - it("renders selector labels from the active locale", async () => { - await i18n.setLocale("zh-CN"); - const { state } = createChatHeaderState(); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - expect( - container - .querySelector('button[data-chat-session-select="true"]') - ?.getAttribute("aria-label"), - ).toBe(t("chat.selectors.session")); - const combinedLabel = container - .querySelector('[data-chat-model-select="true"]') - ?.getAttribute("aria-label"); - expect(combinedLabel).toContain(t("chat.selectors.model")); - expect(combinedLabel).toContain(t("chat.selectors.thinkingLevel")); - }); - - it("searches chat sessions inside the picker without replacing recent sessions", async () => { - const { state, request } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - const originalSessionsResult = state.sessionsResult; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - render(renderChatSessionSelect(state), container); - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - const submit = container.querySelector( - 'button[data-chat-session-search-submit="true"]', - ); - - input!.value = " telegram "; - input!.dispatchEvent(new Event("input", { bubbles: true })); - expect(state.chatSessionPickerQuery).toBe(" telegram "); - expect(submit?.disabled).toBe(false); - submit!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await vi.waitFor(() => expect(state.chatSessionPickerAppliedQuery).toBe("telegram")); - render(renderChatSessionSelect(state), container); - - expect(state.chatSessionPickerQuery).toBe(" telegram "); - expect(state.sessionsResult).toBe(originalSessionsResult); - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:telegram-one", - "agent:main:telegram-two", - ]); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - search: "telegram", - }); - expect(loadSessionsMock).not.toHaveBeenCalled(); - }); - - it("debounces chat session picker search while typing", async () => { - vi.useFakeTimers(); - const { state, request } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - render(renderChatSessionSelect(state), container); - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - - input!.value = "tele"; - input!.dispatchEvent(new Event("input", { bubbles: true })); - await vi.advanceTimersByTimeAsync(299); - - expect(state.chatSessionPickerAppliedQuery).toBe(""); - expect( - request.mock.calls.some( - ([method, params]) => - method === "sessions.list" && - (params as Record | undefined)?.search === "tele", - ), - ).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - - expect(state.chatSessionPickerAppliedQuery).toBe("tele"); - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:telegram-one", - "agent:main:telegram-two", - ]); - }); - - it("flushes pending chat session picker search on blur", async () => { - const { state, request } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - render(renderChatSessionSelect(state), container); - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - - input!.value = "tele"; - input!.dispatchEvent(new Event("input", { bubbles: true })); - input!.dispatchEvent(new FocusEvent("blur", { bubbles: false })); - - await vi.waitFor(() => expect(state.chatSessionPickerAppliedQuery).toBe("tele")); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - search: "tele", - }); - }); - - it("keeps picker options clickable after blurring an empty search input", async () => { - const { state } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - const rows: GatewaySessionRow[] = [ - { key: "main", kind: "direct", label: "Main", updatedAt: 2 }, - { key: "agent:main:work", kind: "direct", label: "Work", updatedAt: 1 }, - ]; - state.sessionsResult = createSessionsResultFromRows(rows); - const request = vi.fn((method: string) => { - if (method === "sessions.list") { - return Promise.resolve(createSessionsResultFromRows(rows)); - } - throw new Error(`Unexpected request: ${method}`); - }); - state.client = { request } as unknown as GatewayBrowserClient; - const onSwitchSession = vi.fn(); - const container = document.createElement("div"); - render(renderChatSessionSelect(state, onSwitchSession), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - await vi.waitFor(() => expect(state.chatSessionPickerResult).not.toBeNull()); - render(renderChatSessionSelect(state, onSwitchSession), container); - const pickerResultBefore = state.chatSessionPickerResult; - const requestCountBefore = request.mock.calls.length; - - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - expect(input!.value).toBe(""); - input!.dispatchEvent(new FocusEvent("blur", { bubbles: false })); - render(renderChatSessionSelect(state, onSwitchSession), container); - - expect(state.chatSessionPickerResult).toBe(pickerResultBefore); - expect(state.chatSessionPickerAppliedQuery).toBe(""); - expect(state.chatSessionPickerOpen).toBe(true); - expect(request).toHaveBeenCalledTimes(requestCountBefore); - const options = container.querySelectorAll( - 'button[data-chat-session-picker-option="true"]', - ); - const target = [...options].find( - (button) => button.dataset.sessionKey && button.dataset.sessionKey !== state.sessionKey, - ); - if (!target?.dataset.sessionKey) { - throw new Error("expected another session option"); - } - const targetSessionKey = target.dataset.sessionKey; - target.click(); - - expect(onSwitchSession).toHaveBeenCalledWith(state, targetSessionKey); - }); - - it("pins sessions from the chat session picker", async () => { - const { state } = createChatHeaderState(); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - await vi.waitFor(() => expect(state.chatSessionPickerResult).not.toBeNull()); - render(renderChatSessionSelect(state), container); - container.querySelector('button[data-chat-session-pin="true"]')!.click(); - - await vi.waitFor(() => - expect(patchSessionMock).toHaveBeenCalledWith( - state, - "main", - { pinned: true }, - { - activeMinutes: 0, - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - preserveSessionsViewResult: true, - showArchived: false, - }, - ), - ); - }); - - it("switches away after archiving a selected legacy session alias", async () => { - const { state } = createChatHeaderState(); - const onSwitchSession = vi.fn(); - state.sessionKey = "Agent:Main:Work"; - state.settings.sessionKey = state.sessionKey; - state.sessionsResult = createSessionsResultFromRows([ - { key: "agent:main:work", kind: "direct", label: "Work", updatedAt: 1 }, - ]); - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerResult = state.sessionsResult; - const container = document.createElement("div"); - render(renderChatSessionSelect(state, onSwitchSession), container); - - container.querySelector('button[data-chat-session-archive="true"]')!.click(); - - await vi.waitFor(() => expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:main:main")); - }); - - it.each([ - [ - "Matrix room", - "agent:main:matrix:channel:!MixedRoomAbC:example.org", - "agent:main:matrix:channel:!mixedroomabc:example.org", - ], - ["Signal group", "agent:main:signal:group:AbC123=", "agent:main:signal:group:abc123="], - ])( - "does not switch after archiving a case-distinct opaque %s", - async (_name, selectedKey, rowKey) => { - const { state } = createChatHeaderState(); - const onSwitchSession = vi.fn(); - state.sessionKey = selectedKey; - state.settings.sessionKey = selectedKey; - state.sessionsResult = createSessionsResultFromRows([ - { key: rowKey, kind: "direct", label: "Other session", updatedAt: 1 }, - ]); - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerResult = state.sessionsResult; - const container = document.createElement("div"); - render(renderChatSessionSelect(state, onSwitchSession), container); - - container - .querySelector('button[data-chat-session-archive="true"]')! - .click(); - - await vi.waitFor(() => expect(patchSessionMock).toHaveBeenCalled()); - expect(onSwitchSession).not.toHaveBeenCalled(); - }, - ); - - it("clears applied chat session picker search when the input is cleared", async () => { - const { state } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - render(renderChatSessionSelect(state), container); - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - const submit = container.querySelector( - 'button[data-chat-session-search-submit="true"]', - ); - - input!.value = "telegram"; - input!.dispatchEvent(new Event("input", { bubbles: true })); - submit!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await vi.waitFor(() => expect(state.chatSessionPickerAppliedQuery).toBe("telegram")); - expect(state.chatSessionPickerResult?.sessions).toHaveLength(2); - - input!.value = ""; - input!.dispatchEvent(new Event("input", { bubbles: true })); - - expect(state.chatSessionPickerQuery).toBe(""); - expect(state.chatSessionPickerAppliedQuery).toBe(""); - expect(state.chatSessionPickerResult).toBeNull(); - }); - - it("reloads scoped picker rows after clearing search", async () => { - const { state } = createChatHeaderState(); - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - const request = vi.fn((method: string, params: Record = {}) => { - if (method !== "sessions.list") { - throw new Error(`Unexpected request: ${method}`); - } - const search = typeof params.search === "string" ? params.search.trim() : ""; - if (search) { - return Promise.resolve( - createSessionsResultFromRows([ - { key: "agent:main:telegram", kind: "direct", label: "Telegram", updatedAt: 5 }, - ]), - ); - } - return Promise.resolve( - createSessionsResultFromRows([ - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 6 }, - { key: "agent:main:work", kind: "direct", label: "Main work", updatedAt: 4 }, - ]), - ); - }); - state.client = { request } as unknown as GatewayBrowserClient; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - render(renderChatSessionSelect(state), container); - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - const submit = container.querySelector( - 'button[data-chat-session-search-submit="true"]', - ); - - input!.value = "telegram"; - input!.dispatchEvent(new Event("input", { bubbles: true })); - submit!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:telegram", - ]), - ); - - input!.value = ""; - input!.dispatchEvent(new Event("input", { bubbles: true })); - - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:main", - "agent:main:work", - ]), - ); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - }); - }); - - it("ignores stale chat session picker search responses", async () => { - const { state } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - let resolveTele!: (value: SessionsListResult) => void; - let resolveTelegram!: (value: SessionsListResult) => void; - const request = vi.fn((method: string, params: Record = {}) => { - if (method !== "sessions.list") { - throw new Error(`Unexpected request: ${method}`); - } - if (params.search === "tele") { - return new Promise((resolve) => { - resolveTele = resolve; - }); - } - if (params.search === "telegram") { - return new Promise((resolve) => { - resolveTelegram = resolve; - }); - } - return Promise.resolve(state.sessionsResult); - }); - state.client = { request } as unknown as GatewayBrowserClient; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - render(renderChatSessionSelect(state), container); - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - - input!.value = "tele"; - input!.dispatchEvent(new Event("input", { bubbles: true })); - input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - search: "tele", - }); - - input!.value = "telegram"; - input!.dispatchEvent(new Event("input", { bubbles: true })); - input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - search: "telegram", - }); - - resolveTelegram( - createSessionsResultFromRows([ - { - key: "agent:main:telegram-latest", - kind: "direct", - label: "Telegram latest", - updatedAt: 5, - }, - ]), - ); - await vi.waitFor(() => expect(state.chatSessionPickerAppliedQuery).toBe("telegram")); - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:telegram-latest", - ]); - - resolveTele( - createSessionsResultFromRows([ - { - key: "agent:main:tele-stale", - kind: "direct", - label: "Tele stale", - updatedAt: 6, - }, - ]), - ); - await flushTasks(); - - expect(state.chatSessionPickerAppliedQuery).toBe("telegram"); - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:telegram-latest", - ]); - }); - - it("loads another chat session picker page using the server next offset", async () => { - const { state, request } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerQuery = "telegram"; - state.chatSessionPickerAppliedQuery = "telegram"; - state.chatSessionPickerResult = createSessionsResultFromRows( - [ - { key: "agent:main:telegram-one", kind: "direct", label: "Telegram one", updatedAt: 4 }, - { key: "agent:main:telegram-two", kind: "direct", label: "Telegram two", updatedAt: 3 }, - ], - { - hasMore: true, - nextOffset: 50, - totalCount: 4, - }, - ); - const originalSessionsResult = state.sessionsResult; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const loadMore = container.querySelector( - 'button[data-chat-session-load-more="true"]', - ); - const input = container.querySelector( - 'input[data-chat-session-picker-search="true"]', - ); - expect(loadMore?.disabled).toBe(false); - request.mockClear(); - input!.dispatchEvent(new FocusEvent("blur", { bubbles: false })); - await flushTasks(); - expect(request).not.toHaveBeenCalled(); - - loadMore!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await vi.waitFor(() => expect(state.chatSessionPickerResult?.sessions).toHaveLength(4)); - - expect(state.sessionsResult).toBe(originalSessionsResult); - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:telegram-one", - "agent:main:telegram-two", - "agent:main:telegram-page-51", - "agent:main:telegram-page-52", - ]); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - offset: 50, - search: "telegram", - }); - }); - - it("skips hidden subagent pages when loading more chat picker sessions", async () => { - const { state, request } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerResult = createSessionsResultFromRows( - [ - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 6 }, - { - key: "agent:main:spawn-child:first", - kind: "direct", - label: "Subagent first", - updatedAt: 5, - spawnedBy: "agent:main:main", - }, - ], - { - hasMore: true, - nextOffset: 2, - totalCount: 177, - }, - ); - request - .mockResolvedValueOnce( - createSessionsResultFromRows( - [ - { - key: "agent:main:spawn-child:second", - kind: "direct", - label: "Subagent second", - updatedAt: 4, - spawnedBy: "agent:main:main", - }, - { - key: "agent:main:spawn-child:third", - kind: "direct", - label: "Subagent third", - updatedAt: 3, - spawnedBy: "agent:main:main", - }, - ], - { hasMore: true, nextOffset: 4, offset: 2, totalCount: 177 }, - ), - ) - .mockResolvedValueOnce( - createSessionsResultFromRows( - [ - { - key: "agent:main:work", - kind: "direct", - label: "Main work", - updatedAt: 2, - }, - ], - { hasMore: false, nextOffset: null, offset: 4, totalCount: 177 }, - ), - ); - - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("1"); - - container - .querySelector('button[data-chat-session-load-more="true"]')! - .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:main", - "agent:main:spawn-child:first", - "agent:main:spawn-child:second", - "agent:main:spawn-child:third", - "agent:main:work", - ]), - ); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - offset: 2, - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - offset: 4, - }); - - render(renderChatSessionSelect(state), container); - const labels = Array.from( - container.querySelectorAll(".chat-session-picker__option-label"), - ).map((node) => node.textContent?.trim()); - expect(labels).toEqual(["Main chat", "Main work"]); - expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("2"); - expect(container.querySelector('button[data-chat-session-load-more="true"]')).toBeNull(); - }); - - it("continues past many hidden chat picker pages until a visible session is loaded", async () => { - const { state } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerResult = createSessionsResultFromRows( - [{ key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 20 }], - { hasMore: true, nextOffset: 1, totalCount: 20 }, - ); - const request = vi.fn((method: string, params: Record = {}) => { - if (method !== "sessions.list") { - throw new Error(`Unexpected request: ${method}`); - } - const offset = - typeof params.offset === "number" && Number.isFinite(params.offset) ? params.offset : 0; - if (offset < 12) { - return Promise.resolve( - createSessionsResultFromRows( - [ - { - key: `agent:main:spawn-child:${offset}`, - kind: "direct", - label: `Subagent ${offset}`, - updatedAt: 20 - offset, - spawnedBy: "agent:main:main", - }, - ], - { hasMore: true, nextOffset: offset + 1, offset, totalCount: 20 }, - ), - ); - } - return Promise.resolve( - createSessionsResultFromRows( - [{ key: "agent:main:work", kind: "direct", label: "Main work", updatedAt: 1 }], - { hasMore: false, nextOffset: null, offset, totalCount: 20 }, - ), - ); - }); - state.client = { request } as unknown as GatewayBrowserClient; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container - .querySelector('button[data-chat-session-load-more="true"]')! - .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.at(-1)?.key).toBe("agent:main:work"), - ); - expect(request).toHaveBeenCalledTimes(12); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - offset: 11, - }); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - offset: 12, - }); - - render(renderChatSessionSelect(state), container); - const labels = Array.from( - container.querySelectorAll(".chat-session-picker__option-label"), - ).map((node) => node.textContent?.trim()); - expect(labels).toEqual(["Main chat", "Main work"]); - expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("2"); - expect(container.querySelector('button[data-chat-session-load-more="true"]')).toBeNull(); - }); - - it("loads unsearched picker pages from a scoped first page", async () => { - const { state } = createChatHeaderState(); - state.sessionsIncludeGlobal = false; - state.sessionsIncludeUnknown = false; - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.sessionsResult = createSessionsResultFromRows( - [ - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 6 }, - { key: "agent:other:main", kind: "direct", label: "Other agent", updatedAt: 5 }, - ], - { - hasMore: true, - nextOffset: 50, - totalCount: 100, - }, - ); - const request = vi.fn((method: string, params: Record = {}) => { - if (method !== "sessions.list") { - throw new Error(`Unexpected request: ${method}`); - } - const offset = - typeof params.offset === "number" && Number.isFinite(params.offset) ? params.offset : 0; - if (offset === 2) { - return Promise.resolve( - createSessionsResultFromRows( - [ - { key: "agent:main:page-three", kind: "direct", label: "Main page 3", updatedAt: 3 }, - { key: "agent:main:page-four", kind: "direct", label: "Main page 4", updatedAt: 2 }, - ], - { hasMore: false, nextOffset: null, offset: 2, totalCount: 4 }, - ), - ); - } - return Promise.resolve( - createSessionsResultFromRows( - [ - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 6 }, - { key: "agent:main:work", kind: "direct", label: "Main work", updatedAt: 4 }, - ], - { hasMore: true, nextOffset: 2, totalCount: 4 }, - ), - ); - }); - state.client = { request } as unknown as GatewayBrowserClient; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - container.querySelector('button[data-chat-session-select="true"]')!.click(); - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:main", - "agent:main:work", - ]), - ); - render(renderChatSessionSelect(state), container); - - container - .querySelector('button[data-chat-session-load-more="true"]')! - .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:main", - "agent:main:work", - "agent:main:page-three", - "agent:main:page-four", - ]), - ); - - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - }); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - offset: 2, - }); - expect(request.mock.calls.some(([, params]) => params?.offset === 50)).toBe(false); - }); - - it("leaves global chat picker requests unscoped", async () => { - const { state } = createChatHeaderState(); - state.sessionKey = "global"; - state.settings.sessionKey = state.sessionKey; - state.sessionsResult = createSessionsResultFromRows([ - { key: "global", kind: "global", label: "Global chat", updatedAt: 6 }, - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 5 }, - ]); - const request = vi.fn((method: string, params: Record = {}) => { - if (method !== "sessions.list") { - throw new Error(`Unexpected request: ${method}`); - } - if (params.agentId) { - return Promise.resolve( - createSessionsResultFromRows([ - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 5 }, - ]), - ); - } - return Promise.resolve( - createSessionsResultFromRows([ - { key: "global", kind: "global", label: "Global chat", updatedAt: 6 }, - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 5 }, - ]), - ); - }); - state.client = { request } as unknown as GatewayBrowserClient; - const container = document.createElement("div"); - - render(renderChatSessionSelect(state), container); - container.querySelector('button[data-chat-session-select="true"]')!.click(); - - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "global", - "agent:main:main", - ]), - ); - - expect(request).toHaveBeenCalledWith("sessions.list", { - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - }); - expect(request.mock.calls.some(([, params]) => Object.hasOwn(params ?? {}, "agentId"))).toBe( - false, - ); - }); - - it("reloads the picker after switching agents", async () => { - const { state } = createChatHeaderState(); - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.agentsList = { - defaultId: "main", - mainKey: "main", - scope: "configured", - agents: [ - { id: "main", name: "Main" }, - { id: "ops", name: "Ops" }, - ], - }; - state.sessionsResult = createSessionsResultFromRows([ - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 6 }, - { key: "agent:ops:main", kind: "direct", label: "Ops chat", updatedAt: 5 }, - ]); - const request = vi.fn((method: string, params: Record = {}) => { - if (method === "chat.history") { - const sessionId = typeof params.sessionKey === "string" ? params.sessionKey : ""; - return Promise.resolve({ messages: [], sessionId }); - } - if (method !== "sessions.list") { - throw new Error(`Unexpected request: ${method}`); - } - const agentId = params.agentId === "ops" ? "ops" : "main"; - return Promise.resolve( - createSessionsResultFromRows([ - { - key: `agent:${agentId}:main`, - kind: "direct", - label: `${agentId} main`, - updatedAt: 6, - }, - { - key: `agent:${agentId}:work`, - kind: "direct", - label: `${agentId} work`, - updatedAt: 4, - }, - ]), - ); - }); - state.client = { request } as unknown as GatewayBrowserClient; - const container = document.createElement("div"); - - render(renderChatSessionSelect(state), container); - container.querySelector('button[data-chat-session-select="true"]')!.click(); - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:main", - "agent:main:work", - ]), - ); - - switchChatSession(state, "agent:ops:main"); - expect(state.chatSessionPickerResult).toBeNull(); - expect(state.chatSessionPickerAppliedQuery).toBe(""); - - render(renderChatSessionSelect(state), container); - container.querySelector('button[data-chat-session-select="true"]')!.click(); - await vi.waitFor(() => - expect(state.chatSessionPickerResult?.sessions.map((row) => row.key)).toEqual([ - "agent:ops:main", - "agent:ops:work", - ]), - ); - - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "main", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - }); - expect(request).toHaveBeenCalledWith("sessions.list", { - agentId: "ops", - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - limit: 50, - }); - }); - - it("keeps Escape inside the chat session picker from bubbling", () => { - const { state } = createChatHeaderState(); - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "mobile"; - const documentKeydown = vi.fn(); - document.addEventListener("keydown", documentKeydown); - try { - const container = document.createElement("div"); - render(renderChatSessionSelect(state, undefined, { surface: "mobile" }), container); - const picker = container.querySelector(".chat-session-picker"); - - picker!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - - expect(state.chatSessionPickerOpen).toBe(false); - expect(documentKeydown).not.toHaveBeenCalled(); - } finally { - document.removeEventListener("keydown", documentKeydown); - } - }); - - it("renders picker pagination controls inside the popover", () => { - const { state } = createChatHeaderState(); - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerResult = { - ...state.sessionsResult!, - totalCount: 125, - limitApplied: 50, - nextOffset: 50, - hasMore: true, - }; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - expect(container.querySelector(".chat-session-picker")).toBeInstanceOf(HTMLElement); - expect(container.querySelector(".chat-session-picker__footer")?.textContent).toContain( - "1 / 125", - ); - expect(container.querySelector('button[data-chat-session-load-more="true"]')).toBeInstanceOf( - HTMLButtonElement, - ); - }); - - it("renders only active-agent chat sessions in the picker popover", () => { - const { state } = createChatHeaderState(); - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerResult = createSessionsResultFromRows([ - { key: "agent:main:main", kind: "direct", label: "Main chat", updatedAt: 6 }, - { key: "agent:main:work", kind: "direct", label: "Main work", updatedAt: 5 }, - { key: "agent:other:main", kind: "direct", label: "Other agent", updatedAt: 4 }, - { key: "agent:main:cron:daily", kind: "direct", label: "Cron daily", updatedAt: 3 }, - { - key: "agent:main:subagent:child", - kind: "direct", - label: "Child worker", - updatedAt: 2, - spawnedBy: "agent:main:main", - }, - ]); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const labels = Array.from( - container.querySelectorAll(".chat-session-picker__option-label"), - ).map((node) => node.textContent?.trim()); - - expect(labels).toEqual(["Main chat", "Main work"]); - }); - - it("does not render Invalid Date for Date-invalid session picker timestamps", () => { - const { state } = createChatHeaderState(); - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerResult = createSessionsResultFromRows([ - { - key: "agent:main:main", - kind: "direct", - label: "Main chat", - updatedAt: 8_640_000_000_000_001, - }, - ]); - const container = document.createElement("div"); - - render(renderChatSessionSelect(state), container); - - expect(container.textContent).toContain("Main chat"); - expect(container.textContent).not.toContain("Invalid Date"); - }); - - it("does not add the active session to searched picker rows", () => { - const { state } = createChatHeaderState(); - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerQuery = "telegram"; - state.chatSessionPickerAppliedQuery = "telegram"; - state.chatSessionPickerResult = createSessionsResultFromRows( - [{ key: "agent:main:telegram", kind: "direct", label: "Telegram", updatedAt: 5 }], - { totalCount: 1 }, - ); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const labels = Array.from( - container.querySelectorAll(".chat-session-picker__option-label"), - ).map((node) => node.textContent?.trim()); - - expect(labels).toEqual(["Telegram"]); - expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("1 / 1"); - }); - - it("keeps empty searched picker rows empty", () => { - const { state } = createChatHeaderState(); - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = state.sessionKey; - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = "desktop"; - state.chatSessionPickerQuery = "missing"; - state.chatSessionPickerAppliedQuery = "missing"; - state.chatSessionPickerResult = createSessionsResultFromRows([], { totalCount: 0 }); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - expect(container.querySelectorAll(".chat-session-picker__option-label")).toHaveLength(0); - expect(container.querySelector(".chat-session-picker__status")?.textContent).toContain( - t("sessionsView.noSessions"), - ); - expect(container.querySelector(".chat-session-picker__count")?.textContent).toBe("0 / 0"); - }); - it("shows provider quota in the chat header when usage data is loaded", () => { const { state } = createChatHeaderState(); state.modelAuthStatusResult = { @@ -4047,288 +2738,18 @@ describe("chat session controls", () => { ], }; const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render( + renderProviderQuotaPill({ + basePath: state.basePath, + modelAuthStatusResult: state.modelAuthStatusResult, + }), + container, + ); const quota = container.querySelector('[data-chat-provider-usage="true"]'); expect(quota?.textContent?.replace(/\s+/g, " ").trim()).toBe("Usage 28%"); expect(quota?.getAttribute("href")).toBe("/usage"); expect(quota?.getAttribute("title")).toContain("Codex · Week"); - - quota?.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true })); - - expect(state.setTab).toHaveBeenCalledWith("usage"); - }); - - it("falls back to the selected agent's main session when no sessions exist yet", () => { - const { state } = createChatHeaderState(); - const onSwitchSession = vi.fn(); - state.sessionKey = "agent:alpha:main"; - state.agentsList = { - defaultId: "alpha", - mainKey: "agent:alpha:main", - scope: "all", - agents: [ - { id: "alpha", name: "Deep Chat" }, - { id: "beta", name: "Coding" }, - ], - }; - state.sessionsResult = { - ts: 0, - path: "", - count: 1, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [{ key: "agent:alpha:main", kind: "direct", updatedAt: 4 }], - }; - - const container = document.createElement("div"); - render(renderChatSessionSelect(state, onSwitchSession), container); - - const agentSelect = container.querySelector( - 'select[data-chat-agent-filter="true"]', - ); - expect(agentSelect).toBeInstanceOf(HTMLSelectElement); - - agentSelect!.value = "beta"; - agentSelect!.dispatchEvent(new Event("change", { bubbles: true })); - - expect(onSwitchSession).toHaveBeenCalledWith(state, "agent:beta:main"); - }); - - it("renders session switch feedback in the chat controls live region", () => { - const { state } = createChatHeaderState(); - state.sessionSwitchNotice = { id: 1, text: "Switched to Coding" }; - state.sessionSwitchFlashKey = state.sessionKey; - - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const notice = container.querySelector(".chat-controls__session-notice"); - expect(notice?.getAttribute("role")).toBe("status"); - expect(notice?.getAttribute("aria-live")).toBe("polite"); - expect(notice?.textContent?.trim()).toBe("Switched to Coding"); - expect(container.querySelectorAll(".chat-controls__session-row--flash")).toHaveLength(1); - }); - - it("shows the active agent main session instead of a blank select when no row exists yet", () => { - const { state } = createChatHeaderState(); - state.sessionKey = "agent:main:main"; - state.settings.sessionKey = "agent:main:main"; - state.agentsList = { - defaultId: "main", - mainKey: "agent:main:main", - scope: "all", - agents: [{ id: "main", name: "MB Black" }], - }; - state.sessionsResult = { - ts: 0, - path: "", - count: 0, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, - sessions: [], - }; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const sessionTrigger = container.querySelector( - 'button[data-chat-session-select="true"]', - ); - - expect(sessionTrigger?.textContent).toContain("Main Session"); - expect(sessionTrigger?.disabled).toBe(false); - }); - - it("patches the current session model and refreshes active tool visibility", async () => { - const { state, request } = createChatHeaderState(); - state.agentsPanel = "tools"; - state.agentsSelectedId = "main"; - state.toolsEffectiveResultKey = "main:main"; - state.toolsEffectiveResult = { - agentId: "main", - profile: "coding", - groups: [], - }; - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const modelSelect = getChatModelSelect(container); - expect(getChatSelectValue(modelSelect)).toBe(""); - - clickChatModelOption(container, "openai/gpt-5-mini"); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "main", - model: "openai/gpt-5-mini", - }); - expect(request.mock.calls.some(([method]) => method === "chat.history")).toBe(false); - await flushTasks(); - expect(loadSessionsMock).toHaveBeenCalledTimes(1); - expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini"); - expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai"); - expect(request).toHaveBeenCalledWith("tools.effective", { - agentId: "main", - sessionKey: "main", - }); - expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini"); - }); - - it("clears the session model override back to the default model", async () => { - const { state, request } = createChatHeaderState({ model: "gpt-5-mini" }); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const modelSelect = getChatModelSelect(container); - expect(getChatSelectValue(modelSelect)).toBe("openai/gpt-5-mini"); - - clickChatModelOption(container, ""); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "main", - model: null, - }); - await flushTasks(); - expect(loadSessionsMock).toHaveBeenCalledTimes(1); - expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined(); - }); - - it("keeps Default available when an explicit model override matches the default", async () => { - const { state, request } = createChatHeaderState({ model: "gpt-5" }); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - clickChatModelOption(container, ""); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "main", - model: null, - }); - }); - - it("scopes composer speed changes for a selected global-session agent", async () => { - const { state, request } = createChatHeaderState(); - state.sessionKey = "global"; - state.settings.sessionKey = "global"; - state.assistantAgentId = "beta"; - state.sessionsResult = createSessionsResultFromRows([ - { - key: "global", - kind: "global", - modelProvider: "openai", - model: "gpt-5", - updatedAt: 1, - }, - ]); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - clickChatSpeedOption(container, "on"); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "global", - agentId: "beta", - fastMode: true, - }); - }); - - it("sets composer speed to auto", async () => { - const { state, request } = createChatHeaderState(); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - expect( - Array.from(container.querySelectorAll("[data-chat-speed-option]")).map( - (option) => option.textContent?.trim(), - ), - ).toEqual(["Default", "Fast", "Standard", "Auto"]); - - clickChatSpeedOption(container, "auto"); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "main", - fastMode: "auto", - }); - }); - - it("scopes composer model changes for a selected global-session agent", async () => { - const { state, request } = createChatHeaderState(); - state.sessionKey = "global"; - state.settings.sessionKey = "global"; - state.assistantAgentId = "beta"; - state.sessionsResult = createSessionsResultFromRows([ - { - key: "global", - kind: "global", - modelProvider: "minimax", - model: "MiniMax-M2.7", - updatedAt: 1, - }, - ]); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - clickChatModelOption(container, "openai/gpt-5-mini"); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "global", - agentId: "beta", - model: "openai/gpt-5-mini", - }); - }); - - it("scopes composer thinking changes for a selected global-session agent", async () => { - const { state, request } = createChatHeaderState(); - state.sessionKey = "global"; - state.settings.sessionKey = "global"; - state.assistantAgentId = "beta"; - state.sessionsResult = createSessionsResultFromRows([ - { - key: "global", - kind: "global", - modelProvider: "openai", - model: "gpt-5", - thinkingLevel: "off", - thinkingLevels: [ - { id: "off", label: "off" }, - { id: "adaptive", label: "adaptive" }, - ], - updatedAt: 1, - }, - ]); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - expect(getThinkingSliderValues(container)).toEqual(["off", "adaptive"]); - setThinkingSliderLevel(container, "adaptive"); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "global", - agentId: "beta", - thinkingLevel: "adaptive", - }); - }); - - it("shows existing speed overrides for providers outside the fast-mode allowlist", async () => { - const { state, request } = createChatHeaderState(); - state.sessionsResult = createSessionsResultFromRows([ - { - key: "main", - kind: "direct", - modelProvider: "custom", - model: "local-model", - fastMode: true, - updatedAt: 1, - }, - ]); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - expect(container.querySelectorAll("[data-chat-speed-option]").length).toBe(4); - - clickChatSpeedOption(container, ""); - - expect(request).toHaveBeenCalledWith("sessions.patch", { - key: "main", - fastMode: null, - }); }); it("disables the chat header model picker while a run is active", () => { @@ -4336,99 +2757,12 @@ describe("chat session controls", () => { state.chatRunId = "run-123"; state.chatStream = "Working"; const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); const modelSelect = getChatModelSelect(container); expect(modelSelect.getAttribute("aria-disabled")).toBe("true"); }); - it("keeps the selected model visible when the active session is absent from sessions.list", async () => { - const { state } = createChatHeaderState({ omitSessionFromList: true }); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - clickChatModelOption(container, "openai/gpt-5-mini"); - await flushTasks(); - render(renderChatSessionSelect(state), container); - - const rerendered = getChatModelSelect(container); - expect(getChatSelectValue(rerendered)).toBe("openai/gpt-5-mini"); - }); - - it("keeps the selected model visible after switching away and back to a session", async () => { - const sessionA = "agent:main:session-a"; - const sessionB = "agent:main:session-b"; - const catalog = createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG, { - id: "claude-opus-4.5", - name: "Claude Opus 4.5", - provider: "bedrock", - }); - const { state } = createChatHeaderState({ models: catalog }); - let rows: GatewaySessionRow[] = [ - { key: sessionA, kind: "direct", label: "Session A", updatedAt: 2 }, - { key: sessionB, kind: "direct", label: "Session B", updatedAt: 1 }, - ]; - const request = vi.fn(async (method: string, params: Record = {}) => { - if (method === "sessions.patch") { - const key = typeof params.key === "string" ? params.key : ""; - const nextModel = typeof params.model === "string" ? params.model.trim() : ""; - rows = rows.map((row) => { - if (row.key !== key) { - return row; - } - const nextRow: GatewaySessionRow = { ...row }; - if (!nextModel) { - delete nextRow.model; - delete nextRow.modelProvider; - return nextRow; - } - const slashIndex = nextModel.indexOf("/"); - if (slashIndex > 0) { - nextRow.modelProvider = nextModel.slice(0, slashIndex); - } else { - delete nextRow.modelProvider; - } - nextRow.model = slashIndex > 0 ? nextModel.slice(slashIndex + 1) : nextModel; - return nextRow; - }); - return { ok: true, key }; - } - if (method === "sessions.list") { - return createSessionsResultFromRows(rows); - } - if (method === "chat.history") { - return { messages: [] }; - } - if (method === "tools.effective") { - return { agentId: "main", profile: "coding", groups: [] }; - } - throw new Error(`Unexpected request: ${method}`); - }); - state.client = { request } as unknown as GatewayBrowserClient; - state.sessionKey = sessionA; - state.settings.sessionKey = sessionA; - state.sessionsResult = createSessionsResultFromRows(rows); - const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); - - const modelSelect = getChatModelSelect(container); - expect(getChatSelectValue(modelSelect)).toBe(""); - - clickChatModelOption(container, "bedrock/claude-opus-4.5"); - await flushTasks(); - - state.sessionKey = sessionB; - state.settings.sessionKey = sessionB; - render(renderChatSessionSelect(state), container); - expect(getChatSelectValue(getChatModelSelect(container))).toBe(""); - - state.sessionKey = sessionA; - state.settings.sessionKey = sessionA; - render(renderChatSessionSelect(state), container); - - expect(getChatSelectValue(getChatModelSelect(container))).toBe("bedrock/claude-opus-4.5"); - }); - it("uses default thinking options when the active session is absent", () => { const { state } = createChatHeaderState({ omitSessionFromList: true }); state.sessionsResult = createSessionsListResult({ @@ -4443,10 +2777,9 @@ describe("chat session controls", () => { omitSessionFromList: true, }); const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); expect(getThinkingSliderValues(container)).toEqual(["off", "adaptive", "xhigh", "max"]); - // No override -> inherit state: no reset affordance. expect(getThinkingResetButton(container)).toBeNull(); }); @@ -4457,15 +2790,12 @@ describe("chat session controls", () => { thinkingDefault: "adaptive", }); const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); const thinkingSelect = getThinkingSelect(container); expect(getChatThinkingValue(thinkingSelect)).toBe(""); expect(getThinkingReasoningValueLabel(container)).toBe("Default (Adaptive)"); - expect(thinkingSelect.title).toContain("Adaptive"); - // "adaptive" is not one of the offered stops, so the parked thumb must - // render as unanchored instead of pretending the default is "off". expect(getThinkingSliderValues(container)).not.toContain("adaptive"); expect( getThinkingSlider(container)?.classList.contains( @@ -4481,7 +2811,7 @@ describe("chat session controls", () => { thinkingDefault: "medium", }); const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); const slider = getThinkingSlider(container); expect(slider?.classList.contains("chat-controls__reasoning-range--unanchored")).toBe(false); @@ -4501,7 +2831,7 @@ describe("chat session controls", () => { }, ]); const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); expect(getThinkingSlider(container)).toBeNull(); const only = container.querySelector( @@ -4546,12 +2876,11 @@ describe("chat session controls", () => { ], }; const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); const thinkingSelect = getThinkingSelect(container); expect(thinkingSelect.dataset.chatThinkingDisabled).toBe("true"); - // No reasoning levels -> no slider and no reset control at all. expect(getThinkingSlider(container)).toBeNull(); expect(getThinkingResetButton(container)).toBeNull(); }); @@ -4578,10 +2907,8 @@ describe("chat session controls", () => { defaultsThinkingDefault: "off", }); const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); - // The session model is reasoning-capable, so the inherited default must - // come from the model (low), not the unrelated global session default (off). expect(getThinkingReasoningValueLabel(container)).toBe("Default (Low)"); }); @@ -4604,13 +2931,12 @@ describe("chat session controls", () => { ], }); const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); const thinkingSelect = getThinkingSelect(container); expect(container.querySelector('[data-chat-thinking-select-compact="true"]')).toBeNull(); expect(getChatThinkingValue(thinkingSelect)).toBe(""); - expect(thinkingSelect.title).toContain("High"); expect(getThinkingSliderValues(container)).toEqual(["off", "low", "medium", "high", "xhigh"]); expect(getThinkingReasoningValueLabel(container)).toBe("Default (High)"); }); @@ -4621,13 +2947,12 @@ describe("chat session controls", () => { omitSessionFromList: true, }); const container = document.createElement("div"); - render(renderChatSessionSelect(state), container); + render(renderChatModelControls(createChatModelControlsProps(state)), container); const thinkingSelect = getThinkingSelect(container); expect(getChatThinkingValue(thinkingSelect)).toBe(""); expect(getThinkingReasoningValueLabel(container)).toBe("Default (Adaptive)"); - expect(thinkingSelect.title).toContain("Adaptive"); }); }); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts new file mode 100644 index 000000000000..f6ec94bf8961 --- /dev/null +++ b/ui/src/pages/chat/chat-view.ts @@ -0,0 +1,374 @@ +// Control UI view renders chat screen composition. +import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; +import type { SessionsListResult } from "../../api/types.ts"; +import { icons } from "../../components/icons.ts"; +import "../../components/tooltip.ts"; +import { t } from "../../i18n/index.ts"; +import type { + ChatAttachment, + ChatQueueItem, + ChatStreamSegment, +} from "../../lib/chat/chat-types.ts"; +import type { ChatSideResult } from "../../lib/chat/side-result.ts"; +import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts"; +import { + handleChatAttachmentDrop, + renderChatComposer, + resetChatComposerState, +} from "./components/chat-composer.ts"; +import type { RealtimeTalkOptions } from "./components/chat-realtime-controls.ts"; +import { + renderSessionWorkspaceRail, + type SessionWorkspaceProps, +} from "./components/chat-session-workspace.ts"; +import "./components/chat-sidebar.ts"; +import type { + DetailFullMessageResult, + SidebarContent, + SidebarFullMessageRequest, +} from "./components/chat-sidebar.ts"; +import { + isChatThreadSearchOpen, + renderChatPinnedMessages, + renderChatSearchBar, + renderChatThread, + resetChatThreadPresentationState, + toggleChatThreadSearch, +} from "./components/chat-thread.ts"; +import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts"; +import type { RealtimeTalkCatalogProvider } from "./realtime-talk-catalog.ts"; +import type { RealtimeTalkConversationEntry } from "./realtime-talk-conversation.ts"; +import type { RealtimeTalkStatus } from "./realtime-talk.ts"; +import type { ChatRunUiStatus } from "./run-lifecycle.ts"; +import type { CompactionStatus, FallbackStatus } from "./tool-stream.ts"; +import "../../components/resizable-divider.ts"; + +export type ChatProps = { + sessionKey: string; + onSessionKeyChange: (next: string) => void; + thinkingLevel: string | null; + showThinking: boolean; + showToolCalls: boolean; + loading: boolean; + sending: boolean; + canAbort?: boolean; + runStatus?: ChatRunUiStatus | null; + compactionStatus?: CompactionStatus | null; + fallbackStatus?: FallbackStatus | null; + messages: unknown[]; + sideResult?: ChatSideResult | null; + toolMessages: unknown[]; + streamSegments: ChatStreamSegment[]; + stream: string | null; + streamStartedAt: number | null; + assistantAvatarUrl?: string | null; + draft: string; + queue: ChatQueueItem[]; + realtimeTalkActive?: boolean; + realtimeTalkStatus?: RealtimeTalkStatus; + realtimeTalkDetail?: string | null; + realtimeTalkTranscript?: string | null; + realtimeTalkConversation?: RealtimeTalkConversationEntry[]; + realtimeTalkOptionsOpen?: boolean; + realtimeTalkCatalogProviders?: RealtimeTalkCatalogProvider[] | null; + realtimeTalkOptions?: RealtimeTalkOptions; + connected: boolean; + canSend: boolean; + disabledReason: string | null; + error: string | null; + sessions: SessionsListResult | null; + focusMode?: boolean; + onLoadSidebarFullMessage?: ( + request: SidebarFullMessageRequest, + ) => Promise; + sidebarOpen?: boolean; + sidebarContent?: SidebarContent | null; + splitRatio?: number; + canvasPluginSurfaceUrl?: string | null; + embedSandboxMode?: EmbedSandboxMode; + allowExternalEmbedUrls?: boolean; + chatMessageMaxWidth?: string | null; + assistantName: string; + assistantAvatar: string | null; + userName?: string | null; + userAvatar?: string | null; + localMediaPreviewRoots?: string[]; + assistantAttachmentAuthToken?: string | null; + autoExpandToolCalls?: boolean; + attachments?: ChatAttachment[]; + onAttachmentsChange?: (attachments: ChatAttachment[]) => void; + onAssistantAttachmentLoaded?: () => void; + showNewMessages?: boolean; + onScrollToBottom?: () => void; + onRefresh: () => void; + onToggleFocusMode?: () => void; + getDraft?: () => string; + onDraftChange: (next: string) => void; + onRequestUpdate?: () => void; + onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; + onSlashIntent?: () => void | Promise; + onSend: () => void; + onCompact?: () => void | Promise; + onOpenSessionCheckpoints?: () => void | Promise; + onToggleRealtimeTalk?: () => void; + onToggleRealtimeTalkOptions?: () => void; + onRealtimeTalkOptionsChange?: ( + next: Partial>, + ) => void; + onDismissError?: () => void; + onDismissRealtimeTalkError?: () => void; + onAbort?: () => void; + onQueueRemove: (id: string) => void; + onQueueRetry?: (id: string) => void; + onQueueSteer?: (id: string) => void; + onDismissSideResult?: () => void; + onNewSession: () => void; + onClearHistory?: () => void; + agentsList: { + agents: Array<{ id: string; name?: string; identity?: { name?: string; avatarUrl?: string } }>; + defaultId?: string; + } | null; + currentAgentId: string; + fullMessageAgentId?: string; + onAgentChange: (agentId: string) => void; + onNavigateToAgent?: () => void; + onSessionSelect?: (sessionKey: string) => void; + onOpenSidebar?: (content: SidebarContent) => void; + onCloseSidebar?: () => void; + onSplitRatioChange?: (ratio: number) => void; + onChatScroll?: (event: Event) => void; + basePath?: string; + composerControls?: TemplateResult | typeof nothing; + replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null; + onClearReply?: () => void; + onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void; + sessionWorkspace?: SessionWorkspaceProps; +}; + +export function resetChatViewState() { + resetChatComposerState(); + resetChatThreadPresentationState(); +} + +export function renderChat(props: ChatProps) { + const requestUpdate = props.onRequestUpdate ?? (() => {}); + const splitRatio = props.splitRatio ?? 0.6; + const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); + const canCompose = props.connected && props.canSend; + let chatSection: HTMLElement | null = null; + + const thread = renderChatThread({ + sessionKey: props.sessionKey, + loading: props.loading, + messages: props.messages, + toolMessages: props.toolMessages, + streamSegments: props.streamSegments, + stream: props.stream, + streamStartedAt: props.streamStartedAt, + queue: props.queue, + showThinking: props.showThinking, + showToolCalls: props.showToolCalls, + sessions: props.sessions, + assistantName: props.assistantName, + assistantAvatar: props.assistantAvatar, + assistantAvatarUrl: props.assistantAvatarUrl, + userName: props.userName, + userAvatar: props.userAvatar, + basePath: props.basePath, + fullMessageAgentId: props.fullMessageAgentId, + localMediaPreviewRoots: props.localMediaPreviewRoots, + assistantAttachmentAuthToken: props.assistantAttachmentAuthToken, + canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, + embedSandboxMode: props.embedSandboxMode, + allowExternalEmbedUrls: props.allowExternalEmbedUrls, + autoExpandToolCalls: props.autoExpandToolCalls, + realtimeTalkConversation: props.realtimeTalkConversation, + onOpenSidebar: props.onOpenSidebar, + onOpenSessionCheckpoints: props.onOpenSessionCheckpoints, + onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded, + onRequestUpdate: requestUpdate, + onScrollToBottom: props.onScrollToBottom, + onChatScroll: props.onChatScroll, + onDraftChange: props.onDraftChange, + onSend: props.onSend, + onSetReply: props.onSetReply, + onFocusComposer: () => + chatSection + ?.querySelector(".agent-chat__composer-combobox > textarea") + ?.focus({ preventScroll: true }), + }); + + const chatColumnFooter = renderChatComposer({ + sessionKey: props.sessionKey, + currentAgentId: props.currentAgentId, + connected: props.connected, + canSend: props.canSend, + disabledReason: props.disabledReason, + sending: props.sending, + canAbort: props.canAbort, + runStatus: props.runStatus, + compactionStatus: props.compactionStatus, + fallbackStatus: props.fallbackStatus, + messages: props.messages, + stream: props.stream, + sideResult: props.sideResult, + queue: props.queue, + draft: props.draft, + sessions: props.sessions, + assistantName: props.assistantName, + attachments: props.attachments, + showNewMessages: props.showNewMessages, + replyTarget: props.replyTarget, + realtimeTalkActive: props.realtimeTalkActive, + realtimeTalkStatus: props.realtimeTalkStatus, + realtimeTalkDetail: props.realtimeTalkDetail, + realtimeTalkTranscript: props.realtimeTalkTranscript, + realtimeTalkConversation: props.realtimeTalkConversation, + realtimeTalkOptionsOpen: props.realtimeTalkOptionsOpen, + realtimeTalkCatalogProviders: props.realtimeTalkCatalogProviders, + realtimeTalkOptions: props.realtimeTalkOptions, + composerControls: props.composerControls, + getDraft: props.getDraft, + onDraftChange: props.onDraftChange, + onRequestUpdate: requestUpdate, + onHistoryKeydown: props.onHistoryKeydown, + onSlashIntent: props.onSlashIntent, + onSend: props.onSend, + onCompact: props.onCompact, + onToggleRealtimeTalk: props.onToggleRealtimeTalk, + onToggleRealtimeTalkOptions: props.onToggleRealtimeTalkOptions, + onRealtimeTalkOptionsChange: props.onRealtimeTalkOptionsChange, + onDismissRealtimeTalkError: props.onDismissRealtimeTalkError, + onAbort: props.onAbort, + onQueueRemove: props.onQueueRemove, + onQueueRetry: props.onQueueRetry, + onQueueSteer: props.onQueueSteer, + onDismissSideResult: props.onDismissSideResult, + onNewSession: props.onNewSession, + onClearReply: props.onClearReply, + onScrollToBottom: props.onScrollToBottom, + onAttachmentsChange: props.onAttachmentsChange, + }); + + return html` +
{ + chatSection = element instanceof HTMLElement ? element : null; + })} + class="card chat" + style=${styleMap( + props.chatMessageMaxWidth ? { "--chat-message-max-width": props.chatMessageMaxWidth } : {}, + )} + @drop=${(event: DragEvent) => { + event.preventDefault(); + if (canCompose) { + handleChatAttachmentDrop(event, props); + } + }} + @dragover=${(event: DragEvent) => event.preventDefault()} + @keydown=${(event: KeyboardEvent) => { + if (event.key === "Escape" && props.replyTarget && !event.defaultPrevented) { + event.preventDefault(); + props.onClearReply?.(); + return; + } + if (event.key === "Escape" && props.sideResult && !isChatThreadSearchOpen()) { + event.preventDefault(); + props.onDismissSideResult?.(); + return; + } + if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key === "f") { + event.preventDefault(); + toggleChatThreadSearch(requestUpdate); + } + }} + > + ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} + ${props.error + ? html` + + ` + : nothing} + ${props.focusMode && props.onToggleFocusMode + ? html` + + + + ` + : nothing} + ${renderChatSearchBar(requestUpdate)} + ${renderChatPinnedMessages( + { + sessionKey: props.sessionKey, + messages: props.messages, + userName: props.userName, + userAvatar: props.userAvatar, + }, + requestUpdate, + )} + +
+ ${renderSessionWorkspaceRail(props.sessionWorkspace)} +
+
+
+ ${thread} ${chatColumnFooter} +
+ + ${sidebarOpen + ? html` + + props.onSplitRatioChange?.(event.detail.splitRatio)} + > + props.onCloseSidebar?.()} + > + ` + : nothing} +
+
+
+
+ `; +} diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts new file mode 100644 index 000000000000..1afa2fe5ce39 --- /dev/null +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -0,0 +1,1860 @@ +// Chat-owned composer, queue, status, context, and run controls. +import { html, nothing, type TemplateResult } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { ref } from "lit/directives/ref.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import type { GatewaySessionRow, SessionGoal, SessionsListResult } from "../../../api/types.ts"; +import { icons, type IconName } from "../../../components/icons.ts"; +import { toSanitizedMarkdownHtml } from "../../../components/markdown.ts"; +import "../../../components/tooltip.ts"; +import { t } from "../../../i18n/index.ts"; +import type { ChatAttachment, ChatQueueItem } from "../../../lib/chat/chat-types.ts"; +import { + CATEGORY_LABELS, + SLASH_COMMANDS, + getHiddenCommandCount, + getSlashCommandCompletions, + type SlashCommandCategory, + type SlashCommandDef, +} from "../../../lib/chat/commands.ts"; +import type { ChatSideResult } from "../../../lib/chat/side-result.ts"; +import { formatCompactTokenCount } from "../../../lib/format.ts"; +import { formatGoalDetail, formatGoalSummary } from "../../../lib/session-goal.ts"; +import { detectTextDirection } from "../../../lib/text-direction.ts"; +import { + getChatAttachmentPreviewUrl, + registerChatAttachmentPayload, + releaseChatAttachmentPayload, +} from "../attachment-payload-store.ts"; +import { exportChatMarkdown } from "../export.ts"; +import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts"; +import type { RealtimeTalkCatalogProvider } from "../realtime-talk-catalog.ts"; +import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; +import type { RealtimeTalkStatus } from "../realtime-talk.ts"; +import { CHAT_RUN_STATUS_TOAST_DURATION_MS, type ChatRunUiStatus } from "../run-lifecycle.ts"; +import type { CompactionStatus, FallbackStatus } from "../tool-stream.ts"; +import { renderRealtimeTalkOptions, type RealtimeTalkOptions } from "./chat-realtime-controls.ts"; + +const COMPACTION_TOAST_DURATION_MS = 5000; +const FALLBACK_TOAST_DURATION_MS = 8000; +const CONTEXT_NOTICE_RATIO = 0.85; +const CONTEXT_COMPACT_RATIO = 0.9; +const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "summary", + "[contenteditable='true']", + "[role='button']", + "[role='listbox']", + "[role='option']", +].join(","); +const SLASH_MENU_LISTBOX_ID = "chat-slash-menu-listbox"; +const SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID = "chat-slash-active-announcement"; +export const CHAT_ATTACHMENT_ACCEPT = + "image/*,audio/*,application/pdf,text/*,.csv,.json,.md,.txt,.zip," + + ".doc,.docx,.xls,.xlsx,.ppt,.pptx"; + +export type ChatComposerProps = { + sessionKey: string; + currentAgentId: string; + connected: boolean; + canSend: boolean; + disabledReason: string | null; + sending: boolean; + canAbort?: boolean; + runStatus?: ChatRunUiStatus | null; + compactionStatus?: CompactionStatus | null; + fallbackStatus?: FallbackStatus | null; + messages: unknown[]; + stream: string | null; + sideResult?: ChatSideResult | null; + queue: ChatQueueItem[]; + draft: string; + sessions: SessionsListResult | null; + assistantName: string; + attachments?: ChatAttachment[]; + showNewMessages?: boolean; + replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null; + realtimeTalkActive?: boolean; + realtimeTalkStatus?: RealtimeTalkStatus; + realtimeTalkDetail?: string | null; + realtimeTalkTranscript?: string | null; + realtimeTalkConversation?: RealtimeTalkConversationEntry[]; + realtimeTalkOptionsOpen?: boolean; + realtimeTalkCatalogProviders?: RealtimeTalkCatalogProvider[] | null; + realtimeTalkOptions?: RealtimeTalkOptions; + composerControls?: TemplateResult | typeof nothing; + getDraft?: () => string; + onDraftChange: (next: string) => void; + onRequestUpdate?: () => void; + onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; + onSlashIntent?: () => void | Promise; + onSend: () => void; + onCompact?: () => void | Promise; + onToggleRealtimeTalk?: () => void; + onToggleRealtimeTalkOptions?: () => void; + onRealtimeTalkOptionsChange?: (next: Partial) => void; + onDismissRealtimeTalkError?: () => void; + onAbort?: () => void; + onQueueRemove: (id: string) => void; + onQueueRetry?: (id: string) => void; + onQueueSteer?: (id: string) => void; + onDismissSideResult?: () => void; + onNewSession: () => void; + onClearReply?: () => void; + onScrollToBottom?: () => void; + onAttachmentsChange?: (attachments: ChatAttachment[]) => void; +}; + +type PendingClearedSubmittedDraft = { + key: string; + value: string; +}; + +type ChatComposerState = { + slashMenuOpen: boolean; + slashMenuItems: SlashCommandDef[]; + slashMenuIndex: number; + slashMenuMode: "command" | "args"; + slashMenuCommand: SlashCommandDef | null; + slashMenuArgItems: string[]; + slashMenuExpanded: boolean; + slashCommandRefreshPending: boolean; + composerComposing: boolean; + composerInputIntentKey: string | null; + pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null; +}; + +function createChatComposerState(): ChatComposerState { + return { + slashMenuOpen: false, + slashMenuItems: [], + slashMenuIndex: 0, + slashMenuMode: "command", + slashMenuCommand: null, + slashMenuArgItems: [], + slashMenuExpanded: false, + slashCommandRefreshPending: false, + composerComposing: false, + composerInputIntentKey: null, + pendingClearedSubmittedDraft: null, + }; +} + +const composerState = createChatComposerState(); + +function hasTerminalRunStatus(status: ChatRunUiStatus | null | undefined): boolean { + return status?.phase === "done" || status?.phase === "interrupted"; +} + +function isCurrentSessionSubmittedProgress( + item: ChatQueueItem, + sessionKey: string, + status: ChatRunUiStatus | null | undefined, +): boolean { + return ( + item.sessionKey === sessionKey && + !item.pendingRunId && + (item.sendState === "sending" || item.sendState === "waiting-model") && + (status == null || item.sendRunId !== status.runId) + ); +} + +function composerDraftKey(props: Pick): string { + return `${props.currentAgentId}\u0000${props.sessionKey}`; +} + +function commitComposerDraft(props: ChatComposerProps, value: string): void { + if (props.getDraft?.() === value || props.draft === value) { + return; + } + props.onDraftChange(value); +} + +function markComposerInputIntent(key: string): void { + composerState.composerInputIntentKey = key; +} + +function consumeComposerInputIntent(key: string): boolean { + if (composerState.composerInputIntentKey !== key) { + return false; + } + composerState.composerInputIntentKey = null; + return true; +} + +function clearPendingClearedSubmittedDraft(key: string): void { + if (composerState.pendingClearedSubmittedDraft?.key === key) { + composerState.pendingClearedSubmittedDraft = null; + } +} + +function isExplicitComposerInsertion(event: InputEvent): boolean { + return event.inputType === "insertFromPaste" || event.inputType === "insertFromDrop"; +} + +function suppressStaleSubmittedDraftReplay( + target: HTMLTextAreaElement, + event: InputEvent, + currentDraft: string, + hasInputIntent: boolean, +): boolean { + const pending = composerState.pendingClearedSubmittedDraft; + if (!pending) { + return false; + } + if (target.value !== pending.value || hasInputIntent || isExplicitComposerInsertion(event)) { + return false; + } + + target.value = currentDraft; + adjustTextareaHeight(target); + return true; +} + +export function resetChatComposerState() { + Object.assign(composerState, createChatComposerState()); +} + +function adjustTextareaHeight(el: HTMLTextAreaElement) { + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 150)}px`; +} + +function focusComposerFromChrome(event: MouseEvent, connected: boolean) { + if (!connected || event.defaultPrevented) { + return; + } + const target = event.target; + const currentTarget = event.currentTarget; + if (!(target instanceof Element) || !(currentTarget instanceof HTMLElement)) { + return; + } + if (target.closest(COMPOSER_CHROME_INTERACTIVE_SELECTOR)) { + return; + } + currentTarget + .querySelector(".agent-chat__composer-combobox > textarea") + ?.focus({ preventScroll: true }); +} + +function restoreHistoryCaret(target: HTMLTextAreaElement, direction: "up" | "down") { + requestAnimationFrame(() => { + if (document.activeElement !== target) { + return; + } + adjustTextareaHeight(target); + const caret = direction === "up" ? 0 : target.value.length; + target.selectionStart = caret; + target.selectionEnd = caret; + }); +} + +function renderChatGoal(goal: SessionGoal | undefined): TemplateResult | typeof nothing { + if (!goal) { + return nothing; + } + return html` + +
+ ${formatGoalSummary(goal)} + ${goal.objective} +
+
+ `; +} + +function resetSlashMenuState(): void { + composerState.slashMenuMode = "command"; + composerState.slashMenuCommand = null; + composerState.slashMenuArgItems = []; + composerState.slashMenuItems = []; + composerState.slashMenuExpanded = false; +} + +function hasVisibleSlashMenuState(): boolean { + return ( + composerState.slashMenuOpen || + composerState.slashMenuMode !== "command" || + composerState.slashMenuCommand !== null || + composerState.slashMenuArgItems.length > 0 || + composerState.slashMenuItems.length > 0 || + composerState.slashMenuExpanded + ); +} + +function closeSlashMenuIfNeeded(requestUpdate: () => void): void { + if (!hasVisibleSlashMenuState()) { + return; + } + composerState.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); +} + +function requestSlashCommandRefresh( + value: string, + props: ChatComposerProps, + requestUpdate: () => void, + getCurrentValue?: () => string, +): void { + if (!props.onSlashIntent || composerState.slashCommandRefreshPending) { + return; + } + const refresh = props.onSlashIntent(); + if (!refresh || typeof refresh.then !== "function") { + return; + } + composerState.slashCommandRefreshPending = true; + void Promise.resolve(refresh).finally(() => { + composerState.slashCommandRefreshPending = false; + const nextValue = getCurrentValue?.() ?? props.getDraft?.() ?? value; + if (!nextValue.startsWith("/")) { + closeSlashMenuIfNeeded(requestUpdate); + return; + } + updateSlashMenu(nextValue, requestUpdate, props, { skipSlashIntent: true }); + }); +} + +function updateSlashMenu( + value: string, + requestUpdate: () => void, + props: ChatComposerProps, + opts: { skipSlashIntent?: boolean } = {}, + getCurrentValue?: () => string, +): void { + const argMatch = value.match(/^\/(\S+)\s(.*)$/); + if (argMatch) { + if (!opts.skipSlashIntent) { + requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue); + } + const cmdName = argMatch[1].toLowerCase(); + const argFilter = argMatch[2].toLowerCase(); + const cmd = SLASH_COMMANDS.find((entry) => entry.name === cmdName); + if (cmd?.argOptions?.length) { + const filtered = argFilter + ? cmd.argOptions.filter((arg) => arg.toLowerCase().startsWith(argFilter)) + : cmd.argOptions; + if (filtered.length > 0) { + composerState.slashMenuMode = "args"; + composerState.slashMenuCommand = cmd; + composerState.slashMenuArgItems = filtered; + composerState.slashMenuOpen = true; + composerState.slashMenuIndex = 0; + composerState.slashMenuItems = []; + requestUpdate(); + return; + } + } + closeSlashMenuIfNeeded(requestUpdate); + return; + } + + const match = value.match(/^\/(\S*)$/); + if (match) { + if (!opts.skipSlashIntent) { + requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue); + } + const items = getSlashCommandCompletions(match[1], { + showAll: composerState.slashMenuExpanded, + }); + composerState.slashMenuItems = items; + composerState.slashMenuOpen = items.length > 0; + composerState.slashMenuIndex = 0; + composerState.slashMenuMode = "command"; + composerState.slashMenuCommand = null; + composerState.slashMenuArgItems = []; + } else { + closeSlashMenuIfNeeded(requestUpdate); + return; + } + requestUpdate(); +} + +function selectSlashCommand( + cmd: SlashCommandDef, + props: ChatComposerProps, + requestUpdate: () => void, +) { + if (cmd.argOptions?.length) { + commitComposerDraft(props, `/${cmd.name} `); + composerState.slashMenuMode = "args"; + composerState.slashMenuCommand = cmd; + composerState.slashMenuArgItems = cmd.argOptions; + composerState.slashMenuOpen = true; + composerState.slashMenuIndex = 0; + composerState.slashMenuItems = []; + requestUpdate(); + return; + } + + if (cmd.executeLocal && !cmd.args) { + composerState.slashMenuOpen = false; + resetSlashMenuState(); + commitComposerDraft(props, `/${cmd.name}`); + props.onSend(); + } else { + commitComposerDraft(props, `/${cmd.name} `); + closeSlashMenuIfNeeded(requestUpdate); + } +} + +function tabCompleteSlashCommand( + cmd: SlashCommandDef, + props: ChatComposerProps, + requestUpdate: () => void, +) { + if (cmd.argOptions?.length) { + commitComposerDraft(props, `/${cmd.name} `); + composerState.slashMenuMode = "args"; + composerState.slashMenuCommand = cmd; + composerState.slashMenuArgItems = cmd.argOptions; + composerState.slashMenuOpen = true; + composerState.slashMenuIndex = 0; + composerState.slashMenuItems = []; + requestUpdate(); + return; + } + commitComposerDraft(props, cmd.args ? `/${cmd.name} ` : `/${cmd.name}`); + composerState.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); +} + +function selectSlashArg( + arg: string, + props: ChatComposerProps, + requestUpdate: () => void, + run: boolean, +) { + const cmdName = composerState.slashMenuCommand?.name ?? ""; + composerState.slashMenuOpen = false; + resetSlashMenuState(); + commitComposerDraft(props, `/${cmdName} ${arg}`); + if (run) { + props.onSend(); + } + requestUpdate(); +} + +function slashOptionIdSegment(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9_-]+/gu, "-") + .replace(/^-+|-+$/gu, "") || "item" + ); +} + +function getSlashCommandOptionId(cmd: SlashCommandDef): string { + return `chat-slash-option-command-${slashOptionIdSegment(cmd.name)}`; +} + +function getSlashArgOptionId(commandName: string, arg: string): string { + return `chat-slash-option-arg-${slashOptionIdSegment(commandName)}-${slashOptionIdSegment(arg)}`; +} + +function isSlashMenuVisible(): boolean { + if (!composerState.slashMenuOpen) { + return false; + } + if (composerState.slashMenuMode === "args") { + return Boolean(composerState.slashMenuCommand && composerState.slashMenuArgItems.length > 0); + } + return composerState.slashMenuItems.length > 0; +} + +function getActiveSlashMenuOptionId(): string | null { + if (!isSlashMenuVisible()) { + return null; + } + if (composerState.slashMenuMode === "args") { + const commandName = composerState.slashMenuCommand?.name; + const arg = composerState.slashMenuArgItems[composerState.slashMenuIndex]; + return commandName && arg ? getSlashArgOptionId(commandName, arg) : null; + } + const cmd = composerState.slashMenuItems[composerState.slashMenuIndex]; + return cmd ? getSlashCommandOptionId(cmd) : null; +} + +function getActiveSlashMenuOptionLabel(): string { + if (!isSlashMenuVisible()) { + return ""; + } + if (composerState.slashMenuMode === "args") { + const commandName = composerState.slashMenuCommand?.name; + const arg = composerState.slashMenuArgItems[composerState.slashMenuIndex]; + return commandName && arg ? `/${commandName} ${arg}` : ""; + } + const cmd = composerState.slashMenuItems[composerState.slashMenuIndex]; + if (!cmd) { + return ""; + } + const command = `/${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}`; + return `${command} ${cmd.description}`; +} + +function scrollActiveSlashMenuOptionIntoView(): void { + const activeId = getActiveSlashMenuOptionId(); + if (!activeId) { + return; + } + requestAnimationFrame(() => { + const activeOption = document.getElementById(activeId); + const menu = activeOption?.closest(".slash-menu"); + if (!activeOption || !menu) { + return; + } + const menuBounds = menu.getBoundingClientRect(); + const optionBounds = activeOption.getBoundingClientRect(); + // scrollIntoView also moves the short-landscape composer and page. Keep + // keyboard navigation owned by the menu so textarea focus stays stable. + if (optionBounds.top < menuBounds.top) { + menu.scrollTop -= menuBounds.top - optionBounds.top; + } else if (optionBounds.bottom > menuBounds.bottom) { + menu.scrollTop += optionBounds.bottom - menuBounds.bottom; + } + }); +} + +function renderSlashIcon(name: string) { + return icons[name as IconName] ?? icons.terminal; +} + +function tokenEstimate(draft: string): string | null { + if (draft.length < 100) { + return null; + } + return `~${Math.ceil(draft.length / 4)} tokens`; +} + +function exportMarkdown(props: Pick): void { + exportChatMarkdown(props.messages, props.assistantName); +} + +function renderSlashMenu( + requestUpdate: () => void, + props: ChatComposerProps, + draft: string, +): TemplateResult | typeof nothing { + if (!composerState.slashMenuOpen) { + return nothing; + } + + if ( + composerState.slashMenuMode === "args" && + composerState.slashMenuCommand && + composerState.slashMenuArgItems.length > 0 + ) { + return html` +
+
+
+ /${composerState.slashMenuCommand.name} ${composerState.slashMenuCommand.description} +
+ ${composerState.slashMenuArgItems.map( + (arg, i) => html` +
selectSlashArg(arg, props, requestUpdate, true)} + @mouseenter=${() => { + composerState.slashMenuIndex = i; + requestUpdate(); + }} + > + ${composerState.slashMenuCommand?.icon + ? html`${renderSlashIcon(composerState.slashMenuCommand.icon)}` + : nothing} + ${arg} + /${composerState.slashMenuCommand?.name} ${arg} +
+ `, + )} +
+ +
+ `; + } + + if (composerState.slashMenuItems.length === 0) { + return nothing; + } + + const grouped = new Map< + SlashCommandCategory, + Array<{ cmd: SlashCommandDef; globalIdx: number }> + >(); + for (let i = 0; i < composerState.slashMenuItems.length; i++) { + const cmd = composerState.slashMenuItems[i]; + const cat = cmd.category ?? "session"; + let list = grouped.get(cat); + if (!list) { + list = []; + grouped.set(cat, list); + } + list.push({ cmd, globalIdx: i }); + } + + const sections: TemplateResult[] = []; + for (const [cat, entries] of grouped) { + sections.push(html` +
+
${CATEGORY_LABELS[cat]}
+ ${entries.map( + ({ cmd, globalIdx }) => html` +
selectSlashCommand(cmd, props, requestUpdate)} + @mouseenter=${() => { + composerState.slashMenuIndex = globalIdx; + requestUpdate(); + }} + > + ${cmd.icon + ? html`${renderSlashIcon(cmd.icon)}` + : nothing} + /${cmd.name} + ${cmd.args ? html`${cmd.args}` : nothing} + ${cmd.description} + ${cmd.argOptions?.length + ? html`${cmd.argOptions.length} options` + : cmd.executeLocal && !cmd.args + ? html` instant ` + : nothing} +
+ `, + )} +
+ `); + } + + const hiddenCount = composerState.slashMenuExpanded ? 0 : getHiddenCommandCount(); + + return html` +
+ ${sections} + ${hiddenCount > 0 + ? html`` + : nothing} + +
+ `; +} + +export type ChatAttachmentControlsProps = { + attachments?: ChatAttachment[]; + onAttachmentsChange?: (attachments: ChatAttachment[]) => void; +}; + +export type ChatQueueProps = { + queue: ChatQueueItem[]; + canAbort?: boolean; + onQueueRetry?: (id: string) => void; + onQueueSteer?: (id: string) => void; + onQueueRemove: (id: string) => void; +}; + +function sendStateLabel(item: ChatQueueItem): string | null { + switch (item.sendState) { + case "waiting-model": + return "Waiting for model"; + case "waiting-reconnect": + return "Waiting for reconnect"; + case "failed": + return "Failed"; + default: + return null; + } +} + +export function renderChatQueue(props: ChatQueueProps) { + const visibleQueue = props.queue.filter((item) => item.sendState !== "sending"); + if (!visibleQueue.length) { + return nothing; + } + return html` +
+
Queued (${visibleQueue.length})
+
+ ${visibleQueue.map((item) => { + const stateLabel = sendStateLabel(item); + return html` +
+
+ ${item.kind === "steered" + ? html`Steered` + : nothing} + ${stateLabel ? html`${stateLabel}` : nothing} +
+ ${item.text || + (item.attachments?.length ? `Image (${item.attachments.length})` : "")} +
+ ${item.sendError + ? html`
${item.sendError}
` + : nothing} +
+
+ ${item.sendState === "failed" && props.onQueueRetry + ? html` + + ` + : nothing} + ${props.canAbort && + props.onQueueSteer && + item.kind !== "steered" && + !item.sendState && + !item.localCommandName + ? html` + + ` + : nothing} + + + +
+
+ `; + })} +
+
+ `; +} + +export function renderSideResult( + sideResult: ChatSideResult | null | undefined, + onDismiss?: () => void, +): TemplateResult | typeof nothing { + if (!sideResult) { + return nothing; + } + return html` +
+
+
+ BTW + Not saved to chat history +
+ + + +
+
${sideResult.question}
+
+ ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} +
+
+ `; +} + +function isSupportedChatAttachmentFile(file: Pick): boolean { + if (file.type.startsWith("video/")) { + return false; + } + return !/\.(?:avi|m4v|mov|mp4|mpeg|mpg|webm)$/i.test(file.name); +} + +export function clickComposerFileInput(event: MouseEvent) { + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) { + return; + } + target + .closest(".agent-chat__input") + ?.querySelector(".agent-chat__file-input") + ?.click(); +} + +function generateAttachmentId(): string { + return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function chatAttachmentFromFile(file: File, dataUrl: string): ChatAttachment { + const attachment = { + id: generateAttachmentId(), + mimeType: file.type || "application/octet-stream", + fileName: file.name || undefined, + sizeBytes: file.size, + }; + return registerChatAttachmentPayload({ attachment, dataUrl, file }); +} + +function dataImageClipboardFile(dataUrl: string): { file: File; dataUrl: string } | null { + const match = /^\s*data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)\s*$/i.exec(dataUrl); + if (!match) { + return null; + } + const mimeType = match[1].toLowerCase(); + if (!isSupportedChatAttachmentFile({ name: "pasted-image", type: mimeType })) { + return null; + } + const base64 = match[2].replace(/\s+/g, ""); + try { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; + return { + file: new File([bytes], `pasted-image.${extension}`, { type: mimeType }), + dataUrl: `data:${mimeType};base64,${base64}`, + }; + } catch { + return null; + } +} + +function isImageAttachment(att: ChatAttachment): boolean { + return att.mimeType.startsWith("image/"); +} + +export function handleChatAttachmentPaste(e: ClipboardEvent, props: ChatAttachmentControlsProps) { + const items = e.clipboardData?.items; + if (!items || !props.onAttachmentsChange) { + return; + } + const imageItems: DataTransferItem[] = []; + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + imageItems.push(item); + } + } + if (imageItems.length === 0) { + const text = e.clipboardData?.getData("text/plain"); + const pasted = text ? dataImageClipboardFile(text) : null; + if (!pasted) { + return; + } + e.preventDefault(); + props.onAttachmentsChange([ + ...(props.attachments ?? []), + chatAttachmentFromFile(pasted.file, pasted.dataUrl), + ]); + return; + } + e.preventDefault(); + for (const item of imageItems) { + const file = item.getAsFile(); + if (!file) { + continue; + } + const reader = new FileReader(); + reader.addEventListener("load", () => { + const dataUrl = reader.result as string; + const newAttachment = chatAttachmentFromFile(file, dataUrl); + const current = props.attachments ?? []; + props.onAttachmentsChange?.([...current, newAttachment]); + }); + reader.readAsDataURL(file); + } +} + +export function handleChatAttachmentFileSelect(e: Event, props: ChatAttachmentControlsProps) { + const input = e.target as HTMLInputElement; + if (!input.files || !props.onAttachmentsChange) { + return; + } + const current = props.attachments ?? []; + const additions: ChatAttachment[] = []; + let pending = 0; + for (const file of input.files) { + if (!isSupportedChatAttachmentFile(file)) { + continue; + } + pending++; + const reader = new FileReader(); + reader.addEventListener("load", () => { + additions.push(chatAttachmentFromFile(file, reader.result as string)); + pending--; + if (pending === 0) { + props.onAttachmentsChange?.([...current, ...additions]); + } + }); + reader.readAsDataURL(file); + } + input.value = ""; +} + +export function handleChatAttachmentDrop(e: DragEvent, props: ChatAttachmentControlsProps) { + e.preventDefault(); + const files = e.dataTransfer?.files; + if (!files || !props.onAttachmentsChange) { + return; + } + const current = props.attachments ?? []; + const additions: ChatAttachment[] = []; + let pending = 0; + for (const file of files) { + if (!isSupportedChatAttachmentFile(file)) { + continue; + } + pending++; + const reader = new FileReader(); + reader.addEventListener("load", () => { + additions.push(chatAttachmentFromFile(file, reader.result as string)); + pending--; + if (pending === 0) { + props.onAttachmentsChange?.([...current, ...additions]); + } + }); + reader.readAsDataURL(file); + } +} + +export function renderAttachmentPreview(props: ChatAttachmentControlsProps) { + const attachments = props.attachments ?? []; + if (attachments.length === 0) { + return nothing; + } + return html` +
+ ${attachments.map( + (att) => html` +
+ ${isImageAttachment(att) && getChatAttachmentPreviewUrl(att) + ? html`Attachment preview` + : html` + +
+ ${icons.paperclip} + ${att.fileName ?? "Attached file"} +
+
+ `} + + + +
+ `, + )} +
+ `; +} + +export type ComposerRunStatus = + | ChatRunUiStatus + | { + phase: "in-progress"; + occurredAt?: number | null; + }; + +export function renderChatRunStatusIndicator(status: ComposerRunStatus | null | undefined) { + if (!status) { + return nothing; + } + if (status.phase !== "in-progress") { + const elapsed = Date.now() - status.occurredAt; + if (elapsed >= CHAT_RUN_STATUS_TOAST_DURATION_MS) { + return nothing; + } + } + const label = + status.phase === "in-progress" + ? "In progress" + : status.phase === "done" + ? "Done" + : "Interrupted"; + const icon = + status.phase === "in-progress" + ? icons.loader + : status.phase === "done" + ? icons.check + : icons.stop; + return html` + + ${icon}${label} + + `; +} + +export function renderCompactionIndicator(status: CompactionStatus | null | undefined) { + if (!status) { + return nothing; + } + if (status.phase === "active" || status.phase === "retrying") { + return html` +
+ ${icons.loader} Compacting context... +
+ `; + } + if (status.completedAt) { + const elapsed = Date.now() - status.completedAt; + if (elapsed < COMPACTION_TOAST_DURATION_MS) { + return html` +
+ ${icons.check} Context compacted +
+ `; + } + } + return nothing; +} + +export function renderFallbackIndicator(status: FallbackStatus | null | undefined) { + if (!status) { + return nothing; + } + const phase = status.phase ?? "active"; + const elapsed = Date.now() - status.occurredAt; + if (elapsed >= FALLBACK_TOAST_DURATION_MS) { + return nothing; + } + const details = [ + `Selected: ${status.selected}`, + phase === "cleared" ? `Active: ${status.selected}` : `Active: ${status.active}`, + phase === "cleared" && status.previous ? `Previous fallback: ${status.previous}` : null, + status.reason ? `Reason: ${status.reason}` : null, + status.attempts.length > 0 ? `Attempts: ${status.attempts.slice(0, 3).join(" | ")}` : null, + ] + .filter(Boolean) + .join(" • "); + const message = + phase === "cleared" + ? `Fallback cleared: ${status.selected}` + : `Fallback active: ${status.active}`; + const className = + phase === "cleared" + ? "compaction-indicator compaction-indicator--fallback-cleared" + : "compaction-indicator compaction-indicator--fallback"; + const icon = phase === "cleared" ? icons.check : icons.brain; + return html` + +
+ ${icon} ${message} +
+
+ `; +} + +export type ContextNoticeOptions = { + compactBusy?: boolean; + compactDisabled?: boolean; + onCompact?: () => void | Promise; +}; + +function parseHexRgb(hex: string): [number, number, number] | null { + const h = hex.trim().replace(/^#/, ""); + if (!/^[0-9a-fA-F]{6}$/.test(h)) { + return null; + } + return [ + Number.parseInt(h.slice(0, 2), 16), + Number.parseInt(h.slice(2, 4), 16), + Number.parseInt(h.slice(4, 6), 16), + ]; +} + +let cachedThemeNoticeColors: { + warnHex: string; + dangerHex: string; + warnRgb: [number, number, number]; + dangerRgb: [number, number, number]; +} | null = null; + +function getThemeNoticeColors() { + if (cachedThemeNoticeColors) { + return cachedThemeNoticeColors; + } + const rootStyle = getComputedStyle(document.documentElement); + const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b"; + const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444"; + cachedThemeNoticeColors = { + warnHex, + dangerHex, + warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11], + dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68], + }; + return cachedThemeNoticeColors; +} + +export function resetContextNoticeThemeCacheForTest(): void { + cachedThemeNoticeColors = null; +} + +export function getContextNoticeViewModel( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +): { + pct: number; + detail: string; + color: string; + bg: string; + warning: boolean; + compactRecommended: boolean; +} | null { + if (session?.totalTokensFresh === false) { + return null; + } + const used = session?.totalTokens; + const limit = session?.contextTokens ?? defaultContextTokens ?? 0; + if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || !limit) { + return null; + } + const ratio = used / limit; + const pct = Math.min(Math.round(ratio * 100), 100); + const warning = ratio >= CONTEXT_NOTICE_RATIO; + if (!warning) { + return { + pct, + detail: `${formatCompactTokenCount(used)} / ${formatCompactTokenCount(limit)}`, + color: "var(--muted)", + bg: "color-mix(in srgb, var(--muted) 8%, transparent)", + warning, + compactRecommended: false, + }; + } + const { warnRgb, dangerRgb } = getThemeNoticeColors(); + const [wr, wg, wb] = warnRgb; + const [dr, dg, db] = dangerRgb; + const mix = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); + const r = Math.round(wr + (dr - wr) * mix); + const g = Math.round(wg + (dg - wg) * mix); + const b = Math.round(wb + (db - wb) * mix); + const color = `rgb(${r}, ${g}, ${b})`; + const bgOpacity = 0.08 + 0.08 * mix; + const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; + return { + pct, + detail: `${formatCompactTokenCount(used)} / ${formatCompactTokenCount(limit)}`, + color, + bg, + warning, + compactRecommended: ratio >= CONTEXT_COMPACT_RATIO, + }; +} + +const RING_RADIUS = 6.5; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +export function renderContextNotice( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, + options: ContextNoticeOptions = {}, +) { + const model = getContextNoticeViewModel(session, defaultContextTokens); + if (!model) { + return nothing; + } + const canRenderCompact = model.compactRecommended && options.onCompact; + const compactDisabled = options.compactDisabled === true || options.compactBusy === true; + const summary = `Session context usage: ${model.detail} (${model.pct}%)`; + const dashOffset = RING_CIRCUMFERENCE * (1 - model.pct / 100); + return html` +
+ + ${model.pct}% + ${canRenderCompact + ? html` + + ` + : nothing} +
+ `; +} + +export type ChatRunControlsProps = { + canAbort: boolean; + connected: boolean; + draft: string; + hasMessages: boolean; + isBusy: boolean; + sending: boolean; + onAbort?: () => void; + onExport: () => void; + onNewSession: () => void; + onSend: () => void; + onStoreDraft: (draft: string) => void; + showSecondary?: boolean; +}; + +export function renderChatRunControls(props: ChatRunControlsProps) { + const showSecondary = props.showSecondary ?? true; + const storeDraftAndSend = () => { + if (props.draft.trim()) { + props.onStoreDraft(props.draft); + } + props.onSend(); + }; + + return html` +
+ ${showSecondary && !props.canAbort + ? html` + + + + ` + : nothing} + ${showSecondary + ? html` + + + + ` + : nothing} + ${props.canAbort + ? html` + + + + + + + ` + : html` + + + + `} +
+ `; +} + +export function renderChatComposer(props: ChatComposerProps) { + const canCompose = props.connected && props.canSend; + const isBusy = props.sending || props.stream !== null; + const canAbort = Boolean(props.canAbort && props.onAbort); + const hasTerminalStatus = hasTerminalRunStatus(props.runStatus); + const showAbortableUi = canAbort && !hasTerminalStatus; + const showSubmittedProgressUi = props.queue.some((item) => + isCurrentSessionSubmittedProgress(item, props.sessionKey, props.runStatus), + ); + const composerRunStatus = + showAbortableUi || showSubmittedProgressUi + ? { phase: "in-progress" as const } + : props.runStatus; + const compactBusy = + props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying"; + const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey); + const visibleDraft = props.draft; + let composerTextarea: HTMLTextAreaElement | null = null; + const hasAttachments = (props.attachments?.length ?? 0) > 0; + const tokens = tokenEstimate(visibleDraft); + const composerControls = props.composerControls; + const requestUpdate = props.onRequestUpdate ?? (() => {}); + + const placeholder = !props.connected + ? t("chat.composer.placeholderDisconnected") + : !canCompose && props.disabledReason + ? props.disabledReason + : hasAttachments + ? t("chat.composer.placeholderWithAttachments") + : t("chat.composer.placeholder", { name: props.assistantName || "agent" }); + + const syncComposerDraftAfterSend = (target: HTMLTextAreaElement | null) => { + const submittedDraft = target?.value ?? props.getDraft?.() ?? props.draft; + const hostDraft = props.getDraft?.() ?? props.draft; + const draftKey = composerDraftKey(props); + const clearedSubmittedDraft = + hostDraft === "" && submittedDraft !== "" && target?.value === submittedDraft; + if (clearedSubmittedDraft) { + composerState.pendingClearedSubmittedDraft = { + key: draftKey, + value: submittedDraft, + }; + } else { + clearPendingClearedSubmittedDraft(draftKey); + } + if (target && target.value !== hostDraft) { + target.value = hostDraft; + adjustTextareaHeight(target); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (composerState.composerComposing || event.isComposing || event.keyCode === 229) { + return; + } + + if ( + composerState.slashMenuOpen && + composerState.slashMenuMode === "args" && + composerState.slashMenuArgItems.length > 0 + ) { + const len = composerState.slashMenuArgItems.length; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + composerState.slashMenuIndex = (composerState.slashMenuIndex + 1) % len; + requestUpdate(); + scrollActiveSlashMenuOptionIntoView(); + return; + case "ArrowUp": + event.preventDefault(); + composerState.slashMenuIndex = (composerState.slashMenuIndex - 1 + len) % len; + requestUpdate(); + scrollActiveSlashMenuOptionIntoView(); + return; + case "Tab": + event.preventDefault(); + selectSlashArg( + composerState.slashMenuArgItems[composerState.slashMenuIndex], + props, + requestUpdate, + false, + ); + return; + case "Enter": + event.preventDefault(); + selectSlashArg( + composerState.slashMenuArgItems[composerState.slashMenuIndex], + props, + requestUpdate, + true, + ); + return; + case "Escape": + event.preventDefault(); + composerState.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); + return; + } + } + + if (composerState.slashMenuOpen && composerState.slashMenuItems.length > 0) { + const len = composerState.slashMenuItems.length; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + composerState.slashMenuIndex = (composerState.slashMenuIndex + 1) % len; + requestUpdate(); + scrollActiveSlashMenuOptionIntoView(); + return; + case "ArrowUp": + event.preventDefault(); + composerState.slashMenuIndex = (composerState.slashMenuIndex - 1 + len) % len; + requestUpdate(); + scrollActiveSlashMenuOptionIntoView(); + return; + case "Tab": + event.preventDefault(); + tabCompleteSlashCommand( + composerState.slashMenuItems[composerState.slashMenuIndex], + props, + requestUpdate, + ); + return; + case "Enter": + event.preventDefault(); + selectSlashCommand( + composerState.slashMenuItems[composerState.slashMenuIndex], + props, + requestUpdate, + ); + return; + case "Escape": + event.preventDefault(); + composerState.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); + return; + } + } + + if ((event.key === "ArrowUp" || event.key === "ArrowDown") && props.onHistoryKeydown) { + const target = event.target as HTMLTextAreaElement; + commitComposerDraft(props, target.value); + const result = props.onHistoryKeydown({ + key: event.key, + selectionStart: target.selectionStart, + selectionEnd: target.selectionEnd, + valueLength: target.value.length, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + isComposing: event.isComposing, + keyCode: event.keyCode, + }); + if (result.handled) { + if (result.preventDefault) { + event.preventDefault(); + } + if (result.restoreCaret) { + restoreHistoryCaret(target, result.restoreCaret); + } + return; + } + } + + if (event.key === "Enter" && !event.shiftKey) { + if (!canCompose) { + return; + } + event.preventDefault(); + const target = event.target as HTMLTextAreaElement; + commitComposerDraft(props, target.value); + props.onSend(); + syncComposerDraftAfterSend(target); + } + }; + + const syncComposerValue = (target: HTMLTextAreaElement) => { + adjustTextareaHeight(target); + commitComposerDraft(props, target.value); + updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value); + }; + const handleBeforeInput = (event: InputEvent) => { + if (!composerState.composerComposing && !event.isComposing) { + markComposerInputIntent(composerDraftKey(props)); + } + }; + const handleInput = (event: InputEvent) => { + const target = event.target as HTMLTextAreaElement; + const draftKey = composerDraftKey(props); + const hasInputIntent = consumeComposerInputIntent(draftKey); + if (composerState.composerComposing || event.isComposing) { + return; + } + if ( + suppressStaleSubmittedDraftReplay( + target, + event, + props.getDraft?.() ?? props.draft, + hasInputIntent, + ) + ) { + return; + } + syncComposerValue(target); + }; + const handleCompositionEnd = (event: CompositionEvent) => { + composerState.composerComposing = false; + syncComposerValue(event.target as HTMLTextAreaElement); + }; + const handleBlur = (event: FocusEvent) => { + const target = event.target as HTMLTextAreaElement; + commitComposerDraft(props, target.value); + }; + const handleSend = () => { + if (!canCompose) { + return; + } + commitComposerDraft(props, composerTextarea?.value ?? props.draft); + props.onSend(); + syncComposerDraftAfterSend(composerTextarea); + }; + const slashMenuVisible = canCompose && isSlashMenuVisible(); + const activeSlashMenuOptionId = getActiveSlashMenuOptionId(); + const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel(); + + return html` + ${renderChatQueue({ + queue: props.queue, + canAbort: showAbortableUi, + onQueueRetry: canCompose ? props.onQueueRetry : undefined, + onQueueSteer: canCompose ? props.onQueueSteer : undefined, + onQueueRemove: props.onQueueRemove, + })} + ${renderSideResult(props.sideResult, props.onDismissSideResult)} + ${props.showNewMessages + ? html` + + ` + : nothing} + +
focusComposerFromChrome(event, canCompose)} + > + ${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing} + ${renderAttachmentPreview(props)} + ${props.replyTarget + ? html` +
+ ${icons.messageSquare} + Replying to ${props.replyTarget.senderLabel ?? "message"} + ${props.replyTarget.text.slice(0, 120)}${props.replyTarget.text.length > 120 + ? "..." + : ""} + +
+ ` + : nothing} +
+ ${renderFallbackIndicator(props.fallbackStatus)} + ${renderCompactionIndicator(props.compactionStatus)} ${renderChatGoal(activeSession?.goal)} +
+ + { + if (canCompose) { + handleChatAttachmentFileSelect(event, props); + } + }} + /> + + ${renderRealtimeTalkOptions(props)} + ${props.realtimeTalkActive || props.realtimeTalkDetail || props.realtimeTalkTranscript + ? html` +
+ + ${props.realtimeTalkDetail ?? + ((props.realtimeTalkConversation?.length ?? 0) === 0 + ? props.realtimeTalkTranscript + : null) ?? + (props.realtimeTalkStatus === "thinking" + ? "Asking OpenClaw..." + : props.realtimeTalkStatus === "connecting" + ? "Connecting Talk..." + : "Talk live")} + + ${props.realtimeTalkStatus === "error" && props.onDismissRealtimeTalkError + ? html` + + + + ` + : nothing} +
+ ` + : nothing} + +
+ + ${activeSlashMenuOptionLabel} +
+ +
+
+ + + + + ${props.onToggleRealtimeTalk + ? html` + + + + ` + : nothing} + ${props.onToggleRealtimeTalkOptions + ? html` + + + + ` + : nothing} + ${tokens ? html`${tokens}` : nothing} + ${renderChatRunStatusIndicator(composerRunStatus)} +
+ + ${composerControls && composerControls !== nothing + ? html`
${composerControls}
` + : nothing} + ${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, { + compactBusy, + compactDisabled: !canCompose || isBusy || showAbortableUi, + onCompact: props.onCompact, + })} + ${renderChatRunControls({ + canAbort: showAbortableUi, + connected: canCompose, + draft: visibleDraft, + hasMessages: props.messages.length > 0, + isBusy, + sending: props.sending, + onAbort: props.onAbort, + onExport: () => exportMarkdown(props), + onNewSession: props.onNewSession, + onSend: handleSend, + onStoreDraft: () => {}, + showSecondary: false, + })} +
+
+ `; +} diff --git a/ui/src/pages/chat/components/chat-controls.ts b/ui/src/pages/chat/components/chat-controls.ts new file mode 100644 index 000000000000..0d779d41b5bc --- /dev/null +++ b/ui/src/pages/chat/components/chat-controls.ts @@ -0,0 +1,320 @@ +// Chat-owned quota, settings, refresh, and display controls. +import { html } from "lit"; +import type { AgentsListResult, SessionsListResult } from "../../../api/types.ts"; +import { + normalizeChatAutoScrollMode, + type ChatAutoScrollMode, + type UiSettings, +} from "../../../app/settings.ts"; +import { icons } from "../../../components/icons.ts"; +import { + renderProviderQuotaPill, + type ProviderQuotaPillProps, +} from "../../../components/provider-quota-pill.ts"; +import "../../../components/tooltip.ts"; +import { t } from "../../../i18n/index.ts"; +import { isCronSessionKey } from "../../../lib/session-display.ts"; +import { + isSessionKeyTiedToAgent, + normalizeAgentId, + parseAgentSessionKey, +} from "../../../lib/sessions/session-key.ts"; +import { renderChatModelControls, type ChatModelControlsProps } from "./chat-model-controls.ts"; + +export type ChatControlsProps = { + agentsList: AgentsListResult | null; + connected: boolean; + hideCronSessions: boolean; + loading: boolean; + manualRefreshInFlight: boolean; + model: ChatModelControlsProps; + onboarding: boolean; + quota: ProviderQuotaPillProps; + runId: string | null; + sending: boolean; + settings: UiSettings; + settingsOpen: boolean; + sessionKey: string; + sessionsResult: SessionsListResult | null; + stream: string | null; + onRefresh: () => Promise | void; + onSettingsChange: (next: UiSettings) => void; + onSettingsOpenChange: ( + open: boolean, + options?: { trigger?: HTMLElement | null; restoreFocus?: boolean }, + ) => void; + onToggleCronSessions?: () => void; +}; + +function chatAutoScrollLabel(mode: ChatAutoScrollMode) { + switch (mode) { + case "always": + return t("chat.autoScrollAlways"); + case "off": + return t("chat.autoScrollOff"); + case "near-bottom": + return t("chat.autoScrollNearBottom"); + } + return t("chat.autoScrollNearBottom"); +} + +function nextChatAutoScrollMode(mode: ChatAutoScrollMode): ChatAutoScrollMode { + switch (mode) { + case "near-bottom": + return "always"; + case "always": + return "off"; + case "off": + return "near-bottom"; + } + return "near-bottom"; +} + +function renderChatAutoScrollToggle(props: { + settings: UiSettings; + onSettingsChange: (next: UiSettings) => void; +}) { + const mode = normalizeChatAutoScrollMode(props.settings.chatAutoScroll); + const label = `${t("chat.autoScrollMode")}: ${chatAutoScrollLabel(mode)}`; + const active = mode !== "off"; + return html` + + + + `; +} + +function renderCronFilterIcon(hiddenCount: number) { + return html` + + ${icons.clock} + ${hiddenCount > 0 + ? html`${hiddenCount}` + : ""} + + `; +} + +function countHiddenCronSessions( + props: Pick, +): number { + const sessions = props.sessionsResult; + if (!sessions?.sessions) { + return 0; + } + const activeAgentId = normalizeAgentId( + parseAgentSessionKey(props.sessionKey)?.agentId ?? props.agentsList?.defaultId ?? "main", + ); + const defaultAgentId = normalizeAgentId(props.agentsList?.defaultId ?? "main"); + + return sessions.sessions.filter( + (row) => + isCronSessionKey(row.key) && + row.key !== props.sessionKey && + isSessionKeyTiedToAgent(row.key, activeAgentId, defaultAgentId), + ).length; +} + +export function renderChatControls(props: ChatControlsProps) { + const hideCron = props.hideCronSessions; + const hiddenCronCount = hideCron ? countHiddenCronSessions(props) : 0; + const disableThinkingToggle = props.onboarding; + const showThinking = props.onboarding ? false : props.settings.chatShowThinking; + const showToolCalls = props.onboarding ? true : props.settings.chatShowToolCalls; + const persistCommentary = props.settings.chatPersistCommentary === true; + const thinkingLabel = disableThinkingToggle + ? t("chat.onboardingDisabled") + : t("chat.thinkingToggle"); + const toolCallsLabel = disableThinkingToggle + ? t("chat.onboardingDisabled") + : t("chat.toolCallsToggle"); + const commentaryLabel = disableThinkingToggle + ? t("chat.onboardingDisabled") + : t("chat.commentaryToggle"); + const refreshDisabled = + !props.connected || + props.manualRefreshInFlight || + props.loading || + props.sending || + props.stream !== null || + Boolean(props.runId); + const cronLabel = hideCron + ? hiddenCronCount > 0 + ? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) }) + : t("chat.showCronSessions") + : t("chat.hideCronSessions"); + const settingsOpen = props.settingsOpen; + const settingsLabel = t("chat.settings"); + const settingsTitle = t("chat.settings"); + + return html` +
{ + if (props.settingsOpen) { + props.onSettingsOpenChange(false); + } + }} + > + ${renderChatModelControls(props.model)} +
+ ${renderProviderQuotaPill(props.quota)} +
+ + + + +
+ `; +} diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/pages/chat/components/chat-message.test.ts similarity index 94% rename from ui/src/ui/chat/grouped-render.test.ts rename to ui/src/pages/chat/components/chat-message.test.ts index c6255e7e8aef..3e24cbc6440a 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -2,15 +2,15 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { setUiTimeFormatPreference } from "../format.ts"; -import type { MessageGroup } from "../types/chat-types.ts"; +import type { MessageGroup } from "../../../lib/chat/chat-types.ts"; +import { normalizeMessage } from "../../../lib/chat/message-normalizer.ts"; +import { setUiTimeFormatPreference } from "../../../lib/format.ts"; import { formatChatTimestampForDisplay, renderMessageGroup, renderStreamGroup, resetAssistantAttachmentAvailabilityCacheForTest, -} from "./grouped-render.ts"; -import { normalizeMessage } from "./message-normalizer.ts"; +} from "./chat-message.ts"; const localStorageValues = vi.hoisted(() => new Map()); const markdownRenderMock = vi.hoisted(() => @@ -23,7 +23,7 @@ const streamingMarkdownRenderMock = vi.hoisted(() => vi.fn((value: string) => `
${value}
`), ); -vi.mock("../../local-storage.ts", () => ({ +vi.mock("../../../local-storage.ts", () => ({ getSafeLocalStorage: () => ({ getItem: (key: string) => localStorageValues.get(key) ?? null, removeItem: (key: string) => localStorageValues.delete(key), @@ -31,8 +31,8 @@ vi.mock("../../local-storage.ts", () => ({ }), })); -vi.mock("../markdown.ts", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../components/markdown.ts", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, toSanitizedMarkdownHtml: markdownRenderMock, @@ -41,7 +41,7 @@ vi.mock("../markdown.ts", async (importOriginal) => { }; }); -vi.mock("../icons.ts", () => ({ +vi.mock("../../../components/icons.ts", () => ({ icons: {}, })); @@ -60,7 +60,7 @@ function requireFirstMockArg( return arg; } -vi.mock("../views/agents-utils.ts", () => { +vi.mock("../../../lib/agents/display.ts", () => { const isRenderableControlUiAvatarUrl = (value: string) => /^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")); @@ -111,25 +111,29 @@ vi.mock("./chat-avatar.ts", () => ({ }, })); -vi.mock("../tool-display.ts", () => ({ - formatToolDetail: () => undefined, - resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => ({ - name, - label: - { - sessions_spawn: "Sub-agent", - skill_workshop: "Skill Workshop", - web_search: "Web Search", - }[name] ?? name, - icon: "zap", - detail: - args && typeof args === "object" && "detail" in args - ? String((args as { detail: unknown }).detail) - : args && typeof args === "object" && name === "skill_workshop" && "action" in args - ? String((args as { action: unknown }).action) - : undefined, - }), -})); +vi.mock("../../../lib/chat/tool-display.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formatToolDetail: () => undefined, + resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => ({ + name, + label: + { + sessions_spawn: "Sub-agent", + skill_workshop: "Skill Workshop", + web_search: "Web Search", + }[name] ?? name, + icon: "zap", + detail: + args && typeof args === "object" && "detail" in args + ? String((args as { detail: unknown }).detail) + : args && typeof args === "object" && name === "skill_workshop" && "action" in args + ? String((args as { action: unknown }).action) + : undefined, + }), + }; +}); type RenderMessageGroupOptions = Parameters[1]; @@ -890,7 +894,6 @@ describe("grouped chat rendering", () => { const display = formatChatTimestampForDisplay(timestamp); expect(time?.dateTime).toBe(display.dateTime); expect(time?.textContent?.trim()).toBe(display.label); - expect(time?.getAttribute("title")).toBe(display.title); render( renderStreamGroup([ @@ -1136,6 +1139,47 @@ describe("grouped chat rendering", () => { expect(onToggleToolMessageExpanded).toHaveBeenCalledWith("activity:tool-group", true); }); + it("keeps succeeded grouped tool activity collapsed without error styling", () => { + const container = document.createElement("div"); + const group: MessageGroup = { + kind: "group", + key: "tool-group", + role: "tool", + turnSucceeded: true, + messages: [ + { + key: "tool-message-1", + message: { + role: "toolResult", + toolCallId: "call-1", + toolName: "web_search", + isError: true, + content: JSON.stringify({ error: "No matches" }), + timestamp: 1000, + }, + }, + { + key: "tool-message-2", + message: { + role: "toolResult", + toolCallId: "call-2", + toolName: "read_file", + content: "Fallback context", + timestamp: 1001, + }, + }, + ], + timestamp: 1000, + isStreaming: false, + }; + + renderMessageGroups(container, [group]); + + expect(container.querySelector(".chat-activity-group.is-open")).toBeNull(); + expect(container.querySelector(".chat-activity-group__summary--error")).toBeNull(); + expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); + }); + it("hides grouped tool activity when tool calls are disabled", () => { const container = document.createElement("div"); const group: MessageGroup = { @@ -1459,96 +1503,33 @@ describe("grouped chat rendering", () => { ).toEqual({ status: "error" }); }); - describe("non-terminal internal tool failure de-promotion (#89683)", () => { - function failedToolMessage(callId = "call-failed") { - return { - id: `tool-${callId}`, - role: "toolResult", - toolCallId: callId, - toolName: "shell", - content: JSON.stringify({ status: "failed", exitCode: 1, stdout: "" }, null, 2), - isError: true, - timestamp: Date.now(), - }; - } - - it("renders a failed tool collapsed (no error banner) when the turn succeeded", () => { - const container = document.createElement("div"); - const group: MessageGroup = { - ...createMessageGroup(failedToolMessage(), "tool"), + it("keeps succeeded standalone tool-result summaries collapsed without error styling", () => { + const container = document.createElement("div"); + const groups = [ + { + ...createMessageGroup( + { + id: "tool-status-error", + role: "toolResult", + toolCallId: "call-status-error", + toolName: "sessions_spawn", + content: JSON.stringify({ status: "error" }, null, 2), + timestamp: Date.now(), + }, + "tool", + ), turnSucceeded: true, - }; - renderMessageGroups(container, [group], { isToolMessageExpanded: () => false }); + }, + ]; - const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement); - expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false); - expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).not.toBe( - "Tool error", - ); - expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull(); + renderMessageGroups(container, groups, { + isToolMessageExpanded: () => false, }); - it("still surfaces a failed tool as an error when the turn did not produce a reply", () => { - const container = document.createElement("div"); - // turnSucceeded undefined = terminal/in-progress failure; behavior unchanged. - const group = createMessageGroup(failedToolMessage(), "tool"); - renderMessageGroups(container, [group], { isToolMessageExpanded: () => false }); - - const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement); - expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true); - expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe( - "Tool error", - ); - expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).not.toBeNull(); - }); - - it("keeps the failed tool detail on expand even when de-promoted", () => { - const container = document.createElement("div"); - const group: MessageGroup = { - ...createMessageGroup(failedToolMessage(), "tool"), - turnSucceeded: true, - }; - renderMessageGroups(container, [group], { isToolMessageExpanded: () => true }); - // Info is not deleted: the expanded body still shows the failed tool output. - const body = container.querySelector(".chat-tool-msg-body"); - expect(body).not.toBeNull(); - expect(body?.textContent).toContain("failed"); - }); - - it("de-promotes a multi-tool activity group when the turn succeeded", () => { - const container = document.createElement("div"); - const group: MessageGroup = { - kind: "group", - key: "tool:multi", - role: "tool", - messages: [ - { key: "t1", message: failedToolMessage("call-1") }, - { key: "t2", message: failedToolMessage("call-2") }, - ], - timestamp: Date.now(), - isStreaming: false, - turnSucceeded: true, - }; - renderMessageGroups(container, [group], { isToolMessageExpanded: () => false }); - - const summary = expectElement(container, ".chat-activity-group__summary", HTMLButtonElement); - expect(summary.classList.contains("chat-activity-group__summary--error")).toBe(false); - expect(summary.querySelector(".chat-activity-group__badge")).toBeNull(); - expect(summary.getAttribute("aria-expanded")).toBe("false"); - }); - - it("keeps failed tools hidden when showToolCalls is false regardless of outcome", () => { - const container = document.createElement("div"); - const group: MessageGroup = { - ...createMessageGroup(failedToolMessage(), "tool"), - turnSucceeded: true, - }; - renderMessageGroups(container, [group], { - showToolCalls: false, - isToolMessageExpanded: () => false, - }); - expect(container.querySelector(".chat-tool-msg-summary")).toBeNull(); - }); + const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement); + expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false); + expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output"); + expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull(); }); it("collapses an inline tool call while keeping matching tool output visible", () => { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/pages/chat/components/chat-message.ts similarity index 93% rename from ui/src/ui/chat/grouped-render.ts rename to ui/src/pages/chat/components/chat-message.ts index 94d918192df4..dc1295b5b749 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -2,43 +2,53 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { until } from "lit/directives/until.js"; -import { t } from "../../i18n/index.ts"; -import { getSafeLocalStorage } from "../../local-storage.ts"; -import type { AssistantIdentity } from "../assistant-identity.ts"; -import type { EmbedSandboxMode } from "../embed-sandbox.ts"; -import { resolveUiHourCycleOptions } from "../format.ts"; -import { icons } from "../icons.ts"; -import { toSanitizedMarkdownHtml, toStreamingMarkdownHtml } from "../markdown.ts"; -import { openExternalUrlSafe } from "../open-external-url.ts"; -import type { SidebarContent } from "../sidebar-content.ts"; -import { detectTextDirection } from "../text-direction.ts"; -import { resolveToolDisplay } from "../tool-display.ts"; +import { resolveLocalUserName } from "../../../app/user-identity.ts"; +import { icons, type IconName } from "../../../components/icons.ts"; +import { toSanitizedMarkdownHtml, toStreamingMarkdownHtml } from "../../../components/markdown.ts"; +import { t } from "../../../i18n/index.ts"; +import type { AssistantIdentity } from "../../../lib/assistant-identity.ts"; import type { ChatItem, MessageContentItem, MessageGroup, NormalizedMessage, ToolCard, -} from "../types/chat-types.ts"; -import { resolveLocalUserName } from "../user-identity.ts"; -export { resolveAssistantTextAvatar } from "../views/agents-utils.ts"; -import { renderChatAvatar } from "./chat-avatar.ts"; -import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; -import { extractThinkingCached, formatReasoningMarkdown } from "./message-extract.ts"; -import { isToolResultMessage, normalizeMessage } from "./message-normalizer.ts"; -import { normalizeRoleForGrouping } from "./role-normalizer.ts"; -import { formatCompactTokenCount } from "./token-format.ts"; +} from "../../../lib/chat/chat-types.ts"; +import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts"; +import { resolveToolDisplay } from "../../../lib/chat/tool-display.ts"; +import { resolveUiHourCycleOptions } from "../../../lib/format.ts"; +import { openExternalUrlSafe } from "../../../lib/open-external-url.ts"; +import { detectTextDirection } from "../../../lib/text-direction.ts"; +import { getSafeLocalStorage } from "../../../local-storage.ts"; +import type { SidebarContent } from "./chat-sidebar.ts"; +export { resolveAssistantTextAvatar } from "../../../lib/agents/display.ts"; +import { renderCopyAsMarkdownButton } from "../../../components/copy-button.ts"; +import "../../../components/tooltip.ts"; +import { + extractThinkingCached, + formatReasoningMarkdown, +} from "../../../lib/chat/message-extract.ts"; +import { isToolResultMessage, normalizeMessage } from "../../../lib/chat/message-normalizer.ts"; +import { normalizeRoleForGrouping } from "../../../lib/chat/message-normalizer.ts"; import { extractToolCardsCached, formatCollapsedToolPreviewText, formatCollapsedToolSummaryText, isToolCardError, +} from "../../../lib/chat/tool-cards.ts"; +import { formatCompactTokenCount } from "../../../lib/format.ts"; +import { renderChatAvatar } from "../chat-avatar.ts"; +import { renderExpandedToolCardContent, renderRawOutputToggle, renderToolCard, renderToolPreview, resolveCollapsedToolDetail, -} from "./tool-cards.ts"; +} from "./chat-tool-cards.ts"; + +function renderChatIcon(name: string) { + return icons[name as IconName] ?? icons.zap; +} type AssistantAttachmentAvailability = | { status: "checking" } @@ -288,7 +298,7 @@ function extractImages(message: unknown): ImageBlock[] { const b = block as Record; if (b.type === "image") { - // Handle source object format (from sendChatMessage) + // Handle source object format from optimistic user sends. const source = b.source as Record | undefined; const imageMeta = { alt: typeof b.alt === "string" ? b.alt : undefined, @@ -655,8 +665,6 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup : toolLabels.length <= 3 ? toolLabels.join(", ") : `${toolLabels.slice(0, 2).join(", ")} +${toolLabels.length - 2} more`; - // Non-terminal internal tool failures (turn produced a clean reply) stay - // collapsed and unstyled; detail remains available on expand. #89683 const hasError = cards.some(isToolCardError) && group.turnSucceeded !== true; const activityDisclosureId = `activity:${group.key}`; const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? hasError; @@ -882,7 +890,7 @@ function renderMessageMeta(meta: GroupMeta | null) { return html`
- + Context @@ -976,25 +984,25 @@ function placeDeleteConfirmPopover( function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) { return html` -
`; - wrap.appendChild(popover); - placeDeleteConfirmPopover(btn, popover, side); + wrap.appendChild(popover); + placeDeleteConfirmPopover(btn, popover, side); - const cancel = popover.querySelector(".chat-delete-confirm__cancel")!; - const yes = popover.querySelector(".chat-delete-confirm__yes")!; - const check = popover.querySelector(".chat-delete-confirm__check") as HTMLInputElement; + const cancel = popover.querySelector(".chat-delete-confirm__cancel")!; + const yes = popover.querySelector(".chat-delete-confirm__yes")!; + const check = popover.querySelector(".chat-delete-confirm__check") as HTMLInputElement; - let dismissed = false; - function dismissPopover() { - if (dismissed) { - return; + let dismissed = false; + function dismissPopover() { + if (dismissed) { + return; + } + dismissed = true; + document.removeEventListener("click", closeOnOutside, true); + deleteConfirmDismissers.delete(popover); + popover.remove(); } - dismissed = true; - document.removeEventListener("click", closeOnOutside, true); - deleteConfirmDismissers.delete(popover); - popover.remove(); - } - function closeOnOutside(evt: MouseEvent) { - const target = evt.target; - if (target instanceof Node && !popover.contains(target) && !btn.contains(target)) { + function closeOnOutside(evt: MouseEvent) { + const target = evt.target; + if (target instanceof Node && !popover.contains(target) && !btn.contains(target)) { + dismissPopover(); + } + } + + deleteConfirmDismissers.set(popover, dismissPopover); + + cancel.addEventListener("click", dismissPopover); + yes.addEventListener("click", () => { + if (check.checked) { + try { + getSafeLocalStorage()?.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); + } catch {} + } dismissPopover(); - } - } + onDelete(); + }); - deleteConfirmDismissers.set(popover, dismissPopover); - - cancel.addEventListener("click", dismissPopover); - yes.addEventListener("click", () => { - if (check.checked) { - try { - getSafeLocalStorage()?.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); - } catch {} - } - dismissPopover(); - onDelete(); - }); - - requestAnimationFrame(() => { - if (!dismissed && popover.isConnected) { - placeDeleteConfirmPopover(btn, popover, side); - document.addEventListener("click", closeOnOutside, true); - } - }); - }} - > - ${icons.trash ?? icons.x} - + requestAnimationFrame(() => { + if (!dismissed && popover.isConnected) { + placeDeleteConfirmPopover(btn, popover, side); + document.addEventListener("click", closeOnOutside, true); + } + }); + }} + > + ${icons.trash ?? icons.x} + + `; } @@ -1748,29 +1757,30 @@ function renderExpandButton( }, ) { return html` - + + + `; } @@ -1784,8 +1794,6 @@ function renderGroupedMessage( duplicateCount?: number; showReasoning: boolean; showToolCalls?: boolean; - // True when the tool's turn still produced a clean assistant reply: a failed - // internal tool then renders collapsed, not as a primary error banner. #89683 turnSucceeded?: boolean; autoExpandToolCalls?: boolean; isToolMessageExpanded?: (messageId: string) => boolean | undefined; @@ -1940,7 +1948,7 @@ function renderGroupedMessage( : "Tool output"; const toolMessageLabel = formatCollapsedToolSummaryText(toolMessageLabelRaw) ?? toolMessageLabelRaw; - const toolMessageIcon = singleToolDisplay ? icons[singleToolDisplay.icon] : icons.zap; + const toolMessageIcon = singleToolDisplay ? renderChatIcon(singleToolDisplay.icon) : icons.zap; const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1)); @@ -2110,7 +2118,6 @@ function renderGroupedMessage( ? html`
×${duplicateCount}
` diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts new file mode 100644 index 000000000000..7280e68d988a --- /dev/null +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -0,0 +1,421 @@ +// Chat-owned model, reasoning, and speed picker. +import { html } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import type { ModelCatalogEntry, SessionsListResult } from "../../../api/types.ts"; +import { icons } from "../../../components/icons.ts"; +import { t } from "../../../i18n/index.ts"; +import { + resolveChatFastModeSelectState, + resolveChatModelSelectState, + type ChatFastModeSelectState, + type ChatFastModeSelectValue, + type ChatModelSelectOption, +} from "../../../lib/chat/model-select-state.ts"; +import { + formatThinkingOverrideLabel, + resolveChatThinkingSelectState, +} from "../../../lib/chat/thinking.ts"; + +export type ChatModelControlsProps = { + activeRunId: string | null; + connected: boolean; + gatewayAvailable: boolean; + loading: boolean; + modelCatalog: ModelCatalogEntry[]; + modelOverrides?: Readonly>; + modelSwitching: boolean; + modelsLoading?: boolean; + sending: boolean; + sessionKey: string; + sessionsResult: SessionsListResult | null; + stream: string | null; + onFastModeSelect?: (value: ChatFastModeSelectValue) => unknown; + onModelSelect?: (value: string) => unknown; + onThinkingSelect?: (value: string) => unknown; +}; + +export function renderChatModelControls(props: ChatModelControlsProps) { + const { + currentOverride, + defaultLabel, + options: selectOptions, + } = resolveChatModelSelectState({ + chatModelCatalog: props.modelCatalog, + modelOverrides: props.modelOverrides ?? {}, + sessionKey: props.sessionKey, + sessionsResult: props.sessionsResult, + }); + const thinking = resolveChatThinkingSelectState({ + catalog: props.modelCatalog, + sessionKey: props.sessionKey, + sessionsResult: props.sessionsResult, + }); + const fastMode = resolveChatFastModeSelectState({ + activeRunId: props.activeRunId, + catalog: props.modelCatalog, + connected: props.connected, + currentModelOverride: currentOverride, + gatewayAvailable: props.gatewayAvailable, + loading: props.loading, + sending: props.sending, + sessionKey: props.sessionKey, + sessionsResult: props.sessionsResult, + stream: props.stream, + }); + const busy = + props.loading || props.sending || Boolean(props.activeRunId) || props.stream !== null; + const disabled = + !props.connected || + busy || + props.modelSwitching || + (props.modelsLoading && selectOptions.length === 0) || + !props.gatewayAvailable; + const thinkingDisabled = + !props.connected || + busy || + !props.gatewayAvailable || + (thinking.options.length === 0 && thinking.currentOverride === ""); + const selectedLabel = + currentOverride === "" + ? defaultLabel + : (selectOptions.find((entry) => entry.value === currentOverride)?.label ?? currentOverride); + const selectedThinkingLabel = + thinking.currentOverride === "" + ? thinking.defaultLabel + : (thinking.options.find((entry) => entry.value === thinking.currentOverride)?.label ?? + thinking.currentOverride); + + return renderChatModelReasoningSelect({ + disabled, + fastMode, + modelOptions: [{ value: "", label: defaultLabel }, ...selectOptions], + selectedModelLabel: selectedLabel, + selectedModelValue: currentOverride, + selectedThinkingLabel, + selectedThinkingValue: thinking.currentOverride, + thinkingDefaultValue: thinking.defaultValue, + thinkingDisabled, + thinkingOptions: [{ value: "", label: thinking.defaultLabel }, ...thinking.options], + onFastModeSelect: async (next) => props.onFastModeSelect?.(next), + onModelSelect: async (next) => props.onModelSelect?.(next), + onThinkingSelect: async (next) => props.onThinkingSelect?.(next), + }); +} + +function formatCombinedPickerModelLabel(label: string): string { + const match = /^Default \((.+)\)$/u.exec(label); + return match?.[1] ?? label; +} + +function formatCombinedPickerModelOptionLabel( + option: ChatModelSelectOption, + selected: boolean, +): string { + return option.value === "" && selected + ? formatCombinedPickerModelLabel(option.label) + : option.label; +} + +function formatCombinedPickerThinkingLabel(label: string): string { + return label.replace(/^Inherited:\s*/u, ""); +} + +function renderChatModelReasoningSelect(params: { + fastMode: ChatFastModeSelectState; + disabled: boolean; + modelOptions: ChatModelSelectOption[]; + selectedModelLabel: string; + selectedModelValue: string; + selectedThinkingLabel: string; + selectedThinkingValue: string; + thinkingDefaultValue: string; + thinkingDisabled: boolean; + thinkingOptions: ChatModelSelectOption[]; + onFastModeSelect: (value: ChatFastModeSelectValue) => Promise; + onModelSelect: (value: string) => Promise; + onThinkingSelect: (value: string) => Promise; +}) { + const { + disabled, + fastMode, + modelOptions, + selectedModelLabel, + selectedModelValue, + selectedThinkingLabel, + selectedThinkingValue, + thinkingDefaultValue, + thinkingDisabled, + thinkingOptions, + onFastModeSelect, + onModelSelect, + onThinkingSelect, + } = params; + const triggerModel = formatCombinedPickerModelLabel(selectedModelLabel); + const triggerThinking = formatCombinedPickerThinkingLabel(selectedThinkingLabel); + const triggerTitle = `${triggerModel} · ${triggerThinking}`; + const triggerLabel = + selectedThinkingValue === "" ? triggerModel : `${triggerModel} · ${triggerThinking}`; + const sliderStops = thinkingOptions.filter((option) => option.value !== ""); + const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue); + const hasThinkingOverride = selectedThinkingValue !== ""; + const overrideStopIndex = sliderStops.findIndex( + (option) => option.value === selectedThinkingValue, + ); + const sliderIndex = Math.max(hasThinkingOverride ? overrideStopIndex : defaultStopIndex, 0); + const sliderUnanchored = !hasThinkingOverride && defaultStopIndex < 0; + const sliderFillPercent = (index: number) => + sliderStops.length > 1 ? (index / (sliderStops.length - 1)) * 100 : 0; + const reasoningValueLabel = hasThinkingOverride + ? triggerThinking + : `Default (${triggerThinking})`; + const defaultLevelLabel = formatThinkingOverrideLabel(thinkingDefaultValue); + const onSliderDrag = (event: Event) => { + const input = event.currentTarget as HTMLInputElement; + input.style.setProperty("--reasoning-fill", `${sliderFillPercent(Number(input.value))}%`); + }; + const onSliderCommit = async (event: Event) => { + if (thinkingDisabled) { + return; + } + const input = event.currentTarget as HTMLInputElement; + const stop = sliderStops[Number(input.value)]; + if (!stop || stop.value === selectedThinkingValue) { + return; + } + await onThinkingSelect(stop.value); + }; + const showReasoning = sliderStops.length > 0; + const onlyStop = sliderStops.length === 1 ? sliderStops[0] : undefined; + const showReasoningPanel = showReasoning || fastMode.supported; + return html` +
+ { + if (disabled) { + event.preventDefault(); + } + }} + > + ${triggerLabel} + + +
+ +
+ ${repeat( + modelOptions, + (entry) => entry.value, + (entry) => { + const selected = entry.value === selectedModelValue; + return html` +
+ +
+ `; + }, + )} +
+ ${showReasoningPanel + ? html` +
+ ${showReasoning + ? html` +
+ + ${reasoningValueLabel} +
+ ${sliderStops.length > 1 + ? html` +
+ + stop.value) + .join(",")} + aria-label=${t("chat.selectors.thinkingLevel")} + aria-valuetext=${reasoningValueLabel} + ?disabled=${thinkingDisabled} + @input=${onSliderDrag} + @change=${onSliderCommit} + /> +
+ + ` + : onlyStop + ? html` + + ` + : ""} + ${hasThinkingOverride + ? html` + + ` + : ""} + ` + : ""} + ${fastMode.supported + ? html` + +
+ ${repeat( + fastMode.options, + (speed) => speed.value, + (speed) => { + const speedValue = speed.value as ChatFastModeSelectValue; + const speedSelected = speedValue === fastMode.currentOverride; + return html` + + `; + }, + )} +
+ ` + : ""} +
+ ` + : ""} +
+
+ `; +} diff --git a/ui/src/pages/chat/components/chat-realtime-controls.ts b/ui/src/pages/chat/components/chat-realtime-controls.ts new file mode 100644 index 000000000000..5089627b5979 --- /dev/null +++ b/ui/src/pages/chat/components/chat-realtime-controls.ts @@ -0,0 +1,289 @@ +import { html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import { t } from "../../../i18n/index.ts"; +import { + REALTIME_TALK_FALLBACK_PROVIDERS, + listSelectableRealtimeTalkProviders, + resolveControlUiRealtimeTalkProviderTransports, + type RealtimeTalkCatalogProvider, +} from "../realtime-talk-catalog.ts"; +import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; + +type TalkSelectOption = { label: string; value: string }; + +const TALK_VOICE_OPTIONS: TalkSelectOption[] = [ + { label: "Default", value: "" }, + { label: "Alloy", value: "alloy" }, + { label: "Ash", value: "ash" }, + { label: "Ballad", value: "ballad" }, + { label: "Coral", value: "coral" }, + { label: "Echo", value: "echo" }, + { label: "Sage", value: "sage" }, + { label: "Shimmer", value: "shimmer" }, + { label: "Verse", value: "verse" }, + { label: "Marin", value: "marin" }, + { label: "Cedar", value: "cedar" }, +]; +const TALK_SENSITIVITY_OPTIONS: TalkSelectOption[] = [ + { label: "Default", value: "" }, + { label: "Low", value: "0.65" }, + { label: "Medium", value: "0.5" }, + { label: "High", value: "0.35" }, +]; +const TALK_PROVIDER_AUTO_OPTION: TalkSelectOption = { label: "Auto", value: "" }; +const TALK_PROVIDER_FALLBACK_OPTIONS: TalkSelectOption[] = [ + TALK_PROVIDER_AUTO_OPTION, + ...REALTIME_TALK_FALLBACK_PROVIDERS.map((provider) => ({ + label: provider.label, + value: provider.id, + })), +]; +const TALK_TRANSPORT_OPTIONS: TalkSelectOption[] = [ + { label: "Auto", value: "" }, + { label: "WebRTC", value: "webrtc" }, + { label: "Gateway relay", value: "gateway-relay" }, + { label: "Provider WebSocket", value: "provider-websocket" }, +]; +const TALK_REASONING_OPTIONS: TalkSelectOption[] = [ + { label: "Default", value: "" }, + { label: "Minimal", value: "minimal" }, + { label: "Low", value: "low" }, + { label: "Medium", value: "medium" }, + { label: "High", value: "high" }, +]; + +export type RealtimeTalkOptions = { + provider: string; + model: string; + voice: string; + transport: string; + vadThreshold: string; + silenceDurationMs: string; + prefixPaddingMs: string; + reasoningEffort: string; +}; + +export type ChatRealtimeTalkOptionsProps = { + realtimeTalkOptionsOpen?: boolean; + realtimeTalkCatalogProviders?: RealtimeTalkCatalogProvider[] | null; + realtimeTalkOptions?: RealtimeTalkOptions; + onRealtimeTalkOptionsChange?: (next: Partial) => void; +}; + +export type ChatRealtimeTalkConversationProps = { + assistantName: string; + userName?: string | null; + realtimeTalkConversation?: RealtimeTalkConversationEntry[]; +}; + +function renderNativeTalkSelect(params: { + label: string; + value: string; + options: TalkSelectOption[]; + onSelect: (value: string) => void; +}) { + return html` + + `; +} + +export function renderRealtimeTalkOptions(props: ChatRealtimeTalkOptionsProps) { + const options = props.realtimeTalkOptions; + const onChange = props.onRealtimeTalkOptionsChange; + if (!props.realtimeTalkOptionsOpen || !options || !onChange) { + return nothing; + } + const catalogProviders = props.realtimeTalkCatalogProviders; + const selectableProviders = listSelectableRealtimeTalkProviders(catalogProviders ?? []); + const providerOptions: TalkSelectOption[] = catalogProviders + ? [ + TALK_PROVIDER_AUTO_OPTION, + ...selectableProviders.map((provider) => ({ label: provider.label, value: provider.id })), + ] + : TALK_PROVIDER_FALLBACK_OPTIONS; + const selectedCatalogProvider = options.provider + ? selectableProviders.find((provider) => provider.id === options.provider) + : null; + const selectedProviderTransports = selectedCatalogProvider + ? resolveControlUiRealtimeTalkProviderTransports(selectedCatalogProvider) + : undefined; + const transportOptions: TalkSelectOption[] = selectedProviderTransports + ? [ + { label: "Auto", value: "" }, + ...TALK_TRANSPORT_OPTIONS.filter( + (opt) => opt.value !== "" && selectedProviderTransports.includes(opt.value), + ), + ] + : TALK_TRANSPORT_OPTIONS; + const update = (key: keyof RealtimeTalkOptions) => (event: Event) => { + const value = (event.currentTarget as HTMLInputElement | HTMLSelectElement).value; + onChange({ [key]: value }); + }; + const isDefaultSensitivity = options.vadThreshold === ""; + const isPresetSensitivity = ["0.65", "0.5", "0.35"].includes(options.vadThreshold); + const isCustomSensitivity = !isDefaultSensitivity && !isPresetSensitivity; + const sensitivityValue = isDefaultSensitivity + ? "" + : isPresetSensitivity + ? options.vadThreshold + : "__custom"; + const sensitivityOptions = isCustomSensitivity + ? [...TALK_SENSITIVITY_OPTIONS, { label: "Custom", value: "__custom" }] + : TALK_SENSITIVITY_OPTIONS; + const updateSensitivity = (value: string) => { + if (value !== "__custom") { + onChange({ vadThreshold: value }); + } + }; + return html` +
+
+ ${renderNativeTalkSelect({ + label: "Voice", + value: options.voice, + options: TALK_VOICE_OPTIONS, + onSelect: (voice) => onChange({ voice }), + })} + + ${renderNativeTalkSelect({ + label: "Sensitivity", + value: sensitivityValue, + options: sensitivityOptions, + onSelect: updateSensitivity, + })} +
+
+ Advanced +
+ ${renderNativeTalkSelect({ + label: "Provider", + value: options.provider, + options: providerOptions, + onSelect: (provider) => { + const selectedProvider = selectableProviders.find((entry) => entry.id === provider); + const transports = selectedProvider + ? resolveControlUiRealtimeTalkProviderTransports(selectedProvider) + : null; + const transport = options.transport; + onChange( + transports && transport && !transports.includes(transport) + ? { provider, transport: "" } + : { provider }, + ); + }, + })} + ${renderNativeTalkSelect({ + label: "Transport", + value: options.transport, + options: transportOptions, + onSelect: (transport) => onChange({ transport }), + })} + ${renderNativeTalkSelect({ + label: "Reasoning", + value: options.reasoningEffort, + options: TALK_REASONING_OPTIONS, + onSelect: (reasoningEffort) => onChange({ reasoningEffort }), + })} + + + +
+
+
+ `; +} + +export function renderRealtimeTalkConversation(props: ChatRealtimeTalkConversationProps) { + const entries = props.realtimeTalkConversation ?? []; + if (entries.length === 0) { + return nothing; + } + return html` +
+ ${repeat( + entries, + (entry) => entry.id, + (entry) => { + const label = + entry.role === "user" ? props.userName?.trim() || "You" : props.assistantName; + return html` +
+ ${label} + ${entry.text} + ${entry.isStreaming + ? html`` + : nothing} +
+ `; + }, + )} +
+ `; +} diff --git a/ui/src/pages/chat/components/chat-session-workspace.ts b/ui/src/pages/chat/components/chat-session-workspace.ts new file mode 100644 index 000000000000..a6acd82052a9 --- /dev/null +++ b/ui/src/pages/chat/components/chat-session-workspace.ts @@ -0,0 +1,890 @@ +import { html, nothing, type TemplateResult } from "lit"; +import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.ts"; +import type { ArtifactDownloadResult, SessionWorkspaceListResult } from "../../../api/types.ts"; +import { icons } from "../../../components/icons.ts"; +import "../../../components/tooltip.ts"; +import { t } from "../../../i18n/index.ts"; +import { copyToClipboard } from "../../../lib/clipboard.ts"; +import { + scopedAgentParamsForSession, + type SessionCapability, + type SessionScopeHost, +} from "../../../lib/sessions/index.ts"; +import { + resolveAgentIdFromSessionKey, + normalizeAgentId, +} from "../../../lib/sessions/session-key.ts"; +import { normalizeOptionalString } from "../../../lib/string-coerce.ts"; +import type { SidebarContent } from "./chat-sidebar.ts"; + +export type SessionWorkspaceProps = { + collapsed: boolean; + sessionKey: string; + list: SessionWorkspaceListResult | null; + loading: boolean; + error: string | null; + activeId: string | null; + onToggleCollapsed: () => void; + onRefresh: () => void; + onBrowsePath: (path: string) => void; + onCopyPath: (path: string) => void; + onOpenFile: (path: string) => void; + onSearch: (search: string) => void; + onOpenArtifact: (artifactId: string) => void; +}; + +export type SessionWorkspaceState = { + activeId: string | null; + agentId: string; + browserPath: string; + browserSearch: string; + browserSearchTimer: ReturnType | null; + collapsed: boolean; + error: string | null; + list: SessionWorkspaceListResult | null; + loading: boolean; + pendingReload: boolean; + requestId: number; + sessionKey: string; +}; + +type OpenRequest = { + agentId: string; + id: number; + itemId: string; + sessionKey: string; +}; + +export type SessionWorkspaceOpenRequest = OpenRequest; + +export type SessionWorkspaceHost = { + sessionKey: string; + sessions: SessionCapability; + client: GatewayBrowserClient | null; + connected: boolean; + hello: GatewayHelloOk | null; + assistantAgentId?: string | null; + agentsList?: SessionScopeHost["agentsList"]; + sessionWorkspaceState?: SessionWorkspaceState; + sessionWorkspaceOpenRequest?: SessionWorkspaceOpenRequest; + requestUpdate?: () => void; + handleOpenSidebar: (content: SidebarContent) => void; +}; + +function workspaceAgentId(state: SessionWorkspaceHost): string { + const normalizedKey = normalizeOptionalString(state.sessionKey)?.toLowerCase(); + const activeAgentId = + normalizedKey === "global" ? null : resolveAgentIdFromSessionKey(state.sessionKey); + const scopedAgentId = scopedAgentParamsForSession(state, state.sessionKey).agentId; + const fallback = normalizeAgentId( + state.assistantAgentId ?? + state.agentsList?.defaultId ?? + state.agentsList?.agents?.[0]?.id ?? + "main", + ); + return normalizedKey === "global" + ? (scopedAgentId ?? fallback) + : (activeAgentId ?? scopedAgentId ?? fallback); +} + +function clearWorkspaceSearchTimer(workspace: SessionWorkspaceState | undefined) { + if (workspace?.browserSearchTimer) { + globalThis.clearTimeout(workspace.browserSearchTimer); + workspace.browserSearchTimer = null; + } +} + +export function clearSessionWorkspaceTimers(state: SessionWorkspaceHost) { + clearWorkspaceSearchTimer(state.sessionWorkspaceState); +} + +function getWorkspaceState(state: SessionWorkspaceHost): SessionWorkspaceState { + const sessionKey = state.sessionKey; + const agentId = workspaceAgentId(state); + const current = state.sessionWorkspaceState; + if (current?.sessionKey === sessionKey && current.agentId === agentId) { + return current; + } + clearWorkspaceSearchTimer(current); + const next: SessionWorkspaceState = { + activeId: null, + agentId, + browserPath: "", + browserSearch: "", + browserSearchTimer: null, + collapsed: true, + error: null, + list: null, + loading: false, + pendingReload: false, + requestId: 0, + sessionKey, + }; + state.sessionWorkspaceState = next; + return next; +} + +function currentWorkspaceState(state: SessionWorkspaceHost): SessionWorkspaceState { + return getWorkspaceState(state); +} + +function requestUpdate(state: SessionWorkspaceHost) { + state.requestUpdate?.(); +} + +function languageForFile(name: string): string { + const extension = name.match(/\.([a-z0-9_-]+)$/i)?.[1]?.toLowerCase() ?? ""; + if (extension === "yml") { + return "yaml"; + } + return extension; +} + +function fileSidebarContent(name: string, content: string): string { + if (/\.(?:md|markdown|mdx)$/i.test(name)) { + return content; + } + return `# ${name}\n\n\`\`\`${languageForFile(name)}\n${content}\n\`\`\``; +} + +function artifactSidebarContent(params: { + data?: string; + encoding?: string; + mimeType: string; + title: string; + url?: string; +}): SidebarContent { + const { data, encoding, mimeType, title, url } = params; + if (encoding === "base64" && data && mimeType.startsWith("image/")) { + return { + kind: "image", + title, + src: `data:${mimeType};base64,${data}`, + mimeType, + rawText: url ?? null, + }; + } + if (encoding === "base64" && data && mimeType === "application/json") { + const decoded = globalThis.atob(data); + return { + kind: "markdown", + content: `# ${title}\n\n\`\`\`json\n${decoded}\n\`\`\``, + rawText: decoded, + }; + } + if (encoding === "base64" && data && mimeType.startsWith("text/")) { + const decoded = globalThis.atob(data); + return { + kind: "markdown", + content: `# ${title}\n\n\`\`\`\n${decoded}\n\`\`\``, + rawText: decoded, + }; + } + if (url) { + const content = `# ${title}\n\n[Open artifact](${url})`; + return { kind: "markdown", content, rawText: content }; + } + const content = `# ${title}\n\nArtifact download is not previewable in the sidebar.`; + return { kind: "markdown", content, rawText: content }; +} + +function loadWorkspace( + state: SessionWorkspaceHost, + workspace: SessionWorkspaceState, + force = false, +) { + if (!state.client || !state.connected) { + return; + } + if (workspace.loading) { + if (force) { + workspace.pendingReload = true; + } + return; + } + const requestId = workspace.requestId + 1; + workspace.requestId = requestId; + workspace.loading = true; + workspace.error = null; + if (force) { + workspace.list = null; + } + workspace.pendingReload = false; + const sessionKey = state.sessionKey; + const agentId = workspace.agentId; + void (async () => { + try { + const files = await state.sessions.listFiles(sessionKey, { + path: workspace.browserSearch ? "" : workspace.browserPath, + search: workspace.browserSearch, + agentId, + }); + const artifacts = await state.client?.request<{ + artifacts?: SessionWorkspaceListResult["artifacts"]; + } | null>("artifacts.list", { + sessionKey, + ...(agentId ? { agentId } : {}), + }); + const current = currentWorkspaceState(state); + if (current !== workspace || current.requestId !== requestId) { + return; + } + const fileItems = files?.files ?? []; + const artifactItems = artifacts?.artifacts ?? []; + current.list = { + sessionKey, + ...(files?.root ? { root: files.root } : {}), + files: fileItems, + ...(files?.browser ? { browser: files.browser } : {}), + artifacts: artifactItems, + }; + if ( + current.activeId && + !fileItems.some((file) => `file:${file.path}` === current.activeId) && + !artifactItems.some((artifact) => `artifact:${artifact.id}` === current.activeId) + ) { + current.activeId = null; + } + } catch (error) { + const current = currentWorkspaceState(state); + if (current === workspace && current.requestId === requestId) { + current.error = String(error); + } + } finally { + const current = currentWorkspaceState(state); + if (current === workspace && current.requestId === requestId) { + current.loading = false; + const reload = current.pendingReload; + current.pendingReload = false; + if (reload) { + loadWorkspace(state, current, true); + } + } + requestUpdate(state); + } + })(); +} + +function beginOpenRequest( + state: SessionWorkspaceHost, + workspace: SessionWorkspaceState, + itemId: string, +): OpenRequest { + workspace.activeId = itemId; + const previous = state.sessionWorkspaceOpenRequest; + const request: OpenRequest = { + agentId: workspace.agentId, + id: (previous?.id ?? 0) + 1, + itemId, + sessionKey: state.sessionKey, + }; + state.sessionWorkspaceOpenRequest = request; + return request; +} + +function isCurrentOpenRequest(state: SessionWorkspaceHost, request: OpenRequest): boolean { + const currentRequest = state.sessionWorkspaceOpenRequest; + const current = currentWorkspaceState(state); + return ( + currentRequest?.id === request.id && + currentRequest.agentId === workspaceAgentId(state) && + currentRequest.itemId === request.itemId && + currentRequest.sessionKey === state.sessionKey && + current?.agentId === request.agentId && + current.activeId === request.itemId + ); +} + +function openWorkspaceItem( + state: SessionWorkspaceHost, + workspace: SessionWorkspaceState, + itemId: string, + load: (request: OpenRequest) => Promise, + render: (result: T) => SidebarContent | null, + missingMessage: string, +) { + const request = beginOpenRequest(state, workspace, itemId); + void (async () => { + if (!state.client || !state.connected) { + return; + } + workspace.error = null; + try { + const result = await load(request); + const content = result == null ? null : render(result); + if (!content) { + if (isCurrentOpenRequest(state, request)) { + workspace.error = missingMessage; + requestUpdate(state); + } + return; + } + if (isCurrentOpenRequest(state, request)) { + state.handleOpenSidebar(content); + } + } catch (error) { + if (isCurrentOpenRequest(state, request)) { + workspace.error = String(error); + } + } finally { + requestUpdate(state); + } + })(); +} + +function openFile(state: SessionWorkspaceHost, workspace: SessionWorkspaceState, path: string) { + openWorkspaceItem( + state, + workspace, + `file:${path}`, + (request) => state.sessions.getFile(request.sessionKey, path, { agentId: request.agentId }), + (result) => { + const file = result.file; + return !file || typeof file.content !== "string" + ? null + : { + kind: "markdown", + content: fileSidebarContent(file.name || path, file.content), + rawText: file.content, + }; + }, + `Failed to load ${path}`, + ); +} + +function openArtifact( + state: SessionWorkspaceHost, + workspace: SessionWorkspaceState, + artifactId: string, +) { + openWorkspaceItem( + state, + workspace, + `artifact:${artifactId}`, + (request) => + state.client!.request("artifacts.download", { + sessionKey: request.sessionKey, + artifactId, + ...(request.agentId ? { agentId: request.agentId } : {}), + }), + (result) => + !result.artifact + ? null + : artifactSidebarContent({ + data: result.data, + encoding: result.encoding, + mimeType: result.artifact.mimeType ?? "", + title: result.artifact.title, + url: result.url, + }), + `Failed to load artifact ${artifactId}`, + ); +} + +export function createSessionWorkspaceProps(state: SessionWorkspaceHost): SessionWorkspaceProps { + const workspace = getWorkspaceState(state); + if ( + !workspace.collapsed && + state.connected && + state.agentsList && + !workspace.loading && + !workspace.error && + workspace.list?.sessionKey !== state.sessionKey + ) { + loadWorkspace(state, workspace); + } + return { + collapsed: workspace.collapsed, + sessionKey: state.sessionKey, + list: workspace.list?.sessionKey === state.sessionKey ? workspace.list : null, + loading: workspace.loading, + error: workspace.error, + activeId: workspace.activeId, + onToggleCollapsed: () => { + workspace.collapsed = !workspace.collapsed; + if (!workspace.collapsed && workspace.list?.sessionKey !== state.sessionKey) { + loadWorkspace(state, workspace); + } + requestUpdate(state); + }, + onRefresh: () => loadWorkspace(state, workspace, true), + onBrowsePath: (path) => { + clearWorkspaceSearchTimer(workspace); + workspace.browserPath = path; + workspace.browserSearch = ""; + loadWorkspace(state, workspace, true); + }, + onCopyPath: (path) => { + void copyToClipboard(path); + }, + onOpenFile: (path) => openFile(state, workspace, path), + onSearch: (search) => { + workspace.browserSearch = search; + clearWorkspaceSearchTimer(workspace); + workspace.browserSearchTimer = globalThis.setTimeout(() => { + workspace.browserSearchTimer = null; + loadWorkspace(state, workspace, true); + }, 160); + }, + onOpenArtifact: (artifactId) => openArtifact(state, workspace, artifactId), + }; +} + +function formatWorkspaceFileSize(file: { size?: number }): string { + const size = file.size; + if (typeof size !== "number" || !Number.isFinite(size) || size < 0) { + return ""; + } + if (size >= 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")} MB`; + } + if (size >= 1024) { + return `${(size / 1024).toFixed(1).replace(/\.0$/, "")} KB`; + } + return `${size} B`; +} + +function renderWorkspaceArtifactSize(artifact: { sizeBytes?: number }): string { + return formatWorkspaceFileSize({ size: artifact.sizeBytes }); +} + +function renderWorkspaceRailSection( + title: string, + content: TemplateResult | typeof nothing, +): TemplateResult | typeof nothing { + if (content === nothing) { + return nothing; + } + return html` +
+
${title}
+ ${content} +
+ `; +} + +export function renderSessionWorkspaceRail( + sessionWorkspace: SessionWorkspaceProps | undefined, +): TemplateResult | typeof nothing { + if (!sessionWorkspace) { + return nothing; + } + if (sessionWorkspace.collapsed) { + return html` + + `; + } + const files = sessionWorkspace.list?.files ?? []; + const modifiedFiles = files.filter((file) => file.kind === "modified"); + const readFiles = files.filter((file) => file.kind === "read"); + const artifacts = sessionWorkspace.list?.artifacts ?? []; + const browser = sessionWorkspace.list?.browser ?? null; + const hasSessionItems = files.length > 0 || artifacts.length > 0; + const hasBrowserItems = (browser?.entries.length ?? 0) > 0; + const hasItems = hasSessionItems || hasBrowserItems; + const renderPathActions = ( + path: string, + options: { preview?: boolean } = {}, + ): TemplateResult => html` + + ${options.preview === false + ? nothing + : html` + + `} + + + + + `; + const renderSessionSummary = (): TemplateResult | typeof nothing => { + if (!sessionWorkspace.list) { + return nothing; + } + const browserCount = browser?.entries.length ?? 0; + return html` +
+ ${t("chat.workspaceFiles.changedCount", { count: String(modifiedFiles.length) })} + ${t("chat.workspaceFiles.readCount", { count: String(readFiles.length) })} + ${t("chat.workspaceFiles.artifactCount", { count: String(artifacts.length) })} + ${t("chat.workspaceFiles.browserCount", { count: String(browserCount) })} +
+ `; + }; + const renderFileRows = (rows: typeof files): TemplateResult | typeof nothing => + rows.length === 0 + ? nothing + : html` +
+ ${rows.map((file) => { + const size = formatWorkspaceFileSize(file); + const itemId = `file:${file.path}`; + const isActive = itemId === sessionWorkspace.activeId; + return html` +
+ + ${file.missing + ? html`${t("chat.workspaceFiles.missing")}` + : nothing} + ${renderPathActions(file.path)} +
+ `; + })} +
+ `; + const renderBrowserBadge = ( + sessionKind: "modified" | "read" | "mixed" | undefined, + ): TemplateResult | typeof nothing => { + if (!sessionKind) { + return nothing; + } + const label = + sessionKind === "modified" + ? t("chat.workspaceFiles.changed") + : sessionKind === "read" + ? t("chat.workspaceFiles.read") + : t("chat.workspaceFiles.session"); + return html`${label}`; + }; + const renderBrowserBreadcrumbs = (): TemplateResult | typeof nothing => { + if (!browser || browser.search) { + return nothing; + } + const parts = browser.path ? browser.path.split("/").filter(Boolean) : []; + let currentPath = ""; + return html` +
+ + ${parts.map((part) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + const pathForPart = currentPath; + return html` + / + + `; + })} +
+ `; + }; + const renderBrowserRows = (): TemplateResult => { + const entries = browser?.entries ?? []; + const parentPath = browser?.parentPath; + return html` +
+
+ +
+ ${renderBrowserBreadcrumbs()} + ${browser?.search + ? html`
+ ${t("chat.workspaceFiles.searchResults")} +
` + : nothing} +
+ ${!browser?.search && parentPath != null + ? html` +
+ +
+ ` + : nothing} + ${entries.length === 0 + ? html`
+ ${browser?.search + ? t("chat.workspaceFiles.noSearchResults") + : t("chat.workspaceFiles.noBrowserFiles")} +
` + : entries.map((entry) => { + const size = entry.kind === "file" ? formatWorkspaceFileSize(entry) : ""; + const itemId = `file:${entry.path}`; + const isActive = itemId === sessionWorkspace.activeId; + const canPreview = entry.kind === "file" && Boolean(entry.sessionKind); + return html` +
+ + ${renderBrowserBadge(entry.sessionKind)} + ${entry.kind === "file" + ? renderPathActions(entry.path, { preview: canPreview }) + : nothing} +
+ `; + })} +
+ ${browser?.truncated + ? html`
+ ${t("chat.workspaceFiles.truncated")} +
` + : nothing} +
+ `; + }; + const renderArtifactRows = (): TemplateResult | typeof nothing => + artifacts.length === 0 + ? nothing + : html` +
+ ${artifacts.map((artifact) => { + const size = renderWorkspaceArtifactSize(artifact); + const itemId = `artifact:${artifact.id}`; + const isActive = itemId === sessionWorkspace.activeId; + const isImage = artifact.mimeType?.startsWith("image/"); + return html` +
+ + + + + + +
+ `; + })} +
+ `; + return html` + + `; +} diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts new file mode 100644 index 000000000000..b507002ed022 --- /dev/null +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -0,0 +1,409 @@ +import { LitElement, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { keyed } from "lit/directives/keyed.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { icons } from "../../../components/icons.ts"; +import { + handleMarkdownCodeBlockCopy, + toSanitizedMarkdownHtml, +} from "../../../components/markdown.ts"; +import "../../../components/tooltip.ts"; +import { extractRawText } from "../../../lib/chat/message-extract.ts"; +import { + resolveCanvasIframeUrl, + resolveEmbedSandbox, + type EmbedSandboxMode, +} from "../../../lib/chat/tool-display.ts"; + +export const CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000; + +type DetailUnavailableReason = "not_found" | "oversized" | "not_visible"; +export type DetailFullMessageResult = { + ok?: boolean; + message?: unknown; + unavailableReason?: DetailUnavailableReason; +}; + +export type SidebarFullMessageRequest = { + sessionKey: string; + agentId?: string; + messageId: string; + kind: "assistant_message" | "tool_output"; +}; + +export type MarkdownSidebarContent = { + kind: "markdown"; + content: string; + rawText?: string | null; + fullMessageRequest?: SidebarFullMessageRequest; + unavailableReason?: DetailUnavailableReason | null; +}; + +export type CanvasSidebarContent = { + kind: "canvas"; + docId: string; + title?: string; + entryUrl: string; + preferredHeight?: number; + rawText?: string | null; + fullMessageRequest?: SidebarFullMessageRequest; + unavailableReason?: DetailUnavailableReason | null; +}; + +export type ImageSidebarContent = { + kind: "image"; + title: string; + src: string; + mimeType?: string | null; + rawText?: string | null; + fullMessageRequest?: SidebarFullMessageRequest; + unavailableReason?: DetailUnavailableReason | null; +}; + +export type SidebarContent = MarkdownSidebarContent | CanvasSidebarContent | ImageSidebarContent; + +function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & { + fullMessageRequest: NonNullable; +} { + return Boolean( + content.fullMessageRequest && (content.kind === "markdown" || content.kind === "canvas"), + ); +} + +function formatUnavailableReason(reason: DetailUnavailableReason | null | undefined): string { + switch (reason) { + case "oversized": + return "Full content is unavailable because the stored transcript entry is too large to return safely."; + case "not_visible": + return "Full content is unavailable because this transcript entry does not have a visible WebChat projection."; + default: + return "Full content is no longer available for this transcript entry."; + } +} + +function extractMessageText(message: unknown): string | null { + if (!message || typeof message !== "object") { + return null; + } + const record = message as Record; + if (typeof record.text === "string") { + return record.text; + } + return extractRawText(message); +} + +function toPlainTextCodeFence(value: string, language = ""): string { + const fenceHeader = language ? `\`\`\`${language}` : "```"; + return `${fenceHeader}\n${value}\n\`\`\``; +} + +export function buildRawSidebarContent( + content: SidebarContent | null | undefined, +): SidebarContent | null { + if (!content) { + return null; + } + if (content.kind === "markdown") { + const rawText = content.rawText ?? content.content; + return { + kind: "markdown", + content: toPlainTextCodeFence(rawText), + rawText, + ...(content.unavailableReason ? { unavailableReason: content.unavailableReason } : {}), + }; + } + if (content.rawText?.trim()) { + return { + kind: "markdown", + content: toPlainTextCodeFence(content.rawText, "json"), + rawText: content.rawText, + ...(content.unavailableReason ? { unavailableReason: content.unavailableReason } : {}), + }; + } + return null; +} + +function resolveSidebarCanvasSandbox( + content: SidebarContent, + embedSandboxMode: EmbedSandboxMode, +): string { + return content.kind === "canvas" ? resolveEmbedSandbox(embedSandboxMode) : "allow-scripts"; +} + +export type MarkdownSidebarProps = { + content: SidebarContent | null; + error: string | null; + onClose: () => void; + onViewRawText: () => void; + canvasPluginSurfaceUrl?: string | null; + embedSandboxMode?: EmbedSandboxMode; + allowExternalEmbedUrls?: boolean; +}; + +export function renderMarkdownSidebar(props: MarkdownSidebarProps) { + const content = props.content; + const markdownHtml = + content?.kind === "markdown" && content.content.trim() + ? toSanitizedMarkdownHtml(content.content) + : ""; + const canvasSandbox = + content?.kind === "canvas" + ? resolveSidebarCanvasSandbox(content, props.embedSandboxMode ?? "scripts") + : ""; + const canvasSrc = + content?.kind === "canvas" + ? resolveCanvasIframeUrl( + content.entryUrl, + props.canvasPluginSurfaceUrl, + props.allowExternalEmbedUrls ?? false, + ) + : null; + const title = + content?.kind === "canvas" + ? content.title?.trim() || "Render Preview" + : content?.kind === "image" + ? content.title.trim() || "Image Preview" + : content?.kind === "markdown" + ? "Markdown Preview" + : "Tool Details"; + return html` + + `; +} + +export class ChatDetailPanel extends LitElement { + @property({ attribute: false }) content: SidebarContent | null = null; + @property({ attribute: false }) loadFullMessage?: + | ((request: SidebarFullMessageRequest) => Promise) + | null = null; + @property() canvasPluginSurfaceUrl: string | null = null; + @property() embedSandboxMode: EmbedSandboxMode = "scripts"; + @property({ type: Boolean }) allowExternalEmbedUrls = false; + + @state() private visibleContent: SidebarContent | null = null; + @state() private error: string | null = null; + + private requestVersion = 0; + private showingRawText = false; + + override createRenderRoot() { + return this; + } + + protected override willUpdate(changed: Map) { + if (!changed.has("content")) { + return; + } + this.requestVersion += 1; + this.visibleContent = this.content; + this.error = null; + this.showingRawText = false; + } + + protected override updated(changed: Map) { + if (!changed.has("content") && !changed.has("loadFullMessage")) { + return; + } + const content = this.content; + if (!content || this.showingRawText) { + return; + } + const version = ++this.requestVersion; + void this.upgradeToFullMessage(content, version); + } + + private async upgradeToFullMessage(content: SidebarContent, version: number) { + if (!hasFullMessageRequest(content) || !this.loadFullMessage) { + return; + } + const request = content.fullMessageRequest; + try { + const result = await this.loadFullMessage(request); + if (version !== this.requestVersion || this.content !== content) { + return; + } + if (!result?.ok || !result.message || typeof result.message !== "object") { + this.visibleContent = { + ...content, + unavailableReason: result?.unavailableReason ?? "not_found", + }; + this.error = formatUnavailableReason(result?.unavailableReason ?? "not_found"); + return; + } + const fetchedText = extractMessageText(result.message); + const rawText = + fetchedText ?? + (typeof content.rawText === "string" + ? content.rawText + : content.kind === "markdown" + ? content.content + : null); + this.visibleContent = + content.kind === "markdown" + ? { + ...content, + content: rawText || content.content, + rawText: rawText || content.rawText || content.content, + unavailableReason: null, + } + : { + ...content, + rawText: rawText || content.rawText || null, + unavailableReason: null, + }; + this.error = null; + } catch (error) { + if (version !== this.requestVersion || this.content !== content) { + return; + } + this.error = `Failed to load full content: ${ + error instanceof Error ? error.message : String(error) + }`; + } + } + + private readonly close = () => { + this.dispatchEvent(new CustomEvent("chat-detail-panel-close", { bubbles: true })); + }; + + private readonly showRawText = () => { + const rawContent = buildRawSidebarContent(this.visibleContent); + if (!rawContent) { + return; + } + this.requestVersion += 1; + this.showingRawText = true; + this.visibleContent = rawContent; + this.error = null; + }; + + override render() { + return html` +
+ ${renderMarkdownSidebar({ + content: this.visibleContent, + error: this.error, + canvasPluginSurfaceUrl: this.canvasPluginSurfaceUrl, + embedSandboxMode: this.embedSandboxMode, + allowExternalEmbedUrls: this.allowExternalEmbedUrls, + onClose: this.close, + onViewRawText: this.showRawText, + })} +
+ `; + } +} + +if (!customElements.get("openclaw-chat-detail-panel")) { + customElements.define("openclaw-chat-detail-panel", ChatDetailPanel); +} diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts new file mode 100644 index 000000000000..9be7050a95ae --- /dev/null +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -0,0 +1,776 @@ +// Chat-owned message thread presentation and thread-local interaction state. +import { html, nothing, type TemplateResult } from "lit"; +import { guard } from "lit/directives/guard.js"; +import { ref } from "lit/directives/ref.js"; +import { repeat } from "lit/directives/repeat.js"; +import type { SessionsListResult } from "../../../api/types.ts"; +import { resolveLocalUserName } from "../../../app/user-identity.ts"; +import { icons } from "../../../components/icons.ts"; +import { handleMarkdownCodeBlockCopy } from "../../../components/markdown.ts"; +import "../../../components/tooltip.ts"; +import { CHAT_HISTORY_RENDER_LIMIT } from "../../../lib/chat/chat-types.ts"; +import type { ChatQueueItem, ChatStreamSegment } from "../../../lib/chat/chat-types.ts"; +import { extractTextCached } from "../../../lib/chat/message-extract.ts"; +import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts"; +import { + buildCachedChatItems, + coalesceStreamRuns, + deletedChatItemsSignature, + getExpandedToolCards, + resetChatThreadState, + stableBooleanMapSignature, + syncToolCardExpansionState, +} from "../chat-thread.ts"; +import { DeletedMessages } from "../deleted-messages.ts"; +import { PinnedMessages } from "../pinned-messages.ts"; +import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; +import { getOrCreateSessionCacheValue } from "../session-cache.ts"; +import { + getAssistantAttachmentAvailabilityRenderVersion, + renderMessageGroup, + renderStreamGroup, +} from "./chat-message.ts"; +import { renderRealtimeTalkConversation } from "./chat-realtime-controls.ts"; +import type { SidebarContent } from "./chat-sidebar.ts"; +import { renderWelcomeState, resolveAssistantDisplayAvatar } from "./chat-welcome.ts"; + +const pinnedMessagesMap = new Map(); +const deletedMessagesMap = new Map(); +const INITIAL_CHAT_HISTORY_RENDER_WINDOW = 30; +const CHAT_HISTORY_RENDER_WINDOW_BATCH = 30; +const CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX = 48; + +type ReplyTarget = { + messageId: string; + text: string; + senderLabel?: string | null; +}; + +type ChatThreadState = { + searchOpen: boolean; + searchQuery: string; + pinnedExpanded: boolean; + historyRenderSessionKey: string | null; + historyRenderMessagesRef: unknown[] | null; + historyRenderMessageCount: number; + historyRenderLimit: number; + historyRenderLastScrollTop: number | null; + historyRenderExpansionFrame: number | null; + historyRenderAnchorAdjustment: { + scrollHeight: number; + scrollTop: number; + } | null; + historyRenderAnchorFrame: number | null; +}; + +export type ChatThreadProps = { + sessionKey: string; + loading: boolean; + messages: unknown[]; + toolMessages: unknown[]; + streamSegments: ChatStreamSegment[]; + stream: string | null; + streamStartedAt: number | null; + queue: ChatQueueItem[]; + showThinking: boolean; + showToolCalls: boolean; + sessions: SessionsListResult | null; + assistantName: string; + assistantAvatar: string | null; + assistantAvatarUrl?: string | null; + userName?: string | null; + userAvatar?: string | null; + basePath?: string; + fullMessageAgentId?: string; + localMediaPreviewRoots?: string[]; + assistantAttachmentAuthToken?: string | null; + canvasPluginSurfaceUrl?: string | null; + embedSandboxMode?: EmbedSandboxMode; + allowExternalEmbedUrls?: boolean; + autoExpandToolCalls?: boolean; + realtimeTalkConversation?: RealtimeTalkConversationEntry[]; + onOpenSidebar?: (content: SidebarContent) => void; + onOpenSessionCheckpoints?: () => void | Promise; + onAssistantAttachmentLoaded?: () => void; + onRequestUpdate?: () => void; + onScrollToBottom?: () => void; + onChatScroll?: (event: Event) => void; + onDraftChange: (next: string) => void; + onSend: () => void; + onSetReply?: (target: ReplyTarget) => void; + onFocusComposer?: () => void; +}; + +export type ChatPinnedMessagesProps = Pick< + ChatThreadProps, + "sessionKey" | "messages" | "userName" | "userAvatar" +>; + +function createChatThreadState(): ChatThreadState { + return { + searchOpen: false, + searchQuery: "", + pinnedExpanded: false, + historyRenderSessionKey: null, + historyRenderMessagesRef: null, + historyRenderMessageCount: 0, + historyRenderLimit: 0, + historyRenderLastScrollTop: null, + historyRenderExpansionFrame: null, + historyRenderAnchorAdjustment: null, + historyRenderAnchorFrame: null, + }; +} + +const threadState = createChatThreadState(); + +function getPinnedMessages(sessionKey: string): PinnedMessages { + return getOrCreateSessionCacheValue( + pinnedMessagesMap, + sessionKey, + () => new PinnedMessages(sessionKey), + ); +} + +function getDeletedMessages(sessionKey: string): DeletedMessages { + return getOrCreateSessionCacheValue( + deletedMessagesMap, + sessionKey, + () => new DeletedMessages(sessionKey), + ); +} + +function getPinnedMessageSummary(message: unknown): string { + return extractTextCached(message) ?? ""; +} + +export function resetChatThreadPresentationState() { + removeReplyContextMenu(); + if (threadState.historyRenderExpansionFrame != null) { + cancelAnimationFrame(threadState.historyRenderExpansionFrame); + } + if (threadState.historyRenderAnchorFrame != null) { + cancelAnimationFrame(threadState.historyRenderAnchorFrame); + } + Object.assign(threadState, createChatThreadState()); + resetChatThreadState(); +} + +function resolveChatHistoryRenderCap(messageCount: number): number { + return Math.min(Math.max(0, messageCount), CHAT_HISTORY_RENDER_LIMIT); +} + +function shouldRenderFullChatHistoryWindow(messageCount: number): boolean { + return ( + messageCount <= INITIAL_CHAT_HISTORY_RENDER_WINDOW || + (threadState.searchOpen && threadState.searchQuery.trim().length > 0) + ); +} + +function resolveChatHistoryRenderWindow(props: Pick) { + const messages = Array.isArray(props.messages) ? props.messages : []; + const cap = resolveChatHistoryRenderCap(messages.length); + const sessionChanged = threadState.historyRenderSessionKey !== props.sessionKey; + const refChanged = threadState.historyRenderMessagesRef !== messages; + const previousCount = threadState.historyRenderMessageCount; + if (sessionChanged || (refChanged && previousCount === 0)) { + threadState.historyRenderLastScrollTop = null; + } + + if (cap === 0) { + threadState.historyRenderSessionKey = props.sessionKey; + threadState.historyRenderMessagesRef = messages; + threadState.historyRenderMessageCount = messages.length; + threadState.historyRenderLimit = 0; + threadState.historyRenderLastScrollTop = null; + return 0; + } + + if (shouldRenderFullChatHistoryWindow(messages.length)) { + threadState.historyRenderSessionKey = props.sessionKey; + threadState.historyRenderMessagesRef = messages; + threadState.historyRenderMessageCount = messages.length; + threadState.historyRenderLimit = cap; + return cap; + } + + if (sessionChanged || (refChanged && previousCount === 0)) { + threadState.historyRenderLimit = Math.min(INITIAL_CHAT_HISTORY_RENDER_WINDOW, cap); + } else if (refChanged) { + const grewBy = messages.length - previousCount; + if (threadState.historyRenderLimit >= previousCount) { + threadState.historyRenderLimit = cap; + } else if (grewBy > 0 && grewBy <= CHAT_HISTORY_RENDER_WINDOW_BATCH) { + threadState.historyRenderLimit = Math.min(cap, threadState.historyRenderLimit + grewBy); + } else { + threadState.historyRenderLimit = Math.min( + Math.max(threadState.historyRenderLimit, INITIAL_CHAT_HISTORY_RENDER_WINDOW), + cap, + ); + } + } + + threadState.historyRenderSessionKey = props.sessionKey; + threadState.historyRenderMessagesRef = messages; + threadState.historyRenderMessageCount = messages.length; + threadState.historyRenderLimit = Math.min(Math.max(1, threadState.historyRenderLimit), cap); + return threadState.historyRenderLimit; +} + +function maybeExpandChatHistoryRenderWindow(event: Event, requestUpdate: () => void) { + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) { + return; + } + const scrollTop = Math.max(0, target.scrollTop); + const previousScrollTop = threadState.historyRenderLastScrollTop; + threadState.historyRenderLastScrollTop = scrollTop; + const distanceFromBottom = Math.max(0, target.scrollHeight - scrollTop - target.clientHeight); + const isTop = scrollTop <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX; + const isBottomAutoScroll = + scrollTop > 0 && distanceFromBottom <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX; + const isTopScrollUp = + isTop && + (scrollTop === 0 || + (!isBottomAutoScroll && (previousScrollTop == null || scrollTop < previousScrollTop))); + if (!isTopScrollUp) { + return; + } + const cap = resolveChatHistoryRenderCap(threadState.historyRenderMessageCount); + if (threadState.historyRenderLimit >= cap) { + return; + } + threadState.historyRenderAnchorAdjustment = { + scrollHeight: target.scrollHeight, + scrollTop, + }; + scheduleChatHistoryRenderAnchorPreservation(target); + threadState.historyRenderLimit = Math.min( + cap, + threadState.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH, + ); + requestUpdate(); +} + +function scheduleChatHistoryRenderAnchorPreservation(thread: HTMLElement) { + const adjustment = threadState.historyRenderAnchorAdjustment; + if (!adjustment || threadState.historyRenderAnchorFrame != null) { + return; + } + threadState.historyRenderAnchorFrame = requestAnimationFrame(() => { + threadState.historyRenderAnchorFrame = null; + threadState.historyRenderAnchorAdjustment = null; + const heightDelta = thread.scrollHeight - adjustment.scrollHeight; + if (heightDelta <= 0) { + return; + } + thread.scrollTop = adjustment.scrollTop + heightDelta; + }); +} + +function scheduleChatHistoryRenderWindowFill( + thread: HTMLElement | null, + requestUpdate: () => void, + scrollToBottom: () => void, +) { + if (!thread || threadState.historyRenderExpansionFrame != null) { + return; + } + const cap = resolveChatHistoryRenderCap(threadState.historyRenderMessageCount); + if (threadState.historyRenderLimit >= cap) { + return; + } + threadState.historyRenderExpansionFrame = requestAnimationFrame(() => { + threadState.historyRenderExpansionFrame = null; + const nextCap = resolveChatHistoryRenderCap(threadState.historyRenderMessageCount); + if (threadState.historyRenderLimit >= nextCap) { + return; + } + const canScroll = thread.scrollHeight - thread.clientHeight > 1; + if (canScroll) { + return; + } + threadState.historyRenderLimit = Math.min( + nextCap, + threadState.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH, + ); + requestUpdate(); + scrollToBottom(); + }); +} + +export function renderChatSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing { + if (!threadState.searchOpen) { + return nothing; + } + return html` + + `; +} + +export function isChatThreadSearchOpen(): boolean { + return threadState.searchOpen; +} + +export function toggleChatThreadSearch(requestUpdate: () => void): void { + threadState.searchOpen = !threadState.searchOpen; + if (!threadState.searchOpen) { + threadState.searchQuery = ""; + } + requestUpdate(); +} + +export function renderChatPinnedMessages( + props: ChatPinnedMessagesProps, + requestUpdate: () => void, +): TemplateResult | typeof nothing { + const pinned = getPinnedMessages(props.sessionKey); + const userRoleLabel = resolveLocalUserName({ + name: props.userName ?? null, + avatar: props.userAvatar ?? null, + }); + const messages = Array.isArray(props.messages) ? props.messages : []; + const entries: Array<{ index: number; text: string; role: string }> = []; + for (const idx of pinned.indices) { + const msg = messages[idx] as Record | undefined; + if (!msg) { + continue; + } + const text = getPinnedMessageSummary(msg); + const role = typeof msg.role === "string" ? msg.role : "unknown"; + entries.push({ index: idx, text, role }); + } + if (entries.length === 0) { + return nothing; + } + return html` +
+ + ${threadState.pinnedExpanded + ? html` +
+ ${entries.map( + ({ index, text, role }) => html` +
+ ${role === "user" ? userRoleLabel : "Assistant"} + ${text.slice(0, 100)}${text.length > 100 ? "..." : ""} + + + +
+ `, + )} +
+ ` + : nothing} +
+ `; +} + +let activeReplyContextMenu: HTMLElement | null = null; +let contextMenuDocumentClickHandler: ((event: MouseEvent) => void) | null = null; +let contextMenuKeydownHandler: ((event: KeyboardEvent) => void) | null = null; + +function removeReplyContextMenu() { + activeReplyContextMenu?.remove(); + activeReplyContextMenu = null; + document.querySelector(".chat-reply-context-menu")?.remove(); + if (contextMenuDocumentClickHandler) { + document.removeEventListener("click", contextMenuDocumentClickHandler); + contextMenuDocumentClickHandler = null; + } + if (contextMenuKeydownHandler) { + document.removeEventListener("keydown", contextMenuKeydownHandler); + contextMenuKeydownHandler = null; + } +} + +function stableReplyMessageId(senderLabel: string | undefined, text: string): string { + const source = `${senderLabel ?? ""}\n${text}`; + let hash = 0x811c9dc5; + for (let index = 0; index < source.length; index += 1) { + hash ^= source.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `reply:${(hash >>> 0).toString(16)}`; +} + +function createReplyContextMenuButton(onClick: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.setAttribute("role", "menuitem"); + button.setAttribute("aria-label", "Reply to message"); + + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("width", "16"); + icon.setAttribute("height", "16"); + icon.setAttribute("fill", "currentColor"); + icon.setAttribute("stroke", "none"); + icon.setAttribute("aria-hidden", "true"); + icon.setAttribute("focusable", "false"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"); + icon.appendChild(path); + + const label = document.createElement("span"); + label.textContent = "Reply"; + + button.append(icon, label); + button.addEventListener("click", onClick); + return button; +} + +function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) { + const bubble = (event.target as HTMLElement).closest(".chat-bubble"); + if (!bubble || typeof props.onSetReply !== "function") { + return; + } + const group = bubble.closest(".chat-group"); + if (!group) { + return; + } + if ( + group.querySelector(".chat-reading-indicator") || + group.querySelector(".chat-bubble.streaming") + ) { + return; + } + const senderEl = group.querySelector(".chat-sender-name"); + const senderLabel = senderEl?.textContent?.trim() ?? undefined; + const text = (bubble as HTMLElement).dataset.messageText?.trim().slice(0, 500) ?? ""; + if (!text) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const messageId = + (bubble as HTMLElement).dataset.messageId?.trim() || stableReplyMessageId(senderLabel, text); + removeReplyContextMenu(); + const menu = document.createElement("div"); + menu.className = "chat-reply-context-menu"; + menu.setAttribute("role", "menu"); + menu.setAttribute("aria-label", "Message actions"); + menu.style.left = `${event.clientX}px`; + menu.style.top = `${event.clientY}px`; + const button = createReplyContextMenuButton(() => { + props.onSetReply?.({ messageId, text, senderLabel }); + removeReplyContextMenu(); + props.onFocusComposer?.(); + }); + menu.append(button); + document.body.appendChild(menu); + activeReplyContextMenu = menu; + + const menuRect = menu.getBoundingClientRect(); + let left = event.clientX; + let top = event.clientY; + if (left + menuRect.width > window.innerWidth) { + left = window.innerWidth - menuRect.width - 8; + } + if (top + menuRect.height > window.innerHeight) { + top = window.innerHeight - menuRect.height - 8; + } + menu.style.left = `${Math.max(0, left)}px`; + menu.style.top = `${Math.max(0, top)}px`; + button.focus(); + requestAnimationFrame(() => { + if (!menu.isConnected || activeReplyContextMenu !== menu) { + return; + } + contextMenuDocumentClickHandler = (nextEvent: MouseEvent) => { + if (!menu.contains(nextEvent.target as Node | null)) { + removeReplyContextMenu(); + } + }; + const handleKeydown = (nextEvent: KeyboardEvent) => { + if (nextEvent.key === "Escape") { + nextEvent.preventDefault(); + nextEvent.stopPropagation(); + removeReplyContextMenu(); + props.onFocusComposer?.(); + } + }; + contextMenuKeydownHandler = handleKeydown; + document.addEventListener("click", contextMenuDocumentClickHandler); + document.addEventListener("keydown", handleKeydown); + }); +} + +function renderLoadingSkeleton() { + return html` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `; +} + +export function renderChatThread(props: ChatThreadProps) { + const requestUpdate = props.onRequestUpdate ?? (() => {}); + const displayStream = props.stream ?? null; + const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey); + const reasoningLevel = activeSession?.reasoningLevel ?? "off"; + const showReasoning = props.showThinking && reasoningLevel !== "off"; + const assistantIdentity = { + name: props.assistantName, + avatar: resolveAssistantDisplayAvatar(props), + }; + const historyRenderLimit = resolveChatHistoryRenderWindow(props); + const deleted = getDeletedMessages(props.sessionKey); + const chatItems = buildCachedChatItems({ + sessionKey: props.sessionKey, + messages: props.messages, + toolMessages: props.toolMessages, + streamSegments: props.streamSegments, + stream: displayStream, + streamStartedAt: props.streamStartedAt, + queue: props.queue, + showToolCalls: props.showToolCalls, + searchOpen: threadState.searchOpen, + searchQuery: threadState.searchQuery, + historyRenderLimit, + }); + syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls)); + const expandedToolCards = getExpandedToolCards(props.sessionKey); + const toggleToolCardExpanded = (toolCardId: string) => { + expandedToolCards.set(toolCardId, !expandedToolCards.get(toolCardId)); + requestUpdate(); + }; + const hasRealtimeTalkConversation = (props.realtimeTalkConversation?.length ?? 0) > 0; + const isEmpty = chatItems.length === 0 && !props.loading && !hasRealtimeTalkConversation; + const showLoadingSkeleton = props.loading && chatItems.length === 0; + const threadContextWindow = + activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null; + const handleChatThreadScroll = (event: Event) => { + maybeExpandChatHistoryRenderWindow(event, requestUpdate); + props.onChatScroll?.(event); + }; + + return html` +
{ + const threadElement = element instanceof HTMLElement ? element : null; + scheduleChatHistoryRenderWindowFill( + threadElement, + requestUpdate, + props.onScrollToBottom ?? (() => {}), + ); + })} + @scroll=${handleChatThreadScroll} + @click=${handleMarkdownCodeBlockCopy} + @contextmenu=${(event: MouseEvent) => handleChatContextMenu(event, props)} + > +
+ ${showLoadingSkeleton ? renderLoadingSkeleton() : nothing} + ${isEmpty && !threadState.searchOpen ? renderWelcomeState(props) : nothing} + ${isEmpty && threadState.searchOpen + ? html`
No matching messages
` + : nothing} + ${guard( + [ + chatItems, + deletedChatItemsSignature(deleted, chatItems), + stableBooleanMapSignature(expandedToolCards), + getAssistantAttachmentAvailabilityRenderVersion(), + props.sessionKey, + props.fullMessageAgentId, + showReasoning, + props.showToolCalls, + Boolean(props.autoExpandToolCalls), + props.assistantName, + assistantIdentity.avatar, + props.userName, + props.userAvatar, + props.basePath, + (props.localMediaPreviewRoots ?? []).join("\u0000"), + props.assistantAttachmentAuthToken, + props.canvasPluginSurfaceUrl, + props.embedSandboxMode ?? "scripts", + props.allowExternalEmbedUrls ?? false, + threadContextWindow, + ], + () => + repeat( + coalesceStreamRuns(chatItems), + (item) => item.key, + (item) => { + if (item.kind === "divider") { + return html` +
+ + ${item.description || item.action + ? html` +
+ ${item.description + ? html` + ${item.description} + ` + : nothing} + ${item.action?.kind === "session-checkpoints" && + props.onOpenSessionCheckpoints + ? html` + + ` + : nothing} +
+ ` + : nothing} +
+ `; + } + if (item.kind === "stream-run") { + return renderStreamGroup(item.parts, { + onOpenSidebar: props.onOpenSidebar, + assistant: assistantIdentity, + basePath: props.basePath, + authToken: props.assistantAttachmentAuthToken ?? null, + }); + } + if (item.kind === "group") { + if (deleted.has(item.key)) { + return nothing; + } + return renderMessageGroup(item, { + onOpenSidebar: props.onOpenSidebar, + sessionKey: props.sessionKey, + agentId: props.fullMessageAgentId, + showReasoning, + showToolCalls: props.showToolCalls, + autoExpandToolCalls: Boolean(props.autoExpandToolCalls), + isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId), + onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => { + expandedToolCards.set( + messageId, + !(expanded ?? expandedToolCards.get(messageId) ?? false), + ); + requestUpdate(); + }, + isToolExpanded: (toolCardId: string) => + expandedToolCards.get(toolCardId) ?? false, + onToggleToolExpanded: toggleToolCardExpanded, + onRequestUpdate: requestUpdate, + onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded, + assistantName: props.assistantName, + assistantAvatar: assistantIdentity.avatar, + userName: props.userName ?? null, + userAvatar: props.userAvatar ?? null, + basePath: props.basePath, + localMediaPreviewRoots: props.localMediaPreviewRoots ?? [], + assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null, + canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, + embedSandboxMode: props.embedSandboxMode ?? "scripts", + allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false, + contextWindow: threadContextWindow, + onDelete: () => { + deleted.delete(item.key); + requestUpdate(); + }, + }); + } + return nothing; + }, + ), + )} + ${renderRealtimeTalkConversation(props)} +
+
+ `; +} diff --git a/ui/src/ui/chat/tool-cards.node.test.ts b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts similarity index 87% rename from ui/src/ui/chat/tool-cards.node.test.ts rename to ui/src/pages/chat/components/chat-tool-cards.node.test.ts index ae4153a0833b..68e022301f62 100644 --- a/ui/src/ui/chat/tool-cards.node.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts @@ -1,13 +1,14 @@ // @vitest-environment node import { describe, expect, it, vi } from "vitest"; -import { buildToolCardSidebarContent, extractToolCards } from "./tool-cards.ts"; +import { extractToolCards } from "../../../lib/chat/tool-cards.ts"; +import { buildToolCardSidebarContent } from "./chat-tool-cards.ts"; -vi.mock("../icons.ts", () => ({ +vi.mock("../../../components/icons.ts", () => ({ icons: {}, })); -vi.mock("../tool-display.ts", () => ({ +vi.mock("../../../lib/chat/tool-display.ts", () => ({ formatToolDetail: () => undefined, resolveToolDisplay: ({ name }: { name: string }) => ({ name, @@ -449,3 +450,38 @@ with Example Deck } }); }); + +describe("tool-card canvas URLs", () => { + async function loadResolver() { + return vi.importActual( + "../../../lib/chat/tool-display.ts", + ); + } + + it("accepts hosted canvas paths and scopes them through the canvas capability host", async () => { + const { resolveCanvasIframeUrl } = await loadResolver(); + + expect(resolveCanvasIframeUrl("/__openclaw__/canvas/documents/cv_demo/index.html")).toBe( + "/__openclaw__/canvas/documents/cv_demo/index.html", + ); + expect( + resolveCanvasIframeUrl( + "/__openclaw__/canvas/documents/cv_demo/index.html", + "http://127.0.0.1:19003/__openclaw__/cap/cap_123", + ), + ).toBe( + "http://127.0.0.1:19003/__openclaw__/cap/cap_123/__openclaw__/canvas/documents/cv_demo/index.html", + ); + }); + + it("rejects unsafe canvas frame URLs unless external embeds are explicitly enabled", async () => { + const { resolveCanvasIframeUrl } = await loadResolver(); + + expect(resolveCanvasIframeUrl("/not-canvas/snake.html")).toBeUndefined(); + expect(resolveCanvasIframeUrl("https://example.com/evil.html")).toBeUndefined(); + expect(resolveCanvasIframeUrl("file:///tmp/snake.html")).toBeUndefined(); + expect(resolveCanvasIframeUrl("https://example.com/embed.html?x=1#y", undefined, true)).toBe( + "https://example.com/embed.html?x=1#y", + ); + }); +}); diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/pages/chat/components/chat-tool-cards.test.ts similarity index 99% rename from ui/src/ui/chat/tool-cards.test.ts rename to ui/src/pages/chat/components/chat-tool-cards.test.ts index 95161756414e..5a3b2400959d 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.test.ts @@ -38,9 +38,8 @@ import { formatCollapsedToolPreviewText, formatCollapsedToolSummaryText, isToolErrorOutput, - renderToolCard, - renderToolCardSidebar, -} from "./tool-cards.ts"; +} from "../../../lib/chat/tool-cards.ts"; +import { renderToolCard, renderToolCardSidebar } from "./chat-tool-cards.ts"; function requireFirstMockArg( mock: ReturnType, diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts similarity index 59% rename from ui/src/ui/chat/tool-cards.ts rename to ui/src/pages/chat/components/chat-tool-cards.ts index 84338089a983..f4a6628821e9 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -1,206 +1,59 @@ // Control UI chat module implements tool cards behavior. import { html, nothing } from "lit"; import { keyed } from "lit/directives/keyed.js"; -import { extractCanvasFromText } from "../../../../src/chat/canvas-render.js"; -import { t } from "../../i18n/index.ts"; -import { resolveCanvasIframeUrl } from "../canvas-url.ts"; -import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts"; -import { icons } from "../icons.ts"; -import { isMarkdownBlockArtText } from "../markdown.ts"; -import type { SidebarContent } from "../sidebar-content.ts"; -import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts"; -import type { ToolCard } from "../types/chat-types.ts"; -import { extractTextCached } from "./message-extract.ts"; -import { isToolResultMessage } from "./role-normalizer.ts"; -import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers.ts"; +import { icons, type IconName } from "../../../components/icons.ts"; +import { isMarkdownBlockArtText } from "../../../components/markdown.ts"; +import "../../../components/tooltip.ts"; +import { t } from "../../../i18n/index.ts"; +import type { ToolCard } from "../../../lib/chat/chat-types.ts"; +import { + formatCollapsedToolPreviewText, + formatCollapsedToolSummaryText, + isToolCardError, + type ToolPreview, +} from "../../../lib/chat/tool-cards.ts"; +import { + formatToolDetail, + resolveCanvasIframeUrl, + resolveEmbedSandbox, + resolveToolDisplay, + type EmbedSandboxMode, +} from "../../../lib/chat/tool-display.ts"; +import type { SidebarContent } from "./chat-sidebar.ts"; -export type ToolPreview = NonNullable; +const TOOL_PREVIEW_MAX_LINES = 2; +const TOOL_PREVIEW_MAX_CHARS = 100; + +function formatToolOutputForSidebar(text: string): string { + if (isMarkdownBlockArtText(text)) { + return "```\n" + text + "\n```"; + } + + const trimmed = text.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return "```json\n" + JSON.stringify(JSON.parse(trimmed), null, 2) + "\n```"; + } catch { + return text; + } + } + return text; +} + +function getTruncatedPreview(text: string): string { + const allLines = text.split("\n"); + const lines = allLines.slice(0, TOOL_PREVIEW_MAX_LINES); + const preview = lines.join("\n"); + if (preview.length > TOOL_PREVIEW_MAX_CHARS) { + return `${preview.slice(0, TOOL_PREVIEW_MAX_CHARS)}…`; + } + return lines.length < allLines.length ? `${preview}…` : preview; +} type FullMessageRequest = NonNullable; -function resolveCanvasPreviewSandbox(preview: ToolPreview): string { - return resolveEmbedSandbox(preview.kind === "canvas" ? "scripts" : "scripts"); -} - -function resolveTranscriptMessageId(message: Record): string | undefined { - if (typeof message.messageId === "string" && message.messageId.trim()) { - return message.messageId; - } - const openClawMeta = message["__openclaw"]; - const transcriptMeta = - openClawMeta && typeof openClawMeta === "object" && !Array.isArray(openClawMeta) - ? (openClawMeta as Record) - : null; - return typeof transcriptMeta?.id === "string" && transcriptMeta.id.trim() - ? transcriptMeta.id - : undefined; -} - -function normalizeContent(content: unknown): Array> { - if (!Array.isArray(content)) { - return []; - } - return content.filter( - (entry): entry is Record => Boolean(entry) && typeof entry === "object", - ); -} - -function coerceArgs(value: unknown): unknown { - if (typeof value !== "string") { - return value; - } - const trimmed = value.trim(); - if (!trimmed) { - return value; - } - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { - return value; - } - try { - return JSON.parse(trimmed); - } catch { - return value; - } -} - -function extractToolText(item: Record): string | undefined { - if (typeof item.text === "string") { - return item.text; - } - if (typeof item.content === "string") { - return item.content; - } - if (Array.isArray(item.content)) { - const parts = item.content.flatMap((entry) => { - if (!entry || typeof entry !== "object") { - return []; - } - const text = (entry as { text?: unknown }).text; - return typeof text === "string" ? [text] : []; - }); - if (parts.length > 0) { - return parts.join("\n"); - } - } - return undefined; -} - -function readToolErrorFlag(value: Record): boolean | undefined { - const raw = value.isError ?? value.is_error; - return typeof raw === "boolean" ? raw : undefined; -} - -const TOOL_NOT_FOUND_PATTERN = /^tool not found\.?$/i; -const MAX_ERROR_DETECT_CHARS = 20_000; -const TOOL_ERROR_STATUSES = new Set(["error", "failed", "timeout"]); - -function hasToolErrorStatus(value: unknown): boolean { - return typeof value === "string" && TOOL_ERROR_STATUSES.has(value.trim().toLowerCase()); -} - -export function isToolErrorOutput(outputText: string | undefined): boolean { - if (!outputText) { - return false; - } - const trimmed = outputText.trim(); - if (!trimmed) { - return false; - } - if (TOOL_NOT_FOUND_PATTERN.test(trimmed)) { - return true; - } - if (trimmed.length > MAX_ERROR_DETECT_CHARS) { - return false; - } - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { - return false; - } - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return false; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return false; - } - const obj = parsed as Record; - const explicitErrorFlag = readToolErrorFlag(obj); - if (explicitErrorFlag !== undefined) { - return explicitErrorFlag; - } - if ("error" in obj) { - const value = obj.error; - if (typeof value === "string") { - return value.trim().length > 0; - } - if (typeof value === "boolean") { - return value; - } - if (value && typeof value === "object") { - return true; - } - } - return hasToolErrorStatus(obj.status); -} - -export function isToolCardError(card: ToolCard): boolean { - if (card.isError !== undefined) { - return card.isError; - } - return isToolErrorOutput(card.outputText); -} - -export function extractToolPreview( - outputText: string | undefined, - toolName: string | undefined, -): ToolCard["preview"] | undefined { - return extractCanvasFromText(outputText, toolName); -} - -function resolveToolCardId( - item: Record, - message: Record, - index: number, - prefix = "tool", -): string { - const explicitId = - (typeof item.id === "string" && item.id.trim()) || - (typeof item.toolCallId === "string" && item.toolCallId.trim()) || - (typeof item.tool_call_id === "string" && item.tool_call_id.trim()) || - (typeof item.callId === "string" && item.callId.trim()) || - (typeof message.toolCallId === "string" && message.toolCallId.trim()) || - (typeof message.tool_call_id === "string" && message.tool_call_id.trim()) || - ""; - if (explicitId) { - return `${prefix}:${explicitId}`; - } - const name = - (typeof item.name === "string" && item.name.trim()) || - (typeof message.toolName === "string" && message.toolName.trim()) || - (typeof message.tool_name === "string" && message.tool_name.trim()) || - "tool"; - return `${prefix}:${name}:${index}`; -} - -function serializeToolInput(args: unknown): string | undefined { - if (args === undefined || args === null) { - return undefined; - } - if (typeof args === "string") { - return args; - } - try { - return JSON.stringify(args, null, 2); - } catch { - if (typeof args === "number" || typeof args === "boolean" || typeof args === "bigint") { - return String(args); - } - if (typeof args === "symbol") { - return args.description ? `Symbol(${args.description})` : "Symbol()"; - } - return Object.prototype.toString.call(args); - } +function renderToolIcon(name: string) { + return icons[name as IconName] ?? icons.puzzle; } function formatPayloadForSidebar( @@ -224,147 +77,6 @@ ${text} \`\`\``; } -export function formatCollapsedToolSummaryText(value: string | undefined): string | undefined { - const normalized = value?.trim().replace(/\s+/g, " "); - if (!normalized) { - return undefined; - } - const withoutConnector = normalized.replace(/^with\s+/i, "").trim(); - return withoutConnector || normalized; -} - -export function formatCollapsedToolPreviewText(value: string | undefined): string | undefined { - const normalized = formatCollapsedToolSummaryText(value); - if (!normalized) { - return undefined; - } - return normalized.slice(0, 120); -} - -function findFirstUnmatchedCard( - cards: ToolCard[], - id: string, - name: string, - fallbackMatchedCards: WeakSet, -): ToolCard | undefined { - let nameOnlyCandidate: ToolCard | undefined; - for (const card of cards) { - if (card.id === id) { - return card; - } - if ( - !nameOnlyCandidate && - card.name === name && - card.outputText === undefined && - !fallbackMatchedCards.has(card) - ) { - nameOnlyCandidate = card; - } - } - return nameOnlyCandidate; -} - -export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] { - const m = message as Record; - const content = normalizeContent(m.content); - const messageIsError = readToolErrorFlag(m); - const cards: ToolCard[] = []; - const fallbackMatchedCards = new WeakSet(); - const transcriptMessageId = resolveTranscriptMessageId(m); - - for (let index = 0; index < content.length; index++) { - const item = content[index] ?? {}; - const kind = (typeof item.type === "string" ? item.type : "").toLowerCase(); - const isToolCall = - ["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) || - (typeof item.name === "string" && - (item.arguments != null || item.args != null || item.input != null)); - if (isToolCall) { - const args = coerceArgs(item.arguments ?? item.args ?? item.input); - cards.push({ - id: resolveToolCardId(item, m, index, prefix), - name: typeof item.name === "string" ? item.name : "tool", - args, - inputText: serializeToolInput(args), - messageId: transcriptMessageId, - }); - continue; - } - - if (kind === "toolresult" || kind === "tool_result") { - const name = typeof item.name === "string" ? item.name : "tool"; - const cardId = resolveToolCardId(item, m, index, prefix); - const existing = findFirstUnmatchedCard(cards, cardId, name, fallbackMatchedCards); - const text = extractToolText(item); - const preview = extractToolPreview(text, name); - const isError = readToolErrorFlag(item) ?? messageIsError; - if (existing) { - fallbackMatchedCards.add(existing); - existing.outputText = text; - existing.preview = preview; - if (isError !== undefined) { - existing.isError = isError; - } - continue; - } - cards.push({ - id: cardId, - name, - outputText: text, - messageId: transcriptMessageId, - ...(isError !== undefined ? { isError } : {}), - preview, - }); - } - } - - const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; - const isStandaloneToolMessage = - isToolResultMessage(message) || - role === "tool" || - role === "function" || - typeof m.toolName === "string" || - typeof m.tool_name === "string"; - - if (isStandaloneToolMessage && cards.length === 0) { - const name = - (typeof m.toolName === "string" && m.toolName) || - (typeof m.tool_name === "string" && m.tool_name) || - "tool"; - const text = extractTextCached(message) ?? undefined; - cards.push({ - id: resolveToolCardId({}, m, 0, prefix), - name, - outputText: text, - messageId: transcriptMessageId, - ...(messageIsError !== undefined ? { isError: messageIsError } : {}), - preview: extractToolPreview(text, name), - }); - } - - return cards; -} - -const toolCardsByMessage = new WeakMap>(); - -export function extractToolCardsCached(message: unknown, prefix = "tool"): ToolCard[] { - if (!message || typeof message !== "object") { - return extractToolCards(message, prefix); - } - let byPrefix = toolCardsByMessage.get(message); - if (!byPrefix) { - byPrefix = new Map(); - toolCardsByMessage.set(message, byPrefix); - } - const cached = byPrefix.get(prefix); - if (cached) { - return cached; - } - const cards = extractToolCards(message, prefix); - byPrefix.set(prefix, cards); - return cards; -} - export function buildToolCardSidebarContent(card: ToolCard): string { const display = resolveToolDisplay({ name: card.name, args: card.args }); const detail = formatToolDetail(display); @@ -465,10 +177,7 @@ export function renderToolPreview( options?.allowExternalEmbedUrls ?? false, ), height: preview.preferredHeight, - sandbox: - preview.kind === "canvas" - ? resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts") - : resolveCanvasPreviewSandbox(preview), + sandbox: resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts"), })} @@ -668,7 +377,7 @@ export function renderToolCard( > ${renderCollapsedToolSummary({ label: summary.label, - icon: icons[display.icon], + icon: renderToolIcon(display.icon), name: summary.name, expanded: opts.expanded, isError, @@ -731,7 +440,7 @@ export function renderExpandedToolCardContent(
- ${icons[display.icon]} + ${renderToolIcon(display.icon)} ${display.label} ${isError ? html` - + + +
` : nothing} @@ -831,7 +541,7 @@ export function renderToolCardSidebar( >
- ${icons[display.icon]} + ${renderToolIcon(display.icon)} ${display.label}
${canClick diff --git a/ui/src/ui/chat/chat-welcome.ts b/ui/src/pages/chat/components/chat-welcome.ts similarity index 95% rename from ui/src/ui/chat/chat-welcome.ts rename to ui/src/pages/chat/components/chat-welcome.ts index 71bf2affaae3..dd995e60bb0d 100644 --- a/ui/src/ui/chat/chat-welcome.ts +++ b/ui/src/pages/chat/components/chat-welcome.ts @@ -1,12 +1,12 @@ // Control UI chat module implements chat welcome behavior. import { html } from "lit"; -import { t } from "../../i18n/index.ts"; +import { t } from "../../../i18n/index.ts"; import { agentLogoUrl, assistantAvatarFallbackUrl, - resolveChatAvatarRenderUrl, resolveAssistantTextAvatar, -} from "../views/agents-utils.ts"; +} from "../../../lib/agents/display.ts"; +import { resolveChatAvatarRenderUrl } from "../../../lib/avatar.ts"; export type ChatWelcomeProps = { assistantName: string; diff --git a/ui/src/ui/chat/composer-persistence.test.ts b/ui/src/pages/chat/composer-persistence.test.ts similarity index 99% rename from ui/src/ui/chat/composer-persistence.test.ts rename to ui/src/pages/chat/composer-persistence.test.ts index 9aeb18eeca43..26e4bcea833f 100644 --- a/ui/src/ui/chat/composer-persistence.test.ts +++ b/ui/src/pages/chat/composer-persistence.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; -import type { ChatQueueItem } from "../ui-types.ts"; import { loadChatComposerSnapshot, persistChatComposerState, diff --git a/ui/src/ui/chat/composer-persistence.ts b/ui/src/pages/chat/composer-persistence.ts similarity index 84% rename from ui/src/ui/chat/composer-persistence.ts rename to ui/src/pages/chat/composer-persistence.ts index f9b24a58aed1..0e401bf84f04 100644 --- a/ui/src/ui/chat/composer-persistence.ts +++ b/ui/src/pages/chat/composer-persistence.ts @@ -1,12 +1,22 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { + ChatAttachment, + ChatQueueItem, + ChatQueueSkillWorkshopRevision, +} from "../../lib/chat/chat-types.ts"; +import { + DEFAULT_AGENT_ID, + normalizeAgentId, + parseAgentSessionKey, +} from "../../lib/sessions/session-key.ts"; // Control UI chat module implements composer persistence behavior. import { getSafeSessionStorage } from "../../local-storage.ts"; -import { DEFAULT_AGENT_ID, normalizeAgentId, parseAgentSessionKey } from "../session-key.ts"; -import type { ChatAttachment, ChatQueueItem, ChatQueueSkillWorkshopRevision } from "../ui-types.ts"; import { getChatAttachmentDataUrl } from "./attachment-payload-store.ts"; const STORAGE_KEY_PREFIX = "openclaw.control.chatComposer.v1:"; const MAX_STORED_SESSIONS = 20; const MAX_STORED_QUEUE_ITEMS = 50; +export const CHAT_COMPOSER_DRAFT_PERSIST_DELAY_MS = 200; export const INTERRUPTED_MODEL_WAIT_ERROR = "Model selection was interrupted. Review and retry when ready."; @@ -22,6 +32,11 @@ type ChatComposerPersistenceState = { chatQueue: ChatQueueItem[]; }; +export type ChatComposerScope = Pick< + ChatComposerPersistenceState, + "settings" | "assistantAgentId" | "agentsList" | "hello" +>; + type StoredComposerSession = { draft?: string; queue?: ChatQueueItem[]; @@ -424,10 +439,7 @@ export function removeStoredChatComposerQueueItem( } export function persistStoredChatComposerQueue( - state: Pick< - ChatComposerPersistenceState, - "settings" | "assistantAgentId" | "agentsList" | "hello" - >, + state: ChatComposerScope, sessionKey: string, queue: ChatQueueItem[], ): void { @@ -476,3 +488,107 @@ export function restoreChatComposerState( } return true; } + +export class ChatComposerPersistenceController implements ReactiveController { + private timer: ReturnType | null = null; + private ready = false; + private lastPersisted: { + sessionKey: string; + chatMessage: string; + chatQueue: ChatQueueItem[]; + } | null = null; + + constructor( + host: ReactiveControllerHost, + private readonly getState: () => ChatComposerPersistenceState | undefined, + ) { + host.addController(this); + } + + hostDisconnected() { + this.stop(); + } + + start() { + const state = this.getState(); + if (!state) { + return; + } + this.ready = true; + this.lastPersisted = this.snapshot(state); + } + + stop() { + this.persistNow(); + this.ready = false; + this.clearTimer(); + } + + restore(options: RestoreOptions = {}): boolean { + const state = this.getState(); + if (!state) { + return false; + } + const restored = restoreChatComposerState(state, options); + this.lastPersisted = this.snapshot(state); + return restored; + } + + schedule() { + this.persist(false); + } + + persistNow() { + this.persist(true); + } + + persistQueueIfChanged() { + const state = this.getState(); + if (this.lastPersisted?.chatQueue !== state?.chatQueue) { + this.persistNow(); + } + } + + private persist(immediate: boolean) { + const state = this.getState(); + if (!this.ready || !state || this.isUnchanged(state)) { + return; + } + this.clearTimer(); + if (!immediate) { + this.timer = globalThis.setTimeout( + () => this.persistNow(), + CHAT_COMPOSER_DRAFT_PERSIST_DELAY_MS, + ); + return; + } + persistChatComposerState(state); + this.lastPersisted = this.snapshot(state); + } + + private clearTimer() { + if (this.timer === null) { + return; + } + globalThis.clearTimeout(this.timer); + this.timer = null; + } + + private isUnchanged(state: ChatComposerPersistenceState): boolean { + const last = this.lastPersisted; + return Boolean( + last && + last.sessionKey === state.sessionKey && + last.chatMessage === state.chatMessage && + last.chatQueue === state.chatQueue, + ); + } + + private snapshot(state: ChatComposerPersistenceState) { + return { + sessionKey: state.sessionKey, + chatMessage: state.chatMessage, + chatQueue: state.chatQueue, + }; + } +} diff --git a/ui/src/ui/connect-error.node.test.ts b/ui/src/pages/chat/connect-error.node.test.ts similarity index 100% rename from ui/src/ui/connect-error.node.test.ts rename to ui/src/pages/chat/connect-error.node.test.ts diff --git a/ui/src/ui/connect-error.test.ts b/ui/src/pages/chat/connect-error.test.ts similarity index 91% rename from ui/src/ui/connect-error.test.ts rename to ui/src/pages/chat/connect-error.test.ts index 2937ae38c77b..86e6ba61fade 100644 --- a/ui/src/ui/connect-error.test.ts +++ b/ui/src/pages/chat/connect-error.test.ts @@ -1,6 +1,6 @@ // Control UI tests cover connect error behavior. import { describe, expect, it } from "vitest"; -import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; import { formatConnectError } from "./connect-error.ts"; describe("formatConnectError", () => { diff --git a/ui/src/ui/connect-error.ts b/ui/src/pages/chat/connect-error.ts similarity index 94% rename from ui/src/ui/connect-error.ts rename to ui/src/pages/chat/connect-error.ts index 6e9b025562e5..b86de0d7a94f 100644 --- a/ui/src/ui/connect-error.ts +++ b/ui/src/pages/chat/connect-error.ts @@ -5,9 +5,9 @@ import { formatConnectPairingRequiredMessage, readConnectPairingRequiredMessage, readPairingConnectErrorDetails, -} from "../../../packages/gateway-protocol/src/connect-error-details.js"; -import { resolveGatewayErrorDetailCode } from "./gateway.ts"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; +} from "../../../../packages/gateway-protocol/src/connect-error-details.js"; +import { resolveGatewayErrorDetailCode } from "../../api/gateway.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; type ErrorWithMessageAndDetails = { message?: unknown; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/pages/chat/deleted-messages.ts similarity index 100% rename from ui/src/ui/chat/deleted-messages.ts rename to ui/src/pages/chat/deleted-messages.ts diff --git a/ui/src/ui/chat/export.node.test.ts b/ui/src/pages/chat/export.node.test.ts similarity index 100% rename from ui/src/ui/chat/export.node.test.ts rename to ui/src/pages/chat/export.node.test.ts diff --git a/ui/src/ui/chat/export.ts b/ui/src/pages/chat/export.ts similarity index 94% rename from ui/src/ui/chat/export.ts rename to ui/src/pages/chat/export.ts index 063a9de60cde..7a647f99723a 100644 --- a/ui/src/ui/chat/export.ts +++ b/ui/src/pages/chat/export.ts @@ -1,6 +1,6 @@ // Control UI chat module implements export behavior. import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; -import { extractTextCached } from "./message-extract.ts"; +import { extractTextCached } from "../../lib/chat/message-extract.ts"; /** * Export chat history as markdown file. diff --git a/ui/src/ui/chat/history-merge.test.ts b/ui/src/pages/chat/history-merge.test.ts similarity index 95% rename from ui/src/ui/chat/history-merge.test.ts rename to ui/src/pages/chat/history-merge.test.ts index cc482908b4f9..f940d32a638d 100644 --- a/ui/src/ui/chat/history-merge.test.ts +++ b/ui/src/pages/chat/history-merge.test.ts @@ -1,6 +1,6 @@ // Control UI tests cover history merge behavior. import { describe, expect, it } from "vitest"; -import { preserveOptimisticTailMessages } from "../controllers/chat.ts"; +import { preserveOptimisticTailMessages } from "./chat-history.ts"; describe("preserveOptimisticTailMessages", () => { it("keeps optimistic tail messages while history is stale", () => { diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/pages/chat/input-history.ts similarity index 98% rename from ui/src/ui/chat/input-history.ts rename to ui/src/pages/chat/input-history.ts index bd0443c994fa..cccca0582bda 100644 --- a/ui/src/ui/chat/input-history.ts +++ b/ui/src/pages/chat/input-history.ts @@ -1,6 +1,6 @@ // Control UI chat module implements input history behavior. -import { CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts"; -import { extractText } from "./message-extract.ts"; +import { CHAT_HISTORY_RENDER_LIMIT } from "../../lib/chat/chat-types.ts"; +import { extractText } from "../../lib/chat/message-extract.ts"; type ChatLocalInputHistoryEntry = { text: string; diff --git a/ui/src/ui/controllers/models.test.ts b/ui/src/pages/chat/models.test.ts similarity index 94% rename from ui/src/ui/controllers/models.test.ts rename to ui/src/pages/chat/models.test.ts index d781af5e2b69..7fd41149334c 100644 --- a/ui/src/ui/controllers/models.test.ts +++ b/ui/src/pages/chat/models.test.ts @@ -1,6 +1,6 @@ // Control UI tests cover models behavior. import { describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { loadModels } from "./models.ts"; describe("loadModels", () => { diff --git a/ui/src/ui/controllers/models.ts b/ui/src/pages/chat/models.ts similarity index 93% rename from ui/src/ui/controllers/models.ts rename to ui/src/pages/chat/models.ts index 0412a59e3654..fc132fa88948 100644 --- a/ui/src/ui/controllers/models.ts +++ b/ui/src/pages/chat/models.ts @@ -1,6 +1,6 @@ // Control UI controller manages models gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { ModelCatalogEntry } from "../types.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ModelCatalogEntry } from "../../api/types.ts"; const MODEL_CATALOG_CACHE_TTL_MS = 60_000; diff --git a/ui/src/pages/chat/performance.ts b/ui/src/pages/chat/performance.ts new file mode 100644 index 000000000000..4c1132ff954b --- /dev/null +++ b/ui/src/pages/chat/performance.ts @@ -0,0 +1,80 @@ +import type { EventLogEntry } from "../../api/event-log.ts"; + +type ChatPerformanceHost = { + eventLogBuffer?: unknown[]; + updateComplete?: Promise; +}; + +const EVENT_LOG_LIMIT = 250; + +export function controlUiNowMs(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); +} + +export function roundedControlUiDurationMs(durationMs: number): number { + return Math.max(0, Math.round(durationMs)); +} + +function runAfterPaint(callback: () => void): void { + if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") { + queueMicrotask(callback); + return; + } + window.requestAnimationFrame(() => window.requestAnimationFrame(callback)); +} + +function keepLatestBufferedEventsForType( + entries: unknown[], + event: string, + maxExistingForType: number, +): unknown[] { + let keptForType = 0; + return entries.filter((entry) => { + if ( + !entry || + typeof entry !== "object" || + !("event" in entry) || + (entry as { event?: unknown }).event !== event + ) { + return true; + } + keptForType += 1; + return keptForType <= maxExistingForType; + }); +} + +export function recordControlUiPerformanceEvent( + host: ChatPerformanceHost, + event: string, + payload: Record, + opts?: { warn?: boolean; console?: boolean; maxBufferedEventsForType?: number }, +): void { + const entry: EventLogEntry = { ts: Date.now(), event, payload }; + if (Array.isArray(host.eventLogBuffer)) { + const existingBuffer = + typeof opts?.maxBufferedEventsForType === "number" + ? keepLatestBufferedEventsForType( + host.eventLogBuffer, + event, + Math.max(0, opts.maxBufferedEventsForType - 1), + ) + : host.eventLogBuffer; + host.eventLogBuffer = [entry, ...existingBuffer].slice(0, EVENT_LOG_LIMIT); + } + if (opts?.console === false) { + return; + } + const logger = opts?.warn === true ? console.warn : console.debug; + logger(`[openclaw] ${event}`, payload); +} + +export function scheduleControlUiAfterPaint( + host: Pick, + callback: () => void, +): void { + void Promise.resolve(host.updateComplete) + .catch(() => undefined) + .then(() => runAfterPaint(callback)); +} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/pages/chat/pinned-messages.ts similarity index 100% rename from ui/src/ui/chat/pinned-messages.ts rename to ui/src/pages/chat/pinned-messages.ts diff --git a/ui/src/ui/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts similarity index 51% rename from ui/src/ui/chat/realtime-talk-audio.ts rename to ui/src/pages/chat/realtime-talk-audio.ts index 21c56f784077..3314aa6dd915 100644 --- a/ui/src/ui/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -36,3 +36,46 @@ export function pcm16ToFloat(bytes: Uint8Array): Float32Array { } return samples; } + +export class RealtimeTalkPcmOutputQueue { + private playhead = 0; + private readonly sources = new Set(); + + get queuedUntil(): number { + return this.playhead; + } + + get isPlaying(): boolean { + return this.sources.size > 0; + } + + play(base64: string, outputContext: AudioContext | null, outputSampleRateHz: number): void { + if (!outputContext) { + return; + } + const samples = pcm16ToFloat(base64ToBytes(base64)); + if (samples.length === 0) { + return; + } + const buffer = outputContext.createBuffer(1, samples.length, outputSampleRateHz); + buffer.getChannelData(0).set(samples); + const source = outputContext.createBufferSource(); + this.sources.add(source); + source.addEventListener("ended", () => this.sources.delete(source)); + source.buffer = buffer; + source.connect(outputContext.destination); + const startAt = Math.max(outputContext.currentTime, this.playhead); + source.start(startAt); + this.playhead = startAt + buffer.duration; + } + + stop(outputContext: AudioContext | null): void { + for (const source of this.sources) { + try { + source.stop(); + } catch {} + } + this.sources.clear(); + this.playhead = outputContext?.currentTime ?? 0; + } +} diff --git a/ui/src/ui/chat/realtime-talk-catalog.ts b/ui/src/pages/chat/realtime-talk-catalog.ts similarity index 100% rename from ui/src/ui/chat/realtime-talk-catalog.ts rename to ui/src/pages/chat/realtime-talk-catalog.ts diff --git a/ui/src/ui/realtime-talk-consult.test.ts b/ui/src/pages/chat/realtime-talk-consult.test.ts similarity index 99% rename from ui/src/ui/realtime-talk-consult.test.ts rename to ui/src/pages/chat/realtime-talk-consult.test.ts index f3def4987c1d..1933f26dd3c5 100644 --- a/ui/src/ui/realtime-talk-consult.test.ts +++ b/ui/src/pages/chat/realtime-talk-consult.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { steerRealtimeTalkActiveConsult, submitRealtimeTalkConsult, -} from "./chat/realtime-talk-shared.js"; +} from "./realtime-talk-shared.ts"; function requireFirstMockCall(calls: readonly unknown[][], label: string): unknown[] { const call = calls.at(0); diff --git a/ui/src/ui/chat/realtime-talk-conversation.test.ts b/ui/src/pages/chat/realtime-talk-conversation.test.ts similarity index 100% rename from ui/src/ui/chat/realtime-talk-conversation.test.ts rename to ui/src/pages/chat/realtime-talk-conversation.test.ts diff --git a/ui/src/ui/chat/realtime-talk-conversation.ts b/ui/src/pages/chat/realtime-talk-conversation.ts similarity index 100% rename from ui/src/ui/chat/realtime-talk-conversation.ts rename to ui/src/pages/chat/realtime-talk-conversation.ts diff --git a/ui/src/ui/realtime-talk-gateway-relay.test.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts similarity index 99% rename from ui/src/ui/realtime-talk-gateway-relay.test.ts rename to ui/src/pages/chat/realtime-talk-gateway-relay.test.ts index 9805335b7e55..b035ae8f85ee 100644 --- a/ui/src/ui/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -1,12 +1,12 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GatewayRelayRealtimeTalkTransport } from "./chat/realtime-talk-gateway-relay.ts"; +import { GatewayRelayRealtimeTalkTransport } from "./realtime-talk-gateway-relay.ts"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, type RealtimeTalkEvent, type RealtimeTalkGatewayRelaySessionResult, type RealtimeTalkTransportContext, -} from "./chat/realtime-talk-shared.ts"; +} from "./realtime-talk-shared.ts"; type GatewayFrame = { event: string; payload?: unknown }; type GatewayListener = (event: GatewayFrame) => void; diff --git a/ui/src/ui/chat/realtime-talk-gateway-relay.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.ts similarity index 98% rename from ui/src/ui/chat/realtime-talk-gateway-relay.ts rename to ui/src/pages/chat/realtime-talk-gateway-relay.ts index 345c62651f37..32f864b29cef 100644 --- a/ui/src/ui/chat/realtime-talk-gateway-relay.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.ts @@ -1,6 +1,5 @@ // Control UI chat module implements realtime talk gateway relay behavior. -import { bytesToBase64, floatToPcm16 } from "./realtime-talk-audio.ts"; -import { RealtimeTalkPcmOutputQueue } from "./realtime-talk-pcm-output.ts"; +import { bytesToBase64, floatToPcm16, RealtimeTalkPcmOutputQueue } from "./realtime-talk-audio.ts"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME, diff --git a/ui/src/ui/realtime-talk-google-live.test.ts b/ui/src/pages/chat/realtime-talk-google-live.test.ts similarity index 99% rename from ui/src/ui/realtime-talk-google-live.test.ts rename to ui/src/pages/chat/realtime-talk-google-live.test.ts index c4746290deaf..e3310db3de6d 100644 --- a/ui/src/ui/realtime-talk-google-live.test.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.test.ts @@ -3,12 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildGoogleLiveUrl, GoogleLiveRealtimeTalkTransport, -} from "./chat/realtime-talk-google-live.ts"; -import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME } from "./chat/realtime-talk-shared.ts"; +} from "./realtime-talk-google-live.ts"; +import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME } from "./realtime-talk-shared.ts"; import type { RealtimeTalkJsonPcmWebSocketSessionResult, RealtimeTalkTransportContext, -} from "./chat/realtime-talk-shared.ts"; +} from "./realtime-talk-shared.ts"; type MockWebSocketEvent = { data?: unknown; diff --git a/ui/src/ui/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts similarity index 98% rename from ui/src/ui/chat/realtime-talk-google-live.ts rename to ui/src/pages/chat/realtime-talk-google-live.ts index 19084a982580..2f6b76fa2d18 100644 --- a/ui/src/ui/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -1,6 +1,10 @@ // Control UI chat module implements realtime talk google live behavior. -import { base64ToBytes, bytesToBase64, floatToPcm16 } from "./realtime-talk-audio.ts"; -import { RealtimeTalkPcmOutputQueue } from "./realtime-talk-pcm-output.ts"; +import { + base64ToBytes, + bytesToBase64, + floatToPcm16, + RealtimeTalkPcmOutputQueue, +} from "./realtime-talk-audio.ts"; import type { RealtimeTalkJsonPcmWebSocketSessionResult } from "./realtime-talk-shared.ts"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, diff --git a/ui/src/ui/chat/realtime-talk-shared.browser-import.test.ts b/ui/src/pages/chat/realtime-talk-shared.browser-import.test.ts similarity index 100% rename from ui/src/ui/chat/realtime-talk-shared.browser-import.test.ts rename to ui/src/pages/chat/realtime-talk-shared.browser-import.test.ts diff --git a/ui/src/ui/chat/realtime-talk-shared.ts b/ui/src/pages/chat/realtime-talk-shared.ts similarity index 99% rename from ui/src/ui/chat/realtime-talk-shared.ts rename to ui/src/pages/chat/realtime-talk-shared.ts index 09442e74196d..83e1a9103294 100644 --- a/ui/src/ui/chat/realtime-talk-shared.ts +++ b/ui/src/pages/chat/realtime-talk-shared.ts @@ -9,7 +9,7 @@ import { } from "../../../../src/talk/agent-run-control-shared.js"; import type { RealtimeVoiceAgentControlMode } from "../../../../src/talk/agent-run-control-shared.js"; import type { TalkEvent } from "../../../../src/talk/talk-events.js"; -import type { GatewayBrowserClient, GatewayEventFrame } from "../gateway.ts"; +import type { GatewayBrowserClient, GatewayEventFrame } from "../../api/gateway.ts"; export type RealtimeTalkStatus = "idle" | "connecting" | "listening" | "thinking" | "error"; export type RealtimeTalkEvent = TalkEvent; diff --git a/ui/src/ui/realtime-talk-webrtc.test.ts b/ui/src/pages/chat/realtime-talk-webrtc.test.ts similarity index 99% rename from ui/src/ui/realtime-talk-webrtc.test.ts rename to ui/src/pages/chat/realtime-talk-webrtc.test.ts index 488177073c5b..4cb2fe4b535b 100644 --- a/ui/src/ui/realtime-talk-webrtc.test.ts +++ b/ui/src/pages/chat/realtime-talk-webrtc.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME, -} from "./chat/realtime-talk-shared.ts"; -import { WebRtcSdpRealtimeTalkTransport } from "./chat/realtime-talk-webrtc.ts"; +} from "./realtime-talk-shared.ts"; +import { WebRtcSdpRealtimeTalkTransport } from "./realtime-talk-webrtc.ts"; class FakeDataChannel extends EventTarget { readyState: RTCDataChannelState = "open"; diff --git a/ui/src/ui/chat/realtime-talk-webrtc.ts b/ui/src/pages/chat/realtime-talk-webrtc.ts similarity index 100% rename from ui/src/ui/chat/realtime-talk-webrtc.ts rename to ui/src/pages/chat/realtime-talk-webrtc.ts diff --git a/ui/src/ui/realtime-talk.test.ts b/ui/src/pages/chat/realtime-talk.test.ts similarity index 96% rename from ui/src/ui/realtime-talk.test.ts rename to ui/src/pages/chat/realtime-talk.test.ts index 8e8a834386a5..840e127f367d 100644 --- a/ui/src/ui/realtime-talk.test.ts +++ b/ui/src/pages/chat/realtime-talk.test.ts @@ -29,19 +29,19 @@ const { }), })); -vi.mock("./chat/realtime-talk-google-live.ts", () => ({ +vi.mock("./realtime-talk-google-live.ts", () => ({ GoogleLiveRealtimeTalkTransport: googleCtor, })); -vi.mock("./chat/realtime-talk-gateway-relay.ts", () => ({ +vi.mock("./realtime-talk-gateway-relay.ts", () => ({ GatewayRelayRealtimeTalkTransport: relayCtor, })); -vi.mock("./chat/realtime-talk-webrtc.ts", () => ({ +vi.mock("./realtime-talk-webrtc.ts", () => ({ WebRtcSdpRealtimeTalkTransport: webRtcCtor, })); -import { RealtimeTalkSession } from "./chat/realtime-talk.ts"; +import { RealtimeTalkSession } from "./realtime-talk.ts"; describe("RealtimeTalkSession", () => { beforeEach(() => { diff --git a/ui/src/ui/chat/realtime-talk.ts b/ui/src/pages/chat/realtime-talk.ts similarity index 98% rename from ui/src/ui/chat/realtime-talk.ts rename to ui/src/pages/chat/realtime-talk.ts index 7006cb0eb025..f47fe12c0bba 100644 --- a/ui/src/ui/chat/realtime-talk.ts +++ b/ui/src/pages/chat/realtime-talk.ts @@ -1,6 +1,6 @@ // Control UI chat module implements realtime talk behavior. import { normalizeTalkTransport } from "../../../../src/talk/talk-session-controller.js"; -import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { GatewayRelayRealtimeTalkTransport } from "./realtime-talk-gateway-relay.ts"; import { GoogleLiveRealtimeTalkTransport } from "./realtime-talk-google-live.ts"; import type { diff --git a/ui/src/pages/chat/route.ts b/ui/src/pages/chat/route.ts new file mode 100644 index 000000000000..89715ced0b18 --- /dev/null +++ b/ui/src/pages/chat/route.ts @@ -0,0 +1,36 @@ +import type { RouteLocation } from "@openclaw/uirouter"; +import { definePage, notFound } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; + +function sessionKeyFromLocation(location: RouteLocation): string | undefined { + const sessionKey = new URLSearchParams(location.search).get("session")?.trim(); + return sessionKey || undefined; +} + +function draftFromLocation(location: RouteLocation): string | undefined { + const draft = new URLSearchParams(location.search).get("draft"); + return draft || undefined; +} + +export const page = definePage({ + id: "chat", + path: "/chat", + loaderDeps: (_context: ApplicationContext, location: RouteLocation) => + `${sessionKeyFromLocation(location) ?? ""}\u0000${draftFromLocation(location) ?? ""}`, + loader: async (_context: ApplicationContext, { location }) => { + const sessionKey = sessionKeyFromLocation(location); + if (!sessionKey) { + return notFound({ routeId: "chat" }); + } + return { + sessionKey, + draft: draftFromLocation(location), + }; + }, + component: () => + import("./chat-page.ts").then(() => ({ + header: true, + render: (data: unknown) => html``, + })), +}); diff --git a/ui/src/ui/chat/run-lifecycle.test.ts b/ui/src/pages/chat/run-lifecycle.test.ts similarity index 98% rename from ui/src/ui/chat/run-lifecycle.test.ts rename to ui/src/pages/chat/run-lifecycle.test.ts index ae69b6e18041..3df8f533f815 100644 --- a/ui/src/ui/chat/run-lifecycle.test.ts +++ b/ui/src/pages/chat/run-lifecycle.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover run lifecycle behavior. import { describe, expect, it } from "vitest"; -import { isSessionRunActive } from "../session-run-state.ts"; -import type { SessionsListResult } from "../types.ts"; +import type { SessionsListResult } from "../../api/types.ts"; +import { isSessionRunActive } from "../../lib/session-run-state.ts"; import { reconcileChatRunFromCurrentSessionRow, reconcileChatRunFromSessionRow, diff --git a/ui/src/ui/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts similarity index 76% rename from ui/src/ui/chat/run-lifecycle.ts rename to ui/src/pages/chat/run-lifecycle.ts index 7491499a9324..73f4d2a2ae85 100644 --- a/ui/src/ui/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -1,8 +1,13 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow, SessionRunStatus, SessionsListResult } from "../../api/types.ts"; +import { isSessionRunActive } from "../../lib/session-run-state.ts"; +import { scopedAgentParamsForSession, type SessionScopeHost } from "../../lib/sessions/index.ts"; +import { uiSessionRowMatchesSelectedChat } from "../../lib/sessions/session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { formatConnectError } from "./connect-error.ts"; +import { resetChatInputHistoryNavigation, type ChatInputHistoryState } from "./input-history.ts"; // Control UI chat module implements run lifecycle behavior. -import { resetToolStream, type CompactionStatus, type FallbackStatus } from "../app-tool-stream.ts"; -import { uiSessionRowMatchesSelectedChat } from "../session-key.ts"; -import { isSessionRunActive } from "../session-run-state.ts"; -import type { GatewaySessionRow, SessionRunStatus, SessionsListResult } from "../types.ts"; +import { resetToolStream, type CompactionStatus, type FallbackStatus } from "./tool-stream.ts"; export const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000; @@ -64,11 +69,97 @@ type ReconcileOptions = { armLocalTerminalReconcile?: boolean; }; +type ChatAbortRunState = SessionScopeHost & { + client: GatewayBrowserClient | null; + connected: boolean; + sessionKey: string; + chatRunId?: string | null; + lastError?: string | null; + chatError?: string | null; +}; + +type ChatAbortHost = ChatAbortRunState & + ChatInputHistoryState & { + pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null; + sessionsResult?: SessionsListResult | null; + }; + +const CHAT_STOP_COMMANDS = new Set(["/stop", "stop", "esc", "abort", "wait", "exit"]); + function toSessionKey(value: string | null | undefined): string | null { const trimmed = typeof value === "string" ? value.trim() : ""; return trimmed ? trimmed : null; } +function setChatError(state: ChatAbortRunState, error: string | null) { + state.lastError = error; + state.chatError = error; +} + +export function isChatBusy(host: { chatSending?: boolean; chatRunId?: string | null }) { + return Boolean(host.chatSending || host.chatRunId); +} + +export function hasAbortableSessionRun(host: { + chatRunId?: string | null; + sessionKey: string; + sessionsResult?: SessionsListResult | null; +}): boolean { + if (host.chatRunId) { + return true; + } + return Boolean( + host.sessionsResult?.sessions.some( + (session) => session.key === host.sessionKey && isSessionRunActive(session), + ), + ); +} + +export function isChatStopCommand(text: string) { + return CHAT_STOP_COMMANDS.has(normalizeLowercaseStringOrEmpty(text.trim())); +} + +export type ChatAbortOptions = { preserveDraft?: boolean }; + +export async function abortChatRun(state: ChatAbortRunState): Promise { + if (!state.client || !state.connected) { + return false; + } + const runId = state.chatRunId; + try { + await state.client.request("chat.abort", { + sessionKey: state.sessionKey, + ...scopedAgentParamsForSession(state, state.sessionKey), + ...(runId ? { runId } : {}), + }); + return true; + } catch (err) { + setChatError(state, formatConnectError(err)); + return false; + } +} + +export async function handleAbortChat(host: ChatAbortHost, opts?: ChatAbortOptions) { + const activeRunId = host.chatRunId; + const queueAbort = !host.connected && hasAbortableSessionRun(host); + if (!host.connected && !queueAbort) { + return; + } + if (!opts?.preserveDraft) { + host.chatMessage = ""; + resetChatInputHistoryNavigation(host); + } + if (queueAbort) { + host.pendingAbort = { + runId: activeRunId, + sessionKey: host.sessionKey, + ...scopedAgentParamsForSession(host, host.sessionKey), + }; + return; + } + await abortChatRun(host); +} + function clearTimer(timer: TimerHandle | number | null | undefined) { if (timer != null) { globalThis.clearTimeout(timer as TimerHandle); diff --git a/ui/src/ui/app-scroll.test.ts b/ui/src/pages/chat/scroll.test.ts similarity index 99% rename from ui/src/ui/app-scroll.test.ts rename to ui/src/pages/chat/scroll.test.ts index a786feee566b..2e87960c8ec8 100644 --- a/ui/src/ui/app-scroll.test.ts +++ b/ui/src/pages/chat/scroll.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover app scroll behavior. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./app-scroll.ts"; -import type { ChatAutoScrollMode } from "./storage.ts"; +import type { ChatAutoScrollMode } from "../../app/settings.ts"; +import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./scroll.ts"; /* ------------------------------------------------------------------ */ /* Helpers */ @@ -57,8 +57,6 @@ function createScrollHost( chatIsProgrammaticScroll: false, chatProgrammaticScrollTarget: 0, settings, - logsScrollFrame: null as number | null, - logsAtBottom: true, topbarObserver: null as ResizeObserver | null, }; diff --git a/ui/src/ui/app-scroll.ts b/ui/src/pages/chat/scroll.ts similarity index 66% rename from ui/src/ui/app-scroll.ts rename to ui/src/pages/chat/scroll.ts index 5caa51a11dba..9ca9f872430e 100644 --- a/ui/src/ui/app-scroll.ts +++ b/ui/src/pages/chat/scroll.ts @@ -1,5 +1,5 @@ // Control UI module implements app scroll behavior. -import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "./storage.ts"; +import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "../../app/settings.ts"; /** Distance (px) from the bottom within which we consider the user "near bottom". */ const NEAR_BOTTOM_THRESHOLD = 450; @@ -7,10 +7,9 @@ const FOLLOW_REACQUIRE_THRESHOLD = 8; const HEADER_HIDE_SCROLL_DELTA = 12; const HEADER_SHOW_TOP_THRESHOLD = 24; -type ScrollHost = { +type ChatScrollHost = { updateComplete: Promise; querySelector: (selectors: string) => Element | null; - style: CSSStyleDeclaration; chatScrollFrame: number | null; chatScrollTimeout: number | null; chatLastScrollTop: number; @@ -24,15 +23,9 @@ type ScrollHost = { settings?: { chatAutoScroll?: ChatAutoScrollMode; }; - logsScrollFrame: number | null; - logsAtBottom: boolean; - activityScrollFrame?: number | null; - activityAutoFollow?: boolean; - activityAtBottom?: boolean; - topbarObserver: ResizeObserver | null; }; -function queryHost(host: Partial, selectors: string): Element | null { +function queryHost(host: Partial, selectors: string): Element | null { return typeof host.querySelector === "function" ? host.querySelector(selectors) : null; } @@ -41,7 +34,7 @@ type ChatScrollOptions = { }; export function scheduleChatScroll( - host: ScrollHost, + host: ChatScrollHost, force = false, smooth = false, options: ChatScrollOptions = {}, @@ -149,55 +142,7 @@ export function scheduleChatScroll( }); } -export function scheduleLogsScroll(host: ScrollHost, force = false) { - if (host.logsScrollFrame) { - cancelAnimationFrame(host.logsScrollFrame); - } - void host.updateComplete.then(() => { - host.logsScrollFrame = requestAnimationFrame(() => { - host.logsScrollFrame = null; - const container = queryHost(host, ".log-stream") as HTMLElement | null; - if (!container) { - return; - } - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - const shouldStick = force || distanceFromBottom < 80; - if (!shouldStick) { - return; - } - container.scrollTop = container.scrollHeight; - }); - }); -} - -export function scheduleActivityScroll(host: ScrollHost, force = false) { - if (host.activityScrollFrame) { - cancelAnimationFrame(host.activityScrollFrame); - } - void host.updateComplete.then(() => { - host.activityScrollFrame = requestAnimationFrame(() => { - host.activityScrollFrame = null; - const container = queryHost(host, ".activity-stream") as HTMLElement | null; - if (!container) { - return; - } - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - const shouldStick = - force || - (host.activityAutoFollow !== false && - (host.activityAtBottom !== false || distanceFromBottom < 120)); - if (!shouldStick) { - return; - } - container.scrollTop = container.scrollHeight; - host.activityAtBottom = true; - }); - }); -} - -export function handleChatScroll(host: ScrollHost, event: Event) { +export function handleChatScroll(host: ChatScrollHost, event: Event) { const container = event.currentTarget as HTMLElement | null; if (!container) { return; @@ -242,25 +187,7 @@ export function handleChatScroll(host: ScrollHost, event: Event) { } } -export function handleLogsScroll(host: ScrollHost, event: Event) { - const container = event.currentTarget as HTMLElement | null; - if (!container) { - return; - } - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - host.logsAtBottom = distanceFromBottom < 80; -} - -export function handleActivityScroll(host: ScrollHost, event: Event) { - const container = event.currentTarget as HTMLElement | null; - if (!container) { - return; - } - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - host.activityAtBottom = distanceFromBottom < 120; -} - -export function resetChatScroll(host: ScrollHost) { +export function resetChatScroll(host: ChatScrollHost) { host.chatHasAutoScrolled = false; host.chatUserNearBottom = true; host.chatFollowLocked = false; @@ -270,34 +197,3 @@ export function resetChatScroll(host: ScrollHost) { host.chatIsProgrammaticScroll = false; host.chatProgrammaticScrollTarget = 0; } - -export function exportLogs(lines: string[], label: string) { - if (lines.length === 0) { - return; - } - const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); - anchor.href = url; - anchor.download = `openclaw-logs-${label}-${stamp}.log`; - anchor.click(); - URL.revokeObjectURL(url); -} - -export function observeTopbar(host: ScrollHost) { - if (typeof ResizeObserver === "undefined") { - return; - } - const topbar = queryHost(host, ".topbar"); - if (!topbar) { - return; - } - const update = () => { - const { height } = topbar.getBoundingClientRect(); - host.style.setProperty("--topbar-height", `${height}px`); - }; - update(); - host.topbarObserver = new ResizeObserver(() => update()); - host.topbarObserver.observe(topbar); -} diff --git a/ui/src/ui/chat/session-cache.ts b/ui/src/pages/chat/session-cache.ts similarity index 100% rename from ui/src/ui/chat/session-cache.ts rename to ui/src/pages/chat/session-cache.ts diff --git a/ui/src/ui/chat/session-message-cache.test.ts b/ui/src/pages/chat/session-message-cache.test.ts similarity index 100% rename from ui/src/ui/chat/session-message-cache.test.ts rename to ui/src/pages/chat/session-message-cache.test.ts diff --git a/ui/src/ui/chat/session-message-cache.ts b/ui/src/pages/chat/session-message-cache.ts similarity index 96% rename from ui/src/ui/chat/session-message-cache.ts rename to ui/src/pages/chat/session-message-cache.ts index f3cf2d953d10..5b703f566094 100644 --- a/ui/src/ui/chat/session-message-cache.ts +++ b/ui/src/pages/chat/session-message-cache.ts @@ -8,8 +8,8 @@ import { resolveUiDefaultAgentId, resolveUiSelectedGlobalAgentId, type UiSessionDefaultsHost, -} from "../session-key.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +} from "../../lib/sessions/session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; import { getSessionCacheValue, setSessionCacheValue } from "./session-cache.ts"; const MAX_CACHED_CHAT_MESSAGES = 100; diff --git a/ui/src/ui/chat/stream-reconciliation.test.ts b/ui/src/pages/chat/stream-reconciliation.test.ts similarity index 87% rename from ui/src/ui/chat/stream-reconciliation.test.ts rename to ui/src/pages/chat/stream-reconciliation.test.ts index fceac2198610..31c6e2a3c0c9 100644 --- a/ui/src/ui/chat/stream-reconciliation.test.ts +++ b/ui/src/pages/chat/stream-reconciliation.test.ts @@ -111,6 +111,58 @@ describe("stream reconciliation", () => { expect(state.toolStreamOrder).toEqual([]); }); + it("prunes persisted tool messages across current tool id shapes", () => { + const messages = [ + { + role: "toolResult", + toolCallId: "call_1", + toolName: "shell", + }, + { + role: "tool", + tool_call_id: "call_2", + tool_name: "shell", + }, + { + role: "assistant", + content: [{ type: "toolcall", id: "call_3", name: "shell", arguments: {} }], + }, + { + role: "assistant", + content: [{ type: "tool_result", tool_use_id: "call_4", name: "shell", content: "ok" }], + }, + { role: "assistant", content: "hello" }, + { role: "user", content: "hello" }, + ]; + const state = { + chatStream: null, + chatStreamStartedAt: null, + chatToolMessages: messages, + toolStreamById: new Map([ + ["call_1", {}], + ["call_2", {}], + ["call_3", {}], + ["call_4", {}], + ]), + toolStreamOrder: ["call_1", "call_2", "call_3", "call_4"], + chatStreamSegments: [], + } satisfies StreamReconciliationState & { + chatToolMessages: unknown[]; + toolStreamById: Map; + toolStreamOrder: string[]; + chatStreamSegments: Array; + }; + + prunePersistedToolStreamMessages(state, new Set(["call_1", "call_2", "call_3", "call_4"])); + + expect(state.chatToolMessages).toEqual([ + { role: "assistant", content: "hello" }, + { role: "user", content: "hello" }, + ]); + expect(state.toolStreamById.size).toBe(0); + expect(state.toolStreamOrder).toEqual([]); + }); + it("keeps materialized keyed preambles before terminal messages that share their prefix", () => { const state = { chatStream: null, diff --git a/ui/src/ui/chat/stream-reconciliation.ts b/ui/src/pages/chat/stream-reconciliation.ts similarity index 88% rename from ui/src/ui/chat/stream-reconciliation.ts rename to ui/src/pages/chat/stream-reconciliation.ts index 91b0001bdf95..3ec187e3ee08 100644 --- a/ui/src/ui/chat/stream-reconciliation.ts +++ b/ui/src/pages/chat/stream-reconciliation.ts @@ -1,13 +1,21 @@ // Control UI chat module implements stream reconciliation behavior. -import { resetToolStream } from "../app-tool-stream.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import { extractText } from "./message-extract.ts"; +import { + isToolCallContentType, + isToolResultContentType, + resolveToolUseId, +} from "../../../../src/chat/tool-content.js"; import { streamSegmentHasItemId, streamSegmentUsesAccumulatedText, trimAccumulatedStreamPrefix, -} from "./stream-text.ts"; -import { extractToolMessageRefs } from "./tool-message-refs.ts"; +} from "../../lib/chat/chat-types.ts"; +import { extractText } from "../../lib/chat/message-extract.ts"; +import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../lib/string-coerce.ts"; +import { resetToolStream } from "./tool-stream.ts"; export type StreamReconciliationState = { chatStream: string | null; @@ -35,6 +43,73 @@ type VisibleAssistantStreamPart = { toolCallId?: string; }; +type ToolMessageRef = { + id: string; +}; + +const TOOL_NAME_FIELDS = ["toolName", "tool_name"] as const; + +function asToolRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function addToolRef(refs: ToolMessageRef[], seen: Set, id: string | undefined) { + if (!id || seen.has(id)) { + return; + } + seen.add(id); + refs.push({ id }); +} + +function isToolLikeRole(role: unknown): boolean { + return typeof role === "string" && normalizeRoleForGrouping(role).toLowerCase() === "tool"; +} + +function hasToolName(message: Record): boolean { + return TOOL_NAME_FIELDS.some((field) => Boolean(normalizeOptionalString(message[field]))); +} + +function toolContentBlocks(message: Record): Record[] { + return Array.isArray(message.content) + ? message.content.filter( + (block): block is Record => Boolean(block) && typeof block === "object", + ) + : []; +} + +function isToolContentBlock(block: Record): boolean { + return isToolCallContentType(block.type) || isToolResultContentType(block.type); +} + +function extractToolMessageRefs(message: unknown): ToolMessageRef[] { + const record = asToolRecord(message); + if (!record) { + return []; + } + + const refs: ToolMessageRef[] = []; + const seen = new Set(); + const blocks = toolContentBlocks(record); + const topLevelToolId = resolveToolUseId(record); + const messageHasToolShape = + isToolLikeRole(record.role) || hasToolName(record) || blocks.some(isToolContentBlock); + + if (messageHasToolShape) { + addToolRef(refs, seen, topLevelToolId); + } + + for (const block of blocks) { + if (!isToolContentBlock(block)) { + continue; + } + addToolRef(refs, seen, resolveToolUseId(block) ?? topLevelToolId); + } + + return refs; +} + export type AssistantMessageVisibility = (message: unknown) => boolean; export type StreamVisibility = (stream: string) => boolean; diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/pages/chat/tool-stream.node.test.ts similarity index 81% rename from ui/src/ui/app-tool-stream.node.test.ts rename to ui/src/pages/chat/tool-stream.node.test.ts index 991ad9b110e1..aff0ae8af019 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/pages/chat/tool-stream.node.test.ts @@ -1,16 +1,19 @@ // @vitest-environment node import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { ACTIVITY_ENTRY_LIMIT, ACTIVITY_OUTPUT_PREVIEW_LIMIT } from "./activity-model.ts"; import { handleAgentEvent, handleSessionOperationEvent, type FallbackStatus, type ToolStreamEntry, -} from "./app-tool-stream.ts"; +} from "./tool-stream.ts"; type ToolStreamHost = Parameters[0]; type AgentEvent = NonNullable[1]>; type MutableHost = ToolStreamHost & { + sessions: { + state: { modelOverrides: Record }; + setModelOverride: (key: string, value: string | null | undefined) => void; + }; compactionStatus?: unknown; compactionClearTimer?: number | null; fallbackStatus?: FallbackStatus | null; @@ -19,6 +22,7 @@ type MutableHost = ToolStreamHost & { const TOOL_STREAM_TEST_NOW = new Date("2026-05-09T00:00:00.000Z").getTime(); function createHost(overrides?: Partial): MutableHost { + const modelOverrides: Record = {}; return { sessionKey: "main", chatRunId: null, @@ -28,9 +32,17 @@ function createHost(overrides?: Partial): MutableHost { toolStreamById: new Map(), toolStreamOrder: [], chatToolMessages: [], - activityEntries: [], toolStreamSyncTimer: null, - chatModelOverrides: {}, + sessions: { + state: { modelOverrides }, + setModelOverride: (key, value) => { + if (value === undefined) { + delete modelOverrides[key]; + } else { + modelOverrides[key] = value; + } + }, + }, compactionStatus: null, compactionClearTimer: null, fallbackStatus: null, @@ -247,18 +259,12 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.chatModelOverrides?.main).toEqual({ - kind: "qualified", - value: "anthropic/claude-sonnet-4-6", - }); + expect(host.sessions.state.modelOverrides.main).toBe("anthropic/claude-sonnet-4-6"); }); it("clears the chat model cache from session_status default resets", () => { - const host = createHost({ - chatModelOverrides: { - main: { kind: "qualified", value: "anthropic/claude-sonnet-4-6" }, - }, - }); + const host = createHost(); + host.sessions.setModelOverride("main", "anthropic/claude-sonnet-4-6"); handleAgentEvent(host, { runId: "run-1", @@ -283,7 +289,7 @@ describe("app-tool-stream fallback lifecycle handling", () => { }, }); - expect(host.chatModelOverrides?.main).toBeNull(); + expect(host.sessions.state.modelOverrides.main).toBeNull(); }); it("tags stream segments with the tool they precede", () => { @@ -432,45 +438,6 @@ describe("app-tool-stream fallback lifecycle handling", () => { vi.useRealTimers(); }); - it("records tool activity summaries without storing raw argument values", () => { - useToolStreamFakeTimers(); - const host = createHost(); - - handleAgentEvent(host, { - runId: "run-activity-1", - seq: 1, - stream: "tool", - ts: Date.now(), - sessionKey: "main", - data: { - phase: "start", - name: "exec", - toolCallId: "activity-tool-1", - args: { - command: "cat /Users/buns/private-token.txt", - token: "sk-test-secret", - }, - }, - }); - - expect(host.activityEntries).toHaveLength(1); - const entry = host.activityEntries?.[0]; - expect(entry).toMatchObject({ - id: "run-activity-1:activity-tool-1", - toolCallId: "activity-tool-1", - runId: "run-activity-1", - sessionKey: "main", - toolName: "exec", - status: "running", - hiddenArgumentCount: 2, - summary: "exec running; 2 arguments hidden", - }); - const stored = JSON.stringify(entry); - expect(stored).not.toContain("cat /Users/buns/private-token.txt"); - expect(stored).not.toContain("sk-test-secret"); - vi.useRealTimers(); - }); - it("ignores selected-global tool events from another agent", () => { const host = createHost({ sessionKey: "global", @@ -493,7 +460,6 @@ describe("app-tool-stream fallback lifecycle handling", () => { }); expect(host.toolStreamOrder).toHaveLength(0); - expect(host.activityEntries).toHaveLength(0); }); it("ignores selected-global lifecycle and fallback events from another agent", () => { @@ -547,106 +513,6 @@ describe("app-tool-stream fallback lifecycle handling", () => { expect(host.fallbackStatus).toBeNull(); }); - it("stores only redacted truncated output previews in activity entries", () => { - useToolStreamFakeTimers(); - const host = createHost(); - const secretOutput = [ - "Authorization: Bearer abcdefghijklmnopqrstuvwxyz", - "file=/Users/buns/private/activity.log", - "token=super-secret-token", - "x".repeat(ACTIVITY_OUTPUT_PREVIEW_LIMIT + 200), - ].join("\n"); - - handleAgentEvent(host, { - runId: "run-activity-2", - seq: 1, - stream: "tool", - ts: Date.now(), - sessionKey: "main", - data: { - phase: "result", - name: "read_file", - toolCallId: "activity-tool-2", - result: { text: secretOutput }, - }, - }); - - const entry = host.activityEntries?.[0]; - expect(entry?.status).toBe("done"); - expect(entry?.outputPreview?.length).toBeLessThanOrEqual(ACTIVITY_OUTPUT_PREVIEW_LIMIT); - expect(entry?.outputPreview).toContain("Authorization: [redacted]"); - expect(entry?.outputPreview).toContain("[redacted path]"); - expect(entry?.outputPreview).not.toContain("abcdefghijklmnopqrstuvwxyz"); - expect(entry?.outputPreview).not.toContain("/Users/buns/private/activity.log"); - expect(entry?.outputPreview).not.toContain("super-secret-token"); - expect(entry?.outputTruncated).toBe(true); - vi.useRealTimers(); - }); - - it("marks result payloads with explicit error flags as failed activity", () => { - const host = createHost(); - - handleAgentEvent(host, { - runId: "run-activity-3", - seq: 1, - stream: "tool", - ts: Date.now(), - sessionKey: "main", - data: { - phase: "result", - name: "exec", - toolCallId: "activity-tool-3", - result: { isError: true }, - }, - }); - - expect(host.activityEntries?.[0]?.status).toBe("error"); - }); - - it("marks snake_case explicit error flags as failed activity", () => { - const host = createHost(); - - handleAgentEvent(host, { - runId: "run-activity-4", - seq: 1, - stream: "tool", - ts: Date.now(), - sessionKey: "main", - data: { - phase: "result", - name: "exec", - toolCallId: "activity-tool-4", - result: { is_error: true }, - }, - }); - - expect(host.activityEntries?.[0]?.status).toBe("error"); - }); - - it("keeps activity entries in a bounded memory ring", () => { - const host = createHost(); - - for (let index = 0; index < ACTIVITY_ENTRY_LIMIT + 5; index += 1) { - handleAgentEvent(host, { - runId: `run-${index}`, - seq: index, - stream: "tool", - ts: index, - sessionKey: "main", - data: { - phase: "start", - name: "tool", - toolCallId: `tool-${index}`, - args: { value: index }, - }, - }); - } - - expect(host.activityEntries).toHaveLength(ACTIVITY_ENTRY_LIMIT); - expect(host.activityEntries?.[0]?.toolCallId).toBe("tool-5"); - expect(host.activityEntries?.at(-1)?.toolCallId).toBe(`tool-${ACTIVITY_ENTRY_LIMIT + 4}`); - }); - it("keeps compaction in retry-pending state until the matching lifecycle end", () => { useToolStreamFakeTimers(); const host = createHost(); diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/pages/chat/tool-stream.ts similarity index 78% rename from ui/src/ui/app-tool-stream.ts rename to ui/src/pages/chat/tool-stream.ts index 945cd5ff5ac4..b62ffb0232b5 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/pages/chat/tool-stream.ts @@ -1,18 +1,10 @@ // Control UI module implements app tool stream behavior. -import { stripInlineDirectiveTagsForDelivery } from "../../../src/utils/directive-tags.js"; -import { updateActivityFromToolEvent, type ActivityEntry } from "./activity-model.ts"; -import { createChatModelOverride } from "./chat-model-ref.ts"; -import type { ChatModelOverride } from "./chat-model-ref.types.ts"; -import type { ChatStreamSegment } from "./chat/stream-text.ts"; -import { formatUnknownText, truncateText } from "./format.ts"; -import { - buildAgentMainSessionKey, - DEFAULT_AGENT_ID, - DEFAULT_MAIN_KEY, - normalizeAgentId, - parseAgentSessionKey, -} from "./session-key.ts"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; +import { stripInlineDirectiveTagsForDelivery } from "../../../../src/utils/directive-tags.js"; +import type { ChatStreamSegment } from "../../lib/chat/chat-types.ts"; +import { formatUnknownText, truncateText } from "../../lib/format.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; +import { uiSessionEventMatches } from "../../lib/sessions/session-key.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; const TOOL_STREAM_LIMIT = 50; const TOOL_STREAM_THROTTLE_MS = 80; @@ -67,9 +59,8 @@ type ToolStreamHost = { toolStreamById: Map; toolStreamOrder: string[]; chatToolMessages: Record[]; - activityEntries?: ActivityEntry[]; toolStreamSyncTimer: number | null; - chatModelOverrides?: Record; + sessions: Pick; }; type SessionDefaultsSnapshot = { @@ -219,152 +210,35 @@ function readRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } -function resolveSessionStatusModelOverride(result: unknown): ChatModelOverride | null | undefined { +function resolveSessionStatusModelOverride(result: unknown): string | null | undefined { const details = readRecord(readRecord(result)?.details); if (!details || details.changedModel !== true) { return undefined; } if (Object.hasOwn(details, "modelOverride")) { const override = toTrimmedString(details.modelOverride); - return override ? createChatModelOverride(override) : null; + return override; } const model = toTrimmedString(details.model); if (!model) { return undefined; } const provider = toTrimmedString(details.modelProvider); - return createChatModelOverride(provider ? `${provider}/${model}` : model); + return provider ? `${provider}/${model}` : model; } function syncSessionStatusModelOverride(host: ToolStreamHost, data: Record) { - if (!host.chatModelOverrides) { - return; - } const result = data.result; const details = readRecord(readRecord(result)?.details); const targetSessionKey = toTrimmedString(details?.sessionKey) ?? host.sessionKey; - if ( - !sessionKeyMatchesHost(host, targetSessionKey, toTrimmedString(details?.agentId) ?? undefined) - ) { + if (!uiSessionEventMatches(host, targetSessionKey, toTrimmedString(details?.agentId))) { return; } const override = resolveSessionStatusModelOverride(result); if (override === undefined) { return; } - host.chatModelOverrides = { - ...host.chatModelOverrides, - [targetSessionKey]: override, - }; -} - -function readSessionDefaults(host: ToolStreamHost): SessionDefaultsSnapshot | undefined { - return host.hello?.snapshot?.sessionDefaults; -} - -function isGlobalSessionKey(sessionKey: string | undefined | null): boolean { - return normalizeLowercaseStringOrEmpty(sessionKey) === "global"; -} - -function resolveDefaultAgentId(host: ToolStreamHost): string { - const defaults = readSessionDefaults(host); - return normalizeAgentId( - toTrimmedString(host.agentsList?.defaultId) ?? - toTrimmedString(defaults?.defaultAgentId) ?? - DEFAULT_AGENT_ID, - ); -} - -function resolveSelectedAgentId(host: ToolStreamHost): string { - return normalizeAgentId(toTrimmedString(host.assistantAgentId) ?? resolveDefaultAgentId(host)); -} - -function agentEventScopeMatches(host: ToolStreamHost, payload: AgentEventPayload): boolean { - if (!isGlobalSessionKey(host.sessionKey) || !isGlobalSessionKey(payload.sessionKey)) { - return true; - } - const payloadAgentId = toTrimmedString(payload.agentId); - const selectedAgentId = resolveSelectedAgentId(host); - return payloadAgentId - ? normalizeAgentId(payloadAgentId) === selectedAgentId - : selectedAgentId === resolveDefaultAgentId(host); -} - -function resolveAgentMainAliasAgentId(host: ToolStreamHost, value?: string): string | null { - const parsed = parseAgentSessionKey(value); - if (!parsed) { - return null; - } - const defaults = readSessionDefaults(host); - const mainKey = toTrimmedString(defaults?.mainKey) ?? DEFAULT_MAIN_KEY; - const rest = normalizeLowercaseStringOrEmpty(parsed.rest); - return rest === DEFAULT_MAIN_KEY || rest === normalizeLowercaseStringOrEmpty(mainKey) - ? normalizeAgentId(parsed.agentId) - : null; -} - -function selectedGlobalAliasEventMatches( - host: ToolStreamHost, - sessionKey: string, - agentId?: string, -): boolean { - const hostAgentId = resolveAgentMainAliasAgentId(host, host.sessionKey); - if (!hostAgentId || !isGlobalSessionKey(sessionKey)) { - return false; - } - const eventAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(host)); - return hostAgentId === eventAgentId; -} - -function sessionKeyMatchesHost( - host: ToolStreamHost, - sessionKey: string, - agentId?: string, -): boolean { - return ( - normalizeSessionKeyForEventComparison(host, sessionKey) === - normalizeSessionKeyForEventComparison(host, host.sessionKey) || - selectedGlobalAliasEventMatches(host, sessionKey, agentId) - ); -} - -function resolveDefaultMainSessionKey(host: ToolStreamHost): string { - const defaults = readSessionDefaults(host); - const configuredMain = toTrimmedString(defaults?.mainSessionKey); - if (configuredMain) { - return configuredMain; - } - return buildAgentMainSessionKey({ - agentId: toTrimmedString(defaults?.defaultAgentId) ?? DEFAULT_AGENT_ID, - mainKey: toTrimmedString(defaults?.mainKey) ?? DEFAULT_MAIN_KEY, - }); -} - -function normalizeSessionKeyForEventComparison( - host: ToolStreamHost, - value?: string, -): string | null { - const raw = toTrimmedString(value); - if (!raw) { - return null; - } - const defaults = readSessionDefaults(host); - const mainKey = toTrimmedString(defaults?.mainKey) ?? DEFAULT_MAIN_KEY; - const defaultAgentId = toTrimmedString(defaults?.defaultAgentId) ?? DEFAULT_AGENT_ID; - const canonicalMain = resolveDefaultMainSessionKey(host); - const aliases = new Set( - [ - DEFAULT_MAIN_KEY, - mainKey, - canonicalMain, - buildAgentMainSessionKey({ agentId: defaultAgentId, mainKey: DEFAULT_MAIN_KEY }), - buildAgentMainSessionKey({ agentId: defaultAgentId, mainKey }), - ].map((entry) => normalizeLowercaseStringOrEmpty(entry)), - ); - const normalizedRaw = normalizeLowercaseStringOrEmpty(raw); - return aliases.has(normalizedRaw) - ? normalizeLowercaseStringOrEmpty(canonicalMain) - : normalizedRaw; + host.sessions.setModelOverride(targetSessionKey, override); } function buildToolStreamMessage(entry: ToolStreamEntry): Record { @@ -514,19 +388,7 @@ export function handleSessionOperationEvent( } const sessionKey = toTrimmedString(payload.sessionKey); const agentId = toTrimmedString(payload.agentId) ?? undefined; - if ( - !sessionKey || - !sessionKeyMatchesHost(host, sessionKey, agentId) || - !agentEventScopeMatches(host, { - runId: toTrimmedString(payload.operationId) ?? "", - seq: 0, - stream: "session.operation", - ts: typeof payload.ts === "number" ? payload.ts : Date.now(), - sessionKey, - ...(agentId ? { agentId } : {}), - data: {}, - }) - ) { + if (!sessionKey || !uiSessionEventMatches(host, sessionKey, agentId)) { return; } @@ -640,10 +502,7 @@ function resolveAcceptedSession( }, ): { accepted: boolean; sessionKey?: string } { const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; - if ( - sessionKey && - !sessionKeyMatchesHost(host, sessionKey, toTrimmedString(payload.agentId) ?? undefined) - ) { + if (sessionKey && !uiSessionEventMatches(host, sessionKey, toTrimmedString(payload.agentId))) { return { accepted: false }; } if (!host.chatRunId && options?.allowSessionScopedWhenIdle && sessionKey) { @@ -808,13 +667,7 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo // to a client-generated UUID (via generateUUID in sendChatMessage), while // agent events arrive with the server's engine runId. const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; - if ( - sessionKey && - !sessionKeyMatchesHost(host, sessionKey, toTrimmedString(payload.agentId) ?? undefined) - ) { - return; - } - if (!agentEventScopeMatches(host, payload)) { + if (sessionKey && !uiSessionEventMatches(host, sessionKey, toTrimmedString(payload.agentId))) { return; } @@ -848,7 +701,6 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo if (!toolCallId) { return; } - updateActivityFromToolEvent(host, { ...payload, data }); const name = typeof data.name === "string" ? data.name : "tool"; const phase = typeof data.phase === "string" ? data.phase : ""; const args = phase === "start" ? data.args : undefined; diff --git a/ui/src/ui/chat/user-message-content.ts b/ui/src/pages/chat/user-message-content.ts similarity index 96% rename from ui/src/ui/chat/user-message-content.ts rename to ui/src/pages/chat/user-message-content.ts index eea729f54b4e..7b0ba108803d 100644 --- a/ui/src/ui/chat/user-message-content.ts +++ b/ui/src/pages/chat/user-message-content.ts @@ -1,5 +1,5 @@ // Control UI chat module implements user message content behavior. -import type { ChatAttachment } from "../ui-types.ts"; +import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; import { getChatAttachmentPreviewUrl } from "./attachment-payload-store.ts"; export type UserChatMessageContentBlock = { diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts new file mode 100644 index 000000000000..8f64737ac452 --- /dev/null +++ b/ui/src/pages/config/config-page.ts @@ -0,0 +1,727 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { FastMode } from "../../api/types.ts"; +import type { RouteId } from "../../app-route-paths.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { importCustomThemeFromUrl } from "../../app/custom-theme.ts"; +import { + loadSettings, + normalizeTextScale, + patchSettings, + type UiSettings, +} from "../../app/settings.ts"; +import { startThemeTransition } from "../../app/theme-transition.ts"; +import { resolveTheme, type ThemeMode, type ThemeName } from "../../app/theme.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { t } from "../../i18n/index.ts"; +import { renderMcp } from "./mcp.ts"; +import { getPresetById } from "./presets.ts"; +import { + renderQuickSettings, + type QuickSettingsChannel, + type QuickSettingsSecurity, +} from "./quick.ts"; +import { + createConfigViewState, + renderConfig, + type ConfigProps, + type ConfigViewState, +} from "./view.ts"; + +export type ConfigPageId = + | "config" + | "communications" + | "appearance" + | "automation" + | "mcp" + | "infrastructure" + | "ai-agents"; + +type ConfigFormMode = "form" | "raw"; +type ConfigSelection = { activeSection: string | null; activeSubsection: string | null }; + +const CONFIG_PAGE_I18N_KEYS = { + config: "config", + communications: "communications", + appearance: "appearance", + automation: "automation", + mcp: "mcp", + infrastructure: "infrastructure", + "ai-agents": "aiAgents", +} as const satisfies Record; + +const COMMUNICATION_SECTION_KEYS = [ + "messages", + "broadcast", + "__notifications__", + "talk", + "audio", + "channels", +] as const; +const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const; +const AUTOMATION_SECTION_KEYS = ["commands", "hooks", "bindings", "cron", "approvals", "plugins"]; +const INFRASTRUCTURE_SECTION_KEYS = [ + "gateway", + "web", + "browser", + "nodeHost", + "canvasHost", + "discovery", + "media", + "acp", + "mcp", +] as const; +const AI_AGENTS_SECTION_KEYS = [ + "agents", + "models", + "skills", + "tools", + "memory", + "session", +] as const; +const SCOPED_CONFIG_SECTION_KEYS = new Set([ + ...COMMUNICATION_SECTION_KEYS, + ...APPEARANCE_SECTION_KEYS, + ...AUTOMATION_SECTION_KEYS, + ...INFRASTRUCTURE_SECTION_KEYS, + ...AI_AGENTS_SECTION_KEYS, +]); +const KNOWN_CHANNELS = [ + { id: "telegram", label: "Telegram" }, + { id: "discord", label: "Discord" }, + { id: "slack", label: "Slack" }, + { id: "whatsapp", label: "WhatsApp" }, + { id: "signal", label: "Signal" }, + { id: "imessage", label: "iMessage" }, +] as const; + +const BASE_RADII = { sm: 6, md: 10, lg: 14, xl: 20, full: 9999, default: 10 }; + +function defaultConfigSelection(pageId: ConfigPageId): ConfigSelection { + switch (pageId) { + case "communications": + return { activeSection: "messages", activeSubsection: null }; + case "appearance": + return { activeSection: "__appearance__", activeSubsection: null }; + case "automation": + return { activeSection: "commands", activeSubsection: null }; + case "mcp": + return { activeSection: "mcp", activeSubsection: null }; + case "infrastructure": + return { activeSection: "gateway", activeSubsection: null }; + case "ai-agents": + return { activeSection: "agents", activeSubsection: null }; + case "config": + return { activeSection: null, activeSubsection: null }; + } + throw new Error("Unknown config page"); +} + +function asConfigRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeConfigSelection( + pageId: ConfigPageId, + activeSection: string | null, + activeSubsection: string | null, +): ConfigSelection { + const sections: readonly string[] | null = + pageId === "communications" + ? COMMUNICATION_SECTION_KEYS + : pageId === "appearance" + ? APPEARANCE_SECTION_KEYS + : pageId === "automation" + ? AUTOMATION_SECTION_KEYS + : pageId === "mcp" || pageId === "infrastructure" + ? INFRASTRUCTURE_SECTION_KEYS + : pageId === "ai-agents" + ? AI_AGENTS_SECTION_KEYS + : null; + if (pageId === "config" && activeSection && SCOPED_CONFIG_SECTION_KEYS.has(activeSection)) { + return { activeSection: null, activeSubsection: null }; + } + if (sections && (!activeSection || !sections.includes(activeSection))) { + return defaultConfigSelection(pageId); + } + return { activeSection, activeSubsection }; +} + +function configPageTitle(pageId: ConfigPageId): string { + return pageId === "config" ? t("nav.settings") : t(`tabs.${CONFIG_PAGE_I18N_KEYS[pageId]}`); +} + +function configPageSubtitle(pageId: ConfigPageId): string { + return t(`subtitles.${CONFIG_PAGE_I18N_KEYS[pageId]}`); +} + +function mcpServerCount(config: unknown): number { + const servers = asConfigRecord(asConfigRecord(config)?.mcp)?.servers; + return servers && typeof servers === "object" && !Array.isArray(servers) + ? Object.keys(servers).length + : 0; +} + +function quickChannels(config: unknown): QuickSettingsChannel[] { + const configured = asConfigRecord(asConfigRecord(config)?.channels) ?? {}; + const configuredIds = Object.keys(configured).filter((id) => id.trim().length > 0); + const channelIds = + configuredIds.length > 0 + ? configuredIds.toSorted((left, right) => left.localeCompare(right)) + : KNOWN_CHANNELS.map(({ id }) => id); + const labels = new Map(KNOWN_CHANNELS.map(({ id, label }) => [id, label])); + return channelIds.map((id) => { + const value = configured[id]; + const connected = Boolean(value && typeof value === "object" && Object.keys(value).length); + return { + id, + label: + labels.get(id) ?? + id.replace(/[-_]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()), + connected, + detail: connected ? "Configured" : undefined, + }; + }); +} + +export function extractQuickSettingsSecurity(config: unknown): QuickSettingsSecurity { + const root = + asConfigRecord((config as { configForm?: unknown } | null)?.configForm) ?? + asConfigRecord(config); + if (!root) { + return { + gatewayAuth: "unknown", + execPolicy: "unknown", + deviceAuth: false, + browserEnabled: true, + toolProfile: "full", + }; + } + const gateway = asConfigRecord(root.gateway); + const auth = asConfigRecord(gateway?.auth); + const tools = asConfigRecord(root.tools) ?? {}; + const exec = asConfigRecord(tools.exec) ?? {}; + const browser = asConfigRecord(root.browser); + const controlUi = asConfigRecord(gateway?.controlUi); + let gatewayAuth = "unknown"; + if (auth) { + const mode = typeof auth.mode === "string" ? auth.mode.trim() : ""; + gatewayAuth = mode + ? mode + : auth.password + ? "password" + : auth.token + ? "token" + : auth.trustedProxy + ? "trusted-proxy" + : "none"; + } + const profile = tools.profile; + const security = exec.security; + return { + gatewayAuth, + execPolicy: typeof security === "string" && security.trim() ? security.trim() : "allowlist", + deviceAuth: controlUi?.dangerouslyDisableDeviceAuth !== true, + browserEnabled: browser?.enabled !== false, + toolProfile: typeof profile === "string" && profile.trim() ? profile.trim() : "full", + }; +} + +function applyBorderRadius(value: number) { + if (typeof document === "undefined") { + return; + } + const root = document.documentElement; + const scale = value / 50; + root.style.setProperty("--radius-sm", `${Math.round(BASE_RADII.sm * scale)}px`); + root.style.setProperty("--radius-md", `${Math.round(BASE_RADII.md * scale)}px`); + root.style.setProperty("--radius-lg", `${Math.round(BASE_RADII.lg * scale)}px`); + root.style.setProperty("--radius-xl", `${Math.round(BASE_RADII.xl * scale)}px`); + root.style.setProperty("--radius-full", `${Math.round(BASE_RADII.full * scale)}px`); + root.style.setProperty("--radius", `${Math.round(BASE_RADII.default * scale)}px`); +} + +function applyTextScale(value: unknown) { + if (typeof document === "undefined") { + return; + } + document.documentElement.style.setProperty( + "--control-ui-text-scale", + (normalizeTextScale(value) / 100).toFixed(2), + ); +} + +export class ConfigPage extends LitElement { + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @property({ attribute: "page-id" }) pageId: ConfigPageId = "config"; + + @state() private settings = loadSettings(); + @state() private settingsMode: "quick" | "advanced" = "quick"; + @state() private formModes: Record = { + config: "form", + communications: "form", + appearance: "form", + automation: "form", + mcp: "form", + infrastructure: "form", + "ai-agents": "form", + }; + @state() private searchQueries: Record = { + config: "", + communications: "", + appearance: "", + automation: "", + mcp: "", + infrastructure: "", + "ai-agents": "", + }; + @state() private selections: Record = { + config: defaultConfigSelection("config"), + communications: defaultConfigSelection("communications"), + appearance: defaultConfigSelection("appearance"), + automation: defaultConfigSelection("automation"), + mcp: defaultConfigSelection("mcp"), + infrastructure: defaultConfigSelection("infrastructure"), + "ai-agents": defaultConfigSelection("ai-agents"), + }; + @state() private customThemeImportUrl = ""; + @state() private customThemeImportBusy = false; + @state() private customThemeImportMessage: { kind: "success" | "error"; text: string } | null = + null; + @state() private customThemeImportExpanded = false; + @state() private customThemeImportFocusToken = 0; + private customThemeImportSelectOnSuccess = false; + private readonly configViewState: ConfigViewState = createConfigViewState(); + private stops: Array<() => void> = []; + + override createRenderRoot() { + return this; + } + + override connectedCallback() { + super.connectedCallback(); + this.settings = loadSettings(); + this.stops = [ + this.context.runtimeConfig.subscribe(() => this.requestUpdate()), + this.context.overlays.subscribe(() => this.requestUpdate()), + this.context.config.subscribe(() => this.requestUpdate()), + this.context.gateway.subscribe(() => this.requestUpdate()), + this.context.webPush.subscribe(() => this.requestUpdate()), + this.context.theme.subscribe(() => { + this.settings = loadSettings(); + }), + ]; + const config = this.context.runtimeConfig.state; + if (!config.configSnapshot && !config.configLoading) { + void this.context.runtimeConfig + .ensureLoaded() + .then(() => this.context.runtimeConfig.ensureSchemaLoaded()); + } else if (!config.configSchema && !config.configSchemaLoading) { + void this.context.runtimeConfig.ensureSchemaLoaded(); + } + } + + override disconnectedCallback() { + for (const stop of this.stops) { + stop(); + } + this.stops = []; + super.disconnectedCallback(); + } + + private navigate(routeId: RouteId) { + this.context.navigate(routeId); + } + + private setFormMode(mode: ConfigFormMode) { + this.formModes = { ...this.formModes, [this.pageId]: mode }; + } + + private setSearchQuery(query: string) { + this.searchQueries = { ...this.searchQueries, [this.pageId]: query }; + } + + private setActiveSection(section: string | null) { + this.selections = { + ...this.selections, + [this.pageId]: { activeSection: section, activeSubsection: null }, + }; + } + + private setActiveSubsection(section: string | null) { + this.selections = { + ...this.selections, + [this.pageId]: { ...this.selections[this.pageId], activeSubsection: section }, + }; + } + + private applySettings(next: UiSettings) { + this.settings = patchSettings({ + theme: next.theme, + themeMode: next.themeMode, + customTheme: next.customTheme, + borderRadius: next.borderRadius, + textScale: next.textScale, + }); + applyBorderRadius(this.settings.borderRadius); + applyTextScale(this.settings.textScale); + this.context.theme.refresh(); + } + + private setTheme( + theme: ThemeName, + context?: Parameters[0]["context"], + ) { + const currentTheme = resolveTheme(this.settings.theme, this.settings.themeMode); + const next = { ...this.settings, theme }; + startThemeTransition({ + currentTheme, + nextTheme: resolveTheme(next.theme, next.themeMode), + context, + applyTheme: () => this.applySettings(next), + }); + } + + private setThemeMode( + mode: ThemeMode, + context?: Parameters[0]["context"], + ) { + const currentTheme = resolveTheme(this.settings.theme, this.settings.themeMode); + const next = { ...this.settings, themeMode: mode }; + startThemeTransition({ + currentTheme, + nextTheme: resolveTheme(next.theme, next.themeMode), + context, + applyTheme: () => this.applySettings(next), + }); + } + + private setBorderRadius(value: number) { + this.applySettings({ ...this.settings, borderRadius: value }); + } + + private setTextScale(value: number) { + this.applySettings({ ...this.settings, textScale: normalizeTextScale(value) }); + } + + private openCustomThemeImport() { + this.customThemeImportExpanded = true; + this.customThemeImportFocusToken += 1; + if (!this.settings.customTheme) { + this.customThemeImportSelectOnSuccess = true; + } + } + + private async importCustomTheme() { + if (this.customThemeImportBusy) { + return; + } + this.customThemeImportExpanded = true; + this.customThemeImportBusy = true; + this.customThemeImportMessage = null; + try { + const customTheme = await importCustomThemeFromUrl(this.customThemeImportUrl); + const selectTheme = !this.settings.customTheme || this.customThemeImportSelectOnSuccess; + this.applySettings({ + ...this.settings, + customTheme, + theme: selectTheme ? "custom" : this.settings.theme, + }); + this.customThemeImportUrl = ""; + this.customThemeImportSelectOnSuccess = false; + this.customThemeImportMessage = { + kind: "success", + text: `Imported ${customTheme.label}.`, + }; + } catch (error) { + this.customThemeImportMessage = { + kind: "error", + text: error instanceof Error ? error.message : String(error), + }; + } finally { + this.customThemeImportBusy = false; + } + } + + private clearCustomTheme() { + this.customThemeImportExpanded = true; + this.customThemeImportSelectOnSuccess = false; + this.applySettings({ + ...this.settings, + theme: this.settings.theme === "custom" ? "claw" : this.settings.theme, + customTheme: undefined, + }); + this.customThemeImportMessage = { + kind: "success", + text: "Custom theme removed.", + }; + } + + private includeSections(): readonly string[] | undefined { + return this.pageId === "communications" + ? COMMUNICATION_SECTION_KEYS + : this.pageId === "appearance" + ? APPEARANCE_SECTION_KEYS + : this.pageId === "automation" + ? AUTOMATION_SECTION_KEYS + : this.pageId === "mcp" || this.pageId === "infrastructure" + ? INFRASTRUCTURE_SECTION_KEYS + : this.pageId === "ai-agents" + ? AI_AGENTS_SECTION_KEYS + : undefined; + } + + private renderAdvancedConfig(configObject: Record) { + const runtimeConfig = this.context.runtimeConfig; + const configState = runtimeConfig.state; + const includeSections = this.includeSections(); + const excludeSections = + this.pageId === "config" + ? [ + ...COMMUNICATION_SECTION_KEYS, + ...AUTOMATION_SECTION_KEYS, + ...INFRASTRUCTURE_SECTION_KEYS, + ...AI_AGENTS_SECTION_KEYS, + "ui", + "wizard", + ] + : undefined; + const selection = normalizeConfigSelection( + this.pageId, + this.selections[this.pageId].activeSection, + this.selections[this.pageId].activeSubsection, + ); + const activeSection = this.pageId === "mcp" ? "mcp" : selection.activeSection; + const activeSubsection = this.pageId === "mcp" ? null : selection.activeSubsection; + const props: ConfigProps = { + raw: configState.configRaw, + originalRaw: configState.configRawOriginal, + valid: configState.configValid, + issues: configState.configIssues, + loading: configState.configLoading, + saving: configState.configSaving, + applying: configState.configApplying, + updating: this.context.overlays.snapshot.updateRunning, + connected: configState.connected, + schema: configState.configSchema, + schemaLoading: configState.configSchemaLoading, + uiHints: configState.configUiHints, + formMode: this.formModes[this.pageId], + viewState: this.configViewState, + rawAvailable: Boolean( + configState.configSnapshot?.config || configState.configForm || configState.configRaw, + ), + showModeToggle: this.pageId === "config", + formValue: configState.configForm, + originalValue: configState.configFormOriginal, + searchQuery: this.searchQueries[this.pageId], + activeSection, + activeSubsection, + onRawChange: (next) => runtimeConfig.setRaw(next), + onFormModeChange: (mode) => this.setFormMode(mode), + onViewStateChange: () => this.requestUpdate(), + onFormPatch: (path, value) => runtimeConfig.patchForm(path, value), + onSearchChange: (query) => this.setSearchQuery(query), + onSectionChange: (section) => this.setActiveSection(section), + onSubsectionChange: (section) => this.setActiveSubsection(section), + onReload: () => void runtimeConfig.refresh({ discardPendingChanges: true }), + onReset: () => runtimeConfig.resetDraft(), + onSave: () => void runtimeConfig.save(), + onApply: () => void runtimeConfig.apply(), + onUpdate: () => void this.context.overlays.runUpdate(), + onOpenFile: () => void runtimeConfig.openFile(), + version: + this.context.config.current.serverVersion ?? + this.context.gateway.snapshot.hello?.server?.version ?? + "", + theme: this.settings.theme, + themeMode: this.settings.themeMode, + setTheme: (theme, transitionContext) => this.setTheme(theme, transitionContext), + setThemeMode: (mode, transitionContext) => this.setThemeMode(mode, transitionContext), + hasCustomTheme: Boolean(this.settings.customTheme), + customThemeLabel: this.settings.customTheme?.label ?? null, + customThemeSourceUrl: this.settings.customTheme?.sourceUrl ?? null, + customThemeImportUrl: this.customThemeImportUrl, + customThemeImportBusy: this.customThemeImportBusy, + customThemeImportMessage: this.customThemeImportMessage, + customThemeImportExpanded: this.customThemeImportExpanded, + customThemeImportFocusToken: this.customThemeImportFocusToken, + onCustomThemeImportUrlChange: (next) => { + this.customThemeImportUrl = next; + if (this.customThemeImportMessage?.kind === "error") { + this.customThemeImportMessage = null; + } + }, + onImportCustomTheme: () => void this.importCustomTheme(), + onClearCustomTheme: () => this.clearCustomTheme(), + onOpenCustomThemeImport: () => this.openCustomThemeImport(), + borderRadius: this.settings.borderRadius, + setBorderRadius: (value) => this.setBorderRadius(value), + textScale: this.settings.textScale ?? 100, + setTextScale: (value) => this.setTextScale(value), + gatewayUrl: this.context.gateway.connection.gatewayUrl, + assistantName: this.context.config.current.assistantIdentity.name, + configPath: configState.configSnapshot?.path ?? null, + navRootLabel: this.pageId === "config" ? undefined : configPageTitle(this.pageId), + showRootTab: !includeSections?.length, + includeSections: includeSections ? [...includeSections] : undefined, + excludeSections, + includeVirtualSections: this.pageId === "communications" || this.pageId === "appearance", + settingsLayout: this.pageId === "config" ? "accordion" : undefined, + onBackToQuick: this.pageId === "config" ? () => (this.settingsMode = "quick") : undefined, + webPush: this.context.webPush.snapshot, + onWebPushSubscribe: () => void this.context.webPush.enable(), + onWebPushUnsubscribe: () => void this.context.webPush.disable(), + onWebPushTest: () => void this.context.webPush.sendTest(), + }; + if (this.pageId !== "mcp") { + return renderConfig(props); + } + return renderMcp({ + configObject, + configDirty: configState.configFormDirty, + configSaving: configState.configSaving, + configApplying: configState.configApplying, + connected: configState.connected, + onSaveConfig: () => void runtimeConfig.save(), + onApplyConfig: () => void runtimeConfig.apply(), + onServerEnabledChange: (name, enabled) => runtimeConfig.setMcpServerEnabled(name, enabled), + editor: renderConfig({ + ...props, + activeSection: "mcp", + activeSubsection: null, + showModeToggle: false, + includeSections: ["mcp"], + navRootLabel: "MCP", + }), + }); + } + + private renderQuickConfig(configObject: Record) { + const runtimeConfig = this.context.runtimeConfig; + const agentsDefaults = asConfigRecord(asConfigRecord(configObject.agents)?.defaults); + const model = typeof agentsDefaults?.model === "string" ? agentsDefaults.model : "default"; + const thinkingLevel = + typeof agentsDefaults?.thinkingLevel === "string" ? agentsDefaults.thinkingLevel : "off"; + const fastMode = agentsDefaults?.fastMode; + const appConfig = this.context.config.current; + return renderQuickSettings({ + currentModel: model, + thinkingLevel, + fastMode: fastMode === "auto" || typeof fastMode === "boolean" ? fastMode : false, + channels: quickChannels(configObject), + automation: { + cronJobCount: 0, + skillCount: 0, + mcpServerCount: mcpServerCount(configObject), + }, + security: extractQuickSettingsSecurity(configObject), + theme: this.settings.theme, + themeMode: this.settings.themeMode, + hasCustomTheme: Boolean(this.settings.customTheme), + customThemeLabel: this.settings.customTheme?.label, + borderRadius: this.settings.borderRadius, + textScale: this.settings.textScale ?? 100, + setTheme: (theme, transitionContext) => this.setTheme(theme, transitionContext), + setThemeMode: (mode, transitionContext) => this.setThemeMode(mode, transitionContext), + onModelChange: () => { + this.settingsMode = "advanced"; + this.selections = { + ...this.selections, + "ai-agents": { activeSection: "models", activeSubsection: null }, + }; + this.navigate("ai-agents"); + }, + setBorderRadius: (value) => this.setBorderRadius(value), + setTextScale: (value) => this.setTextScale(value), + onOpenCustomThemeImport: () => { + this.pageId = "appearance"; + this.setFormMode("form"); + this.setSearchQuery(""); + this.selections = { + ...this.selections, + appearance: { activeSection: "__appearance__", activeSubsection: null }, + }; + this.openCustomThemeImport(); + }, + connected: runtimeConfig.state.connected, + gatewayUrl: this.context.gateway.connection.gatewayUrl, + assistantName: appConfig.assistantIdentity.name, + version: + appConfig.serverVersion ?? this.context.gateway.snapshot.hello?.server?.version ?? "", + configObject, + configDirty: runtimeConfig.state.configFormDirty, + configSaving: runtimeConfig.state.configSaving, + configApplying: runtimeConfig.state.configApplying, + configReady: Boolean(runtimeConfig.state.configSnapshot?.hash), + onSelectPreset: (id) => { + const preset = getPresetById(id); + if (preset) { + runtimeConfig.stagePreset(preset.patch); + } + }, + onResetConfig: () => runtimeConfig.resetDraft(), + onSaveConfig: () => void runtimeConfig.save(), + onApplyConfig: () => void runtimeConfig.apply(), + onAdvancedSettings: () => { + this.settingsMode = "advanced"; + }, + onThinkingChange: (level) => + runtimeConfig.patchForm(["agents", "defaults", "thinkingLevel"], level), + onFastModeChange: (mode: FastMode) => + runtimeConfig.patchForm(["agents", "defaults", "fastMode"], mode), + onChannelConfigure: () => this.navigate("communications"), + onManageCron: () => this.navigate("cron"), + onBrowseSkills: () => this.navigate("skills"), + onConfigureMcp: () => this.navigate("mcp"), + onSecurityConfigure: () => { + this.settingsMode = "advanced"; + this.selections = { + ...this.selections, + config: { activeSection: "auth", activeSubsection: null }, + }; + }, + onBrowserEnabledToggle: (enabled) => runtimeConfig.patchForm(["browser", "enabled"], enabled), + onToolProfileChange: (profile) => runtimeConfig.patchForm(["tools", "profile"], profile), + assistantAvatar: appConfig.assistantIdentity.avatar, + assistantAvatarUrl: appConfig.assistantIdentity.avatar, + assistantAvatarSource: appConfig.assistantIdentity.avatarSource, + assistantAvatarStatus: appConfig.assistantIdentity.avatarStatus, + assistantAvatarReason: appConfig.assistantIdentity.avatarReason, + assistantAvatarOverride: null, + basePath: this.context.basePath, + }); + } + + override render() { + const configState = this.context.runtimeConfig.state; + const configObject = + asConfigRecord(configState.configForm ?? configState.configSnapshot?.config) ?? {}; + const body = + this.pageId === "config" && this.settingsMode === "quick" + ? this.renderQuickConfig(configObject) + : this.renderAdvancedConfig(configObject); + return html` +
+
+
${configPageTitle(this.pageId)}
+
${configPageSubtitle(this.pageId)}
+
+
+ ${renderSettingsWorkspace( + this.context.basePath, + body, + this.pageId, + (routeId) => this.navigate(routeId), + (routeId) => this.context.preload(routeId), + )} + `; + } +} + +customElements.define("openclaw-config-page", ConfigPage); diff --git a/ui/src/ui/views/mcp.test.ts b/ui/src/pages/config/mcp.test.ts similarity index 100% rename from ui/src/ui/views/mcp.test.ts rename to ui/src/pages/config/mcp.test.ts diff --git a/ui/src/ui/views/mcp.ts b/ui/src/pages/config/mcp.ts similarity index 99% rename from ui/src/ui/views/mcp.ts rename to ui/src/pages/config/mcp.ts index fc88d5642b3f..9d823a84f99c 100644 --- a/ui/src/ui/views/mcp.ts +++ b/ui/src/pages/config/mcp.ts @@ -1,4 +1,4 @@ -// Control UI view renders mcp screen content. +// Control UI MCP Settings page presentation. import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { html, nothing, type TemplateResult } from "lit"; diff --git a/ui/src/ui/views/config-presets.test.ts b/ui/src/pages/config/presets.test.ts similarity index 96% rename from ui/src/ui/views/config-presets.test.ts rename to ui/src/pages/config/presets.test.ts index 4a7496e31394..dacbc5ee4a5a 100644 --- a/ui/src/ui/views/config-presets.test.ts +++ b/ui/src/pages/config/presets.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover config presets behavior. import { describe, expect, it } from "vitest"; import { OpenClawSchema } from "../../../../src/config/zod-schema.js"; -import { CONFIG_PRESETS, detectActivePreset } from "./config-presets.ts"; +import { CONFIG_PRESETS, detectActivePreset } from "./presets.ts"; describe("detectActivePreset", () => { it("keeps every preset patch valid for the runtime config schema", () => { diff --git a/ui/src/ui/views/config-presets.ts b/ui/src/pages/config/presets.ts similarity index 100% rename from ui/src/ui/views/config-presets.ts rename to ui/src/pages/config/presets.ts diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/pages/config/quick.test.ts similarity index 99% rename from ui/src/ui/views/config-quick.test.ts rename to ui/src/pages/config/quick.test.ts index 199a2449ae2b..0338bb43b6db 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/pages/config/quick.test.ts @@ -2,7 +2,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { renderQuickSettings, type QuickSettingsProps } from "./config-quick.ts"; +import { renderQuickSettings, type QuickSettingsProps } from "./quick.ts"; function expectButtonByText(container: Element, text: string): HTMLButtonElement { const button = Array.from(container.querySelectorAll("button")).find( diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/pages/config/quick.ts similarity index 98% rename from ui/src/ui/views/config-quick.ts rename to ui/src/pages/config/quick.ts index e7966efc3709..591020807181 100644 --- a/ui/src/ui/views/config-quick.ts +++ b/ui/src/pages/config/quick.ts @@ -7,29 +7,26 @@ import { html, nothing, type TemplateResult } from "lit"; import { formatFastModeValue } from "../../../../src/shared/fast-mode.js"; -import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; -import type { BorderRadiusStop, TextScaleStop } from "../storage.ts"; -import { normalizeOptionalString } from "../string-coerce.ts"; -import type { ThemeTransitionContext } from "../theme-transition.ts"; -import type { ThemeMode, ThemeName } from "../theme.ts"; -import type { FastMode } from "../types.ts"; +import type { FastMode } from "../../api/types.ts"; +import { controlUiPublicAssetPath } from "../../app/public-assets.ts"; +import type { BorderRadiusStop, TextScaleStop } from "../../app/settings.ts"; +import type { ThemeTransitionContext } from "../../app/theme-transition.ts"; +import type { ThemeMode, ThemeName } from "../../app/theme.ts"; import { normalizeLocalUserIdentity, resolveLocalUserAvatarText, resolveLocalUserAvatarUrl, -} from "../user-identity.ts"; -import { - assistantAvatarFallbackUrl, - resolveChatAvatarRenderUrl, - resolveAssistantTextAvatar, -} from "./agents-utils.ts"; +} from "../../app/user-identity.ts"; +import { icons } from "../../components/icons.ts"; +import { t } from "../../i18n/index.ts"; +import { resolveAssistantTextAvatar, resolveChatAvatarRenderUrl } from "../../lib/avatar.ts"; +import { normalizeOptionalString } from "../../lib/string-coerce.ts"; import { CONFIG_PRESETS, detectActivePreset, getPresetById, type ConfigPresetId, -} from "./config-presets.ts"; +} from "./presets.ts"; // ── Types ── @@ -269,7 +266,7 @@ function renderAssistantAvatarPreview(props: QuickSettingsProps) { return html` ${assistantName} `; diff --git a/ui/src/pages/config/route.ts b/ui/src/pages/config/route.ts new file mode 100644 index 000000000000..15a322db0541 --- /dev/null +++ b/ui/src/pages/config/route.ts @@ -0,0 +1,37 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; +import type { ConfigPageId } from "./config-page.ts"; + +function loadConfigRoute(context: ApplicationContext) { + const primaryLoad = context.runtimeConfig.ensureLoaded(); + void primaryLoad.then( + () => { + void context.runtimeConfig.ensureSchemaLoaded(); + }, + () => undefined, + ); +} + +function configPage(id: ConfigPageId, path: string) { + return definePage({ + id, + path, + loader: (context: ApplicationContext) => loadConfigRoute(context), + component: () => + import("./config-page.ts").then(() => ({ + header: true, + render: () => html``, + })), + }); +} + +export const pages = [ + configPage("config", "/config"), + configPage("communications", "/communications"), + configPage("appearance", "/appearance"), + configPage("automation", "/automation"), + configPage("mcp", "/mcp"), + configPage("infrastructure", "/infrastructure"), + configPage("ai-agents", "/ai-agents"), +] as const; diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/pages/config/view.browser.test.ts similarity index 98% rename from ui/src/ui/views/config.browser.test.ts rename to ui/src/pages/config/view.browser.test.ts index d137617b195b..30d0e8eb6799 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -1,8 +1,8 @@ // Control UI tests cover config behavior. import { render } from "lit"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ThemeMode, ThemeName } from "../theme.ts"; -import { renderConfig, resetConfigViewStateForTests, type ConfigProps } from "./config.ts"; +import { describe, expect, it, vi } from "vitest"; +import type { ThemeMode, ThemeName } from "../../app/theme.ts"; +import { createConfigViewState, renderConfig, type ConfigProps } from "./view.ts"; describe("config view", () => { const baseProps = () => ({ @@ -22,6 +22,7 @@ describe("config view", () => { schemaLoading: false, uiHints: {}, formMode: "form" as const, + viewState: createConfigViewState(), showModeToggle: true, formValue: {}, originalValue: {}, @@ -30,6 +31,7 @@ describe("config view", () => { activeSubsection: null, onRawChange: vi.fn(), onFormModeChange: vi.fn(), + onViewStateChange: vi.fn(), onFormPatch: vi.fn(), onSearchChange: vi.fn(), onSectionChange: vi.fn(), @@ -103,7 +105,7 @@ describe("config view", () => { render( renderConfig({ ...props, - onRequestUpdate: rerender, + onViewStateChange: rerender, }), container, ); @@ -148,10 +150,6 @@ describe("config view", () => { return element; } - beforeEach(() => { - resetConfigViewStateForTests(); - }); - it("updates save/apply disabled state from form safety and raw dirtiness", () => { const container = document.createElement("div"); @@ -478,8 +476,9 @@ describe("config view", () => { document.body.append(container); try { + const viewState = createConfigViewState(); const renderCase = (overrides: Partial) => - render(renderConfig({ ...baseProps(), ...overrides }), container); + render(renderConfig({ ...baseProps(), viewState, ...overrides }), container); renderCase({ formMode: "form" }); @@ -699,7 +698,6 @@ describe("config view", () => { expect(container.querySelector("textarea")).toBeNull(); const revealButton = queryRequired(container, ".config-raw-toggle", HTMLButtonElement); - expect(revealButton.getAttribute("title")).toBe("Reveal sensitive values"); expect(revealButton.getAttribute("aria-pressed")).toBe("false"); revealButton.click(); @@ -735,7 +733,7 @@ describe("config view", () => { render( renderConfig({ ...props, - onRequestUpdate: () => { + onViewStateChange: () => { updateCount += 1; rerender(); }, @@ -821,7 +819,7 @@ describe("config view", () => { render( renderConfig({ ...props, - onRequestUpdate: rerender, + onViewStateChange: rerender, }), container, ); @@ -871,7 +869,7 @@ describe("config view", () => { render( renderConfig({ ...props, - onRequestUpdate: rerender, + onViewStateChange: rerender, }), container, ); @@ -957,7 +955,7 @@ describe("config view", () => { render( renderConfig({ ...props, - onRequestUpdate: rerender, + onViewStateChange: rerender, }), container, ); @@ -1001,7 +999,7 @@ describe("config view", () => { render( renderConfig({ ...props, - onRequestUpdate: rerender, + onViewStateChange: rerender, }), container, ); diff --git a/ui/src/ui/views/config.ts b/ui/src/pages/config/view.ts similarity index 91% rename from ui/src/ui/views/config.ts rename to ui/src/pages/config/view.ts index 11db9b2a5390..2cb91fa9b1aa 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/pages/config/view.ts @@ -1,17 +1,15 @@ // Control UI view renders config screen content. import JSON5 from "json5"; import { html, nothing, type TemplateResult } from "lit"; -import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; +import type { ConfigUiHints } from "../../api/types.ts"; import { BORDER_RADIUS_STOPS, TEXT_SCALE_STOPS, type BorderRadiusStop, type TextScaleStop, -} from "../storage.ts"; -import type { ThemeTransitionContext } from "../theme-transition.ts"; -import type { ThemeMode, ThemeName } from "../theme.ts"; -import type { ConfigUiHints } from "../types.ts"; +} from "../../app/settings.ts"; +import type { ThemeTransitionContext } from "../../app/theme-transition.ts"; +import type { ThemeMode, ThemeName } from "../../app/theme.ts"; import { countSensitiveConfigValues, hintForPath, @@ -21,8 +19,16 @@ import { REDACTED_PLACEHOLDER, schemaType, type JsonSchema, -} from "./config-form.shared.ts"; -import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; +} from "../../components/config-form.shared.ts"; +import "../../components/tooltip.ts"; +import { + analyzeConfigSchema, + renderConfigForm, + SECTION_META, + type ConfigSchemaAnalysis, +} from "../../components/config-form.ts"; +import { icons } from "../../components/icons.ts"; +import { t } from "../../i18n/index.ts"; const BORDER_RADIUS_LABELS: Record = { 0: "None", @@ -45,8 +51,51 @@ export type WebPushUiState = { permission: NotificationPermission | "unsupported"; subscribed: boolean; loading: boolean; + error?: string | null; }; +type ConfigFormMode = "form" | "raw"; + +type ConfigDiffPath = string[]; +type ConfigDiffEntry = { path: ConfigDiffPath; from: unknown; to: unknown }; +type RawDiffCache = { + original: string; + current: string; + diff: ConfigDiffEntry[]; +}; +type SchemaAnalysisCache = { + schema: JsonSchema | null; + includeKey: string; + excludeKey: string; + analysis: ConfigSchemaAnalysis; +}; + +export type ConfigViewState = { + rawRevealed: boolean; + rawDiffOpen: boolean; + envRevealed: boolean; + validityDismissed: boolean; + revealedSensitivePaths: Set; + lastCustomThemeImportFocusToken: number | null; + rawDiffCache?: RawDiffCache; + schemaAnalysisCache?: SchemaAnalysisCache; + lastConfigContextKey: string | null; + lastFormModeForScroll: ConfigFormMode | null; +}; + +export function createConfigViewState(): ConfigViewState { + return { + rawRevealed: false, + rawDiffOpen: false, + envRevealed: false, + validityDismissed: false, + revealedSensitivePaths: new Set(), + lastCustomThemeImportFocusToken: null, + lastConfigContextKey: null, + lastFormModeForScroll: null, + }; +} + export type ConfigProps = { raw: string; originalRaw: string; @@ -60,7 +109,8 @@ export type ConfigProps = { schema: unknown; schemaLoading: boolean; uiHints: ConfigUiHints; - formMode: "form" | "raw"; + formMode: ConfigFormMode; + viewState: ConfigViewState; rawAvailable?: boolean; showModeToggle?: boolean; formValue: Record | null; @@ -69,7 +119,8 @@ export type ConfigProps = { activeSection: string | null; activeSubsection: string | null; onRawChange: (next: string) => void; - onFormModeChange: (mode: "form" | "raw") => void; + onFormModeChange: (mode: ConfigFormMode) => void; + onViewStateChange: () => void; onFormPatch: (path: Array, value: unknown) => void; onSearchChange: (query: string) => void; onSectionChange: (section: string | null) => void; @@ -117,7 +168,6 @@ export type ConfigProps = { onWebPushSubscribe?: () => void; onWebPushUnsubscribe?: () => void; onWebPushTest?: () => void; - onRequestUpdate?: () => void; }; // SVG Icons for sidebar (Lucide-style) @@ -521,6 +571,35 @@ function asConfigSchema(value: unknown): JsonSchema | null { return value as JsonSchema; } +function configSectionKey(sections?: readonly string[]): string { + return sections?.length ? sections.join("\u001f") : ""; +} + +function getConfigSchemaAnalysis( + viewState: ConfigViewState, + schema: JsonSchema | null, + includeSections?: readonly string[], + excludeSections?: readonly string[], + include?: ReadonlySet | null, + exclude?: ReadonlySet | null, +): ConfigSchemaAnalysis { + const includeKey = configSectionKey(includeSections); + const excludeKey = configSectionKey(excludeSections); + const cached = viewState.schemaAnalysisCache; + if ( + cached && + cached.schema === schema && + cached.includeKey === includeKey && + cached.excludeKey === excludeKey + ) { + return cached.analysis; + } + const scopedSchema = scopeSchemaSections(schema, { include, exclude }); + const analysis = analyzeConfigSchema(scopedSchema); + viewState.schemaAnalysisCache = { schema, includeKey, excludeKey, analysis }; + return analysis; +} + function resolveSectionMeta( key: string, schema?: JsonSchema, @@ -544,17 +623,6 @@ const MAX_CONFIG_DIFF_CHANGES = 1_000; const MAX_CONFIG_DIFF_ARRAY_COMPARE_ITEMS = 2_000; const MAX_RAW_DIFF_CHARS = 200_000; -type ConfigDiffPath = string[]; -type ConfigDiffEntry = { path: ConfigDiffPath; from: unknown; to: unknown }; - -let rawDiffCache: - | { - original: string; - current: string; - diff: ConfigDiffEntry[]; - } - | undefined; - function formatConfigDiffPath(path: ConfigDiffPath): string { return path.length > 0 ? path.join(".") : ""; } @@ -676,13 +744,17 @@ function computeDiff( return changes; } -function computeRawDiff(original: string, current: string): ConfigDiffEntry[] { - if (rawDiffCache?.original === original && rawDiffCache.current === current) { - return rawDiffCache.diff; +function computeRawDiff( + viewState: ConfigViewState, + original: string, + current: string, +): ConfigDiffEntry[] { + if (viewState.rawDiffCache?.original === original && viewState.rawDiffCache.current === current) { + return viewState.rawDiffCache.diff; } if (original.length > MAX_RAW_DIFF_CHARS || current.length > MAX_RAW_DIFF_CHARS) { - rawDiffCache = { original, current, diff: [] }; - return rawDiffCache.diff; + viewState.rawDiffCache = { original, current, diff: [] }; + return viewState.rawDiffCache.diff; } try { const originalValue = JSON5.parse(original) as unknown; @@ -695,17 +767,17 @@ function computeRawDiff(original: string, current: string): ConfigDiffEntry[] { Array.isArray(originalValue) || Array.isArray(currentValue) ) { - rawDiffCache = { original, current, diff: [] }; + viewState.rawDiffCache = { original, current, diff: [] }; return []; } const diff = computeDiff( originalValue as Record, currentValue as Record, ); - rawDiffCache = { original, current, diff }; + viewState.rawDiffCache = { original, current, diff }; return diff; } catch { - rawDiffCache = { original, current, diff: [] }; + viewState.rawDiffCache = { original, current, diff: [] }; return []; } } @@ -938,6 +1010,7 @@ function renderNotificationsSection(props: ConfigProps) { ` : nothing}
+ ${push.error ? html`
${push.error}
` : nothing}
@@ -945,13 +1018,14 @@ function renderNotificationsSection(props: ConfigProps) { } function renderAppearanceSection(props: ConfigProps) { + const viewState = props.viewState; const showCustomThemeImport = props.hasCustomTheme || props.customThemeImportExpanded === true; if ( showCustomThemeImport && props.customThemeImportFocusToken != null && - props.customThemeImportFocusToken !== cvs.lastCustomThemeImportFocusToken + props.customThemeImportFocusToken !== viewState.lastCustomThemeImportFocusToken ) { - cvs.lastCustomThemeImportFocusToken = props.customThemeImportFocusToken; + viewState.lastCustomThemeImportFocusToken = props.customThemeImportFocusToken; focusCustomThemeImportInput(); } const importedName = importedThemeName(props); @@ -1162,33 +1236,14 @@ function renderAppearanceSection(props: ConfigProps) { `; } -interface ConfigEphemeralState { - rawRevealed: boolean; - rawDiffOpen: boolean; - envRevealed: boolean; - validityDismissed: boolean; - revealedSensitivePaths: Set; - lastCustomThemeImportFocusToken: number | null; -} - -function createConfigEphemeralState(): ConfigEphemeralState { - return { - rawRevealed: false, - rawDiffOpen: false, - envRevealed: false, - validityDismissed: false, - revealedSensitivePaths: new Set(), - lastCustomThemeImportFocusToken: null, - }; -} - -const cvs = createConfigEphemeralState(); -let lastConfigContextKey: string | null = null; -let lastFormModeForScroll: ConfigProps["formMode"] | null = null; - -function resetConfigEphemeralState() { - Object.assign(cvs, createConfigEphemeralState()); - rawDiffCache = undefined; +function resetConfigEphemeralState(viewState: ConfigViewState) { + viewState.rawRevealed = false; + viewState.rawDiffOpen = false; + viewState.envRevealed = false; + viewState.validityDismissed = false; + viewState.revealedSensitivePaths.clear(); + viewState.lastCustomThemeImportFocusToken = null; + viewState.rawDiffCache = undefined; } function configContextKey(props: ConfigProps): string { @@ -1203,42 +1258,46 @@ function configContextKey(props: ConfigProps): string { ].join("\u001e"); } -function isSensitivePathRevealed(path: Array): boolean { +function isSensitivePathRevealed( + viewState: ConfigViewState, + path: Array, +): boolean { const key = pathKey(path); - return key ? cvs.revealedSensitivePaths.has(key) : false; + return key ? viewState.revealedSensitivePaths.has(key) : false; } -function toggleSensitivePathReveal(path: Array) { +function toggleSensitivePathReveal(viewState: ConfigViewState, path: Array) { const key = pathKey(path); if (!key) { return; } - if (cvs.revealedSensitivePaths.has(key)) { - cvs.revealedSensitivePaths.delete(key); + if (viewState.revealedSensitivePaths.has(key)) { + viewState.revealedSensitivePaths.delete(key); } else { - cvs.revealedSensitivePaths.add(key); + viewState.revealedSensitivePaths.add(key); } } -export function resetConfigViewStateForTests() { - resetConfigEphemeralState(); - lastConfigContextKey = null; - lastFormModeForScroll = null; -} - export function renderConfig(props: ConfigProps) { + const viewState = props.viewState; const showModeToggle = props.showModeToggle ?? false; const showRootTab = props.showRootTab ?? true; const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; const includeVirtualSections = props.includeVirtualSections ?? true; const include = props.includeSections?.length ? new Set(props.includeSections) : null; const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null; - const scopedSchema = scopeSchemaSections(asConfigSchema(props.schema), { include, exclude }); - const analysis = analyzeConfigSchema(scopedSchema); + const analysis = getConfigSchemaAnalysis( + viewState, + asConfigSchema(props.schema), + props.includeSections, + props.excludeSections, + include, + exclude, + ); const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false; const rawAvailable = props.rawAvailable ?? true; const formMode = showModeToggle && rawAvailable ? props.formMode : "form"; - const requestUpdate = props.onRequestUpdate ?? (() => {}); + const requestUpdate = props.onViewStateChange; // Scroll helper: target-based (nav clicks) with global fallback (form/raw toggle) const resetContentScroll = (target: EventTarget | null) => { queueMicrotask(() => { @@ -1259,17 +1318,17 @@ export function renderConfig(props: ConfigProps) { }; // Reset scroll position when switching between form and raw mode - if (lastFormModeForScroll !== null && lastFormModeForScroll !== formMode) { + if (viewState.lastFormModeForScroll !== null && viewState.lastFormModeForScroll !== formMode) { resetContentScroll(null); } - lastFormModeForScroll = formMode; + viewState.lastFormModeForScroll = formMode; const currentContextKey = configContextKey(props); - if (lastConfigContextKey !== currentContextKey) { - resetConfigEphemeralState(); - lastConfigContextKey = currentContextKey; + if (viewState.lastConfigContextKey !== currentContextKey) { + resetConfigEphemeralState(viewState); + viewState.lastConfigContextKey = currentContextKey; } - const envSensitiveVisible = cvs.envRevealed; + const envSensitiveVisible = viewState.envRevealed; // Build categorised nav from schema - only include sections that exist in the schema const schemaProps = analysis.schema?.properties ?? {}; @@ -1419,15 +1478,15 @@ export function renderConfig(props: ConfigProps) { // Compute diff for showing changes (works for both form and raw modes) const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw; - if ((!hasRawChanges || formMode !== "raw") && cvs.rawDiffOpen) { - cvs.rawDiffOpen = false; + if ((!hasRawChanges || formMode !== "raw") && viewState.rawDiffOpen) { + viewState.rawDiffOpen = false; } - if (!hasRawChanges || formMode !== "raw" || !cvs.rawDiffOpen) { - rawDiffCache = undefined; + if (!hasRawChanges || formMode !== "raw" || !viewState.rawDiffOpen) { + viewState.rawDiffCache = undefined; } const rawDiff = - formMode === "raw" && hasRawChanges && cvs.rawDiffOpen - ? computeRawDiff(props.originalRaw, props.raw) + formMode === "raw" && hasRawChanges && viewState.rawDiffOpen + ? computeRawDiff(viewState, props.originalRaw, props.raw) : []; const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges; @@ -1505,11 +1564,7 @@ export function renderConfig(props: ConfigProps) {
${props.onOpenFile ? html` - ` @@ -1617,7 +1672,7 @@ export function renderConfig(props: ConfigProps) {
`} - ${validity === "invalid" && !cvs.validityDismissed + ${validity === "invalid" && !viewState.validityDismissed ? html`
{ - cvs.validityDismissed = true; + viewState.validityDismissed = true; requestUpdate(); }} > @@ -1694,15 +1749,15 @@ export function renderConfig(props: ConfigProps) { ? html`
{ const details = e.target as HTMLDetailsElement; - if (cvs.rawDiffOpen === details.open) { + if (viewState.rawDiffOpen === details.open) { return; } - cvs.rawDiffOpen = details.open; + viewState.rawDiffOpen = details.open; if (!details.open) { - rawDiffCache = undefined; + viewState.rawDiffCache = undefined; } requestUpdate(); }} @@ -1733,7 +1788,7 @@ export function renderConfig(props: ConfigProps) { change.path, change.from, props.uiHints, - cvs.rawRevealed, + viewState.rawRevealed, )}
@@ -1742,7 +1797,7 @@ export function renderConfig(props: ConfigProps) { change.path, change.to, props.uiHints, - cvs.rawRevealed, + viewState.rawRevealed, )}
@@ -1780,7 +1835,7 @@ export function renderConfig(props: ConfigProps) { : ""}" title=${envSensitiveVisible ? "Hide env values" : "Reveal env values"} @click=${() => { - cvs.envRevealed = !cvs.envRevealed; + viewState.envRevealed = !viewState.envRevealed; requestUpdate(); }} > @@ -1837,9 +1892,10 @@ export function renderConfig(props: ConfigProps) { activeSubsection: effectiveSubsection, revealSensitive: props.activeSection === "env" ? envSensitiveVisible : false, - isSensitivePathRevealed, + isSensitivePathRevealed: (path) => + isSensitivePathRevealed(viewState, path), onToggleSensitivePath: (path) => { - toggleSensitivePathReveal(path); + toggleSensitivePathReveal(viewState, path); requestUpdate(); }, })} @@ -1850,7 +1906,7 @@ export function renderConfig(props: ConfigProps) { [], props.uiHints, ); - const blurred = sensitiveCount > 0 && !cvs.rawRevealed; + const blurred = sensitiveCount > 0 && !viewState.rawRevealed; return html` ${formUnsafe ? html` @@ -1869,20 +1925,25 @@ export function renderConfig(props: ConfigProps) { >${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} ${blurred ? "redacted" : "visible"} - + + ` : nothing} diff --git a/ui/src/pages/cron/cron-page.ts b/ui/src/pages/cron/cron-page.ts new file mode 100644 index 000000000000..391cd3b665a4 --- /dev/null +++ b/ui/src/pages/cron/cron-page.ts @@ -0,0 +1,443 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { AgentsListResult, CronJob } from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { currentConfigObject } from "../../lib/config/index.ts"; +import { + addCronJob, + cancelCronEdit, + createInitialCronState, + DEFAULT_CRON_FORM, + getCronJobPayload, + getVisibleCronJobs, + hasCronFormErrors, + loadCronJobsPage, + loadCronModelSuggestions, + loadCronRuns, + loadCronStatus, + loadMoreCronRuns, + normalizeCronFormState, + removeCronJob, + resolveConfiguredCronModelSuggestions, + runCronJob, + startCronClone, + startCronEdit, + toggleCronJob, + updateCronJobsFilter, + updateCronRunsFilter, + validateCronForm, + type CronModelSuggestionsState, + type CronState, +} from "../../lib/cron/index.ts"; +import { searchForSession } from "../../lib/sessions/index.ts"; +import { sortUniqueStrings } from "../../lib/string-coerce.ts"; +import { createDefaultDraft, draftToCronFormPatch, renderCronQuickCreate } from "./quick-create.ts"; +import type { CronQuickCreateDraft, CronQuickCreateStep } from "./quick-create.ts"; +import { renderCron } from "./view.ts"; + +const THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; +const TIMEZONE_SUGGESTIONS = [ + "UTC", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "Europe/London", + "Europe/Berlin", + "Asia/Tokyo", +]; + +function unique(values: string[]): string[] { + return sortUniqueStrings(values.map((value) => value.trim()).filter(Boolean)); +} + +export class CronPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @state() private cron = createInitialCronState(); + @state() private agentsList: AgentsListResult | null = null; + @state() private cronModelSuggestions: string[] = []; + @state() private quickCreateOpen = false; + @state() private quickCreateStep: CronQuickCreateStep = "what"; + @state() private quickCreateDraft: CronQuickCreateDraft | null = null; + + private stopGatewaySubscription?: () => void; + private stopGatewayEvents?: () => void; + private stopAgentsSubscription?: () => void; + private stopChannelsSubscription?: () => void; + private stopConfigSubscription?: () => void; + private modelSuggestionsClient: GatewayBrowserClient | null = null; + + override connectedCallback() { + super.connectedCallback(); + this.syncGatewayState(); + this.syncAgentsState(); + this.stopGatewaySubscription = this.context.gateway.subscribe(() => { + this.syncGatewayState(); + this.ensureInitialData(); + }); + this.stopGatewayEvents = this.context.gateway.subscribeEvents((event) => { + if (event.event === "cron") { + void this.refreshCron({ tableFilters: true }); + } + }); + this.stopAgentsSubscription = this.context.agents.subscribe(() => { + this.syncAgentsState(); + this.requestUpdate(); + }); + this.stopChannelsSubscription = this.context.channels.subscribe(() => this.requestUpdate()); + this.stopConfigSubscription = this.context.runtimeConfig.subscribe(() => this.requestUpdate()); + this.ensureInitialData(); + } + + override disconnectedCallback() { + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopGatewayEvents?.(); + this.stopGatewayEvents = undefined; + this.stopAgentsSubscription?.(); + this.stopAgentsSubscription = undefined; + this.stopChannelsSubscription?.(); + this.stopChannelsSubscription = undefined; + this.stopConfigSubscription?.(); + this.stopConfigSubscription = undefined; + super.disconnectedCallback(); + } + + private syncGatewayState() { + const gateway = this.context.gateway.snapshot; + if (this.cron.client !== gateway.client) { + this.cron = createInitialCronState(gateway); + this.cronModelSuggestions = []; + this.modelSuggestionsClient = null; + return; + } + if (this.cron.connected === gateway.connected) { + return; + } + this.cron.connected = gateway.connected; + this.requestUpdate(); + } + + private syncAgentsState() { + this.agentsList = this.context.agents.state.agentsList; + } + + private ensureInitialData() { + if (!this.cron.connected || !this.cron.client) { + return; + } + if (!this.agentsList && !this.context.agents.state.agentsLoading) { + void this.context.agents.ensureList(); + } + if (!this.cron.cronStatus && !this.cron.cronLoading) { + void this.refreshCron({ tableFilters: true }); + } else if (!this.cron.cronRuns.length && !this.cron.cronRunsLoadingMore) { + void this.loadRuns(this.cron.cronRunsScope === "all" ? null : this.cron.cronRunsJobId); + } + if (this.modelSuggestionsClient !== this.cron.client) { + this.modelSuggestionsClient = this.cron.client; + void this.loadModelSuggestions(); + } + } + + private requestCronUpdate(cronState: CronState = this.cron) { + if (this.cron === cronState) { + this.requestUpdate(); + } + } + + private async refreshCron(options: { tableFilters: boolean }) { + const cronState = this.cron; + if (!cronState.connected || !cronState.client) { + return; + } + const activeCronJobId = cronState.cronRunsScope === "job" ? cronState.cronRunsJobId : null; + void this.loadRuns(activeCronJobId); + void this.context.channels.refresh(false); + await Promise.all([ + this.runCronTask((current) => loadCronStatus(current)), + this.runCronTask((current) => + loadCronJobsPage(current, { tableFilters: options.tableFilters }), + ), + ]); + } + + private loadRuns(jobId: string | null) { + return this.runCronTask((cronState) => loadCronRuns(cronState, jobId)); + } + + private async loadModelSuggestions() { + const suggestionState: CronModelSuggestionsState = { + client: this.cron.client, + connected: this.cron.connected, + cronModelSuggestions: this.cronModelSuggestions, + }; + await loadCronModelSuggestions(suggestionState); + if (suggestionState.client === this.cron.client) { + this.cronModelSuggestions = suggestionState.cronModelSuggestions; + } + } + + private async runCronTask(task: (cronState: CronState) => Promise): Promise { + const cronState = this.cron; + try { + const result = task(cronState); + this.requestCronUpdate(cronState); + return await result; + } finally { + this.requestCronUpdate(cronState); + } + } + + private openQuickCreate() { + this.quickCreateOpen = true; + this.quickCreateStep = "what"; + this.quickCreateDraft = createDefaultDraft(); + } + + private closeQuickCreate() { + this.quickCreateOpen = false; + } + + private draftToForm() { + const draft = this.quickCreateDraft ?? createDefaultDraft(); + this.cron.cronEditingJobId = null; + this.cron.cronForm = normalizeCronFormState({ + ...DEFAULT_CRON_FORM, + ...draftToCronFormPatch(draft), + }); + this.cron.cronFieldErrors = validateCronForm(this.cron.cronForm); + this.requestCronUpdate(); + } + + private async createFromQuickCreate() { + this.draftToForm(); + const saved = await this.runCronTask((cronState) => addCronJob(cronState)); + if (saved) { + this.quickCreateOpen = false; + this.quickCreateStep = "what"; + this.quickCreateDraft = null; + } + } + + private suggestions() { + const channels = this.context.channels.state; + const configValue = currentConfigObject(this.context.runtimeConfig.state); + const channel = this.cron.cronForm.deliveryChannel.trim() || "last"; + const agentSuggestions = unique([ + ...(this.agentsList?.agents.map((entry) => entry.id.trim()) ?? []), + ...this.cron.cronJobs.map((job) => + typeof job.agentId === "string" ? job.agentId.trim() : "", + ), + ]); + const modelSuggestions = unique([ + ...this.cronModelSuggestions, + ...resolveConfiguredCronModelSuggestions(configValue), + ...this.cron.cronJobs.map((job) => { + const payload = getCronJobPayload(job); + return payload?.kind === "agentTurn" && typeof payload.model === "string" + ? payload.model.trim() + : ""; + }), + ]); + const jobTargets = this.cron.cronJobs + .map((job) => (typeof job.delivery?.to === "string" ? job.delivery.to.trim() : "")) + .filter(Boolean); + const accountTargets = ( + channel === "last" + ? Object.values(channels.channelsSnapshot?.channelAccounts ?? {}).flat() + : (channels.channelsSnapshot?.channelAccounts?.[channel] ?? []) + ) + .flatMap((account) => [account.accountId, account.name]) + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + const deliveryTargets = unique([...jobTargets, ...accountTargets]); + return { + agentSuggestions, + modelSuggestions, + accountTargets, + deliveryToSuggestions: + this.cron.cronForm.deliveryMode === "webhook" + ? deliveryTargets.filter((value) => /^https?:\/\//i.test(value)) + : deliveryTargets, + }; + } + + private editJob(job: CronJob) { + this.cron.cronFormCollapsed = false; + startCronEdit(this.cron, job); + this.requestCronUpdate(); + } + + private cloneJob(job: CronJob) { + this.cron.cronFormCollapsed = false; + startCronClone(this.cron, job); + this.requestCronUpdate(); + } + + override render() { + const channels = this.context.channels.state; + const suggestions = this.suggestions(); + return html` +
+
+
${titleForRoute("cron")}
+
${subtitleForRoute("cron")}
+
+
+ ${renderSettingsWorkspace( + this.context.basePath, + html` + ${renderCronQuickCreate({ + open: this.quickCreateOpen, + step: this.quickCreateStep, + draft: this.quickCreateDraft ?? createDefaultDraft(), + onCancel: () => this.closeQuickCreate(), + onStepChange: (step) => (this.quickCreateStep = step), + onDraftChange: (patch) => { + this.quickCreateDraft = { + ...(this.quickCreateDraft ?? createDefaultDraft()), + ...patch, + }; + }, + onCreate: () => void this.createFromQuickCreate(), + onAdvancedCreate: () => { + this.draftToForm(); + this.quickCreateOpen = false; + this.quickCreateStep = "what"; + this.quickCreateDraft = null; + this.cron.cronFormCollapsed = false; + this.requestCronUpdate(); + }, + })} + ${renderCron({ + basePath: this.context.basePath, + loading: this.cron.cronLoading, + status: this.cron.cronStatus, + jobs: getVisibleCronJobs(this.cron), + jobsLoadingMore: this.cron.cronJobsLoadingMore, + jobsTotal: this.cron.cronJobsTotal, + jobsHasMore: this.cron.cronJobsHasMore, + jobsQuery: this.cron.cronJobsQuery, + jobsEnabledFilter: this.cron.cronJobsEnabledFilter, + jobsScheduleKindFilter: this.cron.cronJobsScheduleKindFilter, + jobsLastStatusFilter: this.cron.cronJobsLastStatusFilter, + jobsSortBy: this.cron.cronJobsSortBy, + jobsSortDir: this.cron.cronJobsSortDir, + editingJobId: this.cron.cronEditingJobId, + error: this.cron.cronError, + busy: this.cron.cronBusy, + form: this.cron.cronForm, + cronFormCollapsed: this.cron.cronFormCollapsed, + channels: channels.channelsSnapshot?.channelMeta?.length + ? channels.channelsSnapshot.channelMeta.map((entry) => entry.id) + : (channels.channelsSnapshot?.channelOrder ?? []), + channelLabels: channels.channelsSnapshot?.channelLabels ?? {}, + channelMeta: channels.channelsSnapshot?.channelMeta ?? [], + runsJobId: this.cron.cronRunsJobId, + runs: this.cron.cronRuns, + runsTotal: this.cron.cronRunsTotal, + runsHasMore: this.cron.cronRunsHasMore, + runsLoadingMore: this.cron.cronRunsLoadingMore, + runsScope: this.cron.cronRunsScope, + runsStatuses: this.cron.cronRunsStatuses, + runsDeliveryStatuses: this.cron.cronRunsDeliveryStatuses, + runsStatusFilter: this.cron.cronRunsStatusFilter, + runsQuery: this.cron.cronRunsQuery, + runsSortDir: this.cron.cronRunsSortDir, + fieldErrors: this.cron.cronFieldErrors, + canSubmit: !hasCronFormErrors(this.cron.cronFieldErrors), + agentSuggestions: suggestions.agentSuggestions, + modelSuggestions: suggestions.modelSuggestions, + thinkingSuggestions: THINKING_SUGGESTIONS, + timezoneSuggestions: TIMEZONE_SUGGESTIONS, + deliveryToSuggestions: suggestions.deliveryToSuggestions, + accountSuggestions: suggestions.accountTargets, + onFormChange: (patch) => { + this.cron.cronForm = normalizeCronFormState({ ...this.cron.cronForm, ...patch }); + this.cron.cronFieldErrors = validateCronForm(this.cron.cronForm); + this.requestCronUpdate(); + }, + onRefresh: () => void this.refreshCron({ tableFilters: true }), + onAdd: () => + void this.runCronTask(async (cronState) => { + if (await addCronJob(cronState)) { + cronState.cronFormCollapsed = true; + } + }), + onEdit: (job) => this.editJob(job), + onClone: (job) => this.cloneJob(job), + onCancelEdit: () => { + cancelCronEdit(this.cron); + this.cron.cronFormCollapsed = true; + this.requestCronUpdate(); + }, + onToggleFormCollapsed: (collapsed) => { + this.cron.cronFormCollapsed = collapsed; + this.requestCronUpdate(); + }, + onToggle: (job, enabled) => + void this.runCronTask((cronState) => toggleCronJob(cronState, job, enabled)), + onRun: (job, mode) => + void this.runCronTask((cronState) => runCronJob(cronState, job, mode ?? "force")), + onRemove: (job) => void this.runCronTask((cronState) => removeCronJob(cronState, job)), + onQuickCreate: () => this.openQuickCreate(), + onLoadRuns: (jobId) => + void this.runCronTask(async (cronState) => { + updateCronRunsFilter(cronState, { cronRunsScope: "job" }); + await loadCronRuns(cronState, jobId); + }), + onLoadMoreJobs: () => + void this.runCronTask((cronState) => + loadCronJobsPage(cronState, { append: true, tableFilters: true }), + ), + onJobsFiltersChange: (patch) => + void this.runCronTask(async (cronState) => { + updateCronJobsFilter(cronState, patch); + await loadCronJobsPage(cronState, { append: false, tableFilters: true }); + }), + onJobsFiltersReset: () => + void this.runCronTask(async (cronState) => { + updateCronJobsFilter(cronState, { + cronJobsQuery: "", + cronJobsEnabledFilter: "all", + cronJobsScheduleKindFilter: "all", + cronJobsLastStatusFilter: "all", + cronJobsSortBy: "nextRunAtMs", + cronJobsSortDir: "asc", + }); + await loadCronJobsPage(cronState, { append: false, tableFilters: true }); + }), + onLoadMoreRuns: () => void this.runCronTask((cronState) => loadMoreCronRuns(cronState)), + onRunsFiltersChange: (patch) => + void this.runCronTask(async (cronState) => { + updateCronRunsFilter(cronState, patch); + await loadCronRuns( + cronState, + cronState.cronRunsScope === "all" ? null : cronState.cronRunsJobId, + ); + }), + onNavigateToChat: (sessionKey) => + this.context.navigate("chat", { search: searchForSession(sessionKey) }), + })} + `, + "cron", + (routeId) => this.context.navigate(routeId), + (routeId) => this.context.preload(routeId), + )} + `; + } +} + +customElements.define("openclaw-cron-page", CronPage); diff --git a/ui/src/ui/views/cron-quick-create.node.test.ts b/ui/src/pages/cron/quick-create.node.test.ts similarity index 98% rename from ui/src/ui/views/cron-quick-create.node.test.ts rename to ui/src/pages/cron/quick-create.node.test.ts index b9b940a645e3..3c31aabe7aa7 100644 --- a/ui/src/ui/views/cron-quick-create.node.test.ts +++ b/ui/src/pages/cron/quick-create.node.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { draftToCronFormPatch, type CronQuickCreateDraft } from "./cron-quick-create.ts"; +import { draftToCronFormPatch, type CronQuickCreateDraft } from "./quick-create.ts"; function createDraft(overrides: Partial = {}): CronQuickCreateDraft { return { diff --git a/ui/src/ui/views/cron-quick-create.ts b/ui/src/pages/cron/quick-create.ts similarity index 99% rename from ui/src/ui/views/cron-quick-create.ts rename to ui/src/pages/cron/quick-create.ts index 776d840f1fa5..48a07453ab12 100644 --- a/ui/src/ui/views/cron-quick-create.ts +++ b/ui/src/pages/cron/quick-create.ts @@ -7,9 +7,9 @@ */ import { html, nothing } from "lit"; +import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; -import type { CronFormState } from "../ui-types.ts"; +import type { CronFormState } from "../../lib/cron/index.ts"; // ── Types ── diff --git a/ui/src/pages/cron/route.ts b/ui/src/pages/cron/route.ts new file mode 100644 index 000000000000..99ff5d58a622 --- /dev/null +++ b/ui/src/pages/cron/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; + +export const page = definePage({ + id: "cron", + path: "/cron", + component: () => + import("./cron-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/pages/cron/view.test.ts similarity index 99% rename from ui/src/ui/views/cron.test.ts rename to ui/src/pages/cron/view.test.ts index 48a570ca5bfc..0e403e5d6e93 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -1,10 +1,10 @@ // Control UI tests cover cron behavior. import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; -import type { CronJob } from "../types.ts"; -import { createDefaultDraft, renderCronQuickCreate } from "./cron-quick-create.ts"; -import { renderCron, type CronProps } from "./cron.ts"; +import type { CronJob } from "../../api/types.ts"; +import { DEFAULT_CRON_FORM } from "../../lib/cron/index.ts"; +import { createDefaultDraft, renderCronQuickCreate } from "./quick-create.ts"; +import { renderCron, type CronProps } from "./view.ts"; function createJob(id: string): CronJob { return { diff --git a/ui/src/ui/views/cron.ts b/ui/src/pages/cron/view.ts similarity index 98% rename from ui/src/ui/views/cron.ts rename to ui/src/pages/cron/view.ts index aebdeaab623f..18d92ee45928 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/pages/cron/view.ts @@ -2,21 +2,7 @@ import { html, nothing } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { t } from "../../i18n/index.ts"; -import type { - CronFieldErrors, - CronFieldKey, - CronJobsLastStatusFilter, - CronJobsScheduleKindFilter, -} from "../controllers/cron.ts"; -import { getCronJobPayload } from "../cron-payload.ts"; -import { resolveCronJobLastRunStatus } from "../cron-status.ts"; -import { formatRelativeTimestamp, formatMs } from "../format.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; -import { pathForTab } from "../navigation.ts"; -import { formatCronSchedule, formatNextRun } from "../presenter.ts"; -import { normalizeStringEntries, uniqueStrings } from "../string-coerce.ts"; -import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; +import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../../api/types.ts"; import type { CronDeliveryStatus, CronJobsEnabledFilter, @@ -25,8 +11,24 @@ import type { CronJobsSortBy, CronRunsStatusFilter, CronSortDir, -} from "../types.ts"; -import type { CronFormState } from "../ui-types.ts"; +} from "../../api/types.ts"; +import { pathForRoute } from "../../app-route-paths.ts"; +import { toSanitizedMarkdownHtml } from "../../components/markdown.ts"; +import "../../components/tooltip.ts"; +import { t } from "../../i18n/index.ts"; +import { resolveCronJobLastRunStatus } from "../../lib/cron-status.ts"; +import type { + CronFieldErrors, + CronFieldKey, + CronJobsLastStatusFilter, + CronJobsScheduleKindFilter, +} from "../../lib/cron/index.ts"; +import { getCronJobPayload } from "../../lib/cron/index.ts"; +import type { CronFormState } from "../../lib/cron/index.ts"; +import { formatRelativeTimestamp, formatMs } from "../../lib/format.ts"; +import { formatCronSchedule, formatNextRun } from "../../lib/presenter.ts"; +import { searchForSession } from "../../lib/sessions/index.ts"; +import { normalizeStringEntries, uniqueStrings } from "../../lib/string-coerce.ts"; export type CronProps = { basePath: string; @@ -791,16 +793,17 @@ export function renderCron(props: CronProps) { `} - + + +
@@ -1880,7 +1883,7 @@ function renderRun( ) { const chatUrl = typeof entry.sessionKey === "string" && entry.sessionKey.trim().length > 0 - ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(entry.sessionKey)}` + ? `${pathForRoute("chat", basePath)}${searchForSession(entry.sessionKey)}` : null; const status = runStatusLabel(entry.status ?? "unknown"); const delivery = runDeliveryLabel(entry.deliveryStatus ?? "not-requested"); diff --git a/ui/src/pages/debug/debug-page.ts b/ui/src/pages/debug/debug-page.ts new file mode 100644 index 000000000000..6d55787d6c32 --- /dev/null +++ b/ui/src/pages/debug/debug-page.ts @@ -0,0 +1,197 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { state } from "lit/decorators.js"; +import type { EventLogEntry } from "../../api/event-log.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { HealthSnapshot, StatusSummary } from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { loadGatewayDiagnostics } from "../../lib/gateway-diagnostics.ts"; +import { renderDebug } from "./view.ts"; + +const DEBUG_POLL_INTERVAL_MS = 3000; + +export class DebugPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @state() private client: GatewayBrowserClient | null = null; + @state() private connected = false; + @state() private debugLoading = false; + @state() private debugStatus: StatusSummary | null = null; + @state() private debugHealth: HealthSnapshot | null = null; + @state() private debugModels: unknown[] = []; + @state() private debugHeartbeat: unknown = null; + @state() private debugCallMethod = ""; + @state() private debugCallParams = "{}"; + @state() private debugCallResult: string | null = null; + @state() private debugCallError: string | null = null; + @state() private eventLog: readonly EventLogEntry[] = []; + + private debugPollInterval: ReturnType | null = null; + private stopGatewaySubscription?: () => void; + private stopEventLogSubscription?: () => void; + + override connectedCallback() { + super.connectedCallback(); + this.eventLog = this.context.gateway.eventLog; + this.syncGatewayState(); + this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => { + const previousClient = this.client; + this.syncGatewayState(); + if (previousClient !== snapshot.client) { + this.resetServerState(); + } + this.syncPolling(); + this.ensureInitialDebug(); + }); + this.stopEventLogSubscription = this.context.gateway.subscribeEventLog((events) => { + this.eventLog = events; + }); + this.syncPolling(); + this.ensureInitialDebug(); + } + + override disconnectedCallback() { + this.stopPolling(); + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopEventLogSubscription?.(); + this.stopEventLogSubscription = undefined; + super.disconnectedCallback(); + } + + private syncGatewayState() { + const gateway = this.context.gateway.snapshot; + this.client = gateway.client; + this.connected = gateway.connected; + } + + private resetServerState() { + this.debugLoading = false; + this.debugStatus = null; + this.debugHealth = null; + this.debugModels = []; + this.debugHeartbeat = null; + this.debugCallResult = null; + this.debugCallError = null; + } + + private syncPolling() { + if (!this.connected || !this.client) { + this.stopPolling(); + return; + } + if (this.debugPollInterval !== null) { + return; + } + this.debugPollInterval = globalThis.setInterval(() => { + void this.loadDiagnostics(); + }, DEBUG_POLL_INTERVAL_MS); + } + + private stopPolling() { + if (this.debugPollInterval === null) { + return; + } + globalThis.clearInterval(this.debugPollInterval); + this.debugPollInterval = null; + } + + private ensureInitialDebug() { + if (!this.connected || !this.client || this.debugStatus || this.debugLoading) { + return; + } + void this.loadDiagnostics(); + } + + private async loadDiagnostics() { + const client = this.client; + if (!client || !this.connected || this.debugLoading) { + return; + } + this.debugLoading = true; + try { + const result = await loadGatewayDiagnostics(client); + if (this.client !== client || !this.connected) { + return; + } + this.debugStatus = result.status; + this.debugHealth = result.health; + this.debugModels = result.models; + this.debugHeartbeat = result.heartbeat; + } catch (err) { + if (this.client === client && this.connected) { + this.debugCallError = String(err); + } + } finally { + if (this.client === client) { + this.debugLoading = false; + } + } + } + + private async callDebugMethod() { + const client = this.client; + if (!client || !this.connected) { + return; + } + this.debugCallError = null; + this.debugCallResult = null; + try { + const params = this.debugCallParams.trim() + ? (JSON.parse(this.debugCallParams) as unknown) + : {}; + const res = await client.request(this.debugCallMethod.trim(), params); + if (this.client === client) { + this.debugCallResult = JSON.stringify(res, null, 2); + } + } catch (err) { + if (this.client === client) { + this.debugCallError = String(err); + } + } + } + + override render() { + const body = renderDebug({ + loading: this.debugLoading, + status: this.debugStatus, + health: this.debugHealth, + models: this.debugModels, + heartbeat: this.debugHeartbeat, + eventLog: this.eventLog, + methods: (this.context.gateway.snapshot.hello?.features?.methods ?? []).toSorted(), + callMethod: this.debugCallMethod, + callParams: this.debugCallParams, + callResult: this.debugCallResult, + callError: this.debugCallError, + onCallMethodChange: (next) => (this.debugCallMethod = next), + onCallParamsChange: (next) => (this.debugCallParams = next), + onRefresh: () => void this.loadDiagnostics(), + onCall: () => void this.callDebugMethod(), + }); + return html` +
+
+
${titleForRoute("debug")}
+
${subtitleForRoute("debug")}
+
+
+ ${renderSettingsWorkspace( + this.context.basePath, + body, + "debug", + (routeId) => this.context.navigate(routeId), + (routeId) => this.context.preload(routeId), + )} + `; + } +} + +customElements.define("openclaw-debug-page", DebugPage); diff --git a/ui/src/pages/debug/route.ts b/ui/src/pages/debug/route.ts new file mode 100644 index 000000000000..f7e451b172d5 --- /dev/null +++ b/ui/src/pages/debug/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; + +export const page = definePage({ + id: "debug", + path: "/debug", + component: () => + import("./debug-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/views/debug.test.ts b/ui/src/pages/debug/view.test.ts similarity index 97% rename from ui/src/ui/views/debug.test.ts rename to ui/src/pages/debug/view.test.ts index 0ab1f4b24280..714b9b73ab85 100644 --- a/ui/src/ui/views/debug.test.ts +++ b/ui/src/pages/debug/view.test.ts @@ -3,7 +3,7 @@ import { render } from "lit"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../../i18n/index.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; -import { renderDebug, type DebugProps } from "./debug.ts"; +import { renderDebug, type DebugProps } from "./view.ts"; function createProps(overrides: Partial = {}): DebugProps { return { diff --git a/ui/src/ui/views/debug.ts b/ui/src/pages/debug/view.ts similarity index 96% rename from ui/src/ui/views/debug.ts rename to ui/src/pages/debug/view.ts index b08e0a2ba6db..8343a15305bb 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/pages/debug/view.ts @@ -1,9 +1,9 @@ // Control UI view renders debug screen content. import { html, nothing } from "lit"; +import type { EventLogEntry } from "../../api/event-log.ts"; import { t } from "../../i18n/index.ts"; -import type { EventLogEntry } from "../app-events.ts"; -import { formatTimeMs } from "../format.ts"; -import { formatEventPayload } from "../presenter.ts"; +import { formatTimeMs } from "../../lib/format.ts"; +import { formatEventPayload } from "../../lib/presenter.ts"; export type DebugProps = { loading: boolean; @@ -11,7 +11,7 @@ export type DebugProps = { health: Record | null; models: unknown[]; heartbeat: unknown; - eventLog: EventLogEntry[]; + eventLog: readonly EventLogEntry[]; methods: string[]; callMethod: string; callParams: string; diff --git a/ui/src/ui/controllers/dreaming.test.ts b/ui/src/pages/dreams/dreaming.test.ts similarity index 92% rename from ui/src/ui/controllers/dreaming.test.ts rename to ui/src/pages/dreams/dreaming.test.ts index f42db042a765..f80481279b0f 100644 --- a/ui/src/ui/controllers/dreaming.test.ts +++ b/ui/src/pages/dreams/dreaming.test.ts @@ -1,8 +1,10 @@ // Control UI tests cover dreaming behavior. import { describe, expect, it, vi } from "vitest"; +import type { RuntimeConfigCapability } from "../../lib/config/index.ts"; import { backfillDreamDiary, copyDreamingArchivePath, + createDreamingState, dedupeDreamDiary, loadDreamDiary, loadDreamingStatus, @@ -17,40 +19,37 @@ import { } from "./dreaming.ts"; type TestRequest = (method: string, payload?: unknown) => Promise; +type DreamingConfigCapability = Pick< + RuntimeConfigCapability, + "lookupSchemaPath" | "patch" | "state" +>; function createState(): { state: DreamingState; request: ReturnType> } { const request = vi.fn(); const state: DreamingState = { + ...createDreamingState(), client: { request, } as unknown as DreamingState["client"], connected: true, - hello: null, configSnapshot: { hash: "hash-1" }, - applySessionKey: "main", - selectedAgentId: null, - dreamingStatusLoading: false, - dreamingStatusError: null, - dreamingStatus: null, - dreamingModeSaving: false, - dreamDiaryLoading: false, - dreamDiaryActionLoading: false, - dreamDiaryActionMessage: null, - dreamDiaryActionArchivePath: null, - dreamDiaryError: null, - dreamDiaryPath: null, - dreamDiaryContent: null, - wikiImportInsightsLoading: false, - wikiImportInsightsError: null, - wikiImportInsights: null, - wikiMemoryPalaceLoading: false, - wikiMemoryPalaceError: null, - wikiMemoryPalace: null, - lastError: null, }; return { state, request }; } +function createConfig(state: DreamingState): DreamingConfigCapability { + const configState = { + client: state.client, + connected: state.connected, + configSnapshot: state.configSnapshot, + } as DreamingConfigCapability["state"]; + return { + state: configState, + lookupSchemaPath: vi.fn(async () => null), + patch: vi.fn(async () => true), + }; +} + function createDeferred() { let resolve: ((value: T | PromiseLike) => void) | undefined; let reject: ((reason?: unknown) => void) | undefined; @@ -64,39 +63,12 @@ function createDeferred() { return { promise, resolve, reject }; } -function getConfigPatchRawPayload( - request: ReturnType>, -): Record { - const patchCall = request.mock.calls.find((entry) => entry[0] === "config.patch"); - if (!patchCall) { - throw new Error("Expected config.patch request"); +function getConfigPatchRawPayload(config: DreamingConfigCapability): Record { + const patch = vi.mocked(config.patch).mock.calls[0]?.[0]?.raw; + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + throw new Error("Expected config patch object"); } - const requestPayload = patchCall[1] as { raw?: string }; - return JSON.parse(String(requestPayload.raw)) as Record; -} - -function getRequestPayload( - request: ReturnType>, - method: string, -): Record { - const call = request.mock.calls.find((entry) => entry[0] === method); - if (!call) { - throw new Error(`Expected ${method} request`); - } - const payload = call[1]; - if ( - payload === undefined || - payload === null || - typeof payload !== "object" || - Array.isArray(payload) - ) { - throw new Error(`Expected ${method} payload object`); - } - return payload as Record; -} - -function hasRequestMethodCall(request: ReturnType, method: string): boolean { - return request.mock.calls.some((entry) => entry[0] === method); + return patch; } describe("dreaming controller", () => { @@ -775,14 +747,16 @@ describe("dreaming controller", () => { }, }; request.mockResolvedValue({ ok: true }); + const config = createConfig(state); - const ok = await updateDreamingEnabled(state, false); + const ok = await updateDreamingEnabled(state, config, false); expect(ok).toBe(true); - const patchPayload = getRequestPayload(request, "config.patch"); - expect(patchPayload.baseHash).toBe("hash-1"); - expect(patchPayload.sessionKey).toBe("main"); - expect(getConfigPatchRawPayload(request)).toEqual({ + expect(config.patch).toHaveBeenCalledWith({ + note: "Dreaming settings updated from the Dreaming tab.", + raw: expect.any(Object), + }); + expect(getConfigPatchRawPayload(config)).toEqual({ plugins: { entries: { "memos-local-openclaw-plugin": { @@ -812,11 +786,12 @@ describe("dreaming controller", () => { }, }; request.mockResolvedValue({ ok: true }); + const config = createConfig(state); - const ok = await updateDreamingEnabled(state, true); + const ok = await updateDreamingEnabled(state, config, true); expect(ok).toBe(true); - expect(getConfigPatchRawPayload(request)).toEqual({ + expect(getConfigPatchRawPayload(config)).toEqual({ plugins: { entries: { "memory-core": { @@ -832,7 +807,7 @@ describe("dreaming controller", () => { }); it("blocks dreaming patch when selected plugin config rejects unknown keys", async () => { - const { state, request } = createState(); + const { state } = createState(); state.configSnapshot = { hash: "hash-1", config: { @@ -843,32 +818,23 @@ describe("dreaming controller", () => { }, }, }; - request.mockImplementation(async (method: string) => { - if (method === "config.schema.lookup") { - return { - path: "plugins.entries.memory-lancedb.config", - schema: { - type: "object", - additionalProperties: false, - }, - children: [ - { key: "retentionDays", path: "plugins.entries.memory-lancedb.config.retentionDays" }, - ], - }; - } - if (method === "config.patch") { - return { ok: true }; - } - return {}; + const config = createConfig(state); + vi.mocked(config.lookupSchemaPath).mockResolvedValue({ + path: "plugins.entries.memory-lancedb.config", + schema: { + type: "object", + additionalProperties: false, + }, + children: [ + { key: "retentionDays", path: "plugins.entries.memory-lancedb.config.retentionDays" }, + ], }); - const ok = await updateDreamingEnabled(state, true); + const ok = await updateDreamingEnabled(state, config, true); expect(ok).toBe(false); - expect(request).toHaveBeenCalledWith("config.schema.lookup", { - path: "plugins.entries.memory-lancedb.config", - }); - expect(hasRequestMethodCall(request, "config.patch")).toBe(false); + expect(config.lookupSchemaPath).toHaveBeenCalledWith("plugins.entries.memory-lancedb.config"); + expect(config.patch).not.toHaveBeenCalled(); expect(state.dreamingStatusError).toBe( 'Selected memory plugin "memory-lancedb" does not support dreaming settings.', ); @@ -930,13 +896,15 @@ describe("dreaming controller", () => { }); it("fails gracefully when config hash is missing", async () => { - const { state, request } = createState(); + const { state } = createState(); state.configSnapshot = {}; + const config = createConfig(state); - const ok = await updateDreamingEnabled(state, true); + const ok = await updateDreamingEnabled(state, config, true); expect(ok).toBe(false); - expect(request).not.toHaveBeenCalled(); + expect(config.patch).not.toHaveBeenCalled(); + expect(config.lookupSchemaPath).not.toHaveBeenCalled(); expect(state.dreamingStatusError).toBe("Config hash missing; refresh and retry."); }); diff --git a/ui/src/ui/controllers/dreaming.ts b/ui/src/pages/dreams/dreaming.ts similarity index 94% rename from ui/src/ui/controllers/dreaming.ts rename to ui/src/pages/dreams/dreaming.ts index b1dc343dc61f..7a8b0a1d43df 100644 --- a/ui/src/ui/controllers/dreaming.ts +++ b/ui/src/pages/dreams/dreaming.ts @@ -1,8 +1,8 @@ -import { isGatewayMethodAdvertised } from "../gateway-methods.ts"; -// Control UI controller manages dreaming gateway state. -import type { GatewayBrowserClient, GatewayHelloOk } from "../gateway.ts"; -import { isPluginEnabledInConfigSnapshot } from "../plugin-activation.ts"; -import type { ConfigSnapshot } from "../types.ts"; +import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts"; +import type { ConfigSnapshot } from "../../api/types.ts"; +import type { RuntimeConfigCapability } from "../../lib/config/index.ts"; +import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; +import { isPluginEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts"; const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md"; const DEFAULT_DREAMING_PLUGIN_ID = "memory-core"; @@ -241,6 +241,47 @@ export type DreamingState = { lastError: string | null; }; +export function createDreamingState( + initial: Partial< + Pick< + DreamingState, + "client" | "connected" | "hello" | "configSnapshot" | "applySessionKey" | "selectedAgentId" + > + > = {}, +): DreamingState { + return { + client: initial.client ?? null, + connected: initial.connected ?? false, + hello: initial.hello ?? null, + configSnapshot: initial.configSnapshot ?? null, + applySessionKey: initial.applySessionKey ?? "main", + selectedAgentId: initial.selectedAgentId ?? null, + dreamingStatusLoading: false, + dreamingStatusError: null, + dreamingStatus: null, + dreamingModeSaving: false, + dreamDiaryLoading: false, + dreamDiaryActionLoading: false, + dreamDiaryActionMessage: null, + dreamDiaryActionArchivePath: null, + dreamDiaryError: null, + dreamDiaryPath: null, + dreamDiaryContent: null, + wikiImportInsightsLoading: false, + wikiImportInsightsError: null, + wikiImportInsights: null, + wikiMemoryPalaceLoading: false, + wikiMemoryPalaceError: null, + wikiMemoryPalace: null, + lastError: null, + }; +} + +type DreamingConfigCapability = Pick< + RuntimeConfigCapability, + "lookupSchemaPath" | "patch" | "state" +>; + function confirmDreamingAction(message: string): boolean { if (typeof globalThis.confirm !== "function") { return true; @@ -1062,35 +1103,25 @@ export async function dedupeDreamDiary(state: DreamingState): Promise { async function writeDreamingPatch( state: DreamingState, + config: DreamingConfigCapability, patch: Record, ): Promise { - if (!state.client || !state.connected) { - return false; - } if (state.dreamingModeSaving) { return false; } - const baseHash = state.configSnapshot?.hash; - if (!baseHash) { - state.dreamingStatusError = "Config hash missing; refresh and retry."; - return false; - } state.dreamingModeSaving = true; state.dreamingStatusError = null; try { - await state.client.request("config.patch", { - baseHash, - raw: JSON.stringify(patch), - sessionKey: state.applySessionKey, + const updated = await config.patch({ + raw: patch, note: "Dreaming settings updated from the Dreaming tab.", }); - return true; - } catch (err) { - const message = String(err); - state.dreamingStatusError = message; - state.lastError = message; - return false; + if (!updated) { + state.dreamingStatusError = + config.state.lastError ?? state.lastError ?? "Could not update dreaming settings."; + } + return updated; } finally { state.dreamingModeSaving = false; } @@ -1116,15 +1147,14 @@ function lookupDisallowsUnknownProperties(value: unknown): boolean { async function ensureDreamingPathSupported( state: DreamingState, + config: DreamingConfigCapability, pluginId: string, ): Promise { - if (!state.client || !state.connected) { + if (!config.state.client || !config.state.connected) { return true; } try { - const lookup = await state.client.request("config.schema.lookup", { - path: `plugins.entries.${pluginId}.config`, - }); + const lookup = await config.lookupSchemaPath(`plugins.entries.${pluginId}.config`); if (lookupIncludesDreamingProperty(lookup)) { return true; } @@ -1142,20 +1172,23 @@ async function ensureDreamingPathSupported( export async function updateDreamingEnabled( state: DreamingState, + config: DreamingConfigCapability, enabled: boolean, ): Promise { if (state.dreamingModeSaving) { return false; } - if (!state.configSnapshot?.hash) { + if (!config.state.configSnapshot?.hash) { state.dreamingStatusError = "Config hash missing; refresh and retry."; return false; } - const { pluginId } = resolveConfiguredDreaming(asRecord(state.configSnapshot?.config) ?? null); - if (!(await ensureDreamingPathSupported(state, pluginId))) { + const { pluginId } = resolveConfiguredDreaming( + asRecord(config.state.configSnapshot?.config) ?? null, + ); + if (!(await ensureDreamingPathSupported(state, config, pluginId))) { return false; } - const ok = await writeDreamingPatch(state, { + const ok = await writeDreamingPatch(state, config, { plugins: { entries: { [pluginId]: { diff --git a/ui/src/pages/dreams/dreams-page.ts b/ui/src/pages/dreams/dreams-page.ts new file mode 100644 index 000000000000..320f89661820 --- /dev/null +++ b/ui/src/pages/dreams/dreams-page.ts @@ -0,0 +1,465 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationGatewaySnapshot, +} from "../../app/context.ts"; +import { t } from "../../i18n/index.ts"; +import { currentConfigObject } from "../../lib/config/index.ts"; +import { formatTimeMs } from "../../lib/format.ts"; +import { isPluginEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts"; +import { + resolveSessionAgentFilterId, + resolveSessionAgentFilterOptions, +} from "../../lib/sessions/session-options.ts"; +import { + backfillDreamDiary, + copyDreamingArchivePath, + createDreamingState, + dedupeDreamDiary, + loadDreamDiary, + loadDreamingStatus, + loadWikiImportInsights, + loadWikiMemoryPalace, + repairDreamingArtifacts, + resetGroundedShortTerm, + resetDreamDiary, + resolveConfiguredDreaming, + updateDreamingEnabled, + type DreamingState, +} from "./dreaming.ts"; +import { renderDreamingRestartConfirmation } from "./restart-confirmation.ts"; +import { createDreamingViewState, renderDreaming, type DreamingViewState } from "./view.ts"; + +export type DreamsRouteData = { + state: DreamingState; +}; + +type WikiPagePreview = { + title: string; + path: string; + content: string; + totalLines?: number; + truncated?: boolean; + updatedAt?: string; +}; + +function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null { + return formatTimeMs(nextRunAtMs, { hour: "numeric", minute: "2-digit" }, "") || null; +} + +function resolveDreamingNextCycle(status: DreamingState["dreamingStatus"]): string | null { + const nextRunAtMs = Object.values(status?.phases ?? {}) + .filter((phase) => phase.enabled && typeof phase.nextRunAtMs === "number") + .map((phase) => phase.nextRunAtMs as number) + .toSorted((a, b) => a - b)[0]; + return nextRunAtMs === undefined ? null : formatDreamNextCycle(nextRunAtMs); +} + +function readWikiPagePreview(value: unknown, lookup: string): WikiPagePreview { + const payload = + value && typeof value === "object" + ? (value as { + title?: unknown; + path?: unknown; + content?: unknown; + updatedAt?: unknown; + totalLines?: unknown; + truncated?: unknown; + }) + : null; + const title = + typeof payload?.title === "string" && payload.title.trim() ? payload.title.trim() : lookup; + const path = + typeof payload?.path === "string" && payload.path.trim() ? payload.path.trim() : lookup; + const content = + typeof payload?.content === "string" && payload.content.length > 0 + ? payload.content + : "No wiki content available."; + const updatedAt = + typeof payload?.updatedAt === "string" && payload.updatedAt.trim() + ? payload.updatedAt.trim() + : undefined; + const totalLines = + typeof payload?.totalLines === "number" && Number.isFinite(payload.totalLines) + ? Math.max(0, Math.floor(payload.totalLines)) + : undefined; + return { + title, + path, + content, + ...(totalLines === undefined ? {} : { totalLines }), + ...(payload?.truncated === true ? { truncated: true } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; +} + +export class DreamsPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @property({ attribute: false }) routeData?: DreamsRouteData; + + @state() private dreaming = createDreamingState(); + @state() private awaitingRouteData = true; + @state() private restartConfirmOpen = false; + @state() private restartConfirmLoading = false; + @state() private pendingEnabled: boolean | null = null; + + private readonly viewState: DreamingViewState = createDreamingViewState(); + private routeDataEnabled = true; + private subscriptions: Array<() => void> = []; + + override connectedCallback() { + super.connectedCallback(); + this.applyGatewaySnapshot(this.context.gateway.snapshot, true); + this.syncConfigSnapshot(); + this.subscriptions = [ + this.context.gateway.subscribe((snapshot) => this.applyGatewaySnapshot(snapshot)), + this.context.agents.subscribe(() => this.applyAgentsState()), + this.context.runtimeConfig.subscribe(() => { + this.syncConfigSnapshot(); + this.requestUpdate(); + }), + ]; + } + + override willUpdate(changed: Map) { + if (changed.has("routeData")) { + this.applyRouteData(); + } + } + + override disconnectedCallback() { + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + this.subscriptions = []; + this.viewState.wikiPreviewRequestId += 1; + this.dreaming = createDreamingState(); + super.disconnectedCallback(); + } + + private createGatewayState(snapshot = this.context.gateway.snapshot): DreamingState { + return createDreamingState({ + client: snapshot.client, + connected: snapshot.connected, + hello: snapshot.hello, + configSnapshot: this.context.runtimeConfig.state.configSnapshot, + applySessionKey: snapshot.sessionKey, + selectedAgentId: this.resolveSelectedAgentId(), + }); + } + + private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, initial = false) { + const clientChanged = this.dreaming.client !== snapshot.client; + const becameConnected = snapshot.connected && !this.dreaming.connected; + if (clientChanged) { + this.dreaming = this.createGatewayState(snapshot); + if (!initial) { + this.routeDataEnabled = false; + this.awaitingRouteData = false; + } + } else { + this.dreaming.connected = snapshot.connected; + this.dreaming.hello = snapshot.hello; + this.dreaming.applySessionKey = snapshot.sessionKey; + } + if (!this.awaitingRouteData && snapshot.connected && (clientChanged || becameConnected)) { + void this.loadAll(); + } + this.requestUpdate(); + } + + private applyAgentsState() { + const agentsList = this.context.agents.state.agentsList; + const selected = this.dreaming.selectedAgentId; + if (agentsList && (!selected || !agentsList.agents.some((agent) => agent.id === selected))) { + this.dreaming.selectedAgentId = this.resolveSelectedAgentId(); + if (!this.awaitingRouteData) { + this.routeDataEnabled = false; + this.loadSelectedAgentData(); + } + } + this.requestUpdate(); + } + + private applyRouteData() { + const data = this.routeData; + if (!data) { + return; + } + this.awaitingRouteData = false; + if (!this.routeDataEnabled) { + return; + } + const gateway = this.context.gateway.snapshot; + if (data.state.client !== gateway.client || data.state.connected !== gateway.connected) { + this.routeDataEnabled = false; + this.dreaming = this.createGatewayState(gateway); + void this.loadAll(); + return; + } + this.dreaming = { + ...data.state, + configSnapshot: this.context.runtimeConfig.state.configSnapshot ?? data.state.configSnapshot, + }; + } + + private syncConfigSnapshot() { + this.dreaming.configSnapshot = this.context.runtimeConfig.state.configSnapshot; + } + + private resolveSelectedAgentId(): string { + const sessionKey = this.context.gateway.snapshot.sessionKey; + return resolveSessionAgentFilterId( + { + agentsList: this.context.agents.state.agentsList, + sessionKey, + }, + sessionKey, + ); + } + + private resolveAgentOptions() { + const sessionKey = this.context.gateway.snapshot.sessionKey; + return resolveSessionAgentFilterOptions({ + agentsList: this.context.agents.state.agentsList, + sessionKey, + }); + } + + private async runDreamingTask(task: (state: DreamingState) => Promise): Promise { + const dreamingState = this.dreaming; + const result = task(dreamingState); + this.requestUpdate(); + try { + return await result; + } finally { + if (this.dreaming === dreamingState) { + this.requestUpdate(); + } + } + } + + private async loadAll(refreshConfig = false) { + if (!this.dreaming.client || !this.dreaming.connected) { + return; + } + this.routeDataEnabled = false; + if (refreshConfig) { + await this.context.runtimeConfig.refresh(); + if (!this.dreaming.client || !this.dreaming.connected) { + return; + } + } + this.syncConfigSnapshot(); + await Promise.all([ + this.runDreamingTask(loadDreamingStatus), + this.runDreamingTask(loadDreamDiary), + this.runDreamingTask(loadWikiImportInsights), + this.runDreamingTask(loadWikiMemoryPalace), + ]); + } + + private loadSelectedAgentData() { + void Promise.all([ + this.runDreamingTask(loadDreamingStatus), + this.runDreamingTask(loadDreamDiary), + ]); + } + + private selectAgent(agentId: string) { + if (agentId === this.dreaming.selectedAgentId) { + return; + } + this.routeDataEnabled = false; + this.dreaming.selectedAgentId = agentId; + this.loadSelectedAgentData(); + } + + private setEnabled(enabled: boolean, dreamingOn: boolean) { + if ( + this.dreaming.dreamingModeSaving || + this.restartConfirmLoading || + this.restartConfirmOpen || + dreamingOn === enabled + ) { + return; + } + this.pendingEnabled = enabled; + this.restartConfirmOpen = true; + this.dreaming.dreamingStatusError = null; + } + + private cancelRestart() { + if (this.restartConfirmLoading) { + return; + } + this.restartConfirmOpen = false; + this.pendingEnabled = null; + this.dreaming.dreamingStatusError = null; + } + + private async confirmRestart() { + const enabled = this.pendingEnabled; + if (enabled == null || this.restartConfirmLoading) { + return; + } + this.routeDataEnabled = false; + this.restartConfirmLoading = true; + this.dreaming.dreamingStatusError = null; + try { + const updated = await this.runDreamingTask((dreamingState) => + updateDreamingEnabled(dreamingState, this.context.runtimeConfig, enabled), + ); + if (!updated) { + this.dreaming.dreamingStatusError ??= t("dreaming.restartConfirmation.failed"); + return; + } + await this.context.runtimeConfig.refresh(); + this.syncConfigSnapshot(); + await this.runDreamingTask(loadDreamingStatus); + this.restartConfirmOpen = false; + this.pendingEnabled = null; + } finally { + this.restartConfirmLoading = false; + } + } + + private async openWikiPage(lookup: string): Promise { + const client = this.dreaming.client; + if (!client || !this.dreaming.connected) { + return null; + } + const payload = await client.request("wiki.get", { + lookup, + fromLine: 1, + lineCount: 5000, + }); + if (this.dreaming.client !== client || !this.dreaming.connected) { + return null; + } + return readWikiPagePreview(payload, lookup); + } + + override render() { + const dreaming = this.dreaming; + const configState = this.context.runtimeConfig.state; + const dreamingOn = + dreaming.dreamingStatus?.enabled ?? + resolveConfiguredDreaming(currentConfigObject(configState)).enabled; + const loading = + this.awaitingRouteData || dreaming.dreamingStatusLoading || dreaming.dreamingModeSaving; + const refreshLoading = + this.awaitingRouteData || dreaming.dreamingStatusLoading || dreaming.dreamDiaryLoading; + const selectedAgentId = dreaming.selectedAgentId ?? this.resolveSelectedAgentId(); + + return html` +
+
+
${titleForRoute("dreams")}
+
${subtitleForRoute("dreams")}
+
+
+
+ + +
+
+
+ ${renderDreaming({ + viewState: this.viewState, + active: dreamingOn, + selectedAgentId, + agentOptions: this.resolveAgentOptions(), + shortTermCount: dreaming.dreamingStatus?.shortTermCount ?? 0, + groundedSignalCount: dreaming.dreamingStatus?.groundedSignalCount ?? 0, + totalSignalCount: dreaming.dreamingStatus?.totalSignalCount ?? 0, + promotedCount: dreaming.dreamingStatus?.promotedToday ?? 0, + phases: dreaming.dreamingStatus?.phases ?? undefined, + shortTermEntries: dreaming.dreamingStatus?.shortTermEntries ?? [], + promotedEntries: dreaming.dreamingStatus?.promotedEntries ?? [], + dreamingOf: null, + nextCycle: resolveDreamingNextCycle(dreaming.dreamingStatus), + timezone: dreaming.dreamingStatus?.timezone ?? null, + statusLoading: this.awaitingRouteData || dreaming.dreamingStatusLoading, + statusError: dreaming.dreamingStatusError, + modeSaving: dreaming.dreamingModeSaving, + dreamDiaryLoading: this.awaitingRouteData || dreaming.dreamDiaryLoading, + dreamDiaryActionLoading: dreaming.dreamDiaryActionLoading, + dreamDiaryActionMessage: dreaming.dreamDiaryActionMessage, + dreamDiaryActionArchivePath: dreaming.dreamDiaryActionArchivePath, + dreamDiaryError: dreaming.dreamDiaryError, + dreamDiaryPath: dreaming.dreamDiaryPath, + dreamDiaryContent: dreaming.dreamDiaryContent, + memoryWikiEnabled: isPluginEnabledInConfigSnapshot( + configState.configSnapshot, + "memory-wiki", + { enabledByDefault: false }, + ), + wikiImportInsightsLoading: this.awaitingRouteData || dreaming.wikiImportInsightsLoading, + wikiImportInsightsError: dreaming.wikiImportInsightsError, + wikiImportInsights: dreaming.wikiImportInsights, + wikiMemoryPalaceLoading: this.awaitingRouteData || dreaming.wikiMemoryPalaceLoading, + wikiMemoryPalaceError: dreaming.wikiMemoryPalaceError, + wikiMemoryPalace: dreaming.wikiMemoryPalace, + onRefresh: () => void this.loadAll(true), + onSelectAgent: (agentId) => this.selectAgent(agentId), + onRefreshDiary: () => void this.runDreamingTask(loadDreamDiary), + onRefreshImports: () => + void this.context.runtimeConfig.refresh().then(() => { + this.syncConfigSnapshot(); + return this.runDreamingTask(loadWikiImportInsights); + }), + onRefreshMemoryPalace: () => + void this.context.runtimeConfig.refresh().then(() => { + this.syncConfigSnapshot(); + return this.runDreamingTask(loadWikiMemoryPalace); + }), + onOpenConfig: () => void this.context.runtimeConfig.openFile(), + onOpenWikiPage: (lookup) => this.openWikiPage(lookup), + onBackfillDiary: () => void this.runDreamingTask(backfillDreamDiary), + onCopyDreamingArchivePath: () => void this.runDreamingTask(copyDreamingArchivePath), + onDedupeDreamDiary: () => void this.runDreamingTask(dedupeDreamDiary), + onResetDiary: () => void this.runDreamingTask(resetDreamDiary), + onResetGroundedShortTerm: () => void this.runDreamingTask(resetGroundedShortTerm), + onRepairDreamingArtifacts: () => void this.runDreamingTask(repairDreamingArtifacts), + onViewStateChange: () => this.requestUpdate(), + })} + ${renderDreamingRestartConfirmation({ + open: this.restartConfirmOpen, + loading: this.restartConfirmLoading, + onConfirm: () => void this.confirmRestart(), + onCancel: () => this.cancelRestart(), + hasError: Boolean(dreaming.dreamingStatusError), + })} + `; + } +} + +if (!customElements.get("openclaw-dreams-page")) { + customElements.define("openclaw-dreams-page", DreamsPage); +} diff --git a/ui/src/ui/views/dreaming-restart-confirmation.ts b/ui/src/pages/dreams/restart-confirmation.ts similarity index 97% rename from ui/src/ui/views/dreaming-restart-confirmation.ts rename to ui/src/pages/dreams/restart-confirmation.ts index 25cfffbb53ae..1d9236c452de 100644 --- a/ui/src/ui/views/dreaming-restart-confirmation.ts +++ b/ui/src/pages/dreams/restart-confirmation.ts @@ -1,7 +1,7 @@ // Control UI view renders dreaming restart confirmation screen content. import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; -import "../components/modal-dialog.ts"; +import "../../components/modal-dialog.ts"; type DreamingRestartConfirmationProps = { open: boolean; diff --git a/ui/src/pages/dreams/route.ts b/ui/src/pages/dreams/route.ts new file mode 100644 index 000000000000..11d95907f69b --- /dev/null +++ b/ui/src/pages/dreams/route.ts @@ -0,0 +1,52 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; +import { resolveSessionAgentFilterId } from "../../lib/sessions/session-options.ts"; +import { + createDreamingState, + loadDreamDiary, + loadDreamingStatus, + loadWikiImportInsights, + loadWikiMemoryPalace, +} from "./dreaming.ts"; +import type { DreamsRouteData } from "./dreams-page.ts"; + +async function loadDreamsRoute(context: ApplicationContext): Promise { + await Promise.all([context.runtimeConfig.ensureLoaded(), context.agents.ensureList()]); + const gateway = context.gateway.snapshot; + const sessionKey = gateway.sessionKey; + const state = createDreamingState({ + client: gateway.client, + connected: gateway.connected, + hello: gateway.hello, + configSnapshot: context.runtimeConfig.state.configSnapshot, + applySessionKey: sessionKey, + selectedAgentId: resolveSessionAgentFilterId( + { + agentsList: context.agents.state.agentsList, + sessionKey, + }, + sessionKey, + ), + }); + await Promise.all([ + loadDreamingStatus(state), + loadDreamDiary(state), + loadWikiImportInsights(state), + loadWikiMemoryPalace(state), + ]); + return { state }; +} + +export const page = definePage({ + id: "dreams", + path: "/dreaming", + aliases: ["/dreams"], + loader: loadDreamsRoute, + component: () => + import("./dreams-page.ts").then(() => ({ + header: true, + render: (data: DreamsRouteData | undefined) => + html``, + })), +}); diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/pages/dreams/view.test.ts similarity index 97% rename from ui/src/ui/views/dreaming.test.ts rename to ui/src/pages/dreams/view.test.ts index 8a960c58118b..00b744d487e8 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/pages/dreams/view.test.ts @@ -1,17 +1,31 @@ /* @vitest-environment jsdom */ import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { + createDreamingViewState, renderDreaming, - setDreamAdvancedWaitingSort, - setDreamDiarySubTab, - setDreamSubTab, type DreamingProps, -} from "./dreaming.ts"; + type DreamingViewState, +} from "./view.ts"; + +let viewState = createDreamingViewState(); + +function setDreamSubTab(tab: DreamingViewState["activeSubTab"]) { + viewState.activeSubTab = tab; +} + +function setDreamDiarySubTab(tab: DreamingViewState["activeDiarySubTab"]) { + viewState.activeDiarySubTab = tab; +} + +function setDreamAdvancedWaitingSort(sort: DreamingViewState["advancedWaitingSort"]) { + viewState.advancedWaitingSort = sort; +} function buildProps(overrides?: Partial): DreamingProps { const props: DreamingProps = { + viewState, active: true, selectedAgentId: "main", agentOptions: [ @@ -201,6 +215,7 @@ function buildProps(overrides?: Partial): DreamingProps { onResetDiary: () => {}, onResetGroundedShortTerm: () => {}, onRepairDreamingArtifacts: () => {}, + onViewStateChange: () => {}, }; return { ...props, ...overrides }; } @@ -229,6 +244,10 @@ function textItems(container: Element, selector: string): Array { + beforeEach(() => { + viewState = createDreamingViewState(); + }); + it("renders the active dream scene chrome and status", () => { const container = renderInto(buildProps({ dreamingOf: "reindexing old chats\u2026" })); @@ -378,7 +397,7 @@ describe("dreaming view", () => { const rerender = () => render(renderDreaming(props), container); const props: DreamingProps = buildProps({ onOpenWikiPage, - onRequestUpdate: rerender, + onViewStateChange: rerender, }); rerender(); @@ -438,7 +457,7 @@ describe("dreaming view", () => { setDreamDiarySubTab("palace"); const container = document.createElement("div"); const rerender = () => render(renderDreaming(props), container); - const props: DreamingProps = buildProps({ onRequestUpdate: rerender }); + const props: DreamingProps = buildProps({ onViewStateChange: rerender }); rerender(); const card = expectElement(container, "[data-palace-page='syntheses/travel-system.md']"); @@ -463,7 +482,7 @@ describe("dreaming view", () => { const rerender = () => render(renderDreaming(props), container); const props: DreamingProps = buildProps({ onOpenWikiPage, - onRequestUpdate: rerender, + onViewStateChange: rerender, wikiMemoryPalace: { totalItems: 1, totalPages: 1, @@ -877,5 +896,5 @@ describe("dreaming view", () => { setDreamSubTab("scene"); }); - // Toggle lives in the page header (app-render.ts), not inside the dreaming view. + // Toggle lives in the route header, not inside the dreaming view. }); diff --git a/ui/src/ui/views/dreaming.ts b/ui/src/pages/dreams/view.ts similarity index 85% rename from ui/src/ui/views/dreaming.ts rename to ui/src/pages/dreams/view.ts index ec8fdf8616c2..cddab02db8c5 100644 --- a/ui/src/ui/views/dreaming.ts +++ b/ui/src/pages/dreams/view.ts @@ -2,13 +2,9 @@ import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { toSanitizedMarkdownHtml } from "../../components/markdown.ts"; import { t } from "../../i18n/index.ts"; -import type { - DreamingEntry, - WikiImportInsights, - WikiMemoryPalace, -} from "../controllers/dreaming.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; +import type { DreamingEntry, WikiImportInsights, WikiMemoryPalace } from "./dreaming.ts"; // ── Diary entry parser ───────────────────────────────────────────────── @@ -99,6 +95,7 @@ type DreamingAgentOption = { }; export type DreamingProps = { + viewState: DreamingViewState; active: boolean; selectedAgentId: string; agentOptions: DreamingAgentOption[]; @@ -153,7 +150,7 @@ export type DreamingProps = { onResetDiary: () => void; onResetGroundedShortTerm: () => void; onRepairDreamingArtifacts: () => void; - onRequestUpdate?: () => void; + onViewStateChange: () => void; }; const DREAM_PHRASE_KEYS = [ @@ -182,59 +179,65 @@ const DREAM_PHASE_LABEL_KEYS = { rem: "dreaming.phase.rem", } as const; -let dreamIndex = Math.floor(Math.random() * DREAM_PHRASE_KEYS.length); -let dreamLastSwap = 0; const DREAM_SWAP_MS = 6_000; // ── Sub-tab state ───────────────────────────────────────────────────── -type DreamSubTab = "scene" | "diary" | "advanced"; -let activeSubTab: DreamSubTab = "scene"; -type DreamDiarySubTab = "dreams" | "insights" | "palace"; -let activeDiarySubTab: DreamDiarySubTab = "dreams"; -type AdvancedWaitingSort = "recent" | "signals"; -let advancedWaitingSort: AdvancedWaitingSort = "recent"; -const expandedInsightCards = new Set(); -const expandedPalaceCards = new Set(); -let wikiPreviewOpen = false; -let wikiPreviewLoading = false; -let wikiPreviewTitle = ""; -let wikiPreviewPath = ""; -let wikiPreviewUpdatedAt: string | null = null; -let wikiPreviewContent = ""; -let wikiPreviewTotalLines: number | null = null; -let wikiPreviewTruncated = false; -let wikiPreviewError: string | null = null; +export type DreamingViewState = { + dreamIndex: number; + dreamLastSwap: number; + activeSubTab: "scene" | "diary" | "advanced"; + activeDiarySubTab: "dreams" | "insights" | "palace"; + advancedWaitingSort: "recent" | "signals"; + expandedInsightCards: Set; + expandedPalaceCards: Set; + diaryPage: number; + wikiPreviewRequestId: number; + wikiPreviewOpen: boolean; + wikiPreviewLoading: boolean; + wikiPreviewTitle: string; + wikiPreviewPath: string; + wikiPreviewUpdatedAt: string | null; + wikiPreviewContent: string; + wikiPreviewTotalLines: number | null; + wikiPreviewTruncated: boolean; + wikiPreviewError: string | null; +}; -export function setDreamSubTab(tab: DreamSubTab): void { - activeSubTab = tab; +export function createDreamingViewState(): DreamingViewState { + return { + dreamIndex: Math.floor(Math.random() * DREAM_PHRASE_KEYS.length), + dreamLastSwap: 0, + activeSubTab: "scene", + activeDiarySubTab: "dreams", + advancedWaitingSort: "recent", + expandedInsightCards: new Set(), + expandedPalaceCards: new Set(), + diaryPage: 0, + wikiPreviewRequestId: 0, + wikiPreviewOpen: false, + wikiPreviewLoading: false, + wikiPreviewTitle: "", + wikiPreviewPath: "", + wikiPreviewUpdatedAt: null, + wikiPreviewContent: "", + wikiPreviewTotalLines: null, + wikiPreviewTruncated: false, + wikiPreviewError: null, + }; } -export function setDreamAdvancedWaitingSort(sort: AdvancedWaitingSort): void { - advancedWaitingSort = sort; +function setDiaryPage(state: DreamingViewState, page: number, entryCount: number): void { + state.diaryPage = Math.max(0, Math.min(page, Math.max(0, entryCount - 1))); } -export function setDreamDiarySubTab(tab: DreamDiarySubTab): void { - activeDiarySubTab = tab; -} - -// ── Diary pagination state ───────────────────────────────────────────── - -let diaryPage = 0; -let diaryEntryCount = 0; - -/** Navigate to a specific diary page. Triggers a re-render via Lit's reactive cycle. */ -export function setDiaryPage(page: number): void { - diaryPage = Math.max(0, Math.min(page, Math.max(0, diaryEntryCount - 1))); -} - -function currentDreamPhrase(): string { +function currentDreamPhrase(state: DreamingViewState): string { const now = Date.now(); - if (now - dreamLastSwap > DREAM_SWAP_MS) { - dreamLastSwap = now; - dreamIndex = (dreamIndex + 1) % DREAM_PHRASE_KEYS.length; + if (now - state.dreamLastSwap > DREAM_SWAP_MS) { + state.dreamLastSwap = now; + state.dreamIndex = (state.dreamIndex + 1) % DREAM_PHRASE_KEYS.length; } - return t(DREAM_PHRASE_KEYS[dreamIndex] ?? DREAM_PHRASE_KEYS[0]); + return t(DREAM_PHRASE_KEYS[state.dreamIndex] ?? DREAM_PHRASE_KEYS[0]); } const STARS: { @@ -295,8 +298,9 @@ const sleepingLobster = html` `; export function renderDreaming(props: DreamingProps) { + const state = props.viewState; const idle = !props.active; - const dreamText = props.dreamingOf ?? currentDreamPhrase(); + const dreamText = props.dreamingOf ?? currentDreamPhrase(state); return html`
@@ -304,28 +308,28 @@ export function renderDreaming(props: DreamingProps) {
- ${activeSubTab === "scene" + ${state.activeSubTab === "scene" ? renderScene(props, idle, dreamText) - : activeSubTab === "diary" + : state.activeSubTab === "diary" ? renderDiarySection(props) : renderAdvancedSection(props)}
@@ -607,100 +611,111 @@ function formatImportBadge(item: { return "unknown risk"; } -function toggleExpandedCard(bucket: Set, key: string, requestUpdate?: () => void): void { +function toggleExpandedCard(bucket: Set, key: string, onChange: () => void): void { if (bucket.has(key)) { bucket.delete(key); } else { bucket.add(key); } - requestUpdate?.(); + onChange(); } async function openWikiPreview(lookup: string, props: DreamingProps): Promise { - wikiPreviewOpen = true; - wikiPreviewLoading = true; - wikiPreviewTitle = basename(lookup); - wikiPreviewPath = lookup; - wikiPreviewUpdatedAt = null; - wikiPreviewContent = ""; - wikiPreviewTotalLines = null; - wikiPreviewTruncated = false; - wikiPreviewError = null; - props.onRequestUpdate?.(); + const state = props.viewState; + const requestId = ++state.wikiPreviewRequestId; + state.wikiPreviewOpen = true; + state.wikiPreviewLoading = true; + state.wikiPreviewTitle = basename(lookup); + state.wikiPreviewPath = lookup; + state.wikiPreviewUpdatedAt = null; + state.wikiPreviewContent = ""; + state.wikiPreviewTotalLines = null; + state.wikiPreviewTruncated = false; + state.wikiPreviewError = null; + props.onViewStateChange(); try { const preview = await props.onOpenWikiPage(lookup); - if (!preview) { - wikiPreviewError = `No wiki page found for ${lookup}.`; + if (state.wikiPreviewRequestId !== requestId || !state.wikiPreviewOpen) { return; } - wikiPreviewTitle = preview.title; - wikiPreviewPath = preview.path; - wikiPreviewUpdatedAt = preview.updatedAt ?? null; - wikiPreviewContent = preview.content; - wikiPreviewTotalLines = typeof preview.totalLines === "number" ? preview.totalLines : null; - wikiPreviewTruncated = preview.truncated === true; + if (!preview) { + state.wikiPreviewError = `No wiki page found for ${lookup}.`; + return; + } + state.wikiPreviewTitle = preview.title; + state.wikiPreviewPath = preview.path; + state.wikiPreviewUpdatedAt = preview.updatedAt ?? null; + state.wikiPreviewContent = preview.content; + state.wikiPreviewTotalLines = + typeof preview.totalLines === "number" ? preview.totalLines : null; + state.wikiPreviewTruncated = preview.truncated === true; } catch (error) { - wikiPreviewError = String(error); + if (state.wikiPreviewRequestId === requestId && state.wikiPreviewOpen) { + state.wikiPreviewError = String(error); + } } finally { - wikiPreviewLoading = false; - props.onRequestUpdate?.(); + if (state.wikiPreviewRequestId === requestId && state.wikiPreviewOpen) { + state.wikiPreviewLoading = false; + props.onViewStateChange(); + } } } -function closeWikiPreview(requestUpdate?: () => void): void { - wikiPreviewOpen = false; - wikiPreviewLoading = false; - wikiPreviewTitle = ""; - wikiPreviewPath = ""; - wikiPreviewUpdatedAt = null; - wikiPreviewContent = ""; - wikiPreviewTotalLines = null; - wikiPreviewTruncated = false; - wikiPreviewError = null; - requestUpdate?.(); +function resetWikiPreview(state: DreamingViewState): void { + state.wikiPreviewRequestId += 1; + state.wikiPreviewOpen = false; + state.wikiPreviewLoading = false; + state.wikiPreviewTitle = ""; + state.wikiPreviewPath = ""; + state.wikiPreviewUpdatedAt = null; + state.wikiPreviewContent = ""; + state.wikiPreviewTotalLines = null; + state.wikiPreviewTruncated = false; + state.wikiPreviewError = null; +} + +function closeWikiPreview(props: DreamingProps): void { + resetWikiPreview(props.viewState); + props.onViewStateChange(); } function renderWikiPreviewOverlay(props: DreamingProps) { - if (!wikiPreviewOpen) { + const state = props.viewState; + if (!state.wikiPreviewOpen) { return nothing; } return html` -
closeWikiPreview(props.onRequestUpdate)} - > +
closeWikiPreview(props)}>
event.stopPropagation()}>
-
${wikiPreviewTitle || "Wiki page"}
+
${state.wikiPreviewTitle || "Wiki page"}
- ${wikiPreviewPath} ${wikiPreviewUpdatedAt ? ` · ${wikiPreviewUpdatedAt}` : ""} + ${state.wikiPreviewPath} + ${state.wikiPreviewUpdatedAt ? ` · ${state.wikiPreviewUpdatedAt}` : ""}
-
- ${wikiPreviewLoading + ${state.wikiPreviewLoading ? html`
Loading wiki page…
` - : wikiPreviewError - ? html`
${wikiPreviewError}
` + : state.wikiPreviewError + ? html`
${state.wikiPreviewError}
` : html` - ${wikiPreviewTruncated + ${state.wikiPreviewTruncated ? html`
Showing the first chunk of this - page${wikiPreviewTotalLines !== null - ? ` (${wikiPreviewTotalLines} total lines)` + page${state.wikiPreviewTotalLines !== null + ? ` (${state.wikiPreviewTotalLines} total lines)` : ""}.
` : nothing} -
${wikiPreviewContent}
+
${state.wikiPreviewContent}
`}
@@ -708,7 +723,7 @@ function renderWikiPreviewOverlay(props: DreamingProps) { `; } -function renderDiarySubtabExplainer() { +function renderDiarySubtabExplainer(activeDiarySubTab: DreamingViewState["activeDiarySubTab"]) { switch (activeDiarySubTab) { case "dreams": return html` @@ -767,7 +782,10 @@ function compareWaitingEntryBySignals(a: DreamingEntry, b: DreamingEntry): numbe return compareWaitingEntryByRecency(a, b); } -function sortWaitingEntries(entries: DreamingEntry[], sort: AdvancedWaitingSort): DreamingEntry[] { +function sortWaitingEntries( + entries: DreamingEntry[], + sort: DreamingViewState["advancedWaitingSort"], +): DreamingEntry[] { return sort === "signals" ? entries.toSorted(compareWaitingEntryBySignals) : entries.toSorted(compareWaitingEntryByRecency); @@ -841,8 +859,9 @@ function renderAdvancedEntryList(params: { } function renderAdvancedSection(props: DreamingProps) { + const state = props.viewState; const groundedEntries = props.shortTermEntries.filter((entry) => entry.groundedCount > 0); - const waitingEntries = sortWaitingEntries(props.shortTermEntries, advancedWaitingSort); + const waitingEntries = sortWaitingEntries(props.shortTermEntries, state.advancedWaitingSort); const description = t("dreaming.advanced.description"); const summary = [ `${groundedEntries.length} ${t("dreaming.advanced.summaryFromDailyLog")}`, @@ -959,23 +978,23 @@ function renderAdvancedSection(props: DreamingProps) { controls: html`
${cluster.items.map((item) => { - const expanded = expandedInsightCards.has(item.pagePath); + const expanded = state.expandedInsightCards.has(item.pagePath); return html`
- toggleExpandedCard(expandedInsightCards, item.pagePath, props.onRequestUpdate)} + toggleExpandedCard( + state.expandedInsightCards, + item.pagePath, + props.onViewStateChange, + )} >
${item.title}
@@ -1180,7 +1203,11 @@ function renderDiaryImportsSection(props: DreamingProps) { class="btn btn--subtle btn--sm" @click=${(event: Event) => { event.stopPropagation(); - toggleExpandedCard(expandedInsightCards, item.pagePath, props.onRequestUpdate); + toggleExpandedCard( + state.expandedInsightCards, + item.pagePath, + props.onViewStateChange, + ); }} > ${expanded ? "Hide details" : "Details"} @@ -1204,6 +1231,7 @@ function renderDiaryImportsSection(props: DreamingProps) { } function renderMemoryPalaceSection(props: DreamingProps) { + const state = props.viewState; const palace = props.wikiMemoryPalace; const clusters = palace?.clusters ?? []; @@ -1227,8 +1255,7 @@ function renderMemoryPalaceSection(props: DreamingProps) { `; } - diaryEntryCount = clusters.length; - const clusterIndex = Math.max(0, Math.min(diaryPage, clusters.length - 1)); + const clusterIndex = Math.max(0, Math.min(state.diaryPage, clusters.length - 1)); const cluster = clusters[clusterIndex]; const totalPages = palace?.totalPages ?? palace?.totalItems ?? 0; const totalClaims = palace?.totalClaims ?? 0; @@ -1248,8 +1275,8 @@ function renderMemoryPalaceSection(props: DreamingProps) { ? "dreams-diary__day-chip--active" : ""}" @click=${() => { - setDiaryPage(index); - props.onRequestUpdate?.(); + setDiaryPage(state, index, clusters.length); + props.onViewStateChange(); }} > ${entry.label} @@ -1277,7 +1304,7 @@ function renderMemoryPalaceSection(props: DreamingProps) {
${cluster.items.map((item) => { - const expanded = expandedPalaceCards.has(item.pagePath); + const expanded = state.expandedPalaceCards.has(item.pagePath); return html`
@@ -1355,7 +1386,11 @@ function renderMemoryPalaceSection(props: DreamingProps) { class="btn btn--subtle btn--sm" @click=${(event: Event) => { event.stopPropagation(); - toggleExpandedCard(expandedPalaceCards, item.pagePath, props.onRequestUpdate); + toggleExpandedCard( + state.expandedPalaceCards, + item.pagePath, + props.onViewStateChange, + ); }} > ${expanded ? "Hide details" : "Details"} @@ -1379,6 +1414,7 @@ function renderMemoryPalaceSection(props: DreamingProps) { } function renderDreamDiaryEntries(props: DreamingProps) { + const state = props.viewState; if (typeof props.dreamDiaryContent !== "string") { return html`
@@ -1395,8 +1431,6 @@ function renderDreamDiaryEntries(props: DreamingProps) { } const entries = parseDiaryEntries(props.dreamDiaryContent); - diaryEntryCount = entries.length; - if (entries.length === 0) { return html`
@@ -1407,7 +1441,7 @@ function renderDreamDiaryEntries(props: DreamingProps) { } const reversed = buildDiaryNavigation(entries); - const page = Math.max(0, Math.min(diaryPage, reversed.length - 1)); + const page = Math.max(0, Math.min(state.diaryPage, reversed.length - 1)); const entry = reversed[page]; return html` @@ -1419,8 +1453,8 @@ function renderDreamDiaryEntries(props: DreamingProps) { ? "dreams-diary__day-chip--active" : ""}" @click=${() => { - setDiaryPage(e.page); - props.onRequestUpdate?.(); + setDiaryPage(state, e.page, reversed.length); + props.onViewStateChange(); }} > ${formatDiaryChipLabel(e.date)} @@ -1446,6 +1480,8 @@ function renderDreamDiaryEntries(props: DreamingProps) { // ── Diary section renderer ──────────────────────────────────────────── function renderDiarySection(props: DreamingProps) { + const state = props.viewState; + const activeDiarySubTab = state.activeDiarySubTab; const wikiTabSelected = activeDiarySubTab === "insights" || activeDiarySubTab === "palace"; const memoryWikiUnavailable = wikiTabSelected && !props.memoryWikiEnabled; const diaryError = @@ -1473,10 +1509,10 @@ function renderDiarySection(props: DreamingProps) { ? "dreams-diary__subtab--active" : ""}" @click=${() => { - closeWikiPreview(); - activeDiarySubTab = "dreams"; - diaryPage = 0; - props.onRequestUpdate?.(); + resetWikiPreview(state); + state.activeDiarySubTab = "dreams"; + state.diaryPage = 0; + props.onViewStateChange(); }} > Dreams @@ -1486,10 +1522,10 @@ function renderDiarySection(props: DreamingProps) { ? "dreams-diary__subtab--active" : ""}" @click=${() => { - closeWikiPreview(); - activeDiarySubTab = "insights"; - diaryPage = 0; - props.onRequestUpdate?.(); + resetWikiPreview(state); + state.activeDiarySubTab = "insights"; + state.diaryPage = 0; + props.onViewStateChange(); }} > Imported Insights @@ -1499,10 +1535,10 @@ function renderDiarySection(props: DreamingProps) { ? "dreams-diary__subtab--active" : ""}" @click=${() => { - closeWikiPreview(); - activeDiarySubTab = "palace"; - diaryPage = 0; - props.onRequestUpdate?.(); + resetWikiPreview(state); + state.activeDiarySubTab = "palace"; + state.diaryPage = 0; + props.onViewStateChange(); }} > Memory Palace @@ -1519,7 +1555,7 @@ function renderDiarySection(props: DreamingProps) { ? props.wikiImportInsightsLoading : props.wikiMemoryPalaceLoading)} @click=${() => { - diaryPage = 0; + state.diaryPage = 0; if (memoryWikiUnavailable) { props.onOpenConfig(); } else if (activeDiarySubTab === "dreams") { @@ -1546,7 +1582,7 @@ function renderDiarySection(props: DreamingProps) { : "Reload"}
- ${renderDiarySubtabExplainer()} + ${renderDiarySubtabExplainer(activeDiarySubTab)}
${memoryWikiUnavailable diff --git a/ui/src/pages/instances/instances-page.ts b/ui/src/pages/instances/instances-page.ts new file mode 100644 index 000000000000..3ea83302b302 --- /dev/null +++ b/ui/src/pages/instances/instances-page.ts @@ -0,0 +1,176 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { PresenceEntry } from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationGatewaySnapshot, +} from "../../app/context.ts"; +import { + formatMissingOperatorReadScopeMessage, + isMissingOperatorReadScopeError, +} from "../../lib/gateway-errors.ts"; +import { renderInstances } from "./view.ts"; + +function readPresence(value: unknown): PresenceEntry[] | null { + const presence = + value && typeof value === "object" ? (value as { presence?: unknown }).presence : null; + return Array.isArray(presence) ? (presence as PresenceEntry[]) : null; +} + +export class InstancesPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @state() private loading = false; + @state() private entries: PresenceEntry[] = []; + @state() private error: string | null = null; + @state() private status: string | null = null; + @state() private hostsRevealed = false; + + private client: GatewayBrowserClient | null = null; + private connected = false; + private requestId = 0; + private subscriptions: Array<() => void> = []; + + override connectedCallback() { + super.connectedCallback(); + this.subscriptions = [ + this.context.gateway.subscribeEvents((event) => { + const presence = event.event === "presence" ? readPresence(event.payload) : null; + if (presence) { + this.applyPresence(presence); + } + }), + this.context.gateway.subscribe((snapshot) => this.applyGatewaySnapshot(snapshot)), + ]; + this.applyGatewaySnapshot(this.context.gateway.snapshot); + } + + override disconnectedCallback() { + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + this.subscriptions = []; + this.invalidateRequest(); + this.client = null; + this.connected = false; + super.disconnectedCallback(); + } + + private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) { + const clientChanged = snapshot.client !== this.client; + const becameConnected = snapshot.connected && !this.connected; + this.client = snapshot.client; + this.connected = snapshot.connected; + + if (clientChanged) { + this.invalidateRequest(); + this.entries = []; + this.error = null; + this.status = null; + } + if (!snapshot.connected || !snapshot.client) { + this.invalidateRequest(); + return; + } + if (!clientChanged && !becameConnected) { + return; + } + + const initialPresence = readPresence(snapshot.hello?.snapshot); + if (initialPresence) { + this.applyPresence(initialPresence); + } + void this.loadPresence(); + } + + private applyPresence(entries: PresenceEntry[]) { + this.invalidateRequest(); + this.entries = entries; + this.error = null; + this.status = entries.length === 0 ? "No instances yet." : null; + } + + private invalidateRequest() { + this.requestId += 1; + this.loading = false; + } + + private isCurrentRequest(requestId: number, client: GatewayBrowserClient): boolean { + const gateway = this.context.gateway.snapshot; + return this.isConnected && requestId === this.requestId && gateway.client === client; + } + + private async loadPresence() { + const gateway = this.context.gateway.snapshot; + const client = gateway.client; + if (!gateway.connected || !client || this.loading) { + return; + } + + const requestId = ++this.requestId; + this.loading = true; + this.error = null; + this.status = null; + try { + const response = await client.request("system-presence", {}); + if (!this.isCurrentRequest(requestId, client)) { + return; + } + if (Array.isArray(response)) { + this.entries = response as PresenceEntry[]; + this.status = response.length === 0 ? "No instances yet." : null; + } else { + this.entries = []; + this.status = "No presence payload."; + } + } catch (error) { + if (!this.isCurrentRequest(requestId, client)) { + return; + } + if (isMissingOperatorReadScopeError(error)) { + this.entries = []; + this.status = null; + this.error = formatMissingOperatorReadScopeMessage("instance presence"); + } else { + this.error = String(error); + } + } finally { + if (this.isCurrentRequest(requestId, client)) { + this.loading = false; + } + } + } + + override render() { + return html` +
+
+
${titleForRoute("instances")}
+
${subtitleForRoute("instances")}
+
+
+ ${renderInstances({ + loading: this.loading, + entries: this.entries, + lastError: this.error, + statusMessage: this.status, + hostsRevealed: this.hostsRevealed, + onRefresh: () => void this.loadPresence(), + onToggleHosts: () => { + this.hostsRevealed = !this.hostsRevealed; + }, + })} + `; + } +} + +customElements.define("openclaw-instances-page", InstancesPage); diff --git a/ui/src/pages/instances/route.ts b/ui/src/pages/instances/route.ts new file mode 100644 index 000000000000..b1b439d12b54 --- /dev/null +++ b/ui/src/pages/instances/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; + +export const page = definePage({ + id: "instances", + path: "/instances", + component: () => + import("./instances-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/views/instances.ts b/ui/src/pages/instances/view.ts similarity index 79% rename from ui/src/ui/views/instances.ts rename to ui/src/pages/instances/view.ts index 4b0ea8c51a78..4dd76aa2270f 100644 --- a/ui/src/ui/views/instances.ts +++ b/ui/src/pages/instances/view.ts @@ -1,22 +1,23 @@ -// Control UI view renders instances screen content. +// Instances page renders its screen content. import { html, nothing } from "lit"; +import type { PresenceEntry } from "../../api/types.ts"; +import { icons } from "../../components/icons.ts"; +import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; -import { formatPresenceAge } from "../presenter.ts"; -import type { PresenceEntry } from "../types.ts"; +import { formatPresenceAge } from "../../lib/presenter.ts"; export type InstancesProps = { loading: boolean; entries: PresenceEntry[]; lastError: string | null; statusMessage: string | null; + hostsRevealed: boolean; onRefresh: () => void; + onToggleHosts: () => void; }; -let hostsRevealed = false; - export function renderInstances(props: InstancesProps) { - const masked = !hostsRevealed; + const masked = !props.hostsRevealed; return html`
@@ -26,19 +27,19 @@ export function renderInstances(props: InstancesProps) {
${t("instances.subtitle")}
- + + diff --git a/ui/src/ui/controllers/logs.test.ts b/ui/src/pages/logs/data.test.ts similarity index 95% rename from ui/src/ui/controllers/logs.test.ts rename to ui/src/pages/logs/data.test.ts index 7b111d478786..0e2b923b1e8e 100644 --- a/ui/src/ui/controllers/logs.test.ts +++ b/ui/src/pages/logs/data.test.ts @@ -1,6 +1,6 @@ // Control UI tests cover logs behavior. import { describe, expect, it } from "vitest"; -import { parseLogLine } from "./logs.ts"; +import { parseLogLine } from "./log-lines.ts"; describe("parseLogLine", () => { it("strips ANSI escape sequences from rendered log fields", () => { diff --git a/ui/src/pages/logs/log-lines.ts b/ui/src/pages/logs/log-lines.ts new file mode 100644 index 000000000000..900d9932ad94 --- /dev/null +++ b/ui/src/pages/logs/log-lines.ts @@ -0,0 +1,99 @@ +import { stripAnsi } from "../../../../packages/terminal-core/src/ansi.js"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; + +export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; + +export type LogEntry = { + raw: string; + time?: string | null; + level?: LogLevel | null; + subsystem?: string | null; + message?: string | null; + meta?: Record | null; +}; + +export const DEFAULT_LOG_LEVEL_FILTERS: Record = { + trace: true, + debug: true, + info: true, + warn: true, + error: true, + fatal: true, +}; + +const LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); + +function parseMaybeJsonString(value: unknown) { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } +} + +function normalizeLevel(value: unknown): LogLevel | null { + if (typeof value !== "string") { + return null; + } + const lowered = normalizeLowercaseStringOrEmpty(value) as LogLevel; + return LEVELS.has(lowered) ? lowered : null; +} + +export function parseLogLine(line: string): LogEntry { + if (!line.trim()) { + return { raw: line, message: line }; + } + try { + const obj = JSON.parse(line) as Record; + const meta = + obj && typeof obj["_meta"] === "object" && obj["_meta"] !== null + ? (obj["_meta"] as Record) + : null; + const time = + typeof obj.time === "string" ? obj.time : typeof meta?.date === "string" ? meta.date : null; + const level = normalizeLevel(meta?.logLevelName ?? meta?.level); + + const contextCandidate = + typeof obj["0"] === "string" ? obj["0"] : typeof meta?.name === "string" ? meta.name : null; + const contextObj = parseMaybeJsonString(contextCandidate); + let subsystem = + typeof contextObj?.subsystem === "string" + ? contextObj.subsystem + : typeof contextObj?.module === "string" + ? contextObj.module + : null; + if (!subsystem && contextCandidate && contextCandidate.length < 120) { + subsystem = contextCandidate; + } + + const message = + typeof obj["1"] === "string" + ? obj["1"] + : typeof obj["2"] === "string" + ? obj["2"] + : !contextObj && typeof obj["0"] === "string" + ? obj["0"] + : typeof obj.message === "string" + ? obj.message + : line; + + return { + raw: line, + time, + level, + subsystem: subsystem ? stripAnsi(subsystem) : subsystem, + message: stripAnsi(message), + meta: meta ?? undefined, + }; + } catch { + return { raw: line, message: stripAnsi(line) }; + } +} diff --git a/ui/src/pages/logs/logs-page.ts b/ui/src/pages/logs/logs-page.ts new file mode 100644 index 000000000000..cef2d446fe33 --- /dev/null +++ b/ui/src/pages/logs/logs-page.ts @@ -0,0 +1,289 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { + formatMissingOperatorReadScopeMessage, + isMissingOperatorReadScopeError, +} from "../../lib/gateway-errors.ts"; +import { + DEFAULT_LOG_LEVEL_FILTERS, + parseLogLine, + type LogEntry, + type LogLevel, +} from "./log-lines.ts"; +import { renderLogs } from "./view.ts"; + +const LOG_BUFFER_LIMIT = 2000; +const LOGS_POLL_INTERVAL_MS = 2000; + +export class LogsPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @state() private client: GatewayBrowserClient | null = null; + @state() private connected = false; + @state() private logsLoading = false; + @state() private logsError: string | null = null; + @state() private logsFile: string | null = null; + @state() private logsEntries: LogEntry[] = []; + @state() private logsFilterText = ""; + @state() private logsLevelFilters: Record = { ...DEFAULT_LOG_LEVEL_FILTERS }; + @state() private logsAutoFollow = true; + @state() private logsTruncated = false; + @state() private logsAtBottom = true; + + private logsCursor: number | null = null; + private readonly logsLimit = 500; + private readonly logsMaxBytes = 250_000; + private logsPollInterval: ReturnType | null = null; + private logsScrollFrame: number | null = null; + private contentScrollFrame: number | null = null; + private stopGatewaySubscription?: () => void; + + override connectedCallback() { + super.connectedCallback(); + this.syncGatewayState(); + this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => { + const previousClient = this.client; + this.syncGatewayState(); + if (previousClient !== snapshot.client) { + this.resetServerState(); + } + this.syncPolling(); + this.ensureInitialLogs(); + }); + this.logsAtBottom = true; + this.syncPolling(); + this.ensureInitialLogs(); + } + + override firstUpdated() { + this.resetContentScroll(); + this.contentScrollFrame = requestAnimationFrame(() => { + this.contentScrollFrame = null; + this.resetContentScroll(); + }); + } + + override updated(changed: Map) { + if ( + this.logsAutoFollow && + this.logsAtBottom && + (changed.has("logsEntries") || changed.has("logsAutoFollow")) + ) { + this.scheduleScroll(changed.has("logsAutoFollow")); + } + } + + override disconnectedCallback() { + this.stopPolling(); + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + if (this.logsScrollFrame !== null) { + cancelAnimationFrame(this.logsScrollFrame); + this.logsScrollFrame = null; + } + if (this.contentScrollFrame !== null) { + cancelAnimationFrame(this.contentScrollFrame); + this.contentScrollFrame = null; + } + super.disconnectedCallback(); + } + + private resetContentScroll() { + const content = this.closest(".content"); + if (content) { + content.scrollTop = 0; + content.scrollLeft = 0; + } + } + + private syncGatewayState() { + const gateway = this.context.gateway.snapshot; + this.client = gateway.client; + this.connected = gateway.connected; + } + + private resetServerState() { + this.logsLoading = false; + this.logsError = null; + this.logsFile = null; + this.logsEntries = []; + this.logsTruncated = false; + this.logsCursor = null; + this.logsAtBottom = true; + } + + private syncPolling() { + if (!this.connected || !this.client) { + this.stopPolling(); + return; + } + if (this.logsPollInterval !== null) { + return; + } + this.logsPollInterval = globalThis.setInterval(() => { + void this.loadLogs({ quiet: true }); + }, LOGS_POLL_INTERVAL_MS); + } + + private stopPolling() { + if (this.logsPollInterval === null) { + return; + } + globalThis.clearInterval(this.logsPollInterval); + this.logsPollInterval = null; + } + + private ensureInitialLogs() { + if (!this.connected || !this.client || this.logsEntries.length > 0 || this.logsLoading) { + return; + } + void this.loadLogs({ reset: true }).then(() => this.scheduleScroll(true)); + } + + private async loadLogs(opts?: { reset?: boolean; quiet?: boolean }) { + const client = this.client; + const quiet = opts?.quiet === true; + if (!client || !this.connected || (this.logsLoading && !quiet)) { + return; + } + if (!quiet) { + this.logsLoading = true; + } + this.logsError = null; + try { + const res = await client.request("logs.tail", { + cursor: opts?.reset ? undefined : (this.logsCursor ?? undefined), + limit: this.logsLimit, + maxBytes: this.logsMaxBytes, + }); + if (this.client !== client) { + return; + } + const payload = res as { + file?: string; + cursor?: number; + lines?: unknown; + truncated?: boolean; + reset?: boolean; + }; + const lines = Array.isArray(payload.lines) + ? payload.lines.filter((line): line is string => typeof line === "string") + : []; + const entries = lines.map(parseLogLine); + const shouldReset = opts?.reset || payload.reset || this.logsCursor == null; + this.logsEntries = shouldReset + ? entries + : [...this.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT); + this.logsCursor = typeof payload.cursor === "number" ? payload.cursor : this.logsCursor; + this.logsFile = typeof payload.file === "string" ? payload.file : this.logsFile; + this.logsTruncated = Boolean(payload.truncated); + } catch (err) { + if (this.client !== client) { + return; + } + if (isMissingOperatorReadScopeError(err)) { + this.logsEntries = []; + this.logsError = formatMissingOperatorReadScopeMessage("logs"); + } else { + this.logsError = String(err); + } + } finally { + if (this.client === client && !quiet) { + this.logsLoading = false; + } + } + } + + private scheduleScroll(force = false) { + if (this.logsScrollFrame !== null) { + cancelAnimationFrame(this.logsScrollFrame); + } + void this.updateComplete.then(() => { + this.logsScrollFrame = requestAnimationFrame(() => { + this.logsScrollFrame = null; + const container = this.querySelector(".log-stream") as HTMLElement | null; + if (!container) { + return; + } + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + if (force || distanceFromBottom < 80) { + container.scrollTop = container.scrollHeight; + } + }); + }); + } + + private handleScroll(event: Event) { + const container = event.currentTarget as HTMLElement | null; + if (!container) { + return; + } + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + this.logsAtBottom = distanceFromBottom < 80; + } + + private exportLogs(lines: string[], label: string) { + if (lines.length === 0) { + return; + } + const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); + anchor.href = url; + anchor.download = `openclaw-logs-${label}-${stamp}.log`; + anchor.click(); + URL.revokeObjectURL(url); + } + + override render() { + const body = renderLogs({ + loading: this.logsLoading, + error: this.logsError, + file: this.logsFile, + entries: this.logsEntries, + filterText: this.logsFilterText, + levelFilters: this.logsLevelFilters, + autoFollow: this.logsAutoFollow, + truncated: this.logsTruncated, + onFilterTextChange: (next) => (this.logsFilterText = next), + onLevelToggle: (level, enabled) => { + this.logsLevelFilters = { ...this.logsLevelFilters, [level]: enabled }; + }, + onToggleAutoFollow: (next) => (this.logsAutoFollow = next), + onRefresh: () => void this.loadLogs({ reset: true }).then(() => this.scheduleScroll(true)), + onExport: (lines, label) => this.exportLogs(lines, label), + onScroll: (event) => this.handleScroll(event), + }); + return html` +
+
+
${titleForRoute("logs")}
+
${subtitleForRoute("logs")}
+
+
+ ${renderSettingsWorkspace( + this.context.basePath, + body, + "logs", + (routeId) => this.context.navigate(routeId), + (routeId) => this.context.preload(routeId), + { fillHeight: true }, + )} + `; + } +} + +customElements.define("openclaw-logs-page", LogsPage); diff --git a/ui/src/pages/logs/route.ts b/ui/src/pages/logs/route.ts new file mode 100644 index 000000000000..7c87d4b62a23 --- /dev/null +++ b/ui/src/pages/logs/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; + +export const page = definePage({ + id: "logs", + path: "/logs", + component: () => + import("./logs-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/views/logs.test.ts b/ui/src/pages/logs/view.test.ts similarity index 96% rename from ui/src/ui/views/logs.test.ts rename to ui/src/pages/logs/view.test.ts index 74f1be3dffed..3a23c6236084 100644 --- a/ui/src/ui/views/logs.test.ts +++ b/ui/src/pages/logs/view.test.ts @@ -4,8 +4,8 @@ import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../../i18n/index.ts"; import { pt_BR } from "../../i18n/locales/pt-BR.ts"; -import type { LogLevel } from "../types.ts"; -import { renderLogs, type LogsProps } from "./logs.ts"; +import type { LogLevel } from "./log-lines.ts"; +import { renderLogs, type LogsProps } from "./view.ts"; function createLevelFilters(overrides: Partial> = {}) { return { diff --git a/ui/src/ui/views/logs.ts b/ui/src/pages/logs/view.ts similarity index 96% rename from ui/src/ui/views/logs.ts rename to ui/src/pages/logs/view.ts index 5a966212a694..ecd5e702f843 100644 --- a/ui/src/ui/views/logs.ts +++ b/ui/src/pages/logs/view.ts @@ -1,8 +1,8 @@ // Control UI view renders logs screen content. import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { LogEntry, LogLevel } from "../types.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import type { LogEntry, LogLevel } from "./log-lines.ts"; const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; type ExportFileLabel = "filtered" | "visible"; @@ -58,7 +58,7 @@ export function renderLogs(props: LogsProps) { const exportDisplayLabel = t(`logsView.exportLabels.${exportFileLabel}`); return html` -
+
${t("logsView.title")}
diff --git a/ui/src/pages/nodes/nodes-page.ts b/ui/src/pages/nodes/nodes-page.ts new file mode 100644 index 000000000000..1b10e12b8299 --- /dev/null +++ b/ui/src/pages/nodes/nodes-page.ts @@ -0,0 +1,316 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import { titleForRoute, subtitleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { copyToClipboard } from "../../lib/clipboard.ts"; +import { currentConfigObject } from "../../lib/config/index.ts"; +import { + approveDevicePairing, + closeDevicePairSetup, + createInitialNodesState, + loadDevices, + loadExecApprovals, + loadNodes, + openDevicePairSetup, + refreshDevicePairSetup, + rejectDevicePairing, + removeExecApprovalsFormValue, + revokeDeviceToken, + rotateDeviceToken, + saveExecApprovals, + updateExecApprovalsFormValue, + type DevicePairingList, + type DevicePairSetup, + type DevicePairSetupState, + type ExecApprovalsFile, + type ExecApprovalsSnapshot, + type ExecApprovalsTarget, + type NodesPageDataState, +} from "../../lib/nodes/index.ts"; +import { renderNodes } from "./view.ts"; + +export type NodesRouteData = { + nodes: NodesPageDataState; +}; + +const NODES_ACTIVE_POLL_INTERVAL_MS = 30_000; + +export class NodesPage extends LitElement implements NodesPageDataState, DevicePairSetupState { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @property({ attribute: false }) routeData?: NodesRouteData; + + @state() client: NodesPageDataState["client"] = null; + @state() connected = false; + @state() nodesLoading = false; + @state() nodes: Array> = []; + @state() lastError: string | null = null; + @state() chatError: string | null = null; + @state() devicesLoading = false; + @state() devicesError: string | null = null; + @state() devicesList: DevicePairingList | null = null; + @state() devicePairSetupOpen = false; + @state() devicePairSetupLoading = false; + @state() devicePairSetupError: string | null = null; + @state() devicePairSetup: DevicePairSetup | null = null; + @state() private canPairDevice = false; + @state() execApprovalsLoading = false; + @state() execApprovalsSaving = false; + @state() execApprovalsDirty = false; + @state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; + @state() execApprovalsForm: ExecApprovalsFile | null = null; + @state() execApprovalsSelectedAgent: string | null = null; + @state() private execApprovalsTarget: "gateway" | "node" = "gateway"; + @state() private execApprovalsTargetNodeId: string | null = null; + + private routeDataInitialized = false; + private stopGatewaySubscription?: () => void; + private stopGatewayEvents?: () => void; + private stopConfigSubscription?: () => void; + private nodesPollInterval: ReturnType | null = null; + + override connectedCallback() { + super.connectedCallback(); + this.syncGatewayState(); + this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => { + const previousClient = this.client; + this.syncGatewayState(); + if (previousClient !== snapshot.client || !snapshot.connected) { + this.resetServerState(); + } + this.syncPolling(); + this.ensureInitialData(); + }); + this.stopGatewayEvents = this.context.gateway.subscribeEvents((event) => { + if (event.event === "device.pair.requested" || event.event === "device.pair.resolved") { + void loadDevices(this, { quiet: true }); + } + }); + this.stopConfigSubscription = this.context.runtimeConfig.subscribe(() => this.requestUpdate()); + this.syncPolling(); + this.ensureInitialData(); + } + + override willUpdate(changed: Map) { + if (changed.has("routeData")) { + this.applyRouteData(); + } + } + + override updated(changed: Map) { + if (changed.has("routeData")) { + this.ensureInitialData(); + } + } + + override disconnectedCallback() { + closeDevicePairSetup(this); + this.stopPolling(); + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopGatewayEvents?.(); + this.stopGatewayEvents = undefined; + this.stopConfigSubscription?.(); + this.stopConfigSubscription = undefined; + super.disconnectedCallback(); + } + + private syncGatewayState() { + const gateway = this.context.gateway.snapshot; + this.client = gateway.client; + this.connected = gateway.connected; + this.canPairDevice = gateway.connected && hasOperatorAdminAccess(gateway.hello?.auth ?? null); + } + + private applyRouteData() { + const data = this.routeData; + if (!data) { + return; + } + this.routeDataInitialized = true; + const gateway = this.context.gateway.snapshot; + if (data.nodes.client !== gateway.client) { + this.syncGatewayState(); + return; + } + this.client = gateway.client; + this.connected = gateway.connected; + this.nodesLoading = data.nodes.nodesLoading; + this.nodes = data.nodes.nodes; + this.lastError = data.nodes.lastError; + this.chatError = data.nodes.chatError ?? null; + this.devicesLoading = data.nodes.devicesLoading; + this.devicesError = data.nodes.devicesError; + this.devicesList = data.nodes.devicesList; + this.execApprovalsLoading = data.nodes.execApprovalsLoading; + this.execApprovalsSaving = data.nodes.execApprovalsSaving; + this.execApprovalsDirty = data.nodes.execApprovalsDirty; + this.execApprovalsSnapshot = data.nodes.execApprovalsSnapshot; + this.execApprovalsForm = data.nodes.execApprovalsForm; + this.execApprovalsSelectedAgent = data.nodes.execApprovalsSelectedAgent; + } + + private resetServerState() { + closeDevicePairSetup(this); + const next = createInitialNodesState(this.context.gateway.snapshot); + this.nodesLoading = next.nodesLoading; + this.nodes = next.nodes; + this.lastError = next.lastError; + this.chatError = next.chatError ?? null; + this.devicesLoading = next.devicesLoading; + this.devicesError = next.devicesError; + this.devicesList = next.devicesList; + this.execApprovalsLoading = next.execApprovalsLoading; + this.execApprovalsSaving = next.execApprovalsSaving; + this.execApprovalsDirty = next.execApprovalsDirty; + this.execApprovalsSnapshot = next.execApprovalsSnapshot; + this.execApprovalsForm = next.execApprovalsForm; + this.execApprovalsSelectedAgent = next.execApprovalsSelectedAgent; + } + + private ensureInitialData() { + if (!this.connected || !this.client || !this.routeDataInitialized) { + return; + } + if (!this.nodes.length && !this.nodesLoading) { + void loadNodes(this); + } + if (!this.devicesList && !this.devicesLoading) { + void loadDevices(this); + } + const config = this.context.runtimeConfig.state; + if (!config.configSnapshot && !config.configLoading) { + void this.context.runtimeConfig.refresh(); + } + if (!this.execApprovalsSnapshot && !this.execApprovalsLoading) { + void loadExecApprovals(this, this.resolveExecApprovalsTarget()); + } + } + + private syncPolling() { + if (this.connected && this.client) { + if (this.nodesPollInterval == null) { + this.nodesPollInterval = globalThis.setInterval(() => { + void loadNodes(this, { quiet: true }); + }, NODES_ACTIVE_POLL_INTERVAL_MS); + } + return; + } + this.stopPolling(); + } + + private stopPolling() { + if (this.nodesPollInterval == null) { + return; + } + clearInterval(this.nodesPollInterval); + this.nodesPollInterval = null; + } + + private resolveExecApprovalsTarget(): ExecApprovalsTarget { + return this.execApprovalsTarget === "node" && this.execApprovalsTargetNodeId + ? { kind: "node", nodeId: this.execApprovalsTargetNodeId } + : { kind: "gateway" }; + } + + override render() { + const config = this.context.runtimeConfig.state; + return html` +
+
+
${titleForRoute("nodes")}
+
${subtitleForRoute("nodes")}
+
+
+ ${renderSettingsWorkspace( + this.context.basePath, + renderNodes({ + loading: this.nodesLoading, + nodes: this.nodes, + devicesLoading: this.devicesLoading, + devicesError: this.devicesError, + devicesList: this.devicesList, + devicePairSetupOpen: this.devicePairSetupOpen, + devicePairSetupLoading: this.devicePairSetupLoading, + devicePairSetupError: this.devicePairSetupError, + devicePairSetup: this.devicePairSetup, + canPairDevice: this.canPairDevice, + configForm: currentConfigObject(config), + configLoading: config.configLoading, + configSaving: config.configSaving, + configDirty: config.configFormDirty, + configFormMode: config.configFormMode, + execApprovalsLoading: this.execApprovalsLoading, + execApprovalsSaving: this.execApprovalsSaving, + execApprovalsDirty: this.execApprovalsDirty, + execApprovalsSnapshot: this.execApprovalsSnapshot, + execApprovalsForm: this.execApprovalsForm, + execApprovalsSelectedAgent: this.execApprovalsSelectedAgent, + execApprovalsTarget: this.execApprovalsTarget, + execApprovalsTargetNodeId: this.execApprovalsTargetNodeId, + onRefresh: () => void loadNodes(this), + onDevicesRefresh: () => void loadDevices(this), + onDevicePairSetupOpen: () => void openDevicePairSetup(this), + onDevicePairSetupRefresh: () => void refreshDevicePairSetup(this), + onDevicePairSetupClose: () => closeDevicePairSetup(this), + onDevicePairSetupCopy: (setupCode) => void copyToClipboard(setupCode), + onDeviceApprove: (requestId) => void approveDevicePairing(this, requestId), + onDeviceReject: (requestId) => void rejectDevicePairing(this, requestId), + onDeviceRotate: (deviceId, role, scopes) => + void rotateDeviceToken(this, { deviceId, role, scopes }), + onDeviceRevoke: (deviceId, role) => void revokeDeviceToken(this, { deviceId, role }), + onLoadConfig: () => + void this.context.runtimeConfig.refresh({ discardPendingChanges: true }), + onLoadExecApprovals: () => + void loadExecApprovals(this, this.resolveExecApprovalsTarget()), + onBindDefault: (nodeId) => { + if (nodeId) { + this.context.runtimeConfig.patchForm(["tools", "exec", "node"], nodeId); + } else { + this.context.runtimeConfig.removeFormValue(["tools", "exec", "node"]); + } + }, + onBindAgent: (agentIndex, nodeId) => { + const path = ["agents", "list", agentIndex, "tools", "exec", "node"]; + if (nodeId) { + this.context.runtimeConfig.patchForm(path, nodeId); + } else { + this.context.runtimeConfig.removeFormValue(path); + } + }, + onSaveBindings: () => void this.context.runtimeConfig.save(), + onExecApprovalsTargetChange: (kind, nodeId) => { + this.execApprovalsTarget = kind; + this.execApprovalsTargetNodeId = nodeId; + this.execApprovalsSnapshot = null; + this.execApprovalsForm = null; + this.execApprovalsDirty = false; + this.execApprovalsSelectedAgent = null; + }, + onExecApprovalsSelectAgent: (agentId) => { + this.execApprovalsSelectedAgent = agentId; + }, + onExecApprovalsPatch: (path, value) => updateExecApprovalsFormValue(this, path, value), + onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(this, path), + onSaveExecApprovals: () => + void saveExecApprovals(this, this.resolveExecApprovalsTarget()), + }), + "nodes", + (routeId) => this.context.navigate(routeId), + (routeId) => this.context.preload(routeId), + )} + `; + } +} + +if (!customElements.get("openclaw-nodes-page")) { + customElements.define("openclaw-nodes-page", NodesPage); +} diff --git a/ui/src/pages/nodes/route.ts b/ui/src/pages/nodes/route.ts new file mode 100644 index 000000000000..37b9f6929a5b --- /dev/null +++ b/ui/src/pages/nodes/route.ts @@ -0,0 +1,39 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; +import { + createInitialNodesState, + loadDevices, + loadExecApprovals, + loadNodes, +} from "../../lib/nodes/index.ts"; +import type { NodesRouteData } from "./nodes-page.ts"; + +async function loadNodesRouteData(context: ApplicationContext): Promise { + const gateway = context.gateway.snapshot; + const nodes = createInitialNodesState(gateway); + if (!gateway.connected || !gateway.client) { + return { nodes }; + } + await Promise.all([ + loadNodes(nodes), + Promise.allSettled([ + loadDevices(nodes), + context.runtimeConfig.refresh(), + loadExecApprovals(nodes), + ]), + ]); + return { nodes }; +} + +export const page = definePage({ + id: "nodes", + path: "/nodes", + loader: loadNodesRouteData, + component: () => + import("./nodes-page.ts").then(() => ({ + header: true, + render: (data: NodesRouteData | undefined) => + html``, + })), +}); diff --git a/ui/src/ui/views/nodes-exec-approvals.ts b/ui/src/pages/nodes/view-exec-approvals.ts similarity index 98% rename from ui/src/ui/views/nodes-exec-approvals.ts rename to ui/src/pages/nodes/view-exec-approvals.ts index 19cefc29e51b..3e117bc20ac6 100644 --- a/ui/src/ui/views/nodes-exec-approvals.ts +++ b/ui/src/pages/nodes/view-exec-approvals.ts @@ -1,17 +1,14 @@ // Control UI view renders nodes exec approvals screen content. import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; -import type { - ExecApprovalsAllowlistEntry, - ExecApprovalsFile, -} from "../controllers/exec-approvals.ts"; -import { clampText, formatRelativeTimestamp } from "../format.ts"; +import { clampText, formatRelativeTimestamp } from "../../lib/format.ts"; +import type { ExecApprovalsAllowlistEntry, ExecApprovalsFile } from "../../lib/nodes/index.ts"; import { resolveConfigAgents as resolveSharedConfigAgents, resolveNodeTargets, type NodeTargetOption, -} from "./nodes-shared.ts"; -import type { NodesProps } from "./nodes.types.ts"; +} from "./view-shared.ts"; +import type { NodesProps } from "./view.types.ts"; type ExecSecurity = "deny" | "allowlist" | "full"; type ExecAsk = "off" | "on-miss" | "always"; diff --git a/ui/src/ui/views/nodes-pairing.ts b/ui/src/pages/nodes/view-pairing.ts similarity index 96% rename from ui/src/ui/views/nodes-pairing.ts rename to ui/src/pages/nodes/view-pairing.ts index 67c80a66bb9c..170f7a993070 100644 --- a/ui/src/ui/views/nodes-pairing.ts +++ b/ui/src/pages/nodes/view-pairing.ts @@ -1,9 +1,9 @@ -// Control UI view renders the mobile device pairing setup dialog. +// Nodes page renders the mobile device pairing setup dialog. import { html, nothing } from "lit"; +import { icons } from "../../components/icons.ts"; +import "../../components/modal-dialog.ts"; import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; -import "../components/modal-dialog.ts"; -import type { NodesProps } from "./nodes.types.ts"; +import type { NodesProps } from "./view.types.ts"; const PAIRING_DOCS_URL = "https://docs.openclaw.ai/channels/pairing#pair-from-the-control-ui-recommended"; diff --git a/ui/src/ui/views/nodes-shared.ts b/ui/src/pages/nodes/view-shared.ts similarity index 93% rename from ui/src/ui/views/nodes-shared.ts rename to ui/src/pages/nodes/view-shared.ts index 79e4698fe64a..35d3db58d560 100644 --- a/ui/src/ui/views/nodes-shared.ts +++ b/ui/src/pages/nodes/view-shared.ts @@ -1,5 +1,5 @@ -// Control UI view renders nodes shared screen content. -import { normalizeOptionalString } from "../string-coerce.ts"; +// Nodes page owns these pure view helpers. +import { normalizeOptionalString } from "../../lib/string-coerce.ts"; export type NodeTargetOption = { id: string; diff --git a/ui/src/ui/views/nodes.devices.test.ts b/ui/src/pages/nodes/view.devices.test.ts similarity index 75% rename from ui/src/ui/views/nodes.devices.test.ts rename to ui/src/pages/nodes/view.devices.test.ts index 9f0e31ba88c6..91b9d8a4c94b 100644 --- a/ui/src/ui/views/nodes.devices.test.ts +++ b/ui/src/pages/nodes/view.devices.test.ts @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; -import { renderNodes, type NodesProps } from "./nodes.ts"; +import { describe, expect, it } from "vitest"; +import { renderNodes, type NodesProps } from "./view.ts"; function baseProps(overrides: Partial = {}): NodesProps { return { @@ -199,59 +199,3 @@ describe("nodes devices pending rendering", () => { expect(details[1]).toBe("requested: roles: node, operator \u00b7 scopes: operator.read"); }); }); - -describe("nodes mobile device pairing", () => { - it("opens pairing from the Devices card", () => { - const onOpen = vi.fn(); - const container = renderNodesContainer({ onDevicePairSetupOpen: onOpen }); - - const button = getDevicesCard(container).querySelector("button.primary"); - expect(button?.textContent).toContain("Pair mobile device"); - button?.click(); - - expect(onOpen).toHaveBeenCalledOnce(); - }); - - it("disables setup-code creation without administrator access", () => { - const container = renderNodesContainer({ canPairDevice: false }); - const button = getDevicesCard(container).querySelector("button.primary"); - - expect(button?.disabled).toBe(true); - expect(button?.title).toBe("Administrator access is required to create setup codes."); - }); - - it("renders the QR and pending approval state in the pairing dialog", () => { - const setupCode = "OPENCLAW-SETUP-CODE"; - const container = renderNodesContainer({ - devicePairSetupOpen: true, - devicePairSetup: { - setupCode, - qrDataUrl: "data:image/png;base64,cXItZGF0YQ==", - gatewayUrl: "wss://gateway.example.com", - auth: "token", - urlSource: "config", - }, - devicesList: { - pending: [ - { - requestId: "req-1", - deviceId: "phone-1", - publicKey: "key-1", - ts: Date.now(), - }, - ], - paired: [], - }, - }); - - expect(container.querySelector(".device-pair-setup__qr")?.src).toBe( - "data:image/png;base64,cXItZGF0YQ==", - ); - expect(container.querySelector(".device-pair-setup__pending")?.textContent).toContain( - "Device requests waiting for review: 1", - ); - expect(container.querySelector(".device-pair-setup__fallback code")?.textContent).toBe( - setupCode, - ); - }); -}); diff --git a/ui/src/ui/views/nodes.ts b/ui/src/pages/nodes/view.ts similarity index 96% rename from ui/src/ui/views/nodes.ts rename to ui/src/pages/nodes/view.ts index d0856560d5d2..fa3668d6fdf2 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/pages/nodes/view.ts @@ -1,26 +1,27 @@ -// Control UI view renders nodes screen content. +// Nodes page renders its screen content. import { html, nothing } from "lit"; import { resolvePendingDeviceApprovalState, type DevicePairingAccessSummary, type PendingDeviceApprovalKind, } from "../../../../src/shared/device-pairing-access.js"; +import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import type { DeviceTokenSummary, PairedDevice, PendingDevice } from "../controllers/devices.ts"; -import { formatRelativeTimestamp, formatList } from "../format.ts"; -import { icons } from "../icons.ts"; -import { normalizeOptionalString } from "../string-coerce.ts"; -import { renderExecApprovals, resolveExecApprovalsState } from "./nodes-exec-approvals.ts"; -import { renderDevicePairSetup } from "./nodes-pairing.ts"; -import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./nodes-shared.ts"; -export type { NodesProps } from "./nodes.types.ts"; -import type { NodesProps } from "./nodes.types.ts"; +import { formatRelativeTimestamp, formatList } from "../../lib/format.ts"; +import type { DeviceTokenSummary, PairedDevice, PendingDevice } from "../../lib/nodes/index.ts"; +import { normalizeOptionalString } from "../../lib/string-coerce.ts"; +import { renderExecApprovals, resolveExecApprovalsState } from "./view-exec-approvals.ts"; +import { renderDevicePairSetup } from "./view-pairing.ts"; +import { resolveConfigAgents, resolveNodeTargets, type NodeTargetOption } from "./view-shared.ts"; +export type { NodesProps } from "./view.types.ts"; +import type { NodesProps } from "./view.types.ts"; export function renderNodes(props: NodesProps) { const bindingState = resolveBindingsState(props); const approvalsState = resolveExecApprovalsState(props); return html` - ${renderDevicePairSetup(props)} ${renderDevices(props)} + ${renderDevicePairSetup(props)} ${renderExecApprovals(approvalsState)} + ${renderBindings(bindingState)} ${renderDevices(props)}
@@ -37,7 +38,6 @@ export function renderNodes(props: NodesProps) { : props.nodes.map((n) => renderNode(n))}
- ${renderExecApprovals(approvalsState)} ${renderBindings(bindingState)} `; } diff --git a/ui/src/ui/views/nodes.types.ts b/ui/src/pages/nodes/view.types.ts similarity index 88% rename from ui/src/ui/views/nodes.types.ts rename to ui/src/pages/nodes/view.types.ts index b7f8cd8e5d0b..7dea89d6177d 100644 --- a/ui/src/ui/views/nodes.types.ts +++ b/ui/src/pages/nodes/view.types.ts @@ -1,6 +1,10 @@ -// Control UI type declarations define nodes contracts. -import type { DevicePairingList, DevicePairSetup } from "../controllers/devices.ts"; -import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "../controllers/exec-approvals.ts"; +// Nodes page view contracts. +import type { + DevicePairingList, + DevicePairSetup, + ExecApprovalsFile, + ExecApprovalsSnapshot, +} from "../../lib/nodes/index.ts"; export type NodesProps = { loading: boolean; diff --git a/ui/src/ui/views/overview-attention.ts b/ui/src/pages/overview/attention.ts similarity index 88% rename from ui/src/ui/views/overview-attention.ts rename to ui/src/pages/overview/attention.ts index 688e057669b4..ef5b561f5cea 100644 --- a/ui/src/ui/views/overview-attention.ts +++ b/ui/src/pages/overview/attention.ts @@ -1,9 +1,9 @@ // Control UI view renders overview attention screen content. import { html, nothing } from "lit"; +import type { AttentionItem } from "../../api/types.ts"; +import { icons, type IconName } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; -import { icons, type IconName } from "../icons.ts"; -import type { AttentionItem } from "../types.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../../lib/external-link.ts"; export type OverviewAttentionProps = { items: AttentionItem[]; diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/pages/overview/cards.ts similarity index 87% rename from ui/src/ui/views/overview-cards.ts rename to ui/src/pages/overview/cards.ts index 1b38e875a13d..84e2ce5123ee 100644 --- a/ui/src/ui/views/overview-cards.ts +++ b/ui/src/pages/overview/cards.ts @@ -2,17 +2,6 @@ import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { html, nothing, type TemplateResult } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { t } from "../../i18n/index.ts"; -import { isCronJobActiveFailure } from "../cron-status.ts"; -import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; -import { isMonitoredAuthProvider } from "../model-auth-helpers.ts"; -import { formatNextRun } from "../presenter.ts"; -import { - collectQuotaWindows, - formatQuotaReset, - type QuotaWindowSummary, -} from "../provider-quota-summary.ts"; -import { resolveSessionDisplayName } from "../session-display.ts"; import type { SessionsUsageResult, SessionsListResult, @@ -20,7 +9,19 @@ import type { CronJob, CronStatus, ModelAuthStatusResult, -} from "../types.ts"; +} from "../../api/types.ts"; +import type { NavigationRouteId } from "../../app-navigation.ts"; +import { t } from "../../i18n/index.ts"; +import { isCronJobActiveFailure } from "../../lib/cron-status.ts"; +import { formatCost, formatTokens, formatRelativeTimestamp } from "../../lib/format.ts"; +import { isMonitoredAuthProvider } from "../../lib/model-auth.ts"; +import { formatNextRun } from "../../lib/presenter.ts"; +import { + collectQuotaWindows, + formatQuotaReset, + type QuotaWindowSummary, +} from "../../lib/provider-quota-summary.ts"; +import { resolveSessionDisplayName } from "../../lib/session-display.ts"; export type OverviewCardsProps = { usageResult: SessionsUsageResult | null; @@ -29,8 +30,8 @@ export type OverviewCardsProps = { cronJobs: CronJob[]; cronStatus: CronStatus | null; modelAuthStatus: ModelAuthStatusResult | null; - presenceCount: number; - onNavigate: (tab: string) => void; + onNavigate: (routeId: NavigationRouteId) => void; + canNavigate: (routeId: NavigationRouteId) => boolean; }; const DIGIT_RUN = /\d{3,}/g; @@ -43,20 +44,27 @@ function blurDigits(value: string): TemplateResult { type StatCard = { kind: string; - tab: string; + routeId: NavigationRouteId; label: string; value: string | TemplateResult; hint: string | TemplateResult; }; -function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) { - return html` - +function renderStatCard(card: StatCard, props: OverviewCardsProps) { + const content = html` + ${card.label} + ${card.value} + ${card.hint} `; + return props.canNavigate(card.routeId) + ? html`` + : html`
${content}
`; } function renderProviderQuotaCard(windows: QuotaWindowSummary[]): StatCard | null { @@ -83,7 +91,7 @@ function renderProviderQuotaCard(windows: QuotaWindowSummary[]): StatCard | null return { kind: "quota", - tab: "usage", + routeId: "usage", label: t("tabs.usage"), value: html`${t("overview.cards.modelAuthUsageLeft", { pct: String(primary.remaining) })} 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`, }, { kind: "cron", - tab: "cron", + routeId: "cron", label: t("overview.stats.cron"), value: cronValue, hint: cronHint, @@ -199,7 +207,7 @@ export function renderOverviewCards(props: OverviewCardsProps) { if (authLoading) { cards.push({ kind: "auth", - tab: "overview", + routeId: "overview", label: t("overview.cards.modelAuth"), value: t("common.na"), hint: "", @@ -267,7 +275,7 @@ export function renderOverviewCards(props: OverviewCardsProps) { cards.push({ kind: "auth", - tab: "overview", + routeId: "overview", label: t("overview.cards.modelAuth"), value: authValue, hint: authHint, @@ -277,7 +285,7 @@ export function renderOverviewCards(props: OverviewCardsProps) { const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? []; return html` -
${cards.map((c) => renderStatCard(c, props.onNavigate))}
+
${cards.map((card) => renderStatCard(card, props))}
${sessions.length > 0 ? html` diff --git a/ui/src/ui/views/overview-event-log.ts b/ui/src/pages/overview/event-log.ts similarity index 82% rename from ui/src/ui/views/overview-event-log.ts rename to ui/src/pages/overview/event-log.ts index 7cb982aa0957..c5a42932c86f 100644 --- a/ui/src/ui/views/overview-event-log.ts +++ b/ui/src/pages/overview/event-log.ts @@ -1,13 +1,13 @@ // Control UI view renders overview event log screen content. import { html, nothing } from "lit"; +import type { EventLogEntry } from "../../api/event-log.ts"; +import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import type { EventLogEntry } from "../app-events.ts"; -import { formatTimeMs } from "../format.ts"; -import { icons } from "../icons.ts"; -import { formatEventPayload } from "../presenter.ts"; +import { formatTimeMs } from "../../lib/format.ts"; +import { formatEventPayload } from "../../lib/presenter.ts"; export type OverviewEventLogProps = { - events: EventLogEntry[]; + events: readonly EventLogEntry[]; }; export function renderOverviewEventLog(props: OverviewEventLogProps) { diff --git a/ui/src/ui/views/overview-log-tail.ts b/ui/src/pages/overview/log-tail.ts similarity index 96% rename from ui/src/ui/views/overview-log-tail.ts rename to ui/src/pages/overview/log-tail.ts index bd91642d76ef..5e12b849b370 100644 --- a/ui/src/ui/views/overview-log-tail.ts +++ b/ui/src/pages/overview/log-tail.ts @@ -1,7 +1,7 @@ // Control UI view renders overview log tail screen content. import { html, nothing } from "lit"; +import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; const ESCAPE = String.fromCharCode(0x1b); const OSC8_LINK_RE = new RegExp( diff --git a/ui/src/pages/overview/overview-page.ts b/ui/src/pages/overview/overview-page.ts new file mode 100644 index 000000000000..c3fdbb439ca9 --- /dev/null +++ b/ui/src/pages/overview/overview-page.ts @@ -0,0 +1,448 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + AttentionItem, + ModelAuthStatusResult, + SessionsUsageResult, + SkillStatusReport, +} from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { isRouteId } from "../../app-route-paths.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { hasOperatorReadAccess } from "../../app/operator-access.ts"; +import { + loadGatewaySessionSelection, + loadSettings, + patchSettings, + type UiSettings, +} from "../../app/settings.ts"; +import { I18nController, t } from "../../i18n/index.ts"; +import { resolveCronJobLastRunStatus } from "../../lib/cron-status.ts"; +import { createInitialCronState, loadCronJobsPage, loadCronStatus } from "../../lib/cron/index.ts"; +import { isMonitoredAuthProvider, loadModelAuthStatus } from "../../lib/model-auth.ts"; +import { requestSessionUsage } from "../../lib/sessions/index.ts"; +import { loadSkillStatusReport } from "../../lib/skills/index.ts"; +import { renderOverview } from "./view.ts"; + +function localDateString(): string { + const date = new Date(); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String( + date.getDate(), + ).padStart(2, "0")}`; +} + +function hasMissingSkillDependencies(missing: Record | null | undefined): boolean { + return Boolean( + missing && Object.values(missing).some((value) => Array.isArray(value) && value.length > 0), + ); +} + +function addNamedAttention( + items: AttentionItem[], + entries: readonly { name: string }[], + severity: AttentionItem["severity"], + icon: AttentionItem["icon"], + title: string, +) { + if (entries.length > 0) { + items.push({ + severity, + icon, + title, + description: entries.map((entry) => entry.name).join(", "), + }); + } +} + +export class OverviewPage extends LitElement { + readonly i18nController = new I18nController(this); + + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @state() private settings: UiSettings = loadSettings(); + @state() private password = ""; + @state() private showGatewayToken = false; + @state() private showGatewayPassword = false; + @state() private cron = createInitialCronState(); + @state() private usageResult: SessionsUsageResult | null = null; + @state() private skillsReport: SkillStatusReport | null = null; + @state() private modelAuthStatus: ModelAuthStatusResult | null = null; + @state() private overviewLogLines: string[] = []; + + private overviewLogCursor: number | null = null; + private refreshPromise: Promise | null = null; + private subscriptions: Array<() => void> = []; + private sessionKeyDirty = false; + + override connectedCallback() { + super.connectedCallback(); + this.resetServerState(); + this.subscriptions = [ + this.context.gateway.subscribe((snapshot) => { + if (this.cron.client !== snapshot.client) { + this.resetServerState(); + } + this.requestUpdate(); + this.ensureInitialData(); + }), + this.context.channels.subscribe(() => this.requestUpdate()), + this.context.sessions.subscribe(() => this.requestUpdate()), + this.context.gateway.subscribeEventLog(() => this.requestUpdate()), + ]; + this.ensureInitialData(); + } + + override disconnectedCallback() { + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + this.subscriptions = []; + this.refreshPromise = null; + super.disconnectedCallback(); + } + + private resetServerState() { + const gateway = this.context.gateway; + const sessionKey = gateway.snapshot.sessionKey; + this.settings = { + ...loadSettings(), + gatewayUrl: gateway.connection.gatewayUrl, + token: gateway.connection.token, + sessionKey, + lastActiveSessionKey: sessionKey, + }; + this.password = gateway.connection.password; + this.cron = createInitialCronState(gateway.snapshot); + this.usageResult = null; + this.skillsReport = null; + this.modelAuthStatus = null; + this.overviewLogLines = []; + this.overviewLogCursor = null; + this.refreshPromise = null; + this.sessionKeyDirty = false; + } + + private ensureInitialData() { + const gateway = this.context.gateway.snapshot; + if (!gateway.connected || !gateway.client || this.refreshPromise) { + return; + } + void this.refreshOverview(false); + } + + private isCurrentClient(client: GatewayBrowserClient): boolean { + const gateway = this.context.gateway.snapshot; + return gateway.connected && gateway.client === client && this.isConnected; + } + + private async applyRequest( + client: GatewayBrowserClient, + request: Promise, + apply: (value: T) => void, + ) { + const value = await request; + if (this.isCurrentClient(client)) { + apply(value); + } + } + + private async refreshOverview(force: boolean) { + const context = this.context; + const client = context.gateway.snapshot.client; + if (!client || !context.gateway.snapshot.connected || this.refreshPromise) { + return; + } + + const channelRefresh = + force || !context.channels.state.channelsSnapshot + ? context.channels.refresh(false) + : Promise.resolve(); + const date = localDateString(); + const cron = createInitialCronState({ client, connected: true }); + const refresh = Promise.allSettled([ + channelRefresh, + context.sessions.refresh(force ? { force: true } : undefined), + Promise.all([loadCronStatus(cron), loadCronJobsPage(cron)]).then(() => { + if (this.isCurrentClient(client)) { + this.cron = cron; + } + }), + this.applyRequest( + client, + requestSessionUsage(client, { + startDate: date, + endDate: date, + scope: "family", + timeZone: "local", + }), + (result) => (this.usageResult = result), + ), + this.applyRequest(client, loadSkillStatusReport(client, null), (report) => { + this.skillsReport = report ?? null; + }), + this.applyRequest( + client, + loadModelAuthStatus(client, { refresh: force }).catch(() => ({ + ts: 0, + providers: [], + })), + (result) => (this.modelAuthStatus = result), + ), + this.loadLogs(client), + ]).then(() => undefined); + this.refreshPromise = refresh; + try { + await refresh; + } finally { + if (this.refreshPromise === refresh) { + this.refreshPromise = null; + } + } + } + + private async loadLogs(client: GatewayBrowserClient) { + try { + const response = await client.request<{ + cursor?: number; + lines?: unknown; + reset?: boolean; + }>("logs.tail", { + cursor: this.overviewLogCursor ?? undefined, + limit: 100, + maxBytes: 50_000, + }); + if (!this.isCurrentClient(client)) { + return; + } + const lines = Array.isArray(response.lines) + ? response.lines.filter((line): line is string => typeof line === "string") + : []; + this.overviewLogLines = (response.reset ? lines : [...this.overviewLogLines, ...lines]).slice( + -500, + ); + if (typeof response.cursor === "number") { + this.overviewLogCursor = response.cursor; + } + } catch { + // The log tail is optional dashboard context. + } + } + + private updateConnectionDraft(patch: Partial>) { + this.settings = { ...this.settings, ...patch }; + } + + private updateLocale(locale: string) { + const gateway = this.context.gateway; + const navigation = this.context.navigation.snapshot; + const nextDraft = { + ...this.settings, + themeMode: this.context.theme.mode, + navCollapsed: navigation.navCollapsed, + navGroupsCollapsed: navigation.navGroupsCollapsed, + recentSessionsCollapsed: navigation.recentSessionsCollapsed, + locale, + }; + this.settings = nextDraft; + patchSettings({ + gatewayUrl: gateway.connection.gatewayUrl, + token: gateway.connection.token, + sessionKey: gateway.snapshot.sessionKey, + lastActiveSessionKey: gateway.snapshot.sessionKey, + locale, + }); + } + + private connect() { + const session = this.sessionKeyDirty + ? { + sessionKey: this.settings.sessionKey, + lastActiveSessionKey: this.settings.sessionKey, + } + : loadGatewaySessionSelection(this.settings.gatewayUrl); + this.settings = { ...this.settings, ...session }; + this.sessionKeyDirty = false; + this.context.gateway.connect({ + gatewayUrl: this.settings.gatewayUrl, + token: this.settings.token, + password: this.password, + sessionKey: session.sessionKey, + }); + } + + private buildAttentionItems(): AttentionItem[] { + const gateway = this.context.gateway.snapshot; + const items: AttentionItem[] = []; + if (gateway.lastError) { + items.push({ + severity: "error", + icon: "x", + title: "Gateway Error", + description: gateway.lastError, + }); + } + + const auth = gateway.hello?.auth ?? null; + if (auth?.scopes && !hasOperatorReadAccess(auth)) { + items.push({ + severity: "warning", + icon: "key", + title: "Missing operator.read scope", + description: + "This connection does not have the operator.read scope. Some features may be unavailable.", + href: "https://docs.openclaw.ai/web/dashboard", + external: true, + }); + } + + const skills = this.skillsReport?.skills ?? []; + const missingDeps = skills.filter( + (skill) => !skill.disabled && hasMissingSkillDependencies(skill.missing), + ); + if (missingDeps.length > 0) { + const names = missingDeps.slice(0, 3).map((skill) => skill.name); + const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : ""; + items.push({ + severity: "warning", + icon: "zap", + title: "Skills with missing dependencies", + description: `${names.join(", ")}${more}`, + }); + } + + const blocked = skills.filter((skill) => skill.blockedByAllowlist); + addNamedAttention( + items, + blocked, + "warning", + "shield", + `${blocked.length} skill${blocked.length === 1 ? "" : "s"} blocked`, + ); + + const failedCron = this.cron.cronJobs.filter( + (job) => resolveCronJobLastRunStatus(job) === "error", + ); + addNamedAttention( + items, + failedCron, + "error", + "clock", + `${failedCron.length} cron job${failedCron.length === 1 ? "" : "s"} failed`, + ); + + const now = Date.now(); + const overdue = this.cron.cronJobs.filter( + (job) => + job.enabled && job.state?.nextRunAtMs != null && now - job.state.nextRunAtMs > 300_000, + ); + addNamedAttention( + items, + overdue, + "warning", + "clock", + `${overdue.length} overdue job${overdue.length === 1 ? "" : "s"}`, + ); + + const monitored = (this.modelAuthStatus?.providers ?? []).filter(isMonitoredAuthProvider); + const expiredProviders = monitored.filter( + (provider) => provider.status === "expired" || provider.status === "missing", + ); + if (expiredProviders.length > 0) { + items.push({ + severity: "error", + icon: "key", + title: t("overview.cards.modelAuthAttentionExpiredTitle"), + description: t("overview.cards.modelAuthAttentionExpiredDesc", { + providers: expiredProviders.map((provider) => provider.displayName).join(", "), + }), + }); + } + const expiringProviders = monitored.filter((provider) => provider.status === "expiring"); + if (expiringProviders.length > 0) { + items.push({ + severity: "warning", + icon: "key", + title: t("overview.cards.modelAuthAttentionExpiringTitle"), + description: expiringProviders + .map((provider) => + t("overview.cards.modelAuthAttentionExpiringEntry", { + provider: provider.displayName, + when: provider.expiry?.label ?? "soon", + }), + ) + .join(", "), + }); + } + return items; + } + + override render() { + const gateway = this.context.gateway.snapshot; + const channels = this.context.channels.state; + const sessions = this.context.sessions.state; + return html` +
+
+
${titleForRoute("overview")}
+
${subtitleForRoute("overview")}
+
+
+ ${renderOverview({ + connected: gateway.connected, + hello: gateway.hello, + settings: this.settings, + password: this.password, + lastError: gateway.lastError, + lastChannelsRefresh: channels.channelsLastSuccess, + modelAuthStatus: this.modelAuthStatus, + usageResult: this.usageResult, + sessionsResult: sessions.result, + skillsReport: this.skillsReport, + cronJobs: this.cron.cronJobs, + cronStatus: this.cron.cronStatus, + attentionItems: this.buildAttentionItems(), + eventLog: this.context.gateway.eventLog, + overviewLogLines: this.overviewLogLines, + showGatewayToken: this.showGatewayToken, + showGatewayPassword: this.showGatewayPassword, + onConnectionChange: (patch) => this.updateConnectionDraft(patch), + onLocaleChange: (locale) => this.updateLocale(locale), + onPasswordChange: (next) => (this.password = next), + onSessionKeyChange: (sessionKey) => { + this.sessionKeyDirty = true; + this.settings = { + ...this.settings, + sessionKey, + lastActiveSessionKey: sessionKey, + }; + }, + onToggleGatewayTokenVisibility: () => { + this.showGatewayToken = !this.showGatewayToken; + }, + onToggleGatewayPasswordVisibility: () => { + this.showGatewayPassword = !this.showGatewayPassword; + }, + onConnect: () => this.connect(), + onRefresh: () => void this.refreshOverview(true), + onNavigate: (routeId) => { + if (isRouteId(routeId)) { + this.context.navigate(routeId); + } + }, + canNavigate: isRouteId, + onRefreshLogs: () => void this.refreshOverview(true), + })} + `; + } +} + +if (!customElements.get("openclaw-overview-page")) { + customElements.define("openclaw-overview-page", OverviewPage); +} diff --git a/ui/src/pages/overview/route.ts b/ui/src/pages/overview/route.ts new file mode 100644 index 000000000000..b00ed130898d --- /dev/null +++ b/ui/src/pages/overview/route.ts @@ -0,0 +1,12 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; + +export const page = definePage({ + id: "overview", + path: "/overview", + component: () => + import("./overview-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/views/overview.render.test.ts b/ui/src/pages/overview/view.render.test.ts similarity index 71% rename from ui/src/ui/views/overview.render.test.ts rename to ui/src/pages/overview/view.render.test.ts index 9751381eb4f2..9f886d64cd99 100644 --- a/ui/src/ui/views/overview.render.test.ts +++ b/ui/src/pages/overview/view.render.test.ts @@ -4,11 +4,10 @@ import { render } from "lit"; import { describe, expect, it } from "vitest"; import { i18n } from "../../i18n/index.ts"; import { getSafeLocalStorage } from "../../local-storage.ts"; -import { renderOverview, type OverviewProps } from "./overview.ts"; +import { renderOverview, type OverviewProps } from "./view.ts"; function createOverviewProps(overrides: Partial = {}): OverviewProps { return { - warnQueryToken: false, connected: false, hello: null, settings: { @@ -29,11 +28,6 @@ function createOverviewProps(overrides: Partial = {}): OverviewPr }, password: "", lastError: null, - lastErrorCode: null, - presenceCount: 0, - sessionsCount: null, - cronEnabled: null, - cronNext: null, lastChannelsRefresh: null, modelAuthStatus: null, usageResult: null, @@ -46,7 +40,8 @@ function createOverviewProps(overrides: Partial = {}): OverviewPr overviewLogLines: [], showGatewayToken: false, showGatewayPassword: false, - onSettingsChange: () => undefined, + onConnectionChange: () => undefined, + onLocaleChange: () => undefined, onPasswordChange: () => undefined, onSessionKeyChange: () => undefined, onToggleGatewayTokenVisibility: () => undefined, @@ -54,6 +49,7 @@ function createOverviewProps(overrides: Partial = {}): OverviewPr onConnect: () => undefined, onRefresh: () => undefined, onNavigate: () => undefined, + canNavigate: () => true, onRefreshLogs: () => undefined, ...overrides, }; @@ -95,45 +91,6 @@ describe("overview view rendering", () => { await i18n.setLocale("en"); }); - it("renders a dedicated scope-upgrade approval hint with the exact approve command", async () => { - const container = document.createElement("div"); - const props = createOverviewProps({ - lastError: "scope upgrade pending approval (requestId: req-123)", - lastErrorCode: "PAIRING_REQUIRED", - }); - - render(renderOverview(props), container); - await Promise.resolve(); - - const hint = container.querySelector(".mono")?.closest(".muted") ?? null; - expect(compactText(hint)).toBe( - "Scope upgrade pending approval. This device is already paired, but the requested wider scope is waiting for approval. openclaw devices approve req-123 openclaw devices list On mobile? Copy the full URL (including #token=...) from openclaw dashboard --no-open on your desktop. Docs: Device pairing", - ); - expect([...container.querySelectorAll(".mono")].map((node) => node.textContent)).toEqual([ - "openclaw devices approve req-123", - "openclaw devices list", - ]); - }); - - it("does not suggest preview-only latest approval when the request id is absent", async () => { - const container = document.createElement("div"); - const props = createOverviewProps({ - lastError: "scope upgrade pending approval", - lastErrorCode: "PAIRING_REQUIRED", - }); - - render(renderOverview(props), container); - await Promise.resolve(); - - const hint = container.querySelector(".mono")?.closest(".muted") ?? null; - expect(compactText(hint)).toBe( - "Scope upgrade pending approval. This device is already paired, but the requested wider scope is waiting for approval. openclaw devices list On mobile? Copy the full URL (including #token=...) from openclaw dashboard --no-open on your desktop. Docs: Device pairing", - ); - expect([...container.querySelectorAll(".mono")].map((node) => node.textContent)).toEqual([ - "openclaw devices list", - ]); - }); - it("renders recent session names through the shared display resolver", async () => { const container = document.createElement("div"); const props = createOverviewProps({ diff --git a/ui/src/pages/overview/view.ts b/ui/src/pages/overview/view.ts new file mode 100644 index 000000000000..b88d01ba6fbd --- /dev/null +++ b/ui/src/pages/overview/view.ts @@ -0,0 +1,272 @@ +// Control UI view renders overview screen content. +import { html } from "lit"; +import type { EventLogEntry } from "../../api/event-log.ts"; +import type { GatewayHelloOk } from "../../api/gateway.ts"; +import type { + AttentionItem, + CronJob, + CronStatus, + ModelAuthStatusResult, + SessionsListResult, + SessionsUsageResult, + SkillStatusReport, +} from "../../api/types.ts"; +import type { NavigationRouteId } from "../../app-navigation.ts"; +import { resolveGatewayTokenForUrlEdit, type UiSettings } from "../../app/settings.ts"; +import "../../components/tooltip.ts"; +import { icons } from "../../components/icons.ts"; +import { t, i18n, SUPPORTED_LOCALES, type Locale, isSupportedLocale } from "../../i18n/index.ts"; +import { formatRelativeTimestamp, formatDurationHuman } from "../../lib/format.ts"; +import { renderOverviewAttention } from "./attention.ts"; +import { renderOverviewCards } from "./cards.ts"; +import { renderOverviewEventLog } from "./event-log.ts"; +import { renderOverviewLogTail } from "./log-tail.ts"; + +export type OverviewProps = { + connected: boolean; + hello: GatewayHelloOk | null; + settings: UiSettings; + password: string; + lastError: string | null; + lastChannelsRefresh: number | null; + modelAuthStatus: ModelAuthStatusResult | null; + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + attentionItems: AttentionItem[]; + eventLog: readonly EventLogEntry[]; + overviewLogLines: string[]; + showGatewayToken: boolean; + showGatewayPassword: boolean; + onConnectionChange: (patch: Partial>) => void; + onLocaleChange: (locale: Locale) => void; + onPasswordChange: (next: string) => void; + onSessionKeyChange: (next: string) => void; + onToggleGatewayTokenVisibility: () => void; + onToggleGatewayPasswordVisibility: () => void; + onConnect: () => void; + onRefresh: () => void; + onNavigate: (routeId: NavigationRouteId) => void; + canNavigate: (routeId: NavigationRouteId) => boolean; + onRefreshLogs: () => void; +}; + +export function renderOverview(props: OverviewProps) { + const snapshot = props.hello?.snapshot as + | { + uptimeMs?: number; + authMode?: "none" | "token" | "password" | "trusted-proxy"; + } + | undefined; + const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); + const tickIntervalMs = props.hello?.policy?.tickIntervalMs; + const tick = tickIntervalMs + ? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s` + : t("common.na"); + const authMode = snapshot?.authMode; + const isTrustedProxy = authMode === "trusted-proxy"; + + const currentLocale = isSupportedLocale(props.settings.locale) + ? props.settings.locale + : i18n.getLocale(); + + return html` +
+
+
${t("overview.access.title")}
+
${t("overview.access.subtitle")}
+
+ + ${isTrustedProxy + ? "" + : html` + + + `} + + +
+
+ + + ${isTrustedProxy + ? t("overview.access.trustedProxy") + : t("overview.access.connectHint")} +
+
+ +
+
${t("overview.snapshot.title")}
+
${t("overview.snapshot.subtitle")}
+
+
+
${t("overview.snapshot.status")}
+
+ ${props.connected ? t("common.ok") : t("common.offline")} +
+
+
+
${t("overview.snapshot.uptime")}
+
${uptime}
+
+
+
${t("overview.snapshot.tickInterval")}
+
${tick}
+
+
+
${t("overview.snapshot.lastChannelsRefresh")}
+
+ ${props.lastChannelsRefresh + ? formatRelativeTimestamp(props.lastChannelsRefresh) + : t("common.na")} +
+
+
+ ${props.lastError + ? html`
+
${props.lastError}
+
` + : html` +
+ ${t("overview.snapshot.channelsHint")} +
+ `} +
+
+ +
+ + ${renderOverviewCards({ + usageResult: props.usageResult, + sessionsResult: props.sessionsResult, + skillsReport: props.skillsReport, + cronJobs: props.cronJobs, + cronStatus: props.cronStatus, + modelAuthStatus: props.modelAuthStatus, + onNavigate: props.onNavigate, + canNavigate: props.canNavigate, + })} + ${renderOverviewAttention({ items: props.attentionItems })} + +
+ +
+ ${renderOverviewEventLog({ + events: props.eventLog, + })} + ${renderOverviewLogTail({ + lines: props.overviewLogLines, + onRefreshLogs: props.onRefreshLogs, + })} +
+ `; +} diff --git a/ui/src/pages/sessions/route.ts b/ui/src/pages/sessions/route.ts new file mode 100644 index 000000000000..2dea614e167c --- /dev/null +++ b/ui/src/pages/sessions/route.ts @@ -0,0 +1,62 @@ +import type { RouteLocation } from "@openclaw/uirouter"; +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; +import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; +import type { SessionsRouteData } from "./sessions-page.ts"; + +function routeOptions(location: RouteLocation) { + const search = new URLSearchParams(location.search); + const expandedCheckpointKey = search.get("session")?.trim() || null; + const showArchived = ["1", "true"].includes(search.get("showArchived")?.toLowerCase() ?? ""); + return { expandedCheckpointKey, showArchived }; +} + +async function loadSessionsRoute( + context: ApplicationContext, + location: RouteLocation, +): Promise { + const options = routeOptions(location); + const checkpointAgentId = parseAgentSessionKey(options.expandedCheckpointKey)?.agentId; + const [sessions] = await Promise.all([ + context.sessions + .list({ + activeMinutes: options.expandedCheckpointKey || options.showArchived ? 0 : 60, + limit: 50, + search: options.expandedCheckpointKey ?? undefined, + includeGlobal: true, + includeUnknown: Boolean(options.expandedCheckpointKey), + showArchived: options.showArchived, + ...(checkpointAgentId ? { agentId: checkpointAgentId } : {}), + }) + .then( + (result) => ({ result, error: null }), + (error: unknown) => ({ result: null, error: String(error) }), + ), + context.runtimeConfig.ensureLoaded().catch(() => undefined), + ]); + const gateway = context.gateway.snapshot; + return { + client: gateway.client, + connected: gateway.connected, + result: sessions.result, + error: sessions.error, + ...options, + }; +} + +export const page = definePage({ + id: "sessions", + path: "/sessions", + loaderDeps: (_context: ApplicationContext, location: RouteLocation) => { + const options = routeOptions(location); + return `${options.expandedCheckpointKey ?? ""}\u0000${options.showArchived ? "1" : "0"}`; + }, + loader: (context: ApplicationContext, { location }) => loadSessionsRoute(context, location), + component: () => + import("./sessions-page.ts").then(() => ({ + header: true, + render: (data: SessionsRouteData | undefined) => + html``, + })), +}); diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts new file mode 100644 index 000000000000..4e41a9db4085 --- /dev/null +++ b/ui/src/pages/sessions/sessions-page.ts @@ -0,0 +1,762 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + AgentIdentityResult, + GatewaySessionRow, + SessionCompactionCheckpoint, + SessionsListResult, +} from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { hasOperatorWriteAccess } from "../../app/operator-access.ts"; +import { isPluginEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts"; +import { + filterSessionRows, + scopedAgentParamsForSession, + searchForSession, +} from "../../lib/sessions/index.ts"; +import { + areUiSessionKeysEquivalent, + buildAgentMainSessionKey, + parseAgentSessionKey, + resolveUiConfiguredMainKey, +} from "../../lib/sessions/session-key.ts"; +import { captureSessionToWorkboard } from "../../lib/workboard/index.ts"; +import { renderSessions, type SessionsProps } from "./view.ts"; + +export type SessionsRouteData = { + client: GatewayBrowserClient | null; + connected: boolean; + result: SessionsListResult | null; + error: string | null; + expandedCheckpointKey: string | null; + showArchived: boolean; +}; + +function parseFilterInteger(value: string): number | undefined { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +export class SessionsPage extends LitElement { + @consume({ context: applicationContext, subscribe: false }) + private context?: ApplicationContext; + + @property({ attribute: false }) routeData?: SessionsRouteData; + + @state() private result: SessionsListResult | null = null; + @state() private loading = false; + @state() private error: string | null = null; + @state() private activeMinutes = "60"; + @state() private limit = "50"; + @state() private includeGlobal = true; + @state() private includeUnknown = false; + @state() private showArchived = false; + @state() private filtersCollapsed = false; + @state() private searchQuery = ""; + @state() private sortColumn: "key" | "kind" | "updated" | "tokens" = "updated"; + @state() private sortDir: "asc" | "desc" = "desc"; + @state() private page = 0; + @state() private pageSize = 25; + @state() private selectedKeys = new Set(); + @state() private expandedCheckpointKey: string | null = null; + @state() private checkpointItemsByKey: Record = {}; + @state() private checkpointLoadingKey: string | null = null; + @state() private checkpointBusyKey: string | null = null; + @state() private checkpointErrorByKey: Record = {}; + + private stopSessionSubscription?: () => void; + private stopAgentIdentitySubscription?: () => void; + private stopAgentSelectionSubscription?: () => void; + private stopGatewaySubscription?: () => void; + private stopRuntimeConfigSubscription?: () => void; + private stopWorkboardSubscription?: () => void; + private sessionRequestId = 0; + private checkpointRequestId = 0; + private routeDataInitialized = false; + private routeDataEnabled = true; + private appliedRouteData?: SessionsRouteData; + private ignorePendingSharedRefresh = false; + private sessionMutationPending = false; + private sessionReloadQueued = false; + private sharedSessionsResult: SessionsListResult | null = null; + private sharedSessionsLoading = false; + private gatewayClient: GatewayBrowserClient | null = null; + private gatewayConnected = false; + + override createRenderRoot() { + return this; + } + + override connectedCallback() { + super.connectedCallback(); + this.startSessionState(); + this.startAgentIdentityState(); + } + + override willUpdate(changed: Map) { + if (changed.has("routeData") || changed.has("context")) { + this.applyRouteData(); + } + } + + override updated() { + this.startSessionState(); + this.startAgentIdentityState(); + this.startApplicationState(); + } + + override disconnectedCallback() { + this.stopSessionSubscription?.(); + this.stopSessionSubscription = undefined; + this.stopAgentIdentitySubscription?.(); + this.stopAgentIdentitySubscription = undefined; + this.stopAgentSelectionSubscription?.(); + this.stopAgentSelectionSubscription = undefined; + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopRuntimeConfigSubscription?.(); + this.stopRuntimeConfigSubscription = undefined; + this.stopWorkboardSubscription?.(); + this.stopWorkboardSubscription = undefined; + this.sessionRequestId += 1; + this.checkpointRequestId += 1; + this.sessionReloadQueued = false; + this.gatewayClient = null; + this.gatewayConnected = false; + super.disconnectedCallback(); + } + + private startSessionState() { + const context = this.context; + if (!context || this.stopSessionSubscription) { + return; + } + this.sharedSessionsResult = context.sessions.state.result; + this.sharedSessionsLoading = context.sessions.state.loading; + this.stopSessionSubscription = context.sessions.subscribe((snapshot) => { + const resultChanged = snapshot.result !== this.sharedSessionsResult; + const refreshCompleted = this.sharedSessionsLoading && !snapshot.loading; + this.sharedSessionsResult = snapshot.result; + this.sharedSessionsLoading = snapshot.loading; + if (snapshot.loading || !this.routeDataInitialized || this.sessionMutationPending) { + return; + } + if (this.ignorePendingSharedRefresh && refreshCompleted) { + this.ignorePendingSharedRefresh = false; + return; + } + if (resultChanged) { + this.scheduleSessionReload(); + } + }); + } + + private startAgentIdentityState() { + const context = this.context; + if (!context || this.stopAgentIdentitySubscription) { + return; + } + this.stopAgentIdentitySubscription = context.agentIdentity.subscribe(() => + this.requestUpdate(), + ); + } + + private startApplicationState() { + const context = this.context; + if (!context || this.stopGatewaySubscription) { + return; + } + this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => + this.requestUpdate(), + ); + const gateway = context.gateway.snapshot; + this.gatewayClient = gateway.client; + this.gatewayConnected = gateway.connected; + this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => + this.applyGatewaySnapshot(snapshot), + ); + this.stopRuntimeConfigSubscription = context.runtimeConfig.subscribe(() => + this.requestUpdate(), + ); + this.stopWorkboardSubscription = context.workboard.subscribe(() => this.requestUpdate()); + } + + private applyGatewaySnapshot(snapshot: ApplicationContext["gateway"]["snapshot"]) { + const clientChanged = snapshot.client !== this.gatewayClient; + const becameConnected = snapshot.connected && !this.gatewayConnected; + this.gatewayClient = snapshot.client; + this.gatewayConnected = snapshot.connected; + if (clientChanged) { + this.ignorePendingSharedRefresh = false; + this.sessionRequestId += 1; + this.checkpointRequestId += 1; + this.result = null; + this.error = null; + this.loading = false; + this.selectedKeys = new Set(); + this.expandedCheckpointKey = null; + this.checkpointItemsByKey = {}; + this.checkpointLoadingKey = null; + this.checkpointBusyKey = null; + this.checkpointErrorByKey = {}; + } + if (!snapshot.connected || !snapshot.client) { + this.sessionRequestId += 1; + this.loading = false; + this.requestUpdate(); + return; + } + if (this.routeDataInitialized && (clientChanged || becameConnected)) { + this.ignorePendingSharedRefresh = true; + void this.loadSessions(); + } + this.requestUpdate(); + } + + private applyRouteData() { + const data = this.routeData; + const context = this.context; + if (!data || !context) { + return; + } + if (data !== this.appliedRouteData) { + this.appliedRouteData = data; + this.routeDataEnabled = true; + } + this.routeDataInitialized = true; + if (!this.routeDataEnabled) { + return; + } + this.showArchived = data.showArchived; + if (data.expandedCheckpointKey) { + this.activeMinutes = ""; + this.limit = ""; + this.includeGlobal = true; + this.includeUnknown = true; + this.searchQuery = ""; + this.page = 0; + this.selectedKeys = new Set(); + } else { + this.activeMinutes = "60"; + this.limit = "50"; + this.includeGlobal = true; + this.includeUnknown = false; + } + this.expandedCheckpointKey = data.expandedCheckpointKey; + const gateway = context.gateway.snapshot; + if (data.client !== gateway.client || data.connected !== gateway.connected) { + this.routeDataEnabled = false; + void this.loadSessions(); + if (data.expandedCheckpointKey) { + void this.loadCheckpoint(data.expandedCheckpointKey); + } + return; + } + this.result = data.result + ? filterSessionRows(data.result, { showArchived: data.showArchived }) + : null; + this.error = data.error; + this.loading = false; + const sharedSessions = context.sessions.state; + this.ignorePendingSharedRefresh = sharedSessions.loading; + this.ensureAgentIdentities(this.result); + if (data.expandedCheckpointKey) { + void this.loadCheckpoint(data.expandedCheckpointKey); + } + } + + private scheduleSessionReload() { + if (this.sessionReloadQueued) { + return; + } + this.sessionReloadQueued = true; + queueMicrotask(() => { + this.sessionReloadQueued = false; + const context = this.context; + const gateway = context?.gateway.snapshot; + if ( + this.isConnected && + context && + gateway?.connected && + gateway.client && + !context.sessions.state.loading + ) { + void this.loadSessions(); + } + }); + } + + private sessionAgentId(key: string): string | undefined { + const context = this.context; + if (!context) { + return undefined; + } + const { agentId } = scopedAgentParamsForSession( + { + assistantAgentId: context.agentSelection.state.selectedId, + hello: context.gateway.snapshot.hello, + }, + key, + ); + return agentId; + } + + private sessionListOptions() { + const checkpointKey = this.expandedCheckpointKey; + return { + activeMinutes: + checkpointKey || this.showArchived ? 0 : parseFilterInteger(this.activeMinutes), + limit: checkpointKey ? 50 : parseFilterInteger(this.limit), + search: checkpointKey ?? undefined, + includeGlobal: checkpointKey ? true : this.includeGlobal, + includeUnknown: checkpointKey ? true : this.includeUnknown, + showArchived: this.showArchived, + ...(checkpointKey ? { agentId: this.sessionAgentId(checkpointKey) } : {}), + }; + } + + private async loadSessions() { + const context = this.context; + if (!context) { + return; + } + const requestId = ++this.sessionRequestId; + const previous = this.result; + this.routeDataEnabled = false; + this.loading = true; + this.error = null; + try { + const result = await context.sessions.list(this.sessionListOptions()); + if (requestId !== this.sessionRequestId) { + return; + } + this.result = result ? filterSessionRows(result, { showArchived: this.showArchived }) : null; + this.ensureAgentIdentities(this.result); + const checkpointKey = this.reconcileCheckpointCache(previous, this.result); + if (checkpointKey) { + void this.loadCheckpoint(checkpointKey); + } + } catch (error) { + if (requestId === this.sessionRequestId) { + this.error = String(error); + } + } finally { + if (requestId === this.sessionRequestId) { + this.loading = false; + } + } + } + + private ensureAgentIdentities(result: SessionsListResult | null) { + const context = this.context; + if (!context || !result) { + return; + } + const agentIds = this.sessionAgentIds(result).filter( + (agentId) => !context.agentIdentity.get(agentId), + ); + if (agentIds.length === 0) { + return; + } + void context.agentIdentity.ensure(agentIds); + } + + private sessionAgentIds(result: SessionsListResult | null): string[] { + return [ + ...new Set( + (result?.sessions ?? []) + .map((row) => parseAgentSessionKey(row.key)?.agentId) + .filter((agentId): agentId is string => Boolean(agentId)), + ), + ]; + } + + private sessionAgentIdentityById( + result: SessionsListResult | null, + ): Record { + const context = this.context; + if (!context) { + return {}; + } + return Object.fromEntries( + this.sessionAgentIds(result) + .map((agentId) => [agentId, context.agentIdentity.get(agentId)] as const) + .filter((entry): entry is readonly [string, AgentIdentityResult] => Boolean(entry[1])), + ); + } + + private reconcileCheckpointCache( + previous: SessionsListResult | null, + result: SessionsListResult | null, + ): string | null { + const rows = new Map((result?.sessions ?? []).map((row) => [row.key, row] as const)); + const previousRows = new Map((previous?.sessions ?? []).map((row) => [row.key, row] as const)); + const nextItems = { ...this.checkpointItemsByKey }; + const nextErrors = { ...this.checkpointErrorByKey }; + let checkpointKey: string | null = null; + for (const key of Object.keys(nextItems)) { + const row = rows.get(key); + const previousRow = previousRows.get(key); + if ( + !row || + !previousRow || + previousRow.compactionCheckpointCount !== row.compactionCheckpointCount || + previousRow.latestCompactionCheckpoint?.checkpointId !== + row.latestCompactionCheckpoint?.checkpointId + ) { + delete nextItems[key]; + delete nextErrors[key]; + if (this.expandedCheckpointKey === key) { + checkpointKey = key; + } + } + } + this.checkpointItemsByKey = nextItems; + this.checkpointErrorByKey = nextErrors; + return checkpointKey; + } + + private updateFilters(next: { + activeMinutes: string; + limit: string; + includeGlobal: boolean; + includeUnknown: boolean; + showArchived: boolean; + }) { + this.activeMinutes = next.activeMinutes; + this.limit = next.limit; + this.includeGlobal = next.includeGlobal; + this.includeUnknown = next.includeUnknown; + this.showArchived = next.showArchived; + this.page = 0; + this.selectedKeys = new Set(); + void this.loadSessions(); + } + + private async deleteSelected() { + const context = this.context; + const keys = [...this.selectedKeys]; + if (!context || keys.length === 0 || this.loading) { + return; + } + if ( + !window.confirm( + `Delete ${keys.length} ${keys.length === 1 ? "session" : "sessions"}?\n\nThis will delete the session entries and archive their transcripts.`, + ) + ) { + return; + } + this.sessionMutationPending = true; + const result = await context.sessions + .deleteMany( + keys.map((key) => ({ + key, + agentId: this.sessionAgentId(key), + })), + ) + .finally(() => { + this.sessionMutationPending = false; + }); + if (result.deleted.length > 0) { + const deleted = new Set(result.deleted); + const selected = new Set(this.selectedKeys); + for (const key of result.deleted) { + selected.delete(key); + } + this.selectedKeys = selected; + if (this.result) { + const sessions = this.result.sessions.filter((row) => !deleted.has(row.key)); + this.result = { + ...this.result, + count: Math.max(0, this.result.count - (this.result.sessions.length - sessions.length)), + sessions, + }; + } + if (this.expandedCheckpointKey && deleted.has(this.expandedCheckpointKey)) { + this.expandedCheckpointKey = null; + } + } + if (result.errors.length > 0) { + this.error = result.errors.join("; "); + } + } + + private async patchSession(key: string, patch: Parameters[1]) { + const context = this.context; + if (!context) { + return; + } + try { + const patched = await context.sessions.patch(key, patch, { + agentId: this.sessionAgentId(key), + }); + if (!patched) { + this.error = context.sessions.state.error; + return; + } + const selectedKeys = new Set(this.selectedKeys); + selectedKeys.delete(key); + this.selectedKeys = selectedKeys; + if ( + patch.archived === true && + areUiSessionKeysEquivalent(key, context.gateway.snapshot.sessionKey) + ) { + context.gateway.setSessionKey( + buildAgentMainSessionKey({ + agentId: + parseAgentSessionKey(key)?.agentId ?? + context.agentSelection.state.selectedId ?? + "main", + mainKey: resolveUiConfiguredMainKey({ + agentsList: context.agents.state.agentsList, + hello: context.gateway.snapshot.hello, + }), + }), + ); + } + } catch (error) { + this.error = String(error); + } + } + + private async toggleCheckpointDetails(sessionKey: string) { + const context = this.context; + if (!context) { + return; + } + if (this.expandedCheckpointKey === sessionKey) { + this.checkpointRequestId += 1; + this.expandedCheckpointKey = null; + return; + } + this.expandedCheckpointKey = sessionKey; + if (this.checkpointItemsByKey[sessionKey]) { + return; + } + await this.loadCheckpoint(sessionKey); + } + + private async loadCheckpoint(sessionKey: string) { + const context = this.context; + if (!context) { + return; + } + const requestId = ++this.checkpointRequestId; + this.checkpointLoadingKey = sessionKey; + this.checkpointErrorByKey = { ...this.checkpointErrorByKey, [sessionKey]: "" }; + try { + const checkpoints = await context.sessions.listCheckpoints(sessionKey, { + agentId: this.sessionAgentId(sessionKey), + }); + if (requestId !== this.checkpointRequestId) { + return; + } + this.checkpointItemsByKey = { ...this.checkpointItemsByKey, [sessionKey]: checkpoints }; + } catch (error) { + if (requestId !== this.checkpointRequestId) { + return; + } + this.checkpointErrorByKey = { + ...this.checkpointErrorByKey, + [sessionKey]: String(error), + }; + } finally { + if (requestId === this.checkpointRequestId && this.checkpointLoadingKey === sessionKey) { + this.checkpointLoadingKey = null; + } + } + } + + private async branchCheckpoint(sessionKey: string, checkpointId: string) { + const context = this.context; + if (!context) { + return; + } + if (!window.confirm("Create a new child session from this compacted checkpoint?")) { + return; + } + this.checkpointBusyKey = checkpointId; + try { + const result = await context.sessions.branchCheckpoint(sessionKey, checkpointId, { + agentId: this.sessionAgentId(sessionKey), + }); + context.navigate("chat", { search: searchForSession(result.key), hash: "" }); + } catch (error) { + this.error = String(error); + } finally { + if (this.checkpointBusyKey === checkpointId) { + this.checkpointBusyKey = null; + } + } + } + + private async restoreCheckpoint(sessionKey: string, checkpointId: string) { + const context = this.context; + if (!context) { + return; + } + if ( + !window.confirm( + "Restore this session to the selected compacted checkpoint?\n\nThis replaces the current active transcript for the session key.", + ) + ) { + return; + } + this.checkpointBusyKey = checkpointId; + try { + await context.sessions.restoreCheckpoint(sessionKey, checkpointId, { + agentId: this.sessionAgentId(sessionKey), + }); + } catch (error) { + this.error = String(error); + } finally { + if (this.checkpointBusyKey === checkpointId) { + this.checkpointBusyKey = null; + } + } + } + + override render() { + const context = this.context; + if (!context) { + return html``; + } + const gateway = context.gateway.snapshot; + const workboardEnabled = isPluginEnabledInConfigSnapshot( + context.runtimeConfig.state.configSnapshot, + "workboard", + { enabledByDefault: false }, + ); + const canCapture = workboardEnabled && hasOperatorWriteAccess(gateway.hello?.auth ?? null); + const workboardState = context.workboard.state; + return html` +
+
+
${titleForRoute("sessions")}
+
${subtitleForRoute("sessions")}
+
+
+ ${renderSessions({ + loading: this.loading, + result: this.result, + error: this.error, + activeMinutes: this.activeMinutes, + limit: this.limit, + includeGlobal: this.includeGlobal, + includeUnknown: this.includeUnknown, + showArchived: this.showArchived, + mainKey: resolveUiConfiguredMainKey({ + agentsList: context.agents.state.agentsList, + hello: context.gateway.snapshot.hello, + }), + filtersCollapsed: this.filtersCollapsed, + basePath: context.basePath, + searchQuery: this.searchQuery, + agentIdentityById: this.sessionAgentIdentityById(this.result), + sortColumn: this.sortColumn, + sortDir: this.sortDir, + page: this.page, + pageSize: this.pageSize, + selectedKeys: this.selectedKeys, + workboardSessionKeys: new Set( + workboardState.cards + .flatMap((card) => [card.sessionKey, card.execution?.sessionKey]) + .filter((key): key is string => typeof key === "string" && key.length > 0), + ), + workboardBusySessionKey: [...workboardState.capturingSessionKeys][0] ?? null, + expandedCheckpointKey: this.expandedCheckpointKey, + checkpointItemsByKey: this.checkpointItemsByKey, + checkpointLoadingKey: this.checkpointLoadingKey, + checkpointBusyKey: this.checkpointBusyKey, + checkpointErrorByKey: this.checkpointErrorByKey, + onFiltersChange: (next) => this.updateFilters(next), + onToggleFiltersCollapsed: () => { + this.filtersCollapsed = !this.filtersCollapsed; + }, + onClearFilters: () => { + this.activeMinutes = ""; + this.limit = ""; + this.includeGlobal = true; + this.includeUnknown = true; + this.showArchived = false; + this.searchQuery = ""; + this.page = 0; + this.selectedKeys = new Set(); + void this.loadSessions(); + }, + onSearchChange: (query) => { + this.searchQuery = query; + this.page = 0; + }, + onSortChange: (column, direction) => { + this.sortColumn = column; + this.sortDir = direction; + this.page = 0; + }, + onPageChange: (page) => { + this.page = page; + }, + onPageSizeChange: (pageSize) => { + this.pageSize = pageSize; + this.page = 0; + }, + onRefresh: () => void this.loadSessions(), + onPatch: (key, patch) => void this.patchSession(key, patch), + onToggleSelect: (key) => { + const next = new Set(this.selectedKeys); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + this.selectedKeys = next; + }, + onSelectPage: (keys) => { + this.selectedKeys = new Set([...this.selectedKeys, ...keys]); + }, + onDeselectPage: (keys) => { + const next = new Set(this.selectedKeys); + for (const key of keys) { + next.delete(key); + } + this.selectedKeys = next; + }, + onDeselectAll: () => { + this.selectedKeys = new Set(); + }, + onDeleteSelected: () => void this.deleteSelected(), + onNavigateToChat: (sessionKey) => + context.navigate("chat", { search: searchForSession(sessionKey), hash: "" }), + onAddToWorkboard: canCapture + ? (session: GatewaySessionRow) => this.addToWorkboard(session) + : undefined, + onToggleCheckpointDetails: (sessionKey) => void this.toggleCheckpointDetails(sessionKey), + onBranchFromCheckpoint: (sessionKey, checkpointId) => + void this.branchCheckpoint(sessionKey, checkpointId), + onRestoreCheckpoint: (sessionKey, checkpointId) => + void this.restoreCheckpoint(sessionKey, checkpointId), + })} + `; + } + + private async addToWorkboard(session: GatewaySessionRow) { + const context = this.context; + if (!context) { + return; + } + await captureSessionToWorkboard({ + host: context.workboard, + client: context.gateway.snapshot.client, + session, + requestUpdate: context.workboard.notify, + }); + context.navigate("workboard"); + } +} + +if (!customElements.get("openclaw-sessions-page")) { + customElements.define("openclaw-sessions-page", SessionsPage); +} diff --git a/ui/src/ui/views/sessions.browser.test.ts b/ui/src/pages/sessions/view.browser.test.ts similarity index 98% rename from ui/src/ui/views/sessions.browser.test.ts rename to ui/src/pages/sessions/view.browser.test.ts index 67f5e8c6a181..478c65631b6d 100644 --- a/ui/src/ui/views/sessions.browser.test.ts +++ b/ui/src/pages/sessions/view.browser.test.ts @@ -80,7 +80,7 @@ function sessionsTableHtml() { -
+ @@ -106,7 +106,7 @@ function sessionsTableHtml() { - + diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/pages/sessions/view.test.ts similarity index 94% rename from ui/src/ui/views/sessions.test.ts rename to ui/src/pages/sessions/view.test.ts index 1fe0e24542c6..74148337e7a7 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/pages/sessions/view.test.ts @@ -2,8 +2,8 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { SessionsListResult } from "../types.ts"; -import { renderSessions, type SessionsProps } from "./sessions.ts"; +import type { SessionsListResult } from "../../api/types.ts"; +import { renderSessions, type SessionsProps } from "./view.ts"; function buildResult( session: SessionsListResult["sessions"][number], @@ -150,7 +150,9 @@ describe("sessions view", () => { ); await Promise.resolve(); - const button = container.querySelector('button[title="Add to Workboard"]'); + const button = container.querySelector( + 'button[aria-label="Add to Workboard"]', + ); if (!(button instanceof HTMLButtonElement)) { throw new Error("Expected Add to Workboard button"); } @@ -242,10 +244,10 @@ describe("sessions view", () => { ); await Promise.resolve(); - expect(container.querySelector('button[title="Open Workboard card"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Open Workboard card"]')).not.toBeNull(); }); - it("uses one short styled tooltip per session filter", async () => { + it("uses the shared tooltip component for session filters", async () => { const container = document.createElement("div"); render( renderSessions({ @@ -257,33 +259,19 @@ describe("sessions view", () => { await Promise.resolve(); const filters = container.querySelector(".sessions-filter-bar"); - const activeField = filters - ?.querySelector(".session-filter-input--minutes") - ?.closest("label"); - const limitField = filters - ?.querySelector(".session-filter-input--limit") - ?.closest("label"); - const globalToggle = filters - ?.querySelector(".session-filter-check__input[name=includeGlobal]") - ?.closest("label"); - const unknownToggle = filters - ?.querySelector(".session-filter-check__input[name=includeUnknown]") - ?.closest("label"); - const archivedToggle = filters - ?.querySelector(".session-filter-check__input[name=showArchived]") - ?.closest("label"); + const activeField = filters?.querySelector(".session-filter-input--minutes")?.closest("label"); + const tooltips = Array.from( + filters?.querySelectorAll("openclaw-tooltip") ?? [], + ).map((tooltip) => (tooltip as HTMLElement & { content: string }).content); expect(activeField?.querySelector(".session-filter-label")?.textContent).toBe("Updated within"); - expect(activeField?.getAttribute("data-tooltip")).toBe( + expect(tooltips).toEqual([ "Loads sessions updated in the last 120 minutes.", - ); - expect(limitField?.getAttribute("data-tooltip")).toBe("Max sessions to load."); - expect(globalToggle?.getAttribute("data-tooltip")).toBe("Include global sessions."); - expect(unknownToggle?.getAttribute("data-tooltip")).toBe("Include unknown sessions."); - expect(archivedToggle?.getAttribute("data-tooltip")).toBe("Show only archived sessions."); - expect( - Array.from(filters?.querySelectorAll("[title]") ?? []).map((node) => node.className), - ).toStrictEqual([]); + "Max sessions to load.", + "Include global sessions.", + "Include unknown sessions.", + "Show only archived sessions.", + ]); }); it("keeps active and limit together and renders streamlined source toggles", async () => { @@ -301,10 +289,10 @@ describe("sessions view", () => { const primaryRow = container.querySelector(".session-filter-primary-row"); expect(primaryRow?.querySelector(".session-filter-input--minutes")?.closest("label")).toBe( - primaryRow?.firstElementChild, + primaryRow?.firstElementChild?.querySelector("label"), ); expect(primaryRow?.querySelector(".session-filter-input--limit")?.closest("label")).toBe( - primaryRow?.lastElementChild, + primaryRow?.lastElementChild?.querySelector("label"), ); const toggleGroup = container.querySelector(".session-filter-toggle-group"); @@ -520,7 +508,9 @@ describe("sessions view", () => { const keyCell = container.querySelector(".session-key-cell"); expect(keyCell?.textContent?.trim()).toBe("📊 Data Expert (dingtalk)"); - expect(keyCell?.getAttribute("title")).toBe("📊 Data Expert (dingtalk)"); + expect((keyCell?.parentElement as (HTMLElement & { content: string }) | null)?.content).toBe( + "📊 Data Expert (dingtalk)", + ); }); it("keeps raw keys when identity data is unavailable", async () => { @@ -541,7 +531,9 @@ describe("sessions view", () => { const keyCell = container.querySelector(".session-key-cell"); expect(keyCell?.textContent?.trim()).toBe("agent:unknown-agent:telegram:abc123"); - expect(keyCell?.getAttribute("title")).toBe("agent:unknown-agent:telegram:abc123"); + expect((keyCell?.parentElement as (HTMLElement & { content: string }) | null)?.content).toBe( + "agent:unknown-agent:telegram:abc123", + ); }); it("renders cron session kind distinctly", async () => { diff --git a/ui/src/ui/views/sessions.ts b/ui/src/pages/sessions/view.ts similarity index 87% rename from ui/src/ui/views/sessions.ts rename to ui/src/pages/sessions/view.ts index 699ce17bbad2..aab8d01c89ae 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/pages/sessions/view.ts @@ -1,20 +1,5 @@ // Control UI view renders sessions screen content. import { html, nothing } from "lit"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp, parseSessionKeyParts } from "../format.ts"; -import { icons } from "../icons.ts"; -import { pathForTab } from "../navigation.ts"; -import { formatSessionTokens } from "../presenter.ts"; -import { formatGoalDetail, formatGoalSummary } from "../session-goal.ts"; -import { parseAgentSessionKey } from "../session-key.ts"; -import { sessionModelMatchesDefaults } from "../session-model-defaults.ts"; -import { isSessionRunActive } from "../session-run-state.ts"; -import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; -import { - formatInheritedThinkingLabel, - formatThinkingOverrideLabel, - normalizeThinkingOptionValue, -} from "../thinking-labels.ts"; import type { AgentIdentityResult, GatewaySessionRow, @@ -23,8 +8,28 @@ import type { FastMode, SessionCompactionCheckpoint, SessionsListResult, -} from "../types.ts"; -import { resolveAgentRuntimeLabel } from "./agents-utils.ts"; +} from "../../api/types.ts"; +import { pathForRoute } from "../../app-route-paths.ts"; +import { icons } from "../../components/icons.ts"; +import "../../components/tooltip.ts"; +import { t } from "../../i18n/index.ts"; +import { resolveAgentRuntimeLabel } from "../../lib/agents/display.ts"; +import { + formatInheritedThinkingLabel, + formatThinkingOverrideLabel, + normalizeThinkingOptionValue, +} from "../../lib/chat/thinking.ts"; +import { formatRelativeTimestamp, parseSessionKeyParts } from "../../lib/format.ts"; +import { formatSessionTokens } from "../../lib/presenter.ts"; +import { formatGoalDetail, formatGoalSummary } from "../../lib/session-goal.ts"; +import { sessionModelMatchesDefaults } from "../../lib/session-model-defaults.ts"; +import { isSessionRunActive } from "../../lib/session-run-state.ts"; +import { searchForSession } from "../../lib/sessions/index.ts"; +import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../lib/string-coerce.ts"; export type SessionsProps = { loading: boolean; @@ -216,14 +221,12 @@ function renderSessionStatusBadge(row: GatewaySessionRow) { const badge = resolveSessionStatusBadge(row); const title = `${t("sessionsView.status")}: ${badge.label}`; return html` - - - ${badge.label} - + + + + ${badge.label} + + `; } @@ -400,15 +403,14 @@ function renderSessionGoalChip(goal: GatewaySessionRow["goal"]) { if (!goal) { return nothing; } + const title = formatGoalDetail(goal); return html` - - ${formatGoalSummary(goal)} - ${goal.objective} - + + + ${formatGoalSummary(goal)} + ${goal.objective} + + `; } @@ -489,17 +491,19 @@ function renderFilterToggle(params: { .filter(Boolean) .join(" "); return html` - + + + `; } @@ -583,38 +587,42 @@ export function renderSessions(props: SessionsProps) { aria-label="Session filters" >
- - + + + + + +
- + + + ${props.onAddToWorkboard && canLink ? html` - + + ` : nothing} @@ -1149,9 +1162,9 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) { (item) => html`
${item.label}
-
- ${item.value} -
+ +
${item.value}
+
`, )} diff --git a/ui/src/ui/controllers/skill-workshop.test.ts b/ui/src/pages/skill-workshop/proposals.test.ts similarity index 73% rename from ui/src/ui/controllers/skill-workshop.test.ts rename to ui/src/pages/skill-workshop/proposals.test.ts index aa49aed84beb..bca2599e8028 100644 --- a/ui/src/ui/controllers/skill-workshop.test.ts +++ b/ui/src/pages/skill-workshop/proposals.test.ts @@ -1,52 +1,63 @@ // Control UI tests cover skill workshop controller behavior. import { describe, expect, it, vi } from "vitest"; -import type { SkillWorkshopProposal } from "../views/skill-workshop.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/context.ts"; +import type { SkillWorkshopProposal } from "../../lib/skill-workshop/index.ts"; import { + createSkillWorkshopState, loadSkillWorkshopProposalDetail, loadSkillWorkshopProposals, requestSkillWorkshopRevision, runSkillWorkshopLifecycleAction, + type SkillWorkshopContext, type SkillWorkshopState, -} from "./skill-workshop.ts"; +} from "./proposals.ts"; type TestRequest = (method: string, payload?: unknown) => Promise; const ISO_NOW = "2026-06-16T12:00:00.000Z"; -function createState(overrides: Partial = {}): { +function createFixture( + overrides: Partial = {}, + snapshotOverrides: Partial = {}, +): { state: SkillWorkshopState; + context: SkillWorkshopContext; request: ReturnType>; + snapshot: ApplicationGatewaySnapshot; } { const request = vi.fn(); - const state: SkillWorkshopState = { - client: { request } as unknown as SkillWorkshopState["client"], + const snapshot: ApplicationGatewaySnapshot = { + client: { request } as unknown as ApplicationGatewaySnapshot["client"], connected: true, - assistantAgentId: "research", - agentsList: { defaultId: "main", mainKey: "main" }, hello: null, + assistantAgentId: "research", sessionKey: "global", - skillWorkshopAgentId: null, - skillWorkshopLoading: false, - skillWorkshopLoaded: false, - skillWorkshopError: null, - skillWorkshopInspectingKey: null, - skillWorkshopProposals: [], - skillWorkshopSelectedKey: null, - skillWorkshopActionBusy: null, - skillWorkshopActionNotice: null, - skillWorkshopActionNoticeTimer: null, - skillWorkshopRevisionKey: null, - skillWorkshopRevisionDraft: "", - skillWorkshopStatusFilter: "pending", - skillWorkshopQuery: "", - skillWorkshopFilePreviewKey: null, - skillWorkshopFilePreviewQuery: "", - skillWorkshopQueueWidth: 360, - skillWorkshopMode: "today", - skillWorkshopUseCurrentChatForRevisions: false, - ...overrides, + lastError: null, + lastErrorCode: null, + ...snapshotOverrides, }; - return { state, request }; + const context: SkillWorkshopContext = { + agentSelection: { + get state() { + return { selectedId: snapshot.assistantAgentId }; + }, + }, + gateway: { + get snapshot() { + return snapshot; + }, + connection: { gatewayUrl: "", token: "", password: "" }, + eventLog: [], + connect: vi.fn(), + setSessionKey: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + subscribe: vi.fn(() => () => {}), + subscribeEventLog: vi.fn(() => () => {}), + subscribeEvents: vi.fn(() => () => {}), + }, + }; + return { state: { ...createSkillWorkshopState(), ...overrides }, context, request, snapshot }; } function manifest(status: SkillWorkshopProposal["status"] = "pending") { @@ -130,7 +141,7 @@ function clearNoticeTimer(state: SkillWorkshopState): void { describe("Skill Workshop proposal RPCs", () => { it("lists proposals with the selected agent id and carries it into the initial inspect", async () => { - const { state, request } = createState({ assistantAgentId: "research" }); + const { state, context, request } = createFixture(); request.mockImplementation(async (method: string) => { if (method === "skills.proposals.list") { return manifest(); @@ -141,7 +152,7 @@ describe("Skill Workshop proposal RPCs", () => { return {}; }); - await loadSkillWorkshopProposals(state); + await loadSkillWorkshopProposals(state, context); expect(request).toHaveBeenNthCalledWith(1, "skills.proposals.list", { agentId: "research", @@ -153,14 +164,13 @@ describe("Skill Workshop proposal RPCs", () => { }); it("inspects proposals with the current agent from the selected session", async () => { - const { state, request } = createState({ - assistantAgentId: "research", - sessionKey: "agent:ops-team:main", - skillWorkshopProposals: [proposal({ body: "" })], - }); + const { state, context, request } = createFixture( + { skillWorkshopProposals: [proposal({ body: "" })] }, + { sessionKey: "agent:ops-team:main" }, + ); request.mockResolvedValue(inspectResult()); - await loadSkillWorkshopProposalDetail(state, "proposal-1"); + await loadSkillWorkshopProposalDetail(state, context, "proposal-1"); expect(request).toHaveBeenCalledWith("skills.proposals.inspect", { agentId: "ops-team", @@ -174,11 +184,13 @@ describe("Skill Workshop proposal RPCs", () => { ] as const)( "%s sends the selected agent id and refreshes that agent scope", async (action, method, status) => { - const { state, request } = createState({ - assistantAgentId: "reviewer", - skillWorkshopProposals: [proposal()], - skillWorkshopSelectedKey: "proposal-1", - }); + const { state, context, request } = createFixture( + { + skillWorkshopProposals: [proposal()], + skillWorkshopSelectedKey: "proposal-1", + }, + { assistantAgentId: "reviewer" }, + ); request.mockImplementation(async (calledMethod: string) => { if (calledMethod === method) { return {}; @@ -193,7 +205,7 @@ describe("Skill Workshop proposal RPCs", () => { }); try { - await runSkillWorkshopLifecycleAction(state, action, "proposal-1"); + await runSkillWorkshopLifecycleAction(state, context, action, "proposal-1"); } finally { clearNoticeTimer(state); } @@ -213,12 +225,14 @@ describe("Skill Workshop proposal RPCs", () => { ); it("reloads proposals when the selected session changes agent scope", async () => { - const { state, request } = createState({ - sessionKey: "agent:ops:main", - skillWorkshopAgentId: "research", - skillWorkshopLoaded: true, - skillWorkshopProposals: [proposal()], - }); + const { state, context, request } = createFixture( + { + skillWorkshopAgentId: "research", + skillWorkshopLoaded: true, + skillWorkshopProposals: [proposal()], + }, + { sessionKey: "agent:ops:main" }, + ); request.mockImplementation(async (method: string) => { if (method === "skills.proposals.list") { return manifest(); @@ -229,7 +243,7 @@ describe("Skill Workshop proposal RPCs", () => { return {}; }); - await loadSkillWorkshopProposals(state); + await loadSkillWorkshopProposals(state, context); expect(state.skillWorkshopAgentId).toBe("ops"); expect(request).toHaveBeenNthCalledWith(1, "skills.proposals.list", { agentId: "ops" }); @@ -241,8 +255,7 @@ describe("Skill Workshop proposal RPCs", () => { it("clears stale proposals when the agent changes during an in-flight reload", async () => { const researchList = createDeferred>(); - const { state, request } = createState({ - assistantAgentId: "research", + const { state, context, request, snapshot } = createFixture({ skillWorkshopAgentId: "research", skillWorkshopLoaded: true, skillWorkshopProposals: [proposal()], @@ -256,9 +269,9 @@ describe("Skill Workshop proposal RPCs", () => { : manifest(); }); - const researchReload = loadSkillWorkshopProposals(state, { force: true }); - state.assistantAgentId = "ops"; - await loadSkillWorkshopProposals(state); + const researchReload = loadSkillWorkshopProposals(state, context, { force: true }); + snapshot.assistantAgentId = "ops"; + await loadSkillWorkshopProposals(state, context); expect(state.skillWorkshopAgentId).toBe("ops"); expect(state.skillWorkshopProposals).toEqual([]); @@ -274,15 +287,14 @@ describe("Skill Workshop proposal RPCs", () => { }); it("preserves the loaded proposal agent for originless revisions", async () => { - const { state } = createState({ - assistantAgentId: "main", + const { state, context } = createFixture({ skillWorkshopAgentId: "research", skillWorkshopProposals: [proposal()], skillWorkshopRevisionDraft: "Tighten the trigger.", }); const sendRevisionRequest = vi.fn(async () => {}); - await requestSkillWorkshopRevision(state, "proposal-1", sendRevisionRequest); + await requestSkillWorkshopRevision(state, context, "proposal-1", sendRevisionRequest); expect(sendRevisionRequest).toHaveBeenCalledWith( "Tighten the trigger.", @@ -293,13 +305,13 @@ describe("Skill Workshop proposal RPCs", () => { it("discards proposal detail that resolves after the agent scope changes", async () => { const detail = createDeferred>(); - const { state, request } = createState({ + const { state, context, request } = createFixture({ skillWorkshopAgentId: "research", skillWorkshopProposals: [proposal({ body: "" })], }); request.mockReturnValueOnce(detail.promise); - const loading = loadSkillWorkshopProposalDetail(state, "proposal-1"); + const loading = loadSkillWorkshopProposalDetail(state, context, "proposal-1"); state.skillWorkshopAgentId = "ops"; state.skillWorkshopProposals = [proposal({ body: "Ops proposal." })]; state.skillWorkshopInspectingKey = "proposal-1"; @@ -312,7 +324,7 @@ describe("Skill Workshop proposal RPCs", () => { it("does not send an originless revision after the agent scope changes", async () => { const detail = createDeferred>(); - const { state, request } = createState({ + const { state, context, request } = createFixture({ skillWorkshopAgentId: "research", skillWorkshopProposals: [proposal({ body: "" })], skillWorkshopRevisionDraft: "Tighten the trigger.", @@ -320,7 +332,12 @@ describe("Skill Workshop proposal RPCs", () => { request.mockReturnValueOnce(detail.promise); const sendRevisionRequest = vi.fn(async () => {}); - const revision = requestSkillWorkshopRevision(state, "proposal-1", sendRevisionRequest); + const revision = requestSkillWorkshopRevision( + state, + context, + "proposal-1", + sendRevisionRequest, + ); state.skillWorkshopAgentId = "ops"; detail.resolve(inspectResult()); diff --git a/ui/src/ui/controllers/skill-workshop.ts b/ui/src/pages/skill-workshop/proposals.ts similarity index 70% rename from ui/src/ui/controllers/skill-workshop.ts rename to ui/src/pages/skill-workshop/proposals.ts index 5835364e8cf1..fa443afc085a 100644 --- a/ui/src/ui/controllers/skill-workshop.ts +++ b/ui/src/pages/skill-workshop/proposals.ts @@ -1,21 +1,23 @@ +import type { AgentSelectionCapability } from "../../app/agent-selection.ts"; // Control UI controller manages skill workshop gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; +import type { ApplicationGateway } from "../../app/context.ts"; import { normalizeAgentId, parseAgentSessionKey, resolveUiSelectedGlobalAgentId, -} from "../session-key.ts"; +} from "../../lib/sessions/session-key.ts"; import type { SkillWorkshopAction, SkillWorkshopActionNotice, SkillWorkshopMode, SkillWorkshopProposal, + SkillWorkshopProposalStatus, SkillWorkshopStatusFilter, -} from "../views/skill-workshop.ts"; +} from "../../lib/skill-workshop/index.ts"; const SKILL_WORKSHOP_NOTICE_MS = 2800; -type SkillProposalStatus = "pending" | "applied" | "rejected" | "quarantined" | "stale"; +type SkillProposalStatus = SkillWorkshopProposalStatus; type SkillProposalKind = "create" | "update"; type SkillProposalScanState = "pending" | "clean" | "failed" | "quarantined"; @@ -78,13 +80,12 @@ type SkillProposalInspectResult = { supportFiles?: SkillProposalSupportFile[]; }; +export type SkillWorkshopContext = { + gateway: ApplicationGateway; + agentSelection: Pick; +}; + export type SkillWorkshopState = { - client: GatewayBrowserClient | null; - connected: boolean; - assistantAgentId?: string | null; - agentsList?: { defaultId?: string | null; mainKey?: string | null } | null; - hello?: { snapshot?: unknown } | null; - sessionKey?: string | null; skillWorkshopAgentId: string | null; skillWorkshopLoading: boolean; skillWorkshopLoaded: boolean; @@ -106,21 +107,85 @@ export type SkillWorkshopState = { skillWorkshopUseCurrentChatForRevisions: boolean; }; +export type SkillWorkshopRouteData = Pick< + SkillWorkshopState, + | "skillWorkshopAgentId" + | "skillWorkshopLoading" + | "skillWorkshopLoaded" + | "skillWorkshopError" + | "skillWorkshopInspectingKey" + | "skillWorkshopProposals" + | "skillWorkshopSelectedKey" + | "skillWorkshopActionBusy" + | "skillWorkshopActionNotice" + | "skillWorkshopRevisionKey" + | "skillWorkshopRevisionDraft" +>; + +export function createSkillWorkshopState(data?: SkillWorkshopRouteData): SkillWorkshopState { + return { + skillWorkshopAgentId: data?.skillWorkshopAgentId ?? null, + skillWorkshopLoading: data?.skillWorkshopLoading ?? false, + skillWorkshopLoaded: data?.skillWorkshopLoaded ?? false, + skillWorkshopError: data?.skillWorkshopError ?? null, + skillWorkshopInspectingKey: data?.skillWorkshopInspectingKey ?? null, + skillWorkshopProposals: data?.skillWorkshopProposals ?? [], + skillWorkshopSelectedKey: data?.skillWorkshopSelectedKey ?? null, + skillWorkshopActionBusy: data?.skillWorkshopActionBusy ?? null, + skillWorkshopActionNotice: data?.skillWorkshopActionNotice ?? null, + skillWorkshopActionNoticeTimer: null, + skillWorkshopRevisionKey: data?.skillWorkshopRevisionKey ?? null, + skillWorkshopRevisionDraft: data?.skillWorkshopRevisionDraft ?? "", + skillWorkshopStatusFilter: "pending", + skillWorkshopQuery: "", + skillWorkshopFilePreviewKey: null, + skillWorkshopFilePreviewQuery: "", + skillWorkshopQueueWidth: 360, + skillWorkshopMode: "today", + skillWorkshopUseCurrentChatForRevisions: false, + }; +} + +export function skillWorkshopRouteData(state: SkillWorkshopState): SkillWorkshopRouteData { + return { + skillWorkshopAgentId: state.skillWorkshopAgentId, + skillWorkshopLoading: state.skillWorkshopLoading, + skillWorkshopLoaded: state.skillWorkshopLoaded, + skillWorkshopError: state.skillWorkshopError, + skillWorkshopInspectingKey: state.skillWorkshopInspectingKey, + skillWorkshopProposals: state.skillWorkshopProposals, + skillWorkshopSelectedKey: state.skillWorkshopSelectedKey, + skillWorkshopActionBusy: state.skillWorkshopActionBusy, + skillWorkshopActionNotice: state.skillWorkshopActionNotice, + skillWorkshopRevisionKey: state.skillWorkshopRevisionKey, + skillWorkshopRevisionDraft: state.skillWorkshopRevisionDraft, + }; +} + function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -function skillWorkshopAgentParams(state: SkillWorkshopState): { agentId: string } { - const sessionAgentId = parseAgentSessionKey(state.sessionKey)?.agentId; +function skillWorkshopAgentParams(context: SkillWorkshopContext): { agentId: string } { + const snapshot = context.gateway.snapshot; + const sessionAgentId = parseAgentSessionKey(snapshot.sessionKey)?.agentId; + const selectedAgentId = context.agentSelection.state.selectedId; return { agentId: sessionAgentId ? normalizeAgentId(sessionAgentId) - : resolveUiSelectedGlobalAgentId(state), + : selectedAgentId + ? normalizeAgentId(selectedAgentId) + : resolveUiSelectedGlobalAgentId(snapshot), }; } -function loadedSkillWorkshopAgentParams(state: SkillWorkshopState): { agentId: string } { - return { agentId: state.skillWorkshopAgentId ?? skillWorkshopAgentParams(state).agentId }; +function loadedSkillWorkshopAgentParams( + state: SkillWorkshopState, + context: SkillWorkshopContext, +): { agentId: string } { + return { + agentId: state.skillWorkshopAgentId ?? skillWorkshopAgentParams(context).agentId, + }; } function resetSkillWorkshopAgentScope(state: SkillWorkshopState, agentId: string): void { @@ -321,12 +386,15 @@ export function countSkillWorkshopProposals( export async function loadSkillWorkshopProposals( state: SkillWorkshopState, + context: SkillWorkshopContext, options?: { force?: boolean }, ): Promise { - if (!state.client || !state.connected) { + const snapshot = context.gateway.snapshot; + const client = snapshot.client; + if (!client || !snapshot.connected) { return; } - const requestAgentId = skillWorkshopAgentParams(state).agentId; + const requestAgentId = skillWorkshopAgentParams(context).agentId; if (state.skillWorkshopAgentId !== requestAgentId) { resetSkillWorkshopAgentScope(state, requestAgentId); } @@ -339,10 +407,10 @@ export async function loadSkillWorkshopProposals( state.skillWorkshopLoading = true; state.skillWorkshopError = null; try { - const result = await state.client.request("skills.proposals.list", { + const result = await client.request("skills.proposals.list", { agentId: requestAgentId, }); - if (skillWorkshopAgentParams(state).agentId !== requestAgentId) { + if (skillWorkshopAgentParams(context).agentId !== requestAgentId) { return; } const previousByKey = new Map( @@ -357,31 +425,34 @@ export async function loadSkillWorkshopProposals( state.skillWorkshopSelectedKey = proposals[0]?.key ?? null; } if (state.skillWorkshopSelectedKey) { - await loadSkillWorkshopProposalDetail(state, state.skillWorkshopSelectedKey); + await loadSkillWorkshopProposalDetail(state, context, state.skillWorkshopSelectedKey); } } catch (err) { state.skillWorkshopError = getErrorMessage(err); } finally { state.skillWorkshopLoading = false; - if (skillWorkshopAgentParams(state).agentId !== requestAgentId) { - void loadSkillWorkshopProposals(state, { force: true }); + if (skillWorkshopAgentParams(context).agentId !== requestAgentId) { + void loadSkillWorkshopProposals(state, context, { force: true }); } } } export async function loadSkillWorkshopProposalDetail( state: SkillWorkshopState, + context: SkillWorkshopContext, proposalId: string, options?: { force?: boolean }, -): Promise { - if (!state.client || !state.connected || state.skillWorkshopInspectingKey === proposalId) { - return; +): Promise { + const snapshot = context.gateway.snapshot; + const client = snapshot.client; + if (!client || !snapshot.connected || state.skillWorkshopInspectingKey === proposalId) { + return false; } const existing = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId); if (existing?.body && !options?.force) { - return; + return true; } - const requestAgentId = loadedSkillWorkshopAgentParams(state).agentId; + const requestAgentId = loadedSkillWorkshopAgentParams(state, context).agentId; if (state.skillWorkshopAgentId === null) { state.skillWorkshopAgentId = requestAgentId; } @@ -389,18 +460,23 @@ export async function loadSkillWorkshopProposalDetail( state.skillWorkshopError = null; try { const requestParams = { agentId: requestAgentId, proposalId }; - const result = await state.client.request( + const result = await client.request( "skills.proposals.inspect", requestParams, ); - if (state.skillWorkshopAgentId !== requestAgentId) { - return; + if ( + state.skillWorkshopAgentId !== requestAgentId || + state.skillWorkshopInspectingKey !== proposalId + ) { + return false; } mergeProposal(state, proposalFromInspect(result, existing)); + return true; } catch (err) { if (state.skillWorkshopAgentId === requestAgentId) { state.skillWorkshopError = getErrorMessage(err); } + return false; } finally { if ( state.skillWorkshopAgentId === requestAgentId && @@ -411,23 +487,40 @@ export async function loadSkillWorkshopProposalDetail( } } -export function selectSkillWorkshopProposal(state: SkillWorkshopState, proposalId: string): void { +export async function selectSkillWorkshopProposal( + state: SkillWorkshopState, + context: SkillWorkshopContext, + proposalId: string, +): Promise { + const current = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId); + if (!current?.body) { + const loaded = await loadSkillWorkshopProposalDetail(state, context, proposalId); + if (!loaded) { + return; + } + } state.skillWorkshopSelectedKey = proposalId; - void loadSkillWorkshopProposalDetail(state, proposalId); } -async function refreshAfterMutation(state: SkillWorkshopState, proposalId: string): Promise { +async function refreshAfterMutation( + state: SkillWorkshopState, + context: SkillWorkshopContext, + proposalId: string, +): Promise { state.skillWorkshopLoaded = false; - await loadSkillWorkshopProposals(state, { force: true }); - await loadSkillWorkshopProposalDetail(state, proposalId, { force: true }); + await loadSkillWorkshopProposals(state, context, { force: true }); + await loadSkillWorkshopProposalDetail(state, context, proposalId, { force: true }); } export async function runSkillWorkshopLifecycleAction( state: SkillWorkshopState, + context: SkillWorkshopContext, action: Extract, proposalId: string, ): Promise { - if (!state.client || !state.connected || state.skillWorkshopActionBusy) { + const snapshot = context.gateway.snapshot; + const client = snapshot.client; + if (!client || !snapshot.connected || state.skillWorkshopActionBusy) { return; } const previous = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId); @@ -436,9 +529,9 @@ export async function runSkillWorkshopLifecycleAction( state.skillWorkshopError = null; try { const method = action === "apply" ? "skills.proposals.apply" : "skills.proposals.reject"; - const requestParams = { ...loadedSkillWorkshopAgentParams(state), proposalId }; - await state.client.request(method, requestParams); - await refreshAfterMutation(state, proposalId); + const requestParams = { ...loadedSkillWorkshopAgentParams(state, context), proposalId }; + await client.request(method, requestParams); + await refreshAfterMutation(state, context, proposalId); const updated = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId); showActionNotice(state, updated ?? previous, action === "apply" ? "Applied" : "Rejected"); } catch (err) { @@ -455,6 +548,7 @@ export async function runSkillWorkshopLifecycleAction( export async function requestSkillWorkshopRevision( state: SkillWorkshopState, + context: SkillWorkshopContext, proposalId: string, sendRevisionRequest: ( instructions: string, @@ -470,7 +564,7 @@ export async function requestSkillWorkshopRevision( if (!proposal || !instructions) { return false; } - const proposalAgentId = loadedSkillWorkshopAgentParams(state).agentId; + const proposalAgentId = loadedSkillWorkshopAgentParams(state, context).agentId; if (state.skillWorkshopAgentId === null) { state.skillWorkshopAgentId = proposalAgentId; } @@ -478,7 +572,7 @@ export async function requestSkillWorkshopRevision( state.skillWorkshopActionNotice = null; state.skillWorkshopError = null; try { - await loadSkillWorkshopProposalDetail(state, proposalId); + await loadSkillWorkshopProposalDetail(state, context, proposalId); if (state.skillWorkshopAgentId !== proposalAgentId) { return false; } diff --git a/ui/src/pages/skill-workshop/route.ts b/ui/src/pages/skill-workshop/route.ts new file mode 100644 index 000000000000..aabefa43967c --- /dev/null +++ b/ui/src/pages/skill-workshop/route.ts @@ -0,0 +1,27 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; +import { + createSkillWorkshopState, + loadSkillWorkshopProposals, + skillWorkshopRouteData, + type SkillWorkshopRouteData, +} from "./proposals.ts"; + +export const page = definePage({ + id: "skill-workshop", + path: "/skills/workshop", + component: () => + import("./skill-workshop-page.ts").then(() => ({ + render: (data: unknown) => html` + + `, + })), + loader: async (context: ApplicationContext) => { + const state = createSkillWorkshopState(); + await loadSkillWorkshopProposals(state, context); + return skillWorkshopRouteData(state); + }, +}); diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.ts b/ui/src/pages/skill-workshop/skill-workshop-page.ts new file mode 100644 index 000000000000..b0f7cf93ec35 --- /dev/null +++ b/ui/src/pages/skill-workshop/skill-workshop-page.ts @@ -0,0 +1,502 @@ +// Skill Workshop page owns its Control UI render glue. +import { consume } from "@lit/context"; +import { html, LitElement, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { loadSettings } from "../../app/settings.ts"; +import "../../components/tooltip.ts"; +import { t } from "../../i18n/index.ts"; +import { resolveSessionKey, searchForSession } from "../../lib/sessions/index.ts"; +import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; +import { filterSkillWorkshopProposals } from "../../lib/skill-workshop/index.ts"; +import { + countSkillWorkshopProposals, + createSkillWorkshopState, + loadSkillWorkshopProposals, + requestSkillWorkshopRevision, + runSkillWorkshopLifecycleAction, + selectSkillWorkshopProposal, + type SkillWorkshopContext, + type SkillWorkshopRouteData, + type SkillWorkshopState, +} from "./proposals.ts"; +import { + loadSkillWorkshopMode, + loadSkillWorkshopUseCurrentChatForRevisions, + saveSkillWorkshopMode, + saveSkillWorkshopUseCurrentChatForRevisions, +} from "./storage.ts"; +import { renderSkillWorkshop } from "./view.ts"; + +export type SkillWorkshopPageContext = ApplicationContext & SkillWorkshopContext; + +export type SkillWorkshopRevisionRequest = ( + instructions: string, + proposal: SkillWorkshopState["skillWorkshopProposals"][number], + proposalAgentId: string, +) => Promise; + +type SkillWorkshopRenderContext = { + context: SkillWorkshopPageContext; + workshopAgentName: string; + onRevisionRequest?: SkillWorkshopRevisionRequest; +}; + +type SkillWorkshopProposal = SkillWorkshopState["skillWorkshopProposals"][number]; + +function findRevisionSessionRow( + result: SessionsListResult | null, + sessionKey: string | undefined, +): GatewaySessionRow | null { + const key = sessionKey?.trim(); + return key ? (result?.sessions.find((row) => row.key === key) ?? null) : null; +} + +function isUsableRevisionSession(row: GatewaySessionRow | null): row is GatewaySessionRow { + return Boolean(row && !row.archived && !row.hasActiveRun); +} + +async function loadRevisionSessionsForAgent( + context: SkillWorkshopPageContext, + agentId: string, +): Promise { + const current = context.sessions.state; + if (current.agentId === agentId && current.result?.sessions.length) { + return current.result; + } + return context.sessions.list({ agentId }); +} + +async function resolveRevisionSessionKey( + state: SkillWorkshopState, + context: SkillWorkshopPageContext, + proposal: SkillWorkshopProposal, + proposalAgentId: string, +): Promise { + const gatewayHello = context.gateway.snapshot.hello; + if (state.skillWorkshopUseCurrentChatForRevisions) { + return resolveSessionKey(loadSettings().sessionKey, gatewayHello).trim() || null; + } + + const agentId = normalizeAgentId(proposal.origin?.agentId ?? proposalAgentId); + const sessions = await loadRevisionSessionsForAgent(context, agentId); + const originRow = findRevisionSessionRow(sessions, proposal.origin?.sessionKey); + if (isUsableRevisionSession(originRow)) { + return originRow.key; + } + + const createdKey = await context.sessions.create({ + agentId, + label: `Skill Workshop: ${proposal.slug || proposal.key}`.slice(0, 80), + }); + const sessionKey = resolveSessionKey(createdKey, gatewayHello).trim(); + if (!sessionKey) { + throw new Error(context.sessions.state.error ?? "Could not prepare a Skill Workshop session."); + } + return sessionKey; +} + +function setSkillWorkshopUseCurrentChatForRevisions( + state: SkillWorkshopState, + enabled: boolean, + requestUpdate: () => void, +): void { + if (state.skillWorkshopUseCurrentChatForRevisions === enabled) { + return; + } + state.skillWorkshopUseCurrentChatForRevisions = enabled; + saveSkillWorkshopUseCurrentChatForRevisions(enabled); + requestUpdate(); +} + +function setSkillWorkshopMode( + state: SkillWorkshopState, + mode: SkillWorkshopState["skillWorkshopMode"], + requestUpdate: () => void, +) { + if (state.skillWorkshopMode === mode) { + return; + } + state.skillWorkshopMode = mode; + saveSkillWorkshopMode(mode); + requestUpdate(); +} + +function renderSkillWorkshopHeaderControls(state: SkillWorkshopState, requestUpdate: () => void) { + const useCurrentChatLabel = t("skillWorkshop.header.useCurrentChat"); + return html` +
+ +
+ + + +
+
+ `; +} + +export function renderSkillWorkshopPage( + state: SkillWorkshopState, + { context, workshopAgentName, onRevisionRequest }: SkillWorkshopRenderContext, + requestUpdate: () => void, +) { + const pageClass = + state.skillWorkshopMode === "today" + ? "content--skill-workshop content--skill-workshop-today" + : "content--skill-workshop"; + + return html` +
+
+
+
${t("tabs.skillWorkshop")}
+
${t("subtitles.skillWorkshop")}
+
+
${renderSkillWorkshopHeaderControls(state, requestUpdate)}
+
+ ${(() => { + const visibleProposals = filterSkillWorkshopProposals( + state.skillWorkshopProposals, + state.skillWorkshopStatusFilter, + state.skillWorkshopQuery, + ); + const selectedIndex = visibleProposals.findIndex( + (proposal) => proposal.key === state.skillWorkshopSelectedKey, + ); + const selectProposal = (key: string) => { + state.skillWorkshopFilePreviewKey = null; + void selectSkillWorkshopProposal(state, context, key).finally(requestUpdate); + requestUpdate(); + }; + const selectRelativeProposal = (delta: -1 | 1) => { + if (visibleProposals.length === 0) { + return; + } + const nextIndex = + selectedIndex < 0 + ? 0 + : (selectedIndex + delta + visibleProposals.length) % visibleProposals.length; + selectProposal(visibleProposals[nextIndex].key); + }; + const selectVisibleFallback = (proposals: typeof visibleProposals) => { + if ( + proposals.length === 0 || + proposals.some((proposal) => proposal.key === state.skillWorkshopSelectedKey) + ) { + return; + } + selectProposal(proposals[0].key); + }; + return renderSkillWorkshop({ + loading: state.skillWorkshopLoading, + error: state.skillWorkshopError, + inspectingKey: state.skillWorkshopInspectingKey, + proposals: state.skillWorkshopProposals, + selectedKey: state.skillWorkshopSelectedKey, + statusFilter: state.skillWorkshopStatusFilter, + query: state.skillWorkshopQuery, + filePreviewKey: state.skillWorkshopFilePreviewKey, + filePreviewQuery: state.skillWorkshopFilePreviewQuery, + queueWidth: state.skillWorkshopQueueWidth, + mode: state.skillWorkshopMode, + actionBusy: state.skillWorkshopActionBusy, + actionNotice: state.skillWorkshopActionNotice, + revisionKey: state.skillWorkshopRevisionKey, + revisionDraft: state.skillWorkshopRevisionDraft, + assistantName: context.config.current.assistantIdentity.name, + workshopAgentName, + counts: countSkillWorkshopProposals(state.skillWorkshopProposals), + onStatusFilterChange: (status) => { + state.skillWorkshopStatusFilter = status; + requestUpdate(); + selectVisibleFallback( + filterSkillWorkshopProposals( + state.skillWorkshopProposals, + status, + state.skillWorkshopQuery, + ), + ); + }, + onQueryChange: (query) => { + state.skillWorkshopQuery = query; + requestUpdate(); + selectVisibleFallback( + filterSkillWorkshopProposals( + state.skillWorkshopProposals, + state.skillWorkshopStatusFilter, + query, + ), + ); + }, + onFilePreviewQueryChange: (query) => { + state.skillWorkshopFilePreviewQuery = query; + requestUpdate(); + }, + onQueueWidthChange: (width) => { + state.skillWorkshopQueueWidth = width; + requestUpdate(); + }, + onModeChange: (mode) => setSkillWorkshopMode(state, mode, requestUpdate), + onSelect: selectProposal, + onPrev: () => selectRelativeProposal(-1), + onNext: () => selectRelativeProposal(1), + onApply: (key) => { + void runSkillWorkshopLifecycleAction(state, context, "apply", key).finally( + requestUpdate, + ); + requestUpdate(); + }, + onRevise: (key) => { + state.skillWorkshopRevisionKey = key; + state.skillWorkshopRevisionDraft = ""; + requestUpdate(); + }, + onReject: (key) => { + void runSkillWorkshopLifecycleAction(state, context, "reject", key).finally( + requestUpdate, + ); + requestUpdate(); + }, + onRevisionDraftChange: (draft) => { + state.skillWorkshopRevisionDraft = draft; + requestUpdate(); + }, + onRevisionCancel: () => { + state.skillWorkshopRevisionKey = null; + state.skillWorkshopRevisionDraft = ""; + requestUpdate(); + }, + onRevisionSubmit: (key) => + onRevisionRequest + ? void requestSkillWorkshopRevision(state, context, key, onRevisionRequest).finally( + requestUpdate, + ) + : undefined, + onPreviewFile: (key, path) => { + state.skillWorkshopSelectedKey = key; + state.skillWorkshopFilePreviewKey = path; + requestUpdate(); + }, + onClosePreview: () => { + state.skillWorkshopFilePreviewKey = null; + state.skillWorkshopFilePreviewQuery = ""; + requestUpdate(); + }, + }); + })()} +
+ `; +} + +export class SkillWorkshopPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context?: SkillWorkshopPageContext; + @property({ attribute: false }) data?: SkillWorkshopRouteData; + @property({ attribute: false }) onRevisionRequest?: SkillWorkshopRevisionRequest; + + private state?: SkillWorkshopState; + private stopGatewaySubscription?: () => void; + private stopConfigSubscription?: () => void; + private stopAgentSelectionSubscription?: () => void; + private stopAgentIdentitySubscription?: () => void; + + private readonly handleRevisionRequest: SkillWorkshopRevisionRequest = async ( + instructions, + proposal, + proposalAgentId, + ) => { + if (!this.state || !this.context) { + throw new Error("Skill Workshop is not ready."); + } + const sessionKey = await resolveRevisionSessionKey( + this.state, + this.context, + proposal, + proposalAgentId, + ); + if (!sessionKey) { + throw new Error( + this.context.sessions.state.error ?? "Could not prepare a Skill Workshop session.", + ); + } + this.context.skillWorkshopRevision.prepare({ + sessionKey, + instructions, + proposalId: proposal.key, + proposalAgentId: normalizeAgentId(proposal.origin?.agentId ?? proposalAgentId), + }); + this.context.navigate("chat", { search: searchForSession(sessionKey) }); + }; + + override connectedCallback() { + super.connectedCallback(); + this.startGatewaySubscription(); + } + + override willUpdate() { + if (!this.state && this.context) { + this.state = createSkillWorkshopState(this.data); + this.state.skillWorkshopMode = loadSkillWorkshopMode(); + this.state.skillWorkshopUseCurrentChatForRevisions = + loadSkillWorkshopUseCurrentChatForRevisions(); + } + } + + override updated() { + this.startGatewaySubscription(); + this.startConfigSubscription(); + this.startAgentSelectionSubscription(); + this.startAgentIdentitySubscription(); + this.ensureWorkshopAgentIdentity(); + } + + private readonly requestPageUpdate = () => { + if (this.isConnected) { + this.requestUpdate(); + } + }; + + private startGatewaySubscription(): void { + const context = this.context; + if (!this.state || !context || this.stopGatewaySubscription) { + return; + } + this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { + if (!snapshot.connected || !this.state || !this.context) { + return; + } + void loadSkillWorkshopProposals(this.state, this.context).finally(this.requestPageUpdate); + }); + if (!this.data?.skillWorkshopLoaded && context.gateway.snapshot.connected) { + void loadSkillWorkshopProposals(this.state, context).finally(this.requestPageUpdate); + } + } + + private startAgentIdentitySubscription(): void { + const context = this.context; + if (!context || this.stopAgentIdentitySubscription) { + return; + } + this.stopAgentIdentitySubscription = context.agentIdentity.subscribe(this.requestPageUpdate); + } + + private startConfigSubscription(): void { + const context = this.context; + if (!context || this.stopConfigSubscription) { + return; + } + this.stopConfigSubscription = context.config.subscribe(this.requestPageUpdate); + } + + private startAgentSelectionSubscription(): void { + const context = this.context; + if (!context || !this.state || this.stopAgentSelectionSubscription) { + return; + } + this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => { + if (!this.state || !this.context) { + return; + } + void loadSkillWorkshopProposals(this.state, this.context).finally(this.requestPageUpdate); + }); + } + + private ensureWorkshopAgentIdentity(): void { + const context = this.context; + const agentId = this.state?.skillWorkshopAgentId; + if (!context || !agentId || context.agentIdentity.get(agentId)) { + return; + } + void context.agentIdentity.ensure([agentId]); + } + + override disconnectedCallback() { + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopConfigSubscription?.(); + this.stopConfigSubscription = undefined; + this.stopAgentSelectionSubscription?.(); + this.stopAgentSelectionSubscription = undefined; + this.stopAgentIdentitySubscription?.(); + this.stopAgentIdentitySubscription = undefined; + if (this.state?.skillWorkshopActionNoticeTimer) { + globalThis.clearTimeout(this.state.skillWorkshopActionNoticeTimer); + this.state.skillWorkshopActionNoticeTimer = null; + } + super.disconnectedCallback(); + } + + override render() { + return this.state && this.context + ? renderSkillWorkshopPage( + this.state, + { + context: this.context, + workshopAgentName: + this.context.agentIdentity.get(this.state.skillWorkshopAgentId)?.name?.trim() ?? "", + onRevisionRequest: this.onRevisionRequest ?? this.handleRevisionRequest, + }, + this.requestPageUpdate, + ) + : nothing; + } +} + +if (!customElements.get("openclaw-skill-workshop-page")) { + customElements.define("openclaw-skill-workshop-page", SkillWorkshopPage); +} diff --git a/ui/src/pages/skill-workshop/storage.ts b/ui/src/pages/skill-workshop/storage.ts new file mode 100644 index 000000000000..23287a942bbe --- /dev/null +++ b/ui/src/pages/skill-workshop/storage.ts @@ -0,0 +1,38 @@ +import type { SkillWorkshopMode } from "../../lib/skill-workshop/index.ts"; +import { getSafeLocalStorage } from "../../local-storage.ts"; + +const SKILL_WORKSHOP_MODE_KEY = "openclaw:control-ui:skill-workshop-mode:v1"; +const SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY = + "openclaw:control-ui:skill-workshop-current-chat-revisions:v1"; + +export function loadSkillWorkshopMode(): SkillWorkshopMode { + try { + return getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_MODE_KEY) === "board" ? "board" : "today"; + } catch { + return "today"; + } +} + +export function saveSkillWorkshopMode(mode: SkillWorkshopMode): void { + try { + getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_MODE_KEY, mode); + } catch { + // best-effort + } +} + +export function loadSkillWorkshopUseCurrentChatForRevisions(): boolean { + try { + return getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY) === "true"; + } catch { + return false; + } +} + +export function saveSkillWorkshopUseCurrentChatForRevisions(enabled: boolean): void { + try { + getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY, String(enabled)); + } catch { + // best-effort + } +} diff --git a/ui/src/ui/views/skill-workshop.ts b/ui/src/pages/skill-workshop/view.ts similarity index 93% rename from ui/src/ui/views/skill-workshop.ts rename to ui/src/pages/skill-workshop/view.ts index 56c223053e28..620ea017c22e 100644 --- a/ui/src/ui/views/skill-workshop.ts +++ b/ui/src/pages/skill-workshop/view.ts @@ -2,57 +2,16 @@ import { html, nothing } from "lit"; import { keyed } from "lit/directives/keyed.js"; import { styleMap } from "lit/directives/style-map.js"; -import "../components/file-preview-modal.ts"; - -export type SkillWorkshopProposalStatus = - | "pending" - | "applied" - | "rejected" - | "quarantined" - | "stale"; - -export type SkillWorkshopFile = { - path: string; - size: string; - contents: string; -}; - -export type SkillWorkshopProposal = { - key: string; - slug: string; - name: string; - oneLine: string; - body: string; - status: SkillWorkshopProposalStatus; - origin?: { - agentId?: string; - sessionKey?: string; - runId?: string; - messageId?: string; - }; - version: number; - createdAt: number; - updatedAt?: number; - recencyGroup: "today" | "yesterday" | "earlier"; - ageLabel: string; - supportFiles: SkillWorkshopFile[]; - isNew: boolean; -}; - -export type SkillWorkshopStatusFilter = "all" | SkillWorkshopProposalStatus; -export type SkillWorkshopAction = "apply" | "revise" | "reject"; -export type SkillWorkshopMode = "board" | "today"; - -export type SkillWorkshopActionBusy = { - key: string; - action: SkillWorkshopAction; -}; - -export type SkillWorkshopActionNotice = { - key: string; - label: string; - slug: string; -}; +import "../../components/file-preview-modal.ts"; +import "../../components/tooltip.ts"; +import { + filterSkillWorkshopProposals, + type SkillWorkshopActionBusy, + type SkillWorkshopActionNotice, + type SkillWorkshopMode, + type SkillWorkshopProposal, + type SkillWorkshopStatusFilter, +} from "../../lib/skill-workshop/index.ts"; type SkillWorkshopEmptyIcon = "search" | "clock" | "check" | "x" | "shield" | "refresh"; @@ -73,6 +32,7 @@ export type SkillWorkshopProps = { revisionKey: string | null; revisionDraft: string; assistantName: string; + workshopAgentName: string; counts: Record; onStatusFilterChange: (status: SkillWorkshopStatusFilter) => void; onQueryChange: (query: string) => void; @@ -138,7 +98,7 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) { ? renderWorkshopEmptyState(props) : props.mode === "today" ? renderToday(props, todayHero, allPending) - : renderBoard(props, filtered, groups, selected); + : renderBoard(props, groups, selected); return html`
@@ -185,15 +145,16 @@ function renderRevisionDialog(props: SkillWorkshopProps, proposal: SkillWorkshop
${verb} proposal

${proposal.slug}

- + + +

Tell the agent what should change. The proposal stays pending and the workshop will create @@ -239,11 +200,9 @@ function renderRevisionDialog(props: SkillWorkshopProps, proposal: SkillWorkshop function renderBoard( props: SkillWorkshopProps, - filtered: SkillWorkshopProposal[], groups: Array<{ label: string; items: SkillWorkshopProposal[] }>, selected: SkillWorkshopProposal | undefined, ) { - void filtered; return html` ${renderLifecycleTabs(props)}

@@ -413,8 +372,12 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
- - + + + + + +
@@ -620,7 +583,7 @@ function renderEmptyStateIcon(icon: SkillWorkshopEmptyIcon) { } function renderWorkshopEmptyState(props: SkillWorkshopProps) { - const assistantName = props.assistantName.trim() || "Your agent"; + const assistantName = resolveSkillWorkshopAgentName(props, "Your agent"); return html`
@@ -638,6 +601,10 @@ function renderWorkshopEmptyState(props: SkillWorkshopProps) { `; } +function resolveSkillWorkshopAgentName(props: SkillWorkshopProps, fallback: string): string { + return props.workshopAgentName.trim() || props.assistantName.trim() || fallback; +} + function renderToday( props: SkillWorkshopProps, hero: SkillWorkshopProposal | undefined, @@ -667,7 +634,7 @@ function renderToday( const isPending = hero.status === "pending"; const busy = props.actionBusy?.key === hero.key ? props.actionBusy.action : null; const disabled = Boolean(props.actionBusy); - const assistantName = props.assistantName.trim() || "agent"; + const assistantName = resolveSkillWorkshopAgentName(props, "agent"); return html`
@@ -1063,26 +1030,6 @@ function renderInline(text: string): unknown { return parts; } -export function filterSkillWorkshopProposals( - proposals: SkillWorkshopProposal[], - statusFilter: SkillWorkshopStatusFilter, - query: string, -): SkillWorkshopProposal[] { - const q = query.trim().toLowerCase(); - return proposals.filter((p) => { - if (statusFilter !== "all" && p.status !== statusFilter) { - return false; - } - if (q) { - const hay = `${p.name} ${p.oneLine} ${p.slug}`.toLowerCase(); - if (!hay.includes(q)) { - return false; - } - } - return true; - }); -} - function groupByRecency( proposals: SkillWorkshopProposal[], ): Array<{ label: string; items: SkillWorkshopProposal[] }> { diff --git a/ui/src/pages/skills/route.ts b/ui/src/pages/skills/route.ts new file mode 100644 index 000000000000..cab7474c7211 --- /dev/null +++ b/ui/src/pages/skills/route.ts @@ -0,0 +1,56 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; +import { loadSkillStatusReport } from "../../lib/skills/index.ts"; +import type { SkillsRouteData } from "./skills-page.ts"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function loadSkillsRouteData(context: ApplicationContext): Promise { + const gateway = context.gateway.snapshot; + const client = gateway.client; + if (!gateway.connected || !client) { + return { + connected: false, + agentsList: null, + selectedAgentId: null, + report: null, + error: null, + }; + } + + let error: string | null = null; + let agentsList: SkillsRouteData["agentsList"] = null; + let report: SkillsRouteData["report"] = null; + try { + agentsList = await context.agents.ensureList(); + } catch (err) { + error = errorMessage(err); + } + try { + report = (await loadSkillStatusReport(client, null)) ?? null; + } catch (err) { + error ??= errorMessage(err); + } + return { + connected: true, + agentsList, + selectedAgentId: null, + report, + error, + }; +} + +export const page = definePage({ + id: "skills", + path: "/skills", + loader: loadSkillsRouteData, + component: () => + import("./skills-page.ts").then(() => ({ + header: true, + render: (data: SkillsRouteData | undefined) => + html``, + })), +}); diff --git a/ui/src/pages/skills/skills-page.ts b/ui/src/pages/skills/skills-page.ts new file mode 100644 index 000000000000..885fabafadee --- /dev/null +++ b/ui/src/pages/skills/skills-page.ts @@ -0,0 +1,345 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { AgentsListResult, SkillStatusReport } from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { applicationContext, type ApplicationContext } from "../../app/context.ts"; +import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { + closeClawHubDetail, + installFromClawHub, + installSkill, + loadClawHubDetail, + loadSkillCard, + loadSkills, + reconcileSkillsAgentId, + saveSkillApiKey, + searchClawHub, + setClawHubSearchQuery, + setSkillsAgentId, + updateSkillEdit, + updateSkillEnabled, + type ClawHubSearchResult, + type ClawHubSkillDetail, + type ClawHubSkillSecurityVerdict, + type SkillMessageMap, +} from "../../lib/skills/index.ts"; +import { renderSkills, type SkillDetailTab, type SkillsStatusFilter } from "./view.ts"; + +export type SkillsRouteData = { + connected: boolean; + agentsList: AgentsListResult | null; + selectedAgentId: string | null; + report: SkillStatusReport | null; + error: string | null; +}; + +export class SkillsPage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @property({ attribute: false }) routeData?: SkillsRouteData; + + @state() client: GatewayBrowserClient | null = null; + @state() connected = false; + @state() agentsLoading = false; + @state() agentsError: string | null = null; + @state() agentsList: AgentsListResult | null = null; + @state() skillsAgentId: string | null = null; + @state() skillsAgentRevision = 0; + @state() skillsLoading = false; + @state() skillsReport: SkillStatusReport | null = null; + @state() skillsError: string | null = null; + @state() skillsBusyKey: string | null = null; + @state() skillsFilter = ""; + @state() skillsStatusFilter: SkillsStatusFilter = "all"; + @state() skillEdits: Record = {}; + @state() skillMessages: SkillMessageMap = {}; + @state() skillsDetailKey: string | null = null; + @state() skillsDetailTab: SkillDetailTab = "overview"; + @state() clawhubSearchQuery = ""; + @state() clawhubSearchResults: ClawHubSearchResult[] | null = null; + @state() clawhubSearchLoading = false; + @state() clawhubSearchError: string | null = null; + @state() clawhubDetail: ClawHubSkillDetail | null = null; + @state() clawhubDetailSlug: string | null = null; + @state() clawhubDetailLoading = false; + @state() clawhubDetailError: string | null = null; + @state() clawhubInstallSlug: string | null = null; + @state() clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + acknowledgeLabel?: string; + } | null = null; + @state() clawhubVerdicts: Record = {}; + @state() clawhubVerdictsLoading = false; + @state() clawhubVerdictsError: string | null = null; + @state() skillCardContents: Record = {}; + @state() skillCardContentKeys: Record = {}; + @state() skillCardLoadingKey: string | null = null; + @state() skillCardErrors: Record = {}; + + private stopGatewaySubscription?: () => void; + private stopAgentsSubscription?: () => void; + private clawhubSearchTimer: ReturnType | null = null; + + override connectedCallback() { + super.connectedCallback(); + this.syncGatewayState(); + this.stopGatewaySubscription = this.context.gateway.subscribe(() => { + const previousClient = this.client; + this.syncGatewayState(); + if (previousClient !== this.client) { + this.resetLoadedSkillState(); + } + this.ensureInitialData(); + }); + this.stopAgentsSubscription = this.context.agents.subscribe(() => { + this.syncAgentState(); + this.requestUpdate(); + }); + this.syncAgentState(); + this.ensureInitialData(); + } + + override willUpdate(changed: Map) { + if (changed.has("routeData")) { + this.applyRouteData(); + } + } + + override disconnectedCallback() { + this.stopGatewaySubscription?.(); + this.stopGatewaySubscription = undefined; + this.stopAgentsSubscription?.(); + this.stopAgentsSubscription = undefined; + if (this.clawhubSearchTimer) { + clearTimeout(this.clawhubSearchTimer); + this.clawhubSearchTimer = null; + } + super.disconnectedCallback(); + } + + private syncGatewayState() { + const gateway = this.context.gateway.snapshot; + this.client = gateway.client; + this.connected = gateway.connected; + } + + private syncAgentState() { + const agentState = this.context.agents.state; + this.agentsLoading = agentState.agentsLoading; + this.agentsError = agentState.agentsError; + this.agentsList = agentState.agentsList; + if (agentState.agentsList) { + const previousAgentId = this.skillsAgentId; + reconcileSkillsAgentId(this, agentState.agentsList); + if (previousAgentId !== this.skillsAgentId) { + this.skillsDetailKey = null; + this.skillsDetailTab = "overview"; + } + } + } + + private resetLoadedSkillState() { + this.agentsLoading = false; + this.agentsError = null; + this.agentsList = null; + this.skillsAgentId = null; + this.skillsAgentRevision++; + this.skillsLoading = false; + this.skillsReport = null; + this.skillsError = null; + this.skillsBusyKey = null; + this.skillEdits = {}; + this.skillMessages = {}; + this.skillsDetailKey = null; + this.skillsDetailTab = "overview"; + this.clawhubInstallSlug = null; + this.clawhubInstallMessage = null; + this.clawhubVerdicts = {}; + this.clawhubVerdictsLoading = false; + this.clawhubVerdictsError = null; + this.skillCardContents = {}; + this.skillCardContentKeys = {}; + this.skillCardLoadingKey = null; + this.skillCardErrors = {}; + } + + private applyRouteData() { + const data = this.routeData; + if (!data) { + return; + } + if (this.skillsAgentId && data.selectedAgentId && data.selectedAgentId !== this.skillsAgentId) { + return; + } + this.connected = data.connected; + this.agentsLoading = false; + this.agentsError = null; + this.agentsList = data.agentsList ?? this.context.agents.state.agentsList; + this.skillsAgentId = data.selectedAgentId ?? this.skillsAgentId; + this.skillsLoading = false; + this.skillsReport = data.report; + this.skillsError = data.error; + } + + private ensureInitialData() { + if (!this.connected || !this.client) { + return; + } + if (this.routeData?.agentsList || this.routeData?.report || this.routeData?.error) { + return; + } + if (!this.agentsList && !this.agentsLoading) { + void this.loadAgents(); + } + if (!this.skillsReport && !this.skillsLoading) { + void loadSkills(this); + } + } + + private async loadAgents() { + const client = this.client; + if (!client || !this.connected || this.agentsLoading) { + return; + } + if (this.context.agents.state.agentsList) { + this.syncAgentState(); + return; + } + this.agentsLoading = true; + this.agentsError = null; + try { + const agents = await this.context.agents.ensureList(); + if (this.client !== client) { + return; + } + this.agentsList = agents; + const previousAgentId = this.skillsAgentId; + reconcileSkillsAgentId(this, agents); + if (previousAgentId !== this.skillsAgentId) { + this.skillsDetailKey = null; + this.skillsDetailTab = "overview"; + } + } catch (err) { + if (this.client === client) { + this.agentsError = String(err); + } + } finally { + if (this.client === client) { + this.agentsLoading = false; + } + } + } + + private async refreshPage() { + await this.loadAgents(); + await loadSkills(this, { clearMessages: true }); + } + + private changeAgent(agentId: string) { + const previousAgentId = this.skillsAgentId; + setSkillsAgentId(this, agentId); + if (previousAgentId !== this.skillsAgentId) { + this.skillsDetailKey = null; + this.skillsDetailTab = "overview"; + } + void loadSkills(this, { clearMessages: true }); + } + + private changeClawHubQuery(query: string) { + setClawHubSearchQuery(this, query); + if (this.clawhubSearchTimer) { + clearTimeout(this.clawhubSearchTimer); + } + this.clawhubSearchTimer = setTimeout(() => void searchClawHub(this, query), 300); + } + + private changeDetailTab(tab: SkillDetailTab) { + this.skillsDetailTab = tab; + if (tab === "card" && this.skillsDetailKey) { + void loadSkillCard(this, this.skillsDetailKey); + } + } + + override render() { + const error = this.skillsError ?? this.agentsError; + return html` +
+
+
${titleForRoute("skills")}
+
${subtitleForRoute("skills")}
+
+
+ ${renderSettingsWorkspace( + this.context.basePath, + renderSkills({ + connected: this.connected, + loading: this.skillsLoading || this.agentsLoading, + report: this.skillsReport, + agentsList: this.agentsList, + selectedAgentId: this.skillsAgentId ?? this.agentsList?.defaultId ?? null, + error, + filter: this.skillsFilter, + statusFilter: this.skillsStatusFilter, + edits: this.skillEdits, + messages: this.skillMessages, + busyKey: this.skillsBusyKey, + detailKey: this.skillsDetailKey, + detailTab: this.skillsDetailTab, + clawhubVerdicts: this.clawhubVerdicts, + clawhubVerdictsLoading: this.clawhubVerdictsLoading, + clawhubVerdictsError: this.clawhubVerdictsError, + skillCardContents: this.skillCardContents, + skillCardLoadingKey: this.skillCardLoadingKey, + skillCardErrors: this.skillCardErrors, + clawhubQuery: this.clawhubSearchQuery, + clawhubResults: this.clawhubSearchResults, + clawhubSearchLoading: this.clawhubSearchLoading, + clawhubSearchError: this.clawhubSearchError, + clawhubDetail: this.clawhubDetail, + clawhubDetailSlug: this.clawhubDetailSlug, + clawhubDetailLoading: this.clawhubDetailLoading, + clawhubDetailError: this.clawhubDetailError, + clawhubInstallSlug: this.clawhubInstallSlug, + clawhubInstallMessage: this.clawhubInstallMessage, + onAgentChange: (agentId) => this.changeAgent(agentId), + onFilterChange: (next) => (this.skillsFilter = next), + onStatusFilterChange: (next) => (this.skillsStatusFilter = next), + onRefresh: () => void this.refreshPage(), + onToggle: (key, enabled) => void updateSkillEnabled(this, key, enabled), + onEdit: (key, value) => updateSkillEdit(this, key, value), + onSaveKey: (key) => void saveSkillApiKey(this, key), + onInstall: (skillKey, name, installId) => + void installSkill(this, skillKey, name, installId), + onDetailOpen: (key) => { + this.skillsDetailKey = key; + this.skillsDetailTab = "overview"; + }, + onDetailClose: () => (this.skillsDetailKey = null), + onDetailTabChange: (tab) => this.changeDetailTab(tab), + onClawHubQueryChange: (query) => this.changeClawHubQuery(query), + onClawHubDetailOpen: (slug) => void loadClawHubDetail(this, slug), + onClawHubDetailClose: () => closeClawHubDetail(this), + onClawHubInstall: (slug, acknowledgeClawHubRisk, version) => + void installFromClawHub(this, slug, acknowledgeClawHubRisk, version), + }), + "skills", + (routeId) => this.context.navigate(routeId), + (routeId) => this.context.preload(routeId), + )} + `; + } +} + +if (!customElements.get("openclaw-skills-page")) { + customElements.define("openclaw-skills-page", SkillsPage); +} diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/pages/skills/view.test.ts similarity index 99% rename from ui/src/ui/views/skills.test.ts rename to ui/src/pages/skills/view.test.ts index bef55ad3201b..f1d9891107b3 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/pages/skills/view.test.ts @@ -2,8 +2,8 @@ import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../types.ts"; -import { renderSkills, type SkillsProps } from "./skills.ts"; +import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts"; +import { renderSkills, type SkillsProps } from "./view.ts"; const dialogRestores: Array<() => void> = []; diff --git a/ui/src/ui/views/skills.ts b/ui/src/pages/skills/view.ts similarity index 97% rename from ui/src/ui/views/skills.ts rename to ui/src/pages/skills/view.ts index 9f97b0b3c353..d4463909bf1d 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/pages/skills/view.ts @@ -1,28 +1,28 @@ -// Control UI view renders skills screen content. +// Control UI page renders skills screen content. import { html, nothing } from "lit"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts"; +import { toSanitizedMarkdownHtml } from "../../components/markdown.ts"; import { t } from "../../i18n/index.ts"; -import type { - ClawHubSkillSecurityVerdict, - ClawHubSearchResult, - ClawHubSkillDetail, - SkillMessageMap, -} from "../controllers/skills.ts"; -import { clawhubVerdictKey } from "../controllers/skills.ts"; -import { clampText } from "../format.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; -import { resolveSafeExternalUrl } from "../open-external-url.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../types.ts"; -import { groupSkills } from "./skills-grouping.ts"; +import { clampText } from "../../lib/format.ts"; +import { resolveSafeExternalUrl } from "../../lib/open-external-url.ts"; +import { groupSkills } from "../../lib/skills-grouping.ts"; import { computeSkillMissing, computeSkillReasons, isSkillAvailable, renderSkillStatusChips, -} from "./skills-shared.ts"; +} from "../../lib/skills-shared.ts"; +import { + clawhubVerdictKey, + type ClawHubSkillSecurityVerdict, + type ClawHubSearchResult, + type ClawHubSkillDetail, + type SkillMessageMap, +} from "../../lib/skills/index.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; function safeExternalHref(raw?: string): string | null { if (!raw) { diff --git a/ui/src/ui/usage-cache-status.test.ts b/ui/src/pages/usage/cache-status.test.ts similarity index 92% rename from ui/src/ui/usage-cache-status.test.ts rename to ui/src/pages/usage/cache-status.test.ts index 411ad7e19e94..33d2777d009b 100644 --- a/ui/src/ui/usage-cache-status.test.ts +++ b/ui/src/pages/usage/cache-status.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { getUsageCacheRefreshTitle } from "./usage-cache-status.ts"; +import { getUsageCacheRefreshTitle } from "./cache-status.ts"; describe("getUsageCacheRefreshTitle", () => { it("formats non-fresh cache states for the Usage loading badge", () => { diff --git a/ui/src/pages/usage/cache-status.ts b/ui/src/pages/usage/cache-status.ts new file mode 100644 index 000000000000..f02f89916687 --- /dev/null +++ b/ui/src/pages/usage/cache-status.ts @@ -0,0 +1,47 @@ +// Control UI module implements usage cache status behavior. +import { t } from "../../i18n/index.ts"; +import type { SessionsUsageResult } from "./data-types.ts"; + +export type UsageCacheStatus = SessionsUsageResult["cacheStatus"]; + +export function mergeUsageCacheStatus( + sessionsStatus: UsageCacheStatus, + costStatus: UsageCacheStatus, +): UsageCacheStatus { + if (!sessionsStatus) { + return costStatus; + } + if (!costStatus) { + return sessionsStatus; + } + const rank = { fresh: 0, partial: 1, stale: 2, refreshing: 3 } as const; + const status = + rank[costStatus.status] > rank[sessionsStatus.status] + ? costStatus.status + : sessionsStatus.status; + return { + status, + cachedFiles: Math.max(sessionsStatus.cachedFiles, costStatus.cachedFiles), + pendingFiles: Math.max(sessionsStatus.pendingFiles, costStatus.pendingFiles), + staleFiles: Math.max(sessionsStatus.staleFiles, costStatus.staleFiles), + refreshedAt: + Math.max(sessionsStatus.refreshedAt ?? 0, costStatus.refreshedAt ?? 0) || undefined, + }; +} + +export function getUsageCacheRefreshTitle(cacheStatus: UsageCacheStatus): string | null { + if ( + !cacheStatus || + (cacheStatus.status !== "refreshing" && + cacheStatus.status !== "stale" && + cacheStatus.status !== "partial") + ) { + return null; + } + return t("usage.cacheStatus.title", { + status: t(`usage.cacheStatus.status.${cacheStatus.status}`), + pending: String(cacheStatus.pendingFiles), + stale: String(cacheStatus.staleFiles), + cached: String(cacheStatus.cachedFiles), + }); +} diff --git a/ui/src/ui/usage-types.ts b/ui/src/pages/usage/data-types.ts similarity index 88% rename from ui/src/ui/usage-types.ts rename to ui/src/pages/usage/data-types.ts index 3a45b1ae7b11..5080ff591afe 100644 --- a/ui/src/ui/usage-types.ts +++ b/ui/src/pages/usage/data-types.ts @@ -2,8 +2,8 @@ import type { SessionUsageTimePoint as SharedSessionUsageTimePoint, SessionUsageTimeSeries as SharedSessionUsageTimeSeries, -} from "../../../src/shared/session-usage-timeseries-types.js"; -import type { SessionsUsageResult as SharedSessionsUsageResult } from "../../../src/shared/usage-types.js"; +} from "../../../../src/shared/session-usage-timeseries-types.js"; +import type { SessionsUsageResult as SharedSessionsUsageResult } from "../../../../src/shared/usage-types.js"; export type SessionsUsageEntry = SharedSessionsUsageResult["sessions"][number]; export type SessionsUsageTotals = SharedSessionsUsageResult["totals"]; diff --git a/ui/src/ui/usage-helpers.node.test.ts b/ui/src/pages/usage/helpers.node.test.ts similarity index 98% rename from ui/src/ui/usage-helpers.node.test.ts rename to ui/src/pages/usage/helpers.node.test.ts index d8bf17488127..ae8e1283f218 100644 --- a/ui/src/ui/usage-helpers.node.test.ts +++ b/ui/src/pages/usage/helpers.node.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./usage-helpers.ts"; +import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./helpers.ts"; function requireFirstTool(tools: Array<[string, number]>): [string, number] { const tool = tools[0]; diff --git a/ui/src/ui/usage-helpers.ts b/ui/src/pages/usage/helpers.ts similarity index 84% rename from ui/src/ui/usage-helpers.ts rename to ui/src/pages/usage/helpers.ts index 3f2c9954fd42..69a455a10962 100644 --- a/ui/src/ui/usage-helpers.ts +++ b/ui/src/pages/usage/helpers.ts @@ -1,4 +1,6 @@ // Control UI module implements usage helpers behavior. +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; + export type UsageQueryTerm = { key?: string; value: string; @@ -32,6 +34,54 @@ type UsageSessionQueryTarget = { } | null; }; +export function toggleUsageRangeSelection( + selected: T[], + value: T, + orderedValues: T[], + shiftKey: boolean, + append: boolean, +): T[] { + if (shiftKey && selected.length > 0) { + const lastIndex = orderedValues.indexOf(selected[selected.length - 1]); + const nextIndex = orderedValues.indexOf(value); + if (lastIndex !== -1 && nextIndex !== -1) { + const [start, end] = lastIndex < nextIndex ? [lastIndex, nextIndex] : [nextIndex, lastIndex]; + return [...new Set([...selected, ...orderedValues.slice(start, end + 1)])]; + } + } + if (selected.includes(value)) { + return selected.filter((entry) => entry !== value); + } + return append ? [...selected, value] : [value]; +} + +export function selectUsageSessionKeys( + selected: string[], + key: string, + sessions: UsageSessionQueryTarget[], + tokenMode: boolean, + shiftKey: boolean, +): string[] { + if (shiftKey && selected.length > 0) { + const orderedKeys = [...sessions] + .toSorted((left, right) => { + const leftValue = tokenMode ? (left.usage?.totalTokens ?? 0) : (left.usage?.totalCost ?? 0); + const rightValue = tokenMode + ? (right.usage?.totalTokens ?? 0) + : (right.usage?.totalCost ?? 0); + return rightValue - leftValue; + }) + .map((session) => session.key); + const lastIndex = orderedKeys.indexOf(selected[selected.length - 1]); + const nextIndex = orderedKeys.indexOf(key); + if (lastIndex !== -1 && nextIndex !== -1) { + const [start, end] = lastIndex < nextIndex ? [lastIndex, nextIndex] : [nextIndex, lastIndex]; + return [...new Set([...selected, ...orderedKeys.slice(start, end + 1)])]; + } + } + return selected.length === 1 && selected[0] === key ? [] : [key]; +} + const QUERY_KEYS = new Set([ "agent", "channel", @@ -323,4 +373,3 @@ export function parseToolSummary(content: string) { cleanContent: nonToolLines.join("\n").trim(), }; } -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; diff --git a/ui/src/ui/views/usage-metrics.node.test.ts b/ui/src/pages/usage/metrics.node.test.ts similarity index 92% rename from ui/src/ui/views/usage-metrics.node.test.ts rename to ui/src/pages/usage/metrics.node.test.ts index e5bd06a604aa..e4b23bb56de4 100644 --- a/ui/src/ui/views/usage-metrics.node.test.ts +++ b/ui/src/pages/usage/metrics.node.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../../../../src/test-utils/env.js"; -import { formatDayLabel, formatFullDate } from "./usage-metrics.ts"; +import { formatDayLabel, formatFullDate } from "./metrics.ts"; describe("usage metrics date labels", () => { it("formats YYYY-MM-DD values as stable calendar dates in negative UTC offsets", async () => { diff --git a/ui/src/ui/views/usage-metrics.test.ts b/ui/src/pages/usage/metrics.test.ts similarity index 99% rename from ui/src/ui/views/usage-metrics.test.ts rename to ui/src/pages/usage/metrics.test.ts index 291fb61b1d97..40f4a6b74235 100644 --- a/ui/src/ui/views/usage-metrics.test.ts +++ b/ui/src/pages/usage/metrics.test.ts @@ -6,8 +6,8 @@ import { formatTokens, getHourAndWeekdayForUtcQuarterBucket, sessionTouchesSelectedHours, -} from "./usage-metrics.ts"; -import type { UsageSessionEntry } from "./usageTypes.ts"; +} from "./metrics.ts"; +import type { UsageSessionEntry } from "./types.ts"; /** * Helper: build a minimal UsageSessionEntry with utcQuarterHourMessageCounts diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/pages/usage/metrics.ts similarity index 97% rename from ui/src/ui/views/usage-metrics.ts rename to ui/src/pages/usage/metrics.ts index 4a440cdb3699..69ddaf7c1d15 100644 --- a/ui/src/ui/views/usage-metrics.ts +++ b/ui/src/pages/usage/metrics.ts @@ -6,8 +6,9 @@ import { mergeUsageLatency, } from "../../../../src/shared/usage-aggregates.js"; import { t } from "../../i18n/index.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { UsageSessionEntry, UsageTotals, UsageAggregates } from "./usageTypes.ts"; +import { formatCompactTokenCount } from "../../lib/format.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import type { UsageSessionEntry, UsageTotals, UsageAggregates } from "./types.ts"; const CHARS_PER_TOKEN = 4; @@ -16,22 +17,7 @@ function charsToTokens(chars: number): number { } function formatTokens(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1)}M`; - } - if (n >= 1_000) { - // Values from 999,950-999,999 round to "1000.0" at one-decimal - // thousands precision, which would display the nonsensical "1000.0K" - // instead of rolling over to the M branch above. Re-check the - // rounded result before formatting. Mirrors the guard in - // formatCompactTokenCount (../chat/token-format.ts). - const thousands = (n / 1_000).toFixed(1); - if (Number(thousands) >= 1_000) { - return `${(n / 1_000_000).toFixed(1)}M`; - } - return `${thousands}K`; - } - return String(n); + return formatCompactTokenCount(n, { thousandsSuffix: "K", trimTrailingZero: false }); } function formatHourLabel(hour: number): string { diff --git a/ui/src/ui/views/usage-query.test.ts b/ui/src/pages/usage/query.test.ts similarity index 81% rename from ui/src/ui/views/usage-query.test.ts rename to ui/src/pages/usage/query.test.ts index 1c7197de7715..650dd7d64c77 100644 --- a/ui/src/ui/views/usage-query.test.ts +++ b/ui/src/pages/usage/query.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { buildSessionsCsv } from "./usage-query.ts"; -import type { UsageSessionEntry } from "./usageTypes.ts"; +import { buildSessionsCsv } from "./query.ts"; +import type { UsageSessionEntry } from "./types.ts"; describe("usage query CSV export", () => { it("omits invalid session updated timestamps instead of throwing", () => { diff --git a/ui/src/ui/views/usage-query.ts b/ui/src/pages/usage/query.ts similarity index 98% rename from ui/src/ui/views/usage-query.ts rename to ui/src/pages/usage/query.ts index ac7445b779bd..5f46f1233d88 100644 --- a/ui/src/ui/views/usage-query.ts +++ b/ui/src/pages/usage/query.ts @@ -1,8 +1,8 @@ // Control UI view renders usage query screen content. import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; -import { normalizeLowercaseStringOrEmpty, uniqueStrings } from "../string-coerce.ts"; -import { extractQueryTerms } from "../usage-helpers.ts"; -import type { CostDailyEntry, UsageAggregates, UsageSessionEntry } from "./usageTypes.ts"; +import { normalizeLowercaseStringOrEmpty, uniqueStrings } from "../../lib/string-coerce.ts"; +import { extractQueryTerms } from "./helpers.ts"; +import type { CostDailyEntry, UsageAggregates, UsageSessionEntry } from "./types.ts"; function downloadTextFile(filename: string, content: string, type = "text/plain") { const blob = new Blob([content], { type: `${type};charset=utf-8` }); diff --git a/ui/src/pages/usage/route.ts b/ui/src/pages/usage/route.ts new file mode 100644 index 000000000000..e421f658b72f --- /dev/null +++ b/ui/src/pages/usage/route.ts @@ -0,0 +1,91 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { CostUsageSummary } from "../../api/types.ts"; +import type { ApplicationContext } from "../../app/context.ts"; +import { + formatMissingOperatorReadScopeMessage, + isMissingOperatorReadScopeError, +} from "../../lib/gateway-errors.ts"; +import { buildSessionUsageDateParams, requestSessionUsage } from "../../lib/sessions/index.ts"; +import type { UsageRouteData } from "./usage-page.ts"; + +function currentLocalDate(): string { + const date = new Date(); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; +} + +function errorMessage(error: unknown): string { + if (isMissingOperatorReadScopeError(error)) { + return formatMissingOperatorReadScopeMessage("usage"); + } + if (error instanceof Error && error.message.trim()) { + return error.message; + } + return typeof error === "string" ? error : "request failed"; +} + +async function loadUsageRouteData(context: ApplicationContext): Promise { + const gateway = context.gateway.snapshot; + const startDate = currentLocalDate(); + const query: UsageRouteData["query"] = { + startDate, + endDate: startDate, + scope: "family", + timeZone: "local", + agentId: null, + }; + if (!gateway.connected || !gateway.client) { + return { + client: gateway.client, + connected: gateway.connected, + query, + result: null, + costSummary: null, + error: null, + }; + } + + try { + const [result, costSummary] = await Promise.all([ + requestSessionUsage(gateway.client, { + ...query, + agentId: query.agentId ?? undefined, + }), + gateway.client.request("usage.cost", { + startDate: query.startDate, + endDate: query.endDate, + agentScope: "all", + ...buildSessionUsageDateParams(query.timeZone), + }), + ]); + return { + client: gateway.client, + connected: true, + query, + result, + costSummary, + error: null, + }; + } catch (error) { + return { + client: gateway.client, + connected: true, + query, + result: null, + costSummary: null, + error: errorMessage(error), + }; + } +} + +export const page = definePage({ + id: "usage", + path: "/usage", + loader: loadUsageRouteData, + component: () => + import("./usage-page.ts").then(() => ({ + header: true, + render: (data: UsageRouteData | undefined) => + html``, + })), +}); diff --git a/ui/src/ui/views/usageTypes.ts b/ui/src/pages/usage/types.ts similarity index 99% rename from ui/src/ui/views/usageTypes.ts rename to ui/src/pages/usage/types.ts index 8bad37b61864..480e76bfeda4 100644 --- a/ui/src/ui/views/usageTypes.ts +++ b/ui/src/pages/usage/types.ts @@ -5,7 +5,7 @@ import type { SessionsUsageResult, SessionsUsageTotals, SessionUsageTimePoint, -} from "../usage-types.ts"; +} from "./data-types.ts"; export type UsageSessionEntry = SessionsUsageEntry; export type UsageTotals = SessionsUsageTotals; diff --git a/ui/src/pages/usage/usage-page.ts b/ui/src/pages/usage/usage-page.ts new file mode 100644 index 000000000000..e42cebbeeb22 --- /dev/null +++ b/ui/src/pages/usage/usage-page.ts @@ -0,0 +1,661 @@ +import { consume } from "@lit/context"; +import { html, LitElement } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + CostUsageSummary, + SessionsUsageResult, + SessionUsageTimeSeries, +} from "../../api/types.ts"; +import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationGatewaySnapshot, +} from "../../app/context.ts"; +import { + formatMissingOperatorReadScopeMessage, + isMissingOperatorReadScopeError, +} from "../../lib/gateway-errors.ts"; +import { + buildSessionUsageDateParams, + requestSessionUsage, + requestSessionUsageLogs, + requestSessionUsageTimeSeries, +} from "../../lib/sessions/index.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { mergeUsageCacheStatus } from "./cache-status.ts"; +import { selectUsageSessionKeys, toggleUsageRangeSelection } from "./helpers.ts"; +import type { SessionLogEntry, SessionLogRole, UsageColumnId, UsageProps } from "./types.ts"; +import { renderUsage } from "./view.ts"; + +export type UsageRouteData = { + client: GatewayBrowserClient | null; + connected: boolean; + query: { + startDate: string; + endDate: string; + scope: "instance" | "family"; + timeZone: "local" | "utc"; + agentId: string | null; + }; + result: SessionsUsageResult | null; + costSummary: CostUsageSummary | null; + error: string | null; +}; + +const DEFAULT_VISIBLE_COLUMNS: UsageColumnId[] = [ + "channel", + "agent", + "provider", + "model", + "messages", + "tools", + "errors", + "duration", +]; + +function currentLocalDate(): string { + const date = new Date(); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; +} + +function toErrorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (error instanceof Error && error.message.trim()) { + return error.message; + } + if (error && typeof error === "object") { + try { + return JSON.stringify(error) || "request failed"; + } catch { + // Fall through to the stable generic message. + } + } + return "request failed"; +} + +export class UsagePage extends LitElement { + override createRenderRoot() { + return this; + } + + @consume({ context: applicationContext, subscribe: false }) + private context!: ApplicationContext; + + @property({ attribute: false }) routeData?: UsageRouteData; + + @state() private usageLoading = true; + @state() private usageResult: SessionsUsageResult | null = null; + @state() private usageCostSummary: CostUsageSummary | null = null; + @state() private usageError: string | null = null; + @state() private usageStartDate = currentLocalDate(); + @state() private usageEndDate = currentLocalDate(); + @state() private usageScope: "instance" | "family" = "family"; + @state() private usageAgentId: string | null = null; + @state() private usageSelectedSessions: string[] = []; + @state() private usageSelectedDays: string[] = []; + @state() private usageSelectedHours: number[] = []; + @state() private usageChartMode: "tokens" | "cost" = "tokens"; + @state() private usageDailyChartMode: "total" | "by-type" = "by-type"; + @state() private usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn"; + @state() private usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type"; + @state() private usageTimeSeries: SessionUsageTimeSeries | null = null; + @state() private usageTimeSeriesLoading = false; + @state() private usageTimeSeriesCursorStart: number | null = null; + @state() private usageTimeSeriesCursorEnd: number | null = null; + @state() private usageSessionLogs: SessionLogEntry[] | null = null; + @state() private usageSessionLogsLoading = false; + @state() private usageSessionLogsExpanded = false; + @state() private usageQuery = ""; + @state() private usageQueryDraft = ""; + @state() private usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors" = + "recent"; + @state() private usageSessionSortDir: "desc" | "asc" = "desc"; + @state() private usageRecentSessions: string[] = []; + @state() private usageTimeZone: "local" | "utc" = "local"; + @state() private usageContextExpanded = false; + @state() private usageHeaderPinned = false; + @state() private usageSessionsTab: "all" | "recent" = "all"; + @state() private usageVisibleColumns = [...DEFAULT_VISIBLE_COLUMNS]; + @state() private usageLogFilterRoles: SessionLogRole[] = []; + @state() private usageLogFilterTools: string[] = []; + @state() private usageLogFilterHasTools = false; + @state() private usageLogFilterQuery = ""; + + private client: GatewayBrowserClient | null = null; + private connected = false; + private usageRequestId = 0; + private timeSeriesRequestId = 0; + private logsRequestId = 0; + private dateDebounceTimer: number | null = null; + private queryDebounceTimer: number | null = null; + private subscriptions: Array<() => void> = []; + private routeDataInitialized = false; + private routeDataEnabled = true; + + override connectedCallback() { + super.connectedCallback(); + this.subscriptions = [ + this.context.gateway.subscribe((snapshot) => this.applyGatewaySnapshot(snapshot)), + this.context.agents.subscribe(() => this.requestUpdate()), + ]; + this.applyGatewaySnapshot(this.context.gateway.snapshot, true); + } + + override willUpdate(changed: Map) { + if (changed.has("routeData")) { + this.applyRouteData(); + } + } + + override updated(changed: Map) { + if (changed.has("routeData")) { + this.ensureInitialData(); + } + } + + override disconnectedCallback() { + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + this.subscriptions = []; + this.clearDateDebounce(); + this.clearQueryDebounce(); + this.invalidateRequests(); + this.client = null; + this.connected = false; + super.disconnectedCallback(); + } + + private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, initial = false) { + const clientChanged = snapshot.client !== this.client; + const becameConnected = snapshot.connected && !this.connected; + this.client = snapshot.client; + this.connected = snapshot.connected; + + if (clientChanged && !initial) { + this.resetForClientChange(); + } + if (!snapshot.connected || !snapshot.client) { + this.invalidateRequests(); + return; + } + + void this.context.agents.ensureList(); + if (this.routeDataInitialized && (clientChanged || becameConnected)) { + void this.loadUsage(); + } + } + + private applyRouteData() { + const data = this.routeData; + if (!data) { + return; + } + this.routeDataInitialized = true; + if (!this.routeDataEnabled) { + return; + } + const gateway = this.context.gateway.snapshot; + if (data.client !== gateway.client || data.connected !== gateway.connected) { + this.routeDataEnabled = false; + this.usageLoading = false; + return; + } + + this.usageStartDate = data.query.startDate; + this.usageEndDate = data.query.endDate; + this.usageScope = data.query.scope; + this.usageTimeZone = data.query.timeZone; + this.usageAgentId = data.query.agentId; + this.usageResult = data.result; + this.usageCostSummary = data.costSummary; + this.usageError = data.error; + this.usageLoading = false; + } + + private ensureInitialData() { + if ( + this.routeDataEnabled || + !this.routeDataInitialized || + !this.client || + !this.connected || + this.usageLoading + ) { + return; + } + void this.loadUsage(); + } + + private resetForClientChange() { + this.clearDateDebounce(); + this.invalidateRequests(); + this.routeDataEnabled = false; + this.usageResult = null; + this.usageCostSummary = null; + this.usageError = null; + this.usageAgentId = null; + this.clearSelectionsAndDetails(); + } + + private invalidateRequests() { + this.usageRequestId += 1; + this.timeSeriesRequestId += 1; + this.logsRequestId += 1; + this.usageLoading = false; + this.usageTimeSeriesLoading = false; + this.usageSessionLogsLoading = false; + } + + private invalidateUsageRequest() { + this.usageRequestId += 1; + this.routeDataEnabled = false; + this.usageLoading = false; + } + + private invalidateDetailRequests() { + this.timeSeriesRequestId += 1; + this.logsRequestId += 1; + this.usageTimeSeriesLoading = false; + this.usageSessionLogsLoading = false; + } + + private isCurrentRequest(requestId: number, client: GatewayBrowserClient): boolean { + const gateway = this.context.gateway.snapshot; + return this.isConnected && requestId === this.usageRequestId && gateway.client === client; + } + + private isCurrentDetailRequest( + requestId: number, + currentRequestId: number, + client: GatewayBrowserClient, + sessionKey: string, + ): boolean { + const gateway = this.context.gateway.snapshot; + return ( + this.isConnected && + requestId === currentRequestId && + gateway.client === client && + this.usageSelectedSessions.length === 1 && + this.usageSelectedSessions[0] === sessionKey + ); + } + + private async loadUsage() { + const client = this.client; + if (!client || !this.connected || this.usageLoading) { + return; + } + + this.routeDataEnabled = false; + const requestId = ++this.usageRequestId; + const startDate = this.usageStartDate; + const endDate = this.usageEndDate; + const scope = this.usageScope; + const timeZone = this.usageTimeZone; + const agentId = normalizeLowercaseStringOrEmpty(this.usageAgentId ?? "") || undefined; + this.usageLoading = true; + this.usageError = null; + try { + const agentScopeParams = agentId ? { agentId } : { agentScope: "all" as const }; + const [sessionsResult, costSummary] = await Promise.all([ + requestSessionUsage(client, { startDate, endDate, agentId, scope, timeZone }), + client.request("usage.cost", { + startDate, + endDate, + ...agentScopeParams, + ...buildSessionUsageDateParams(timeZone), + }), + ]); + if (!this.isCurrentRequest(requestId, client)) { + return; + } + this.usageResult = sessionsResult; + this.usageCostSummary = costSummary; + } catch (error) { + if (!this.isCurrentRequest(requestId, client)) { + return; + } + if (isMissingOperatorReadScopeError(error)) { + this.usageResult = null; + this.usageCostSummary = null; + this.usageError = formatMissingOperatorReadScopeMessage("usage"); + } else { + this.usageError = toErrorMessage(error); + } + } finally { + if (this.isCurrentRequest(requestId, client)) { + this.usageLoading = false; + } + } + } + + private async loadSessionTimeSeries(sessionKey: string) { + const client = this.client; + if (!client || !this.connected) { + return; + } + const requestId = ++this.timeSeriesRequestId; + this.usageTimeSeriesLoading = true; + try { + const result = await requestSessionUsageTimeSeries(client, sessionKey); + if (this.isCurrentDetailRequest(requestId, this.timeSeriesRequestId, client, sessionKey)) { + this.usageTimeSeries = result; + } + } catch { + // Optional detail endpoint. + } finally { + if (this.isCurrentDetailRequest(requestId, this.timeSeriesRequestId, client, sessionKey)) { + this.usageTimeSeriesLoading = false; + } + } + } + + private async loadSessionLogs(sessionKey: string) { + const client = this.client; + if (!client || !this.connected) { + return; + } + const requestId = ++this.logsRequestId; + this.usageSessionLogsLoading = true; + try { + const payload = await requestSessionUsageLogs(client, sessionKey); + if (!this.isCurrentDetailRequest(requestId, this.logsRequestId, client, sessionKey)) { + return; + } + this.usageSessionLogs = Array.isArray(payload.logs) + ? (payload.logs as SessionLogEntry[]) + : null; + } catch { + // Optional detail endpoint. + } finally { + if (this.isCurrentDetailRequest(requestId, this.logsRequestId, client, sessionKey)) { + this.usageSessionLogsLoading = false; + } + } + } + + private clearSelections() { + this.usageSelectedDays = []; + this.usageSelectedHours = []; + this.usageSelectedSessions = []; + } + + private clearDetails() { + this.invalidateDetailRequests(); + this.usageTimeSeries = null; + this.usageSessionLogs = null; + this.usageTimeSeriesCursorStart = null; + this.usageTimeSeriesCursorEnd = null; + } + + private clearSelectionsAndDetails() { + this.clearSelections(); + this.clearDetails(); + } + + private clearDateDebounce() { + if (this.dateDebounceTimer !== null) { + window.clearTimeout(this.dateDebounceTimer); + this.dateDebounceTimer = null; + } + } + + private scheduleUsageLoad() { + this.clearDateDebounce(); + this.invalidateUsageRequest(); + this.dateDebounceTimer = window.setTimeout(() => { + this.dateDebounceTimer = null; + void this.loadUsage(); + }, 400); + } + + private reloadUsage() { + this.clearDateDebounce(); + this.invalidateUsageRequest(); + void this.loadUsage(); + } + + private clearQueryDebounce() { + if (this.queryDebounceTimer !== null) { + window.clearTimeout(this.queryDebounceTimer); + this.queryDebounceTimer = null; + } + } + + private selectSession(key: string, shiftKey: boolean) { + this.clearDetails(); + this.usageRecentSessions = [ + key, + ...this.usageRecentSessions.filter((entry) => entry !== key), + ].slice(0, 8); + + this.usageSelectedSessions = selectUsageSessionKeys( + this.usageSelectedSessions, + key, + this.usageResult?.sessions ?? [], + this.usageChartMode === "tokens", + shiftKey, + ); + + if (this.usageSelectedSessions.length === 1) { + const sessionKey = this.usageSelectedSessions[0]; + void this.loadSessionTimeSeries(sessionKey); + void this.loadSessionLogs(sessionKey); + } + } + + override render() { + const props: UsageProps = { + data: { + loading: this.usageLoading, + error: this.usageError, + sessions: this.usageResult?.sessions ?? [], + agents: + this.context.agents.state.agentsList?.agents.map((entry) => entry.id).filter(Boolean) ?? + [], + sessionsLimitReached: (this.usageResult?.sessions.length ?? 0) >= 1000, + totals: this.usageResult?.totals ?? null, + aggregates: this.usageResult?.aggregates ?? null, + costDaily: this.usageCostSummary?.daily ?? [], + cacheStatus: mergeUsageCacheStatus( + this.usageResult?.cacheStatus, + this.usageCostSummary?.cacheStatus, + ), + }, + filters: { + startDate: this.usageStartDate, + endDate: this.usageEndDate, + scope: this.usageScope, + selectedSessions: this.usageSelectedSessions, + selectedDays: this.usageSelectedDays, + selectedHours: this.usageSelectedHours, + agentId: this.usageAgentId, + query: this.usageQuery, + queryDraft: this.usageQueryDraft, + timeZone: this.usageTimeZone, + }, + display: { + chartMode: this.usageChartMode, + dailyChartMode: this.usageDailyChartMode, + sessionSort: this.usageSessionSort, + sessionSortDir: this.usageSessionSortDir, + recentSessions: this.usageRecentSessions, + sessionsTab: this.usageSessionsTab, + visibleColumns: this.usageVisibleColumns, + contextExpanded: this.usageContextExpanded, + headerPinned: this.usageHeaderPinned, + }, + detail: { + timeSeriesMode: this.usageTimeSeriesMode, + timeSeriesBreakdownMode: this.usageTimeSeriesBreakdownMode, + timeSeries: this.usageTimeSeries, + timeSeriesLoading: this.usageTimeSeriesLoading, + timeSeriesCursorStart: this.usageTimeSeriesCursorStart, + timeSeriesCursorEnd: this.usageTimeSeriesCursorEnd, + sessionLogs: this.usageSessionLogs, + sessionLogsLoading: this.usageSessionLogsLoading, + sessionLogsExpanded: this.usageSessionLogsExpanded, + logFilters: { + roles: this.usageLogFilterRoles, + tools: this.usageLogFilterTools, + hasTools: this.usageLogFilterHasTools, + query: this.usageLogFilterQuery, + }, + }, + callbacks: { + filters: { + onStartDateChange: (date) => { + this.usageStartDate = date; + this.clearSelectionsAndDetails(); + this.scheduleUsageLoad(); + }, + onEndDateChange: (date) => { + this.usageEndDate = date; + this.clearSelectionsAndDetails(); + this.scheduleUsageLoad(); + }, + onScopeChange: (scope) => { + this.usageScope = scope; + this.clearSelectionsAndDetails(); + this.reloadUsage(); + }, + onAgentChange: (agentId) => { + this.usageAgentId = agentId; + this.clearSelectionsAndDetails(); + this.reloadUsage(); + }, + onRefresh: () => this.reloadUsage(), + onTimeZoneChange: (timeZone) => { + this.usageTimeZone = timeZone; + this.clearSelectionsAndDetails(); + this.reloadUsage(); + }, + onToggleHeaderPinned: () => { + this.usageHeaderPinned = !this.usageHeaderPinned; + }, + onSelectHour: (hour, shiftKey) => { + this.usageSelectedHours = toggleUsageRangeSelection( + this.usageSelectedHours, + hour, + Array.from({ length: 24 }, (_, index) => index), + shiftKey, + true, + ); + }, + onQueryDraftChange: (query) => { + this.usageQueryDraft = query; + this.clearQueryDebounce(); + this.queryDebounceTimer = window.setTimeout(() => { + this.usageQuery = this.usageQueryDraft; + this.queryDebounceTimer = null; + }, 250); + }, + onApplyQuery: () => { + this.clearQueryDebounce(); + this.usageQuery = this.usageQueryDraft; + }, + onClearQuery: () => { + this.clearQueryDebounce(); + this.usageQueryDraft = ""; + this.usageQuery = ""; + }, + onSelectDay: (day, shiftKey) => { + this.usageSelectedDays = toggleUsageRangeSelection( + this.usageSelectedDays, + day, + (this.usageCostSummary?.daily ?? []).map((entry) => entry.date), + shiftKey, + false, + ); + }, + onClearDays: () => { + this.usageSelectedDays = []; + }, + onClearHours: () => { + this.usageSelectedHours = []; + }, + onClearSessions: () => { + this.usageSelectedSessions = []; + this.clearDetails(); + }, + onClearFilters: () => this.clearSelectionsAndDetails(), + }, + display: { + onChartModeChange: (mode) => { + this.usageChartMode = mode; + }, + onDailyChartModeChange: (mode) => { + this.usageDailyChartMode = mode; + }, + onSessionSortChange: (sort) => { + this.usageSessionSort = sort; + }, + onSessionSortDirChange: (direction) => { + this.usageSessionSortDir = direction; + }, + onSessionsTabChange: (tab) => { + this.usageSessionsTab = tab; + }, + onToggleColumn: (column) => { + this.usageVisibleColumns = this.usageVisibleColumns.includes(column) + ? this.usageVisibleColumns.filter((entry) => entry !== column) + : [...this.usageVisibleColumns, column]; + }, + }, + details: { + onToggleContextExpanded: () => { + this.usageContextExpanded = !this.usageContextExpanded; + }, + onToggleSessionLogsExpanded: () => { + this.usageSessionLogsExpanded = !this.usageSessionLogsExpanded; + }, + onLogFilterRolesChange: (roles) => { + this.usageLogFilterRoles = roles; + }, + onLogFilterToolsChange: (tools) => { + this.usageLogFilterTools = tools; + }, + onLogFilterHasToolsChange: (hasTools) => { + this.usageLogFilterHasTools = hasTools; + }, + onLogFilterQueryChange: (query) => { + this.usageLogFilterQuery = query; + }, + onLogFilterClear: () => { + this.usageLogFilterRoles = []; + this.usageLogFilterTools = []; + this.usageLogFilterHasTools = false; + this.usageLogFilterQuery = ""; + }, + onSelectSession: (key, shiftKey) => this.selectSession(key, shiftKey), + onTimeSeriesModeChange: (mode) => { + this.usageTimeSeriesMode = mode; + }, + onTimeSeriesBreakdownChange: (mode) => { + this.usageTimeSeriesBreakdownMode = mode; + }, + onTimeSeriesCursorRangeChange: (start, end) => { + this.usageTimeSeriesCursorStart = start; + this.usageTimeSeriesCursorEnd = end; + }, + }, + }, + }; + + return html` +
+
+
${titleForRoute("usage")}
+
${subtitleForRoute("usage")}
+
+
+ ${renderUsage(props)} + `; + } +} + +customElements.define("openclaw-usage-page", UsagePage); diff --git a/ui/src/ui/views/usage-render-details.test.ts b/ui/src/pages/usage/view-details.test.ts similarity index 98% rename from ui/src/ui/views/usage-render-details.test.ts rename to ui/src/pages/usage/view-details.test.ts index 04bf4eac0348..da846086f76e 100644 --- a/ui/src/ui/views/usage-render-details.test.ts +++ b/ui/src/pages/usage/view-details.test.ts @@ -1,13 +1,13 @@ // Control UI tests cover usage render details behavior. import { render } from "lit"; import { describe, it, expect } from "vitest"; +import type { TimeSeriesPoint, UsageSessionEntry } from "./types.ts"; import { computeFilteredUsage, CHART_BAR_WIDTH_RATIO, CHART_MAX_BAR_WIDTH, renderTimeSeriesCompact, -} from "./usage-render-details.ts"; -import type { TimeSeriesPoint, UsageSessionEntry } from "./usageTypes.ts"; +} from "./view-details.ts"; function makePoint(overrides: Partial = {}): TimeSeriesPoint { return { diff --git a/ui/src/ui/views/usage-render-details.ts b/ui/src/pages/usage/view-details.ts similarity index 98% rename from ui/src/ui/views/usage-render-details.ts rename to ui/src/pages/usage/view-details.ts index c839e93e439d..5463d634ce9c 100644 --- a/ui/src/ui/views/usage-render-details.ts +++ b/ui/src/pages/usage/view-details.ts @@ -2,17 +2,18 @@ import { html, svg, nothing } from "lit"; import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts"; import { t } from "../../i18n/index.ts"; -import { formatDateTimeMs, formatMs, formatTimeMs } from "../format.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import { parseToolSummary } from "../usage-helpers.ts"; -import { charsToTokens, formatCost, formatTokens } from "./usage-metrics.ts"; -import { renderInsightList } from "./usage-render-overview.ts"; +import "../../components/tooltip.ts"; +import { formatDateTimeMs, formatMs, formatTimeMs } from "../../lib/format.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { parseToolSummary } from "./helpers.ts"; +import { charsToTokens, formatCost, formatTokens } from "./metrics.ts"; import type { SessionLogEntry, SessionLogRole, TimeSeriesPoint, UsageSessionEntry, -} from "./usageTypes.ts"; +} from "./types.ts"; +import { renderInsightList } from "./view-overview.ts"; // Chart constants const CHART_BAR_WIDTH_RATIO = 0.75; // Fraction of slot used for bar (rest is gap) @@ -293,14 +294,15 @@ function renderSessionDetailPanel( ` : nothing}
- + + +
${session.scope === "family" && session.includedSessionIds?.length ? html` diff --git a/ui/src/ui/views/usage-render-overview.test.ts b/ui/src/pages/usage/view-overview.test.ts similarity index 54% rename from ui/src/ui/views/usage-render-overview.test.ts rename to ui/src/pages/usage/view-overview.test.ts index 202b91843e4f..22d9eccb513d 100644 --- a/ui/src/ui/views/usage-render-overview.test.ts +++ b/ui/src/pages/usage/view-overview.test.ts @@ -2,17 +2,12 @@ import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CostDailyEntry, UsageAggregates, UsageSessionEntry, UsageTotals } from "./types.ts"; import { renderDailyChartCompact, renderSessionsCard, renderUsageInsights, -} from "./usage-render-overview.ts"; -import type { - CostDailyEntry, - UsageAggregates, - UsageSessionEntry, - UsageTotals, -} from "./usageTypes.ts"; +} from "./view-overview.ts"; const totals: UsageTotals = { input: 100, @@ -49,49 +44,6 @@ const aggregates = { daily: [], } as unknown as UsageAggregates; -function rect(left: number, top: number, width: number, height: number): DOMRect { - return { - x: left, - y: top, - left, - top, - width, - height, - right: left + width, - bottom: top + height, - toJSON: () => ({}), - } as DOMRect; -} - -function setViewport(width: number, height: number) { - Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); - Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); -} - -function mockTooltipRect(width: number, height: number) { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( - function (this: HTMLElement) { - if (this.classList.contains("daily-bar-tooltip--floating")) { - return rect(0, 0, width, height); - } - return rect(0, 0, 0, 0); - }, - ); -} - -function mockElementRect( - element: HTMLElement, - left: number, - top: number, - width: number, - height: number, -) { - Object.defineProperty(element, "getBoundingClientRect", { - configurable: true, - value: () => rect(left, top, width, height), - }); -} - function dailyEntry(date: string, totalTokens: number, totalCost = 0): CostDailyEntry { return { ...totals, @@ -122,13 +74,8 @@ function renderDailyChart( }; } -function getFloatingTooltip(): HTMLElement | null { - return document.body.querySelector(".daily-bar-tooltip--floating"); -} - afterEach(() => { document.body.replaceChildren(); - window.dispatchEvent(new Event("scroll")); vi.restoreAllMocks(); }); @@ -185,75 +132,8 @@ describe("renderUsageInsights", () => { }); describe("renderDailyChartCompact", () => { - it("shows one floating tooltip for tall and short daily bars and hides it on mouse leave", () => { - setViewport(800, 600); - mockTooltipRect(180, 64); - const { bars } = renderDailyChart([ - dailyEntry("2026-05-01", 1_200_000, 3.5), - dailyEntry("2026-05-02", 4, 0.01), - ]); - - mockElementRect(bars[0], 100, 100, 24, 200); - bars[0].dispatchEvent(new MouseEvent("mouseenter")); - - let tooltip = getFloatingTooltip(); - expect(tooltip).not.toBeNull(); - expect(tooltip?.textContent).toContain("1.2M tokens"); - expect(tooltip?.style.top).toBe("28px"); - expect(document.body.querySelectorAll(".daily-bar-tooltip--floating")).toHaveLength(1); - - bars[0].dispatchEvent(new MouseEvent("mouseleave")); - expect(getFloatingTooltip()).toBeNull(); - - mockElementRect(bars[1], 200, 320, 24, 6); - bars[1].dispatchEvent(new MouseEvent("mouseenter")); - - tooltip = getFloatingTooltip(); - expect(tooltip).not.toBeNull(); - expect(tooltip?.textContent).toContain("4 tokens"); - bars[1].dispatchEvent(new MouseEvent("mouseleave")); - }); - - it("flips below when the bar is near the top and clamps inside a narrow viewport", () => { - setViewport(120, 140); - mockTooltipRect(100, 40); - const { bars } = renderDailyChart([dailyEntry("2026-05-03", 10_000, 1)]); - - mockElementRect(bars[0], 110, 12, 20, 20); - bars[0].dispatchEvent(new MouseEvent("mouseenter")); - - const tooltip = getFloatingTooltip(); - expect(tooltip?.dataset.placement).toBe("below"); - expect(tooltip?.style.top).toBe("40px"); - expect(tooltip?.style.left).toBe("12px"); - bars[0].dispatchEvent(new MouseEvent("mouseleave")); - }); - - it("clears the floating tooltip when the chart DOM is removed", async () => { - setViewport(800, 600); - mockTooltipRect(160, 56); - const { bars, container } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]); - mockElementRect(bars[0], 300, 220, 24, 80); - - bars[0].dispatchEvent(new MouseEvent("mouseenter")); - expect(getFloatingTooltip()).not.toBeNull(); - - container.remove(); - await Promise.resolve(); - expect(getFloatingTooltip()).toBeNull(); - }); - - it("shows on keyboard focus, hides on blur, and keeps day selection operable", () => { - setViewport(800, 600); - mockTooltipRect(160, 56); + it("keeps day selection operable with mouse and keyboard", () => { const { bars, onSelectDay } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]); - mockElementRect(bars[0], 300, 220, 24, 80); - - bars[0].dispatchEvent(new Event("focus")); - expect(getFloatingTooltip()?.textContent).toContain("500 tokens"); - - bars[0].dispatchEvent(new Event("blur")); - expect(getFloatingTooltip()).toBeNull(); bars[0].dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true })); expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true); @@ -270,12 +150,6 @@ describe("renderDailyChartCompact", () => { bars[0].dispatchEvent(space); expect(space.defaultPrevented).toBe(true); expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true); - - bars[0].dispatchEvent(new MouseEvent("mouseenter")); - bars[0].dispatchEvent(new Event("pointerdown", { bubbles: true })); - bars[0].dispatchEvent(new Event("focus")); - bars[0].dispatchEvent(new MouseEvent("mouseleave")); - expect(getFloatingTooltip()).toBeNull(); }); }); diff --git a/ui/src/ui/views/usage-render-overview.ts b/ui/src/pages/usage/view-overview.ts similarity index 78% rename from ui/src/ui/views/usage-render-overview.ts rename to ui/src/pages/usage/view-overview.ts index bdf9994564ac..067fb657954a 100644 --- a/ui/src/ui/views/usage-render-overview.ts +++ b/ui/src/pages/usage/view-overview.ts @@ -2,16 +2,17 @@ import { html, nothing } from "lit"; import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts"; import { t } from "../../i18n/index.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import { formatCost, formatDayLabel, formatFullDate, formatTokens } from "./usage-metrics.ts"; -import type { UsageInsightStats } from "./usage-metrics.ts"; +import "../../components/tooltip.ts"; +import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts"; +import { formatCost, formatDayLabel, formatFullDate, formatTokens } from "./metrics.ts"; +import type { UsageInsightStats } from "./metrics.ts"; import type { UsageAggregates, UsageColumnId, UsageSessionEntry, UsageTotals, CostDailyEntry, -} from "./usageTypes.ts"; +} from "./types.ts"; function pct(part: number, total: number): number { if (total === 0) { @@ -20,205 +21,6 @@ function pct(part: number, total: number): number { return (part / total) * 100; } -const DAILY_BAR_TOOLTIP_MARGIN_PX = 8; -const DAILY_BAR_TOOLTIP_GAP_PX = 8; - -type DailyBarTooltipTrigger = "hover" | "focus"; - -type DailyBarTooltipContent = { - dateLabel: string; - tokensLabel: string; - costLabel: string; - breakdownLines: string[]; -}; - -type ActiveDailyBarTooltip = { - source: HTMLElement; - reasons: Set; - content: DailyBarTooltipContent; -}; - -let activeDailyBarTooltip: ActiveDailyBarTooltip | null = null; -let floatingDailyBarTooltip: HTMLElement | null = null; -let floatingDailyBarTooltipListenersAttached = false; -let floatingDailyBarTooltipObserver: MutationObserver | null = null; -let suppressNextDailyBarFocusTooltip = false; -let suppressDailyBarFocusTooltipTimer: number | null = null; - -function clampValue(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), Math.max(min, max)); -} - -function getFloatingDailyBarTooltip(): HTMLElement { - if (!floatingDailyBarTooltip) { - floatingDailyBarTooltip = document.createElement("div"); - floatingDailyBarTooltip.className = "daily-bar-tooltip daily-bar-tooltip--floating"; - } - if (!floatingDailyBarTooltip.isConnected) { - document.body.append(floatingDailyBarTooltip); - } - return floatingDailyBarTooltip; -} - -function renderFloatingDailyBarTooltipContent( - tooltip: HTMLElement, - content: DailyBarTooltipContent, -) { - const date = document.createElement("strong"); - date.textContent = content.dateLabel; - - const children: Node[] = [ - date, - document.createElement("br"), - document.createTextNode(content.tokensLabel), - document.createElement("br"), - document.createTextNode(content.costLabel), - ]; - - for (const line of content.breakdownLines) { - const item = document.createElement("div"); - item.textContent = line; - children.push(item); - } - - tooltip.replaceChildren(...children); -} - -function positionFloatingDailyBarTooltip() { - if (!activeDailyBarTooltip) { - return; - } - if (!activeDailyBarTooltip.source.isConnected) { - hideDailyBarTooltip(); - return; - } - - const tooltip = getFloatingDailyBarTooltip(); - const sourceRect = activeDailyBarTooltip.source.getBoundingClientRect(); - tooltip.style.visibility = "hidden"; - tooltip.style.left = "0px"; - tooltip.style.top = "0px"; - - const tooltipRect = tooltip.getBoundingClientRect(); - const viewportWidth = window.innerWidth || document.documentElement.clientWidth; - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - const maxLeft = viewportWidth - tooltipRect.width - DAILY_BAR_TOOLTIP_MARGIN_PX; - const maxTop = viewportHeight - tooltipRect.height - DAILY_BAR_TOOLTIP_MARGIN_PX; - const left = clampValue( - sourceRect.left + sourceRect.width / 2 - tooltipRect.width / 2, - DAILY_BAR_TOOLTIP_MARGIN_PX, - maxLeft, - ); - let top = sourceRect.top - tooltipRect.height - DAILY_BAR_TOOLTIP_GAP_PX; - let placement = "above"; - - if (top < DAILY_BAR_TOOLTIP_MARGIN_PX) { - placement = "below"; - top = sourceRect.bottom + DAILY_BAR_TOOLTIP_GAP_PX; - } - - tooltip.dataset.placement = placement; - tooltip.style.left = `${Math.round(left)}px`; - tooltip.style.top = `${Math.round(clampValue(top, DAILY_BAR_TOOLTIP_MARGIN_PX, maxTop))}px`; - tooltip.style.visibility = ""; -} - -function attachFloatingDailyBarTooltipListeners() { - if (floatingDailyBarTooltipListenersAttached) { - return; - } - window.addEventListener("resize", positionFloatingDailyBarTooltip); - window.addEventListener("scroll", positionFloatingDailyBarTooltip, true); - floatingDailyBarTooltipListenersAttached = true; -} - -function detachFloatingDailyBarTooltipListeners() { - if (!floatingDailyBarTooltipListenersAttached) { - return; - } - window.removeEventListener("resize", positionFloatingDailyBarTooltip); - window.removeEventListener("scroll", positionFloatingDailyBarTooltip, true); - floatingDailyBarTooltipListenersAttached = false; -} - -function attachFloatingDailyBarTooltipObserver() { - if (floatingDailyBarTooltipObserver) { - return; - } - floatingDailyBarTooltipObserver = new MutationObserver(() => { - if (activeDailyBarTooltip && !activeDailyBarTooltip.source.isConnected) { - hideDailyBarTooltip(); - } - }); - floatingDailyBarTooltipObserver.observe(document.body, { childList: true, subtree: true }); -} - -function detachFloatingDailyBarTooltipObserver() { - floatingDailyBarTooltipObserver?.disconnect(); - floatingDailyBarTooltipObserver = null; -} - -function showDailyBarTooltip( - source: HTMLElement, - content: DailyBarTooltipContent, - reason: DailyBarTooltipTrigger, -) { - if (!activeDailyBarTooltip || activeDailyBarTooltip.source !== source) { - activeDailyBarTooltip = { - source, - reasons: new Set(), - content, - }; - } - - activeDailyBarTooltip.content = content; - activeDailyBarTooltip.reasons.add(reason); - - const tooltip = getFloatingDailyBarTooltip(); - renderFloatingDailyBarTooltipContent(tooltip, content); - positionFloatingDailyBarTooltip(); - attachFloatingDailyBarTooltipListeners(); - attachFloatingDailyBarTooltipObserver(); -} - -function hideDailyBarTooltip(source?: HTMLElement, reason?: DailyBarTooltipTrigger) { - if (!activeDailyBarTooltip) { - return; - } - if (source && activeDailyBarTooltip.source !== source) { - return; - } - if (reason) { - activeDailyBarTooltip.reasons.delete(reason); - if (activeDailyBarTooltip.reasons.size > 0) { - return; - } - } - - activeDailyBarTooltip = null; - floatingDailyBarTooltip?.remove(); - detachFloatingDailyBarTooltipListeners(); - detachFloatingDailyBarTooltipObserver(); -} - -function suppressDailyBarFocusTooltipForPointer() { - suppressNextDailyBarFocusTooltip = true; - if (suppressDailyBarFocusTooltipTimer !== null) { - window.clearTimeout(suppressDailyBarFocusTooltipTimer); - } - suppressDailyBarFocusTooltipTimer = window.setTimeout(() => { - suppressNextDailyBarFocusTooltip = false; - suppressDailyBarFocusTooltipTimer = null; - }, 0); -} - -function showDailyBarFocusTooltip(source: HTMLElement, content: DailyBarTooltipContent) { - if (suppressNextDailyBarFocusTooltip) { - return; - } - showDailyBarTooltip(source, content, "focus"); -} - function handleDailyBarKeydown( event: KeyboardEvent, day: string, @@ -306,14 +108,15 @@ function renderFilterChips( ? html`
${t("usage.filters.days")}: ${daysLabel} - + + +
` : nothing} @@ -321,14 +124,15 @@ function renderFilterChips( ? html`
${t("usage.filters.hours")}: ${hoursLabel} - + + +
` : nothing} @@ -336,14 +140,15 @@ function renderFilterChips( ? html`
${t("usage.filters.session")}: ${sessionsLabel} - + + +
` : nothing} @@ -474,47 +279,49 @@ function renderDailyChartCompact( breakdownLines, }; return html` -
- showDailyBarTooltip(e.currentTarget as HTMLElement, tooltipContent, "hover")} - @mouseleave=${(e: MouseEvent) => - hideDailyBarTooltip(e.currentTarget as HTMLElement, "hover")} - @focus=${(e: FocusEvent) => - showDailyBarFocusTooltip(e.currentTarget as HTMLElement, tooltipContent)} - @blur=${(e: FocusEvent) => - hideDailyBarTooltip(e.currentTarget as HTMLElement, "focus")} - @keydown=${(e: KeyboardEvent) => handleDailyBarKeydown(e, d.date, onSelectDay)} - @click=${(e: MouseEvent) => onSelectDay(d.date, e.shiftKey)} + - ${dailyChartMode === "by-type" - ? html` -
- ${(() => { - const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1; - return segments.map( - (seg) => html` -
- `, - ); - })()} -
- ` - : html`
`} - ${showTotals ? html`
${totalLabel}
` : nothing} -
${shortLabel}
-
+
handleDailyBarKeydown(e, d.date, onSelectDay)} + @click=${(e: MouseEvent) => onSelectDay(d.date, e.shiftKey)} + > + ${dailyChartMode === "by-type" + ? html` +
+ ${(() => { + const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1; + return segments.map( + (seg) => html` +
+ `, + ); + })()} +
+ ` + : html` +
+ `} + ${showTotals ? html`
${totalLabel}
` : nothing} +
${shortLabel}
+
+ `; })}
@@ -1050,7 +857,6 @@ function renderSessionsCard(
+ + ${selectedCount > 0 ? html` + + + `; })} diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/pages/workboard/data.test.ts similarity index 99% rename from ui/src/ui/controllers/workboard.test.ts rename to ui/src/pages/workboard/data.test.ts index 2bc5ee693b84..6157a20e4dd8 100644 --- a/ui/src/ui/controllers/workboard.test.ts +++ b/ui/src/pages/workboard/data.test.ts @@ -1,7 +1,7 @@ // Control UI tests cover workboard behavior. import { afterEach, describe, expect, it, vi } from "vitest"; -import { GatewayRequestError } from "../gateway.ts"; -import type { GatewaySessionRow } from "../types.ts"; +import { GatewayRequestError } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; import { addWorkboardCardComment, archiveWorkboardCard, @@ -26,7 +26,7 @@ import { syncWorkboardLifecycle, type WorkboardCard, type WorkboardTaskSummary, -} from "./workboard.ts"; +} from "../../lib/workboard/index.ts"; function createClient( responses: Record | ((method: string, params: unknown) => unknown), diff --git a/ui/src/pages/workboard/route.ts b/ui/src/pages/workboard/route.ts new file mode 100644 index 000000000000..e2701bac1102 --- /dev/null +++ b/ui/src/pages/workboard/route.ts @@ -0,0 +1,23 @@ +import { definePage } from "@openclaw/uirouter"; +import { html } from "lit"; +import type { ApplicationContext } from "../../app/context.ts"; + +async function loadWorkboardRoute(context: ApplicationContext) { + const sessions = context.sessions.state; + await Promise.all([ + context.runtimeConfig.ensureLoaded(), + context.agents.ensureList(), + sessions.result || sessions.loading ? Promise.resolve() : context.sessions.refresh(), + ]); +} + +export const page = definePage({ + id: "workboard", + path: "/workboard", + loader: loadWorkboardRoute, + component: () => + import("./workboard-page.ts").then(() => ({ + header: true, + render: () => html``, + })), +}); diff --git a/ui/src/ui/views/workboard.browser.test.ts b/ui/src/pages/workboard/view.browser.test.ts similarity index 96% rename from ui/src/ui/views/workboard.browser.test.ts rename to ui/src/pages/workboard/view.browser.test.ts index a485b31bfb6d..15e5f888d836 100644 --- a/ui/src/ui/views/workboard.browser.test.ts +++ b/ui/src/pages/workboard/view.browser.test.ts @@ -1,8 +1,8 @@ // Control UI tests cover workboard behavior. import { nothing, render } from "lit"; import { describe, expect, it } from "vitest"; -import { getWorkboardState } from "../controllers/workboard.ts"; -import { renderWorkboard } from "./workboard.ts"; +import { getWorkboardState } from "../../lib/workboard/index.ts"; +import { renderWorkboard } from "./view.ts"; type WorkboardRenderProps = Parameters[0]; diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/pages/workboard/view.test.ts similarity index 86% rename from ui/src/ui/views/workboard.test.ts rename to ui/src/pages/workboard/view.test.ts index 5a10f78707ac..a5e9af41a124 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/pages/workboard/view.test.ts @@ -1,13 +1,9 @@ // Control UI tests cover workboard behavior. import { nothing, render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { - getWorkboardState, - stopWorkboardLifecycleRefresh, - stopWorkboardPolling, -} from "../controllers/workboard.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { renderWorkboard } from "./workboard.ts"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { getWorkboardState, stopWorkboardLifecycleRefresh } from "../../lib/workboard/index.ts"; +import { renderWorkboard } from "./view.ts"; type WorkboardRenderProps = Parameters[0]; @@ -21,6 +17,22 @@ function renderInto(container: HTMLElement, props: WorkboardRenderProps) { render(renderWorkboard(props), container); } +function buttonByLabel(container: Element, label: string): HTMLButtonElement | null { + return ( + Array.from(container.querySelectorAll("button")).find( + (button) => button.getAttribute("aria-label") === label, + ) ?? null + ); +} + +function buttonByText(container: Element, text: string): HTMLButtonElement | null { + return ( + Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes(text), + ) ?? null + ); +} + function dispatchKey(target: EventTarget, key: string, options: KeyboardEventInit = {}) { const event = new KeyboardEvent("keydown", { key, @@ -54,7 +66,7 @@ describe("renderWorkboard", () => { container, ); - expect(container.querySelector('button[title="Refresh"]')).toBeNull(); + expect(buttonByText(container, "Refresh")).toBeNull(); expect(container.querySelector(".workboard-toolbar__actions")?.textContent).not.toContain( "Refreshing", ); @@ -86,131 +98,6 @@ describe("renderWorkboard", () => { expect(container.querySelector(".callout.danger")?.textContent).toBe("Write denied"); }); - it("stops and does not rearm auto-refresh while disconnected", async () => { - vi.useFakeTimers(); - const host = {}; - const state = getWorkboardState(host); - state.loaded = true; - state.lifecycleTasksPrepared = true; - state.autoRefreshIntervalMs = 5000; - const request = vi.fn(async () => ({ cards: [], statuses: [] })); - const client = { request } as unknown as GatewayBrowserClient; - const container = document.createElement("div"); - const props = { - host, - client, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [], - onOpenSession: () => undefined, - } satisfies WorkboardRenderProps; - - try { - renderInto(container, props); - renderInto(container, { ...props, connected: false }); - await vi.advanceTimersByTimeAsync(5000); - - expect(request).not.toHaveBeenCalled(); - - const interval = container.querySelector(".workboard-auto-refresh select"); - interval!.value = "15000"; - interval!.dispatchEvent(new Event("change", { bubbles: true })); - await vi.advanceTimersByTimeAsync(15_000); - - expect(request).not.toHaveBeenCalled(); - } finally { - stopWorkboardPolling(host); - vi.useRealTimers(); - } - }); - - it("stops lifecycle refresh and reconciliation while disconnected", async () => { - vi.useFakeTimers(); - const host = {}; - const state = getWorkboardState(host); - const task = { - id: "task-1", - taskId: "task-1", - status: "running" as const, - updatedAt: 1, - }; - state.loaded = true; - state.cards = [ - { - id: "card-1", - title: "Running card", - status: "running", - priority: "normal", - labels: [], - taskId: task.taskId, - position: 1000, - createdAt: 1, - updatedAt: 1, - }, - ]; - state.tasksByCardId.set("card-1", task); - state.lifecycleTasksPrepared = true; - state.lifecycleTasksPreparedAt = Date.now(); - const request = vi.fn(); - const requestUpdate = vi.fn(); - const container = document.createElement("div"); - const props = { - host, - client: { request } as unknown as GatewayBrowserClient, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [], - onOpenSession: () => undefined, - onRequestUpdate: requestUpdate, - } satisfies WorkboardRenderProps; - - try { - renderInto(container, props); - await Promise.resolve(); - renderInto(container, { ...props, connected: false }); - await vi.advanceTimersByTimeAsync(5000); - - expect(request).not.toHaveBeenCalled(); - expect(requestUpdate).not.toHaveBeenCalled(); - expect(state.lifecycleTasksPrepared).toBe(false); - expect(state.lifecycleTaskRefreshFailed).toBe(false); - } finally { - stopWorkboardLifecycleRefresh(host); - vi.useRealTimers(); - } - }); - - it("stops polling and lifecycle refresh while the plugin is disabled", () => { - const host = {}; - const state = getWorkboardState(host); - state.loaded = true; - state.loading = true; - state.pollRefreshInProgress = true; - state.lifecycleTasksPrepared = true; - state.lifecycleTasksPreparedAt = Date.now(); - state.lifecycleTaskRefreshFailed = true; - state.lifecycleTaskRefreshError = "Task refresh unavailable"; - const container = document.createElement("div"); - - renderInto(container, { - host, - client: null, - connected: true, - pluginEnabled: false, - agentsList: null, - sessions: [], - onOpenSession: () => undefined, - }); - - expect(state.pollRefreshInProgress).toBe(false); - expect(state.loading).toBe(false); - expect(state.lifecycleTasksPrepared).toBe(false); - expect(state.lifecycleTaskRefreshFailed).toBe(false); - expect(state.lifecycleTaskRefreshError).toBeNull(); - }); - it("keeps dispatch available during refresh and disables it during writes", () => { const host = {}; const state = getWorkboardState(host); @@ -230,39 +117,29 @@ describe("renderWorkboard", () => { render(renderWorkboard(props), container); - const dispatchButton = container.querySelector( - 'button[title="Dispatch ready work"]', - ); + const dispatchButton = buttonByText(container, "Dispatch ready work"); expect(dispatchButton?.disabled).toBe(false); state.draftSaving = true; render(renderWorkboard(props), container); - expect( - container.querySelector('button[title="Dispatch ready work"]')?.disabled, - ).toBe(true); + expect(buttonByText(container, "Dispatch ready work")?.disabled).toBe(true); state.loading = false; state.autoRefreshIntervalMs = 0; render(renderWorkboard(props), container); - expect(container.querySelector('button[title="Refresh"]')?.disabled).toBe( - true, - ); + expect(buttonByText(container, "Refresh")?.disabled).toBe(true); state.draftSaving = false; state.dispatching = true; render(renderWorkboard(props), container); - expect( - container.querySelector('button[title="Dispatch ready work"]')?.disabled, - ).toBe(true); + expect(buttonByText(container, "Dispatch ready work")?.disabled).toBe(true); render(renderWorkboard(props), container); - expect(container.querySelector('button[title="Refresh"]')?.disabled).toBe( - true, - ); + expect(buttonByText(container, "Refresh")?.disabled).toBe(true); }); it("disables card-write controls while dispatch is running", () => { @@ -300,18 +177,10 @@ describe("renderWorkboard", () => { render(renderWorkboard(props), container); - expect(container.querySelector('button[title="New card"]')?.disabled).toBe( - true, - ); - expect(container.querySelector('button[title="Edit card"]')?.disabled).toBe( - true, - ); - expect( - container.querySelector('button[title="Archive card"]')?.disabled, - ).toBe(true); - expect( - container.querySelector('button[title="Delete card"]')?.disabled, - ).toBe(true); + expect(buttonByText(container, "New card")?.disabled).toBe(true); + expect(buttonByLabel(container, "Edit card")?.disabled).toBe(true); + expect(buttonByLabel(container, "Archive card")?.disabled).toBe(true); + expect(buttonByLabel(container, "Delete card")?.disabled).toBe(true); expect( container.querySelector(".workboard-card__move-select")?.disabled, ).toBe(true); @@ -555,18 +424,9 @@ describe("renderWorkboard", () => { }, ]; stopWorkboardLifecycleRefresh(host); - const request = vi.fn(async (method: string) => { - if (method === "workboard.cards.list") { - return { cards: state.cards, statuses: ["todo", "done"] }; - } - if (method === "tasks.list") { - return { tasks: [] }; - } - return {}; - }); const props = { host, - client: { request } as unknown as GatewayBrowserClient, + client: null, connected: true, pluginEnabled: true, agentsList: null, @@ -577,16 +437,16 @@ describe("renderWorkboard", () => { render(renderWorkboard(props), container); - expect(container.querySelector('button[title="Edit card"]')).toBeNull(); - expect(container.querySelector('button[title="Archive card"]')).toBeNull(); - expect(container.querySelector('button[title="New card"]')).toBeNull(); + expect(buttonByLabel(container, "Edit card")).toBeNull(); + expect(buttonByLabel(container, "Archive card")).toBeNull(); + expect(buttonByText(container, "New card")).toBeNull(); expect(container.querySelector(".workboard-card")?.getAttribute("draggable")).toBe("false"); - await vi.waitFor(() => expect(state.mutationReadiness).toBe("ready")); + state.mutationReadiness = "ready"; render(renderWorkboard(props), container); - expect(container.querySelector('button[title="Edit card"]')).not.toBeNull(); - expect(container.querySelector('button[title="New card"]')).not.toBeNull(); + expect(buttonByLabel(container, "Edit card")).not.toBeNull(); + expect(buttonByText(container, "New card")).not.toBeNull(); }); it("keeps a stale edit draft disabled until it is cancelled", async () => { @@ -609,18 +469,9 @@ describe("renderWorkboard", () => { state.editingCardId = "card-1"; state.draftTitle = "Unsaved edit"; stopWorkboardLifecycleRefresh(host); - const request = vi.fn(async (method: string) => { - if (method === "workboard.cards.list") { - return { cards: state.cards, statuses: ["todo", "done"] }; - } - if (method === "tasks.list") { - return { tasks: [] }; - } - return {}; - }); const props = { host, - client: { request } as unknown as GatewayBrowserClient, + client: null, connected: true, pluginEnabled: true, agentsList: null, @@ -630,7 +481,7 @@ describe("renderWorkboard", () => { const container = document.createElement("div"); render(renderWorkboard(props), container); - await vi.waitFor(() => expect(state.mutationReadiness).toBe("stale_edit_draft")); + state.mutationReadiness = "stale_edit_draft"; render(renderWorkboard(props), container); expect( @@ -641,11 +492,10 @@ describe("renderWorkboard", () => { ); container - .querySelector('button[title="Cancel"]') + .querySelector('button[aria-label="Cancel"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(state.draftOpen).toBe(false); - expect(state.mutationReadiness).toBe("ready"); }); it("renders health counts and dense card metadata", () => { @@ -1182,112 +1032,6 @@ describe("renderWorkboard", () => { } }); - it("skips lifecycle sync during a poll and reconciles after it completes", async () => { - const host = {}; - const state = getWorkboardState(host); - state.loaded = true; - state.pollRefreshInProgress = true; - state.cards = [ - { - id: "card-1", - title: "Completed session", - status: "running", - priority: "normal", - labels: [], - position: 1000, - createdAt: 1, - updatedAt: 1, - sessionKey: "agent:main:dashboard:1", - }, - ]; - const request = vi.fn(async (method: string) => - method === "workboard.cards.update" - ? { card: { ...state.cards[0], status: "review" } } - : { cards: state.cards, statuses: ["running", "review"] }, - ); - const container = document.createElement("div"); - - render( - renderWorkboard({ - host, - client: { request } as unknown as GatewayBrowserClient, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [ - { - key: "agent:main:dashboard:1", - kind: "direct", - updatedAt: 2, - status: "done", - }, - ], - onOpenSession: () => undefined, - }), - container, - ); - await Promise.resolve(); - - expect(request).not.toHaveBeenCalledWith("workboard.cards.update", expect.anything()); - - render( - renderWorkboard({ - host, - client: { request } as unknown as GatewayBrowserClient, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [ - { - key: "agent:main:dashboard:1", - kind: "direct", - updatedAt: 2, - status: "done", - }, - ], - onOpenSession: () => undefined, - }), - container, - ); - await Promise.resolve(); - - expect(request).not.toHaveBeenCalledWith("workboard.cards.update", expect.anything()); - - state.pollRefreshInProgress = false; - state.lifecycleTasksPrepared = true; - state.lifecycleTasksPreparedAt = Date.now(); - render( - renderWorkboard({ - host, - client: { request } as unknown as GatewayBrowserClient, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [ - { - key: "agent:main:dashboard:1", - kind: "direct", - updatedAt: 2, - status: "done", - }, - ], - onOpenSession: () => undefined, - }), - container, - ); - await Promise.resolve(); - - expect(request).toHaveBeenCalledWith( - "workboard.cards.update", - expect.objectContaining({ - id: "card-1", - patch: expect.objectContaining({ status: "review" }), - }), - ); - expect(request).not.toHaveBeenCalledWith("tasks.list", expect.anything()); - expect(state.lifecycleTasksPrepared).toBe(true); - }); - it("can hide empty columns while keeping populated columns visible", () => { const host = {}; const state = getWorkboardState(host); @@ -1445,7 +1189,7 @@ describe("renderWorkboard", () => { onOpenSession.mockClear(); container - .querySelector('button[title="Delete card"]') + .querySelector('button[aria-label="Delete card"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onOpenSession).not.toHaveBeenCalled(); }); @@ -1770,11 +1514,13 @@ describe("renderWorkboard", () => { ...container.querySelectorAll(".workboard-card__start"), ]; expect(startButtons.map((button) => button.textContent?.trim())).toEqual([""]); - expect(startButtons.map((button) => button.title)).toEqual(["Run default agent"]); + expect(startButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Run default agent", + ]); expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button"); container - .querySelector('button[title="View details"]') + .querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render( renderWorkboard({ @@ -1850,10 +1596,10 @@ describe("renderWorkboard", () => { const start = childCard?.querySelector(".workboard-card__start"); expect(childCard?.textContent).toContain("1 blocked"); expect(start?.disabled).toBe(false); - expect(start?.title).toBe("Run default agent"); + expect(start?.getAttribute("aria-label")).toBe("Run default agent"); childCard - ?.querySelector('button[title="View details"]') + ?.querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render(renderWorkboard(props), container); @@ -1913,10 +1659,12 @@ describe("renderWorkboard", () => { ...container.querySelectorAll(".workboard-card__start"), ]; expect(startButtons.map((button) => button.textContent?.trim())).toEqual([""]); - expect(startButtons.map((button) => button.title)).toEqual(["Run default agent"]); + expect(startButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Run default agent", + ]); container - .querySelector('button[title="View details"]') + .querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render( renderWorkboard({ @@ -2046,7 +1794,7 @@ describe("renderWorkboard", () => { expect(container.textContent).not.toContain("Still running according to stale cache."); container - .querySelector('button[title="View details"]') + .querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render(renderWorkboard(props), container); @@ -2095,12 +1843,12 @@ describe("renderWorkboard", () => { render(renderWorkboard(props), container); expect(container.textContent).toContain("Task running"); - expect(container.querySelector('button[title="Stop session"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Stop session"]')).not.toBeNull(); expect(container.querySelectorAll(".workboard-card__start")).toHaveLength(0); expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button"); container - .querySelector('button[title="View details"]') + .querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render(renderWorkboard(props), container); @@ -2142,7 +1890,7 @@ describe("renderWorkboard", () => { container, ); - expect(container.querySelector('button[title="Stop session"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Stop session"]')).not.toBeNull(); expect(container.querySelectorAll(".workboard-card__start")).toHaveLength(0); }); @@ -2179,7 +1927,7 @@ describe("renderWorkboard", () => { ); expect(container.querySelector(".workboard-live")).toBeNull(); - expect(container.querySelector('button[title="Stop session"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Stop session"]')).toBeNull(); expect(container.querySelectorAll(".workboard-card__start")).toHaveLength(0); }); @@ -2216,7 +1964,7 @@ describe("renderWorkboard", () => { container, ); - expect(container.querySelector('button[title="Stop session"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Stop session"]')).not.toBeNull(); expect(container.querySelectorAll(".workboard-card__start")).toHaveLength(0); }); @@ -2253,7 +2001,7 @@ describe("renderWorkboard", () => { container, ); - expect(container.querySelector('button[title="Stop session"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Stop session"]')).toBeNull(); expect(container.querySelectorAll(".workboard-card__start")).toHaveLength(1); }); @@ -2289,8 +2037,8 @@ describe("renderWorkboard", () => { container, ); - expect(container.querySelector('button[title="Edit card"]')).toBeNull(); - expect(container.querySelector('button[title="Delete card"]')).toBeNull(); + expect(buttonByLabel(container, "Edit card")).toBeNull(); + expect(buttonByLabel(container, "Delete card")).toBeNull(); expect(container.querySelectorAll(".workboard-card__start")).toHaveLength(0); expect( container.querySelector(".workboard-toolbar__actions .btn.primary"), @@ -2601,7 +2349,7 @@ describe("renderWorkboard", () => { expect(container.querySelector(".workboard-events")?.textContent).toContain("Moved to Review"); container - .querySelector('button[title="View details"]') + .querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render(renderWorkboard(props), container); @@ -2689,7 +2437,7 @@ describe("renderWorkboard", () => { expect(container.textContent).not.toContain("Archived task"); container - .querySelector('button[title="Show archived cards"]') + .querySelector(".workboard-archive-toggle") ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render( renderWorkboard({ @@ -2704,12 +2452,10 @@ describe("renderWorkboard", () => { container, ); expect(container.textContent).toContain("Archived task"); - expect( - container.querySelector('button[title="Hide archived cards"]'), - ).not.toBeNull(); + expect(container.querySelector(".workboard-archive-toggle")).not.toBeNull(); container - .querySelector('button[title="View details"]') + .querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render( renderWorkboard({ @@ -3137,122 +2883,6 @@ describe("renderWorkboard", () => { } }); - it("clears an active card tooltip when opening details", () => { - const host = {}; - const state = getWorkboardState(host); - state.loaded = true; - state.cards = [ - { - id: "card-1", - title: "Tooltip clearing task", - status: "ready", - priority: "normal", - labels: [], - position: 1000, - createdAt: 1, - updatedAt: 1, - }, - ]; - const container = document.createElement("div"); - document.body.append(container); - - render( - renderWorkboard({ - host, - client: null, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [], - onOpenSession: () => undefined, - }), - container, - ); - - const detailsButton = container.querySelector( - 'button[title="View details"]', - ); - const tooltip = document.createElement("div"); - tooltip.className = "control-ui-floating-tooltip"; - tooltip.dataset.open = "true"; - document.body.append(tooltip); - detailsButton?.setAttribute("data-floating-tooltip-active", "true"); - detailsButton?.setAttribute("data-native-tooltip-title", "View details"); - detailsButton?.setAttribute("data-native-tooltip-generated", "true"); - detailsButton?.setAttribute("data-tooltip", "View details"); - detailsButton?.removeAttribute("title"); - - detailsButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(state.detailCardId).toBe("card-1"); - expect(detailsButton?.getAttribute("title")).toBe("View details"); - expect(detailsButton?.getAttribute("data-tooltip")).toBeNull(); - expect(detailsButton?.getAttribute("data-floating-tooltip-active")).toBeNull(); - expect(tooltip.dataset.open).toBe("false"); - - container.remove(); - tooltip.remove(); - }); - - it("clears active tooltips before opening create and edit modals", () => { - const host = {}; - const state = getWorkboardState(host); - state.loaded = true; - state.cards = [ - { - id: "card-1", - title: "Tooltip clearing task", - status: "ready", - priority: "normal", - labels: [], - position: 1000, - createdAt: 1, - updatedAt: 1, - }, - ]; - const container = document.createElement("div"); - document.body.append(container); - const props: WorkboardRenderProps = { - host, - client: null, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [], - onOpenSession: () => undefined, - }; - - try { - for (const title of ["New card", "Edit card"]) { - state.draftOpen = false; - state.editingCardId = null; - renderInto(container, props); - const button = container.querySelector(`button[title="${title}"]`); - const tooltip = document.createElement("div"); - tooltip.className = "control-ui-floating-tooltip"; - tooltip.dataset.open = "true"; - document.body.append(tooltip); - button?.setAttribute("data-floating-tooltip-active", "true"); - button?.setAttribute("data-native-tooltip-title", title); - button?.setAttribute("data-native-tooltip-generated", "true"); - button?.setAttribute("data-tooltip", title); - button?.removeAttribute("title"); - - button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(state.draftOpen).toBe(true); - expect(button?.getAttribute("title")).toBe(title); - expect(button?.getAttribute("data-tooltip")).toBeNull(); - expect(button?.getAttribute("data-floating-tooltip-active")).toBeNull(); - expect(tooltip.dataset.open).toBe("false"); - tooltip.remove(); - } - } finally { - container.remove(); - document.querySelector(".control-ui-floating-tooltip")?.remove(); - } - }); - it("preflights model-specific starts for ACP runtime agents", () => { const host = {}; const state = getWorkboardState(host); @@ -3298,7 +2928,7 @@ describe("renderWorkboard", () => { ]; expect(engineButtons).toHaveLength(4); expect(engineButtons.every((button) => button.disabled)).toBe(true); - expect(engineButtons[0]?.title).toContain("uses the codex ACP runtime"); + expect(engineButtons[0]?.getAttribute("aria-label")).toContain("uses the codex ACP runtime"); }); it("does not render details for archived selected cards", () => { @@ -3395,7 +3025,7 @@ describe("renderWorkboard", () => { expect(container.textContent).toContain("No recent session activity"); expect(container.textContent).not.toContain("codex autonomous"); expect(container.querySelector(".workboard-live")).toBeNull(); - expect(container.querySelector('button[title="Stop session"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Stop session"]')).toBeNull(); } finally { nowSpy.mockRestore(); } @@ -3442,7 +3072,7 @@ describe("renderWorkboard", () => { ); expect(container.querySelector(".workboard-live")?.textContent).toContain("live"); - expect(container.querySelector('button[title="Stop session"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Stop session"]')).not.toBeNull(); }); it("opens an edit modal and submits card updates", async () => { @@ -3501,7 +3131,7 @@ describe("renderWorkboard", () => { render(renderWorkboard(props), container); container - .querySelector('button[title="Edit card"]') + .querySelector('button[aria-label="Edit card"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render(renderWorkboard(props), container); @@ -3555,7 +3185,7 @@ describe("renderWorkboard", () => { render(renderWorkboard(props), container); expect(container.querySelector('[role="dialog"]')).toBeNull(); container - .querySelector('button[title="Edit card"]') + .querySelector('button[aria-label="Edit card"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render(renderWorkboard(props), container); @@ -3650,7 +3280,7 @@ describe("renderWorkboard", () => { render(renderWorkboard(props), container); container - .querySelector('button[title="View details"]') + .querySelector('button[aria-label="View details"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); render(renderWorkboard(props), container); @@ -3707,7 +3337,7 @@ describe("renderWorkboard", () => { container, ); container - .querySelector('button[title="Archive card"]') + .querySelector('button[aria-label="Archive card"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); await Promise.resolve(); @@ -3894,35 +3524,4 @@ describe("renderWorkboard", () => { ?.click(); expect(onReloadConfig).toHaveBeenCalledOnce(); }); - - it("does not retry a failed initial load on every render", async () => { - const host = {}; - const container = document.createElement("div"); - const request = vi.fn(async (_method: string) => { - throw new Error("workboard unavailable"); - }); - const props = { - host, - client: { request } as unknown as GatewayBrowserClient, - connected: true, - pluginEnabled: true, - agentsList: null, - sessions: [], - onOpenSession: () => undefined, - onRequestUpdate: () => undefined, - }; - - render(renderWorkboard(props), container); - await Promise.resolve(); - await Promise.resolve(); - render(renderWorkboard(props), container); - await Promise.resolve(); - - expect(request).toHaveBeenCalledTimes(2); - expect(request.mock.calls.map(([method]) => method)).toEqual([ - "workboard.cards.diagnostics.refresh", - "workboard.cards.list", - ]); - expect(getWorkboardState(host).error).toBe("workboard unavailable"); - }); }); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/pages/workboard/view.ts similarity index 93% rename from ui/src/ui/views/workboard.ts rename to ui/src/pages/workboard/view.ts index 8aed0347daf9..830bd2e8620d 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/pages/workboard/view.ts @@ -1,7 +1,12 @@ // Control UI view renders workboard screen content. import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { AgentsListResult, GatewaySessionRow } from "../../api/types.ts"; +import { icons } from "../../components/icons.ts"; +import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; +import { formatDateMs, formatDateTimeMs } from "../../lib/format.ts"; import { addWorkboardCardComment, archiveWorkboardCard, @@ -13,16 +18,12 @@ import { getWorkboardDependencyState, getWorkboardLifecycle, getWorkboardState, - loadWorkboard, moveWorkboardCard, refreshWorkboard, saveWorkboardCardDraft, startWorkboardCard, stopWorkboardCard, - stopWorkboardLifecycleRefresh, - stopWorkboardPolling, summarizeWorkboardHealth, - syncWorkboardLifecycle, workboardCardMatchesHealthKey, workboardHasActiveWrites, workboardMutationsReady, @@ -41,12 +42,7 @@ import { type WorkboardTaskSummary, type WorkboardTemplateId, type WorkboardUiState, -} from "../controllers/workboard.ts"; -import { clearActiveFloatingTooltips } from "../dom-tooltips.ts"; -import { formatDateMs, formatDateTimeMs } from "../format.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { icons } from "../icons.ts"; -import type { AgentsListResult, GatewaySessionRow } from "../types.ts"; +} from "../../lib/workboard/index.ts"; type WorkboardAgentRow = AgentsListResult["agents"][number]; type WorkboardConfiguredAgentOption = { @@ -1201,13 +1197,11 @@ function renderCardActionSlot(content: TemplateResult | typeof nothing) { } function openCardDetails(state: WorkboardUiState, card: WorkboardCard) { - clearActiveFloatingTooltips(); state.detailCardId = card.id; state.detailCommentBody = ""; } function closeCardDetails(state: WorkboardUiState) { - clearActiveFloatingTooltips(); state.detailCardId = null; state.detailCommentBody = ""; } @@ -1242,13 +1236,11 @@ function resetDraft(state: WorkboardUiState) { } function openCreateModal(state: WorkboardUiState) { - clearActiveFloatingTooltips(); resetDraft(state); state.draftOpen = true; } function openEditModal(state: WorkboardUiState, card: WorkboardCard) { - clearActiveFloatingTooltips(); state.draftOpen = true; state.editingCardId = card.id; state.draftTitle = card.title; @@ -1350,18 +1342,19 @@ function renderCardModal(props: WorkboardProps) { ${editing ? t("workboard.editCardHelp") : t("workboard.newCardHelp")}

- + + +
${!editing @@ -1769,13 +1762,12 @@ function renderStartExecutionButton( ? t("workboard.runEngine", { engine: engineDisplayName(engine) }) : t("workboard.openEngine", { engine: engineDisplayName(engine) }) : t("workboard.runDefaultAgent"); - return html` + const button = html` `; + return options.iconOnly + ? html`${button}` + : button; } function renderStartExecutionControls(props: WorkboardProps, card: WorkboardCard) { @@ -1905,18 +1900,19 @@ function renderCardDetailsPanel(props: WorkboardProps) { ${t("workboard.detailTitle")}: ${card.title}
- + + +
@@ -2242,112 +2238,120 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { const topEditAction = writable && !archived ? html` - + + + ` : nothing; const topArchiveAction = writable ? html` - + + ` : nothing; const detailAction = html` - + + + `; const sessionAction = linkedSessionKey ? html` - + + + ` : nothing; const stopAction = writable && (linkedSessionKey ? live : activeTask) ? html` - + + + ` : nothing; const moveAction = writable ? renderCardMoveControl(props, card, busy) : nothing; const deleteAction = writable ? html` - + + + ` : nothing; return html` @@ -2492,33 +2496,6 @@ function renderColumn(props: WorkboardProps, status: WorkboardStatus, cards: Wor export function renderWorkboard(props: WorkboardProps) { const state = getWorkboardState(props.host); - if (!props.connected || props.pluginEnabled !== true) { - stopWorkboardPolling(props.host); - stopWorkboardLifecycleRefresh(props.host); - } - configureWorkboardPolling({ - host: props.host, - client: props.client, - enabled: props.connected && props.pluginEnabled === true && state.autoRefreshIntervalMs > 0, - requestUpdate: props.onRequestUpdate, - }); - if (props.connected && props.pluginEnabled === true) { - void loadWorkboard({ - host: props.host, - client: props.client, - requestUpdate: props.onRequestUpdate, - refreshDiagnostics: canWrite(props), - }); - if (!state.pollRefreshInProgress && !state.dispatching) { - void syncWorkboardLifecycle({ - host: props.host, - client: props.client, - sessions: props.sessions, - canWrite: props.canWrite, - requestUpdate: props.onRequestUpdate, - }); - } - } if (props.pluginEnabled === null) { if (props.pluginEnablementError) { @@ -2684,9 +2661,6 @@ export function renderWorkboard(props: WorkboardProps) {
- - + + + + + +
${renderRefreshStatus(state)}
@@ -2749,7 +2725,6 @@ export function renderWorkboard(props: WorkboardProps) { - `; -} - -export function renderChatControls(state: AppViewState) { - const hideCron = state.sessionsHideCron ?? true; - const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0; - const disableThinkingToggle = state.onboarding; - const showThinking = state.onboarding ? false : state.settings.chatShowThinking; - const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls; - const persistCommentary = state.settings.chatPersistCommentary === true; - const thinkingLabel = disableThinkingToggle - ? t("chat.onboardingDisabled") - : t("chat.thinkingToggle"); - const toolCallsLabel = disableThinkingToggle - ? t("chat.onboardingDisabled") - : t("chat.toolCallsToggle"); - const commentaryLabel = disableThinkingToggle - ? t("chat.onboardingDisabled") - : t("chat.commentaryToggle"); - const refreshDisabled = - !state.connected || - state.chatManualRefreshInFlight || - state.chatLoading || - state.chatSending || - state.chatStream !== null || - Boolean(state.chatRunId); - const cronLabel = hideCron - ? hiddenCronCount > 0 - ? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) }) - : t("chat.showCronSessions") - : t("chat.hideCronSessions"); - const toolCallsIcon = html` - - - - `; - const settingsOpen = state.chatMobileControlsOpen; - const settingsLabel = t("chat.settings"); - const settingsTitle = t("chat.settings"); - - return html` -
{ - if (state.chatMobileControlsOpen) { - state.setChatMobileControlsOpen(false); - } - }} - > - ${renderChatModelSelect(state)} -
- ${renderChatQuotaPill(state)} -
- - -
- `; -} - -/** - * Mobile-only gear toggle + dropdown for chat controls. - * Rendered in the topbar so it doesn't consume content-header space. - * Hidden on desktop via CSS. - */ -export function renderChatMobileToggle(state: AppViewState) { - const controlsDropdownId = "chat-mobile-controls-dropdown"; - const mobileControlsOpen = state.chatMobileControlsOpen; - const disableThinkingToggle = state.onboarding; - const showThinking = state.onboarding ? false : state.settings.chatShowThinking; - const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls; - const persistCommentary = state.settings.chatPersistCommentary === true; - const hideCron = state.sessionsHideCron ?? true; - const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0; - const toolCallsIcon = html` - - - - `; - - return html` -
- -
{ - e.stopPropagation(); - }} - > -
- ${renderChatSessionSelectBase(state, switchChatSession, { surface: "mobile" })} -
- ${renderChatAutoScrollToggle(state)} - - - - -
-
-
-
- `; -} - -function switchChatSessionInternal( - state: AppViewState, - nextSessionKey: string, - opts?: { awaitInitialLoad?: boolean }, -): Promise | undefined { - const previousSessionKey = state.sessionKey; - const previousSessionsResult = state.sessionsResult; - const nextSessionRow = - state.sessionsResult?.sessions.find((row) => row.key === nextSessionKey) ?? - state.chatSessionPickerResult?.sessions.find((row) => row.key === nextSessionKey); - const nextSessionLabel = resolveSessionDisplayName(nextSessionKey, nextSessionRow); - resetChatStateForSessionSwitch(state, nextSessionKey); - state.selectedChatSessionArchived = nextSessionRow?.archived === true; - if (previousSessionKey !== nextSessionKey) { - state.announceSessionSwitch?.(nextSessionKey, nextSessionLabel); - } - void state.loadAssistantIdentity(); - void refreshChatAvatar(state); - void refreshSlashCommands({ - client: state.client, - agentId: parseAgentSessionKey(nextSessionKey)?.agentId, - }); - syncUrlWithSessionKey( - state as unknown as Parameters[0], - nextSessionKey, - true, - ); - const subscriptionSync = syncSelectedSessionMessageSubscription( - state as unknown as AppViewState & { chatSessionMessageSubscriptionKey?: string | null }, - ); - const historyLoad = loadChatHistory(state as unknown as ChatState); - const sessionsRefresh = refreshSessionOptions(state); - flushChatQueueAfterIdleSessionReconciliation( - state as unknown as Parameters[0], - nextSessionKey, - historyLoad, - sessionsRefresh, - previousSessionsResult, - ); - if (opts?.awaitInitialLoad) { - void sessionsRefresh; - return Promise.allSettled([subscriptionSync, historyLoad]).then(() => undefined); - } - void subscriptionSync; - void historyLoad; - void sessionsRefresh; - return undefined; -} - -export function switchChatSession(state: AppViewState, nextSessionKey: string): void { - void switchChatSessionInternal(state, nextSessionKey); -} - -export function switchChatSessionAndWait( - state: AppViewState, - nextSessionKey: string, -): Promise { - return ( - switchChatSessionInternal(state, nextSessionKey, { awaitInitialLoad: true }) ?? - Promise.resolve() - ); -} - -export function isCurrentChatSessionArchived(state: AppViewState): boolean { - if (state.selectedChatSessionArchived === true) { - return true; - } - return [ - ...(state.sessionsResult?.sessions ?? []), - ...Object.values(state.chatAgentSessionRowsByAgent ?? {}).flat(), - ].some( - (row) => row.archived === true && areUiSessionKeysEquivalent(row.key, state.sessionKey), - ); -} - -export function openCurrentSessionCheckpoints(state: AppViewState): void { - const showArchived = isCurrentChatSessionArchived(state); - state.sessionsExpandedCheckpointKey = state.sessionKey; - state.sessionsFilterActive = ""; - state.sessionsFilterLimit = ""; - state.sessionsIncludeGlobal = true; - state.sessionsIncludeUnknown = true; - state.sessionsShowArchived = showArchived; - state.sessionsSearchQuery = ""; - state.sessionsSelectedKeys = new Set(); - state.sessionsPage = 0; - state.setTab("sessions"); - void loadSessions(state, { - activeMinutes: 0, - limit: 0, - includeGlobal: true, - includeUnknown: true, - showArchived, - ...scopedAgentListParamsForSession(state, state.sessionKey), - }); -} - -export async function patchSessionFromSessionsView( - state: AppViewState, - key: string, - patch: { label?: string | null; archived?: boolean; pinned?: boolean }, -): Promise { - const patched = await patchSession(state, key, patch); - if (patched && patch.archived !== undefined && state.sessionsSelectedKeys?.has(key)) { - const selectedKeys = new Set(state.sessionsSelectedKeys); - selectedKeys.delete(key); - state.sessionsSelectedKeys = selectedKeys; - } - const patchesSelectedArchiveState = - patch.archived !== undefined && areUiSessionKeysEquivalent(key, state.sessionKey); - if (!patched || !patchesSelectedArchiveState) { - return patched; - } - state.selectedChatSessionArchived = patch.archived; - if (!patch.archived) { - return true; - } - const parsed = parseAgentSessionKey(key); - const fallbackKey = buildAgentMainSessionKey({ - agentId: parsed?.agentId ?? state.agentsList?.defaultId ?? "main", - mainKey: state.agentsList?.mainKey ?? undefined, - }); - switchChatSession(state, fallbackKey); - return true; -} - -export function dismissRealtimeTalkError(state: AppViewState) { - if (state.realtimeTalkStatus !== "error") { - return; - } - const talkHost = state as unknown as { - realtimeTalkSession?: { stop(): void } | null; - }; - talkHost.realtimeTalkSession?.stop(); - talkHost.realtimeTalkSession = null; - state.realtimeTalkActive = false; - state.realtimeTalkStatus = "idle"; - state.realtimeTalkDetail = null; - state.realtimeTalkTranscript = null; - state.resetRealtimeTalkConversation?.(); -} - -export function dismissChatError(state: AppViewState) { - state.lastError = null; - state.lastErrorCode = null; - state.chatError = null; -} - -export type CreateChatSessionIntent = { source: "user" }; - -export async function createChatSession( - state: AppViewState, - intent?: CreateChatSessionIntent, -): Promise { - if (intent?.source !== "user") { - return false; - } - if (!state.client || !state.connected) { - return false; - } - if (!canSwitchToNewChatSession(state)) { - state.lastError = NEW_CHAT_ACTIVE_RUN_MESSAGE; - state.chatError = state.lastError; - return false; - } - if (state.sessionsLoading) { - state.lastError = NEW_CHAT_SESSIONS_LOADING_MESSAGE; - state.chatError = state.lastError; - return false; - } - - state.lastError = null; - state.chatError = null; - const previousSessionKey = state.sessionKey; - const normalizedPreviousSessionKey = normalizeOptionalString(previousSessionKey); - const parentSessionKey = - normalizeLowercaseStringOrEmpty(normalizedPreviousSessionKey) === "unknown" - ? undefined - : normalizedPreviousSessionKey; - const nextSessionKey = await createSessionAndRefresh( - state as unknown as Parameters[0], - { - agentId: - scopedAgentParamsForSession(state, previousSessionKey).agentId ?? - resolveAgentIdFromSessionKey(previousSessionKey), - parentSessionKey, - emitCommandHooks: parentSessionKey !== undefined ? true : undefined, - }, - { - ...createChatSessionsLoadOverrides(state), - ...scopedAgentListParamsForSession(state, previousSessionKey), - }, - ); - if ( - !nextSessionKey || - state.sessionKey !== previousSessionKey || - !canSwitchToNewChatSession(state) - ) { - if (!nextSessionKey) { - state.lastError = - state.sessionsError ?? - (state.sessionsLoading - ? NEW_CHAT_SESSIONS_LOADING_MESSAGE - : NEW_CHAT_CREATE_FAILED_MESSAGE); - state.chatError = state.lastError; - } - return false; - } - - const preservedDraft = state.chatMessage; - const preservedAttachments = state.chatAttachments; - switchChatSession(state, nextSessionKey); - state.chatMessage = preservedDraft; - state.chatAttachments = preservedAttachments; - return true; -} - -async function refreshSessionOptions(state: AppViewState) { - await loadSessions(state as unknown as Parameters[0], { - ...createChatSessionsLoadOverrides(state), - ...scopedAgentListParamsForSession(state, state.sessionKey), - }); -} - -/** Count cron sessions hidden by the active agent-scoped chat filter. */ -function countHiddenCronSessions(state: AppViewState, sessions: SessionsListResult | null): number { - if (!sessions?.sessions) { - return 0; - } - const activeAgentId = normalizeAgentId( - parseAgentSessionKey(state.sessionKey)?.agentId ?? state.agentsList?.defaultId ?? "main", - ); - const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); - - return sessions.sessions.filter( - (s) => - isCronSessionKey(s.key) && - s.key !== state.sessionKey && - isSessionKeyTiedToAgent(s.key, activeAgentId, defaultAgentId), - ).length; -} - -type ThemeModeOption = { id: ThemeMode; labelKey: string; short: string }; -const THEME_MODE_OPTIONS: ThemeModeOption[] = [ - { id: "system", labelKey: "common.system", short: "SYS" }, - { id: "light", labelKey: "common.light", short: "LIGHT" }, - { id: "dark", labelKey: "common.dark", short: "DARK" }, -]; - -export function renderTopbarThemeModeToggle(state: AppViewState) { - const modeIcon = (mode: ThemeMode) => { - if (mode === "system") { - return icons.monitor; - } - if (mode === "light") { - return icons.sun; - } - return icons.moon; - }; - - const applyMode = (mode: ThemeMode, e: Event) => { - if (mode === state.themeMode) { - return; - } - state.setThemeMode(mode, { element: e.currentTarget as HTMLElement }); - }; - - return html` -
- ${THEME_MODE_OPTIONS.map((opt) => { - // Group aria-label already says "Color mode"; per-button label only needs - // the differentiating mode name (System/Light/Dark). - const label = t(opt.labelKey); - return html` - - `; - })} -
- `; -} - -/** - * Sidebar footer status dot. The footer intentionally shows only live - * connection state; the gateway version lives in Settings (Quick Settings - * footer) instead of persistent chrome. - */ -export function renderSidebarConnectionStatus(state: AppViewState) { - const label = state.connected ? t("common.online") : t("common.offline"); - const toneClass = state.connected - ? "sidebar-connection-status--online" - : "sidebar-connection-status--offline"; - - return html` - - `; -} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts deleted file mode 100644 index ca08a1ee4260..000000000000 --- a/ui/src/ui/app-render.ts +++ /dev/null @@ -1,4293 +0,0 @@ -// Control UI module implements app render behavior. -import { html, nothing } from "lit"; -import { guard } from "lit/directives/guard.js"; -import { styleMap } from "lit/directives/style-map.js"; -import { i18n, t } from "../i18n/index.ts"; -import { getSafeLocalStorage } from "../local-storage.ts"; -import { - createChatSessionsLoadOverrides, - hasAbortableSessionRun, - refreshChat, - refreshChatCommands, - scopedAgentParamsForSession, -} from "./app-chat.ts"; -import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; -import "./terminal/terminal-panel.ts"; -import { renderUsageTab } from "./app-render-usage-tab.ts"; -import { - renderChatControls, - renderTab, - resolveAssistantAttachmentAuthToken, - resolveDashboardHeaderContext, - renderSidebarConnectionStatus, - renderTopbarThemeModeToggle, - createChatSession, - dismissChatError, - dismissRealtimeTalkError, - isCurrentChatSessionArchived, - isTerminalAvailable, - openCurrentSessionCheckpoints, - patchSessionFromSessionsView, - switchChatSession, - switchChatSessionAndWait, -} from "./app-render.helpers.ts"; -import { hasOperatorAdminAccess, hasOperatorWriteAccess, warnQueryToken } from "./app-settings.ts"; -import type { AppViewState } from "./app-view-state.ts"; -import { copyToClipboard } from "./chat/clipboard.ts"; -import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts"; -import { - renderChatQuotaPill, - renderSidebarAgentFilter, - renderSidebarSessionSearch, - resolveChatAgentFilterId, - resolveChatAgentFilterOptions, - resolvePreferredSessionForAgent, -} from "./chat/session-controls.ts"; -import { clearChatMessagesFromCache } from "./chat/session-message-cache.ts"; -import { - controlUiNowMs, - recordControlUiRenderTiming, - roundedControlUiDurationMs, -} from "./control-ui-performance.ts"; -import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; -import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; -import { loadAgentSkills } from "./controllers/agent-skills.ts"; -import { - buildToolsEffectiveRequestKey, - loadAgents, - loadToolsCatalog, - loadToolsEffective, - resetToolsEffectiveState, - refreshVisibleToolsEffectiveForCurrentSession, - saveAgentsConfig, - setDefaultAgent, -} from "./controllers/agents.ts"; -import { setAssistantAvatarOverride } from "./controllers/assistant-identity.ts"; -import { loadChannels } from "./controllers/channels.ts"; -import { loadChatHistory } from "./controllers/chat.ts"; -import { - applyConfig, - ensureAgentConfigEntry, - findAgentConfigEntryIndex, - loadConfig, - openConfigFile, - resetConfigPendingChanges, - runUpdate, - saveConfig, - stageConfigPreset, - updateConfigRawValue, - updateConfigFormValue, - removeConfigFormValue, - updateMcpServerEnabled, -} from "./controllers/config.ts"; -import { - loadCronJobsPage, - loadCronRuns, - loadMoreCronRuns, - toggleCronJob, - runCronJob, - removeCronJob, - addCronJob, - startCronEdit, - startCronClone, - cancelCronEdit, - validateCronForm, - hasCronFormErrors, - normalizeCronFormState, - getVisibleCronJobs, - updateCronJobsFilter, - updateCronRunsFilter, -} from "./controllers/cron.ts"; -import { loadDebug, callDebugMethod } from "./controllers/debug.ts"; -import { - approveDevicePairing, - closeDevicePairSetup, - loadDevices, - openDevicePairSetup, - refreshDevicePairSetup, - rejectDevicePairing, - revokeDeviceToken, - rotateDeviceToken, -} from "./controllers/devices.ts"; -import { - backfillDreamDiary, - copyDreamingArchivePath, - dedupeDreamDiary, - loadDreamDiary, - loadDreamingStatus, - loadWikiImportInsights, - loadWikiMemoryPalace, - repairDreamingArtifacts, - resetGroundedShortTerm, - resetDreamDiary, - resolveConfiguredDreaming, - updateDreamingEnabled, -} from "./controllers/dreaming.ts"; -import { - loadExecApprovals, - removeExecApprovalsFormValue, - saveExecApprovals, - updateExecApprovalsFormValue, -} from "./controllers/exec-approvals.ts"; -import { loadLogs } from "./controllers/logs.ts"; -import { loadNodes } from "./controllers/nodes.ts"; -import { loadPresence } from "./controllers/presence.ts"; -import { - branchSessionFromCheckpoint, - compareSessionRowsByUpdatedAt, - createSessionAndRefresh, - deleteSessionsAndRefresh, - loadSessions, - parseSessionsFilterInteger, - patchSession, - restoreSessionFromCheckpoint, - toggleSessionCompactionCheckpoints, -} from "./controllers/sessions.ts"; -import { - countSkillWorkshopProposals, - requestSkillWorkshopRevision, - runSkillWorkshopLifecycleAction, - selectSkillWorkshopProposal, -} from "./controllers/skill-workshop.ts"; -import { - closeClawHubDetail, - installFromClawHub, - loadSkillCard, - installSkill, - loadClawHubDetail, - loadSkills, - reconcileSkillsAgentId, - saveSkillApiKey, - searchClawHub, - setClawHubSearchQuery, - setSkillsAgentId, - updateSkillEdit, - updateSkillEnabled, -} from "./controllers/skills.ts"; -import { captureSessionToWorkboard, getWorkboardState } from "./controllers/workboard.ts"; -import { getCronJobPayload } from "./cron-payload.ts"; -import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; -import { formatTimeMs } from "./format.ts"; -import { formatRelativeTimestamp } from "./format.ts"; -import { icons } from "./icons.ts"; -import { createLazyView, renderLazyView } from "./lazy-view.ts"; -import { - iconForTab, - isSettingsTab, - normalizeBasePath, - pathForTab, - SETTINGS_TABS, - TAB_GROUPS, - subtitleForTab, - titleForTab, - type Tab, -} from "./navigation.ts"; -import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts"; -import { isCronSessionKey, resolveSessionDisplayName } from "./session-display.ts"; -import { - areUiSessionKeysEquivalent, - buildAgentMainSessionKey, - canArchiveSessionRow, - isSessionKeyTiedToAgent, - isSubagentSessionKey, - normalizeAgentId, - parseAgentSessionKey, - resolveAgentIdFromSessionKey, - resolveUiConfiguredMainKey, - resolveUiSelectedGlobalAgentId, - uiSessionRowMatchesSelectedChat, -} from "./session-key.ts"; -import type { SidebarContent } from "./sidebar-content.ts"; -import "./components/dashboard-header.ts"; -import { loadLocalAssistantIdentity } from "./storage.ts"; -import { normalizeStringEntries } from "./string-coerce.ts"; -import { normalizeOptionalString } from "./string-coerce.ts"; -import { resolveTheme } from "./theme.ts"; -import type { - ArtifactDownloadResult, - GatewaySessionRow, - SessionWorkspaceGetResult, - SessionWorkspaceListResult, -} from "./types.ts"; -import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts"; -import { agentLogoUrl } from "./views/agents-utils.ts"; -import { - resolveAgentConfig, - resolveConfiguredCronModelSuggestions, - resolveEffectiveModelFallbacks, - resolveModelPrimary, - sortLocaleStrings, -} from "./views/agents-utils.ts"; -import { renderChat } from "./views/chat.ts"; -import { renderCommandPalette } from "./views/command-palette.ts"; -import { getPresetById } from "./views/config-presets.ts"; -import { renderQuickSettings, type QuickSettingsChannel } from "./views/config-quick.ts"; -import { renderConfig, type ConfigProps } from "./views/config.ts"; -import { - renderCronQuickCreate, - createDefaultDraft, - draftToCronFormPatch, -} from "./views/cron-quick-create.ts"; -import { renderDreamingRestartConfirmation } from "./views/dreaming-restart-confirmation.ts"; -import { renderDreaming } from "./views/dreaming.ts"; -import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; -import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; -import { renderLoginGate } from "./views/login-gate.ts"; -import { renderMcp } from "./views/mcp.ts"; -import { renderOverview } from "./views/overview.ts"; - -let pendingUpdate: (() => void) | undefined; - -const notifyLazyViewChanged = () => pendingUpdate?.(); - -function runUiTask( - task: (...args: Args) => Promise, -): (...args: Args) => void { - return (...args) => { - void task(...args); - }; -} - -const SKILL_WORKSHOP_MODE_KEY = "openclaw:control-ui:skill-workshop-mode:v1"; -const SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY = - "openclaw:control-ui:skill-workshop-current-chat-revisions:v1"; - -export function loadSkillWorkshopMode(): "board" | "today" { - try { - const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_MODE_KEY); - return raw === "board" ? "board" : "today"; - } catch { - return "today"; - } -} - -export function loadSkillWorkshopUseCurrentChatForRevisions(): boolean { - try { - return getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY) === "true"; - } catch { - return false; - } -} - -function setSkillWorkshopUseCurrentChatForRevisions(state: AppViewState, enabled: boolean): void { - state.skillWorkshopUseCurrentChatForRevisions = enabled; - try { - getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY, String(enabled)); - } catch { - // Preference persistence is optional; the active toggle still controls this handoff. - } -} - -function setSkillWorkshopMode(state: AppViewState, mode: "board" | "today"): void { - if (state.skillWorkshopMode === mode) { - return; - } - state.skillWorkshopMode = mode; - try { - getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_MODE_KEY, mode); - } catch { - // Mode persistence is a convenience; the in-memory switch still works. - } -} - -function renderSkillWorkshopHeaderControls(state: AppViewState) { - const useCurrentChatLabel = t("skillWorkshop.header.useCurrentChat"); - return html` -
- -
- - - -
-
- `; -} - -function findSkillWorkshopRevisionSessionRow( - state: AppViewState, - sessionKey: string | undefined, -): GatewaySessionRow | null { - const key = normalizeOptionalString(sessionKey); - if (!key) { - return null; - } - const current = state.sessionsResult?.sessions.find((row) => row.key === key); - if (current) { - return current; - } - for (const rows of Object.values(state.chatAgentSessionRowsByAgent ?? {})) { - const cached = rows.find((row) => row.key === key); - if (cached) { - return cached; - } - } - return null; -} - -function isUsableSkillWorkshopRevisionSession( - row: GatewaySessionRow | null, -): row is GatewaySessionRow { - return Boolean(row && !row.archived && !row.hasActiveRun); -} - -async function ensureSkillWorkshopRevisionSessionsLoaded( - state: AppViewState, - agentId: string, -): Promise { - const resultAgentId = normalizeOptionalString(state.sessionsResultAgentId); - if (resultAgentId === agentId && state.sessionsResult?.sessions.length) { - return; - } - await loadSessions(state, { - ...createChatSessionsLoadOverrides(state), - agentId, - }); -} - -async function resolveSkillWorkshopRevisionSessionKey( - state: AppViewState, - proposal: { key: string; slug: string; origin?: { agentId?: string; sessionKey?: string } }, - proposalAgentId: string, -): Promise { - if (state.skillWorkshopUseCurrentChatForRevisions) { - return normalizeOptionalString(state.sessionKey) ?? null; - } - - const agentId = normalizeAgentId(proposal.origin?.agentId ?? proposalAgentId); - await ensureSkillWorkshopRevisionSessionsLoaded(state, agentId); - - const originRow = findSkillWorkshopRevisionSessionRow(state, proposal.origin?.sessionKey); - if (isUsableSkillWorkshopRevisionSession(originRow)) { - return originRow.key; - } - - return createSessionAndRefresh( - state as unknown as Parameters[0], - { - agentId, - label: `Skill Workshop: ${proposal.slug || proposal.key}`.slice(0, 80), - }, - { - ...createChatSessionsLoadOverrides(state), - agentId, - }, - ); -} - -async function sendSkillWorkshopRevisionRequest( - state: AppViewState, - instructions: string, - proposal: { key: string; slug: string; origin?: { agentId?: string; sessionKey?: string } }, - proposalAgentId: string, -): Promise { - if (!state.client || !state.connected) { - throw new Error("Gateway is not connected."); - } - const sessionKey = await resolveSkillWorkshopRevisionSessionKey(state, proposal, proposalAgentId); - if (!sessionKey) { - throw new Error(state.sessionsError ?? "Could not prepare a Skill Workshop session."); - } - if (state.tab !== "chat") { - state.setTab("chat" as Tab); - } - if (state.sessionKey === sessionKey) { - await loadChatHistory(state); - } else { - await switchChatSessionAndWait(state, sessionKey); - } - const scopedProposalAgentId = proposal.origin?.agentId?.trim() || proposalAgentId; - await state.handleSendChat(instructions, { - restoreDraft: true, - skillWorkshopRevision: { - proposalId: proposal.key, - agentId: scopedProposalAgentId, - }, - }); -} - -function renderSettingsSectionNav(state: AppViewState) { - if (!isSettingsTab(state.tab)) { - return nothing; - } - return html` - - `; -} - -function renderSettingsWorkspace(state: AppViewState, body: unknown) { - return html` -
- ${renderSettingsSectionNav(state)} -
${body}
-
- `; -} - -function isSidebarSessionBusy(state: AppViewState) { - return ( - state.chatLoading || - state.chatSending || - Boolean(state.chatRunId) || - state.chatStream !== null || - state.chatQueue.length > 0 - ); -} - -function resolveSidebarDefaultAgentId(state: AppViewState): string { - const snapshot = state.hello?.snapshot as - | { sessionDefaults?: { defaultAgentId?: string } } - | undefined; - return normalizeAgentId( - state.agentsList?.defaultId ?? snapshot?.sessionDefaults?.defaultAgentId ?? "main", - ); -} - -function resolveSidebarSelectedAgentId(state: AppViewState): string { - const parsed = parseAgentSessionKey(state.sessionKey); - if (parsed) { - return normalizeAgentId(parsed.agentId); - } - const sessionKey = normalizeOptionalString(state.sessionKey)?.toLowerCase(); - const fallbackAgentId = - sessionKey === "global" || sessionKey === "unknown" - ? (state.assistantAgentId ?? resolveSidebarDefaultAgentId(state)) - : resolveSidebarDefaultAgentId(state); - return normalizeAgentId(fallbackAgentId); -} - -function isSidebarSessionForSelectedAgent( - state: AppViewState, - row: GatewaySessionRow, - selectedAgentId: string, -): boolean { - return isSessionKeyTiedToAgent(row.key, selectedAgentId, resolveSidebarDefaultAgentId(state)); -} - -function resolveSidebarRecentSessions(state: AppViewState): GatewaySessionRow[] { - const selectedAgentId = resolveSidebarSelectedAgentId(state); - const shouldFilterByAgent = - normalizeOptionalString(state.sessionKey)?.toLowerCase() !== "unknown"; - return (state.sessionsResult?.sessions ?? []) - .filter( - (row) => - !row.archived && - row.kind !== "global" && - row.kind !== "unknown" && - row.kind !== "cron" && - !isCronSessionKey(row.key) && - !isSubagentSessionKey(row.key) && - !row.spawnedBy && - // The active session renders as the pinned row above this list. - !isActiveSidebarSessionRow(state, row.key) && - (!shouldFilterByAgent || isSidebarSessionForSelectedAgent(state, row, selectedAgentId)), - ) - .toSorted(compareSessionRowsByUpdatedAt) - .slice(0, 9); -} - -// Session keys have alias spellings ("main" vs "agent::main", and "global" -// for the selected agent's global chat); active-row checks must use the -// host-aware matcher so every spelling counts as the same session. -function isActiveSidebarSessionRow(state: AppViewState, rowKey: string): boolean { - return uiSessionRowMatchesSelectedChat(state, rowKey, state.sessionKey); -} - -// Generic Chat entry for sentinel selections ("unknown"/empty sessionKey) -// where no pinned session row can render; keeps a deterministic way into the -// open chat from every tab. -function renderSidebarChatFallbackRow(state: AppViewState) { - return html` - - `; -} - -// Pinned current-session row, derived from sessionKey alone: with no dedicated -// chat nav item this is the guaranteed way back to the open chat, so it must -// survive filtered, capped, or replaced session lists (archived/global/cron -// active sessions included). -function resolveSidebarActiveRow(state: AppViewState): GatewaySessionRow | null { - const activeKey = normalizeOptionalString(state.sessionKey); - if (!activeKey || activeKey.toLowerCase() === "unknown") { - return null; - } - // Exact key equivalence wins; the looser host-aware global alias is only - // trusted when the row source is scoped to the active agent, so another - // agent's "global" row can never lend its metadata to the pinned entry. - // Keep the matched row's metadata but the selected key: an aliased row key - // (e.g. "global") in the pinned anchor's href would drop the agent scope on - // middle-click / open-in-new-tab navigation. - const activeAgentId = normalizeAgentId( - parseAgentSessionKey(activeKey)?.agentId ?? resolveUiSelectedGlobalAgentId(state), - ); - const findActiveRow = (rows: readonly GatewaySessionRow[], scopeAgentId: string | null) => - rows.find((row) => areUiSessionKeysEquivalent(row.key, activeKey)) ?? - (scopeAgentId === activeAgentId - ? rows.find((row) => uiSessionRowMatchesSelectedChat(state, row.key, activeKey)) - : undefined); - const fromResult = findActiveRow( - state.sessionsResult?.sessions ?? [], - state.sessionsResultAgentId ? normalizeAgentId(state.sessionsResultAgentId) : null, - ); - if (fromResult) { - return { ...fromResult, key: activeKey }; - } - for (const [agentId, rows] of Object.entries(state.chatAgentSessionRowsByAgent ?? {})) { - const cached = findActiveRow(rows, normalizeAgentId(agentId)); - if (cached) { - return { ...cached, key: activeKey }; - } - } - return { key: activeKey, kind: "direct", updatedAt: null }; -} - -// `collapsed` is the effective rail state (persisted setting minus an open -// mobile drawer), not the raw setting: an open drawer must show the sessions. -function renderSidebarSessions(state: AppViewState, collapsed: boolean) { - const busy = isSidebarSessionBusy(state); - const recent = collapsed ? [] : resolveSidebarRecentSessions(state); - const activeRow = collapsed ? null : resolveSidebarActiveRow(state); - const newSessionDisabled = !state.connected || state.sessionsLoading || busy || !state.client; - const newSessionTitle = !state.connected - ? "Connect to create a new session" - : busy - ? "Finish the active run before creating a new session" - : "New session"; - - return html` - - `; -} - -function hasModifierKey(event: MouseEvent): boolean { - return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; -} - -function renderSidebarRecentSession(state: AppViewState, row: GatewaySessionRow) { - const active = isActiveSidebarSessionRow(state, row.key); - const label = resolveSessionDisplayName(row.key, row); - const meta = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : ""; - const href = `${pathForTab("chat", state.basePath)}?session=${encodeURIComponent(row.key)}`; - const pinned = row.pinned === true; - const running = row.hasActiveRun === true; - const controlsDisabled = !state.connected || !state.client; - const archiveAllowed = canArchiveSessionRow(row, resolveUiConfiguredMainKey(state)); - const rowClass = [ - "sidebar-recent-session", - "session-row-host", - active ? "sidebar-recent-session--active" : "", - pinned ? "session-row-host--pinned" : "", - running ? "session-row-host--running" : "", - ] - .filter(Boolean) - .join(" "); - return html` -
- { - if (event.defaultPrevented || event.button !== 0 || hasModifierKey(event)) { - return; - } - event.preventDefault(); - if (!isActiveSidebarSessionRow(state, row.key)) { - switchChatSession(state, row.key); - } - state.setTab("chat" as import("./navigation.ts").Tab); - }} - > - ${label} - - - - ${running - ? html`` - : meta} - - - - - - -
- `; -} - -// Lazy-loaded view modules are deferred so the initial bundle stays small. -// The shared loader renders visible fallback states instead of leaving a tab blank. -const lazyAgents = createLazyView(() => import("./views/agents.ts"), notifyLazyViewChanged); -const lazyActivity = createLazyView(() => import("./views/activity.ts"), notifyLazyViewChanged); -const lazyChannels = createLazyView(() => import("./views/channels.ts"), notifyLazyViewChanged); -const lazyCron = createLazyView(() => import("./views/cron.ts"), notifyLazyViewChanged); -const lazyDebug = createLazyView(() => import("./views/debug.ts"), notifyLazyViewChanged); -const lazyInstances = createLazyView(() => import("./views/instances.ts"), notifyLazyViewChanged); -const lazyLogs = createLazyView(() => import("./views/logs.ts"), notifyLazyViewChanged); -const lazyNodes = createLazyView(() => import("./views/nodes.ts"), notifyLazyViewChanged); -const lazySessions = createLazyView(() => import("./views/sessions.ts"), notifyLazyViewChanged); -const lazySkillWorkshop = createLazyView( - () => import("./views/skill-workshop.ts"), - notifyLazyViewChanged, -); -const lazySkills = createLazyView(() => import("./views/skills.ts"), notifyLazyViewChanged); -const lazyUsage = createLazyView(() => import("./views/usage.ts"), notifyLazyViewChanged); -const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notifyLazyViewChanged); - -type ChatWorkspaceFilesState = { - activeId: string | null; - agentId: string; - browserPath: string; - browserSearch: string; - browserSearchTimer: ReturnType | null; - collapsed: boolean; - error: string | null; - list: SessionWorkspaceListResult | null; - loading: boolean; - pendingReload: boolean; - requestId: number; - sessionKey: string; -}; - -const chatWorkspaceFilesStates = new WeakMap(); -const chatWorkspaceFileOpenRequests = new WeakMap< - AppViewState, - { agentId: string; id: number; itemId: string; sessionKey: string } ->(); - -function getChatWorkspaceFilesState( - state: AppViewState, - sessionKey: string, - agentId: string, -): ChatWorkspaceFilesState { - const current = chatWorkspaceFilesStates.get(state); - if (current?.sessionKey === sessionKey && current.agentId === agentId) { - return current; - } - const next = { - activeId: null, - agentId, - browserPath: "", - browserSearch: "", - browserSearchTimer: null, - collapsed: true, - error: null, - list: null, - loading: false, - pendingReload: false, - requestId: 0, - sessionKey, - }; - chatWorkspaceFilesStates.set(state, next); - return next; -} - -export function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null { - return ( - formatTimeMs( - nextRunAtMs, - { - hour: "numeric", - minute: "2-digit", - }, - "", - ) || null - ); -} - -function resolveDreamingNextCycle( - status: { phases?: Record } | null, -): string | null { - if (!status?.phases) { - return null; - } - let nextRunAtMs: number | undefined; - for (const phase of Object.values(status.phases)) { - if (!phase.enabled || typeof phase.nextRunAtMs !== "number") { - continue; - } - if (nextRunAtMs === undefined || phase.nextRunAtMs < nextRunAtMs) { - nextRunAtMs = phase.nextRunAtMs; - } - } - return formatDreamNextCycle(nextRunAtMs); -} - -let clawhubSearchTimer: ReturnType | null = null; - -const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; -const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; -const CRON_TIMEZONE_SUGGESTIONS = [ - "UTC", - "America/Los_Angeles", - "America/Denver", - "America/Chicago", - "America/New_York", - "Europe/London", - "Europe/Berlin", - "Asia/Tokyo", -]; - -function isHttpUrl(value: string): boolean { - return /^https?:\/\//i.test(value.trim()); -} - -function normalizeSuggestionValue(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function uniquePreserveOrder(values: string[]): string[] { - const seen = new Set(); - const output: string[] = []; - for (const value of values) { - const normalized = value.trim(); - if (!normalized) { - continue; - } - const key = normalized.toLowerCase(); - if (seen.has(key)) { - continue; - } - seen.add(key); - output.push(normalized); - } - return output; -} - -type DismissedUpdateBanner = { - latestVersion: string; - channel: string | null; - dismissedAtMs: number; -}; - -function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { - try { - const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as Partial; - if (!parsed || typeof parsed.latestVersion !== "string") { - return null; - } - return { - latestVersion: parsed.latestVersion, - channel: typeof parsed.channel === "string" ? parsed.channel : null, - dismissedAtMs: typeof parsed.dismissedAtMs === "number" ? parsed.dismissedAtMs : Date.now(), - }; - } catch { - return null; - } -} - -function isUpdateBannerDismissed(updateAvailable: unknown): boolean { - const dismissed = loadDismissedUpdateBanner(); - if (!dismissed) { - return false; - } - const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; - const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; - const channel = info && typeof info.channel === "string" ? info.channel : null; - return Boolean( - latestVersion && dismissed.latestVersion === latestVersion && dismissed.channel === channel, - ); -} - -function dismissUpdateBanner(updateAvailable: unknown) { - const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; - const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; - if (!latestVersion) { - return; - } - const channel = info && typeof info.channel === "string" ? info.channel : null; - const payload: DismissedUpdateBanner = { - latestVersion, - channel, - dismissedAtMs: Date.now(), - }; - try { - getSafeLocalStorage()?.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); - } catch { - // ignore - } -} - -const COMMUNICATION_SECTION_KEYS = [ - "messages", - "broadcast", - "__notifications__", - "talk", - "audio", - "channels", -] as const; -const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const; -const AUTOMATION_SECTION_KEYS = [ - "commands", - "hooks", - "bindings", - "cron", - "approvals", - "plugins", -] as const; -const INFRASTRUCTURE_SECTION_KEYS = [ - "gateway", - "web", - "browser", - "nodeHost", - "canvasHost", - "discovery", - "media", - "acp", - "mcp", -] as const; -const AI_AGENTS_SECTION_KEYS = [ - "agents", - "models", - "skills", - "tools", - "memory", - "session", -] as const; -type ConfigSectionSelection = { - activeSection: string | null; - activeSubsection: string | null; -}; - -type ConfigTabOverrides = Pick< - ConfigProps, - | "formMode" - | "searchQuery" - | "activeSection" - | "activeSubsection" - | "onFormModeChange" - | "onSearchChange" - | "onSectionChange" - | "onSubsectionChange" -> & - Partial< - Pick< - ConfigProps, - | "showModeToggle" - | "navRootLabel" - | "showRootTab" - | "includeSections" - | "excludeSections" - | "includeVirtualSections" - | "settingsLayout" - | "onBackToQuick" - | "webPush" - | "onWebPushSubscribe" - | "onWebPushUnsubscribe" - | "onWebPushTest" - > - >; - -const SCOPED_CONFIG_SECTION_KEYS = new Set([ - ...COMMUNICATION_SECTION_KEYS, - ...APPEARANCE_SECTION_KEYS, - ...AUTOMATION_SECTION_KEYS, - ...INFRASTRUCTURE_SECTION_KEYS, - ...AI_AGENTS_SECTION_KEYS, -]); - -function normalizeMainConfigSelection( - activeSection: string | null, - activeSubsection: string | null, -): ConfigSectionSelection { - if (activeSection && SCOPED_CONFIG_SECTION_KEYS.has(activeSection)) { - return { activeSection: null, activeSubsection: null }; - } - return { activeSection, activeSubsection }; -} - -function normalizeScopedConfigSelection( - activeSection: string | null, - activeSubsection: string | null, - includedSections: readonly string[], -): ConfigSectionSelection { - if (activeSection && !includedSections.includes(activeSection)) { - return { activeSection: null, activeSubsection: null }; - } - return { activeSection, activeSubsection }; -} - -function countScopedTopLevelSchemaProperties( - schema: unknown, - includeSections?: readonly string[], - excludeSections?: readonly string[], -): number { - if (!schema || typeof schema !== "object" || Array.isArray(schema)) { - return 0; - } - const properties = (schema as { properties?: unknown }).properties; - if (!properties || typeof properties !== "object" || Array.isArray(properties)) { - return 0; - } - const include = includeSections?.length ? new Set(includeSections) : null; - const exclude = excludeSections?.length ? new Set(excludeSections) : null; - return Object.keys(properties).filter((key) => { - if (include && !include.has(key)) { - return false; - } - if (exclude?.has(key)) { - return false; - } - return true; - }).length; -} - -function renderMeasured( - state: AppViewState, - surface: string, - payload: Record, - render: () => T, -): T { - const startedAtMs = controlUiNowMs(); - const result = render(); - recordControlUiRenderTiming(state, surface, { - ...payload, - durationMs: roundedControlUiDurationMs(controlUiNowMs() - startedAtMs), - }); - return result; -} - -function renderGuardedChatControls(state: AppViewState) { - return guard( - [ - state.sessionKey, - state.connected, - state.client, - state.onboarding, - state.chatManualRefreshInFlight, - state.chatLoading, - state.chatSending, - state.chatStream, - state.chatRunId, - state.chatMobileControlsOpen, - state.sessionsHideCron ?? true, - state.sessionsResult, - state.sessionsShowArchived, - state.agentsList, - state.chatModelOverrides, - state.chatModelSwitchPromises, - state.chatModelsLoading, - state.chatModelCatalog, - // Provider usage windows arrive async after auth status loads; without this the guarded - // composer controls never re-render and the quota pill stays absent/stale (#93041). - state.modelAuthStatusResult, - state.settings.chatShowThinking, - state.settings.chatShowToolCalls, - state.settings.chatAutoScroll, - state.chatSessionPickerOpen, - state.chatSessionPickerSurface, - state.chatSessionPickerQuery, - state.chatSessionPickerAppliedQuery, - state.chatSessionPickerLoading, - state.chatSessionPickerError, - state.chatSessionPickerResult, - state.sessionSwitchNotice?.id ?? null, - state.sessionSwitchNotice?.text ?? null, - state.sessionSwitchFlashKey, - i18n.getLocale(), - ], - () => renderChatControls(state), - ); -} - -function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { - const list = state.agentsList?.agents ?? []; - const parsed = parseAgentSessionKey(state.sessionKey); - const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main"; - const agent = list.find((entry) => entry.id === agentId); - const identity = agent?.identity; - const candidate = identity?.avatarUrl ?? identity?.avatar; - if (!candidate) { - return undefined; - } - if (isRenderableControlUiAvatarUrl(candidate)) { - return candidate; - } - return undefined; -} - -function resolveAssistantAvatarOverride(config: unknown): string | null { - if (!config || typeof config !== "object" || Array.isArray(config)) { - return null; - } - const ui = (config as { ui?: unknown }).ui; - if (!ui || typeof ui !== "object" || Array.isArray(ui)) { - return null; - } - const assistant = (ui as { assistant?: unknown }).assistant; - if (!assistant || typeof assistant !== "object" || Array.isArray(assistant)) { - return null; - } - return normalizeOptionalString((assistant as { avatar?: unknown }).avatar) ?? null; -} - -function buildAssistantAvatarRoute(basePathValue: string | null | undefined, agentId: string) { - const basePath = normalizeBasePath(basePathValue ?? ""); - const encoded = encodeURIComponent(agentId); - return basePath ? `${basePath}/avatar/${encoded}` : `/avatar/${encoded}`; -} - -// ── Quick Settings data extraction helpers ── - -const KNOWN_CHANNEL_IDS = [ - { id: "telegram", label: "Telegram" }, - { id: "discord", label: "Discord" }, - { id: "slack", label: "Slack" }, - { id: "whatsapp", label: "WhatsApp" }, - { id: "signal", label: "Signal" }, - { id: "imessage", label: "iMessage" }, -] as const; - -function formatQuickSettingsLabel(id: string): string { - const trimmed = id.trim(); - if (!trimmed) { - return "Unknown"; - } - return trimmed - .split(/[-_]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function extractQuickSettingsChannels(state: AppViewState): QuickSettingsChannel[] { - const config = state.configForm ?? state.configSnapshot?.config; - if (!config || typeof config !== "object") { - return []; - } - const channelsConfig = - "channels" in config && config.channels && typeof config.channels === "object" - ? (config.channels as Record) - : {}; - const configuredIds = Object.keys(channelsConfig).filter((id) => id.trim().length > 0); - const channelIds = - configuredIds.length > 0 - ? configuredIds.toSorted((a, b) => a.localeCompare(b)) - : KNOWN_CHANNEL_IDS.map(({ id }) => id); - const knownLabels = new Map( - KNOWN_CHANNEL_IDS.map(({ id, label }) => [id, label]), - ); - const channels: QuickSettingsChannel[] = []; - for (const id of channelIds) { - const channelConfig = channelsConfig[id]; - const hasConfig = - channelConfig != null && - typeof channelConfig === "object" && - Object.keys(channelConfig).length > 0; - channels.push({ - id, - label: knownLabels.get(id) ?? formatQuickSettingsLabel(id), - connected: hasConfig, - detail: hasConfig ? "Configured" : undefined, - }); - } - return channels; -} - -function extractMcpServerCount(state: AppViewState): number { - const config = state.configForm ?? state.configSnapshot?.config; - if (!config || typeof config !== "object") { - return 0; - } - const mcp = config.mcp; - if (!mcp || typeof mcp !== "object") { - return 0; - } - const servers = - "servers" in mcp && mcp.servers && typeof mcp.servers === "object" - ? (mcp.servers as Record) - : {}; - return Object.keys(servers).length; -} - -export function extractQuickSettingsSecurity(state: AppViewState): { - gatewayAuth: string; - execPolicy: string; - deviceAuth: boolean; - browserEnabled: boolean; - toolProfile: string; -} { - const config = state.configForm ?? state.configSnapshot?.config; - if (!config || typeof config !== "object") { - return { - gatewayAuth: "unknown", - execPolicy: "unknown", - deviceAuth: false, - browserEnabled: true, - toolProfile: "full", - }; - } - const cfg = config; - const gateway = - "gateway" in cfg && cfg.gateway && typeof cfg.gateway === "object" - ? (cfg.gateway as Record) - : null; - const auth = - gateway && "auth" in gateway && gateway.auth && typeof gateway.auth === "object" - ? (gateway.auth as Record) - : null; - let gatewayAuth = "unknown"; - if (auth) { - const mode = typeof auth.mode === "string" ? auth.mode.trim() : ""; - if (mode) { - gatewayAuth = mode; - } else if (auth.password) { - gatewayAuth = "password"; - } else if (auth.token) { - gatewayAuth = "token"; - } else if (auth.trustedProxy) { - gatewayAuth = "trusted-proxy"; - } else { - gatewayAuth = "none"; - } - } - let execPolicy = "allowlist"; - let toolProfile = "full"; - const tools = cfg.tools; - if (tools && typeof tools === "object") { - const profile = (tools as Record).profile; - if (typeof profile === "string") { - const trimmedProfile = profile.trim(); - if (trimmedProfile) { - toolProfile = trimmedProfile; - } - } - const exec = (tools as Record).exec; - if (exec && typeof exec === "object") { - const security = (exec as Record).security; - if (typeof security === "string") { - const trimmedSecurity = security.trim(); - if (trimmedSecurity) { - execPolicy = trimmedSecurity; - } - } - } - } - let browserEnabled = true; - const browser = - "browser" in cfg && cfg.browser && typeof cfg.browser === "object" - ? (cfg.browser as Record) - : null; - if (browser && typeof browser.enabled === "boolean") { - browserEnabled = browser.enabled; - } - let deviceAuth = true; - if (gateway) { - const controlUi = - "controlUi" in gateway && gateway.controlUi && typeof gateway.controlUi === "object" - ? (gateway.controlUi as Record) - : null; - if (controlUi?.dangerouslyDisableDeviceAuth === true) { - deviceAuth = false; - } - } - return { gatewayAuth, execPolicy, deviceAuth, browserEnabled, toolProfile }; -} - -function resolveQuickSettingsSessionRow(state: AppViewState) { - return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); -} - -function renderCronQuickCreateForTab( - state: AppViewState, - requestHostUpdate: (() => void) | undefined, -) { - return renderCronQuickCreate({ - open: state.cronQuickCreateOpen, - step: state.cronQuickCreateStep, - draft: state.cronQuickCreateDraft ?? createDefaultDraft(), - onDraftChange: (patch) => { - state.cronQuickCreateDraft = { - ...(state.cronQuickCreateDraft ?? createDefaultDraft()), - ...patch, - }; - requestHostUpdate?.(); - }, - onStepChange: (step) => { - state.cronQuickCreateStep = step; - requestHostUpdate?.(); - }, - onCreate: () => { - const draft = state.cronQuickCreateDraft ?? createDefaultDraft(); - const formPatch = draftToCronFormPatch(draft); - state.cronEditingJobId = null; - state.cronForm = { ...DEFAULT_CRON_FORM, ...formPatch } as typeof state.cronForm; - requestHostUpdate?.(); - void (async () => { - const saved = await addCronJob(state); - if (!saved) { - requestHostUpdate?.(); - return; - } - state.cronQuickCreateOpen = false; - state.cronQuickCreateStep = "what"; - state.cronQuickCreateDraft = null; - requestHostUpdate?.(); - })(); - }, - onAdvancedCreate: () => { - const draft = state.cronQuickCreateDraft ?? createDefaultDraft(); - const formPatch = draftToCronFormPatch(draft); - state.cronEditingJobId = null; - state.cronForm = normalizeCronFormState({ - ...DEFAULT_CRON_FORM, - ...formPatch, - } as typeof state.cronForm); - state.cronFieldErrors = validateCronForm(state.cronForm); - state.cronQuickCreateOpen = false; - state.cronQuickCreateStep = "what"; - state.cronQuickCreateDraft = null; - state.cronFormCollapsed = false; - requestHostUpdate?.(); - }, - onCancel: () => { - state.cronQuickCreateOpen = false; - state.cronQuickCreateStep = "what"; - state.cronQuickCreateDraft = null; - requestHostUpdate?.(); - }, - }); -} - -function languageForWorkspaceFile(name: string): string { - const extension = name.match(/\.([a-z0-9_-]+)$/i)?.[1]?.toLowerCase() ?? ""; - if (extension === "json") { - return "json"; - } - if (extension === "mdx") { - return "mdx"; - } - if (extension === "tsx" || extension === "jsx") { - return extension; - } - if (extension === "ts" || extension === "js" || extension === "css" || extension === "html") { - return extension; - } - if (extension === "yaml" || extension === "yml") { - return "yaml"; - } - if (extension === "toml" || extension === "xml" || extension === "svg") { - return extension; - } - return extension; -} - -function buildWorkspaceFileSidebarContent(name: string, content: string): string { - if (/\.(?:md|markdown|mdx)$/i.test(name)) { - return content; - } - const language = languageForWorkspaceFile(name); - return `# ${name}\n\n\`\`\`${language}\n${content}\n\`\`\``; -} - -function buildArtifactSidebarContent(params: { - data?: string; - encoding?: string; - mimeType: string; - title: string; - url?: string; -}): SidebarContent { - const { data, encoding, mimeType, title, url } = params; - if (encoding === "base64" && data && mimeType.startsWith("image/")) { - return { - kind: "image", - title, - src: `data:${mimeType};base64,${data}`, - mimeType, - rawText: url ?? null, - }; - } - if (encoding === "base64" && data && mimeType === "application/json") { - const decoded = globalThis.atob(data); - return { - kind: "markdown", - content: `# ${title}\n\n\`\`\`json\n${decoded}\n\`\`\``, - rawText: decoded, - }; - } - if (encoding === "base64" && data && mimeType.startsWith("text/")) { - const decoded = globalThis.atob(data); - return { - kind: "markdown", - content: `# ${title}\n\n\`\`\`\n${decoded}\n\`\`\``, - rawText: decoded, - }; - } - if (url) { - const content = `# ${title}\n\n[Open artifact](${url})`; - return { kind: "markdown", content, rawText: content }; - } - const content = `# ${title}\n\nArtifact download is not previewable in the sidebar.`; - return { kind: "markdown", content, rawText: content }; -} - -export function renderApp(state: AppViewState) { - const updatableState = state as AppViewState & { requestUpdate?: () => void }; - const requestHostUpdate = - typeof updatableState.requestUpdate === "function" - ? () => updatableState.requestUpdate?.() - : undefined; - pendingUpdate = requestHostUpdate; - - // Gate: require successful gateway connection before showing the dashboard. - // The gateway URL confirmation overlay is always rendered so URL-param flows still work. - if (!state.connected) { - return html` ${renderLoginGate(state)} ${renderGatewayUrlConfirmation(state)} `; - } - - const presenceCount = state.presenceEntries.length; - const sessionsCount = state.sessionsResult?.count ?? null; - const cronNext = state.cronStatus?.nextWakeAtMs ?? null; - const chatSessionArchived = isCurrentChatSessionArchived(state); - const chatDisabledReason = !state.connected - ? t("chat.disconnected") - : chatSessionArchived - ? t("chat.archivedSessionDisabled") - : null; - const isChat = state.tab === "chat"; - const headerError = !isChat && state.lastError !== state.chatError ? state.lastError : null; - const chatViewError = state.lastError; - const chatHeaderHidden = isChat && (state.onboarding || state.chatHeaderControlsHidden); - const navDrawerOpen = state.navDrawerOpen && !state.onboarding; - const navCollapsed = state.settings.navCollapsed && !navDrawerOpen; - const dashboardHeaderContext = resolveDashboardHeaderContext(state); - const showThinking = state.onboarding ? false : state.settings.chatShowThinking; - const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls; - const activeAssistantAgentId = resolveSidebarSelectedAgentId(state); - const localAssistantAvatarOverride = - normalizeOptionalString( - loadLocalAssistantIdentity({ agentId: activeAssistantAgentId }).avatar, - ) ?? null; - const assistantAvatarUrl = resolveAssistantAvatarUrl(state); - const chatAssistantAvatarStatus = localAssistantAvatarOverride - ? "data" - : (state.chatAvatarStatus ?? state.assistantAvatarStatus ?? null); - const chatAssistantAvatarReason = localAssistantAvatarOverride - ? null - : (state.chatAvatarReason ?? state.assistantAvatarReason ?? null); - const chatAssistantAvatarMissing = - chatAssistantAvatarStatus === "none" && chatAssistantAvatarReason === "missing"; - const effectiveAssistantAvatar = - localAssistantAvatarOverride ?? (chatAssistantAvatarMissing ? null : state.assistantAvatar); - const chatAvatarUrl = - localAssistantAvatarOverride ?? - state.chatAvatarUrl ?? - (chatAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)); - const configAssistantAvatarStatus = localAssistantAvatarOverride - ? "data" - : (state.assistantAvatarStatus ?? state.chatAvatarStatus ?? null); - const configAssistantAvatarReason = localAssistantAvatarOverride - ? null - : (state.assistantAvatarReason ?? state.chatAvatarReason ?? null); - const configAssistantAvatarSource = - localAssistantAvatarOverride ?? state.assistantAvatarSource ?? state.chatAvatarSource ?? null; - const configAssistantAvatarMissing = - configAssistantAvatarStatus === "none" && configAssistantAvatarReason === "missing"; - const configAssistantAvatar = - localAssistantAvatarOverride ?? - (configAssistantAvatarMissing || configAssistantAvatarStatus === "local" - ? null - : state.assistantAvatar); - const configAssistantAvatarUrl = - localAssistantAvatarOverride ?? - (configAssistantAvatarStatus === "local" && state.assistantAgentId - ? buildAssistantAvatarRoute(state.basePath, state.assistantAgentId) - : (state.chatAvatarUrl ?? - (configAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)))); - const configValue = - state.configForm ?? (state.configSnapshot?.config as Record | null); - const configuredDreaming = resolveConfiguredDreaming(configValue); - const dreamingOn = state.dreamingStatus?.enabled ?? configuredDreaming.enabled; - const dreamingNextCycle = resolveDreamingNextCycle(state.dreamingStatus); - const dreamingAgentOptions = resolveChatAgentFilterOptions(state); - const dreamingSelectedAgentId = resolveChatAgentFilterId(state, state.sessionKey); - const syncDreamingSelectedAgent = () => { - state.selectedAgentId = dreamingSelectedAgentId; - }; - const dreamingLoading = state.dreamingStatusLoading || state.dreamingModeSaving; - const dreamingRefreshLoading = state.dreamingStatusLoading || state.dreamDiaryLoading; - const refreshDreaming = () => { - void (async () => { - syncDreamingSelectedAgent(); - await loadConfig(state); - await Promise.all([ - loadDreamingStatus(state), - loadDreamDiary(state), - loadWikiImportInsights(state), - loadWikiMemoryPalace(state), - ]); - })(); - }; - const openWikiPage = async (lookup: string) => { - if (!state.client || !state.connected) { - return null; - } - const payload: { - title?: unknown; - path?: unknown; - content?: unknown; - updatedAt?: unknown; - totalLines?: unknown; - truncated?: unknown; - } | null = await state.client.request("wiki.get", { - lookup, - fromLine: 1, - lineCount: 5000, - }); - const title = - typeof payload?.title === "string" && payload.title.trim() ? payload.title.trim() : lookup; - const path = - typeof payload?.path === "string" && payload.path.trim() ? payload.path.trim() : lookup; - const content = - typeof payload?.content === "string" && payload.content.length > 0 - ? payload.content - : "No wiki content available."; - const updatedAt = - typeof payload?.updatedAt === "string" && payload.updatedAt.trim() - ? payload.updatedAt.trim() - : undefined; - const totalLines = - typeof payload?.totalLines === "number" && Number.isFinite(payload.totalLines) - ? Math.max(0, Math.floor(payload.totalLines)) - : undefined; - const truncated = payload?.truncated === true; - return { - title, - path, - content, - ...(totalLines !== undefined ? { totalLines } : {}), - ...(truncated ? { truncated } : {}), - ...(updatedAt ? { updatedAt } : {}), - }; - }; - const applyDreamingEnabled = (enabled: boolean) => { - if ( - state.dreamingModeSaving || - state.dreamingRestartConfirmLoading || - state.dreamingRestartConfirmOpen || - dreamingOn === enabled - ) { - return; - } - state.dreamingPendingEnabled = enabled; - state.dreamingRestartConfirmOpen = true; - state.dreamingStatusError = null; - }; - const cancelDreamingRestart = () => { - if (state.dreamingRestartConfirmLoading) { - return; - } - state.dreamingRestartConfirmOpen = false; - state.dreamingPendingEnabled = null; - state.dreamingStatusError = null; - }; - const confirmDreamingRestart = () => { - const enabled = state.dreamingPendingEnabled; - if (enabled == null || state.dreamingRestartConfirmLoading) { - return; - } - void (async () => { - state.dreamingRestartConfirmLoading = true; - state.dreamingStatusError = null; - try { - const updated = await updateDreamingEnabled(state, enabled); - if (!updated) { - if (!state.dreamingStatusError) { - state.dreamingStatusError = t("dreaming.restartConfirmation.failed"); - } - return; - } - await loadConfig(state); - await loadDreamingStatus(state); - state.dreamingRestartConfirmOpen = false; - state.dreamingPendingEnabled = null; - } finally { - state.dreamingRestartConfirmLoading = false; - } - })(); - }; - const basePath = normalizeBasePath(state.basePath ?? ""); - const resolveSelectedAgentId = () => - state.agentsSelectedId ?? - state.agentsList?.defaultId ?? - state.agentsList?.agents?.[0]?.id ?? - null; - const resolvedAgentId = resolveSelectedAgentId(); - const normalizedChatSessionKey = normalizeOptionalString(state.sessionKey)?.toLowerCase(); - const activeSessionAgentId = - normalizedChatSessionKey === "global" ? null : resolveAgentIdFromSessionKey(state.sessionKey); - const scopedChatAgentId = scopedAgentParamsForSession(state, state.sessionKey).agentId; - const chatFallbackAgentId = normalizeAgentId( - state.assistantAgentId ?? - state.agentsList?.defaultId ?? - state.agentsList?.agents?.[0]?.id ?? - "main", - ); - const resolveChatWorkspaceAgentId = () => { - const normalizedKey = normalizeOptionalString(state.sessionKey)?.toLowerCase(); - const activeAgentId = - normalizedKey === "global" ? null : resolveAgentIdFromSessionKey(state.sessionKey); - const scopedAgentId = scopedAgentParamsForSession(state, state.sessionKey).agentId; - return normalizedKey === "global" - ? (scopedAgentId ?? chatFallbackAgentId) - : (activeAgentId ?? scopedAgentId ?? chatFallbackAgentId); - }; - const chatAgentId = - normalizedChatSessionKey === "global" - ? (scopedChatAgentId ?? chatFallbackAgentId) - : (activeSessionAgentId ?? scopedChatAgentId ?? chatFallbackAgentId); - const toolsPanelUsesActiveSession = Boolean(resolvedAgentId && resolvedAgentId === chatAgentId); - const chatWorkspaceAgentId = resolveChatWorkspaceAgentId(); - const chatWorkspaceFiles = getChatWorkspaceFilesState( - state, - state.sessionKey, - chatWorkspaceAgentId, - ); - const currentChatWorkspaceFilesState = () => - getChatWorkspaceFilesState(state, state.sessionKey, resolveChatWorkspaceAgentId()); - const currentSessionWorkspaceKey = () => state.sessionKey; - const getCurrentConfigValue = () => - state.configForm ?? (state.configSnapshot?.config as Record | null); - const findAgentIndex = (agentId: string) => - findAgentConfigEntryIndex(getCurrentConfigValue(), agentId); - const ensureAgentIndex = (agentId: string) => ensureAgentConfigEntry(state, agentId); - const resolveAgentToolsPath = (agentId: string, ensure: boolean) => { - const index = ensure ? ensureAgentIndex(agentId) : findAgentIndex(agentId); - return index >= 0 ? (["agents", "list", index, "tools"] as const) : null; - }; - const resolveAgentModelFormEntry = (index: number) => { - const list = (getCurrentConfigValue() as { agents?: { list?: unknown[] } } | null)?.agents - ?.list; - const existing = Array.isArray(list) - ? (list[index] as { model?: unknown } | undefined)?.model - : undefined; - return { - basePath: ["agents", "list", index, "model"] as Array, - existing, - }; - }; - const cronAgentSuggestions = sortLocaleStrings( - new Set( - [ - ...(state.agentsList?.agents?.map((entry) => entry.id.trim()) ?? []), - ...state.cronJobs - .map((job) => (typeof job.agentId === "string" ? job.agentId.trim() : "")) - .filter(Boolean), - ].filter(Boolean), - ), - ); - const cronModelSuggestions = sortLocaleStrings( - new Set( - [ - ...state.cronModelSuggestions, - ...resolveConfiguredCronModelSuggestions(configValue), - ...state.cronJobs - .map((job) => { - const payload = getCronJobPayload(job); - if (payload?.kind !== "agentTurn" || typeof payload.model !== "string") { - return ""; - } - return payload.model.trim(); - }) - .filter(Boolean), - ].filter(Boolean), - ), - ); - const visibleCronJobs = getVisibleCronJobs(state); - const selectedDeliveryChannel = - state.cronForm.deliveryChannel && state.cronForm.deliveryChannel.trim() - ? state.cronForm.deliveryChannel.trim() - : "last"; - const jobToSuggestions = state.cronJobs - .map((job) => normalizeSuggestionValue(job.delivery?.to)) - .filter(Boolean); - const accountToSuggestions = ( - selectedDeliveryChannel === "last" - ? Object.values(state.channelsSnapshot?.channelAccounts ?? {}).flat() - : (state.channelsSnapshot?.channelAccounts?.[selectedDeliveryChannel] ?? []) - ) - .flatMap((account) => [ - normalizeSuggestionValue(account.accountId), - normalizeSuggestionValue(account.name), - ]) - .filter(Boolean); - const rawDeliveryToSuggestions = uniquePreserveOrder([ - ...jobToSuggestions, - ...accountToSuggestions, - ]); - const accountSuggestions = uniquePreserveOrder(accountToSuggestions); - const deliveryToSuggestions = - state.cronForm.deliveryMode === "webhook" - ? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value)) - : rawDeliveryToSuggestions; - const commonConfigProps = { - raw: state.configRaw, - originalRaw: state.configRawOriginal, - valid: state.configValid, - issues: state.configIssues, - loading: state.configLoading, - saving: state.configSaving, - applying: state.configApplying, - updating: state.updateRunning, - connected: state.connected, - schema: state.configSchema, - schemaLoading: state.configSchemaLoading, - uiHints: state.configUiHints, - formValue: state.configForm, - originalValue: state.configFormOriginal, - onRawChange: (next: string) => { - updateConfigRawValue(state, next); - }, - onRequestUpdate: requestHostUpdate, - onFormPatch: (path: Array, value: unknown) => - updateConfigFormValue(state, path, value), - onReload: () => void loadConfig(state, { discardPendingChanges: true }), - onReset: () => resetConfigPendingChanges(state), - onSave: () => void saveConfig(state), - onApply: () => void applyConfig(state), - onUpdate: () => void runUpdate(state), - onOpenFile: () => void openConfigFile(state), - version: state.hello?.server?.version ?? "", - theme: state.theme, - themeMode: state.themeMode, - setTheme: (theme, context) => state.setTheme(theme, context), - setThemeMode: (mode, context) => state.setThemeMode(mode, context), - hasCustomTheme: Boolean(state.settings.customTheme), - customThemeLabel: state.settings.customTheme?.label ?? null, - customThemeSourceUrl: state.settings.customTheme?.sourceUrl ?? null, - customThemeImportUrl: state.customThemeImportUrl, - customThemeImportBusy: state.customThemeImportBusy, - customThemeImportMessage: state.customThemeImportMessage, - customThemeImportExpanded: state.customThemeImportExpanded, - customThemeImportFocusToken: state.customThemeImportFocusToken, - onCustomThemeImportUrlChange: (next) => state.setCustomThemeImportUrl(next), - onOpenCustomThemeImport: () => state.openCustomThemeImport(), - onImportCustomTheme: () => void state.importCustomTheme(), - onClearCustomTheme: () => state.clearCustomTheme(), - borderRadius: state.settings.borderRadius, - setBorderRadius: (value) => state.setBorderRadius(value), - textScale: state.settings.textScale ?? 100, - setTextScale: (value) => state.setTextScale(value), - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - configPath: state.configSnapshot?.path ?? null, - rawAvailable: - typeof state.configSnapshot?.raw === "string" || - Boolean(state.configSnapshot?.config) || - Boolean(state.configForm), - } satisfies Omit< - ConfigProps, - | "formMode" - | "searchQuery" - | "activeSection" - | "activeSubsection" - | "onFormModeChange" - | "onSearchChange" - | "onSectionChange" - | "onSubsectionChange" - | "showModeToggle" - | "navRootLabel" - | "includeSections" - | "excludeSections" - | "includeVirtualSections" - >; - const renderConfigTab = (overrides: ConfigTabOverrides) => { - const scopedDefaultSection = overrides.includeSections?.[0] ?? null; - const activeSection = overrides.activeSection ?? scopedDefaultSection; - const showRootTab = overrides.showRootTab ?? !overrides.includeSections?.length; - return renderMeasured( - state, - "config", - { - tab: state.tab, - formMode: overrides.formMode, - activeSection, - activeSubsection: overrides.activeSubsection, - schemaSectionCount: countScopedTopLevelSchemaProperties( - commonConfigProps.schema, - overrides.includeSections, - overrides.excludeSections, - ), - hasSearch: Boolean(overrides.searchQuery?.trim()), - }, - () => - renderConfig({ - ...commonConfigProps, - includeVirtualSections: false, - ...overrides, - activeSection, - showRootTab, - }), - ); - }; - const configSelection = normalizeMainConfigSelection( - state.configActiveSection, - state.configActiveSubsection, - ); - const communicationsSelection = normalizeScopedConfigSelection( - state.communicationsActiveSection, - state.communicationsActiveSubsection, - COMMUNICATION_SECTION_KEYS, - ); - const appearanceSelection = normalizeScopedConfigSelection( - state.appearanceActiveSection, - state.appearanceActiveSubsection, - APPEARANCE_SECTION_KEYS, - ); - const automationSelection = normalizeScopedConfigSelection( - state.automationActiveSection, - state.automationActiveSubsection, - AUTOMATION_SECTION_KEYS, - ); - const infrastructureSelection = normalizeScopedConfigSelection( - state.infrastructureActiveSection, - state.infrastructureActiveSubsection, - INFRASTRUCTURE_SECTION_KEYS, - ); - const aiAgentsSelection = normalizeScopedConfigSelection( - state.aiAgentsActiveSection, - state.aiAgentsActiveSubsection, - AI_AGENTS_SECTION_KEYS, - ); - const renderConfigTabForActiveTab = () => { - switch (state.tab) { - case "config": { - // Quick Settings mode — opinionated card layout - if (state.configSettingsMode === "quick") { - const configObj = state.configForm ?? state.configSnapshot?.config ?? {}; - const assistantAvatarOverride = - localAssistantAvatarOverride ?? resolveAssistantAvatarOverride(configObj); - const agentsDefaults = ((configObj.agents as Record | undefined) - ?.defaults ?? {}) as Record; - const activeSession = resolveQuickSettingsSessionRow(state); - const currentModel = - typeof activeSession?.model === "string" - ? activeSession.model - : typeof agentsDefaults.model === "string" - ? agentsDefaults.model - : "default"; - const thinkingLevel = - typeof activeSession?.thinkingLevel === "string" - ? activeSession.thinkingLevel - : typeof agentsDefaults.thinkingLevel === "string" - ? agentsDefaults.thinkingLevel - : "off"; - const resolvedFastMode = - activeSession?.effectiveFastMode ?? activeSession?.fastMode ?? agentsDefaults.fastMode; - const fastMode = - resolvedFastMode === "auto" || typeof resolvedFastMode === "boolean" - ? resolvedFastMode - : false; - return renderQuickSettings({ - currentModel, - thinkingLevel, - fastMode, - onModelChange: () => { - state.configSettingsMode = "advanced"; - state.aiAgentsActiveSection = "models"; - state.setTab("aiAgents"); - }, - onThinkingChange: (level) => { - void patchSession(state, state.sessionKey, { thinkingLevel: level }).then(() => - requestHostUpdate?.(), - ); - }, - onFastModeChange: (mode) => { - void patchSession(state, state.sessionKey, { fastMode: mode }).then(() => - requestHostUpdate?.(), - ); - }, - channels: extractQuickSettingsChannels(state), - onChannelConfigure: () => { - state.setTab("channels"); - }, - automation: { - cronJobCount: state.cronJobs?.length ?? 0, - skillCount: state.skillsReport?.skills?.length ?? 0, - mcpServerCount: extractMcpServerCount(state), - }, - onManageCron: () => { - state.setTab("cron"); - }, - onBrowseSkills: () => { - state.setTab("skills"); - }, - onConfigureMcp: () => { - state.setTab("mcp"); - }, - security: extractQuickSettingsSecurity(state), - onSecurityConfigure: () => { - state.configSettingsMode = "advanced"; - state.configActiveSection = "auth"; - requestHostUpdate?.(); - }, - onBrowserEnabledToggle: (enabled) => { - updateConfigFormValue(state, ["browser", "enabled"], enabled); - requestHostUpdate?.(); - }, - onToolProfileChange: (profile) => { - updateConfigFormValue(state, ["tools", "profile"], profile); - requestHostUpdate?.(); - }, - theme: state.theme, - themeMode: state.themeMode, - hasCustomTheme: Boolean(state.settings.customTheme), - customThemeLabel: state.settings.customTheme?.label ?? null, - borderRadius: state.settings.borderRadius, - textScale: state.settings.textScale ?? 100, - setTheme: (theme, context) => state.setTheme(theme, context), - onOpenCustomThemeImport: () => { - state.setTab("appearance"); - state.appearanceFormMode = "form"; - state.appearanceSearchQuery = ""; - state.appearanceActiveSection = "__appearance__"; - state.appearanceActiveSubsection = null; - state.openCustomThemeImport(); - requestHostUpdate?.(); - }, - setThemeMode: (mode, context) => state.setThemeMode(mode, context), - setBorderRadius: (value) => state.setBorderRadius(value), - setTextScale: (value) => state.setTextScale(value), - userAvatar: state.userAvatar ?? null, - onUserAvatarChange: (avatar) => state.applyLocalUserIdentity?.({ avatar }), - assistantAvatar: configAssistantAvatar, - assistantAvatarUrl: configAssistantAvatarUrl, - assistantAvatarSource: configAssistantAvatarSource, - assistantAvatarStatus: configAssistantAvatarStatus, - assistantAvatarReason: configAssistantAvatarReason, - assistantAvatarOverride, - assistantAvatarUploadBusy: state.assistantAvatarUploadBusy, - assistantAvatarUploadError: state.assistantAvatarUploadError, - onAssistantAvatarOverrideChange: (dataUrl) => { - setAssistantAvatarOverride(state, dataUrl, activeAssistantAgentId); - state.chatAvatarUrl = dataUrl; - state.chatAvatarSource = dataUrl; - state.chatAvatarStatus = "data"; - state.chatAvatarReason = null; - state.assistantAvatarUploadError = null; - requestHostUpdate?.(); - }, - onAssistantAvatarClearOverride: () => { - setAssistantAvatarOverride(state, null, activeAssistantAgentId); - state.chatAvatarUrl = null; - state.chatAvatarSource = null; - state.chatAvatarStatus = null; - state.chatAvatarReason = null; - state.assistantAvatarUploadError = null; - const identitySessionKey = buildAgentMainSessionKey({ - agentId: activeAssistantAgentId, - }); - void state - .loadAssistantIdentity?.({ - sessionKey: identitySessionKey, - expectedSessionKey: state.sessionKey, - }) - .finally(() => requestHostUpdate?.()); - requestHostUpdate?.(); - }, - basePath: state.basePath ?? "", - configObject: configObj, - savedConfigObject: - (state.configSnapshot?.config as Record | null) ?? {}, - configDirty: state.configFormDirty, - configSaving: state.configSaving, - configApplying: state.configApplying, - configReady: Boolean(state.configSnapshot?.hash), - onSelectPreset: (presetId) => { - const preset = getPresetById(presetId); - if (!preset) { - return; - } - stageConfigPreset(state, preset.patch); - requestHostUpdate?.(); - }, - onResetConfig: () => resetConfigPendingChanges(state), - onSaveConfig: () => void saveConfig(state), - onApplyConfig: () => void applyConfig(state), - onAdvancedSettings: () => { - state.configSettingsMode = "advanced"; - requestHostUpdate?.(); - }, - connected: state.connected, - gatewayUrl: state.settings.gatewayUrl, - assistantName: state.assistantName, - version: state.hello?.server?.version ?? "", - }); - } - // Advanced mode — full config form with accordion groups - return renderConfigTab({ - formMode: state.configFormMode, - searchQuery: state.configSearchQuery, - activeSection: configSelection.activeSection, - activeSubsection: configSelection.activeSubsection, - onFormModeChange: (mode) => (state.configFormMode = mode), - onSearchChange: (query) => (state.configSearchQuery = query), - onSectionChange: (section) => { - state.configActiveSection = section; - state.configActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.configActiveSubsection = section), - showModeToggle: true, - settingsLayout: "accordion", - onBackToQuick: () => { - state.configSettingsMode = "quick"; - requestHostUpdate?.(); - }, - excludeSections: [ - ...COMMUNICATION_SECTION_KEYS, - ...AUTOMATION_SECTION_KEYS, - ...INFRASTRUCTURE_SECTION_KEYS, - ...AI_AGENTS_SECTION_KEYS, - "ui", - "wizard", - ], - }); - } - case "channels": - return renderLazyView(lazyChannels, (m) => - m.renderChannels({ - connected: state.connected, - loading: state.channelsLoading, - snapshot: state.channelsSnapshot, - lastError: state.channelsError, - lastSuccessAt: state.channelsLastSuccess, - whatsappMessage: state.whatsappLoginMessage, - whatsappQrDataUrl: state.whatsappLoginQrDataUrl, - whatsappConnected: state.whatsappLoginConnected, - whatsappBusy: state.whatsappBusy, - configSchema: state.configSchema, - configSchemaLoading: state.configSchemaLoading, - configForm: state.configForm, - configUiHints: state.configUiHints, - configSaving: state.configSaving, - configFormDirty: state.configFormDirty, - nostrProfileFormState: state.nostrProfileFormState, - nostrProfileAccountId: state.nostrProfileAccountId, - onRefresh: (probe) => void loadChannels(state, probe), - onWhatsAppStart: (force) => void state.handleWhatsAppStart(force), - onWhatsAppWait: () => void state.handleWhatsAppWait(), - onWhatsAppLogout: () => void state.handleWhatsAppLogout(), - onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), - onConfigSave: () => void state.handleChannelConfigSave(), - onConfigReload: () => void state.handleChannelConfigReload(), - onNostrProfileEdit: (accountId, profile) => - state.handleNostrProfileEdit(accountId, profile), - onNostrProfileCancel: () => state.handleNostrProfileCancel(), - onNostrProfileFieldChange: (field, value) => - state.handleNostrProfileFieldChange(field, value), - onNostrProfileSave: () => void state.handleNostrProfileSave(), - onNostrProfileImport: () => void state.handleNostrProfileImport(), - onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), - }), - ); - case "communications": - return renderConfigTab({ - formMode: state.communicationsFormMode, - searchQuery: state.communicationsSearchQuery, - activeSection: communicationsSelection.activeSection, - activeSubsection: communicationsSelection.activeSubsection, - onFormModeChange: (mode) => (state.communicationsFormMode = mode), - onSearchChange: (query) => (state.communicationsSearchQuery = query), - onSectionChange: (section) => { - state.communicationsActiveSection = section; - state.communicationsActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.communicationsActiveSubsection = section), - navRootLabel: "Communication", - includeSections: [...COMMUNICATION_SECTION_KEYS], - includeVirtualSections: true, - webPush: { - supported: state.webPushSupported, - permission: state.webPushPermission, - subscribed: state.webPushSubscribed, - loading: state.webPushLoading, - }, - onWebPushSubscribe: () => void state.handleWebPushSubscribe(), - onWebPushUnsubscribe: () => void state.handleWebPushUnsubscribe(), - onWebPushTest: () => void state.handleWebPushTest(), - }); - case "appearance": - return renderConfigTab({ - formMode: state.appearanceFormMode, - searchQuery: state.appearanceSearchQuery, - activeSection: appearanceSelection.activeSection, - activeSubsection: appearanceSelection.activeSubsection, - onFormModeChange: (mode) => (state.appearanceFormMode = mode), - onSearchChange: (query) => (state.appearanceSearchQuery = query), - onSectionChange: (section) => { - state.appearanceActiveSection = section; - state.appearanceActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.appearanceActiveSubsection = section), - navRootLabel: t("tabs.appearance"), - includeSections: [...APPEARANCE_SECTION_KEYS], - includeVirtualSections: true, - }); - case "automation": - return renderConfigTab({ - formMode: state.automationFormMode, - searchQuery: state.automationSearchQuery, - activeSection: automationSelection.activeSection, - activeSubsection: automationSelection.activeSubsection, - onFormModeChange: (mode) => (state.automationFormMode = mode), - onSearchChange: (query) => (state.automationSearchQuery = query), - onSectionChange: (section) => { - state.automationActiveSection = section; - state.automationActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.automationActiveSubsection = section), - navRootLabel: "Automation", - includeSections: [...AUTOMATION_SECTION_KEYS], - }); - case "mcp": - return renderMcp({ - configObject: - state.configForm ?? - ((state.configSnapshot?.config as Record | null) || {}), - configDirty: state.configFormDirty, - configSaving: state.configSaving, - configApplying: state.configApplying, - connected: state.connected, - onSaveConfig: () => void saveConfig(state), - onApplyConfig: () => void applyConfig(state), - onServerEnabledChange: (name, enabled) => { - updateMcpServerEnabled(state, name, enabled); - requestHostUpdate?.(); - }, - editor: renderConfigTab({ - formMode: "form", - searchQuery: "", - activeSection: "mcp", - activeSubsection: null, - onFormModeChange: () => undefined, - onSearchChange: () => undefined, - onSectionChange: () => { - state.infrastructureActiveSection = "mcp"; - state.infrastructureActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.infrastructureActiveSubsection = section), - navRootLabel: "MCP", - includeSections: ["mcp"], - }), - }); - case "infrastructure": - return renderConfigTab({ - formMode: state.infrastructureFormMode, - searchQuery: state.infrastructureSearchQuery, - activeSection: infrastructureSelection.activeSection, - activeSubsection: infrastructureSelection.activeSubsection, - onFormModeChange: (mode) => (state.infrastructureFormMode = mode), - onSearchChange: (query) => (state.infrastructureSearchQuery = query), - onSectionChange: (section) => { - state.infrastructureActiveSection = section; - state.infrastructureActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.infrastructureActiveSubsection = section), - navRootLabel: "Infrastructure", - includeSections: [...INFRASTRUCTURE_SECTION_KEYS], - }); - case "aiAgents": - return renderConfigTab({ - formMode: state.aiAgentsFormMode, - searchQuery: state.aiAgentsSearchQuery, - activeSection: aiAgentsSelection.activeSection, - activeSubsection: aiAgentsSelection.activeSubsection, - onFormModeChange: (mode) => (state.aiAgentsFormMode = mode), - onSearchChange: (query) => (state.aiAgentsSearchQuery = query), - onSectionChange: (section) => { - state.aiAgentsActiveSection = section; - state.aiAgentsActiveSubsection = null; - }, - onSubsectionChange: (section) => (state.aiAgentsActiveSubsection = section), - navRootLabel: "AI & Agents", - includeSections: [...AI_AGENTS_SECTION_KEYS], - }); - default: - return nothing; - } - }; - const loadAgentPanelDataForSelectedAgent = (agentId: string | null) => { - if (!agentId) { - return; - } - switch (state.agentsPanel) { - case "files": - void loadAgentFiles(state, agentId); - return; - case "skills": - void loadAgentSkills(state, agentId); - return; - case "tools": - void loadToolsCatalog(state, agentId); - void refreshVisibleToolsEffectiveForCurrentSession(state); - case "overview": - case "channels": - case "cron": - } - }; - const refreshAgentsPanelSupplementalData = (panel: AppViewState["agentsPanel"]) => { - if (panel === "channels") { - void loadChannels(state, false); - return; - } - if (panel === "cron") { - void state.loadCron(); - } - }; - const resetAgentFilesState = (clearLoading = false) => { - state.agentFilesList = null; - state.agentFilesError = null; - state.agentFileActive = null; - state.agentFileContents = {}; - state.agentFileDrafts = {}; - if (clearLoading) { - state.agentFilesLoading = false; - } - }; - const resetAgentSelectionPanelState = () => { - resetAgentFilesState(true); - state.agentSkillsReport = null; - state.agentSkillsError = null; - state.agentSkillsAgentId = null; - state.toolsCatalogResult = null; - state.toolsCatalogError = null; - state.toolsCatalogLoading = false; - resetToolsEffectiveState(state); - }; - if ( - isChat && - !chatWorkspaceFiles.collapsed && - state.connected && - state.agentsList && - !chatWorkspaceFiles.loading && - !chatWorkspaceFiles.error && - chatWorkspaceFiles.list?.sessionKey !== state.sessionKey - ) { - loadChatWorkspaceFiles(); - } - const toggleChatWorkspaceFilesCollapsed = () => { - chatWorkspaceFiles.collapsed = !chatWorkspaceFiles.collapsed; - if (!chatWorkspaceFiles.collapsed && chatWorkspaceFiles.list?.sessionKey !== state.sessionKey) { - loadChatWorkspaceFiles(); - } - requestHostUpdate?.(); - }; - const refreshChatWorkspaceFiles = () => { - loadChatWorkspaceFiles({ force: true }); - }; - const browseChatWorkspacePath = (path: string) => { - if (chatWorkspaceFiles.browserSearchTimer) { - globalThis.clearTimeout(chatWorkspaceFiles.browserSearchTimer); - chatWorkspaceFiles.browserSearchTimer = null; - } - chatWorkspaceFiles.browserPath = path; - chatWorkspaceFiles.browserSearch = ""; - loadChatWorkspaceFiles({ force: true }); - }; - const searchChatWorkspaceFiles = (search: string) => { - chatWorkspaceFiles.browserSearch = search; - if (chatWorkspaceFiles.browserSearchTimer) { - globalThis.clearTimeout(chatWorkspaceFiles.browserSearchTimer); - } - chatWorkspaceFiles.browserSearchTimer = globalThis.setTimeout(() => { - chatWorkspaceFiles.browserSearchTimer = null; - loadChatWorkspaceFiles({ force: true }); - }, 160); - }; - const copyChatWorkspacePath = (filePath: string) => { - void copyToClipboard(filePath); - }; - function loadChatWorkspaceFiles(opts?: { force?: boolean }) { - if (!state.client || !state.connected) { - return; - } - if (chatWorkspaceFiles.loading) { - if (opts?.force) { - chatWorkspaceFiles.pendingReload = true; - } - return; - } - const requestId = chatWorkspaceFiles.requestId + 1; - chatWorkspaceFiles.requestId = requestId; - chatWorkspaceFiles.loading = true; - chatWorkspaceFiles.error = null; - if (opts?.force) { - chatWorkspaceFiles.list = null; - } - const requestState = chatWorkspaceFiles; - requestState.pendingReload = false; - const sessionKey = state.sessionKey; - const agentId = chatWorkspaceFiles.agentId; - void (async () => { - try { - const res = await state.client?.request( - "sessions.files.list", - { - sessionKey, - path: requestState.browserSearch ? "" : requestState.browserPath, - search: requestState.browserSearch, - ...(agentId ? { agentId } : {}), - }, - ); - const artifacts = await state.client?.request<{ - artifacts?: SessionWorkspaceListResult["artifacts"]; - } | null>("artifacts.list", { - sessionKey, - ...(agentId ? { agentId } : {}), - }); - const current = currentChatWorkspaceFilesState(); - if (current !== requestState || current.requestId !== requestId) { - return; - } - const files = res?.files ?? []; - const artifactItems = artifacts?.artifacts ?? []; - current.list = { - sessionKey, - ...(res?.root ? { root: res.root } : {}), - files, - ...(res?.browser ? { browser: res.browser } : {}), - artifacts: artifactItems, - }; - if ( - current.activeId && - !files.some((file) => `file:${file.path}` === current.activeId) && - !artifactItems.some((artifact) => `artifact:${artifact.id}` === current.activeId) - ) { - current.activeId = null; - } - } catch (err) { - const current = currentChatWorkspaceFilesState(); - if (current === requestState && current.requestId === requestId) { - current.error = String(err); - } - } finally { - const current = currentChatWorkspaceFilesState(); - if (current === requestState && current.requestId === requestId) { - current.loading = false; - const shouldReload = current.pendingReload; - current.pendingReload = false; - if (shouldReload) { - loadChatWorkspaceFiles({ force: true }); - } - } - requestHostUpdate?.(); - } - })(); - } - const startChatWorkspaceFileOpenRequest = (itemId: string) => { - chatWorkspaceFiles.activeId = itemId; - const previousRequest = chatWorkspaceFileOpenRequests.get(state); - const openRequest = { - agentId: chatWorkspaceFiles.agentId, - id: (previousRequest?.id ?? 0) + 1, - itemId, - sessionKey: currentSessionWorkspaceKey(), - }; - chatWorkspaceFileOpenRequests.set(state, openRequest); - const isCurrentOpenRequest = () => { - const currentRequest = chatWorkspaceFileOpenRequests.get(state); - const currentFiles = currentChatWorkspaceFilesState(); - return ( - currentRequest?.id === openRequest.id && - currentRequest.agentId === resolveChatWorkspaceAgentId() && - currentRequest.itemId === itemId && - currentRequest.sessionKey === currentSessionWorkspaceKey() && - currentFiles?.agentId === openRequest.agentId && - currentFiles?.activeId === itemId - ); - }; - return { isCurrentOpenRequest, openRequest }; - }; - const openChatWorkspaceFile = (filePath: string) => { - const itemId = `file:${filePath}`; - const { isCurrentOpenRequest, openRequest } = startChatWorkspaceFileOpenRequest(itemId); - void (async () => { - if (!state.client || !state.connected) { - return; - } - chatWorkspaceFiles.error = null; - try { - const agentId = openRequest.agentId; - const res = await state.client.request( - "sessions.files.get", - { - sessionKey: openRequest.sessionKey, - path: filePath, - ...(agentId ? { agentId } : {}), - }, - ); - const file = res?.file; - if (!file || typeof file.content !== "string") { - if (isCurrentOpenRequest()) { - chatWorkspaceFiles.error = `Failed to load ${filePath}`; - requestHostUpdate?.(); - } - return; - } - const content = file.content; - if (!isCurrentOpenRequest()) { - return; - } - state.handleOpenSidebar({ - kind: "markdown", - content: buildWorkspaceFileSidebarContent(file.name || filePath, content), - rawText: content, - }); - } catch (err) { - if (isCurrentOpenRequest()) { - chatWorkspaceFiles.error = String(err); - } - } finally { - requestHostUpdate?.(); - } - })(); - }; - const openChatWorkspaceArtifact = (artifactId: string) => { - const itemId = `artifact:${artifactId}`; - const { isCurrentOpenRequest, openRequest } = startChatWorkspaceFileOpenRequest(itemId); - void (async () => { - if (!state.client || !state.connected) { - return; - } - chatWorkspaceFiles.error = null; - try { - const agentId = openRequest.agentId; - const res = await state.client.request( - "artifacts.download", - { - sessionKey: openRequest.sessionKey, - artifactId, - ...(agentId ? { agentId } : {}), - }, - ); - if (!res?.artifact) { - if (isCurrentOpenRequest()) { - chatWorkspaceFiles.error = `Failed to load artifact ${artifactId}`; - requestHostUpdate?.(); - } - return; - } - if (!isCurrentOpenRequest()) { - return; - } - const title = res.artifact.title; - const mimeType = res.artifact.mimeType ?? ""; - const preview = buildArtifactSidebarContent({ - data: res.data, - encoding: res.encoding, - mimeType, - title, - url: res.url, - }); - state.handleOpenSidebar(preview); - } catch (err) { - if (isCurrentOpenRequest()) { - chatWorkspaceFiles.error = String(err); - } - } finally { - requestHostUpdate?.(); - } - })(); - }; - - return html` - ${renderCommandPalette({ - open: state.paletteOpen, - query: state.paletteQuery, - activeIndex: state.paletteActiveIndex, - onOpen: () => { - void refreshChatCommands(state).finally(requestHostUpdate); - }, - onToggle: () => { - state.paletteOpen = !state.paletteOpen; - }, - onQueryChange: (q) => { - state.paletteQuery = q; - }, - onActiveIndexChange: (i) => { - state.paletteActiveIndex = i; - }, - onNavigate: (tab) => { - state.setTab(tab as import("./navigation.ts").Tab); - }, - onSlashCommand: (cmd) => { - state.setTab("chat" as import("./navigation.ts").Tab); - state.handleChatDraftChange(cmd.endsWith(" ") ? cmd : `${cmd} `); - }, - })} -
- -
-
- -
- ) => { - state.setTab(event.detail); - }} - > -
-
- - ${isTerminalAvailable(state) - ? html`` - : nothing} -
${renderTopbarThemeModeToggle(state)}
-
-
-
-
- -
-
- ${state.updateStatusBanner - ? html`` - : nothing} - ${state.updateAvailable && - state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion && - !isUpdateBannerDismissed(state.updateAvailable) - ? html`` - : nothing} - ${state.tab === "config" || isChat - ? nothing - : html`
-
-
${titleForTab(state.tab)}
-
${subtitleForTab(state.tab)}
-
-
- ${state.tab === "skillWorkshop" - ? renderSkillWorkshopHeaderControls(state) - : nothing} - ${state.tab === "dreams" - ? html` -
- - -
- ` - : nothing} - ${headerError ? html`
${headerError}
` : nothing} -
-
`} - ${state.tab === "overview" - ? renderOverview({ - connected: state.connected, - hello: state.hello, - settings: state.settings, - password: state.password, - lastError: state.lastError, - lastErrorCode: state.lastErrorCode, - presenceCount, - sessionsCount, - cronEnabled: state.cronStatus?.enabled ?? null, - cronNext, - lastChannelsRefresh: state.channelsLastSuccess, - warnQueryToken, - modelAuthStatus: state.modelAuthStatusResult, - usageResult: state.usageResult, - sessionsResult: state.sessionsResult, - skillsReport: state.skillsReport, - cronJobs: state.cronJobs, - cronStatus: state.cronStatus, - attentionItems: state.attentionItems, - eventLog: state.eventLog, - overviewLogLines: state.overviewLogLines, - showGatewayToken: state.overviewShowGatewayToken, - showGatewayPassword: state.overviewShowGatewayPassword, - onSettingsChange: (next) => state.applySettings(next), - onPasswordChange: (next) => (state.password = next), - onSessionKeyChange: (next) => { - switchChatSession(state, next); - }, - onToggleGatewayTokenVisibility: () => { - state.overviewShowGatewayToken = !state.overviewShowGatewayToken; - }, - onToggleGatewayPasswordVisibility: () => { - state.overviewShowGatewayPassword = !state.overviewShowGatewayPassword; - }, - onConnect: () => state.connect(), - onRefresh: () => void state.loadOverview({ refresh: true }), - onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab), - onRefreshLogs: () => void state.loadOverview({ refresh: true }), - }) - : nothing} - ${state.tab === "activity" - ? renderLazyView(lazyActivity, (m) => - m.renderActivity({ - entries: state.activityEntries, - filterText: state.activityFilterText, - statusFilters: state.activityStatusFilters, - toolFilter: state.activityToolFilter, - expandedIds: state.activityExpandedIds, - autoFollow: state.activityAutoFollow, - onFilterTextChange: (next) => (state.activityFilterText = next), - onToolFilterChange: (next) => (state.activityToolFilter = next), - onStatusToggle: (status, enabled) => { - state.activityStatusFilters = { - ...state.activityStatusFilters, - [status]: enabled, - }; - }, - onToggleAutoFollow: (next) => { - state.activityAutoFollow = next; - if (next) { - state.scheduleActivityScroll(true); - } - }, - onClear: () => { - state.activityEntries = []; - state.activityExpandedIds = new Set(); - state.activityAtBottom = true; - }, - onExpandAll: () => { - state.activityExpandedIds = new Set( - state.activityEntries.map((entry) => entry.id), - ); - }, - onCollapseAll: () => { - state.activityExpandedIds = new Set(); - }, - onEntryToggle: (id, open) => { - const next = new Set(state.activityExpandedIds); - if (open) { - next.add(id); - } else { - next.delete(id); - } - state.activityExpandedIds = next; - }, - onScroll: (event) => state.handleActivityScroll(event), - }), - ) - : nothing} - ${state.tab === "instances" - ? renderLazyView(lazyInstances, (m) => - m.renderInstances({ - loading: state.presenceLoading, - entries: state.presenceEntries, - lastError: state.presenceError, - statusMessage: state.presenceStatus, - onRefresh: () => void loadPresence(state), - }), - ) - : nothing} - ${state.tab === "sessions" - ? renderLazyView(lazySessions, (m) => { - const workboardState = getWorkboardState(state); - const workboardEnabled = isPluginEnabledInConfigSnapshot( - state.configSnapshot, - "workboard", - { - enabledByDefault: false, - }, - ); - const operatorCanWrite = hasOperatorWriteAccess( - (state.hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? - null, - ); - return m.renderSessions({ - loading: state.sessionsLoading, - result: state.sessionsResult, - error: state.sessionsError, - activeMinutes: state.sessionsFilterActive, - limit: state.sessionsFilterLimit, - includeGlobal: state.sessionsIncludeGlobal, - includeUnknown: state.sessionsIncludeUnknown, - showArchived: state.sessionsShowArchived, - mainKey: state.agentsList?.mainKey ?? "main", - filtersCollapsed: state.sessionsFiltersCollapsed, - basePath: state.basePath, - searchQuery: state.sessionsSearchQuery, - agentIdentityById: state.agentIdentityById, - sortColumn: state.sessionsSortColumn, - sortDir: state.sessionsSortDir, - page: state.sessionsPage, - pageSize: state.sessionsPageSize, - selectedKeys: state.sessionsSelectedKeys, - workboardSessionKeys: new Set( - workboardState.cards - .flatMap((card) => [card.sessionKey, card.execution?.sessionKey]) - .filter((key): key is string => typeof key === "string" && key.length > 0), - ), - workboardBusySessionKey: [...workboardState.capturingSessionKeys][0] ?? null, - expandedCheckpointKey: state.sessionsExpandedCheckpointKey, - checkpointItemsByKey: state.sessionsCheckpointItemsByKey, - checkpointLoadingKey: state.sessionsCheckpointLoadingKey, - checkpointBusyKey: state.sessionsCheckpointBusyKey, - checkpointErrorByKey: state.sessionsCheckpointErrorByKey, - onFiltersChange: (next) => { - state.sessionsFilterActive = next.activeMinutes; - state.sessionsFilterLimit = next.limit; - state.sessionsIncludeGlobal = next.includeGlobal; - state.sessionsIncludeUnknown = next.includeUnknown; - state.sessionsShowArchived = next.showArchived; - state.sessionsSelectedKeys = new Set(); - state.sessionsPage = 0; - void loadSessions(state, { - activeMinutes: parseSessionsFilterInteger(next.activeMinutes), - limit: parseSessionsFilterInteger(next.limit), - includeGlobal: next.includeGlobal, - includeUnknown: next.includeUnknown, - showArchived: next.showArchived, - }); - }, - onToggleFiltersCollapsed: () => { - state.sessionsFiltersCollapsed = !state.sessionsFiltersCollapsed; - }, - onClearFilters: () => { - state.sessionsFilterActive = ""; - state.sessionsFilterLimit = ""; - state.sessionsIncludeGlobal = true; - state.sessionsIncludeUnknown = true; - state.sessionsShowArchived = false; - state.sessionsSearchQuery = ""; - state.sessionsSelectedKeys = new Set(); - state.sessionsPage = 0; - void loadSessions(state, { - activeMinutes: 0, - limit: 0, - includeGlobal: true, - includeUnknown: true, - showArchived: false, - }); - }, - onSearchChange: (q) => { - state.sessionsSearchQuery = q; - state.sessionsPage = 0; - }, - onSortChange: (col, dir) => { - state.sessionsSortColumn = col; - state.sessionsSortDir = dir; - state.sessionsPage = 0; - }, - onPageChange: (p) => { - state.sessionsPage = p; - }, - onPageSizeChange: (s) => { - state.sessionsPageSize = s; - state.sessionsPage = 0; - }, - onRefresh: () => void loadSessions(state), - onPatch: (key, patch) => void patchSessionFromSessionsView(state, key, patch), - onToggleSelect: (key) => { - const next = new Set(state.sessionsSelectedKeys); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - state.sessionsSelectedKeys = next; - }, - onSelectPage: (keys) => { - const next = new Set(state.sessionsSelectedKeys); - for (const k of keys) { - next.add(k); - } - state.sessionsSelectedKeys = next; - }, - onDeselectPage: (keys) => { - const next = new Set(state.sessionsSelectedKeys); - for (const k of keys) { - next.delete(k); - } - state.sessionsSelectedKeys = next; - }, - onDeselectAll: () => { - state.sessionsSelectedKeys = new Set(); - }, - onDeleteSelected: runUiTask(async () => { - const keys = [...state.sessionsSelectedKeys]; - const deleted = await deleteSessionsAndRefresh(state, keys); - if (deleted.length > 0) { - const next = new Set(state.sessionsSelectedKeys); - for (const k of deleted) { - next.delete(k); - clearChatMessagesFromCache(state.chatMessagesBySession, state, { - sessionKey: k, - }); - } - state.sessionsSelectedKeys = next; - } - }), - onNavigateToChat: (sessionKey) => { - switchChatSession(state, sessionKey); - state.setTab("chat" as import("./navigation.ts").Tab); - }, - onAddToWorkboard: - workboardEnabled && operatorCanWrite - ? runUiTask(async (session) => { - await captureSessionToWorkboard({ - host: state, - client: state.client, - session, - requestUpdate: requestHostUpdate, - }); - state.setTab("workboard" as import("./navigation.ts").Tab); - }) - : undefined, - onToggleCheckpointDetails: (sessionKey) => - void toggleSessionCompactionCheckpoints(state, sessionKey), - onBranchFromCheckpoint: runUiTask(async (sessionKey, checkpointId) => { - const nextKey = await branchSessionFromCheckpoint( - state, - sessionKey, - checkpointId, - ); - if (nextKey) { - switchChatSession(state, nextKey); - state.setTab("chat" as import("./navigation.ts").Tab); - } - }), - onRestoreCheckpoint: (sessionKey, checkpointId) => - void restoreSessionFromCheckpoint(state, sessionKey, checkpointId), - }); - }) - : nothing} - ${state.tab === "workboard" - ? renderLazyView(lazyWorkboard, (m) => { - const auth = - (state.hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? - null; - return m.renderWorkboard({ - host: state, - client: state.client, - connected: state.connected, - canWrite: hasOperatorWriteAccess(auth), - canModelOverride: hasOperatorAdminAccess(auth), - pluginEnabled: state.configSnapshot - ? isPluginEnabledInConfigSnapshot(state.configSnapshot, "workboard", { - enabledByDefault: false, - }) - : null, - pluginEnablementError: - !state.configSnapshot && !state.configLoading ? state.lastError : null, - agentsList: state.agentsList, - sessions: state.sessionsResult?.sessions ?? [], - onOpenSession: (sessionKey) => { - switchChatSession(state, sessionKey); - state.setTab("chat" as import("./navigation.ts").Tab); - }, - onReloadConfig: () => void loadConfig(state, { discardPendingChanges: true }), - onRequestUpdate: requestHostUpdate, - }); - }) - : nothing} - ${renderUsageTab(state, lazyUsage)} - ${state.tab === "cron" ? renderCronQuickCreateForTab(state, requestHostUpdate) : nothing} - ${state.tab === "cron" - ? renderLazyView(lazyCron, (m) => - m.renderCron({ - basePath: state.basePath, - loading: state.cronLoading, - status: state.cronStatus, - jobs: visibleCronJobs, - jobsLoadingMore: state.cronJobsLoadingMore, - jobsTotal: state.cronJobsTotal, - jobsHasMore: state.cronJobsHasMore, - jobsQuery: state.cronJobsQuery, - jobsEnabledFilter: state.cronJobsEnabledFilter, - jobsScheduleKindFilter: state.cronJobsScheduleKindFilter, - jobsLastStatusFilter: state.cronJobsLastStatusFilter, - jobsSortBy: state.cronJobsSortBy, - jobsSortDir: state.cronJobsSortDir, - editingJobId: state.cronEditingJobId, - error: state.cronError, - busy: state.cronBusy, - form: state.cronForm, - cronFormCollapsed: state.cronFormCollapsed, - channels: state.channelsSnapshot?.channelMeta?.length - ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) - : (state.channelsSnapshot?.channelOrder ?? []), - channelLabels: state.channelsSnapshot?.channelLabels ?? {}, - channelMeta: state.channelsSnapshot?.channelMeta ?? [], - runsJobId: state.cronRunsJobId, - runs: state.cronRuns, - runsTotal: state.cronRunsTotal, - runsHasMore: state.cronRunsHasMore, - runsLoadingMore: state.cronRunsLoadingMore, - runsScope: state.cronRunsScope, - runsStatuses: state.cronRunsStatuses, - runsDeliveryStatuses: state.cronRunsDeliveryStatuses, - runsStatusFilter: state.cronRunsStatusFilter, - runsQuery: state.cronRunsQuery, - runsSortDir: state.cronRunsSortDir, - fieldErrors: state.cronFieldErrors, - canSubmit: !hasCronFormErrors(state.cronFieldErrors), - agentSuggestions: cronAgentSuggestions, - modelSuggestions: cronModelSuggestions, - thinkingSuggestions: CRON_THINKING_SUGGESTIONS, - timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS, - deliveryToSuggestions, - accountSuggestions, - onFormChange: (patch) => { - state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch }); - state.cronFieldErrors = validateCronForm(state.cronForm); - }, - onRefresh: () => void state.loadCron(), - onAdd: () => { - void (async () => { - const saved = await addCronJob(state); - if (saved) { - state.cronFormCollapsed = true; - } - requestHostUpdate?.(); - })(); - }, - onEdit: (job) => { - state.cronFormCollapsed = false; - startCronEdit(state, job); - }, - onClone: (job) => { - state.cronFormCollapsed = false; - startCronClone(state, job); - }, - onCancelEdit: () => { - cancelCronEdit(state); - state.cronFormCollapsed = true; - requestHostUpdate?.(); - }, - onToggleFormCollapsed: (collapsed) => { - state.cronFormCollapsed = collapsed; - requestHostUpdate?.(); - }, - onToggle: (job, enabled) => void toggleCronJob(state, job, enabled), - onRun: (job, mode) => void runCronJob(state, job, mode ?? "force"), - onRemove: (job) => void removeCronJob(state, job), - onQuickCreate: () => { - state.cronQuickCreateOpen = true; - state.cronQuickCreateStep = "what"; - state.cronQuickCreateDraft = createDefaultDraft(); - requestHostUpdate?.(); - }, - onLoadRuns: runUiTask(async (jobId) => { - updateCronRunsFilter(state, { cronRunsScope: "job" }); - await loadCronRuns(state, jobId); - }), - onLoadMoreJobs: () => - void loadCronJobsPage(state, { append: true, tableFilters: true }), - onJobsFiltersChange: runUiTask(async (patch) => { - updateCronJobsFilter(state, patch); - const shouldReload = - typeof patch.cronJobsQuery === "string" || - Boolean(patch.cronJobsEnabledFilter) || - Boolean(patch.cronJobsScheduleKindFilter) || - Boolean(patch.cronJobsLastStatusFilter) || - Boolean(patch.cronJobsSortBy) || - Boolean(patch.cronJobsSortDir); - if (shouldReload) { - await loadCronJobsPage(state, { append: false, tableFilters: true }); - } - }), - onJobsFiltersReset: runUiTask(async () => { - updateCronJobsFilter(state, { - cronJobsQuery: "", - cronJobsEnabledFilter: "all", - cronJobsScheduleKindFilter: "all", - cronJobsLastStatusFilter: "all", - cronJobsSortBy: "nextRunAtMs", - cronJobsSortDir: "asc", - }); - await loadCronJobsPage(state, { append: false, tableFilters: true }); - }), - onLoadMoreRuns: () => void loadMoreCronRuns(state), - onRunsFiltersChange: runUiTask(async (patch) => { - updateCronRunsFilter(state, patch); - if (state.cronRunsScope === "all") { - await loadCronRuns(state, null); - return; - } - await loadCronRuns(state, state.cronRunsJobId); - }), - onNavigateToChat: (sessionKey) => { - switchChatSession(state, sessionKey); - state.setTab("chat" as import("./navigation.ts").Tab); - }, - }), - ) - : nothing} - ${state.tab === "agents" - ? renderLazyView(lazyAgents, (m) => - m.renderAgents({ - basePath: state.basePath ?? "", - loading: state.agentsLoading, - error: state.agentsError, - agentsList: state.agentsList, - selectedAgentId: resolvedAgentId, - activePanel: state.agentsPanel, - config: { - form: configValue, - loading: state.configLoading, - saving: state.configSaving, - dirty: state.configFormDirty, - }, - channels: { - snapshot: state.channelsSnapshot, - loading: state.channelsLoading, - error: state.channelsError, - lastSuccess: state.channelsLastSuccess, - }, - cron: { - status: state.cronStatus, - jobs: state.cronJobs, - loading: state.cronLoading, - error: state.cronError, - }, - agentFiles: { - list: state.agentFilesList, - loading: state.agentFilesLoading, - error: state.agentFilesError, - active: state.agentFileActive, - contents: state.agentFileContents, - drafts: state.agentFileDrafts, - saving: state.agentFileSaving, - }, - agentIdentityLoading: state.agentIdentityLoading, - agentIdentityError: state.agentIdentityError, - agentIdentityById: state.agentIdentityById, - agentSkills: { - report: state.agentSkillsReport, - loading: state.agentSkillsLoading, - error: state.agentSkillsError, - agentId: state.agentSkillsAgentId, - filter: state.skillsFilter, - }, - toolsCatalog: { - loading: state.toolsCatalogLoading, - error: state.toolsCatalogError, - result: state.toolsCatalogResult, - }, - toolsEffective: { - loading: state.toolsEffectiveLoading, - error: state.toolsEffectiveError, - result: state.toolsEffectiveResult, - }, - runtimeSessionKey: state.sessionKey, - runtimeSessionMatchesSelectedAgent: toolsPanelUsesActiveSession, - modelCatalog: state.chatModelCatalog ?? [], - onRefresh: runUiTask(async () => { - await loadAgents(state); - const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; - if (agentIds.length > 0) { - void loadAgentIdentities(state, agentIds); - } - loadAgentPanelDataForSelectedAgent(resolveSelectedAgentId()); - refreshAgentsPanelSupplementalData(state.agentsPanel); - }), - onSelectAgent: (agentId) => { - if (state.agentsSelectedId === agentId) { - return; - } - state.agentsSelectedId = agentId; - resetAgentSelectionPanelState(); - void loadAgentIdentity(state, agentId); - loadAgentPanelDataForSelectedAgent(agentId); - }, - onSelectPanel: (panel) => { - state.agentsPanel = panel; - if ( - panel === "files" && - resolvedAgentId && - state.agentFilesList?.agentId !== resolvedAgentId - ) { - resetAgentFilesState(); - void loadAgentFiles(state, resolvedAgentId); - } - if (panel === "skills" && resolvedAgentId) { - void loadAgentSkills(state, resolvedAgentId); - } - if (panel === "tools" && resolvedAgentId) { - if ( - state.toolsCatalogResult?.agentId !== resolvedAgentId || - state.toolsCatalogError - ) { - void loadToolsCatalog(state, resolvedAgentId); - } - if (resolvedAgentId === chatAgentId) { - const toolsRequestKey = buildToolsEffectiveRequestKey(state, { - agentId: resolvedAgentId, - sessionKey: state.sessionKey, - }); - if ( - state.toolsEffectiveResultKey !== toolsRequestKey || - state.toolsEffectiveError - ) { - void loadToolsEffective(state, { - agentId: resolvedAgentId, - sessionKey: state.sessionKey, - }); - } - } else { - resetToolsEffectiveState(state); - } - } - refreshAgentsPanelSupplementalData(panel); - }, - onLoadFiles: (agentId) => void loadAgentFiles(state, agentId), - onSelectFile: (name) => { - state.agentFileActive = name; - if (!resolvedAgentId) { - return; - } - void loadAgentFileContent(state, resolvedAgentId, name); - }, - onFileDraftChange: (name, content) => { - state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content }; - }, - onFileReset: (name) => { - const base = state.agentFileContents[name] ?? ""; - state.agentFileDrafts = { ...state.agentFileDrafts, [name]: base }; - }, - onFileSave: (name) => { - if (!resolvedAgentId) { - return; - } - const content = - state.agentFileDrafts[name] ?? state.agentFileContents[name] ?? ""; - void saveAgentFile(state, resolvedAgentId, name, content); - }, - onToolsProfileChange: (agentId, profile, clearAllow) => { - const basePathItem = resolveAgentToolsPath( - agentId, - Boolean(profile || clearAllow), - ); - if (!basePathItem) { - return; - } - if (profile) { - updateConfigFormValue(state, [...basePathItem, "profile"], profile); - } else { - removeConfigFormValue(state, [...basePathItem, "profile"]); - } - if (clearAllow) { - removeConfigFormValue(state, [...basePathItem, "allow"]); - } - }, - onToolsOverridesChange: (agentId, alsoAllow, deny) => { - const basePathCandidate = resolveAgentToolsPath( - agentId, - alsoAllow.length > 0 || deny.length > 0, - ); - if (!basePathCandidate) { - return; - } - if (alsoAllow.length > 0) { - updateConfigFormValue(state, [...basePathCandidate, "alsoAllow"], alsoAllow); - } else { - removeConfigFormValue(state, [...basePathCandidate, "alsoAllow"]); - } - if (deny.length > 0) { - updateConfigFormValue(state, [...basePathCandidate, "deny"], deny); - } else { - removeConfigFormValue(state, [...basePathCandidate, "deny"]); - } - }, - onConfigReload: () => void loadConfig(state, { discardPendingChanges: true }), - onConfigSave: () => void saveAgentsConfig(state), - onChannelsRefresh: () => void loadChannels(state, false), - onCronRefresh: () => void state.loadCron(), - onCronRunNow: (jobId) => { - const job = state.cronJobs.find((entry) => entry.id === jobId); - if (!job) { - return; - } - void runCronJob(state, job, "force"); - }, - onSkillsFilterChange: (next) => (state.skillsFilter = next), - onSkillsRefresh: () => { - if (resolvedAgentId) { - void loadAgentSkills(state, resolvedAgentId); - } - }, - onAgentSkillToggle: (agentId, skillName, enabled) => { - const index = ensureAgentIndex(agentId); - if (index < 0) { - return; - } - const list = (getCurrentConfigValue() as { agents?: { list?: unknown[] } } | null) - ?.agents?.list; - const entry = Array.isArray(list) - ? (list[index] as { skills?: unknown }) - : undefined; - const normalizedSkill = skillName.trim(); - if (!normalizedSkill) { - return; - } - const allSkills = - state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ?? - []; - const existing = Array.isArray(entry?.skills) - ? normalizeStringEntries(entry.skills) - : undefined; - const base = existing ?? allSkills; - const next = new Set(base); - if (enabled) { - next.add(normalizedSkill); - } else { - next.delete(normalizedSkill); - } - updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]); - }, - onAgentSkillsClear: (agentId) => { - const index = findAgentIndex(agentId); - if (index < 0) { - return; - } - removeConfigFormValue(state, ["agents", "list", index, "skills"]); - }, - onAgentSkillsDisableAll: (agentId) => { - const index = ensureAgentIndex(agentId); - if (index < 0) { - return; - } - updateConfigFormValue(state, ["agents", "list", index, "skills"], []); - }, - onModelChange: (agentId, modelId) => { - const index = modelId ? ensureAgentIndex(agentId) : findAgentIndex(agentId); - if (index < 0) { - return; - } - const modelEntry = resolveAgentModelFormEntry(index); - const { basePath: basePathEntry, existing } = modelEntry; - if (!modelId) { - removeConfigFormValue(state, basePathEntry); - } else if (existing && typeof existing === "object" && !Array.isArray(existing)) { - const fallbacks = (existing as { fallbacks?: unknown }).fallbacks; - const next = { - primary: modelId, - ...(Array.isArray(fallbacks) ? { fallbacks } : {}), - }; - updateConfigFormValue(state, basePathEntry, next); - } else { - updateConfigFormValue(state, basePathEntry, modelId); - } - void refreshVisibleToolsEffectiveForCurrentSession(state); - }, - onModelFallbacksChange: (agentId, fallbacks) => { - const normalized = normalizeStringEntries(fallbacks); - const currentConfig = getCurrentConfigValue(); - const resolvedConfig = resolveAgentConfig(currentConfig, agentId); - const effectivePrimary = - resolveModelPrimary(resolvedConfig.entry?.model) ?? - resolveModelPrimary(resolvedConfig.defaults?.model); - const effectiveFallbacks = resolveEffectiveModelFallbacks( - resolvedConfig.entry?.model, - resolvedConfig.defaults?.model, - ); - const index = - normalized.length > 0 - ? effectivePrimary - ? ensureAgentIndex(agentId) - : -1 - : (effectiveFallbacks?.length ?? 0) > 0 || findAgentIndex(agentId) >= 0 - ? ensureAgentIndex(agentId) - : -1; - if (index < 0) { - return; - } - const { basePath: basePathResult, existing } = resolveAgentModelFormEntry(index); - const resolvePrimary = () => { - if (typeof existing === "string") { - return existing.trim() || null; - } - if (existing && typeof existing === "object" && !Array.isArray(existing)) { - const primary = (existing as { primary?: unknown }).primary; - if (typeof primary === "string") { - const trimmed = primary.trim(); - return trimmed || null; - } - } - return null; - }; - const primary = resolvePrimary() ?? effectivePrimary; - if (normalized.length === 0) { - if (primary) { - updateConfigFormValue(state, basePathResult, primary); - } else { - removeConfigFormValue(state, basePathResult); - } - return; - } - if (!primary) { - return; - } - updateConfigFormValue(state, basePathResult, { primary, fallbacks: normalized }); - }, - onSetDefault: (agentId) => { - void setDefaultAgent(state, agentId); - }, - }), - ) - : nothing} - ${state.tab === "skills" - ? renderLazyView(lazySkills, (m) => - m.renderSkills({ - connected: state.connected, - loading: state.skillsLoading, - report: state.skillsReport, - agentsList: state.agentsList, - selectedAgentId: state.skillsAgentId ?? state.agentsList?.defaultId ?? null, - error: state.skillsError, - filter: state.skillsFilter, - statusFilter: state.skillsStatusFilter, - edits: state.skillEdits, - messages: state.skillMessages, - busyKey: state.skillsBusyKey, - detailKey: state.skillsDetailKey, - detailTab: state.skillsDetailTab, - clawhubVerdicts: state.clawhubVerdicts, - clawhubVerdictsLoading: state.clawhubVerdictsLoading, - clawhubVerdictsError: state.clawhubVerdictsError, - skillCardContents: state.skillCardContents, - skillCardLoadingKey: state.skillCardLoadingKey, - skillCardErrors: state.skillCardErrors, - clawhubQuery: state.clawhubSearchQuery, - clawhubResults: state.clawhubSearchResults, - clawhubSearchLoading: state.clawhubSearchLoading, - clawhubSearchError: state.clawhubSearchError, - clawhubDetail: state.clawhubDetail, - clawhubDetailSlug: state.clawhubDetailSlug, - clawhubDetailLoading: state.clawhubDetailLoading, - clawhubDetailError: state.clawhubDetailError, - clawhubInstallSlug: state.clawhubInstallSlug, - clawhubInstallMessage: state.clawhubInstallMessage, - onAgentChange: (agentId) => { - setSkillsAgentId(state, agentId); - void loadSkills(state, { clearMessages: true }); - }, - onFilterChange: (next) => (state.skillsFilter = next), - onStatusFilterChange: (next) => (state.skillsStatusFilter = next), - onRefresh: () => { - void (async () => { - await loadAgents(state); - reconcileSkillsAgentId(state, state.agentsList); - await loadSkills(state, { clearMessages: true }); - })(); - }, - onToggle: (key, enabled) => void updateSkillEnabled(state, key, enabled), - onEdit: (key, value) => updateSkillEdit(state, key, value), - onSaveKey: (key) => void saveSkillApiKey(state, key), - onInstall: (skillKey, name, installId) => - void installSkill(state, skillKey, name, installId), - onDetailOpen: (key) => { - state.skillsDetailKey = key; - state.skillsDetailTab = "overview"; - }, - onDetailClose: () => (state.skillsDetailKey = null), - onDetailTabChange: (tab) => { - state.skillsDetailTab = tab; - if (tab === "card" && state.skillsDetailKey) { - void loadSkillCard(state, state.skillsDetailKey); - } - }, - onClawHubQueryChange: (query) => { - setClawHubSearchQuery(state, query); - if (clawhubSearchTimer) { - clearTimeout(clawhubSearchTimer); - } - clawhubSearchTimer = setTimeout(() => { - void searchClawHub(state, query); - }, 300); - }, - onClawHubDetailOpen: (slug) => void loadClawHubDetail(state, slug), - onClawHubDetailClose: () => closeClawHubDetail(state), - onClawHubInstall: (slug, acknowledgeClawHubRisk, version) => - void installFromClawHub(state, slug, acknowledgeClawHubRisk, version), - }), - ) - : nothing} - ${state.tab === "skillWorkshop" - ? renderLazyView(lazySkillWorkshop, (m) => { - const visibleProposals = m.filterSkillWorkshopProposals( - state.skillWorkshopProposals, - state.skillWorkshopStatusFilter, - state.skillWorkshopQuery, - ); - const selectedIndex = visibleProposals.findIndex( - (proposal) => proposal.key === state.skillWorkshopSelectedKey, - ); - const selectRelativeProposal = (delta: -1 | 1) => { - if (visibleProposals.length === 0) { - return; - } - const nextIndex = - selectedIndex < 0 - ? 0 - : (selectedIndex + delta + visibleProposals.length) % visibleProposals.length; - selectSkillWorkshopProposal(state, visibleProposals[nextIndex].key); - }; - const selectVisibleFallback = (proposals: typeof visibleProposals) => { - if ( - proposals.length === 0 || - proposals.some((proposal) => proposal.key === state.skillWorkshopSelectedKey) - ) { - return; - } - state.skillWorkshopFilePreviewKey = null; - selectSkillWorkshopProposal(state, proposals[0].key); - }; - return m.renderSkillWorkshop({ - loading: state.skillWorkshopLoading, - error: state.skillWorkshopError, - inspectingKey: state.skillWorkshopInspectingKey, - proposals: state.skillWorkshopProposals, - selectedKey: state.skillWorkshopSelectedKey, - statusFilter: state.skillWorkshopStatusFilter, - query: state.skillWorkshopQuery, - filePreviewKey: state.skillWorkshopFilePreviewKey, - filePreviewQuery: state.skillWorkshopFilePreviewQuery, - queueWidth: state.skillWorkshopQueueWidth, - mode: state.skillWorkshopMode, - actionBusy: state.skillWorkshopActionBusy, - actionNotice: state.skillWorkshopActionNotice, - revisionKey: state.skillWorkshopRevisionKey, - revisionDraft: state.skillWorkshopRevisionDraft, - assistantName: state.assistantName, - counts: countSkillWorkshopProposals(state.skillWorkshopProposals), - onStatusFilterChange: (status) => { - state.skillWorkshopStatusFilter = status; - selectVisibleFallback( - m.filterSkillWorkshopProposals( - state.skillWorkshopProposals, - status, - state.skillWorkshopQuery, - ), - ); - }, - onQueryChange: (query) => { - state.skillWorkshopQuery = query; - selectVisibleFallback( - m.filterSkillWorkshopProposals( - state.skillWorkshopProposals, - state.skillWorkshopStatusFilter, - query, - ), - ); - }, - onFilePreviewQueryChange: (query) => (state.skillWorkshopFilePreviewQuery = query), - onQueueWidthChange: (width) => (state.skillWorkshopQueueWidth = width), - onModeChange: (mode) => setSkillWorkshopMode(state, mode), - onSelect: (key) => { - state.skillWorkshopFilePreviewKey = null; - selectSkillWorkshopProposal(state, key); - }, - onPrev: () => selectRelativeProposal(-1), - onNext: () => selectRelativeProposal(1), - onApply: (key) => void runSkillWorkshopLifecycleAction(state, "apply", key), - onRevise: (key) => { - state.skillWorkshopRevisionKey = key; - state.skillWorkshopRevisionDraft = ""; - }, - onReject: (key) => void runSkillWorkshopLifecycleAction(state, "reject", key), - onRevisionDraftChange: (draft) => (state.skillWorkshopRevisionDraft = draft), - onRevisionCancel: () => { - state.skillWorkshopRevisionKey = null; - state.skillWorkshopRevisionDraft = ""; - }, - onRevisionSubmit: (key) => - void requestSkillWorkshopRevision(state, key, (message, proposal, agentId) => - sendSkillWorkshopRevisionRequest(state, message, proposal, agentId), - ), - onPreviewFile: (key, path) => { - state.skillWorkshopSelectedKey = key; - state.skillWorkshopFilePreviewKey = path; - }, - onClosePreview: () => { - state.skillWorkshopFilePreviewKey = null; - state.skillWorkshopFilePreviewQuery = ""; - }, - }); - }) - : nothing} - ${state.tab === "nodes" - ? renderLazyView(lazyNodes, (m) => - m.renderNodes({ - loading: state.nodesLoading, - nodes: state.nodes, - devicesLoading: state.devicesLoading, - devicesError: state.devicesError, - devicesList: state.devicesList, - devicePairSetupOpen: state.devicePairSetupOpen, - devicePairSetupLoading: state.devicePairSetupLoading, - devicePairSetupError: state.devicePairSetupError, - devicePairSetup: state.devicePairSetup, - canPairDevice: - state.connected && - hasOperatorAdminAccess( - (state.hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? - null, - ), - configForm: - state.configForm ?? - (state.configSnapshot?.config as Record | null), - configLoading: state.configLoading, - configSaving: state.configSaving, - configDirty: state.configFormDirty, - configFormMode: state.configFormMode, - execApprovalsLoading: state.execApprovalsLoading, - execApprovalsSaving: state.execApprovalsSaving, - execApprovalsDirty: state.execApprovalsDirty, - execApprovalsSnapshot: state.execApprovalsSnapshot, - execApprovalsForm: state.execApprovalsForm, - execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, - execApprovalsTarget: state.execApprovalsTarget, - execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, - onRefresh: () => void loadNodes(state), - onDevicesRefresh: () => void loadDevices(state), - onDevicePairSetupOpen: () => void openDevicePairSetup(state), - onDevicePairSetupRefresh: () => void refreshDevicePairSetup(state), - onDevicePairSetupClose: () => closeDevicePairSetup(state), - onDevicePairSetupCopy: (setupCode) => void copyToClipboard(setupCode), - onDeviceApprove: (requestId) => void approveDevicePairing(state, requestId), - onDeviceReject: (requestId) => void rejectDevicePairing(state, requestId), - onDeviceRotate: (deviceId, role, scopes) => - void rotateDeviceToken(state, { deviceId, role, scopes }), - onDeviceRevoke: (deviceId, role) => - void revokeDeviceToken(state, { deviceId, role }), - onLoadConfig: () => void loadConfig(state, { discardPendingChanges: true }), - onLoadExecApprovals: () => { - const target = - state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId - ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } - : { kind: "gateway" as const }; - void loadExecApprovals(state, target); - }, - onBindDefault: (nodeId) => { - if (nodeId) { - updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); - } else { - removeConfigFormValue(state, ["tools", "exec", "node"]); - } - }, - onBindAgent: (agentIndex, nodeId) => { - const basePathLocal = ["agents", "list", agentIndex, "tools", "exec", "node"]; - if (nodeId) { - updateConfigFormValue(state, basePathLocal, nodeId); - } else { - removeConfigFormValue(state, basePathLocal); - } - }, - onSaveBindings: () => void saveConfig(state), - onExecApprovalsTargetChange: (kind, nodeId) => { - state.execApprovalsTarget = kind; - state.execApprovalsTargetNodeId = nodeId; - state.execApprovalsSnapshot = null; - state.execApprovalsForm = null; - state.execApprovalsDirty = false; - state.execApprovalsSelectedAgent = null; - }, - onExecApprovalsSelectAgent: (agentId) => { - state.execApprovalsSelectedAgent = agentId; - }, - onExecApprovalsPatch: (path, value) => - updateExecApprovalsFormValue(state, path, value), - onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path), - onSaveExecApprovals: () => { - const target = - state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId - ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } - : { kind: "gateway" as const }; - void saveExecApprovals(state, target); - }, - }), - ) - : nothing} - ${state.tab === "chat" - ? renderMeasured( - state, - "chat", - { - messageCount: state.chatMessages.length, - toolMessageCount: state.chatToolMessages.length, - streamSegmentCount: state.chatStreamSegments.length, - queueCount: state.chatQueue.length, - }, - () => - renderChat({ - sessionKey: state.sessionKey, - onSessionKeyChange: (next) => { - switchChatSession(state, next); - }, - thinkingLevel: state.chatThinkingLevel, - showThinking, - showToolCalls, - loading: state.chatLoading, - sending: state.chatSending, - compactionStatus: state.compactionStatus, - fallbackStatus: state.fallbackStatus, - assistantAvatarUrl: chatAvatarUrl, - messages: state.chatMessages, - sideResult: state.chatSideResult, - toolMessages: state.chatToolMessages, - streamSegments: state.chatStreamSegments, - stream: state.chatStream, - streamStartedAt: state.chatStreamStartedAt, - draft: state.chatMessage, - queue: state.chatQueue, - realtimeTalkActive: state.realtimeTalkActive, - realtimeTalkStatus: state.realtimeTalkStatus, - realtimeTalkDetail: state.realtimeTalkDetail, - realtimeTalkTranscript: state.realtimeTalkTranscript, - realtimeTalkConversation: state.realtimeTalkConversation, - realtimeTalkOptionsOpen: state.realtimeTalkOptionsOpen, - realtimeTalkOptions: state.realtimeTalkOptions, - realtimeTalkCatalogProviders: state.realtimeTalkCatalogProviders, - connected: state.connected, - canSend: state.connected && !chatSessionArchived, - disabledReason: chatDisabledReason, - error: chatViewError, - runStatus: state.chatRunStatus, - onDismissError: () => dismissChatError(state), - onDismissRealtimeTalkError: () => dismissRealtimeTalkError(state), - sessions: state.sessionsResult, - composerControls: renderGuardedChatControls(state), - sessionWorkspace: { - collapsed: chatWorkspaceFiles.collapsed, - sessionKey: state.sessionKey, - list: - chatWorkspaceFiles.list?.sessionKey === state.sessionKey - ? chatWorkspaceFiles.list - : null, - loading: chatWorkspaceFiles.loading, - error: chatWorkspaceFiles.error, - activeId: chatWorkspaceFiles.activeId, - onToggleCollapsed: toggleChatWorkspaceFilesCollapsed, - onRefresh: refreshChatWorkspaceFiles, - onBrowsePath: browseChatWorkspacePath, - onCopyPath: copyChatWorkspacePath, - onOpenFile: openChatWorkspaceFile, - onSearch: searchChatWorkspaceFiles, - onOpenArtifact: openChatWorkspaceArtifact, - }, - autoExpandToolCalls: state.chatVerboseLevel === "full", - onRefresh: () => { - state.chatSideResult = null; - state.resetToolStream(); - void refreshChat(state, { awaitHistory: true, scheduleScroll: false }); - }, - onChatScroll: (event) => state.handleChatScroll(event), - getDraft: () => state.chatMessage, - onDraftChange: (next) => state.handleChatDraftChange(next), - onRequestUpdate: requestHostUpdate, - onHistoryKeydown: (input) => state.handleChatInputHistoryKey(input), - onSlashIntent: () => refreshChatCommands(state).finally(requestHostUpdate), - attachments: state.chatAttachments, - onAttachmentsChange: (next) => (state.chatAttachments = next), - onSend: () => void state.handleSendChat(), - onCompact: () => void state.handleSendChat("/compact", { restoreDraft: true }), - onOpenSessionCheckpoints: () => openCurrentSessionCheckpoints(state), - onToggleRealtimeTalk: () => void state.toggleRealtimeTalk(), - onToggleRealtimeTalkOptions: () => { - state.realtimeTalkOptionsOpen = !state.realtimeTalkOptionsOpen; - if (state.realtimeTalkOptionsOpen) { - void state.fetchRealtimeTalkCatalog(); - } - }, - onRealtimeTalkOptionsChange: (next) => state.updateRealtimeTalkOptions(next), - canAbort: hasAbortableSessionRun(state), - onAbort: () => void state.handleAbortChat({ preserveDraft: true }), - onQueueRemove: (id) => state.removeQueuedMessage(id), - onQueueRetry: (id) => void state.retryQueuedChatMessage(id), - onQueueSteer: (id) => void state.steerQueuedChatMessage(id), - onDismissSideResult: () => { - state.chatSideResult = null; - }, - replyTarget: state.chatReplyTarget ?? null, - onClearReply: () => { - state.chatReplyTarget = null; - requestHostUpdate?.(); - }, - onSetReply: (target) => { - state.chatReplyTarget = target; - requestHostUpdate?.(); - }, - onNewSession: () => void createChatSession(state, { source: "user" }), - onClearHistory: runUiTask(async () => { - if (!state.client || !state.connected) { - return; - } - const hadActiveRun = hasAbortableSessionRun(state); - try { - await state.client.request("sessions.reset", { - key: state.sessionKey, - ...scopedAgentParamsForSession(state, state.sessionKey), - }); - state.chatMessages = []; - clearChatMessagesFromCache(state.chatMessagesBySession, state, { - sessionKey: state.sessionKey, - }); - state.chatSideResult = null; - state.chatReplyTarget = null; - reconcileChatRunLifecycle( - state as unknown as Parameters[0], - { - outcome: hadActiveRun ? "interrupted" : undefined, - sessionStatus: "killed", - runId: state.chatRunId, - sessionKey: state.sessionKey, - clearLocalRun: true, - clearChatStream: true, - clearToolStream: true, - clearSideResultTerminalRuns: true, - clearRunStatus: !hadActiveRun, - }, - ); - await loadChatHistory(state); - } catch (err) { - state.lastError = String(err); - state.chatError = state.lastError; - } - }), - agentsList: state.agentsList, - currentAgentId: chatAgentId, - fullMessageAgentId: scopedAgentParamsForSession(state, state.sessionKey).agentId, - onAgentChange: (agentId: string) => { - switchChatSession(state, buildAgentMainSessionKey({ agentId })); - }, - onNavigateToAgent: () => { - state.agentsSelectedId = resolvedAgentId; - state.setTab("agents" as import("./navigation.ts").Tab); - }, - onSessionSelect: (key: string) => { - switchChatSession(state, key); - }, - showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, - onScrollToBottom: () => state.scrollToBottom(), - onAssistantAttachmentLoaded: () => state.scheduleChatScroll(), - // Sidebar props for tool output viewing - sidebarOpen: state.sidebarOpen, - sidebarContent: state.sidebarContent, - sidebarError: state.sidebarError, - splitRatio: state.splitRatio, - canvasPluginSurfaceUrl: state.hello?.pluginSurfaceUrls?.canvas ?? null, - onOpenSidebar: (content) => state.handleOpenSidebar(content), - onCloseSidebar: () => state.handleCloseSidebar(), - onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), - assistantName: state.assistantName, - assistantAvatar: effectiveAssistantAvatar, - userName: state.userName ?? null, - userAvatar: state.userAvatar ?? null, - localMediaPreviewRoots: state.localMediaPreviewRoots, - embedSandboxMode: state.embedSandboxMode, - allowExternalEmbedUrls: state.allowExternalEmbedUrls, - assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state), - basePath: state.basePath ?? "", - }), - ) - : nothing} - ${isSettingsTab(state.tab) && state.tab !== "debug" && state.tab !== "logs" - ? renderSettingsWorkspace(state, renderConfigTabForActiveTab()) - : renderConfigTabForActiveTab()} - ${state.tab === "debug" - ? renderSettingsWorkspace( - state, - renderLazyView(lazyDebug, (m) => - m.renderDebug({ - loading: state.debugLoading, - status: state.debugStatus, - health: state.debugHealth, - models: state.debugModels, - heartbeat: state.debugHeartbeat, - eventLog: state.eventLog, - methods: (state.hello?.features?.methods ?? []).toSorted(), - callMethod: state.debugCallMethod, - callParams: state.debugCallParams, - callResult: state.debugCallResult, - callError: state.debugCallError, - onCallMethodChange: (next) => (state.debugCallMethod = next), - onCallParamsChange: (next) => (state.debugCallParams = next), - onRefresh: () => void loadDebug(state), - onCall: () => void callDebugMethod(state), - }), - ), - ) - : nothing} - ${state.tab === "logs" - ? renderSettingsWorkspace( - state, - renderLazyView(lazyLogs, (m) => - m.renderLogs({ - loading: state.logsLoading, - error: state.logsError, - file: state.logsFile, - entries: state.logsEntries, - filterText: state.logsFilterText, - levelFilters: state.logsLevelFilters, - autoFollow: state.logsAutoFollow, - truncated: state.logsTruncated, - onFilterTextChange: (next) => (state.logsFilterText = next), - onLevelToggle: (level, enabled) => { - state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; - }, - onToggleAutoFollow: (next) => (state.logsAutoFollow = next), - onRefresh: () => void loadLogs(state, { reset: true }), - onExport: (lines, label) => state.exportLogs(lines, label), - onScroll: (event) => state.handleLogsScroll(event), - }), - ), - ) - : nothing} - ${state.tab === "dreams" - ? renderDreaming({ - active: dreamingOn, - selectedAgentId: dreamingSelectedAgentId, - agentOptions: dreamingAgentOptions, - shortTermCount: state.dreamingStatus?.shortTermCount ?? 0, - groundedSignalCount: state.dreamingStatus?.groundedSignalCount ?? 0, - totalSignalCount: state.dreamingStatus?.totalSignalCount ?? 0, - promotedCount: state.dreamingStatus?.promotedToday ?? 0, - phases: state.dreamingStatus?.phases ?? undefined, - shortTermEntries: state.dreamingStatus?.shortTermEntries ?? [], - promotedEntries: state.dreamingStatus?.promotedEntries ?? [], - dreamingOf: null, - nextCycle: dreamingNextCycle, - timezone: state.dreamingStatus?.timezone ?? null, - statusLoading: state.dreamingStatusLoading, - statusError: state.dreamingStatusError, - modeSaving: state.dreamingModeSaving, - dreamDiaryLoading: state.dreamDiaryLoading, - dreamDiaryActionLoading: state.dreamDiaryActionLoading, - dreamDiaryActionMessage: state.dreamDiaryActionMessage, - dreamDiaryActionArchivePath: state.dreamDiaryActionArchivePath, - dreamDiaryError: state.dreamDiaryError, - dreamDiaryPath: state.dreamDiaryPath, - dreamDiaryContent: state.dreamDiaryContent, - memoryWikiEnabled: isPluginEnabledInConfigSnapshot( - state.configSnapshot, - "memory-wiki", - { enabledByDefault: false }, - ), - wikiImportInsightsLoading: state.wikiImportInsightsLoading, - wikiImportInsightsError: state.wikiImportInsightsError, - wikiImportInsights: state.wikiImportInsights, - wikiMemoryPalaceLoading: state.wikiMemoryPalaceLoading, - wikiMemoryPalaceError: state.wikiMemoryPalaceError, - wikiMemoryPalace: state.wikiMemoryPalace, - onRefresh: refreshDreaming, - onSelectAgent: (agentId: string) => { - state.selectedAgentId = agentId; - switchChatSession(state, resolvePreferredSessionForAgent(state, agentId)); - void loadDreamingStatus(state); - void loadDreamDiary(state); - }, - onRefreshDiary: () => { - syncDreamingSelectedAgent(); - void loadDreamDiary(state); - }, - onRefreshImports: () => { - void (async () => { - await loadConfig(state); - await loadWikiImportInsights(state); - })(); - }, - onRefreshMemoryPalace: () => { - void (async () => { - await loadConfig(state); - await loadWikiMemoryPalace(state); - })(); - }, - onOpenConfig: () => void openConfigFile(state), - onOpenWikiPage: (lookup: string) => openWikiPage(lookup), - onBackfillDiary: () => { - syncDreamingSelectedAgent(); - void backfillDreamDiary(state); - }, - onCopyDreamingArchivePath: () => { - void copyDreamingArchivePath(state); - }, - onDedupeDreamDiary: () => { - syncDreamingSelectedAgent(); - void dedupeDreamDiary(state); - }, - onResetDiary: () => { - syncDreamingSelectedAgent(); - void resetDreamDiary(state); - }, - onResetGroundedShortTerm: () => { - syncDreamingSelectedAgent(); - void resetGroundedShortTerm(state); - }, - onRepairDreamingArtifacts: () => { - syncDreamingSelectedAgent(); - void repairDreamingArtifacts(state); - }, - onRequestUpdate: requestHostUpdate, - }) - : nothing} -
- ${(() => { - const terminalAvailable = isTerminalAvailable(state); - const terminalMode = resolveTheme(state.theme, state.themeMode).includes("light") - ? "light" - : "dark"; - return html``; - })()} - ${renderExecApprovalPrompt(state)} ${renderGatewayUrlConfirmation(state)} - ${renderDreamingRestartConfirmation({ - open: state.dreamingRestartConfirmOpen, - loading: state.dreamingRestartConfirmLoading, - onConfirm: confirmDreamingRestart, - onCancel: cancelDreamingRestart, - hasError: Boolean(state.dreamingStatusError), - })} - ${nothing} -
- `; -} diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts deleted file mode 100644 index 8f76e46b8090..000000000000 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ /dev/null @@ -1,699 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// @vitest-environment node -import { createDeferred } from "../../../src/test-utils/deferred.js"; - -type CronRunsLoadStatus = "ok" | "error" | "skipped"; - -async function raceWithNextMacrotask(promise: Promise): Promise<"resolved" | "pending"> { - return await Promise.race([ - promise.then(() => "resolved" as const), - new Promise<"pending">((resolve) => { - setImmediate(() => resolve("pending")); - }), - ]); -} - -const mocks = vi.hoisted(() => ({ - refreshChatMock: vi.fn(async () => {}), - scheduleChatScrollMock: vi.fn(), - scheduleLogsScrollMock: vi.fn(), - loadAgentFilesMock: vi.fn(async () => {}), - loadAgentIdentitiesMock: vi.fn(async () => {}), - loadAgentIdentityMock: vi.fn(async () => {}), - loadAgentSkillsMock: vi.fn(async () => {}), - loadAgentsMock: vi.fn(async () => {}), - loadChannelsMock: vi.fn<(hostValue: unknown, _probe: boolean) => Promise>(async () => {}), - loadConfigMock: vi.fn(async () => {}), - loadConfigSchemaMock: vi.fn(async () => {}), - loadCronStatusMock: vi.fn(async () => {}), - loadCronJobsPageMock: vi.fn(async () => {}), - loadCronRunsMock: vi.fn<() => Promise>(async () => "ok"), - loadDebugMock: vi.fn(async () => {}), - loadDevicesMock: vi.fn(async () => {}), - loadDreamDiaryMock: vi.fn(async () => {}), - loadDreamingStatusMock: vi.fn(async () => {}), - loadWikiImportInsightsMock: vi.fn(async () => {}), - loadWikiMemoryPalaceMock: vi.fn(async () => {}), - loadExecApprovalsMock: vi.fn(async () => {}), - loadLogsMock: vi.fn(async () => {}), - loadModelAuthStatusStateMock: vi.fn(async () => {}), - loadNodesMock: vi.fn(async () => {}), - loadPresenceMock: vi.fn(async () => {}), - loadSessionsMock: vi.fn(async () => {}), - loadSkillsMock: vi.fn(async () => {}), - reconcileSkillsAgentIdMock: vi.fn(), - loadUsageMock: vi.fn(async () => {}), - loadWorkboardMock: vi.fn(async () => {}), - stopWorkboardLifecycleRefreshMock: vi.fn(), - stopWorkboardPollingMock: vi.fn(), - startDebugPollingMock: vi.fn(), - startLogsPollingMock: vi.fn(), - startNodesPollingMock: vi.fn(), - stopDebugPollingMock: vi.fn(), - stopLogsPollingMock: vi.fn(), - stopNodesPollingMock: vi.fn(), -})); - -vi.mock("./app-chat.ts", () => ({ - refreshChat: mocks.refreshChatMock, - createChatSessionsLoadOverrides: () => ({ - activeMinutes: 0, - limit: 50, - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }), - scopedAgentListParamsForSession: () => ({}), -})); -vi.mock("./app-polling.ts", () => ({ - startDebugPolling: mocks.startDebugPollingMock, - startLogsPolling: mocks.startLogsPollingMock, - startNodesPolling: mocks.startNodesPollingMock, - stopDebugPolling: mocks.stopDebugPollingMock, - stopLogsPolling: mocks.stopLogsPollingMock, - stopNodesPolling: mocks.stopNodesPollingMock, -})); -vi.mock("./app-scroll.ts", () => ({ - scheduleChatScroll: mocks.scheduleChatScrollMock, - scheduleLogsScroll: mocks.scheduleLogsScrollMock, -})); -vi.mock("./controllers/agent-files.ts", () => ({ - loadAgentFiles: mocks.loadAgentFilesMock, -})); -vi.mock("./controllers/agent-identity.ts", () => ({ - loadAgentIdentities: mocks.loadAgentIdentitiesMock, - loadAgentIdentity: mocks.loadAgentIdentityMock, -})); -vi.mock("./controllers/agent-skills.ts", () => ({ - loadAgentSkills: mocks.loadAgentSkillsMock, -})); -vi.mock("./controllers/agents.ts", () => ({ - loadAgents: mocks.loadAgentsMock, -})); -vi.mock("./controllers/channels.ts", () => ({ - loadChannels: mocks.loadChannelsMock, -})); -vi.mock("./controllers/config.ts", () => ({ - loadConfig: mocks.loadConfigMock, - loadConfigSchema: mocks.loadConfigSchemaMock, -})); -vi.mock("./controllers/cron.ts", () => ({ - loadCronStatus: mocks.loadCronStatusMock, - loadCronJobsPage: mocks.loadCronJobsPageMock, - loadCronRuns: mocks.loadCronRunsMock, -})); -vi.mock("./controllers/debug.ts", () => ({ - loadDebug: mocks.loadDebugMock, -})); -vi.mock("./controllers/devices.ts", () => ({ - loadDevices: mocks.loadDevicesMock, -})); -vi.mock("./controllers/dreaming.ts", () => ({ - loadDreamDiary: mocks.loadDreamDiaryMock, - loadDreamingStatus: mocks.loadDreamingStatusMock, - loadWikiImportInsights: mocks.loadWikiImportInsightsMock, - loadWikiMemoryPalace: mocks.loadWikiMemoryPalaceMock, -})); -vi.mock("./controllers/exec-approvals.ts", () => ({ - loadExecApprovals: mocks.loadExecApprovalsMock, -})); -vi.mock("./controllers/logs.ts", () => ({ - loadLogs: mocks.loadLogsMock, -})); -vi.mock("./controllers/model-auth-status.ts", () => ({ - loadModelAuthStatusState: mocks.loadModelAuthStatusStateMock, -})); -vi.mock("./controllers/nodes.ts", () => ({ - loadNodes: mocks.loadNodesMock, -})); -vi.mock("./controllers/presence.ts", () => ({ - loadPresence: mocks.loadPresenceMock, -})); -vi.mock("./controllers/sessions.ts", () => ({ - loadSessions: mocks.loadSessionsMock, - syncSelectedSessionMessageSubscription: vi.fn(), -})); -vi.mock("./controllers/skills.ts", () => ({ - loadSkills: mocks.loadSkillsMock, - reconcileSkillsAgentId: mocks.reconcileSkillsAgentIdMock, -})); -vi.mock("./controllers/usage.ts", () => ({ - loadUsage: mocks.loadUsageMock, -})); -vi.mock("./controllers/workboard.ts", () => ({ - loadWorkboard: mocks.loadWorkboardMock, - stopWorkboardLifecycleRefresh: mocks.stopWorkboardLifecycleRefreshMock, - stopWorkboardPolling: mocks.stopWorkboardPollingMock, -})); - -import { loadChannelsTab, refreshActiveTab, setTab } from "./app-settings.ts"; - -function createHost() { - return { - tab: "agents", - connected: true, - client: {}, - agentsPanel: "overview", - agentsSelectedId: "agent-b", - agentsList: { - defaultId: "agent-a", - agents: [{ id: "agent-a" }, { id: "agent-b" }], - }, - chatHasAutoScrolled: false, - logsAtBottom: false, - eventLog: [], - eventLogBuffer: [], - requestUpdate: vi.fn(), - updateComplete: Promise.resolve(), - cronRunsScope: "all", - cronRunsJobId: null as string | null, - sessionsChangedReloadTimer: null as number | ReturnType | null, - sessionsResult: null as { sessions: unknown[] } | null, - sessionKey: "main", - selectedAgentId: null as string | null, - hello: null as { auth?: { role?: string; scopes?: string[] } } | null, - settings: {}, - basePath: "", - }; -} - -type BufferedPerformanceEvent = { - event?: string; - payload?: Record; -}; - -function expectBufferedPerformanceEvent( - host: { eventLogBuffer: unknown[] }, - event: string, - expectedPayload: Record, -) { - const entry = host.eventLogBuffer.find((value): value is BufferedPerformanceEvent => { - if (!value || typeof value !== "object") { - return false; - } - const candidate = value as BufferedPerformanceEvent; - if (candidate.event !== event || !candidate.payload || typeof candidate.payload !== "object") { - return false; - } - return Object.entries(expectedPayload).every(([key, expected]) => { - return candidate.payload?.[key] === expected; - }); - }); - if (!entry) { - throw new Error(`Expected performance event ${event}`); - } - for (const [key, expected] of Object.entries(expectedPayload)) { - expect(entry.payload?.[key]).toBe(expected); - } - expect(entry.payload?.durationMs).toBeTypeOf("number"); - return entry.payload; -} - -describe("refreshActiveTab", () => { - beforeEach(() => { - for (const fn of Object.values(mocks)) { - fn.mockReset(); - } - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - const expectCommonAgentsTabRefresh = (host: ReturnType) => { - expect(mocks.loadAgentsMock).toHaveBeenCalledOnce(); - expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); - expect(mocks.loadAgentIdentitiesMock).toHaveBeenCalledWith(host, ["agent-a", "agent-b"]); - expect(mocks.loadAgentIdentityMock).toHaveBeenCalledWith(host, "agent-b"); - }; - const expectNoCronLoaders = () => { - expect(mocks.loadCronStatusMock).not.toHaveBeenCalled(); - expect(mocks.loadCronJobsPageMock).not.toHaveBeenCalled(); - expect(mocks.loadCronRunsMock).not.toHaveBeenCalled(); - }; - const panelLoaderArgs = { - files: [mocks.loadAgentFilesMock, "agent-b"], - skills: [mocks.loadAgentSkillsMock, "agent-b"], - channels: [mocks.loadChannelsMock, false], - tools: null, - } as const; - - it("syncs selected agent before refreshing the Dreams tab", async () => { - const host = createHost(); - host.tab = "dreams"; - host.sessionKey = "agent:research:main"; - mocks.loadDreamingStatusMock.mockImplementationOnce(async () => { - expect(host.selectedAgentId).toBe("research"); - }); - mocks.loadDreamDiaryMock.mockImplementationOnce(async () => { - expect(host.selectedAgentId).toBe("research"); - }); - - await refreshActiveTab(host as unknown as Parameters[0]); - - expect(host.selectedAgentId).toBe("research"); - expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); - expect(mocks.loadDreamingStatusMock).toHaveBeenCalledWith(host); - expect(mocks.loadDreamDiaryMock).toHaveBeenCalledWith(host); - expect(mocks.loadWikiImportInsightsMock).toHaveBeenCalledWith(host); - expect(mocks.loadWikiMemoryPalaceMock).toHaveBeenCalledWith(host); - }); - - for (const panel of ["files", "skills", "channels", "tools"] as const) { - it(`routes agents ${panel} panel refresh through the expected loaders`, async () => { - const host = createHost(); - host.agentsPanel = panel; - - await refreshActiveTab(host as never); - - expectCommonAgentsTabRefresh(host); - expect(mocks.loadAgentFilesMock).toHaveBeenCalledTimes(panel === "files" ? 1 : 0); - expect(mocks.loadAgentSkillsMock).toHaveBeenCalledTimes(panel === "skills" ? 1 : 0); - expect(mocks.loadChannelsMock).toHaveBeenCalledTimes(panel === "channels" ? 1 : 0); - const expectedLoader = panelLoaderArgs[panel]; - if (expectedLoader) { - const [loader, expectedArg] = expectedLoader; - expect(loader).toHaveBeenCalledWith(host, expectedArg); - } - expectNoCronLoaders(); - }); - } - - it("routes agents cron panel refresh through cron loaders", async () => { - const host = createHost(); - host.agentsPanel = "cron"; - host.cronRunsScope = "job"; - host.cronRunsJobId = "job-123"; - - await refreshActiveTab(host as never); - - expectCommonAgentsTabRefresh(host); - expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false); - expect(mocks.loadCronStatusMock).toHaveBeenCalledOnce(); - expect(mocks.loadCronJobsPageMock).toHaveBeenCalledWith(host, { tableFilters: false }); - expect(mocks.loadCronRunsMock).toHaveBeenCalledWith(host, "job-123"); - expect(mocks.loadAgentFilesMock).not.toHaveBeenCalled(); - expect(mocks.loadAgentSkillsMock).not.toHaveBeenCalled(); - }); - - it("loads the Channels tab without automatic live probes", async () => { - const host = createHost(); - - await loadChannelsTab(host as never); - - expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false); - expect(mocks.loadConfigSchemaMock).toHaveBeenCalledWith(host); - expect(mocks.loadConfigMock).toHaveBeenCalledWith(host); - }); - - it("refreshes logs tab by resetting bottom-follow and scheduling scroll", async () => { - const host = createHost(); - host.tab = "logs"; - - await refreshActiveTab(host as never); - - expect(host.logsAtBottom).toBe(true); - expect(mocks.loadLogsMock).toHaveBeenCalledWith(host, { reset: true }); - expect(mocks.scheduleLogsScrollMock).toHaveBeenCalledWith(host, true); - }); - - it("records tab visible timing without waiting for the tab refresh RPC", async () => { - const host = createHost(); - host.tab = "chat"; - const sessions = createDeferred(); - mocks.loadSessionsMock.mockReturnValueOnce(sessions.promise); - - setTab(host as never, "sessions"); - - expect(host.requestUpdate).toHaveBeenCalled(); - await vi.waitFor(() => { - expectBufferedPerformanceEvent(host, "control-ui.tab.visible", { - previousTab: "chat", - tab: "sessions", - }); - }); - - sessions.resolve(); - }); - - it("loads config before rendering session Workboard actions", async () => { - const host = createHost(); - host.tab = "sessions"; - - await refreshActiveTab(host as never); - - expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); - expect(mocks.loadSessionsMock).toHaveBeenCalledOnce(); - }); - - it("refreshes workboard cards with config, sessions, and agents", async () => { - const host = createHost(); - host.tab = "workboard"; - - await refreshActiveTab(host as never); - - expect(mocks.loadConfigMock).toHaveBeenCalledWith(host); - expect(mocks.loadSessionsMock).toHaveBeenCalledWith(host); - expect(mocks.loadAgentsMock).toHaveBeenCalledWith(host); - expect(mocks.loadWorkboardMock).toHaveBeenCalledWith({ - host, - client: host.client, - force: true, - requestUpdate: host.requestUpdate, - refreshDiagnostics: true, - }); - }); - - it("keeps read-only Workboard tab preload on the read refresh path", async () => { - const host = createHost(); - host.tab = "workboard"; - host.hello = { auth: { role: "operator", scopes: ["operator.read"] } }; - - await refreshActiveTab(host as never); - - expect(mocks.loadWorkboardMock).toHaveBeenCalledWith({ - host, - client: host.client, - force: true, - requestUpdate: host.requestUpdate, - refreshDiagnostics: false, - }); - }); - - it("loads agents before rendering the Skills tab agent selector", async () => { - const host = createHost(); - host.tab = "skills"; - const calls: string[] = []; - mocks.loadAgentsMock.mockImplementationOnce(async () => { - calls.push("agents"); - }); - mocks.reconcileSkillsAgentIdMock.mockImplementationOnce(() => { - calls.push("reconcile"); - }); - mocks.loadSkillsMock.mockImplementationOnce(async () => { - calls.push("skills"); - }); - - await refreshActiveTab(host as never); - - expect(calls).toEqual(["agents", "reconcile", "skills"]); - expect(mocks.loadAgentsMock).toHaveBeenCalledWith(host); - expect(mocks.reconcileSkillsAgentIdMock).toHaveBeenCalledWith(host, host.agentsList); - expect(mocks.loadSkillsMock).toHaveBeenCalledWith(host); - }); - - it("starts node polling and stops inactive tab pollers on tab changes", () => { - vi.useFakeTimers(); - const host = createHost(); - host.tab = "workboard"; - const pendingReload = vi.fn(); - host.sessionsChangedReloadTimer = globalThis.setTimeout(() => pendingReload(), 1_000); - - setTab(host as never, "nodes"); - - expect(host.sessionsChangedReloadTimer).toBeNull(); - expect(mocks.startNodesPollingMock).toHaveBeenCalledWith(host); - expect(mocks.stopLogsPollingMock).toHaveBeenCalledWith(host); - expect(mocks.stopDebugPollingMock).toHaveBeenCalledWith(host); - expect(mocks.stopWorkboardPollingMock).toHaveBeenCalledWith(host); - expect(mocks.stopWorkboardLifecycleRefreshMock).toHaveBeenCalledWith(host); - vi.advanceTimersByTime(1_000); - expect(pendingReload).not.toHaveBeenCalled(); - - setTab(host as never, "sessions"); - expect(mocks.stopNodesPollingMock).toHaveBeenCalledWith(host); - }); - - it("does not wait for secondary overview refreshes before resolving", async () => { - const host = createHost(); - host.tab = "overview"; - mocks.loadUsageMock.mockReturnValueOnce(new Promise(() => {})); - - const refresh = refreshActiveTab(host as never); - const outcome = await raceWithNextMacrotask(refresh); - - expect(outcome).toBe("resolved"); - expect(mocks.loadChannelsMock).toHaveBeenCalled(); - expect(mocks.loadSessionsMock).toHaveBeenCalled(); - expect(mocks.loadUsageMock).toHaveBeenCalled(); - }); - - it("skips overview usage refresh if the user leaves while primary loaders run", async () => { - const host = createHost(); - host.tab = "overview"; - const channels = createDeferred(); - mocks.loadChannelsMock.mockReturnValueOnce(channels.promise); - - const refresh = refreshActiveTab(host as never); - await Promise.resolve(); - host.tab = "sessions"; - channels.resolve(); - - await refresh; - - expect(mocks.loadUsageMock).not.toHaveBeenCalled(); - expect(mocks.loadSkillsMock).toHaveBeenCalledOnce(); - }); - - it("does not wait for config schema before resolving config tab refresh", async () => { - const host = createHost(); - host.tab = "config"; - const schema = createDeferred(); - mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise); - - const refresh = refreshActiveTab(host as never); - const outcome = await raceWithNextMacrotask(refresh); - - expect(outcome).toBe("resolved"); - expect(mocks.loadConfigSchemaMock).toHaveBeenCalledOnce(); - expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); - expect(host.requestUpdate).not.toHaveBeenCalled(); - - schema.resolve(); - - await vi.waitFor(() => { - expect(host.requestUpdate).toHaveBeenCalledOnce(); - }); - }); - - it("loads scoped settings snapshots before starting the schema refresh", async () => { - const host = createHost(); - host.tab = "communications"; - const config = createDeferred(); - mocks.loadConfigMock.mockReturnValueOnce(config.promise); - - const refresh = refreshActiveTab(host as never); - await Promise.resolve(); - - expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); - expect(mocks.loadConfigSchemaMock).not.toHaveBeenCalled(); - await expect(raceWithNextMacrotask(refresh)).resolves.toBe("pending"); - - config.resolve(); - await refresh; - - await vi.waitFor(() => { - expect(mocks.loadConfigSchemaMock).toHaveBeenCalledOnce(); - }); - }); - - it("loads config, sessions, and agents before rendering the Workboard tab", async () => { - const host = createHost(); - host.tab = "workboard"; - - await refreshActiveTab(host as never); - - expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); - expect(mocks.loadSessionsMock).toHaveBeenCalledOnce(); - expect(mocks.loadAgentsMock).toHaveBeenCalledOnce(); - expect(mocks.loadConfigSchemaMock).not.toHaveBeenCalled(); - }); - - it("does not start the deferred schema refresh when scoped settings fail to load", async () => { - const host = createHost(); - host.tab = "communications"; - const error = new Error("config unavailable"); - mocks.loadConfigMock.mockRejectedValueOnce(error); - - await expect(refreshActiveTab(host as never)).rejects.toBe(error); - await Promise.resolve(); - - expect(mocks.loadConfigSchemaMock).not.toHaveBeenCalled(); - }); - - it("renders channels from the cheap snapshot without waiting for config schema", async () => { - const host = createHost(); - host.tab = "channels"; - const schema = createDeferred(); - mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise); - - const refresh = refreshActiveTab(host as never); - const outcome = await raceWithNextMacrotask(refresh); - - expect(outcome).toBe("resolved"); - expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false]); - expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); - expect(host.requestUpdate).not.toHaveBeenCalled(); - - schema.resolve(); - - await vi.waitFor(() => { - expect(host.requestUpdate).toHaveBeenCalledOnce(); - }); - }); - - it("records overview secondary refresh duration and aggregate status", async () => { - const host = createHost(); - host.tab = "overview"; - const usage = createDeferred(); - mocks.loadUsageMock.mockReturnValueOnce(usage.promise); - mocks.loadSkillsMock.mockRejectedValueOnce(new Error("skills failed")); - - await refreshActiveTab(host as never); - usage.resolve(); - - await vi.waitFor(() => { - expectBufferedPerformanceEvent(host, "control-ui.overview.secondary", { - phase: "end", - status: "error", - }); - }); - }); - - it("does not wait for cron runs before resolving the cron tab refresh", async () => { - const host = createHost(); - host.tab = "cron"; - mocks.loadCronRunsMock.mockReturnValueOnce(new Promise<"ok">(() => {})); - - const refresh = refreshActiveTab(host as never); - const outcome = await raceWithNextMacrotask(refresh); - - expect(outcome).toBe("resolved"); - expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false); - expect(mocks.loadCronStatusMock).toHaveBeenCalledOnce(); - expect(mocks.loadCronJobsPageMock).toHaveBeenCalledWith(host, { tableFilters: true }); - expect(mocks.loadCronRunsMock).toHaveBeenCalledOnce(); - }); - - it("refreshes model auth status on the chat tab for the quota pill", async () => { - const host = createHost(); - host.tab = "chat"; - - await refreshActiveTab(host as never); - - expect(mocks.refreshChatMock).toHaveBeenCalledOnce(); - expect(mocks.loadModelAuthStatusStateMock).toHaveBeenCalledWith(host); - expect(mocks.scheduleChatScrollMock).toHaveBeenCalledOnce(); - }); - - it("hydrates the sidebar session list on chat startup as a background load", async () => { - const host = createHost(); - host.tab = "chat"; - host.sessionsResult = { sessions: [] }; - - await refreshActiveTab(host as never, { chatStartup: true }); - - expect(mocks.loadSessionsMock).toHaveBeenCalledWith( - host, - expect.objectContaining({ backgroundHydrate: true }), - ); - }); - - it("skips the sidebar session hydration on plain chat refreshes with data", async () => { - const host = createHost(); - host.tab = "chat"; - host.sessionsResult = { sessions: [] }; - - await refreshActiveTab(host as never); - - expect(mocks.loadSessionsMock).not.toHaveBeenCalled(); - }); - - it("does not wait for quota status before scrolling the chat tab", async () => { - const host = createHost(); - host.tab = "chat"; - const quotaRefresh = createDeferred(); - mocks.loadModelAuthStatusStateMock.mockReturnValueOnce(quotaRefresh.promise); - - const refresh = refreshActiveTab(host as never); - const outcome = await raceWithNextMacrotask(refresh); - - expect(outcome).toBe("resolved"); - expect(mocks.refreshChatMock).toHaveBeenCalledOnce(); - expect(mocks.scheduleChatScrollMock).toHaveBeenCalledOnce(); - - quotaRefresh.resolve(); - await quotaRefresh.promise; - }); - - it("preserves chat refresh failures while loading quota status", async () => { - const host = createHost(); - host.tab = "chat"; - mocks.refreshChatMock.mockRejectedValueOnce(new Error("chat refresh failed")); - - await expect(refreshActiveTab(host as never)).rejects.toThrow("chat refresh failed"); - - expect(mocks.loadModelAuthStatusStateMock).toHaveBeenCalledWith(host); - expect(mocks.scheduleChatScrollMock).not.toHaveBeenCalled(); - }); - - it("contains quota status failures on the chat tab", async () => { - const host = createHost(); - host.tab = "chat"; - mocks.loadModelAuthStatusStateMock.mockRejectedValueOnce(new Error("quota failed")); - - await expect(refreshActiveTab(host as never)).resolves.toBeUndefined(); - - expect(mocks.refreshChatMock).toHaveBeenCalledOnce(); - expect(mocks.scheduleChatScrollMock).toHaveBeenCalledOnce(); - }); - - it("records failed cron runs status from the controller outcome", async () => { - const host = createHost(); - host.tab = "cron"; - mocks.loadCronRunsMock.mockResolvedValueOnce("error" as const); - - await expect(refreshActiveTab(host as never)).resolves.toBeUndefined(); - await Promise.resolve(); - - expectBufferedPerformanceEvent(host, "control-ui.cron.runs", { - phase: "end", - status: "error", - }); - }); - - it("contains rejected cron runs refreshes without failing the primary cron tab refresh", async () => { - const host = createHost(); - host.tab = "cron"; - mocks.loadCronRunsMock.mockRejectedValueOnce(new Error("cron runs slow path failed")); - - await expect(refreshActiveTab(host as never)).resolves.toBeUndefined(); - await Promise.resolve(); - - expectBufferedPerformanceEvent(host, "control-ui.cron.runs", { - phase: "end", - status: "error", - }); - }); - - it("does not record stale cron run timing after leaving the cron tab", async () => { - const host = createHost(); - host.tab = "cron"; - const runs = createDeferred<"ok">(); - mocks.loadCronRunsMock.mockReturnValueOnce(runs.promise); - - await refreshActiveTab(host as never); - host.tab = "chat"; - runs.resolve("ok"); - await Promise.resolve(); - - expect( - host.eventLogBuffer.some( - (entry) => - Boolean(entry) && - typeof entry === "object" && - (entry as { event?: unknown }).event === "control-ui.cron.runs", - ), - ).toBe(false); - }); -}); diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts deleted file mode 100644 index 51435125d4b1..000000000000 --- a/ui/src/ui/app-settings.test.ts +++ /dev/null @@ -1,494 +0,0 @@ -// Control UI tests cover app settings behavior. -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createImportedCustomThemeFixture } from "../test-helpers/custom-theme.ts"; -import { createStorageMock } from "../test-helpers/storage.ts"; -import { - applyResolvedTheme, - applySettings, - applySettingsFromUrl, - setTabFromRoute, - syncThemeWithSettings, -} from "./app-settings.ts"; -import type { ThemeMode, ThemeName } from "./theme.ts"; - -type Tab = - | "agents" - | "overview" - | "channels" - | "instances" - | "sessions" - | "usage" - | "cron" - | "skills" - | "nodes" - | "chat" - | "config" - | "communications" - | "appearance" - | "automation" - | "infrastructure" - | "aiAgents" - | "debug" - | "logs"; - -type SettingsHost = { - settings: { - gatewayUrl: string; - token: string; - sessionKey: string; - lastActiveSessionKey: string; - theme: ThemeName; - themeMode: ThemeMode; - chatShowThinking: boolean; - chatShowToolCalls: boolean; - splitRatio: number; - navCollapsed: boolean; - navWidth: number; - navGroupsCollapsed: Record; - borderRadius: number; - textScale?: import("./storage.ts").TextScaleStop; - customTheme?: import("./custom-theme.ts").ImportedCustomTheme; - }; - theme: ThemeName & ThemeMode; - themeMode: ThemeMode; - themeResolved: import("./theme.ts").ResolvedTheme; - applySessionKey: string; - sessionKey: string; - tab: Tab; - connected: boolean; - chatHasAutoScrolled: boolean; - logsAtBottom: boolean; - eventLog: unknown[]; - eventLogBuffer: unknown[]; - password?: string; - basePath: string; - themeMedia: MediaQueryList | null; - themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; - logsPollInterval: number | null; - debugPollInterval: number | null; - pendingGatewayUrl?: string | null; - pendingGatewayToken?: string | null; - dreamingStatusLoading: boolean; - dreamingStatusError: string | null; - dreamingStatus: null; - dreamingModeSaving: boolean; - dreamDiaryLoading: boolean; - dreamDiaryActionLoading: boolean; - dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null; - dreamDiaryActionArchivePath: string | null; - dreamDiaryError: string | null; - dreamDiaryPath: string | null; - dreamDiaryContent: string | null; - wikiImportInsightsLoading: boolean; - wikiImportInsightsError: string | null; - wikiImportInsights: null; - wikiMemoryPalaceLoading: boolean; - wikiMemoryPalaceError: string | null; - wikiMemoryPalace: null; -}; - -function setTestWindowUrl(urlString: string) { - const current = new URL(urlString); - const history = { - replaceState: vi.fn((_state: unknown, _title: string, nextUrl: string | URL) => { - const next = new URL(String(nextUrl), current.toString()); - current.href = next.toString(); - current.protocol = next.protocol; - current.host = next.host; - current.pathname = next.pathname; - current.search = next.search; - current.hash = next.hash; - }), - }; - const locationLike = { - get href() { - return current.toString(); - }, - get protocol() { - return current.protocol; - }, - get host() { - return current.host; - }, - get pathname() { - return current.pathname; - }, - get search() { - return current.search; - }, - get hash() { - return current.hash; - }, - }; - vi.stubGlobal("window", { - location: locationLike, - history, - setInterval, - clearInterval, - } as unknown as Window & typeof globalThis); - vi.stubGlobal("location", locationLike as Location); - return { history, location: locationLike }; -} - -const createHost = (tab: Tab): SettingsHost => ({ - settings: { - gatewayUrl: "", - token: "", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatShowThinking: true, - chatShowToolCalls: true, - splitRatio: 0.6, - navCollapsed: false, - navWidth: 220, - navGroupsCollapsed: {}, - borderRadius: 50, - textScale: 100, - }, - theme: "claw" as unknown as ThemeName & ThemeMode, - themeMode: "system", - themeResolved: "dark", - applySessionKey: "main", - sessionKey: "main", - tab, - connected: false, - chatHasAutoScrolled: false, - logsAtBottom: false, - eventLog: [], - eventLogBuffer: [], - password: "", - basePath: "", - themeMedia: null, - themeMediaHandler: null, - logsPollInterval: null, - debugPollInterval: null, - pendingGatewayUrl: null, - pendingGatewayToken: null, - dreamingStatusLoading: false, - dreamingStatusError: null, - dreamingStatus: null, - dreamingModeSaving: false, - dreamDiaryLoading: false, - dreamDiaryActionLoading: false, - dreamDiaryActionMessage: null, - dreamDiaryActionArchivePath: null, - dreamDiaryError: null, - dreamDiaryPath: null, - dreamDiaryContent: null, - wikiImportInsightsLoading: false, - wikiImportInsightsError: null, - wikiImportInsights: null, - wikiMemoryPalaceLoading: false, - wikiMemoryPalaceError: null, - wikiMemoryPalace: null, -}); - -describe("setTabFromRoute", () => { - beforeEach(() => { - vi.stubGlobal("localStorage", createStorageMock()); - vi.stubGlobal("navigator", { language: "en-US" } as Navigator); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("starts and stops log polling based on the tab", () => { - const host = createHost("chat"); - - setTabFromRoute(host, "logs"); - expect(host.debugPollInterval).toBeNull(); - expect(host.logsPollInterval).not.toBe(host.debugPollInterval); - - setTabFromRoute(host, "chat"); - expect(host.logsPollInterval).toBeNull(); - }); - - it("starts and stops debug polling based on the tab", () => { - const host = createHost("chat"); - - setTabFromRoute(host, "debug"); - expect(host.logsPollInterval).toBeNull(); - expect(host.debugPollInterval).not.toBe(host.logsPollInterval); - - setTabFromRoute(host, "chat"); - expect(host.debugPollInterval).toBeNull(); - }); - - it("re-resolves the active palette when only themeMode changes", () => { - const host = createHost("chat"); - host.settings.theme = "knot"; - host.settings.themeMode = "dark"; - host.theme = "knot" as unknown as ThemeName & ThemeMode; - host.themeMode = "dark"; - host.themeResolved = "openknot"; - - applySettings(host, { - ...host.settings, - themeMode: "light", - }); - - expect(host.theme).toBe("knot"); - expect(host.themeMode).toBe("light"); - expect(host.themeResolved).toBe("openknot-light"); - }); - - it("applies normalized browser-local text scale", () => { - const host = createHost("chat"); - - applySettings(host, { - ...host.settings, - textScale: 125, - }); - - expect(host.settings.textScale).toBe(125); - expect(document.documentElement.style.getPropertyValue("--control-ui-text-scale")).toBe("1.25"); - }); - - it("syncs both theme family and mode from persisted settings", () => { - const host = createHost("chat"); - host.settings.theme = "dash"; - host.settings.themeMode = "light"; - - syncThemeWithSettings(host); - - expect(host.theme).toBe("dash"); - expect(host.themeMode).toBe("light"); - expect(host.themeResolved).toBe("dash-light"); - }); - - it("falls back to claw when custom is selected without a stored custom theme", () => { - const host = createHost("chat"); - host.settings.theme = "custom"; - host.settings.themeMode = "dark"; - - syncThemeWithSettings(host); - - expect(host.theme).toBe("claw"); - expect(host.settings.theme).toBe("claw"); - expect(host.themeResolved).toBe("dark"); - }); - - it("applies named system themes on OS preference changes", () => { - const listeners: Array<(event: MediaQueryListEvent) => void> = []; - const matchMedia = vi.fn().mockReturnValue({ - matches: false, - addEventListener: (_name: string, handler: (event: MediaQueryListEvent) => void) => { - listeners.push(handler); - }, - removeEventListener: vi.fn(), - }); - vi.stubGlobal("matchMedia", matchMedia); - Object.defineProperty(window, "matchMedia", { - configurable: true, - value: matchMedia, - }); - - const host = createHost("chat"); - host.settings.theme = "knot" as unknown as ThemeName & ThemeMode; - host.settings.themeMode = "system"; - - syncThemeWithSettings(host); - listeners[0]?.({ matches: true } as MediaQueryListEvent); - expect(host.themeResolved).toBe("openknot"); - - listeners[0]?.({ matches: false } as MediaQueryListEvent); - expect(host.themeResolved).toBe("openknot"); - }); - - it("normalizes light family themes to the shared light CSS token", () => { - const root = { - dataset: {} as DOMStringMap, - style: { colorScheme: "" } as CSSStyleDeclaration & { colorScheme: string }, - }; - vi.stubGlobal("document", { documentElement: root } as Document); - - const host = createHost("chat"); - applyResolvedTheme(host, "dash-light"); - - expect(host.themeResolved).toBe("dash-light"); - expect(root.dataset.theme).toBe("dash-light"); - expect(root.style.colorScheme).toBe("light"); - }); - - it("applies imported custom light themes as light-mode tokens", () => { - const root = { - dataset: {} as DOMStringMap, - style: { colorScheme: "" } as CSSStyleDeclaration & { colorScheme: string }, - }; - vi.stubGlobal("document", { documentElement: root } as Document); - - const host = createHost("chat"); - host.settings.customTheme = createImportedCustomThemeFixture(); - applyResolvedTheme(host, "custom-light"); - - expect(host.themeResolved).toBe("custom-light"); - expect(root.dataset.theme).toBe("custom-light"); - expect(root.style.colorScheme).toBe("light"); - }); -}); - -describe("applySettingsFromUrl", () => { - beforeEach(() => { - vi.stubGlobal("localStorage", createStorageMock()); - vi.stubGlobal("sessionStorage", createStorageMock()); - vi.stubGlobal("navigator", { language: "en-US" } as Navigator); - setTestWindowUrl("https://control.example/ui/overview"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("hydrates query token params and strips them from the URL", () => { - setTestWindowUrl("https://control.example/ui/overview?token=abc123&password=sekret"); - const host = createHost("overview"); - host.settings.gatewayUrl = "wss://control.example/openclaw"; - - applySettingsFromUrl(host); - - expect(host.settings.token).toBe("abc123"); - expect(window.location.search).toBe(""); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - }); - - it("prefers fragment tokens over legacy query tokens when both are present", () => { - setTestWindowUrl("https://control.example/ui/overview?token=query-token#token=hash-token"); - const host = createHost("overview"); - host.settings.gatewayUrl = "wss://control.example/openclaw"; - - applySettingsFromUrl(host); - - expect(host.settings.token).toBe("hash-token"); - expect(window.location.search).toBe(""); - expect(window.location.hash).toBe(""); - }); - - it("hydrates native Mac app auth before the first connection", () => { - setTestWindowUrl("https://control.example/ui/chat"); - ( - window as unknown as { - __OPENCLAW_NATIVE_CONTROL_AUTH__?: { - gatewayUrl?: string; - token?: string; - password?: string; - }; - } - )["__OPENCLAW_NATIVE_CONTROL_AUTH__"] = { - gatewayUrl: "wss://control.example/ui/", - token: "device-token", - password: "shared-password", - }; - const host = createHost("chat"); - - applySettingsFromUrl(host); - - expect(host.settings.gatewayUrl).toBe("wss://control.example/ui/"); - expect(host.settings.token).toBe("device-token"); - expect(host.password).toBe("shared-password"); - expect( - ( - window as unknown as { - __OPENCLAW_NATIVE_CONTROL_AUTH__?: unknown; - } - )["__OPENCLAW_NATIVE_CONTROL_AUTH__"], - ).toBeUndefined(); - }); - - it("resets stale persisted session selection to main when a token is supplied without a session", () => { - setTestWindowUrl("https://control.example/chat#token=test-token"); - const host = createHost("chat"); - host.settings = { - ...host.settings, - gatewayUrl: "ws://localhost:18789", - token: "", - sessionKey: "agent:test_old:main", - lastActiveSessionKey: "agent:test_old:main", - }; - host.sessionKey = "agent:test_old:main"; - - applySettingsFromUrl(host); - - expect(host.sessionKey).toBe("main"); - expect(host.settings.sessionKey).toBe("main"); - expect(host.settings.lastActiveSessionKey).toBe("main"); - }); - - it("characterizes token, session, and gateway URL combinations", () => { - const scenarios = [ - { - name: "same gateway applies token and session immediately", - url: "https://control.example/chat?session=agent%3Atest_new%3Amain#token=token-a", - settingsGatewayUrl: "ws://gateway-a.example:18789", - settingsToken: "", - expectedToken: "token-a", - expectedSession: "agent:test_new:main", - expectedPendingGatewayUrl: null, - expectedPendingGatewayToken: null, - expectedSearch: "?session=agent%3Atest_new%3Amain", - }, - { - name: "different gateway defers token and keeps explicit session", - url: "https://control.example/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789&session=agent%3Atest_new%3Amain#token=token-b", - settingsGatewayUrl: "ws://gateway-a.example:18789", - settingsToken: "", - expectedToken: "", - expectedSession: "agent:test_new:main", - expectedPendingGatewayUrl: "ws://gateway-b.example:18789", - expectedPendingGatewayToken: "token-b", - expectedSearch: "?session=agent%3Atest_new%3Amain", - }, - { - name: "different gateway defers token without changing session", - url: "https://control.example/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789#token=token-c", - settingsGatewayUrl: "ws://gateway-a.example:18789", - settingsToken: "", - expectedToken: "", - expectedSession: "agent:test_old:main", - expectedPendingGatewayUrl: "ws://gateway-b.example:18789", - expectedPendingGatewayToken: "token-c", - expectedSearch: "", - }, - { - name: "different gateway without token clears pending token", - url: "https://control.example/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789&session=agent%3Atest_new%3Amain", - settingsGatewayUrl: "ws://gateway-a.example:18789", - settingsToken: "existing-token", - expectedToken: "existing-token", - expectedSession: "agent:test_new:main", - expectedPendingGatewayUrl: "ws://gateway-b.example:18789", - expectedPendingGatewayToken: null, - expectedSearch: "?session=agent%3Atest_new%3Amain", - }, - ] as const; - - for (const scenario of scenarios) { - setTestWindowUrl(scenario.url); - const host = createHost("chat"); - host.settings = { - ...host.settings, - gatewayUrl: scenario.settingsGatewayUrl, - token: scenario.settingsToken, - sessionKey: "agent:test_old:main", - lastActiveSessionKey: "agent:test_old:main", - }; - host.sessionKey = "agent:test_old:main"; - - applySettingsFromUrl(host); - - expect(host.settings.token, scenario.name).toBe(scenario.expectedToken); - expect(host.sessionKey, scenario.name).toBe(scenario.expectedSession); - expect(host.settings.sessionKey, scenario.name).toBe(scenario.expectedSession); - expect(host.settings.lastActiveSessionKey, scenario.name).toBe(scenario.expectedSession); - expect(host.pendingGatewayUrl, scenario.name).toBe(scenario.expectedPendingGatewayUrl); - expect(host.pendingGatewayToken, scenario.name).toBe(scenario.expectedPendingGatewayToken); - expect(window.location.search, scenario.name).toBe(scenario.expectedSearch); - expect(window.location.hash, scenario.name).toBe(""); - } - }); -}); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts deleted file mode 100644 index 2736c6020ac2..000000000000 --- a/ui/src/ui/app-settings.ts +++ /dev/null @@ -1,1065 +0,0 @@ -// Control UI module implements app settings behavior. -import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js"; -import { t } from "../i18n/index.ts"; -import { - createChatSessionsLoadOverrides, - refreshChat, - scopedAgentListParamsForSession, -} from "./app-chat.ts"; -import { - startLogsPolling, - startNodesPolling, - stopLogsPolling, - stopNodesPolling, - startDebugPolling, - stopDebugPolling, -} from "./app-polling.ts"; -import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts"; -import { - beginControlUiRefresh, - controlUiNowMs, - finishControlUiRefresh, - recordControlUiPerformanceEvent, - roundedControlUiDurationMs, - scheduleControlUiTabVisibleTiming, -} from "./control-ui-performance.ts"; -import { loadAgentFiles, type AgentFilesState } from "./controllers/agent-files.ts"; -import { - loadAgentIdentities, - loadAgentIdentity, - type AgentIdentityState, -} from "./controllers/agent-identity.ts"; -import { loadAgentSkills, type AgentSkillsState } from "./controllers/agent-skills.ts"; -import { loadAgents, type AgentsState } from "./controllers/agents.ts"; -import { loadChannels, type ChannelsState } from "./controllers/channels.ts"; -import { loadConfig, loadConfigSchema, type ConfigState } from "./controllers/config.ts"; -import { - loadCronJobsPage, - loadCronRuns, - loadCronStatus, - type CronState, -} from "./controllers/cron.ts"; -import { loadDebug, type DebugState } from "./controllers/debug.ts"; -import { loadDevices, type DevicesState } from "./controllers/devices.ts"; -import { - loadDreamDiary, - loadDreamingStatus, - loadWikiImportInsights, - loadWikiMemoryPalace, - type DreamingState, -} from "./controllers/dreaming.ts"; -import { loadExecApprovals, type ExecApprovalsState } from "./controllers/exec-approvals.ts"; -import { loadLogs, type LogsState } from "./controllers/logs.ts"; -import { - loadModelAuthStatusState, - type ModelAuthStatusState, -} from "./controllers/model-auth-status.ts"; -import { loadNodes, type NodesState } from "./controllers/nodes.ts"; -import { loadPresence, type PresenceState } from "./controllers/presence.ts"; -import { loadSessions, type SessionsState } from "./controllers/sessions.ts"; -import { - loadSkillWorkshopProposals, - type SkillWorkshopState, -} from "./controllers/skill-workshop.ts"; -import { loadSkills, reconcileSkillsAgentId, type SkillsState } from "./controllers/skills.ts"; -import { loadUsage, type UsageState } from "./controllers/usage.ts"; -import { - loadWorkboard, - stopWorkboardLifecycleRefresh, - stopWorkboardPolling, -} from "./controllers/workboard.ts"; -import { isCronJobActiveFailure } from "./cron-status.ts"; -import { syncCustomThemeStyleTag } from "./custom-theme.ts"; -import { isMonitoredAuthProvider } from "./model-auth-helpers.ts"; -import { - inferBasePathFromPathname, - normalizeBasePath, - normalizePath, - pathForTab, - tabFromPath, - type Tab, -} from "./navigation.ts"; -import { normalizeAgentId, parseAgentSessionKey } from "./session-key.ts"; -import { - normalizeTextScale, - saveLocalUserIdentity, - saveSettings, - type LocalUserIdentity, - type UiSettings, -} from "./storage.ts"; -import { normalizeOptionalString } from "./string-coerce.ts"; -import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts"; -import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts"; -import type { AgentsListResult, AttentionItem } from "./types.ts"; -import { normalizeLocalUserIdentity } from "./user-identity.ts"; -import { resetChatViewState } from "./views/chat.ts"; - -export { setLastActiveSessionKey } from "./app-last-active-session.ts"; - -type SettingsHost = { - settings: UiSettings; - userName?: string | null; - userAvatar?: string | null; - password?: string; - theme: ThemeName; - themeMode: ThemeMode; - themeResolved: ResolvedTheme; - applySessionKey: string; - sessionKey: string; - tab: Tab; - connected: boolean; - chatHasAutoScrolled: boolean; - logsAtBottom: boolean; - eventLog: unknown[]; - eventLogBuffer: unknown[]; - basePath: string; - agentsList?: AgentsListResult | null; - selectedAgentId?: string | null; - agentsSelectedId?: string | null; - agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; - pendingGatewayUrl?: string | null; - systemThemeCleanup?: (() => void) | null; - pendingGatewayToken?: string | null; - requestUpdate?: () => void; - updateComplete?: Promise; - controlUiRefreshSeq?: number; - controlUiTabPaintSeq?: number; - controlUiOverviewRefreshSeq?: number; - controlUiCronRefreshSeq?: number; - sessionsChangedReloadTimer?: number | ReturnType | null; - dreamingStatusLoading: boolean; - dreamingStatusError: string | null; - dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null; - dreamingModeSaving: boolean; - dreamDiaryLoading: boolean; - dreamDiaryError: string | null; - dreamDiaryPath: string | null; - dreamDiaryContent: string | null; -}; - -type LocalUserIdentityHost = { - userName?: string | null; - userAvatar?: string | null; -}; - -function resolveDreamingAgentIdForSession(host: SettingsHost): string { - return normalizeAgentId( - parseAgentSessionKey(host.sessionKey)?.agentId ?? host.agentsList?.defaultId ?? "main", - ); -} - -type SettingsAppHost = SettingsHost & - AgentFilesState & - AgentIdentityState & - AgentSkillsState & - AgentsState & - ChannelsState & - ConfigState & - CronState & - DebugState & - DevicesState & - DreamingState & - ExecApprovalsState & - LogsState & - NodesState & - PresenceState & - SessionsState & - SkillsState & - SkillWorkshopState & - ModelAuthStatusState & - UsageState & { - overviewLogCursor: number | null; - overviewLogLines: string[]; - attentionItems: AttentionItem[]; - hello: { auth?: { role?: string; scopes?: string[] } } | null; - }; - -export function applySettings(host: SettingsHost, next: UiSettings) { - const normalized = { - ...next, - textScale: normalizeTextScale(next.textScale), - lastActiveSessionKey: - normalizeOptionalString(next.lastActiveSessionKey) ?? - normalizeOptionalString(next.sessionKey) ?? - "main", - }; - host.settings = normalized; - saveSettings(normalized); - syncCustomThemeStyleTag(normalized.customTheme); - if (next.theme !== host.theme || next.themeMode !== host.themeMode) { - host.theme = next.theme; - host.themeMode = next.themeMode; - applyResolvedTheme(host, resolveTheme(next.theme, next.themeMode)); - } - applyBorderRadius(normalized.borderRadius); - applyTextScale(normalized.textScale); - host.applySessionKey = host.settings.lastActiveSessionKey; -} - -export function applyLocalUserIdentity( - host: LocalUserIdentityHost, - next: Partial, -) { - const normalized = normalizeLocalUserIdentity({ - name: host.userName, - avatar: host.userAvatar, - ...next, - }); - host.userName = normalized.name; - host.userAvatar = normalized.avatar; - saveLocalUserIdentity(normalized); -} - -function applySessionSelection(host: SettingsHost, session: string) { - host.sessionKey = session; - applySettings(host, { - ...host.settings, - sessionKey: session, - lastActiveSessionKey: session, - }); -} - -/** Set to true when the token is read from a query string (?token=) instead of a URL fragment. */ -export let warnQueryToken = false; - -declare global { - interface Window { - __OPENCLAW_NATIVE_CONTROL_AUTH__?: { - gatewayUrl?: string | null; - token?: string | null; - password?: string | null; - }; - } -} - -function applyNativeControlAuth(host: SettingsHost) { - const nativeAuth = window["__OPENCLAW_NATIVE_CONTROL_AUTH__"]; - if (!nativeAuth) { - return; - } - try { - delete window["__OPENCLAW_NATIVE_CONTROL_AUTH__"]; - } catch { - window["__OPENCLAW_NATIVE_CONTROL_AUTH__"] = undefined; - } - - const gatewayUrl = normalizeOptionalString(nativeAuth.gatewayUrl); - const token = normalizeOptionalString(nativeAuth.token); - const password = normalizeOptionalString(nativeAuth.password); - const nextSettings = { - ...host.settings, - ...(gatewayUrl ? { gatewayUrl } : {}), - ...(token ? { token } : {}), - }; - if (gatewayUrl || (token && token !== host.settings.token)) { - applySettings(host, nextSettings); - } - if (password && password !== host.password) { - host.password = password; - } -} - -export function applySettingsFromUrl(host: SettingsHost) { - applyNativeControlAuth(host); - if (!window.location.search && !window.location.hash) { - return; - } - const url = new URL(window.location.href); - const params = new URLSearchParams(url.search); - const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); - - const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl"); - const nextGatewayUrl = normalizeOptionalString(gatewayUrlRaw) ?? ""; - const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl); - // Prefer fragment tokens over query tokens. Fragments avoid server-side request - // logs and referrer leakage; query-param tokens remain a one-time legacy fallback - // for compatibility with older deep links. - const queryToken = params.get("token"); - const hashToken = hashParams.get("token"); - const hasTokenParam = hashToken != null || queryToken != null; - const token = normalizeOptionalString(hashToken ?? queryToken); - const session = normalizeOptionalString(params.get("session") ?? hashParams.get("session")); - const shouldResetSessionForToken = Boolean(token && !session && !gatewayUrlChanged); - let shouldCleanUrl = false; - - if (params.has("token")) { - params.delete("token"); - shouldCleanUrl = true; - } - - if (hasTokenParam) { - if (queryToken != null) { - warnQueryToken = true; - console.warn( - "[openclaw] Auth token passed as query parameter (?token=). Use URL fragment instead: #token=. Query parameters may appear in server logs.", - ); - } - if (token && gatewayUrlChanged) { - host.pendingGatewayToken = token; - } else if (token && token !== host.settings.token) { - applySettings(host, { ...host.settings, token }); - } - hashParams.delete("token"); - shouldCleanUrl = true; - } - - if (shouldResetSessionForToken) { - host.sessionKey = "main"; - applySettings(host, { - ...host.settings, - sessionKey: "main", - lastActiveSessionKey: "main", - }); - } - - if (params.has("password") || hashParams.has("password")) { - // Never hydrate password from URL params; strip only. - params.delete("password"); - hashParams.delete("password"); - shouldCleanUrl = true; - } - - if (session) { - applySessionSelection(host, session); - } - - if (gatewayUrlRaw != null) { - host.pendingGatewayUrl = gatewayUrlChanged ? nextGatewayUrl : null; - host.pendingGatewayToken = gatewayUrlChanged ? (token ?? null) : null; - params.delete("gatewayUrl"); - hashParams.delete("gatewayUrl"); - shouldCleanUrl = true; - } - - if (!shouldCleanUrl) { - return; - } - url.search = params.toString(); - const nextHash = hashParams.toString(); - url.hash = nextHash ? `#${nextHash}` : ""; - updateBrowserHistory(url, true); -} - -export function setTab(host: SettingsHost, next: Tab) { - applyTabSelection(host, next, { refreshPolicy: "always", syncUrl: true }); -} - -function applyThemeTransition( - host: SettingsHost, - nextTheme: ResolvedTheme, - applyTheme: () => void, - context?: ThemeTransitionContext, -) { - startThemeTransition({ - nextTheme, - applyTheme, - context, - currentTheme: host.themeResolved, - }); - syncSystemThemeListener(host); -} - -export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) { - applyThemeTransition( - host, - resolveTheme(next, host.themeMode), - () => applySettings(host, { ...host.settings, theme: next }), - context, - ); -} - -export function setThemeMode( - host: SettingsHost, - next: ThemeMode, - context?: ThemeTransitionContext, -) { - applyThemeTransition( - host, - resolveTheme(host.theme, next), - () => applySettings(host, { ...host.settings, themeMode: next }), - context, - ); -} - -async function refreshAgentsTab(host: SettingsHost, app: SettingsAppHost) { - await loadAgents(app); - await loadConfig(app); - const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; - if (agentIds.length > 0) { - void loadAgentIdentities(app, agentIds); - } - const agentId = - host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id; - if (!agentId) { - return; - } - void loadAgentIdentity(app, agentId); - switch (host.agentsPanel) { - case "files": - void loadAgentFiles(app, agentId); - return; - case "skills": - void loadAgentSkills(app, agentId); - return; - case "channels": - void loadChannels(app, false); - return; - case "cron": - void loadCron(host); - case "overview": - case "tools": - case undefined: - } -} - -function loadConfigSchemaAfterPrimary( - host: SettingsHost, - app: SettingsAppHost, - primaryRefresh: Promise, -) { - void primaryRefresh.then( - () => { - void loadConfigSchema(app).finally(() => host.requestUpdate?.()); - }, - () => undefined, - ); -} - -export async function refreshActiveTab(host: SettingsHost, opts?: { chatStartup?: boolean }) { - const app = host as unknown as SettingsAppHost; - const refreshRun = beginControlUiRefresh(host, host.tab); - try { - switch (host.tab) { - case "config": - case "communications": - case "appearance": - case "automation": - case "mcp": - case "infrastructure": - case "aiAgents": - { - const primaryRefresh = loadConfig(app); - loadConfigSchemaAfterPrimary(host, app, primaryRefresh); - await primaryRefresh; - } - break; - case "overview": - await loadOverview(host); - break; - case "activity": - break; - case "workboard": - await Promise.all([ - loadConfig(app), - loadSessions(app), - loadAgents(app), - loadWorkboard({ - host, - client: app.client, - force: true, - requestUpdate: host.requestUpdate, - refreshDiagnostics: hasOperatorWriteAccess(app.hello?.auth ?? null), - }), - ]); - break; - case "channels": - await loadChannelsTab(host); - break; - case "instances": - await loadPresence(app); - break; - case "usage": - await loadUsage(app); - break; - case "sessions": - await Promise.all([loadConfig(app), loadSessions(app)]); - break; - case "cron": - await loadCron(host); - break; - case "skills": - await loadAgents(app); - reconcileSkillsAgentId(app, app.agentsList); - await loadSkills(app); - break; - case "skillWorkshop": - await loadSkillWorkshopProposals(app, { force: true }); - break; - case "agents": - await refreshAgentsTab(host, app); - break; - case "nodes": - await loadNodes(app); - await Promise.allSettled([loadDevices(app), loadConfig(app), loadExecApprovals(app)]); - break; - case "dreams": - host.selectedAgentId = resolveDreamingAgentIdForSession(host); - await loadConfig(app); - await Promise.all([ - loadDreamingStatus(app), - loadDreamDiary(app), - loadWikiImportInsights(app), - loadWikiMemoryPalace(app), - ]); - break; - case "chat": { - // Captured before refreshChat, which seeds a one-row sessionsResult - // via applyChatHistorySessionInfo and would mask the missing list. - const hadSessionsResult = Boolean(app.sessionsResult); - try { - await refreshChat(host as unknown as Parameters[0], { - awaitHistory: opts?.chatStartup === true, - startup: opts?.chatStartup === true, - }); - scheduleChatScroll( - host as unknown as Parameters[0], - !host.chatHasAutoScrolled, - ); - } finally { - void loadModelAuthStatusState(app).catch(() => undefined); - // The sidebar session list is the chat entry point; hydrate the full - // list on chat startup and on first entry from tabs that never load - // sessions. Uses the chat picker's recency-free overrides so an old - // open session is not filtered out of its own list; backgroundHydrate - // keeps New Session enabled and the live run state untouched. - if (opts?.chatStartup === true || !hadSessionsResult) { - void loadSessions(app, { - ...createChatSessionsLoadOverrides(app), - ...scopedAgentListParamsForSession(app, app.sessionKey), - backgroundHydrate: true, - }).catch(() => undefined); - } - } - break; - } - case "debug": - await loadDebug(app); - host.eventLog = host.eventLogBuffer; - break; - case "logs": - host.logsAtBottom = true; - await loadLogs(app, { reset: true }); - scheduleLogsScroll(host as unknown as Parameters[0], true); - break; - } - finishControlUiRefresh(host, refreshRun, "ok"); - } catch (err) { - finishControlUiRefresh(host, refreshRun, "error"); - throw err; - } -} - -export function inferBasePath() { - if (typeof window === "undefined") { - return ""; - } - const configured = window["__OPENCLAW_CONTROL_UI_BASE_PATH__"]; - const normalizedConfigured = normalizeOptionalString(configured); - if (normalizedConfigured) { - return normalizeBasePath(normalizedConfigured); - } - return inferBasePathFromPathname(window.location.pathname); -} - -export function syncThemeWithSettings(host: SettingsHost) { - syncCustomThemeStyleTag(host.settings.customTheme); - const normalizedTheme = - host.settings.theme === "custom" && !host.settings.customTheme - ? "claw" - : (host.settings.theme ?? "claw"); - host.theme = normalizedTheme; - host.themeMode = host.settings.themeMode ?? "system"; - if (normalizedTheme !== host.settings.theme) { - host.settings = { ...host.settings, theme: normalizedTheme }; - saveSettings(host.settings); - } - applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode)); - applyBorderRadius(host.settings.borderRadius ?? 50); - applyTextScale(host.settings.textScale); - syncSystemThemeListener(host); -} - -export function detachThemeListener(host: SettingsHost) { - host.systemThemeCleanup?.(); - host.systemThemeCleanup = null; -} - -const BASE_RADII = { sm: 6, md: 10, lg: 14, xl: 20, full: 9999, default: 10 }; - -export function applyBorderRadius(value: number) { - if (typeof document === "undefined") { - return; - } - const root = document.documentElement; - const scale = value / 50; - root.style.setProperty("--radius-sm", `${Math.round(BASE_RADII.sm * scale)}px`); - root.style.setProperty("--radius-md", `${Math.round(BASE_RADII.md * scale)}px`); - root.style.setProperty("--radius-lg", `${Math.round(BASE_RADII.lg * scale)}px`); - root.style.setProperty("--radius-xl", `${Math.round(BASE_RADII.xl * scale)}px`); - root.style.setProperty("--radius-full", `${Math.round(BASE_RADII.full * scale)}px`); - root.style.setProperty("--radius", `${Math.round(BASE_RADII.default * scale)}px`); -} - -export function applyTextScale(value: unknown) { - if (typeof document === "undefined") { - return; - } - const root = document.documentElement; - const scale = normalizeTextScale(value) / 100; - root.style.setProperty("--control-ui-text-scale", scale.toFixed(2)); -} - -export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) { - host.themeResolved = resolved; - if (typeof document === "undefined") { - return; - } - const root = document.documentElement; - const themeMode = resolved.endsWith("light") ? "light" : "dark"; - root.dataset.theme = resolved; - root.dataset.themeMode = themeMode; - root.style.colorScheme = themeMode; -} - -function syncSystemThemeListener(host: SettingsHost) { - // Clean up existing listener if mode is not "system" - if (host.themeMode !== "system") { - host.systemThemeCleanup?.(); - host.systemThemeCleanup = null; - return; - } - - // Skip if listener already attached for this host - if (host.systemThemeCleanup) { - return; - } - - if (typeof globalThis.matchMedia !== "function") { - return; - } - - const mql = globalThis.matchMedia("(prefers-color-scheme: light)"); - const onChange = () => { - if (host.themeMode !== "system") { - return; - } - applyResolvedTheme(host, resolveTheme(host.theme, "system")); - }; - if (typeof mql.addEventListener === "function") { - mql.addEventListener("change", onChange); - host.systemThemeCleanup = () => mql.removeEventListener("change", onChange); - return; - } - if (typeof mql.addListener === "function") { - mql.addListener(onChange); - host.systemThemeCleanup = () => mql.removeListener(onChange); - } -} - -export function syncTabWithLocation(host: SettingsHost, replace: boolean) { - if (typeof window === "undefined") { - return; - } - const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat"; - setTabFromRoute(host, resolved); - syncUrlWithTab(host, resolved, replace); -} - -export function onPopState(host: SettingsHost) { - if (typeof window === "undefined") { - return; - } - const resolved = tabFromPath(window.location.pathname, host.basePath); - if (!resolved) { - return; - } - - const url = new URL(window.location.href); - const session = normalizeOptionalString(url.searchParams.get("session")); - if (session) { - applySessionSelection(host, session); - } - - setTabFromRoute(host, resolved); -} - -export function setTabFromRoute(host: SettingsHost, next: Tab) { - applyTabSelection(host, next, { refreshPolicy: "connected" }); -} - -function clearPendingSessionsChangedReload(host: SettingsHost) { - if (host.sessionsChangedReloadTimer == null) { - return; - } - globalThis.clearTimeout(host.sessionsChangedReloadTimer); - host.sessionsChangedReloadTimer = null; -} - -function updateBrowserHistory(url: URL, replace: boolean) { - const history = typeof window === "undefined" ? undefined : window.history; - if (!history) { - return; - } - if (replace) { - return history.replaceState({}, "", url.toString()); - } - return history.pushState({}, "", url.toString()); -} - -function applyTabSelection( - host: SettingsHost, - next: Tab, - options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean }, -) { - const prev = host.tab; - host.tab = next; - if (prev !== next) { - scheduleControlUiTabVisibleTiming(host, prev, next); - clearPendingSessionsChangedReload(host); - } - - // Cleanup chat module state when navigating away from chat - if (prev === "chat" && next !== "chat") { - resetChatViewState(); - } - - if (next === "chat") { - host.chatHasAutoScrolled = false; - } - (next === "logs" ? startLogsPolling : stopLogsPolling)( - host as unknown as Parameters[0], - ); - (next === "nodes" ? startNodesPolling : stopNodesPolling)( - host as unknown as Parameters[0], - ); - (next === "debug" ? startDebugPolling : stopDebugPolling)( - host as unknown as Parameters[0], - ); - if (next !== "workboard") { - stopWorkboardPolling(host as unknown as Parameters[0]); - stopWorkboardLifecycleRefresh( - host as unknown as Parameters[0], - ); - } - - if (options.refreshPolicy === "always" || host.connected) { - void refreshActiveTab(host); - } - - if (options.syncUrl) { - syncUrlWithTab(host, next, false); - } -} - -export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) { - const href = typeof window === "undefined" ? undefined : window.location?.href; - const pathname = typeof window === "undefined" ? undefined : window.location?.pathname; - if (!href || !pathname) { - return; - } - const targetPath = normalizePath(pathForTab(tab, host.basePath)); - const currentPath = normalizePath(pathname); - const url = new URL(href); - - if (tab === "chat" && host.sessionKey) { - url.searchParams.set("session", host.sessionKey); - } else { - url.searchParams.delete("session"); - } - - if (currentPath !== targetPath) { - url.pathname = targetPath; - } - - updateBrowserHistory(url, replace); -} - -export function syncUrlWithSessionKey( - _hostValue: SettingsHost, - sessionKey: string, - replace: boolean, -) { - const href = typeof window === "undefined" ? undefined : window.location?.href; - if (!href) { - return; - } - const url = new URL(href); - url.searchParams.set("session", sessionKey); - updateBrowserHistory(url, replace); -} - -export async function loadOverview(host: SettingsHost, opts?: { refresh?: boolean }) { - const app = host as SettingsAppHost; - const overviewSeq = (host.controlUiOverviewRefreshSeq ?? 0) + 1; - host.controlUiOverviewRefreshSeq = overviewSeq; - const isCurrentOverviewRefresh = () => - host.controlUiOverviewRefreshSeq === overviewSeq && host.tab === "overview"; - - await Promise.allSettled([ - loadChannels(app, false), - loadPresence(app), - loadSessions(app), - loadCronStatus(app), - loadCronJobsPage(app), - ]); - if (isCurrentOverviewRefresh()) { - buildAttentionItems(app); - } - - const secondaryStartedAtMs = controlUiNowMs(); - void Promise.allSettled([ - loadDebug(app), - loadSkills(app), - // The primary overview loaders can finish after the user has navigated away. - // Avoid starting the expensive usage RPC for stale overview refreshes. - isCurrentOverviewRefresh() ? loadUsage(app) : Promise.resolve(), - loadOverviewLogs(app), - // `refresh: true` bypasses the gateway's 60s auth-status cache so a - // user-initiated refresh surfaces post-re-auth state immediately. - loadModelAuthStatusState(app, { refresh: opts?.refresh }), - ]).then((results) => { - if (!isCurrentOverviewRefresh()) { - return; - } - const status = results.some((result) => result.status === "rejected") ? "error" : "ok"; - buildAttentionItems(app); - recordControlUiPerformanceEvent( - app, - "control-ui.overview.secondary", - { - phase: "end", - status, - durationMs: roundedControlUiDurationMs(controlUiNowMs() - secondaryStartedAtMs), - }, - { console: false }, - ); - }); -} - -export function hasOperatorReadAccess( - auth: { role?: string; scopes?: readonly string[] } | null, -): boolean { - if (!auth?.scopes) { - return false; - } - return roleScopesAllow({ - role: auth.role ?? "operator", - requestedScopes: ["operator.read"], - allowedScopes: auth.scopes, - }); -} - -export function hasOperatorWriteAccess( - auth: { role?: string; scopes?: readonly string[] } | null, -): boolean { - if (!auth?.scopes) { - return true; - } - return roleScopesAllow({ - role: auth.role ?? "operator", - requestedScopes: ["operator.write"], - allowedScopes: auth.scopes, - }); -} - -export function hasOperatorAdminAccess( - auth: { role?: string; scopes?: readonly string[] } | null, -): boolean { - if (!auth?.scopes) { - return true; - } - return roleScopesAllow({ - role: auth.role ?? "operator", - requestedScopes: ["operator.admin"], - allowedScopes: auth.scopes, - }); -} - -export function hasMissingSkillDependencies( - missing: Record | null | undefined, -): boolean { - if (!missing) { - return false; - } - return Object.values(missing).some((value) => Array.isArray(value) && value.length > 0); -} - -async function loadOverviewLogs(host: SettingsAppHost) { - if (!host.client || !host.connected) { - return; - } - try { - const res = await host.client.request("logs.tail", { - cursor: host.overviewLogCursor || undefined, - limit: 100, - maxBytes: 50_000, - }); - const payload = res as { - cursor?: number; - lines?: unknown; - }; - const lines = Array.isArray(payload.lines) - ? payload.lines.filter((line): line is string => typeof line === "string") - : []; - host.overviewLogLines = [...host.overviewLogLines, ...lines].slice(-500); - if (typeof payload.cursor === "number") { - host.overviewLogCursor = payload.cursor; - } - } catch { - /* non-critical */ - } -} - -function buildAttentionItems(host: SettingsAppHost) { - const items: AttentionItem[] = []; - - if (host.lastError) { - items.push({ - severity: "error", - icon: "x", - title: "Gateway Error", - description: host.lastError, - }); - } - - const hello = host.hello; - const auth = (hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? null; - if (auth?.scopes && !hasOperatorReadAccess(auth)) { - items.push({ - severity: "warning", - icon: "key", - title: "Missing operator.read scope", - description: - "This connection does not have the operator.read scope. Some features may be unavailable.", - href: "https://docs.openclaw.ai/web/dashboard", - external: true, - }); - } - - const skills = host.skillsReport?.skills ?? []; - const missingDeps = skills.filter((s) => !s.disabled && hasMissingSkillDependencies(s.missing)); - if (missingDeps.length > 0) { - const names = missingDeps.slice(0, 3).map((s) => s.name); - const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : ""; - items.push({ - severity: "warning", - icon: "zap", - title: "Skills with missing dependencies", - description: `${names.join(", ")}${more}`, - }); - } - - const blocked = skills.filter((s) => s.blockedByAllowlist); - if (blocked.length > 0) { - items.push({ - severity: "warning", - icon: "shield", - title: `${blocked.length} skill${blocked.length > 1 ? "s" : ""} blocked`, - description: blocked.map((s) => s.name).join(", "), - }); - } - - const cronJobs = host.cronJobs ?? []; - const failedCron = cronJobs.filter(isCronJobActiveFailure); - if (failedCron.length > 0) { - items.push({ - severity: "error", - icon: "clock", - title: `${failedCron.length} cron job${failedCron.length > 1 ? "s" : ""} failed`, - description: failedCron.map((j) => j.name).join(", "), - }); - } - - const now = Date.now(); - const overdue = cronJobs.filter( - (j) => j.enabled && j.state?.nextRunAtMs != null && now - j.state.nextRunAtMs > 300_000, - ); - if (overdue.length > 0) { - items.push({ - severity: "warning", - icon: "clock", - title: `${overdue.length} overdue job${overdue.length > 1 ? "s" : ""}`, - description: overdue.map((j) => j.name).join(", "), - }); - } - - const modelAuth = host.modelAuthStatusResult; - if (modelAuth) { - // Use the same predicate as the Overview card so the two stay in sync. - // Without this, a `missing` provider shows up on the card but never - // produces the re-auth attention callout. - const monitored = (modelAuth.providers ?? []).filter(isMonitoredAuthProvider); - const expiredProviders = monitored.filter( - (p) => p.status === "expired" || p.status === "missing", - ); - if (expiredProviders.length > 0) { - items.push({ - severity: "error", - icon: "key", - title: t("overview.cards.modelAuthAttentionExpiredTitle"), - description: t("overview.cards.modelAuthAttentionExpiredDesc", { - providers: expiredProviders.map((p) => p.displayName).join(", "), - }), - }); - } - const expiringProviders = monitored.filter((p) => p.status === "expiring"); - if (expiringProviders.length > 0) { - items.push({ - severity: "warning", - icon: "key", - title: t("overview.cards.modelAuthAttentionExpiringTitle"), - description: expiringProviders - .map((p) => - t("overview.cards.modelAuthAttentionExpiringEntry", { - provider: p.displayName, - when: p.expiry?.label ?? "soon", - }), - ) - .join(", "), - }); - } - } - - host.attentionItems = items; -} - -export async function loadChannelsTab(host: SettingsHost) { - const app = host as unknown as SettingsAppHost; - const primaryRefresh = Promise.all([loadChannels(app, false), loadConfig(app)]); - loadConfigSchemaAfterPrimary(host, app, primaryRefresh); - await primaryRefresh; -} - -export async function loadCron(host: SettingsHost) { - const app = host as unknown as SettingsAppHost; - const activeCronJobId = app.cronRunsScope === "job" ? app.cronRunsJobId : null; - const cronSeq = (host.controlUiCronRefreshSeq ?? 0) + 1; - host.controlUiCronRefreshSeq = cronSeq; - const isCurrentCronRefresh = () => - host.controlUiCronRefreshSeq === cronSeq && host.tab === "cron"; - const useTableFilters = host.tab === "cron"; - const runsStartedAtMs = controlUiNowMs(); - const runsRefresh = loadCronRuns(app, activeCronJobId) - .catch(() => "error" as const) - .then((status) => { - if (!isCurrentCronRefresh()) { - return; - } - recordControlUiPerformanceEvent( - app, - "control-ui.cron.runs", - { - phase: "end", - status, - durationMs: roundedControlUiDurationMs(controlUiNowMs() - runsStartedAtMs), - }, - { console: false }, - ); - }); - void runsRefresh; - await Promise.all([ - loadChannels(app, false), - loadCronStatus(app), - loadCronJobsPage(app, { tableFilters: useTableFilters }), - ]); -} diff --git a/ui/src/ui/app-sidebar-full-message.test.ts b/ui/src/ui/app-sidebar-full-message.test.ts deleted file mode 100644 index 6b06550c2072..000000000000 --- a/ui/src/ui/app-sidebar-full-message.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* @vitest-environment jsdom */ - -import { describe, expect, it, vi } from "vitest"; -import type { SidebarContent } from "./sidebar-content.ts"; - -describe("OpenClawApp full-message sidebar upgrade", () => { - async function createApp() { - await import("./app.ts"); - return document.createElement("openclaw-app") as import("./app.ts").OpenClawApp; - } - - it("defaults canvas embeds to strict sandbox before bootstrap config loads", async () => { - const app = await createApp(); - - expect(app.embedSandboxMode).toBe("strict"); - }); - - it("uses string content returned by chat.message.get", async () => { - const content: SidebarContent = { - kind: "markdown", - content: "short\n...(truncated)...", - fullMessageRequest: { - sessionKey: "main", - messageId: "msg-1", - kind: "assistant_message", - }, - }; - const request = vi.fn(async () => ({ - ok: true, - message: { role: "assistant", content: "full assistant text" }, - })); - const app = await createApp(); - app.client = { request } as never; - - app.handleOpenSidebar(content); - - await vi.waitFor(() => { - expect(request).toHaveBeenCalledWith("chat.message.get", { - sessionKey: "main", - messageId: "msg-1", - maxChars: 500_000, - }); - expect(app.sidebarContent).toMatchObject({ - kind: "markdown", - content: "full assistant text", - rawText: "full assistant text", - unavailableReason: null, - }); - }); - }); - - it("updates canvas raw text from chat.message.get", async () => { - const content: SidebarContent = { - kind: "canvas", - docId: "preview-1", - entryUrl: "https://example.test/preview", - rawText: "short\n...(truncated)...", - fullMessageRequest: { - sessionKey: "global", - agentId: "work", - messageId: "msg-2", - kind: "tool_output", - }, - }; - const request = vi.fn(async () => ({ - ok: true, - message: { role: "assistant", text: "full canvas raw text" }, - })); - const app = await createApp(); - app.client = { request } as never; - - app.handleOpenSidebar(content); - - await vi.waitFor(() => { - expect(request).toHaveBeenCalledWith("chat.message.get", { - sessionKey: "global", - agentId: "work", - messageId: "msg-2", - maxChars: 500_000, - }); - expect(app.sidebarContent).toMatchObject({ - kind: "canvas", - docId: "preview-1", - entryUrl: "https://example.test/preview", - rawText: "full canvas raw text", - unavailableReason: null, - }); - }); - }); -}); diff --git a/ui/src/ui/app-tooltip-lifecycle.test.ts b/ui/src/ui/app-tooltip-lifecycle.test.ts deleted file mode 100644 index e7f1a4973b73..000000000000 --- a/ui/src/ui/app-tooltip-lifecycle.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* @vitest-environment jsdom */ - -import { afterEach, describe, expect, it } from "vitest"; -import { clearActiveFloatingTooltips, promoteNativeTitleTooltip } from "./dom-tooltips.ts"; - -afterEach(() => { - clearActiveFloatingTooltips(); - document.querySelector(".control-ui-floating-tooltip")?.remove(); -}); - -describe("OpenClawApp tooltip lifecycle", () => { - it("clears the active floating tooltip when the app disconnects", async () => { - const { OpenClawApp } = await import("./app.ts"); - const app = document.createElement("openclaw-app") as InstanceType; - const button = document.createElement("button"); - button.title = "Refresh files"; - app.append(button); - - promoteNativeTitleTooltip(button, app, "pointer"); - expect(document.querySelector(".control-ui-floating-tooltip")?.dataset.open).toBe( - "true", - ); - - app.disconnectedCallback(); - - expect(button.title).toBe("Refresh files"); - expect(button.hasAttribute("data-floating-tooltip-active")).toBe(false); - expect(document.querySelector(".control-ui-floating-tooltip")?.dataset.open).toBe( - "false", - ); - }); -}); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts deleted file mode 100644 index 78ebd7cda33a..000000000000 --- a/ui/src/ui/app-view-state.ts +++ /dev/null @@ -1,586 +0,0 @@ -// Control UI module implements app view state behavior. -import type { ActivityEntry, ActivityStatus } from "./activity-model.ts"; -import type { ChatAbortOptions, ChatSendOptions } from "./app-chat.ts"; -import type { EventLogEntry } from "./app-events.ts"; -import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts"; -import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./chat/input-history.ts"; -import type { RealtimeTalkCatalogProvider } from "./chat/realtime-talk-catalog.ts"; -import type { RealtimeTalkConversationEntry } from "./chat/realtime-talk-conversation.ts"; -import type { RealtimeTalkStatus } from "./chat/realtime-talk.ts"; -import type { ChatRunUiStatus } from "./chat/run-lifecycle.ts"; -import type { ChatMessageCache } from "./chat/session-message-cache.ts"; -import type { ChatSideResult } from "./chat/side-result.ts"; -import type { ChatStreamSegment } from "./chat/stream-text.ts"; -import type { CronModelSuggestionsState, CronState } from "./controllers/cron.ts"; -import type { DevicePairingList, DevicePairSetup } from "./controllers/devices.ts"; -import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; -import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; -import type { SkillWorkshopState } from "./controllers/skill-workshop.ts"; -import type { - ClawHubSearchResult, - ClawHubSkillSecurityVerdict, - ClawHubSkillDetail, - SkillMessage, -} from "./controllers/skills.ts"; -import type { EmbedSandboxMode } from "./embed-sandbox.ts"; -import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; -import type { Tab } from "./navigation.ts"; -import type { SidebarContent } from "./sidebar-content.ts"; -import type { UiSettings } from "./storage.ts"; -import type { ThemeTransitionContext } from "./theme-transition.ts"; -import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts"; -import type { - AgentsListResult, - AgentsFilesListResult, - AgentIdentityResult, - AttentionItem, - ChannelsStatusSnapshot, - ConfigSnapshot, - ConfigUiHints, - HealthSummary, - LogEntry, - LogLevel, - ChatModelOverride, - ModelAuthStatusResult, - ModelCatalogEntry, - NostrProfile, - PresenceEntry, - SessionsUsageResult, - CostUsageSummary, - SessionUsageTimeSeries, - SessionsListResult, - SessionCompactionCheckpoint, - SkillStatusReport, - StatusSummary, - ToolsCatalogResult, -} from "./types.ts"; -import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; -import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts"; -import type { SessionLogEntry } from "./views/usage.ts"; - -export type AppViewState = { - settings: UiSettings; - password: string; - loginShowGatewayToken: boolean; - loginShowGatewayPassword: boolean; - tab: Tab; - onboarding: boolean; - basePath: string; - connected: boolean; - theme: ThemeName; - themeMode: ThemeMode; - themeResolved: ResolvedTheme; - themeOrder: ThemeName[]; - customThemeImportUrl: string; - customThemeImportBusy: boolean; - customThemeImportMessage: { kind: "success" | "error"; text: string } | null; - customThemeImportExpanded: boolean; - customThemeImportFocusToken: number; - hello: GatewayHelloOk | null; - lastError: string | null; - lastErrorCode: string | null; - chatError: string | null; - eventLog: EventLogEntry[]; - assistantName: string; - assistantAvatar: string | null; - assistantAvatarSource?: string | null; - assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; - assistantAvatarReason?: string | null; - assistantAvatarUploadBusy: boolean; - assistantAvatarUploadError: string | null; - assistantAgentId: string | null; - userName?: string | null; - userAvatar?: string | null; - localMediaPreviewRoots: string[]; - embedSandboxMode: EmbedSandboxMode; - /** Whether the gateway advertises the operator terminal as enabled. */ - terminalEnabled: boolean; - allowExternalEmbedUrls: boolean; - chatMessageMaxWidth?: string | null; - sessionKey: string; - chatSessionMessageSubscriptionKey?: string | null; - chatSessionMessageSubscriptionRequestedKey?: string | null; - chatLoading: boolean; - chatSending: boolean; - chatMessage: string; - chatAttachments: ChatAttachment[]; - chatReplyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null; - chatMessages: unknown[]; - chatToolMessages: unknown[]; - activityEntries: ActivityEntry[]; - activityFilterText: string; - activityStatusFilters: Record; - activityToolFilter: string; - activityExpandedIds: Set; - activityAutoFollow: boolean; - activityAtBottom: boolean; - chatStreamSegments: ChatStreamSegment[]; - chatStream: string | null; - chatStreamStartedAt: number | null; - chatRunId: string | null; - chatSideResult: ChatSideResult | null; - chatSideResultTerminalRuns: Set; - compactionStatus: CompactionStatus | null; - fallbackStatus: FallbackStatus | null; - chatRunStatus: ChatRunUiStatus | null; - chatRunStatusClearTimer?: ReturnType | number | null; - chatAvatarUrl: string | null; - chatAvatarSource?: string | null; - chatAvatarStatus?: "none" | "local" | "remote" | "data" | null; - chatAvatarReason?: string | null; - chatThinkingLevel: string | null; - chatVerboseLevel: string | null; - chatModelOverrides: Record; - chatModelSwitchPromises: Record>; - chatModelsLoading: boolean; - chatModelCatalog: ModelCatalogEntry[]; - sessionSwitchNotice: { id: number; text: string } | null; - sessionSwitchFlashKey: string | null; - chatSessionPickerOpen: boolean; - chatSessionPickerSurface: "desktop" | "mobile" | "sidebar" | null; - chatSessionPickerQuery: string; - chatSessionPickerAppliedQuery: string; - chatSessionPickerLoading: boolean; - chatSessionPickerError: string | null; - chatSessionPickerResult: SessionsListResult | null; - sessionsResultAgentId?: string | null; - sessionsResultShowArchived?: boolean; - selectedChatSessionArchived?: boolean; - chatAgentSessionRowsByAgent?: Record; - announceSessionSwitch?: (sessionKey: string, label: string) => void; - chatQueue: ChatQueueItem[]; - chatQueueBySession: Record; - chatMessagesBySession: ChatMessageCache; - chatLocalInputHistoryBySession: Record>; - chatInputHistorySessionKey: string | null; - chatInputHistoryItems: string[] | null; - chatInputHistoryIndex: number; - chatDraftBeforeHistory: string | null; - realtimeTalkActive: boolean; - realtimeTalkStatus: RealtimeTalkStatus; - realtimeTalkDetail: string | null; - realtimeTalkTranscript: string | null; - realtimeTalkConversation: RealtimeTalkConversationEntry[]; - realtimeTalkOptionsOpen: boolean; - realtimeTalkCatalogProviders: RealtimeTalkCatalogProvider[] | null; - realtimeTalkOptions: { - provider: string; - model: string; - voice: string; - transport: string; - vadThreshold: string; - silenceDurationMs: string; - prefixPaddingMs: string; - reasoningEffort: string; - }; - resetRealtimeTalkConversation?: () => void; - updateRealtimeTalkOptions: (next: Partial) => void; - fetchRealtimeTalkCatalog: () => Promise; - chatManualRefreshInFlight: boolean; - chatHeaderControlsHidden: boolean; - chatMobileControlsOpen: boolean; - nodesLoading: boolean; - nodes: Array>; - chatNewMessagesBelow: boolean; - navDrawerOpen: boolean; - sidebarOpen: boolean; - sidebarContent: SidebarContent | null; - sidebarError: string | null; - splitRatio: number; - scrollToBottom: (opts?: { smooth?: boolean }) => void; - scheduleChatScroll: () => void; - devicesLoading: boolean; - devicesError: string | null; - devicesList: DevicePairingList | null; - devicePairSetupOpen: boolean; - devicePairSetupLoading: boolean; - devicePairSetupError: string | null; - devicePairSetup: DevicePairSetup | null; - execApprovalsLoading: boolean; - execApprovalsSaving: boolean; - execApprovalsDirty: boolean; - execApprovalsSnapshot: ExecApprovalsSnapshot | null; - execApprovalsForm: ExecApprovalsFile | null; - execApprovalsSelectedAgent: string | null; - execApprovalsTarget: "gateway" | "node"; - execApprovalsTargetNodeId: string | null; - execApprovalQueue: ExecApprovalRequest[]; - execApprovalBusy: boolean; - execApprovalError: string | null; - pendingGatewayUrl: string | null; - configLoading: boolean; - configRaw: string; - configRawOriginal: string; - configValid: boolean | null; - configIssues: unknown[]; - configSaving: boolean; - configApplying: boolean; - updateRunning: boolean; - applySessionKey: string; - configSnapshot: ConfigSnapshot | null; - configSchema: unknown; - configSchemaVersion: string | null; - configSchemaLoading: boolean; - configUiHints: ConfigUiHints; - configForm: Record | null; - configFormOriginal: Record | null; - selectedAgentId: string | null; - dreamingStatusLoading: boolean; - dreamingStatusError: string | null; - dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null; - dreamingModeSaving: boolean; - dreamingRestartConfirmOpen: boolean; - dreamingRestartConfirmLoading: boolean; - dreamingPendingEnabled: boolean | null; - dreamDiaryLoading: boolean; - dreamDiaryActionLoading: boolean; - dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null; - dreamDiaryActionArchivePath: string | null; - dreamDiaryError: string | null; - dreamDiaryPath: string | null; - dreamDiaryContent: string | null; - wikiImportInsightsLoading: boolean; - wikiImportInsightsError: string | null; - wikiImportInsights: import("./controllers/dreaming.js").WikiImportInsights | null; - wikiMemoryPalaceLoading: boolean; - wikiMemoryPalaceError: string | null; - wikiMemoryPalace: import("./controllers/dreaming.js").WikiMemoryPalace | null; - configFormMode: "form" | "raw"; - configSettingsMode: "quick" | "advanced"; - configSearchQuery: string; - configActiveSection: string | null; - configActiveSubsection: string | null; - pendingUpdateExpectedVersion: string | null; - pendingUpdateHandoff: boolean; - updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null; - communicationsFormMode: "form" | "raw"; - communicationsSearchQuery: string; - communicationsActiveSection: string | null; - communicationsActiveSubsection: string | null; - appearanceFormMode: "form" | "raw"; - appearanceSearchQuery: string; - appearanceActiveSection: string | null; - appearanceActiveSubsection: string | null; - automationFormMode: "form" | "raw"; - automationSearchQuery: string; - automationActiveSection: string | null; - automationActiveSubsection: string | null; - infrastructureFormMode: "form" | "raw"; - infrastructureSearchQuery: string; - infrastructureActiveSection: string | null; - infrastructureActiveSubsection: string | null; - aiAgentsFormMode: "form" | "raw"; - aiAgentsSearchQuery: string; - aiAgentsActiveSection: string | null; - aiAgentsActiveSubsection: string | null; - channelsLoading: boolean; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsError: string | null; - channelsLastSuccess: number | null; - whatsappLoginMessage: string | null; - whatsappLoginQrDataUrl: string | null; - whatsappLoginConnected: boolean | null; - whatsappBusy: boolean; - nostrProfileFormState: NostrProfileFormState | null; - nostrProfileAccountId: string | null; - configFormDirty: boolean; - presenceLoading: boolean; - presenceEntries: PresenceEntry[]; - presenceError: string | null; - presenceStatus: string | null; - agentsLoading: boolean; - agentsList: AgentsListResult | null; - agentsError: string | null; - agentsSelectedId: string | null; - toolsCatalogLoading: boolean; - toolsCatalogError: string | null; - toolsCatalogResult: ToolsCatalogResult | null; - toolsEffectiveLoading: boolean; - toolsEffectiveLoadingKey: string | null; - toolsEffectiveResultKey: string | null; - toolsEffectiveError: string | null; - toolsEffectiveResult: import("./types.js").ToolsEffectiveResult | null; - agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; - agentFilesLoading: boolean; - agentFilesError: string | null; - agentFilesList: AgentsFilesListResult | null; - agentFileContents: Record; - agentFileDrafts: Record; - agentFileActive: string | null; - agentFileSaving: boolean; - agentIdentityLoading: boolean; - agentIdentityError: string | null; - agentIdentityById: Record; - agentSkillsLoading: boolean; - agentSkillsError: string | null; - agentSkillsReport: SkillStatusReport | null; - agentSkillsAgentId: string | null; - sessionsLoading: boolean; - sessionsResult: SessionsListResult | null; - sessionsError: string | null; - threadsLoading: boolean; - threadsResult: SessionsListResult | null; - threadsError: string | null; - sessionsFilterActive: string; - sessionsFilterLimit: string; - sessionsIncludeGlobal: boolean; - sessionsIncludeUnknown: boolean; - sessionsShowArchived: boolean; - sessionsFiltersCollapsed: boolean; - sessionsHideCron: boolean; - sessionsSearchQuery: string; - sessionsSortColumn: "key" | "kind" | "updated" | "tokens"; - sessionsSortDir: "asc" | "desc"; - sessionsPage: number; - sessionsPageSize: number; - sessionsSelectedKeys: Set; - sessionsExpandedCheckpointKey: string | null; - sessionsCheckpointItemsByKey: Record; - sessionsCheckpointLoadingKey: string | null; - sessionsCheckpointBusyKey: string | null; - sessionsCheckpointErrorByKey: Record; - usageLoading: boolean; - usageResult: SessionsUsageResult | null; - usageCostSummary: CostUsageSummary | null; - usageError: string | null; - usageStartDate: string; - usageEndDate: string; - usageScope: "instance" | "family"; - usageAgentId: string | null; - usageSelectedSessions: string[]; - usageSelectedDays: string[]; - usageSelectedHours: number[]; - usageChartMode: "tokens" | "cost"; - usageDailyChartMode: "total" | "by-type"; - usageTimeSeriesMode: "cumulative" | "per-turn"; - usageTimeSeriesBreakdownMode: "total" | "by-type"; - usageTimeSeries: SessionUsageTimeSeries | null; - usageTimeSeriesLoading: boolean; - usageTimeSeriesCursorStart: number | null; - usageTimeSeriesCursorEnd: number | null; - usageSessionLogs: SessionLogEntry[] | null; - usageSessionLogsLoading: boolean; - usageSessionLogsExpanded: boolean; - usageQuery: string; - usageQueryDraft: string; - usageQueryDebounceTimer: number | null; - usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors"; - usageSessionSortDir: "asc" | "desc"; - usageRecentSessions: string[]; - usageTimeZone: "local" | "utc"; - usageContextExpanded: boolean; - usageHeaderPinned: boolean; - usageSessionsTab: "all" | "recent"; - usageVisibleColumns: string[]; - usageLogFilterRoles: import("./views/usage.js").SessionLogRole[]; - usageLogFilterTools: string[]; - usageLogFilterHasTools: boolean; - usageLogFilterQuery: string; -} & Pick< - CronState, - | "cronLoading" - | "cronQuickCreateOpen" - | "cronQuickCreateStep" - | "cronQuickCreateDraft" - | "cronJobsLoadingMore" - | "cronJobsReloadPending" - | "cronJobsReloadPendingTableFilters" - | "cronJobs" - | "cronJobsTotal" - | "cronJobsHasMore" - | "cronJobsNextOffset" - | "cronJobsLimit" - | "cronJobsQuery" - | "cronJobsEnabledFilter" - | "cronJobsScheduleKindFilter" - | "cronJobsLastStatusFilter" - | "cronJobsSortBy" - | "cronJobsSortDir" - | "cronStatus" - | "cronError" - | "cronForm" - | "cronFormCollapsed" - | "cronFieldErrors" - | "cronEditingJobId" - | "cronRunsJobId" - | "cronRunsLoadingMore" - | "cronRuns" - | "cronRunsTotal" - | "cronRunsHasMore" - | "cronRunsNextOffset" - | "cronRunsLimit" - | "cronRunsScope" - | "cronRunsStatuses" - | "cronRunsDeliveryStatuses" - | "cronRunsStatusFilter" - | "cronRunsQuery" - | "cronRunsSortDir" - | "cronBusy" -> & - Pick & { - skillsLoading: boolean; - skillsAgentId: string | null; - skillsAgentRevision: number; - skillsReport: SkillStatusReport | null; - skillsError: string | null; - skillsFilter: string; - skillsStatusFilter: "all" | "ready" | "needs-setup" | "disabled"; - skillEdits: Record; - skillMessages: Record; - skillsBusyKey: string | null; - skillsDetailKey: string | null; - skillsDetailTab: "overview" | "card"; - clawhubSearchQuery: string; - clawhubSearchResults: ClawHubSearchResult[] | null; - clawhubSearchLoading: boolean; - clawhubSearchError: string | null; - clawhubDetail: ClawHubSkillDetail | null; - clawhubDetailSlug: string | null; - clawhubDetailLoading: boolean; - clawhubDetailError: string | null; - clawhubInstallSlug: string | null; - clawhubInstallMessage: { - kind: "success" | "error"; - text: string; - acknowledgeSlug?: string; - acknowledgeVersion?: string; - acknowledgeLabel?: string; - } | null; - clawhubVerdicts: Record; - clawhubVerdictsLoading: boolean; - clawhubVerdictsError: string | null; - skillCardContents: Record; - skillCardContentKeys: Record; - skillCardLoadingKey: string | null; - skillCardErrors: Record; - healthLoading: boolean; - healthResult: HealthSummary | null; - healthError: string | null; - modelAuthStatusLoading: boolean; - modelAuthStatusResult: ModelAuthStatusResult | null; - modelAuthStatusError: string | null; - debugLoading: boolean; - debugStatus: StatusSummary | null; - debugHealth: HealthSummary | null; - debugModels: ModelCatalogEntry[]; - debugHeartbeat: unknown; - debugCallMethod: string; - debugCallParams: string; - debugCallResult: string | null; - debugCallError: string | null; - logsLoading: boolean; - logsError: string | null; - logsFile: string | null; - logsEntries: LogEntry[]; - logsFilterText: string; - logsLevelFilters: Record; - logsAutoFollow: boolean; - logsTruncated: boolean; - logsCursor: number | null; - logsLastFetchAt: number | null; - logsLimit: number; - logsMaxBytes: number; - logsAtBottom: boolean; - updateAvailable: import("./types.js").UpdateAvailable | null; - attentionItems: AttentionItem[]; - paletteOpen: boolean; - paletteQuery: string; - paletteActiveIndex: number; - streamMode: boolean; - overviewShowGatewayToken: boolean; - overviewShowGatewayPassword: boolean; - overviewLogLines: string[]; - overviewLogCursor: number; - client: GatewayBrowserClient | null; - refreshSessionsAfterChat: Map; - connect: () => void; - setTab: (tab: Tab) => void; - setChatMobileControlsOpen: ( - open: boolean, - options?: { trigger?: HTMLElement | null; restoreFocus?: boolean }, - ) => void; - setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void; - setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; - setCustomThemeImportUrl: (next: string) => void; - openCustomThemeImport: () => void; - importCustomTheme: () => Promise; - clearCustomTheme: () => void; - setBorderRadius: (value: number) => void; - setTextScale: (value: number) => void; - applySettings: (next: UiSettings) => void; - applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void; - loadOverview: (opts?: { refresh?: boolean }) => Promise; - loadAssistantIdentity: (opts?: { - sessionKey?: string; - expectedSessionKey?: string; - }) => Promise; - loadCron: () => Promise; - handleWhatsAppStart: (force: boolean) => Promise; - handleWhatsAppWait: () => Promise; - handleWhatsAppLogout: () => Promise; - handleChannelConfigSave: () => Promise; - handleChannelConfigReload: () => Promise; - handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void; - handleNostrProfileCancel: () => void; - handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void; - handleNostrProfileSave: () => Promise; - handleNostrProfileImport: () => Promise; - handleNostrProfileToggleAdvanced: () => void; - handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise; - handleGatewayUrlConfirm: () => void; - handleGatewayUrlCancel: () => void; - handleConfigLoad: () => Promise; - handleConfigSave: () => Promise; - handleConfigApply: () => Promise; - handleConfigFormUpdate: (path: string, value: unknown) => void; - handleConfigFormModeChange: (mode: "form" | "raw") => void; - handleConfigRawChange: (raw: string) => void; - handleInstallSkill: (key: string) => Promise; - handleUpdateSkill: (key: string) => Promise; - handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise; - handleUpdateSkillEdit: (key: string, value: string) => void; - handleSaveSkillApiKey: (key: string, apiKey: string) => Promise; - handleCronToggle: (jobId: string, enabled: boolean) => Promise; - handleCronRun: (jobId: string) => Promise; - handleCronRemove: (jobId: string) => Promise; - handleCronAdd: () => Promise; - handleCronRunsLoad: (jobId: string) => Promise; - handleCronFormUpdate: (path: string, value: unknown) => void; - handleSessionsLoad: () => Promise; - handleSessionsPatch: (key: string, patch: unknown) => Promise; - handleLoadNodes: () => Promise; - handleLoadPresence: () => Promise; - handleLoadSkills: () => Promise; - handleLoadDebug: () => Promise; - handleLoadLogs: () => Promise; - handleDebugCall: () => Promise; - handleRunUpdate: () => Promise; - setPassword: (next: string) => void; - setChatMessage: (next: string) => void; - handleChatDraftChange: (next: string) => void; - handleChatInputHistoryKey: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; - resetChatInputHistoryNavigation: () => void; - handleSendChat: (messageOverride?: string, opts?: ChatSendOptions) => Promise; - toggleRealtimeTalk: () => Promise; - steerQueuedChatMessage: (id: string) => Promise; - handleAbortChat: (opts?: ChatAbortOptions) => Promise; - removeQueuedMessage: (id: string) => void; - retryQueuedChatMessage: (id: string) => Promise; - handleChatScroll: (event: Event) => void; - resetToolStream: () => void; - resetChatScroll: () => void; - exportLogs: (lines: string[], label: string) => void; - handleLogsScroll: (event: Event) => void; - handleActivityScroll: (event: Event) => void; - scheduleActivityScroll: (force?: boolean) => void; - handleOpenSidebar: (content: SidebarContent) => void; - handleCloseSidebar: () => void; - handleSplitRatioChange: (ratio: number) => void; - webPushSupported: boolean; - webPushPermission: NotificationPermission | "unsupported"; - webPushSubscribed: boolean; - webPushLoading: boolean; - handleWebPushSubscribe: () => Promise; - handleWebPushUnsubscribe: () => Promise; - handleWebPushTest: () => Promise; - } & SkillWorkshopState; diff --git a/ui/src/ui/app.exec-approval.test.ts b/ui/src/ui/app.exec-approval.test.ts deleted file mode 100644 index daebed215ec7..000000000000 --- a/ui/src/ui/app.exec-approval.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* @vitest-environment jsdom */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createStorageMock } from "../test-helpers/storage.ts"; -import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; - -type RequestFn = (method: string, params?: unknown) => Promise; - -function createExecApproval(overrides: Partial = {}): ExecApprovalRequest { - return { - id: "approval-1", - kind: "exec", - request: { command: "echo hello" }, - createdAtMs: 1000, - expiresAtMs: Date.now() + 60_000, - ...overrides, - }; -} - -function createGatewayError(message: string, details?: unknown): Error { - const err = new Error(message); - Object.defineProperty(err, "gatewayCode", { - value: "INVALID_REQUEST", - enumerable: true, - }); - Object.defineProperty(err, "details", { - value: details, - enumerable: true, - }); - return err; -} - -async function createApp( - request: RequestFn, - queue: ExecApprovalRequest[] = [createExecApproval()], -) { - const { OpenClawApp } = await import("./app.ts"); - const app = Object.create(OpenClawApp.prototype) as InstanceType; - Object.defineProperties(app, { - client: { value: { request }, writable: true }, - execApprovalBusy: { value: false, writable: true }, - execApprovalError: { value: null, writable: true }, - execApprovalQueue: { value: queue, writable: true }, - }); - return app; -} - -describe("OpenClawApp exec approval decisions", () => { - beforeEach(() => { - vi.stubGlobal("localStorage", createStorageMock()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it("dismisses the active approval after same-decision idempotent success", async () => { - const request = vi.fn(async () => ({ ok: true })); - const app = await createApp(request); - - await app.handleExecApprovalDecision("allow-once"); - - expect(request).toHaveBeenCalledWith("exec.approval.resolve", { - id: "approval-1", - decision: "allow-once", - }); - expect(app.execApprovalQueue).toEqual([]); - expect(app.execApprovalError).toBeNull(); - expect(app.execApprovalBusy).toBe(false); - }); - - it("dismisses and refreshes when the backend reports an already resolved approval", async () => { - const request = vi.fn(async (method) => { - if (method === "exec.approval.resolve") { - throw createGatewayError("approval already resolved", { - reason: "APPROVAL_ALREADY_RESOLVED", - }); - } - if (method === "exec.approval.list") { - return []; - } - if (method === "plugin.approval.list") { - return []; - } - return {}; - }); - const app = await createApp(request); - - await app.handleExecApprovalDecision("deny"); - - expect(app.execApprovalQueue).toEqual([]); - expect(app.execApprovalError).toBeNull(); - expect(app.execApprovalBusy).toBe(false); - expect(request).toHaveBeenCalledWith("exec.approval.list", {}); - expect(request).toHaveBeenCalledWith("plugin.approval.list", {}); - }); - - it("keeps the active approval open for unrelated errors", async () => { - const request = vi.fn(async () => { - throw createGatewayError("gateway unavailable"); - }); - const active = createExecApproval(); - const app = await createApp(request, [active]); - - await app.handleExecApprovalDecision("deny"); - - expect(app.execApprovalQueue).toEqual([active]); - expect(app.execApprovalError).toBe("Approval failed: Error: gateway unavailable"); - expect(app.execApprovalBusy).toBe(false); - }); -}); diff --git a/ui/src/ui/app.talk.test.ts b/ui/src/ui/app.talk.test.ts deleted file mode 100644 index 85c65a209db5..000000000000 --- a/ui/src/ui/app.talk.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/* @vitest-environment jsdom */ - -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { realtimeTalkCtor, startMock, stopMock } = vi.hoisted(() => ({ - realtimeTalkCtor: vi.fn(), - startMock: vi.fn(), - stopMock: vi.fn(), -})); - -describe("OpenClawApp Talk controls", () => { - beforeEach(() => { - vi.resetModules(); - vi.doMock("./chat/realtime-talk.ts", () => ({ - RealtimeTalkSession: realtimeTalkCtor, - })); - realtimeTalkCtor.mockReset(); - startMock.mockReset(); - stopMock.mockReset(); - realtimeTalkCtor.mockImplementation( - function MockRealtimeTalkSession(this: { start: typeof startMock; stop: typeof stopMock }) { - this.start = startMock; - this.stop = stopMock; - }, - ); - startMock.mockResolvedValue(undefined); - }); - - it("retries Talk immediately when the previous session is already in error state", async () => { - const { OpenClawApp } = await import("./app.ts"); - const app = Object.create(OpenClawApp.prototype) as { - client: unknown; - connected: boolean; - realtimeTalkActive: boolean; - realtimeTalkDetail: string | null; - realtimeTalkConversation: Array<{ id: string; role: string; text: string }>; - realtimeTalkStatus: string; - realtimeTalkSession: { stop(): void } | null; - realtimeTalkTranscript: string | null; - sessionKey: string; - }; - const staleStop = vi.fn(); - Object.defineProperties(app, { - client: { value: { request: vi.fn() }, writable: true }, - connected: { value: true, writable: true }, - realtimeTalkActive: { value: true, writable: true }, - realtimeTalkConversation: { value: [], writable: true }, - realtimeTalkDetail: { value: null, writable: true }, - realtimeTalkSession: { value: { stop: staleStop }, writable: true }, - realtimeTalkStatus: { value: "error", writable: true }, - realtimeTalkTranscript: { value: null, writable: true }, - sessionKey: { value: "main", writable: true }, - }); - - await OpenClawApp.prototype.toggleRealtimeTalk.call(app as never); - - expect(staleStop).toHaveBeenCalledOnce(); - expect(realtimeTalkCtor).toHaveBeenCalledOnce(); - expect(startMock).toHaveBeenCalledOnce(); - expect(stopMock).not.toHaveBeenCalled(); - expect(app.realtimeTalkStatus).toBe("connecting"); - const session = app.realtimeTalkSession as { start?: unknown; stop?: unknown } | undefined; - expect(session?.start).toBe(startMock); - expect(session?.stop).toBe(stopMock); - }); - - it("preserves unrelated errors when retrying Talk", async () => { - const { OpenClawApp } = await import("./app.ts"); - const app = Object.create(OpenClawApp.prototype) as { - chatError: string | null; - client: unknown; - connected: boolean; - lastError: string | null; - realtimeTalkActive: boolean; - realtimeTalkDetail: string | null; - realtimeTalkConversation: Array<{ id: string; role: string; text: string }>; - realtimeTalkStatus: string; - realtimeTalkSession: { stop(): void } | null; - realtimeTalkTranscript: string | null; - sessionKey: string; - }; - Object.defineProperties(app, { - chatError: { value: "current chat failure", writable: true }, - client: { value: { request: vi.fn() }, writable: true }, - connected: { value: true, writable: true }, - lastError: { value: "current gateway failure", writable: true }, - realtimeTalkActive: { value: true, writable: true }, - realtimeTalkConversation: { value: [], writable: true }, - realtimeTalkDetail: { value: null, writable: true }, - realtimeTalkSession: { value: { stop: vi.fn() }, writable: true }, - realtimeTalkStatus: { value: "error", writable: true }, - realtimeTalkTranscript: { value: null, writable: true }, - sessionKey: { value: "main", writable: true }, - }); - - await OpenClawApp.prototype.toggleRealtimeTalk.call(app as never); - - expect(app.lastError).toBe("current gateway failure"); - expect(app.chatError).toBe("current chat failure"); - }); - - it("accumulates Talk transcripts as ordered conversation turns", async () => { - const { OpenClawApp } = await import("./app.ts"); - const app = Object.create(OpenClawApp.prototype) as { - client: unknown; - connected: boolean; - lastError: string | null; - realtimeTalkActive: boolean; - realtimeTalkConversation: Array<{ role: string; text: string; isStreaming: boolean }>; - realtimeTalkDetail: string | null; - realtimeTalkStatus: string; - realtimeTalkSession: { stop(): void } | null; - realtimeTalkTranscript: string | null; - sessionKey: string; - }; - Object.defineProperties(app, { - client: { value: { request: vi.fn() }, writable: true }, - connected: { value: true, writable: true }, - lastError: { value: null, writable: true }, - realtimeTalkActive: { value: false, writable: true }, - realtimeTalkConversation: { value: [], writable: true }, - realtimeTalkDetail: { value: null, writable: true }, - realtimeTalkSession: { value: null, writable: true }, - realtimeTalkStatus: { value: "idle", writable: true }, - realtimeTalkTranscript: { value: null, writable: true }, - sessionKey: { value: "main", writable: true }, - }); - - await OpenClawApp.prototype.toggleRealtimeTalk.call(app as never); - const callbacks = realtimeTalkCtor.mock.calls[0]?.[2] as - | { - onTranscript?: (entry: { - role: "user" | "assistant"; - text: string; - final: boolean; - }) => void; - } - | undefined; - - callbacks?.onTranscript?.({ role: "user", text: "Turn off", final: false }); - callbacks?.onTranscript?.({ role: "user", text: "the lights", final: false }); - callbacks?.onTranscript?.({ role: "assistant", text: "Checking", final: false }); - callbacks?.onTranscript?.({ role: "user", text: "Second request", final: true }); - - expect(app.realtimeTalkConversation).toMatchObject([ - { role: "user", text: "Turn off the lights", isStreaming: false }, - { role: "assistant", text: "Checking", isStreaming: false }, - { role: "user", text: "Second request", isStreaming: false }, - ]); - }); - - it("keeps Talk startup failures on the dedicated Talk error surface", async () => { - startMock.mockRejectedValueOnce(new Error("voice provider missing")); - const { OpenClawApp } = await import("./app.ts"); - const app = Object.create(OpenClawApp.prototype) as { - chatError: string | null; - client: unknown; - connected: boolean; - lastError: string | null; - realtimeTalkActive: boolean; - realtimeTalkConversation: Array<{ role: string; text: string; isStreaming: boolean }>; - realtimeTalkDetail: string | null; - realtimeTalkStatus: string; - realtimeTalkSession: { stop(): void } | null; - realtimeTalkTranscript: string | null; - sessionKey: string; - }; - Object.defineProperties(app, { - chatError: { value: "previous chat failure", writable: true }, - client: { value: { request: vi.fn() }, writable: true }, - connected: { value: true, writable: true }, - lastError: { value: "previous chat failure", writable: true }, - realtimeTalkActive: { value: false, writable: true }, - realtimeTalkConversation: { value: [], writable: true }, - realtimeTalkDetail: { value: null, writable: true }, - realtimeTalkSession: { value: null, writable: true }, - realtimeTalkStatus: { value: "idle", writable: true }, - realtimeTalkTranscript: { value: null, writable: true }, - sessionKey: { value: "main", writable: true }, - }); - - await OpenClawApp.prototype.toggleRealtimeTalk.call(app as never); - - expect(app.realtimeTalkStatus).toBe("error"); - expect(app.realtimeTalkDetail).toBe("voice provider missing"); - expect(app.lastError).toBe("previous chat failure"); - expect(app.chatError).toBe("previous chat failure"); - expect(stopMock).toHaveBeenCalledOnce(); - }); - - it("keeps the Talk options toggle inside the open-panel click guard", async () => { - await import("./app.ts"); - const app = document.createElement("openclaw-app"); - const guardHost = app as unknown as { - chatMobileControlsPointerdownHandler: (event: Event) => void; - realtimeTalkOptionsOpen: boolean; - }; - const toggle = document.createElement("button"); - toggle.setAttribute("aria-label", "Talk options"); - app.append(toggle); - - guardHost.realtimeTalkOptionsOpen = true; - guardHost.chatMobileControlsPointerdownHandler({ - composedPath: () => [toggle, app, document, window], - } as unknown as Event); - - expect(guardHost.realtimeTalkOptionsOpen).toBe(true); - - guardHost.chatMobileControlsPointerdownHandler({ - composedPath: () => [document, window], - } as unknown as Event); - - expect(guardHost.realtimeTalkOptionsOpen).toBe(false); - }); - - it("clears stale Talk catalog providers but preserves selection when a refresh fails", async () => { - const request = vi - .fn() - .mockResolvedValueOnce({ - realtime: { - providers: [ - { - id: "plugin-realtime", - label: "Plugin realtime", - configured: true, - }, - ], - }, - }) - .mockRejectedValueOnce(new Error("talk.catalog unavailable")); - const { OpenClawApp } = await import("./app.ts"); - const app = Object.create(OpenClawApp.prototype) as { - client: { request: typeof request }; - connected: boolean; - realtimeTalkCatalogProviders: unknown[] | null; - realtimeTalkOptions: { provider: string; transport: string }; - }; - Object.defineProperties(app, { - client: { value: { request }, writable: true }, - connected: { value: true, writable: true }, - realtimeTalkCatalogProviders: { - value: [{ id: "stale", label: "Stale provider" }], - writable: true, - }, - realtimeTalkOptions: { - value: { provider: "plugin-realtime", transport: "webrtc" }, - writable: true, - }, - }); - - await OpenClawApp.prototype.fetchRealtimeTalkCatalog.call(app as never); - expect(app.realtimeTalkCatalogProviders).toMatchObject([{ id: "plugin-realtime" }]); - expect(app.realtimeTalkOptions).toEqual({ provider: "plugin-realtime", transport: "" }); - - await OpenClawApp.prototype.fetchRealtimeTalkCatalog.call(app as never); - expect(app.realtimeTalkCatalogProviders).toBeNull(); - expect(app.realtimeTalkOptions).toEqual({ provider: "plugin-realtime", transport: "" }); - }); - - it("clears a Talk provider removed by a successful catalog refresh", async () => { - const request = vi.fn().mockResolvedValueOnce({ realtime: { providers: [] } }); - const { OpenClawApp } = await import("./app.ts"); - const app = Object.create(OpenClawApp.prototype) as { - client: { request: typeof request }; - connected: boolean; - realtimeTalkCatalogProviders: unknown[] | null; - realtimeTalkOptions: { provider: string; transport: string }; - }; - Object.defineProperties(app, { - client: { value: { request }, writable: true }, - connected: { value: true, writable: true }, - realtimeTalkCatalogProviders: { value: null, writable: true }, - realtimeTalkOptions: { - value: { provider: "removed-plugin", transport: "gateway-relay" }, - writable: true, - }, - }); - - await OpenClawApp.prototype.fetchRealtimeTalkCatalog.call(app as never); - - expect(app.realtimeTalkCatalogProviders).toEqual([]); - expect(app.realtimeTalkOptions).toEqual({ provider: "", transport: "" }); - }); -}); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts deleted file mode 100644 index d89df99d2de5..000000000000 --- a/ui/src/ui/app.ts +++ /dev/null @@ -1,1694 +0,0 @@ -import { LitElement } from "lit"; -import { state } from "lit/decorators.js"; -// Control UI module implements app behavior. -import { CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE } from "../../../src/gateway/control-ui-contract.js"; -import { i18n, I18nController, isSupportedLocale, t } from "../i18n/index.ts"; -import type { ActivityEntry, ActivityStatus } from "./activity-model.ts"; -import { - handleChannelConfigReload as handleChannelConfigReloadInternal, - handleChannelConfigSave as handleChannelConfigSaveInternal, - handleNostrProfileCancel as handleNostrProfileCancelInternal, - handleNostrProfileEdit as handleNostrProfileEditInternal, - handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal, - handleNostrProfileImport as handleNostrProfileImportInternal, - handleNostrProfileSave as handleNostrProfileSaveInternal, - handleNostrProfileToggleAdvanced as handleNostrProfileToggleAdvancedInternal, - handleWhatsAppLogout as handleWhatsAppLogoutInternal, - handleWhatsAppStart as handleWhatsAppStartInternal, - handleWhatsAppWait as handleWhatsAppWaitInternal, -} from "./app-channels.ts"; -import { - handleAbortChat as handleAbortChatInternal, - handleChatDraftChange as handleChatDraftChangeInternal, - handleChatInputHistoryKey as handleChatInputHistoryKeyInternal, - handleSendChat as handleSendChatInternal, - removeQueuedMessage as removeQueuedMessageInternal, - resetChatInputHistoryNavigation as resetChatInputHistoryNavigationInternal, - retryQueuedChatMessage as retryQueuedChatMessageInternal, - steerQueuedChatMessage as steerQueuedChatMessageInternal, - type ChatInputHistoryKeyInput, - type ChatInputHistoryKeyResult, -} from "./app-chat.ts"; -import { - DEFAULT_CRON_FORM, - DEFAULT_LOG_LEVEL_FILTERS, - DEFAULT_SESSIONS_FILTERS, -} from "./app-defaults.ts"; -import type { EventLogEntry } from "./app-events.ts"; -import { connectGateway as connectGatewayInternal } from "./app-gateway.ts"; -import { - handleConnected, - handleDisconnected, - handleFirstUpdated, - handleUpdated, -} from "./app-lifecycle.ts"; -import { initNativeBridge } from "./app-native-bridge.ts"; -import { createChatSession as createChatSessionInternal } from "./app-render.helpers.ts"; -import { - loadSkillWorkshopMode, - loadSkillWorkshopUseCurrentChatForRevisions, - renderApp, -} from "./app-render.ts"; -import { - exportLogs as exportLogsInternal, - handleActivityScroll as handleActivityScrollInternal, - handleChatScroll as handleChatScrollInternal, - handleLogsScroll as handleLogsScrollInternal, - resetChatScroll as resetChatScrollInternal, - scheduleActivityScroll as scheduleActivityScrollInternal, - scheduleChatScroll as scheduleChatScrollInternal, -} from "./app-scroll.ts"; -import { - applySettings as applySettingsInternal, - applyLocalUserIdentity as applyLocalUserIdentityInternal, - loadCron as loadCronInternal, - loadOverview as loadOverviewInternal, - setTab as setTabInternal, - setTheme as setThemeInternal, - setThemeMode as setThemeModeInternal, - onPopState as onPopStateInternal, -} from "./app-settings.ts"; -import { - resetToolStream as resetToolStreamInternal, - type ToolStreamEntry, - type CompactionStatus, - type FallbackStatus, -} from "./app-tool-stream.ts"; -import type { AppViewState } from "./app-view-state.ts"; -import { normalizeAssistantIdentity } from "./assistant-identity.ts"; -import { restoreChatComposerState } from "./chat/composer-persistence.ts"; -import { exportChatMarkdown } from "./chat/export.ts"; -import { - reconcileRealtimeTalkCatalogSelection, - type RealtimeTalkCatalogProvider, -} from "./chat/realtime-talk-catalog.ts"; -import { - createRealtimeTalkConversationState, - updateRealtimeTalkConversation, - type RealtimeTalkConversationEntry, - type RealtimeTalkConversationState, -} from "./chat/realtime-talk-conversation.ts"; -import { - RealtimeTalkSession, - type RealtimeTalkLaunchOptions, - type RealtimeTalkStatus, -} from "./chat/realtime-talk.ts"; -import type { ChatRunUiStatus } from "./chat/run-lifecycle.ts"; -import type { ChatMessageCache } from "./chat/session-message-cache.ts"; -import type { ChatSideResult } from "./chat/side-result.ts"; -import type { ChatStreamSegment } from "./chat/stream-text.ts"; -import { - loadToolsEffective as loadToolsEffectiveInternal, - refreshVisibleToolsEffectiveForCurrentSession as refreshVisibleToolsEffectiveForCurrentSessionInternal, -} from "./controllers/agents.ts"; -import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; -import type { DevicePairingList, DevicePairSetup } from "./controllers/devices.ts"; -import type { - DreamingStatus, - WikiImportInsights, - WikiMemoryPalace, -} from "./controllers/dreaming.ts"; -import { - dismissExecApprovalPrompt, - isStaleApprovalResolutionError, - refreshPendingApprovalQueue, - type ExecApprovalRequest, -} from "./controllers/exec-approval.ts"; -import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; -import { - loadSkillWorkshopProposals, - type SkillWorkshopState, -} from "./controllers/skill-workshop.ts"; -import type { - ClawHubSearchResult, - ClawHubSkillSecurityVerdict, - ClawHubSkillDetail, - SkillMessage, -} from "./controllers/skills.ts"; -import { importCustomThemeFromUrl } from "./custom-theme.ts"; -import { - clearActiveFloatingTooltips, - prepareActiveFloatingTooltipsForRender, - promoteNativeTitleTooltip, - refreshActiveFloatingTooltip, - restoreNativeTitleTooltip, -} from "./dom-tooltips.ts"; -import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; -import type { Tab } from "./navigation.ts"; -import { resolveAgentIdFromSessionKey } from "./session-key.ts"; -import type { SidebarContent } from "./sidebar-content.ts"; -import { loadLocalUserIdentity, loadSettings, type UiSettings } from "./storage.ts"; -import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts"; -import type { - AgentsListResult, - AgentsFilesListResult, - AgentIdentityResult, - ConfigSnapshot, - ConfigUiHints, - ChatModelOverride, - CronJob, - CronRunLogEntry, - CronStatus, - HealthSummary, - LogEntry, - LogLevel, - ModelAuthStatusResult, - ModelCatalogEntry, - PresenceEntry, - ChannelsStatusSnapshot, - SessionCompactionCheckpoint, - SessionsListResult, - SkillStatusReport, - StatusSummary, - NostrProfile, - ToolsCatalogResult, - ToolsEffectiveResult, -} from "./types.ts"; -import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts"; -import { generateUUID } from "./uuid.ts"; -import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts"; - -declare global { - interface Window { - __OPENCLAW_CONTROL_UI_BASE_PATH__?: string; - } -} - -const bootAssistantIdentity = normalizeAssistantIdentity({}); -const bootLocalUserIdentity = loadLocalUserIdentity(); -const bootTerminalEnabled = - typeof document !== "undefined" && - document.documentElement.getAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE) === "true"; -const FULL_MESSAGE_SIDEBAR_MAX_CHARS = 500_000; - -function isSidebarMarkdownLike(content: SidebarContent | null): content is SidebarContent { - return Boolean(content && (content.kind === "markdown" || content.kind === "canvas")); -} - -function resolveSidebarUnavailableReason( - reason: "not_found" | "oversized" | "not_visible" | null | undefined, -): string { - switch (reason) { - case "oversized": - return "Full content is unavailable because the stored transcript entry is too large to return safely."; - case "not_visible": - return "Full content is unavailable because this transcript entry does not have a visible WebChat projection."; - default: - return "Full content is no longer available for this transcript entry."; - } -} - -function resolveOnboardingMode(): boolean { - if (!window.location.search) { - return false; - } - const params = new URLSearchParams(window.location.search); - const raw = params.get("onboarding"); - if (!raw) { - return false; - } - const normalized = raw.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; -} - -export class OpenClawApp extends LitElement { - readonly i18nController = new I18nController(this); - clientInstanceId = generateUUID(); - connectGeneration = 0; - @state() settings: UiSettings = loadSettings(); - constructor() { - super(); - if (isSupportedLocale(this.settings.locale)) { - void i18n.setLocale(this.settings.locale); - } - } - @state() password = ""; - @state() loginShowGatewayToken = false; - @state() loginShowGatewayPassword = false; - @state() tab: Tab = "chat"; - @state() onboarding = resolveOnboardingMode(); - @state() connected = false; - @state() theme: ThemeName = this.settings.theme ?? "claw"; - @state() themeMode: ThemeMode = this.settings.themeMode ?? "system"; - @state() themeResolved: ResolvedTheme = "dark"; - @state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme); - @state() customThemeImportUrl = ""; - @state() customThemeImportBusy = false; - @state() customThemeImportMessage: { kind: "success" | "error"; text: string } | null = null; - @state() customThemeImportExpanded = false; - @state() customThemeImportFocusToken = 0; - private customThemeImportSelectOnSuccess = false; - @state() hello: GatewayHelloOk | null = null; - @state() lastError: string | null = null; - @state() lastErrorCode: string | null = null; - @state() chatError: string | null = null; - @state() eventLog: EventLogEntry[] = []; - eventLogBuffer: EventLogEntry[] = []; - toolStreamSyncTimer: number | null = null; - private sidebarCloseTimer: number | null = null; - - @state() assistantName = bootAssistantIdentity.name; - @state() assistantAvatar = bootAssistantIdentity.avatar; - @state() assistantAvatarSource = bootAssistantIdentity.avatarSource ?? null; - @state() assistantAvatarStatus = bootAssistantIdentity.avatarStatus ?? null; - @state() assistantAvatarReason = bootAssistantIdentity.avatarReason ?? null; - @state() assistantAvatarUploadBusy = false; - @state() assistantAvatarUploadError: string | null = null; - @state() assistantAgentId = bootAssistantIdentity.agentId ?? null; - @state() userName = bootLocalUserIdentity.name; - @state() userAvatar = bootLocalUserIdentity.avatar; - @state() localMediaPreviewRoots: string[] = []; - @state() embedSandboxMode: "strict" | "scripts" | "trusted" = "strict"; - @state() terminalEnabled = bootTerminalEnabled; - @state() allowExternalEmbedUrls = false; - @state() chatMessageMaxWidth: string | null = null; - @state() serverVersion: string | null = null; - - @state() sessionKey = this.settings.sessionKey; - chatSessionMessageSubscriptionKey: string | null = null; - chatSessionMessageSubscriptionRequestedKey: string | null = null; - currentSessionId: string | null = null; - reconnectResumeSessionId: string | null = null; - @state() chatLoading = false; - @state() chatSending = false; - @state() chatMessage = ""; - @state() chatMessages: unknown[] = []; - @state() chatToolMessages: unknown[] = []; - @state() activityEntries: ActivityEntry[] = []; - @state() activityFilterText = ""; - @state() activityStatusFilters: Record = { - running: true, - done: true, - error: true, - }; - @state() activityToolFilter = ""; - @state() activityExpandedIds = new Set(); - @state() activityAutoFollow = true; - @state() activityAtBottom = true; - @state() chatStreamSegments: ChatStreamSegment[] = []; - @state() chatStream: string | null = null; - @state() chatStreamStartedAt: number | null = null; - @state() chatRunId: string | null = null; - @state() chatSideResult: ChatSideResult | null = null; - @state() compactionStatus: CompactionStatus | null = null; - @state() fallbackStatus: FallbackStatus | null = null; - @state() chatRunStatus: ChatRunUiStatus | null = null; - chatRunStatusClearTimer: ReturnType | number | null = null; - @state() chatAvatarUrl: string | null = null; - @state() chatAvatarSource: string | null = null; - @state() chatAvatarStatus: "none" | "local" | "remote" | "data" | null = null; - @state() chatAvatarReason: string | null = null; - @state() chatThinkingLevel: string | null = null; - @state() chatVerboseLevel: string | null = null; - @state() chatModelOverrides: Record = {}; - @state() chatModelSwitchPromises: Record> = {}; - @state() chatModelsLoading = false; - @state() chatModelCatalog: ModelCatalogEntry[] = []; - @state() sessionSwitchNotice: { id: number; text: string } | null = null; - @state() sessionSwitchFlashKey: string | null = null; - @state() chatSessionPickerOpen = false; - @state() chatSessionPickerSurface: "desktop" | "mobile" | "sidebar" | null = null; - @state() chatSessionPickerQuery = ""; - @state() chatSessionPickerAppliedQuery = ""; - @state() chatSessionPickerLoading = false; - @state() chatSessionPickerError: string | null = null; - @state() chatSessionPickerResult: SessionsListResult | null = null; - private sessionSwitchNoticeSeq = 0; - private sessionSwitchNoticeTimer: number | null = null; - private sessionSwitchFlashTimer: number | null = null; - chatComposerPersistTimer: ReturnType | number | null = null; - chatComposerPersistSnapshot: { - sessionKey: string; - chatMessage: string; - chatQueue: ChatQueueItem[]; - } | null = null; - @state() chatQueue: ChatQueueItem[] = []; - @state() chatQueueBySession: Record = {}; - @state() chatMessagesBySession: ChatMessageCache = new Map(); - @state() chatAttachments: ChatAttachment[] = []; - @state() chatReplyTarget: { - messageId: string; - text: string; - senderLabel?: string | null; - } | null = null; - @state() realtimeTalkActive = false; - @state() realtimeTalkStatus: RealtimeTalkStatus = "idle"; - @state() realtimeTalkDetail: string | null = null; - @state() realtimeTalkTranscript: string | null = null; - @state() realtimeTalkConversation: RealtimeTalkConversationEntry[] = []; - @state() realtimeTalkOptionsOpen = false; - @state() realtimeTalkCatalogProviders: RealtimeTalkCatalogProvider[] | null = null; - @state() realtimeTalkOptions = { - provider: "", - model: "", - voice: "", - transport: "", - vadThreshold: "", - silenceDurationMs: "", - prefixPaddingMs: "", - reasoningEffort: "", - }; - private realtimeTalkSession: RealtimeTalkSession | null = null; - private realtimeTalkConversationState: RealtimeTalkConversationState = - createRealtimeTalkConversationState(); - private nativeBridgeCleanup: (() => void) | null = null; - @state() chatManualRefreshInFlight = false; - @state() chatHeaderControlsHidden = false; - @state() chatMobileControlsOpen = false; - private chatMobileControlsTrigger: HTMLElement | null = null; - @state() navDrawerOpen = false; - - onSlashAction?: (action: string) => void | Promise; - chatLocalInputHistoryBySession: Record> = {}; - chatInputHistorySessionKey: string | null = null; - chatInputHistoryItems: string[] | null = null; - @state() chatInputHistoryIndex = -1; - chatDraftBeforeHistory: string | null = null; - - // Sidebar state for tool output viewing - @state() sidebarOpen = false; - @state() sidebarContent: SidebarContent | null = null; - @state() sidebarError: string | null = null; - @state() splitRatio = this.settings.splitRatio; - - @state() nodesLoading = false; - @state() nodes: Array> = []; - @state() devicesLoading = false; - @state() devicesError: string | null = null; - @state() devicesList: DevicePairingList | null = null; - @state() devicePairSetupOpen = false; - @state() devicePairSetupLoading = false; - @state() devicePairSetupError: string | null = null; - @state() devicePairSetup: DevicePairSetup | null = null; - @state() execApprovalsLoading = false; - @state() execApprovalsSaving = false; - @state() execApprovalsDirty = false; - @state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; - @state() execApprovalsForm: ExecApprovalsFile | null = null; - @state() execApprovalsSelectedAgent: string | null = null; - @state() execApprovalsTarget: "gateway" | "node" = "gateway"; - @state() execApprovalsTargetNodeId: string | null = null; - @state() execApprovalQueue: ExecApprovalRequest[] = []; - @state() execApprovalBusy = false; - @state() execApprovalError: string | null = null; - @state() pendingGatewayUrl: string | null = null; - pendingGatewayToken: string | null = null; - - @state() configLoading = false; - @state() configRaw = "{\n}\n"; - @state() configRawOriginal = ""; - @state() configValid: boolean | null = null; - @state() configIssues: unknown[] = []; - @state() configSaving = false; - @state() configApplying = false; - @state() updateRunning = false; - @state() applySessionKey = this.settings.lastActiveSessionKey; - @state() configSnapshot: ConfigSnapshot | null = null; - @state() configSchema: unknown = null; - @state() configSchemaVersion: string | null = null; - @state() configSchemaLoading = false; - @state() configUiHints: ConfigUiHints = {}; - @state() configForm: Record | null = null; - @state() configFormOriginal: Record | null = null; - @state() selectedAgentId: string | null = null; - @state() dreamingStatusLoading = false; - @state() dreamingStatusError: string | null = null; - @state() dreamingStatus: DreamingStatus | null = null; - @state() dreamingModeSaving = false; - @state() dreamingRestartConfirmOpen = false; - @state() dreamingRestartConfirmLoading = false; - @state() dreamingPendingEnabled: boolean | null = null; - @state() dreamDiaryLoading = false; - @state() dreamDiaryActionLoading = false; - @state() dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null = null; - @state() dreamDiaryActionArchivePath: string | null = null; - @state() dreamDiaryError: string | null = null; - @state() dreamDiaryPath: string | null = null; - @state() dreamDiaryContent: string | null = null; - @state() wikiImportInsightsLoading = false; - @state() wikiImportInsightsError: string | null = null; - @state() wikiImportInsights: WikiImportInsights | null = null; - @state() wikiMemoryPalaceLoading = false; - @state() wikiMemoryPalaceError: string | null = null; - @state() wikiMemoryPalace: WikiMemoryPalace | null = null; - @state() configFormDirty = false; - @state() configSettingsMode: "quick" | "advanced" = "quick"; - @state() configFormMode: "form" | "raw" = "form"; - @state() configSearchQuery = ""; - @state() configActiveSection: string | null = null; - @state() configActiveSubsection: string | null = null; - @state() pendingUpdateExpectedVersion: string | null = null; - @state() pendingUpdateHandoff = false; - @state() updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null = null; - @state() communicationsFormMode: "form" | "raw" = "form"; - @state() communicationsSearchQuery = ""; - @state() communicationsActiveSection: string | null = null; - @state() communicationsActiveSubsection: string | null = null; - @state() appearanceFormMode: "form" | "raw" = "form"; - @state() appearanceSearchQuery = ""; - @state() appearanceActiveSection: string | null = null; - @state() appearanceActiveSubsection: string | null = null; - @state() automationFormMode: "form" | "raw" = "form"; - @state() automationSearchQuery = ""; - @state() automationActiveSection: string | null = null; - @state() automationActiveSubsection: string | null = null; - @state() infrastructureFormMode: "form" | "raw" = "form"; - @state() infrastructureSearchQuery = ""; - @state() infrastructureActiveSection: string | null = null; - @state() infrastructureActiveSubsection: string | null = null; - @state() aiAgentsFormMode: "form" | "raw" = "form"; - @state() aiAgentsSearchQuery = ""; - @state() aiAgentsActiveSection: string | null = null; - @state() aiAgentsActiveSubsection: string | null = null; - - @state() channelsLoading = false; - @state() channelsSnapshot: ChannelsStatusSnapshot | null = null; - @state() channelsError: string | null = null; - @state() channelsLastSuccess: number | null = null; - @state() whatsappLoginMessage: string | null = null; - @state() whatsappLoginQrDataUrl: string | null = null; - @state() whatsappLoginConnected: boolean | null = null; - @state() whatsappBusy = false; - @state() nostrProfileFormState: NostrProfileFormState | null = null; - @state() nostrProfileAccountId: string | null = null; - - @state() presenceLoading = false; - @state() presenceEntries: PresenceEntry[] = []; - @state() presenceError: string | null = null; - @state() presenceStatus: string | null = null; - - @state() agentsLoading = false; - @state() agentsList: AgentsListResult | null = null; - @state() agentsError: string | null = null; - @state() agentsSelectedId: string | null = null; - @state() toolsCatalogLoading = false; - @state() toolsCatalogError: string | null = null; - @state() toolsCatalogResult: ToolsCatalogResult | null = null; - @state() toolsEffectiveLoading = false; - @state() toolsEffectiveLoadingKey: string | null = null; - @state() toolsEffectiveResultKey: string | null = null; - @state() toolsEffectiveError: string | null = null; - @state() toolsEffectiveResult: ToolsEffectiveResult | null = null; - @state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" = "files"; - @state() agentFilesLoading = false; - @state() agentFilesError: string | null = null; - @state() agentFilesList: AgentsFilesListResult | null = null; - @state() agentFileContents: Record = {}; - @state() agentFileDrafts: Record = {}; - @state() agentFileActive: string | null = null; - @state() agentFileSaving = false; - @state() agentIdentityLoading = false; - @state() agentIdentityError: string | null = null; - @state() agentIdentityById: Record = {}; - @state() agentSkillsLoading = false; - @state() agentSkillsError: string | null = null; - @state() agentSkillsReport: SkillStatusReport | null = null; - @state() agentSkillsAgentId: string | null = null; - - @state() sessionsLoading = false; - @state() sessionsResult: SessionsListResult | null = null; - @state() sessionsResultShowArchived = false; - @state() selectedChatSessionArchived = false; - @state() sessionsError: string | null = null; - @state() sessionsFilterActive = DEFAULT_SESSIONS_FILTERS.activeMinutes; - @state() sessionsFilterLimit = DEFAULT_SESSIONS_FILTERS.limit; - @state() sessionsIncludeGlobal = true; - @state() sessionsIncludeUnknown = false; - @state() sessionsShowArchived = false; - @state() sessionsFiltersCollapsed = false; - @state() sessionsHideCron = true; - @state() sessionsSearchQuery = ""; - @state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated"; - @state() sessionsSortDir: "asc" | "desc" = "desc"; - @state() sessionsPage = 0; - @state() sessionsPageSize = 25; - @state() sessionsSelectedKeys: Set = new Set(); - @state() sessionsExpandedCheckpointKey: string | null = null; - @state() sessionsCheckpointItemsByKey: Record = {}; - @state() sessionsCheckpointLoadingKey: string | null = null; - @state() sessionsCheckpointBusyKey: string | null = null; - @state() sessionsCheckpointErrorByKey: Record = {}; - - @state() usageLoading = false; - @state() usageResult: import("./types.js").SessionsUsageResult | null = null; - @state() usageCostSummary: import("./types.js").CostUsageSummary | null = null; - @state() usageError: string | null = null; - @state() usageStartDate = (() => { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - })(); - @state() usageEndDate = (() => { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - })(); - @state() usageScope: "instance" | "family" = "family"; - @state() usageAgentId: string | null = null; - @state() usageSelectedSessions: string[] = []; - @state() usageSelectedDays: string[] = []; - @state() usageSelectedHours: number[] = []; - @state() usageChartMode: "tokens" | "cost" = "tokens"; - @state() usageDailyChartMode: "total" | "by-type" = "by-type"; - @state() usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn"; - @state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type"; - @state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null; - @state() usageTimeSeriesLoading = false; - @state() usageTimeSeriesCursorStart: number | null = null; - @state() usageTimeSeriesCursorEnd: number | null = null; - @state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null; - @state() usageSessionLogsLoading = false; - @state() usageSessionLogsExpanded = false; - // Applied query (used to filter the already-loaded sessions list client-side). - @state() usageQuery = ""; - // Draft query text (updates immediately as the user types; applied via debounce or "Search"). - @state() usageQueryDraft = ""; - @state() usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors" = "recent"; - @state() usageSessionSortDir: "desc" | "asc" = "desc"; - @state() usageRecentSessions: string[] = []; - @state() usageTimeZone: "local" | "utc" = "local"; - @state() usageContextExpanded = false; - @state() usageHeaderPinned = false; - @state() usageSessionsTab: "all" | "recent" = "all"; - @state() usageVisibleColumns: string[] = [ - "channel", - "agent", - "provider", - "model", - "messages", - "tools", - "errors", - "duration", - ]; - @state() usageLogFilterRoles: import("./views/usage.js").SessionLogRole[] = []; - @state() usageLogFilterTools: string[] = []; - @state() usageLogFilterHasTools = false; - @state() usageLogFilterQuery = ""; - - // Non-reactive (don’t trigger renders just for timer bookkeeping). - usageQueryDebounceTimer: number | null = null; - - @state() cronLoading = false; - @state() cronQuickCreateOpen = false; - @state() cronQuickCreateStep: import("./views/cron-quick-create.ts").CronQuickCreateStep = "what"; - @state() cronQuickCreateDraft: - | import("./views/cron-quick-create.ts").CronQuickCreateDraft - | null = null; - @state() cronJobsLoadingMore = false; - cronJobsReloadPending = false; - cronJobsReloadPendingTableFilters = false; - @state() cronJobs: CronJob[] = []; - @state() cronJobsTotal = 0; - @state() cronJobsHasMore = false; - @state() cronJobsNextOffset: number | null = null; - @state() cronJobsLimit = 50; - @state() cronJobsQuery = ""; - @state() cronJobsEnabledFilter: import("./types.js").CronJobsEnabledFilter = "all"; - @state() cronJobsScheduleKindFilter: import("./controllers/cron.js").CronJobsScheduleKindFilter = - "all"; - @state() cronJobsLastStatusFilter: import("./controllers/cron.js").CronJobsLastStatusFilter = - "all"; - @state() cronJobsSortBy: import("./types.js").CronJobsSortBy = "nextRunAtMs"; - @state() cronJobsSortDir: import("./types.js").CronSortDir = "asc"; - @state() cronStatus: CronStatus | null = null; - @state() cronError: string | null = null; - @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM }; - @state() cronFormCollapsed = true; - @state() cronFieldErrors: import("./controllers/cron.js").CronFieldErrors = {}; - @state() cronEditingJobId: string | null = null; - @state() cronRunsJobId: string | null = null; - @state() cronRunsLoadingMore = false; - @state() cronRuns: CronRunLogEntry[] = []; - @state() cronRunsTotal = 0; - @state() cronRunsHasMore = false; - @state() cronRunsNextOffset: number | null = null; - @state() cronRunsLimit = 50; - @state() cronRunsScope: import("./types.js").CronRunScope = "all"; - @state() cronRunsStatuses: import("./types.js").CronRunsStatusValue[] = []; - @state() cronRunsDeliveryStatuses: import("./types.js").CronDeliveryStatus[] = []; - @state() cronRunsStatusFilter: import("./types.js").CronRunsStatusFilter = "all"; - @state() cronRunsQuery = ""; - @state() cronRunsSortDir: import("./types.js").CronSortDir = "desc"; - @state() cronModelSuggestions: string[] = []; - @state() cronBusy = false; - - @state() updateAvailable: import("./types.js").UpdateAvailable | null = null; - - // Overview dashboard state - @state() attentionItems: import("./types.js").AttentionItem[] = []; - @state() paletteOpen = false; - @state() paletteQuery = ""; - @state() paletteActiveIndex = 0; - @state() overviewShowGatewayToken = false; - @state() overviewShowGatewayPassword = false; - @state() overviewLogLines: string[] = []; - @state() overviewLogCursor = 0; - - @state() skillsLoading = false; - @state() skillsAgentId: string | null = null; - skillsAgentRevision = 0; - @state() skillsReport: SkillStatusReport | null = null; - @state() skillsError: string | null = null; - @state() skillsFilter = ""; - @state() skillsStatusFilter: "all" | "ready" | "needs-setup" | "disabled" = "all"; - @state() skillEdits: Record = {}; - @state() skillsBusyKey: string | null = null; - @state() skillMessages: Record = {}; - @state() skillsDetailKey: string | null = null; - @state() skillsDetailTab: "overview" | "card" = "overview"; - @state() clawhubSearchQuery = ""; - @state() clawhubSearchResults: ClawHubSearchResult[] | null = null; - @state() clawhubSearchLoading = false; - @state() clawhubSearchError: string | null = null; - @state() clawhubDetail: ClawHubSkillDetail | null = null; - @state() clawhubDetailSlug: string | null = null; - @state() clawhubDetailLoading = false; - @state() clawhubDetailError: string | null = null; - @state() clawhubInstallSlug: string | null = null; - @state() clawhubInstallMessage: { - kind: "success" | "error"; - text: string; - acknowledgeSlug?: string; - acknowledgeVersion?: string; - } | null = null; - @state() clawhubVerdicts: Record = {}; - @state() clawhubVerdictsLoading = false; - @state() clawhubVerdictsError: string | null = null; - @state() skillCardContents: Record = {}; - @state() skillCardContentKeys: Record = {}; - @state() skillCardLoadingKey: string | null = null; - @state() skillCardErrors: Record = {}; - @state() skillWorkshopLoading = false; - @state() skillWorkshopAgentId: string | null = null; - @state() skillWorkshopLoaded = false; - @state() skillWorkshopError: string | null = null; - @state() skillWorkshopInspectingKey: string | null = null; - @state() skillWorkshopProposals: SkillWorkshopState["skillWorkshopProposals"] = []; - @state() skillWorkshopSelectedKey: string | null = null; - @state() skillWorkshopActionBusy: SkillWorkshopState["skillWorkshopActionBusy"] = null; - @state() skillWorkshopActionNotice: SkillWorkshopState["skillWorkshopActionNotice"] = null; - skillWorkshopActionNoticeTimer: ReturnType | number | null = null; - @state() skillWorkshopRevisionKey: string | null = null; - @state() skillWorkshopRevisionDraft = ""; - @state() skillWorkshopStatusFilter: SkillWorkshopState["skillWorkshopStatusFilter"] = "pending"; - @state() skillWorkshopQuery = ""; - @state() skillWorkshopFilePreviewKey: string | null = null; - @state() skillWorkshopFilePreviewQuery = ""; - @state() skillWorkshopQueueWidth = 360; - @state() skillWorkshopMode: SkillWorkshopState["skillWorkshopMode"] = loadSkillWorkshopMode(); - @state() skillWorkshopUseCurrentChatForRevisions = loadSkillWorkshopUseCurrentChatForRevisions(); - - @state() healthLoading = false; - @state() healthResult: HealthSummary | null = null; - @state() healthError: string | null = null; - - @state() modelAuthStatusLoading = false; - @state() modelAuthStatusResult: ModelAuthStatusResult | null = null; - @state() modelAuthStatusError: string | null = null; - - @state() debugLoading = false; - @state() debugStatus: StatusSummary | null = null; - @state() debugHealth: HealthSummary | null = null; - @state() debugModels: ModelCatalogEntry[] = []; - @state() debugHeartbeat: unknown = null; - @state() debugCallMethod = ""; - @state() debugCallParams = "{}"; - @state() debugCallResult: string | null = null; - @state() debugCallError: string | null = null; - - @state() webPushSupported = false; - @state() webPushPermission: NotificationPermission | "unsupported" = "unsupported"; - @state() webPushSubscribed = false; - @state() webPushLoading = false; - - @state() logsLoading = false; - @state() logsError: string | null = null; - @state() logsFile: string | null = null; - @state() logsEntries: LogEntry[] = []; - @state() logsFilterText = ""; - @state() logsLevelFilters: Record = { - ...DEFAULT_LOG_LEVEL_FILTERS, - }; - @state() logsAutoFollow = true; - @state() logsTruncated = false; - @state() logsCursor: number | null = null; - @state() logsLastFetchAt: number | null = null; - @state() logsLimit = 500; - @state() logsMaxBytes = 250_000; - @state() logsAtBottom = true; - - client: GatewayBrowserClient | null = null; - chatScrollFrame: number | null = null; - chatScrollTimeout: number | null = null; - chatLastScrollTop = 0; - chatHasAutoScrolled = false; - chatUserNearBottom = true; - chatFollowLocked = false; - chatIsProgrammaticScroll = false; - chatProgrammaticScrollTarget = 0; - @state() chatNewMessagesBelow = false; - nodesPollInterval: number | null = null; - logsPollInterval: number | null = null; - debugPollInterval: number | null = null; - sessionsChangedReloadTimer: number | ReturnType | null = null; - logsScrollFrame: number | null = null; - activityScrollFrame: number | null = null; - controlUiResponsivenessObserver: { disconnect: () => void } | null = null; - toolStreamById = new Map(); - toolStreamOrder: string[] = []; - refreshSessionsAfterChat = new Map(); - chatSideResultTerminalRuns = new Set(); - basePath = ""; - popStateHandler = () => - onPopStateInternal(this as unknown as Parameters[0]); - topbarObserver: ResizeObserver | null = null; - private globalKeydownHandler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "k") { - e.preventDefault(); - this.paletteOpen = !this.paletteOpen; - if (this.paletteOpen) { - this.paletteQuery = ""; - this.paletteActiveIndex = 0; - } - } - }; - private chatMobileControlsKeydownHandler = (e: KeyboardEvent) => { - if (e.key !== "Escape") { - return; - } - if (this.chatSessionPickerOpen) { - e.preventDefault(); - this.chatSessionPickerOpen = false; - this.chatSessionPickerSurface = null; - return; - } - const openComposerDetails = this.querySelectorAll( - ".chat-controls__inline-select[open], .agent-chat__talk-select[open], .agent-chat__talk-options-advanced[open]", - ); - if (openComposerDetails.length > 0) { - e.preventDefault(); - openComposerDetails.forEach((details) => { - details.open = false; - }); - return; - } - if (this.realtimeTalkOptionsOpen) { - e.preventDefault(); - this.realtimeTalkOptionsOpen = false; - return; - } - if (!this.chatMobileControlsOpen) { - return; - } - e.preventDefault(); - this.setChatMobileControlsOpen(false, { restoreFocus: true }); - }; - private chatMobileControlsPointerdownHandler = (e: Event) => { - const path = e.composedPath(); - this.querySelectorAll( - ".chat-controls__inline-select[open], .agent-chat__talk-select[open], .agent-chat__talk-options-advanced[open]", - ).forEach((details) => { - if (!path.includes(details)) { - details.open = false; - } - }); - if (this.realtimeTalkOptionsOpen) { - const insideTalkOptions = Array.from( - this.querySelectorAll( - ".agent-chat__talk-options, [aria-label='Talk settings'], [aria-label='Talk options']", - ), - ).some((node) => path.includes(node)); - if (!insideTalkOptions) { - this.realtimeTalkOptionsOpen = false; - } - } - if (this.chatSessionPickerOpen) { - const insidePicker = Array.from( - this.querySelectorAll(".chat-controls__session-picker, .sidebar-session-search"), - ).some((node) => path.includes(node)); - if (!insidePicker) { - this.chatSessionPickerOpen = false; - this.chatSessionPickerSurface = null; - } - } - if (!this.chatMobileControlsOpen) { - return; - } - const wrapper = - this.querySelector(".chat-settings-popover-wrapper") ?? - this.querySelector(".chat-mobile-controls-wrapper"); - if (wrapper && path.includes(wrapper)) { - return; - } - this.setChatMobileControlsOpen(false); - }; - private nativeTitleTooltipPointerOverHandler = (event: PointerEvent) => { - promoteNativeTitleTooltip(event.target, this, "pointer"); - }; - private nativeTitleTooltipPointerOutHandler = (event: PointerEvent) => { - restoreNativeTitleTooltip(event.target, this, "pointer", event.relatedTarget); - }; - private nativeTitleTooltipFocusInHandler = (event: FocusEvent) => { - promoteNativeTitleTooltip(event.target, this, "focus"); - }; - private nativeTitleTooltipFocusOutHandler = (event: FocusEvent) => { - restoreNativeTitleTooltip(event.target, this, "focus", event.relatedTarget); - }; - - override createRenderRoot() { - return this; - } - - override connectedCallback() { - super.connectedCallback(); - this.onSlashAction = async (action: string) => { - switch (action) { - case "new-session": - await createChatSessionInternal(this as unknown as AppViewState, { source: "user" }); - break; - case "export": - exportChatMarkdown(this.chatMessages, this.assistantName); - break; - case "refresh-tools-effective": { - await refreshVisibleToolsEffectiveForCurrentSessionInternal(this); - break; - } - } - }; - document.addEventListener("keydown", this.globalKeydownHandler); - document.addEventListener("keydown", this.chatMobileControlsKeydownHandler); - document.addEventListener("pointerdown", this.chatMobileControlsPointerdownHandler); - this.addEventListener("pointerover", this.nativeTitleTooltipPointerOverHandler); - this.addEventListener("pointerout", this.nativeTitleTooltipPointerOutHandler); - this.addEventListener("focusin", this.nativeTitleTooltipFocusInHandler); - this.addEventListener("focusout", this.nativeTitleTooltipFocusOutHandler); - handleConnected(this as unknown as Parameters[0]); - this.nativeBridgeCleanup = initNativeBridge(this); - void this.initWebPushState(); - } - - protected override firstUpdated() { - handleFirstUpdated(this as unknown as Parameters[0]); - } - - protected override willUpdate() { - prepareActiveFloatingTooltipsForRender(this); - } - - override disconnectedCallback() { - document.removeEventListener("keydown", this.globalKeydownHandler); - this.nativeBridgeCleanup?.(); - this.nativeBridgeCleanup = null; - document.removeEventListener("keydown", this.chatMobileControlsKeydownHandler); - document.removeEventListener("pointerdown", this.chatMobileControlsPointerdownHandler); - this.removeEventListener("pointerover", this.nativeTitleTooltipPointerOverHandler); - this.removeEventListener("pointerout", this.nativeTitleTooltipPointerOutHandler); - this.removeEventListener("focusin", this.nativeTitleTooltipFocusInHandler); - this.removeEventListener("focusout", this.nativeTitleTooltipFocusOutHandler); - clearActiveFloatingTooltips(this); - if (this.sessionSwitchNoticeTimer !== null) { - window.clearTimeout(this.sessionSwitchNoticeTimer); - this.sessionSwitchNoticeTimer = null; - } - if (this.sessionSwitchFlashTimer !== null) { - window.clearTimeout(this.sessionSwitchFlashTimer); - this.sessionSwitchFlashTimer = null; - } - this.chatMobileControlsTrigger = null; - handleDisconnected(this as unknown as Parameters[0]); - super.disconnectedCallback(); - } - - protected override updated(changed: Map) { - handleUpdated(this as unknown as Parameters[0], changed); - refreshActiveFloatingTooltip(this); - // Some render callbacks assign tab directly while preparing nested panel state. - if (changed.has("tab") && this.tab !== "chat" && this.chatMobileControlsOpen) { - this.setChatMobileControlsOpen(false); - } - if ( - this.tab === "skillWorkshop" && - (changed.has("sessionKey") || changed.has("assistantAgentId")) - ) { - void loadSkillWorkshopProposals(this, { force: true }); - } - if (!changed.has("sessionKey") || this.agentsPanel !== "tools") { - return; - } - const activeSessionAgentId = resolveAgentIdFromSessionKey(this.sessionKey); - if (this.agentsSelectedId && this.agentsSelectedId === activeSessionAgentId) { - void loadToolsEffectiveInternal(this, { - agentId: this.agentsSelectedId, - sessionKey: this.sessionKey, - }); - return; - } - this.toolsEffectiveResult = null; - this.toolsEffectiveResultKey = null; - this.toolsEffectiveError = null; - this.toolsEffectiveLoading = false; - this.toolsEffectiveLoadingKey = null; - } - - connect() { - connectGatewayInternal(this as unknown as Parameters[0]); - } - - handleChatScroll(event: Event) { - handleChatScrollInternal( - this as unknown as Parameters[0], - event, - ); - } - - handleLogsScroll(event: Event) { - handleLogsScrollInternal( - this as unknown as Parameters[0], - event, - ); - } - - handleActivityScroll(event: Event) { - handleActivityScrollInternal( - this as unknown as Parameters[0], - event, - ); - } - - scheduleActivityScroll(force = false) { - scheduleActivityScrollInternal( - this as unknown as Parameters[0], - force, - ); - } - - exportLogs(lines: string[], label: string) { - exportLogsInternal(lines, label); - } - - resetToolStream() { - resetToolStreamInternal(this as unknown as Parameters[0]); - } - - resetChatScroll() { - resetChatScrollInternal(this as unknown as Parameters[0]); - } - - scrollToBottom(opts?: { smooth?: boolean }) { - resetChatScrollInternal(this as unknown as Parameters[0]); - scheduleChatScrollInternal( - this as unknown as Parameters[0], - true, - Boolean(opts?.smooth), - { source: "manual" }, - ); - } - - scheduleChatScroll() { - scheduleChatScrollInternal(this as unknown as Parameters[0]); - } - - async loadAssistantIdentity(opts?: { sessionKey?: string; expectedSessionKey?: string }) { - await loadAssistantIdentityInternal(this, opts); - } - - applySettings(next: UiSettings) { - applySettingsInternal(this as unknown as Parameters[0], next); - } - - applyLocalUserIdentity(next: { name?: string | null; avatar?: string | null }) { - applyLocalUserIdentityInternal( - this as unknown as Parameters[0], - next, - ); - } - - setTab(next: Tab) { - setTabInternal(this as unknown as Parameters[0], next); - if (next !== "chat") { - this.setChatMobileControlsOpen(false); - } - this.navDrawerOpen = false; - } - - setChatMobileControlsOpen( - open: boolean, - options?: { trigger?: HTMLElement | null; restoreFocus?: boolean }, - ) { - if (open) { - this.chatMobileControlsTrigger = options?.trigger ?? this.chatMobileControlsTrigger; - this.chatMobileControlsOpen = true; - return; - } - - const focusTarget = options?.restoreFocus ? this.chatMobileControlsTrigger : null; - this.chatMobileControlsOpen = false; - if (this.chatSessionPickerSurface === "mobile") { - this.chatSessionPickerOpen = false; - this.chatSessionPickerSurface = null; - } - this.chatMobileControlsTrigger = null; - if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) { - return; - } - requestAnimationFrame(() => { - if (focusTarget.isConnected) { - focusTarget.focus(); - } - }); - } - - setTheme(next: ThemeName, context?: Parameters[2]) { - setThemeInternal(this as unknown as Parameters[0], next, context); - this.themeOrder = this.buildThemeOrder(next); - } - - setThemeMode(next: ThemeMode, context?: Parameters[2]) { - setThemeModeInternal( - this as unknown as Parameters[0], - next, - context, - ); - } - - setCustomThemeImportUrl(next: string) { - this.customThemeImportUrl = next; - if (this.customThemeImportMessage?.kind === "error") { - this.customThemeImportMessage = null; - } - } - - openCustomThemeImport() { - this.customThemeImportExpanded = true; - this.customThemeImportFocusToken += 1; - if (!this.settings.customTheme) { - this.customThemeImportSelectOnSuccess = true; - } - } - - async importCustomTheme() { - if (this.customThemeImportBusy) { - return; - } - this.customThemeImportExpanded = true; - this.customThemeImportBusy = true; - this.customThemeImportMessage = null; - try { - const customTheme = await importCustomThemeFromUrl(this.customThemeImportUrl); - const shouldSelectImportedTheme = - this.theme === "custom" || - !this.settings.customTheme || - this.customThemeImportSelectOnSuccess; - applySettingsInternal(this as unknown as Parameters[0], { - ...this.settings, - theme: shouldSelectImportedTheme ? "custom" : this.settings.theme, - customTheme, - }); - this.themeOrder = this.buildThemeOrder(shouldSelectImportedTheme ? "custom" : this.theme); - this.customThemeImportUrl = ""; - this.customThemeImportSelectOnSuccess = false; - this.customThemeImportMessage = { - kind: "success", - text: `Imported ${customTheme.label}.`, - }; - } catch (error) { - this.customThemeImportMessage = { - kind: "error", - text: error instanceof Error ? error.message : "Failed to import tweakcn theme.", - }; - } finally { - this.customThemeImportBusy = false; - } - } - - clearCustomTheme() { - const nextTheme = this.theme === "custom" ? "claw" : this.theme; - this.customThemeImportExpanded = true; - this.customThemeImportSelectOnSuccess = false; - applySettingsInternal(this as unknown as Parameters[0], { - ...this.settings, - theme: nextTheme, - customTheme: undefined, - }); - this.themeOrder = this.buildThemeOrder(nextTheme); - this.customThemeImportMessage = { - kind: "success", - text: "Cleared custom theme.", - }; - } - - setBorderRadius(value: number) { - applySettingsInternal(this as unknown as Parameters[0], { - ...this.settings, - borderRadius: value, - }); - this.requestUpdate(); - } - - setTextScale(value: number) { - applySettingsInternal(this as unknown as Parameters[0], { - ...this.settings, - textScale: value as typeof this.settings.textScale, - }); - this.requestUpdate(); - } - - announceSessionSwitch(sessionKey: string, label: string) { - const id = ++this.sessionSwitchNoticeSeq; - if (this.sessionSwitchNoticeTimer !== null) { - window.clearTimeout(this.sessionSwitchNoticeTimer); - } - if (this.sessionSwitchFlashTimer !== null) { - window.clearTimeout(this.sessionSwitchFlashTimer); - } - this.sessionSwitchNotice = { - id, - text: t("chat.switchedSession", { session: label }), - }; - this.sessionSwitchFlashKey = sessionKey; - this.sessionSwitchFlashTimer = window.setTimeout(() => { - if (this.sessionSwitchNotice?.id === id) { - this.sessionSwitchFlashKey = null; - } - this.sessionSwitchFlashTimer = null; - }, 200); - this.sessionSwitchNoticeTimer = window.setTimeout(() => { - if (this.sessionSwitchNotice?.id === id) { - this.sessionSwitchNotice = null; - } - this.sessionSwitchNoticeTimer = null; - }, 2800); - } - - buildThemeOrder(active: ThemeName): ThemeName[] { - const all = [...VALID_THEME_NAMES]; - const rest = all.filter((id) => id !== active); - return [active, ...rest]; - } - - async loadOverview(opts?: { refresh?: boolean }) { - await loadOverviewInternal(this as unknown as Parameters[0], opts); - } - - async loadCron() { - await loadCronInternal(this as unknown as Parameters[0]); - } - - async handleAbortChat(opts?: Parameters[1]) { - await handleAbortChatInternal( - this as unknown as Parameters[0], - opts, - ); - } - - handleChatDraftChange(next: string) { - handleChatDraftChangeInternal( - this as unknown as Parameters[0], - next, - ); - } - - handleChatInputHistoryKey(input: ChatInputHistoryKeyInput): ChatInputHistoryKeyResult { - return handleChatInputHistoryKeyInternal( - this as unknown as Parameters[0], - input, - ); - } - - resetChatInputHistoryNavigation() { - resetChatInputHistoryNavigationInternal( - this as unknown as Parameters[0], - ); - } - - removeQueuedMessage(id: string) { - removeQueuedMessageInternal( - this as unknown as Parameters[0], - id, - ); - } - - async retryQueuedChatMessage(id: string) { - await retryQueuedChatMessageInternal( - this as unknown as Parameters[0], - id, - ); - } - - async handleSendChat( - messageOverride?: string, - opts?: Parameters[2], - ) { - await handleSendChatInternal( - this as unknown as Parameters[0], - messageOverride, - opts, - ); - } - - updateRealtimeTalkOptions(next: Partial) { - this.realtimeTalkOptions = { ...this.realtimeTalkOptions, ...next }; - } - - async fetchRealtimeTalkCatalog() { - if (!this.client || !this.connected) { - return; - } - this.realtimeTalkCatalogProviders = null; - try { - const result = await this.client.request<{ - realtime?: { providers?: RealtimeTalkCatalogProvider[] }; - }>("talk.catalog", {}); - const providers = result?.realtime?.providers ?? []; - this.realtimeTalkCatalogProviders = providers; - const update = reconcileRealtimeTalkCatalogSelection({ - providers, - selection: this.realtimeTalkOptions, - }); - if (update) { - this.updateRealtimeTalkOptions(update); - } - } catch { - this.realtimeTalkCatalogProviders = null; - } - } - - private buildRealtimeTalkLaunchOptions(): RealtimeTalkLaunchOptions { - const options = this.realtimeTalkOptions ?? { - provider: "", - model: "", - voice: "", - transport: "", - vadThreshold: "", - silenceDurationMs: "", - prefixPaddingMs: "", - reasoningEffort: "", - }; - const text = (value: string) => value.trim() || undefined; - const number = (value: string) => { - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const parsed = Number(trimmed); - return Number.isFinite(parsed) ? parsed : undefined; - }; - const transport = text(options.transport) as RealtimeTalkLaunchOptions["transport"] | undefined; - return { - provider: text(options.provider), - model: text(options.model), - voice: text(options.voice), - transport, - vadThreshold: number(options.vadThreshold), - silenceDurationMs: number(options.silenceDurationMs), - prefixPaddingMs: number(options.prefixPaddingMs), - reasoningEffort: text(options.reasoningEffort), - }; - } - - async toggleRealtimeTalk() { - if (this.realtimeTalkSession) { - if (this.realtimeTalkStatus === "error") { - this.realtimeTalkSession.stop(); - this.realtimeTalkSession = null; - } else { - this.realtimeTalkSession.stop(); - this.realtimeTalkSession = null; - this.realtimeTalkActive = false; - this.realtimeTalkStatus = "idle"; - this.realtimeTalkDetail = null; - this.realtimeTalkTranscript = null; - this.resetRealtimeTalkConversation(); - return; - } - } - if (!this.client || !this.connected) { - this.lastError = "Gateway not connected"; - this.chatError = this.lastError; - return; - } - this.realtimeTalkActive = true; - this.realtimeTalkStatus = "connecting"; - this.realtimeTalkDetail = null; - this.realtimeTalkTranscript = null; - this.resetRealtimeTalkConversation(); - const session = new RealtimeTalkSession( - this.client, - this.sessionKey, - { - onStatus: (status, detail) => { - this.realtimeTalkStatus = status; - this.realtimeTalkDetail = detail ?? null; - if (status === "idle" || status === "error") { - this.realtimeTalkActive = status !== "idle"; - } - }, - onTranscript: (entry) => { - this.realtimeTalkTranscript = `${entry.role === "user" ? "You" : "OpenClaw"}: ${entry.text}`; - this.realtimeTalkConversationState = updateRealtimeTalkConversation( - this.realtimeTalkConversationState, - entry, - ); - this.realtimeTalkConversation = this.realtimeTalkConversationState.entries; - }, - }, - this.buildRealtimeTalkLaunchOptions(), - ); - this.realtimeTalkSession = session; - try { - await session.start(); - } catch (error) { - session.stop(); - if (this.realtimeTalkSession === session) { - this.realtimeTalkSession = null; - } - this.realtimeTalkActive = false; - this.realtimeTalkStatus = "error"; - this.realtimeTalkDetail = error instanceof Error ? error.message : String(error); - } - } - - resetRealtimeTalkConversation() { - this.realtimeTalkConversationState = createRealtimeTalkConversationState(); - this.realtimeTalkConversation = []; - } - - async steerQueuedChatMessage(id: string) { - await steerQueuedChatMessageInternal( - this as unknown as Parameters[0], - id, - ); - } - - async handleWhatsAppStart(force: boolean) { - await handleWhatsAppStartInternal(this, force); - } - - async handleWhatsAppWait() { - await handleWhatsAppWaitInternal(this); - } - - async handleWhatsAppLogout() { - await handleWhatsAppLogoutInternal(this); - } - - async handleChannelConfigSave() { - await handleChannelConfigSaveInternal(this); - } - - async handleChannelConfigReload() { - await handleChannelConfigReloadInternal(this); - } - - handleNostrProfileEdit(accountId: string, profile: NostrProfile | null) { - handleNostrProfileEditInternal(this, accountId, profile); - } - - handleNostrProfileCancel() { - handleNostrProfileCancelInternal(this); - } - - handleNostrProfileFieldChange(field: keyof NostrProfile, value: string) { - handleNostrProfileFieldChangeInternal(this, field, value); - } - - async handleNostrProfileSave() { - await handleNostrProfileSaveInternal(this); - } - - async handleNostrProfileImport() { - await handleNostrProfileImportInternal(this); - } - - handleNostrProfileToggleAdvanced() { - handleNostrProfileToggleAdvancedInternal(this); - } - - async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") { - const active = this.execApprovalQueue[0]; - if (!active || !this.client || this.execApprovalBusy) { - return; - } - this.execApprovalBusy = true; - this.execApprovalError = null; - try { - const method = active.kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; - await this.client.request(method, { - id: active.id, - decision, - }); - dismissExecApprovalPrompt(this, active.id); - } catch (err) { - if (isStaleApprovalResolutionError(err)) { - dismissExecApprovalPrompt(this, active.id); - await refreshPendingApprovalQueue(this); - return; - } - if (!this.execApprovalQueue.some((entry) => entry.id === active.id)) { - return; - } - this.execApprovalError = `Approval failed: ${String(err)}`; - } finally { - this.execApprovalBusy = false; - } - } - - handleGatewayUrlConfirm() { - const nextGatewayUrl = this.pendingGatewayUrl; - if (!nextGatewayUrl) { - return; - } - const nextToken = this.pendingGatewayToken?.trim() || ""; - this.pendingGatewayUrl = null; - this.pendingGatewayToken = null; - applySettingsInternal(this as unknown as Parameters[0], { - ...this.settings, - gatewayUrl: nextGatewayUrl, - token: nextToken, - }); - restoreChatComposerState(this, { preserveCurrent: true }); - this.connect(); - } - - handleGatewayUrlCancel() { - this.pendingGatewayUrl = null; - this.pendingGatewayToken = null; - restoreChatComposerState(this, { preserveCurrent: true }); - } - - private async maybeUpgradeSidebarToFullMessage(content: SidebarContent) { - const request = content.fullMessageRequest; - if (!request || !this.client) { - return; - } - try { - const result = (await this.client.request("chat.message.get", { - sessionKey: request.sessionKey, - ...(request.agentId ? { agentId: request.agentId } : {}), - messageId: request.messageId, - maxChars: FULL_MESSAGE_SIDEBAR_MAX_CHARS, - })) as - | { - ok?: boolean; - message?: unknown; - unavailableReason?: "not_found" | "oversized" | "not_visible"; - } - | undefined; - - if (this.sidebarContent !== content) { - return; - } - - if (!result?.ok || !result.message || typeof result.message !== "object") { - this.sidebarContent = { - ...content, - unavailableReason: result?.unavailableReason ?? "not_found", - }; - this.sidebarError = resolveSidebarUnavailableReason( - result?.unavailableReason ?? "not_found", - ); - return; - } - - const message = result.message as Record; - const fetchedMessageText = - typeof message.text === "string" - ? message.text - : typeof message.content === "string" - ? message.content - : Array.isArray(message.content) - ? message.content - .map((block) => - block && - typeof block === "object" && - typeof (block as { text?: unknown }).text === "string" - ? (block as { text: string }).text - : null, - ) - .filter((value): value is string => typeof value === "string") - .join("\n") - : null; - const nextRawText = - fetchedMessageText ?? - (typeof content.rawText === "string" - ? content.rawText - : content.kind === "markdown" - ? content.content - : null); - - if (content.kind === "markdown") { - this.sidebarContent = { - ...content, - content: nextRawText || content.content, - rawText: nextRawText || content.rawText || content.content, - unavailableReason: null, - }; - } else { - this.sidebarContent = { - ...content, - rawText: nextRawText || content.rawText || null, - unavailableReason: null, - }; - } - this.sidebarError = null; - } catch (err) { - if (this.sidebarContent !== content) { - return; - } - this.sidebarError = `Failed to load full content: ${err instanceof Error ? err.message : String(err)}`; - } - } - - // Sidebar handlers for tool output viewing - handleOpenSidebar(content: SidebarContent) { - if (this.sidebarCloseTimer != null) { - window.clearTimeout(this.sidebarCloseTimer); - this.sidebarCloseTimer = null; - } - this.sidebarContent = content; - this.sidebarError = null; - this.sidebarOpen = true; - if (isSidebarMarkdownLike(content) && content.fullMessageRequest) { - void this.maybeUpgradeSidebarToFullMessage(content); - } - } - - handleCloseSidebar() { - this.sidebarOpen = false; - // Clear content after transition - if (this.sidebarCloseTimer != null) { - window.clearTimeout(this.sidebarCloseTimer); - } - this.sidebarCloseTimer = window.setTimeout(() => { - if (this.sidebarOpen) { - return; - } - this.sidebarContent = null; - this.sidebarError = null; - this.sidebarCloseTimer = null; - }, 200); - } - - handleSplitRatioChange(ratio: number) { - const newRatio = Math.max(0.4, Math.min(0.7, ratio)); - this.splitRatio = newRatio; - this.applySettings({ ...this.settings, splitRatio: newRatio }); - } - - private async initWebPushState() { - const supported = - "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; - this.webPushSupported = supported; - this.webPushPermission = supported ? Notification.permission : "unsupported"; - if (supported) { - try { - const { getExistingSubscription } = await import("./push-subscription.ts"); - const existing = await getExistingSubscription(); - this.webPushSubscribed = existing !== null; - } catch { - // ignore — just means we can't check - } - } - } - - /** Re-register local push subscription with the gateway after connect. */ - async reconcileWebPushState() { - if (!this.client) { - return; - } - try { - // Always check PushManager directly — initWebPushState may not have finished - // yet if gateway connected quickly. - const { getExistingSubscription } = await import("./push-subscription.ts"); - const existing = await getExistingSubscription(); - if (!existing) { - return; - } - this.webPushSubscribed = true; - const subJson = existing.toJSON(); - if (subJson.endpoint && subJson.keys?.p256dh && subJson.keys?.auth) { - await this.client.request("push.web.subscribe", { - endpoint: subJson.endpoint, - keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth }, - }); - } - } catch { - // Best-effort — don't block if gateway is unreachable. - } - } - - async handleWebPushSubscribe() { - if (!this.client || this.webPushLoading) { - return; - } - this.webPushLoading = true; - try { - const { subscribeToWebPush } = await import("./push-subscription.ts"); - await subscribeToWebPush(this.client); - this.webPushSubscribed = true; - this.webPushPermission = Notification.permission; - } catch (err) { - this.lastError = String(err); - } finally { - this.webPushLoading = false; - // Always refresh permission state — catches denied prompts too. - if ("Notification" in window) { - this.webPushPermission = Notification.permission; - } - } - } - - async handleWebPushUnsubscribe() { - if (!this.client || this.webPushLoading) { - return; - } - this.webPushLoading = true; - try { - const { unsubscribeFromWebPush } = await import("./push-subscription.ts"); - await unsubscribeFromWebPush(this.client); - this.webPushSubscribed = false; - } catch (err) { - this.lastError = String(err); - } finally { - this.webPushLoading = false; - } - } - - async handleWebPushTest() { - if (!this.client) { - return; - } - try { - const { sendTestWebPush } = await import("./push-subscription.ts"); - await sendTestWebPush(this.client); - } catch (err) { - this.lastError = String(err); - } - } - - override render() { - return renderApp(this as unknown as AppViewState); - } -} - -if (!customElements.get("openclaw-app")) { - customElements.define("openclaw-app", OpenClawApp); -} diff --git a/ui/src/ui/canvas-url.test.ts b/ui/src/ui/canvas-url.test.ts deleted file mode 100644 index 4613554138a9..000000000000 --- a/ui/src/ui/canvas-url.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Control UI tests cover canvas url behavior. -import { describe, expect, it } from "vitest"; -import { resolveCanvasIframeUrl } from "./canvas-url.ts"; - -describe("resolveCanvasIframeUrl", () => { - it("allows same-origin hosted canvas document paths", () => { - expect(resolveCanvasIframeUrl("/__openclaw__/canvas/documents/cv_demo/index.html")).toBe( - "/__openclaw__/canvas/documents/cv_demo/index.html", - ); - }); - - it("rewrites safe canvas paths through the scoped canvas host", () => { - expect( - resolveCanvasIframeUrl( - "/__openclaw__/canvas/documents/cv_demo/index.html", - "http://127.0.0.1:19003/__openclaw__/cap/cap_123", - ), - ).toBe( - "http://127.0.0.1:19003/__openclaw__/cap/cap_123/__openclaw__/canvas/documents/cv_demo/index.html", - ); - }); - - it("rejects non-canvas same-origin paths", () => { - expect(resolveCanvasIframeUrl("/not-canvas/snake.html")).toBeUndefined(); - }); - - it("rejects absolute external URLs", () => { - expect(resolveCanvasIframeUrl("https://example.com/evil.html")).toBeUndefined(); - }); - - it("allows absolute external URLs only when explicitly enabled", () => { - expect(resolveCanvasIframeUrl("https://example.com/embed.html?x=1#y", undefined, true)).toBe( - "https://example.com/embed.html?x=1#y", - ); - }); - - it("rejects file URLs", () => { - expect(resolveCanvasIframeUrl("file:///tmp/snake.html")).toBeUndefined(); - }); -}); diff --git a/ui/src/ui/canvas-url.ts b/ui/src/ui/canvas-url.ts deleted file mode 100644 index 868fa8b70a15..000000000000 --- a/ui/src/ui/canvas-url.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Control UI module implements canvas url behavior. -const A2UI_PATH = "/__openclaw__/a2ui"; -const CANVAS_HOST_PATH = "/__openclaw__/canvas"; -const CANVAS_CAPABILITY_PATH_PREFIX = "/__openclaw__/cap"; - -function isCanvasHttpPath(pathname: string): boolean { - return ( - pathname === CANVAS_HOST_PATH || - pathname.startsWith(`${CANVAS_HOST_PATH}/`) || - pathname === A2UI_PATH || - pathname.startsWith(`${A2UI_PATH}/`) - ); -} - -function isExternalHttpUrl(entry: URL): boolean { - return entry.protocol === "http:" || entry.protocol === "https:"; -} - -function sanitizeCanvasEntryUrl( - rawEntryUrl: string, - allowExternalEmbedUrls = false, -): string | undefined { - try { - const entry = new URL(rawEntryUrl, "http://localhost"); - if (entry.origin !== "http://localhost") { - if (!allowExternalEmbedUrls || !isExternalHttpUrl(entry)) { - return undefined; - } - return entry.toString(); - } - if (!isCanvasHttpPath(entry.pathname)) { - return undefined; - } - return `${entry.pathname}${entry.search}${entry.hash}`; - } catch { - return undefined; - } -} - -export function resolveCanvasIframeUrl( - entryUrl: string | undefined, - canvasPluginSurfaceUrl?: string | null, - allowExternalEmbedUrls = false, -): string | undefined { - const rawEntryUrl = entryUrl?.trim(); - if (!rawEntryUrl) { - return undefined; - } - const safeEntryUrl = sanitizeCanvasEntryUrl(rawEntryUrl, allowExternalEmbedUrls); - if (!safeEntryUrl) { - return undefined; - } - if (!canvasPluginSurfaceUrl?.trim()) { - return safeEntryUrl; - } - try { - const scopedHostUrl = new URL(canvasPluginSurfaceUrl); - const scopedPrefix = scopedHostUrl.pathname.replace(/\/+$/, ""); - if (!scopedPrefix.startsWith(CANVAS_CAPABILITY_PATH_PREFIX)) { - return safeEntryUrl; - } - const entry = new URL(safeEntryUrl, scopedHostUrl.origin); - if (!isCanvasHttpPath(entry.pathname)) { - return safeEntryUrl; - } - entry.protocol = scopedHostUrl.protocol; - entry.username = scopedHostUrl.username; - entry.password = scopedHostUrl.password; - entry.host = scopedHostUrl.host; - entry.pathname = `${scopedPrefix}${entry.pathname}`; - return entry.toString(); - } catch { - return safeEntryUrl; - } -} diff --git a/ui/src/ui/chat-event-reload.test.ts b/ui/src/ui/chat-event-reload.test.ts deleted file mode 100644 index eebf8300fa28..000000000000 --- a/ui/src/ui/chat-event-reload.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Control UI tests cover chat event reload behavior. -import { describe, expect, it } from "vitest"; -import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; - -describe("shouldReloadHistoryForFinalEvent", () => { - it("returns false for non-final events", () => { - expect( - shouldReloadHistoryForFinalEvent({ - runId: "run-1", - sessionKey: "main", - state: "delta", - message: { role: "assistant", content: [{ type: "text", text: "x" }] }, - }), - ).toBe(false); - }); - - it("returns true when final event has no message payload", () => { - expect( - shouldReloadHistoryForFinalEvent({ - runId: "run-1", - sessionKey: "main", - state: "final", - }), - ).toBe(true); - }); - - it("returns false when final event includes renderable assistant payload", () => { - expect( - shouldReloadHistoryForFinalEvent({ - runId: "run-1", - sessionKey: "main", - state: "final", - message: { role: "assistant", content: [{ type: "text", text: "done" }] }, - }), - ).toBe(false); - }); - - it("returns false when final event includes a legacy assistant text payload without role", () => { - expect( - shouldReloadHistoryForFinalEvent({ - runId: "run-1", - sessionKey: "main", - state: "final", - message: { text: "done" }, - }), - ).toBe(false); - }); - - it("returns true when final event includes legacy silent assistant payload", () => { - expect( - shouldReloadHistoryForFinalEvent({ - runId: "run-1", - sessionKey: "main", - state: "final", - message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] }, - }), - ).toBe(true); - }); - - it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])( - "returns false when assistant payload is plain text %s", - (text) => { - expect( - shouldReloadHistoryForFinalEvent({ - runId: "run-1", - sessionKey: "main", - state: "final", - message: { role: "assistant", content: [{ type: "text", text }] }, - }), - ).toBe(false); - }, - ); - - it("returns true when final event message role is non-assistant", () => { - expect( - shouldReloadHistoryForFinalEvent({ - runId: "run-1", - sessionKey: "main", - state: "final", - message: { role: "user", content: [{ type: "text", text: "echo" }] }, - }), - ).toBe(true); - }); -}); diff --git a/ui/src/ui/chat-event-reload.ts b/ui/src/ui/chat-event-reload.ts deleted file mode 100644 index bf2bf41433a3..000000000000 --- a/ui/src/ui/chat-event-reload.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Control UI module implements chat event reload behavior. -import { extractText } from "./chat/message-extract.ts"; -import type { ChatEventPayload } from "./controllers/chat.ts"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; - -const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/; - -function hasRenderableAssistantFinalMessage(message: unknown): boolean { - if (!message || typeof message !== "object") { - return false; - } - const entry = message as Record; - const role = normalizeLowercaseStringOrEmpty(entry.role); - if (role && role !== "assistant") { - return false; - } - if (!("content" in entry) && !("text" in entry)) { - return false; - } - const text = extractText(message); - return typeof text === "string" && text.trim() !== "" && !SILENT_REPLY_PATTERN.test(text); -} - -export function shouldReloadHistoryForFinalEvent(payload?: ChatEventPayload): boolean { - return Boolean( - payload && payload.state === "final" && !hasRenderableAssistantFinalMessage(payload.message), - ); -} diff --git a/ui/src/ui/chat-model-ref.types.ts b/ui/src/ui/chat-model-ref.types.ts deleted file mode 100644 index e838966c6218..000000000000 --- a/ui/src/ui/chat-model-ref.types.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Control UI type declarations define chat model ref contracts. -export type ChatModelOverride = - | { - kind: "qualified"; - value: string; - } - | { - kind: "raw"; - value: string; - }; diff --git a/ui/src/ui/chat-model-select-state.ts b/ui/src/ui/chat-model-select-state.ts deleted file mode 100644 index b49185714a68..000000000000 --- a/ui/src/ui/chat-model-select-state.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Control UI module implements chat model select state behavior. -import type { AppViewState } from "./app-view-state.ts"; -import { - buildCatalogDisplayLookup, - buildChatModelOptionFromLookup, - formatCatalogChatModelDisplayFromLookup, - normalizeChatModelOverrideValue, - resolvePreferredServerChatModelValue, -} from "./chat-model-ref.ts"; -import { pushUniqueTrimmedSelectOption } from "./select-options.ts"; -import type { ModelCatalogEntry } from "./types.ts"; - -type ChatModelSelectStateInput = Pick< - AppViewState, - "sessionKey" | "chatModelOverrides" | "chatModelCatalog" | "sessionsResult" ->; - -export type ChatModelSelectOption = { - value: string; - label: string; -}; - -export type ChatModelSelectState = { - currentOverride: string; - defaultModel: string; - defaultDisplay: string; - defaultLabel: string; - options: ChatModelSelectOption[]; -}; - -function resolveActiveSessionRow(state: ChatModelSelectStateInput) { - return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); -} - -export function resolveChatModelOverrideValue(state: ChatModelSelectStateInput): string { - const catalog = state.chatModelCatalog ?? []; - - // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. - const cached = state.chatModelOverrides[state.sessionKey]; - if (cached) { - return normalizeChatModelOverrideValue(cached, catalog); - } - if (cached === null) { - return ""; - } - - const activeRow = resolveActiveSessionRow(state); - return resolvePreferredServerChatModelValue(activeRow?.model, activeRow?.modelProvider, catalog); -} - -function resolveDefaultModelValue(state: ChatModelSelectStateInput): string { - return resolvePreferredServerChatModelValue( - state.sessionsResult?.defaults?.model, - state.sessionsResult?.defaults?.modelProvider, - state.chatModelCatalog ?? [], - ); -} - -function buildChatModelOptions( - catalog: ModelCatalogEntry[], - displayLookup: ReturnType, - currentOverride: string, - defaultModel: string, -): ChatModelSelectOption[] { - const seen = new Set(); - const options: ChatModelSelectOption[] = []; - - const addOption = (value: string, label?: string) => { - pushUniqueTrimmedSelectOption(options, seen, value, (trimmed) => label ?? trimmed); - }; - - for (const entry of catalog) { - const option = buildChatModelOptionFromLookup(entry, displayLookup); - addOption(option.value, option.label); - } - - if (currentOverride) { - addOption( - currentOverride, - formatCatalogChatModelDisplayFromLookup(currentOverride, displayLookup), - ); - } - if (defaultModel) { - addOption(defaultModel, formatCatalogChatModelDisplayFromLookup(defaultModel, displayLookup)); - } - return options; -} - -export function resolveChatModelSelectState( - state: ChatModelSelectStateInput, -): ChatModelSelectState { - const catalog = state.chatModelCatalog ?? []; - const displayLookup = buildCatalogDisplayLookup(catalog); - const currentOverride = resolveChatModelOverrideValue(state); - const defaultModel = resolveDefaultModelValue(state); - const defaultDisplay = formatCatalogChatModelDisplayFromLookup(defaultModel, displayLookup); - - return { - currentOverride, - defaultModel, - defaultDisplay, - defaultLabel: defaultModel ? `Default (${defaultDisplay})` : "Default model", - options: buildChatModelOptions(catalog, displayLookup, currentOverride, defaultModel), - }; -} diff --git a/ui/src/ui/chat/attachment-support.ts b/ui/src/ui/chat/attachment-support.ts deleted file mode 100644 index 1c2921fe878a..000000000000 --- a/ui/src/ui/chat/attachment-support.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Control UI chat module implements attachment support behavior. -export const CHAT_ATTACHMENT_ACCEPT = - "image/*,audio/*,application/pdf,text/*,.csv,.json,.md,.txt,.zip," + - ".doc,.docx,.xls,.xlsx,.ppt,.pptx"; - -export function isSupportedChatAttachmentFile(file: Pick): boolean { - if (file.type.startsWith("video/")) { - return false; - } - return !/\.(?:avi|m4v|mov|mp4|mpeg|mpg|webm)$/i.test(file.name); -} diff --git a/ui/src/ui/chat/chat-avatar.ts b/ui/src/ui/chat/chat-avatar.ts deleted file mode 100644 index 644613526da1..000000000000 --- a/ui/src/ui/chat/chat-avatar.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Control UI chat module implements chat avatar behavior. -import { html } from "lit"; -import type { AssistantIdentity } from "../assistant-identity.ts"; -import { - resolveLocalUserAvatarText, - resolveLocalUserAvatarUrl, - resolveLocalUserName, -} from "../user-identity.ts"; -import { - assistantAvatarFallbackUrl, - isRenderableControlUiAvatarUrl, - resolveAssistantTextAvatar, -} from "../views/agents-utils.ts"; -import { normalizeRoleForGrouping } from "./role-normalizer.ts"; - -export function renderChatAvatar( - role: string, - assistant?: Pick, - user?: { name?: string | null; avatar?: string | null }, - basePath?: string, - authToken?: string | null, -) { - const normalized = normalizeRoleForGrouping(role); - const assistantName = assistant?.name?.trim() || "Assistant"; - const assistantAvatar = assistant?.avatar?.trim() || ""; - const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar); - const assistantFallbackAvatar = assistantAvatarFallbackUrl(basePath ?? ""); - const userName = resolveLocalUserName(user); - const userAvatarUrl = resolveLocalUserAvatarUrl(user); - const userAvatarText = resolveLocalUserAvatarText(user); - const initial = - normalized === "user" - ? html` - - - - - ` - : normalized === "assistant" - ? html` - - - - ` - : normalized === "tool" - ? html` - - - - ` - : html` - - - - ? - - - `; - const className = - normalized === "user" - ? "user" - : normalized === "assistant" - ? "assistant" - : normalized === "tool" - ? "tool" - : "other"; - - if (normalized === "user" && userAvatarUrl) { - return html`${userName}`; - } - - if (normalized === "user" && userAvatarText) { - return html`
- ${userAvatarText} -
`; - } - - if (assistantAvatar && normalized === "assistant") { - if (isAvatarUrl(assistantAvatar)) { - if (authToken?.trim() && assistantAvatar.startsWith("/")) { - return html``; - } - return html`${assistantName}`; - } - if (assistantAvatarText) { - return html`
- ${assistantAvatarText} -
`; - } - return html``; - } - - if (normalized === "assistant") { - return html``; - } - - return html`
${initial}
`; -} - -function isAvatarUrl(value: string): boolean { - const trimmed = value.trim(); - return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed); -} diff --git a/ui/src/ui/chat/chat-queue.ts b/ui/src/ui/chat/chat-queue.ts deleted file mode 100644 index 2776924072cb..000000000000 --- a/ui/src/ui/chat/chat-queue.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Control UI chat module implements chat queue behavior. -import { html, nothing } from "lit"; -import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; -import type { ChatQueueItem } from "../ui-types.ts"; - -export type ChatQueueProps = { - queue: ChatQueueItem[]; - canAbort?: boolean; - onQueueRetry?: (id: string) => void; - onQueueSteer?: (id: string) => void; - onQueueRemove: (id: string) => void; -}; - -function sendStateLabel(item: ChatQueueItem): string | null { - switch (item.sendState) { - case "waiting-model": - return "Waiting for model"; - case "sending": - return "Sending"; - case "waiting-reconnect": - return "Waiting for reconnect"; - case "failed": - return "Failed"; - default: - return null; - } -} - -export function renderChatQueue(props: ChatQueueProps) { - if (!props.queue.length) { - return nothing; - } - return html` -
-
Queued (${props.queue.length})
-
- ${props.queue.map((item) => { - const stateLabel = sendStateLabel(item); - return html` -
-
- ${item.kind === "steered" - ? html`Steered` - : nothing} - ${stateLabel ? html`${stateLabel}` : nothing} -
- ${item.text || - (item.attachments?.length ? `Image (${item.attachments.length})` : "")} -
- ${item.sendError - ? html`
${item.sendError}
` - : nothing} -
-
- ${item.sendState === "failed" && props.onQueueRetry - ? html` - - ` - : nothing} - ${props.canAbort && - props.onQueueSteer && - item.kind !== "steered" && - !item.sendState && - !item.localCommandName - ? html` - - ` - : nothing} - -
-
- `; - })} -
-
- `; -} diff --git a/ui/src/ui/chat/chat-sidebar-raw.ts b/ui/src/ui/chat/chat-sidebar-raw.ts deleted file mode 100644 index 13a7c6ce0ec0..000000000000 --- a/ui/src/ui/chat/chat-sidebar-raw.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Control UI chat module implements chat sidebar raw behavior. -import type { SidebarContent } from "../sidebar-content.ts"; - -function toPlainTextCodeFence(value: string, language = ""): string { - const fenceHeader = language ? `\`\`\`${language}` : "```"; - return `${fenceHeader}\n${value}\n\`\`\``; -} - -export function buildRawSidebarContent( - content: SidebarContent | null | undefined, -): SidebarContent | null { - if (!content) { - return null; - } - if (content.kind === "markdown") { - const rawText = content.rawText ?? content.content; - return { - kind: "markdown", - content: toPlainTextCodeFence(rawText), - rawText, - ...(content.unavailableReason ? { unavailableReason: content.unavailableReason } : {}), - }; - } - if (content.rawText?.trim()) { - return { - kind: "markdown", - content: toPlainTextCodeFence(content.rawText, "json"), - rawText: content.rawText, - ...(content.unavailableReason ? { unavailableReason: content.unavailableReason } : {}), - }; - } - return null; -} diff --git a/ui/src/ui/chat/code-block-copy-payload.ts b/ui/src/ui/chat/code-block-copy-payload.ts deleted file mode 100644 index 16a61d080f3c..000000000000 --- a/ui/src/ui/chat/code-block-copy-payload.ts +++ /dev/null @@ -1,21 +0,0 @@ -const blockArtCopyPayloadPrefix = "openclaw:block-art-code:"; -export const blockArtCodeBlockCopyPayloadEncoding = "block-art-json"; - -export function encodeBlockArtCodeBlockCopyPayload(value: string): string { - return `${blockArtCopyPayloadPrefix}${JSON.stringify(value)}`; -} - -export function decodeCodeBlockCopyPayload(value: string, encoding?: string): string { - if ( - encoding !== blockArtCodeBlockCopyPayloadEncoding || - !value.startsWith(blockArtCopyPayloadPrefix) - ) { - return value; - } - try { - const decoded = JSON.parse(value.slice(blockArtCopyPayloadPrefix.length)); - return typeof decoded === "string" ? decoded : value; - } catch { - return value; - } -} diff --git a/ui/src/ui/chat/constants.ts b/ui/src/ui/chat/constants.ts deleted file mode 100644 index 93b0d4f725d5..000000000000 --- a/ui/src/ui/chat/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Chat-related constants for the UI layer. - */ - -/** Maximum lines to show in collapsed preview */ -export const PREVIEW_MAX_LINES = 2; - -/** Maximum characters to show in collapsed preview */ -export const PREVIEW_MAX_CHARS = 100; diff --git a/ui/src/ui/chat/context-notice.ts b/ui/src/ui/chat/context-notice.ts deleted file mode 100644 index afb6e1f4ede5..000000000000 --- a/ui/src/ui/chat/context-notice.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Control UI chat module implements context notice behavior. -import { html, nothing } from "lit"; -import { icons } from "../icons.ts"; -import type { GatewaySessionRow } from "../types.ts"; -import { formatCompactTokenCount } from "./token-format.ts"; - -const CONTEXT_NOTICE_RATIO = 0.85; -const CONTEXT_COMPACT_RATIO = 0.9; - -export type ContextNoticeOptions = { - compactBusy?: boolean; - compactDisabled?: boolean; - onCompact?: () => void | Promise; -}; - -/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */ -function parseHexRgb(hex: string): [number, number, number] | null { - const h = hex.trim().replace(/^#/, ""); - if (!/^[0-9a-fA-F]{6}$/.test(h)) { - return null; - } - return [ - Number.parseInt(h.slice(0, 2), 16), - Number.parseInt(h.slice(2, 4), 16), - Number.parseInt(h.slice(4, 6), 16), - ]; -} - -let cachedThemeNoticeColors: { - warnHex: string; - dangerHex: string; - warnRgb: [number, number, number]; - dangerRgb: [number, number, number]; -} | null = null; - -function getThemeNoticeColors() { - if (cachedThemeNoticeColors) { - return cachedThemeNoticeColors; - } - const rootStyle = getComputedStyle(document.documentElement); - const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b"; - const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444"; - cachedThemeNoticeColors = { - warnHex, - dangerHex, - warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11], - dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68], - }; - return cachedThemeNoticeColors; -} - -export function resetContextNoticeThemeCacheForTest(): void { - cachedThemeNoticeColors = null; -} - -export function getContextNoticeViewModel( - session: GatewaySessionRow | undefined, - defaultContextTokens: number | null, -): { - pct: number; - detail: string; - color: string; - bg: string; - warning: boolean; - compactRecommended: boolean; -} | null { - if (session?.totalTokensFresh === false) { - return null; - } - const used = session?.totalTokens; - const limit = session?.contextTokens ?? defaultContextTokens ?? 0; - if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || !limit) { - return null; - } - const ratio = used / limit; - const pct = Math.min(Math.round(ratio * 100), 100); - const warning = ratio >= CONTEXT_NOTICE_RATIO; - if (!warning) { - return { - pct, - detail: `${formatCompactTokenCount(used)} / ${formatCompactTokenCount(limit)}`, - color: "var(--muted)", - bg: "color-mix(in srgb, var(--muted) 8%, transparent)", - warning, - compactRecommended: false, - }; - } - // Read theme semantic tokens so color tracks the active theme (Dash, dark, light ...). - const { warnRgb, dangerRgb } = getThemeNoticeColors(); - const [wr, wg, wb] = warnRgb; - const [dr, dg, db] = dangerRgb; - const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); - const r = Math.round(wr + (dr - wr) * t); - const g = Math.round(wg + (dg - wg) * t); - const b = Math.round(wb + (db - wb) * t); - const color = `rgb(${r}, ${g}, ${b})`; - const bgOpacity = 0.08 + 0.08 * t; - const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; - return { - pct, - detail: `${formatCompactTokenCount(used)} / ${formatCompactTokenCount(limit)}`, - color, - bg, - warning, - compactRecommended: ratio >= CONTEXT_COMPACT_RATIO, - }; -} - -const RING_RADIUS = 6.5; -const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; - -export function renderContextNotice( - session: GatewaySessionRow | undefined, - defaultContextTokens: number | null, - options: ContextNoticeOptions = {}, -) { - const model = getContextNoticeViewModel(session, defaultContextTokens); - if (!model) { - return nothing; - } - const canRenderCompact = model.compactRecommended && options.onCompact; - const compactDisabled = options.compactDisabled === true || options.compactBusy === true; - const summary = `Session context usage: ${model.detail} (${model.pct}%)`; - const dashOffset = RING_CIRCUMFERENCE * (1 - model.pct / 100); - return html` -
- - ${model.pct}% - ${canRenderCompact - ? html` - - ` - : nothing} -
- `; -} diff --git a/ui/src/ui/chat/copy-as-markdown.ts b/ui/src/ui/chat/copy-as-markdown.ts deleted file mode 100644 index c082d93b1c80..000000000000 --- a/ui/src/ui/chat/copy-as-markdown.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Control UI chat module implements copy as markdown behavior. -import { html, type TemplateResult } from "lit"; -import { icons } from "../icons.ts"; -import { copyToClipboard } from "./clipboard.ts"; - -const COPIED_FOR_MS = 1500; -const ERROR_FOR_MS = 2000; -const COPY_LABEL = "Copy as markdown"; -const COPIED_LABEL = "Copied"; -const ERROR_LABEL = "Copy failed"; - -type CopyButtonOptions = { - text: () => string; - label?: string; -}; - -function setButtonLabel(button: HTMLButtonElement, label: string) { - button.title = label; - button.setAttribute("aria-label", label); -} - -function createCopyButton(options: CopyButtonOptions): TemplateResult { - const idleLabel = options.label ?? COPY_LABEL; - return html` - - `; -} - -export function renderCopyButton(text: string, label = COPY_LABEL): TemplateResult { - return createCopyButton({ text: () => text, label }); -} - -export function renderCopyAsMarkdownButton(markdown: string): TemplateResult { - return renderCopyButton(markdown, COPY_LABEL); -} diff --git a/ui/src/ui/chat/history-limits.ts b/ui/src/ui/chat/history-limits.ts deleted file mode 100644 index 28bee9b7fcf5..000000000000 --- a/ui/src/ui/chat/history-limits.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Control UI chat module implements history limits behavior. -export const CHAT_HISTORY_RENDER_LIMIT = 100; -export const CHAT_HISTORY_RENDER_CHAR_BUDGET = 240_000; diff --git a/ui/src/ui/chat/pinned-summary.ts b/ui/src/ui/chat/pinned-summary.ts deleted file mode 100644 index 55ab04847625..000000000000 --- a/ui/src/ui/chat/pinned-summary.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Control UI chat module implements pinned summary behavior. -import { extractTextCached } from "./message-extract.ts"; - -export function getPinnedMessageSummary(message: unknown): string { - return extractTextCached(message) ?? ""; -} diff --git a/ui/src/ui/chat/realtime-talk-pcm-output.ts b/ui/src/ui/chat/realtime-talk-pcm-output.ts deleted file mode 100644 index fe6eba27b9c1..000000000000 --- a/ui/src/ui/chat/realtime-talk-pcm-output.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Control UI chat module implements realtime talk pcm output behavior. -import { base64ToBytes, pcm16ToFloat } from "./realtime-talk-audio.ts"; - -export class RealtimeTalkPcmOutputQueue { - private playhead = 0; - private readonly sources = new Set(); - - get queuedUntil(): number { - return this.playhead; - } - - get isPlaying(): boolean { - return this.sources.size > 0; - } - - play(base64: string, outputContext: AudioContext | null, outputSampleRateHz: number): void { - if (!outputContext) { - return; - } - const samples = pcm16ToFloat(base64ToBytes(base64)); - if (samples.length === 0) { - return; - } - const buffer = outputContext.createBuffer(1, samples.length, outputSampleRateHz); - buffer.getChannelData(0).set(samples); - const source = outputContext.createBufferSource(); - this.sources.add(source); - source.addEventListener("ended", () => this.sources.delete(source)); - source.buffer = buffer; - source.connect(outputContext.destination); - const startAt = Math.max(outputContext.currentTime, this.playhead); - source.start(startAt); - this.playhead = startAt + buffer.duration; - } - - stop(outputContext: AudioContext | null): void { - for (const source of this.sources) { - try { - source.stop(); - } catch {} - } - this.sources.clear(); - this.playhead = outputContext?.currentTime ?? 0; - } -} diff --git a/ui/src/ui/chat/role-normalizer.test.ts b/ui/src/ui/chat/role-normalizer.test.ts deleted file mode 100644 index ceefad6db829..000000000000 --- a/ui/src/ui/chat/role-normalizer.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Control UI tests cover role normalizer behavior. -import { describe, expect, it } from "vitest"; -import { isToolResultMessage, normalizeRoleForGrouping } from "./role-normalizer.ts"; - -describe("normalizeRoleForGrouping", () => { - it("returns tool for tool result role variants", () => { - expect(normalizeRoleForGrouping("toolresult")).toBe("tool"); - expect(normalizeRoleForGrouping("toolResult")).toBe("tool"); - expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool"); - expect(normalizeRoleForGrouping("tool_result")).toBe("tool"); - expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool"); - }); - - it("returns tool for tool and function roles", () => { - expect(normalizeRoleForGrouping("tool")).toBe("tool"); - expect(normalizeRoleForGrouping("Tool")).toBe("tool"); - expect(normalizeRoleForGrouping("function")).toBe("tool"); - expect(normalizeRoleForGrouping("Function")).toBe("tool"); - }); - - it("normalizes core roles", () => { - expect(normalizeRoleForGrouping("user")).toBe("user"); - expect(normalizeRoleForGrouping("User")).toBe("user"); - expect(normalizeRoleForGrouping("assistant")).toBe("assistant"); - expect(normalizeRoleForGrouping("Assistant")).toBe("assistant"); - expect(normalizeRoleForGrouping("system")).toBe("system"); - expect(normalizeRoleForGrouping("System")).toBe("system"); - }); - - it("detects only tool result role variants", () => { - expect(isToolResultMessage({ role: "toolresult" })).toBe(true); - expect(isToolResultMessage({ role: "toolResult" })).toBe(true); - expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true); - expect(isToolResultMessage({ role: "tool_result" })).toBe(true); - expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true); - expect(isToolResultMessage({ role: "user" })).toBe(false); - expect(isToolResultMessage({ role: "assistant" })).toBe(false); - expect(isToolResultMessage({ role: "tool" })).toBe(false); - expect(isToolResultMessage({})).toBe(false); - expect(isToolResultMessage({ content: "test" })).toBe(false); - expect(isToolResultMessage({ role: 123 })).toBe(false); - expect(isToolResultMessage({ role: null })).toBe(false); - }); -}); diff --git a/ui/src/ui/chat/role-normalizer.ts b/ui/src/ui/chat/role-normalizer.ts deleted file mode 100644 index f32bcdf8bbf9..000000000000 --- a/ui/src/ui/chat/role-normalizer.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Normalize role for grouping purposes. - */ -export function normalizeRoleForGrouping(role: string): string { - const lower = role.toLowerCase(); - // Core roles drive grouping and layout; casing variants should not split groups. - if (lower === "user") { - return "user"; - } - if (lower === "assistant") { - return "assistant"; - } - if (lower === "system") { - return "system"; - } - // Keep tool-related roles distinct so the UI can style/toggle them. - if ( - lower === "toolresult" || - lower === "tool_result" || - lower === "tool" || - lower === "function" - ) { - return "tool"; - } - return role; -} - -/** - * Check if a message is a tool result message based on its role. - */ -export function isToolResultMessage(message: unknown): boolean { - const m = message as Record; - const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; - return role === "toolresult" || role === "tool_result"; -} diff --git a/ui/src/ui/chat/run-controls.ts b/ui/src/ui/chat/run-controls.ts deleted file mode 100644 index 8fee62a50493..000000000000 --- a/ui/src/ui/chat/run-controls.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Control UI chat module implements run controls behavior. -import { html, nothing } from "lit"; -import { t } from "../../i18n/index.ts"; -import { icons } from "../icons.ts"; - -export type ChatRunControlsProps = { - canAbort: boolean; - connected: boolean; - draft: string; - hasMessages: boolean; - isBusy: boolean; - sending: boolean; - onAbort?: () => void; - onExport: () => void; - onNewSession: () => void; - onSend: () => void; - onStoreDraft: (draft: string) => void; - showSecondary?: boolean; -}; - -export function renderChatRunControls(props: ChatRunControlsProps) { - const showSecondary = props.showSecondary ?? true; - return html` -
- ${showSecondary && !props.canAbort - ? html` - - ` - : nothing} - ${showSecondary - ? html` - - ` - : nothing} - ${props.canAbort - ? html` - - - ` - : html` - - `} -
- `; -} diff --git a/ui/src/ui/chat/search-match.ts b/ui/src/ui/chat/search-match.ts deleted file mode 100644 index f9b10673b932..000000000000 --- a/ui/src/ui/chat/search-match.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Control UI chat module implements search match behavior. -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import { extractTextCached } from "./message-extract.ts"; - -export function messageMatchesSearchQuery(message: unknown, query: string): boolean { - const normalizedQuery = normalizeLowercaseStringOrEmpty(query); - if (!normalizedQuery) { - return true; - } - const text = normalizeLowercaseStringOrEmpty(extractTextCached(message)); - return text.includes(normalizedQuery); -} diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts deleted file mode 100644 index c53766492cbe..000000000000 --- a/ui/src/ui/chat/session-controls.ts +++ /dev/null @@ -1,2061 +0,0 @@ -// Control UI chat module implements session controls behavior. -import { html } from "lit"; -import { repeat } from "lit/directives/repeat.js"; -import { t } from "../../i18n/index.ts"; -import { - createChatSessionsLoadOverrides, - scopedAgentListParamsForSession, - scopedAgentParamsForSession, -} from "../app-chat.ts"; -import type { AppViewState } from "../app-view-state.ts"; -import { createChatModelOverride } from "../chat-model-ref.ts"; -import { - resolveChatModelOverrideValue, - resolveChatModelSelectState, -} from "../chat-model-select-state.ts"; -import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts"; -import { loadSessions, patchSession } from "../controllers/sessions.ts"; -import { formatDateTimeMs, formatRelativeTimestamp } from "../format.ts"; -import { icons } from "../icons.ts"; -import { isMonitoredAuthProvider } from "../model-auth-helpers.ts"; -import { pathForTab } from "../navigation.ts"; -import { collectQuotaWindowsFromAuthStatus, formatQuotaReset } from "../provider-quota-summary.ts"; -import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; -import { isCronSessionKey, resolveSessionDisplayName } from "../session-display.ts"; -import { - areUiSessionKeysEquivalent, - buildAgentMainSessionKey, - canArchiveSessionRow, - isSessionKeyTiedToAgent, - isSubagentSessionKey, - normalizeAgentId, - parseAgentSessionKey, - resolveUiConfiguredMainKey, -} from "../session-key.ts"; -import { sessionModelMatchesDefaults } from "../session-model-defaults.ts"; -import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; -import { - formatInheritedThinkingLabel, - formatThinkingOverrideLabel, - normalizeThinkingOptionValue, -} from "../thinking-labels.ts"; -import { - type ThinkingCatalogEntry, - listThinkingLevelLabels, - normalizeThinkLevel, - resolveThinkingDefaultForModel, -} from "../thinking.ts"; -import type { FastMode, GatewayThinkingLevelOption, SessionsListResult } from "../types.ts"; - -type ChatSessionSwitchHandler = (state: AppViewState, nextSessionKey: string) => void; -type ChatSessionSelectSurface = "desktop" | "mobile" | "sidebar"; -type ChatSessionPickerSearchController = { - activeRequestId: number | null; - activeRequestSignature: string | null; - nextRequestId: number; - timer: ReturnType | null; -}; - -type ChatInlineSelectOption = { - value: string; - label: string; -}; - -const FAST_MODE_PROVIDER_IDS = new Set([ - "anthropic", - "minimax", - "minimax-portal", - "openai", - "openrouter", - "xai", -]); - -const CHAT_SESSION_PICKER_SEARCH_DEBOUNCE_MS = 300; -const chatSessionPickerSearchControllers = new WeakMap< - AppViewState, - ChatSessionPickerSearchController ->(); - -function setChatError(state: AppViewState, error: string | null) { - state.lastError = error; - state.chatError = error; -} - -export function renderChatSessionSelect( - state: AppViewState, - onSwitchSession: ChatSessionSwitchHandler = () => undefined, - options: { - surface?: ChatSessionSelectSurface; - } = {}, -) { - rememberChatAgentSessionRows(state, state.sessionsResult); - const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); - const agentOptions = resolveChatAgentFilterOptions(state); - const hasAgentSelect = agentOptions.length > 1; - const agentSelect = renderChatAgentSelect(state, onSwitchSession, agentOptions); - const modelSelect = renderChatModelSelect(state); - const quotaPill = renderChatQuotaPill(state); - const surface = options.surface ?? "desktop"; - const selectedSessionLabel = resolveSelectedChatSessionLabel(state, sessionGroups); - const pickerOpen = state.chatSessionPickerOpen && state.chatSessionPickerSurface === surface; - const flashSession = state.sessionSwitchFlashKey === state.sessionKey; - const rowClass = [ - "chat-controls__session-row", - hasAgentSelect ? "" : "chat-controls__session-row--single-agent", - quotaPill ? "chat-controls__session-row--has-quota" : "", - flashSession ? "chat-controls__session-row--flash" : "", - ] - .filter(Boolean) - .join(" "); - return html` -
- ${agentSelect} - ${renderChatSessionPicker({ - state, - onSwitchSession, - surface, - selectedSessionLabel, - pickerOpen, - disabled: !state.connected || !state.client, - })} - ${modelSelect} ${quotaPill} -
-
- ${state.sessionSwitchNotice?.text ?? ""} -
- `; -} - -// Sidebar selections must land the user in Chat: the expanded sidebar has no -// dedicated Chat nav item, so a switch that stays on the current tab would -// strand the user on Overview/Sessions after picking a session. -function enterChatOnSwitch(onSwitchSession: ChatSessionSwitchHandler): ChatSessionSwitchHandler { - return (state, nextSessionKey) => { - onSwitchSession(state, nextSessionKey); - state.setTab("chat"); - }; -} - -// Agent filter for the sidebar sessions section; hidden for the common -// single-agent install, mirrors the removed sidebar select's agent scoping. -export function renderSidebarAgentFilter( - state: AppViewState, - onSwitchSession: ChatSessionSwitchHandler, -) { - // Keep the per-agent row cache fresh so resolvePreferredSessionForAgent can - // pick each agent's most recent saved session after switching away and back; - // the removed sidebar session select used to do this on every render. - rememberChatAgentSessionRows(state, state.sessionsResult); - const agentOptions = resolveChatAgentFilterOptions(state); - if (agentOptions.length <= 1) { - return ""; - } - return html` - - `; -} - -// Icon-only trigger for the full session picker (search + pagination) in the -// sidebar sessions section; the recents list covers the common quick switches. -export function renderSidebarSessionSearch( - state: AppViewState, - onSwitchSession: ChatSessionSwitchHandler, -) { - const surface: ChatSessionSelectSurface = "sidebar"; - const pickerOpen = state.chatSessionPickerOpen && state.chatSessionPickerSurface === surface; - const pickerId = `chat-session-picker-${surface}`; - const label = t("chat.selectors.sessionSearch"); - // display:contents wrapper: groups the trigger and popover for the global - // outside-pointerdown close check without adding a layout box, so the - // popover keeps anchoring to the positioned sessions header. - return html` - - `; -} - -function resolveNextChatSessionOffset( - sessions: SessionsListResult | null | undefined, -): number | null { - if (!sessions?.hasMore) { - return null; - } - if (typeof sessions.nextOffset === "number" && Number.isFinite(sessions.nextOffset)) { - return Math.max(0, Math.floor(sessions.nextOffset)); - } - return sessions.sessions.length; -} - -async function refreshSessionOptions(state: AppViewState) { - await loadSessions(state as unknown as Parameters[0], { - ...createChatSessionsLoadOverrides(state), - ...scopedAgentListParamsForSession(state, state.sessionKey), - }); -} - -function requestHostUpdate(state: AppViewState) { - (state as AppViewState & { requestUpdate?: () => void }).requestUpdate?.(); -} - -function getChatSessionPickerSearchController( - state: AppViewState, -): ChatSessionPickerSearchController { - let controller = chatSessionPickerSearchControllers.get(state); - if (!controller) { - controller = { - activeRequestId: null, - activeRequestSignature: null, - nextRequestId: 0, - timer: null, - }; - chatSessionPickerSearchControllers.set(state, controller); - } - return controller; -} - -function clearChatSessionPickerSearchTimer(state: AppViewState) { - const controller = getChatSessionPickerSearchController(state); - if (controller.timer) { - globalThis.clearTimeout(controller.timer); - controller.timer = null; - } -} - -function invalidateChatSessionPickerSearchRequests(state: AppViewState) { - const controller = getChatSessionPickerSearchController(state); - controller.nextRequestId += 1; - controller.activeRequestId = null; - controller.activeRequestSignature = null; -} - -function beginChatSessionPickerSearchRequest( - state: AppViewState, - signature: string, -): number | null { - const controller = getChatSessionPickerSearchController(state); - if (controller.activeRequestSignature === signature) { - return null; - } - controller.nextRequestId += 1; - controller.activeRequestId = controller.nextRequestId; - controller.activeRequestSignature = signature; - return controller.activeRequestId; -} - -function isCurrentChatSessionPickerSearchRequest(state: AppViewState, requestId: number): boolean { - return getChatSessionPickerSearchController(state).activeRequestId === requestId; -} - -function finishChatSessionPickerSearchRequest(state: AppViewState, requestId: number) { - if (!isCurrentChatSessionPickerSearchRequest(state, requestId)) { - return; - } - const controller = getChatSessionPickerSearchController(state); - controller.activeRequestId = null; - controller.activeRequestSignature = null; -} - -function createChatSessionPickerRequestSignature(options: { - append?: boolean; - offset?: number; - query: string; -}) { - return [ - options.query, - typeof options.offset === "number" && Number.isFinite(options.offset) - ? Math.max(0, Math.floor(options.offset)) - : 0, - options.append === true ? "append" : "replace", - ].join("\n"); -} - -function focusChatSessionPickerSearch(state: AppViewState) { - const updateComplete = (state as AppViewState & { updateComplete?: Promise }) - .updateComplete; - const focus = () => { - document.querySelector('[data-chat-session-picker-search="true"]')?.focus(); - }; - if (updateComplete) { - void updateComplete.then(focus); - return; - } - setTimeout(focus, 0); -} - -function openChatSessionPicker(state: AppViewState, surface: ChatSessionSelectSurface) { - state.chatSessionPickerOpen = true; - state.chatSessionPickerSurface = surface; - state.chatSessionPickerError = null; - if (!state.chatSessionPickerResult && !state.chatSessionPickerAppliedQuery) { - void loadChatSessionPickerPage(state); - } - requestHostUpdate(state); - focusChatSessionPickerSearch(state); -} - -function closeChatSessionPicker(state: AppViewState) { - clearChatSessionPickerSearchTimer(state); - state.chatSessionPickerOpen = false; - state.chatSessionPickerSurface = null; - requestHostUpdate(state); -} - -export function resetChatSessionPickerState(state: AppViewState) { - clearChatSessionPickerSearchTimer(state); - invalidateChatSessionPickerSearchRequests(state); - state.chatSessionPickerOpen = false; - state.chatSessionPickerSurface = null; - state.chatSessionPickerQuery = ""; - state.chatSessionPickerAppliedQuery = ""; - state.chatSessionPickerLoading = false; - state.chatSessionPickerError = null; - state.chatSessionPickerResult = null; -} - -function toggleChatSessionPicker(state: AppViewState, surface: ChatSessionSelectSurface) { - if (state.chatSessionPickerOpen && state.chatSessionPickerSurface === surface) { - closeChatSessionPicker(state); - return; - } - openChatSessionPicker(state, surface); -} - -function createChatSessionPickerRequestParams( - state: AppViewState, - options: { query?: string; offset?: number } = {}, -): Record { - const overrides = createChatSessionsLoadOverrides(state, { - search: options.query, - offset: options.offset, - }); - const params: Record = { - includeGlobal: overrides.includeGlobal, - includeUnknown: overrides.includeUnknown, - configuredAgentsOnly: overrides.configuredAgentsOnly, - limit: overrides.limit, - }; - const activeAgentSession = parseAgentSessionKey(state.sessionKey); - const activeSessionRow = state.sessionsResult?.sessions.find( - (row) => row.key === state.sessionKey, - ); - const isGlobalScopeSession = - activeSessionRow?.kind === "global" || - activeSessionRow?.kind === "unknown" || - state.sessionKey === "global" || - state.sessionKey === "unknown"; - if (activeAgentSession || !isGlobalScopeSession) { - params.agentId = normalizeAgentId( - activeAgentSession?.agentId ?? state.agentsList?.defaultId ?? "main", - ); - } - const offset = - typeof overrides.offset === "number" && Number.isFinite(overrides.offset) - ? Math.max(0, Math.floor(overrides.offset)) - : 0; - if (offset > 0) { - params.offset = offset; - } - const search = normalizeOptionalString(overrides.search ?? undefined); - if (search) { - params.search = search; - } - return params; -} - -function projectChatSessionPickerResult(result: SessionsListResult): SessionsListResult { - const sessions = result.sessions.filter((row) => row.key && row.archived !== true); - return { - ...result, - count: sessions.length, - sessions, - }; -} - -function appendChatSessionPickerResult( - previous: SessionsListResult, - page: SessionsListResult, -): SessionsListResult { - const rowsByKey = new Map(previous.sessions.map((row) => [row.key, row] as const)); - const sessions = [...previous.sessions]; - for (const row of page.sessions) { - if (rowsByKey.has(row.key)) { - continue; - } - rowsByKey.set(row.key, row); - sessions.push(row); - } - return { - ...page, - count: sessions.length, - sessions, - totalCount: page.totalCount ?? previous.totalCount, - }; -} - -async function loadChatSessionPickerPage( - state: AppViewState, - options: { query?: string; offset?: number; append?: boolean } = {}, -): Promise { - if (!state.client || !state.connected) { - return null; - } - const query = normalizeOptionalString(options.query ?? state.chatSessionPickerAppliedQuery) ?? ""; - const requestId = beginChatSessionPickerSearchRequest( - state, - createChatSessionPickerRequestSignature({ - append: options.append, - offset: options.offset, - query, - }), - ); - if (requestId === null) { - return null; - } - state.chatSessionPickerLoading = true; - state.chatSessionPickerError = null; - requestHostUpdate(state); - try { - const page = projectChatSessionPickerResult( - await state.client.request( - "sessions.list", - createChatSessionPickerRequestParams(state, { query, offset: options.offset }), - ), - ); - if (!isCurrentChatSessionPickerSearchRequest(state, requestId)) { - return null; - } - const previous = state.chatSessionPickerResult ?? state.sessionsResult; - state.chatSessionPickerResult = - options.append === true && previous ? appendChatSessionPickerResult(previous, page) : page; - state.chatSessionPickerAppliedQuery = query; - return state.chatSessionPickerResult; - } catch (err) { - if (!isCurrentChatSessionPickerSearchRequest(state, requestId)) { - return null; - } - state.chatSessionPickerError = String(err); - return null; - } finally { - if (isCurrentChatSessionPickerSearchRequest(state, requestId)) { - finishChatSessionPickerSearchRequest(state, requestId); - state.chatSessionPickerLoading = false; - requestHostUpdate(state); - } - } -} - -async function applyChatSessionPickerSearch(state: AppViewState) { - clearChatSessionPickerSearchTimer(state); - const query = normalizeOptionalString(state.chatSessionPickerQuery) ?? ""; - if (!query) { - clearChatSessionPickerSearch(state); - return; - } - if (query === state.chatSessionPickerAppliedQuery && state.chatSessionPickerResult) { - return; - } - await loadChatSessionPickerPage(state, { query }); -} - -function clearChatSessionPickerSearch(state: AppViewState, options: { focus?: boolean } = {}) { - clearChatSessionPickerSearchTimer(state); - invalidateChatSessionPickerSearchRequests(state); - state.chatSessionPickerQuery = ""; - state.chatSessionPickerAppliedQuery = ""; - state.chatSessionPickerError = null; - state.chatSessionPickerResult = null; - state.chatSessionPickerLoading = false; - requestHostUpdate(state); - if (state.chatSessionPickerOpen) { - void loadChatSessionPickerPage(state); - } - if (options.focus ?? true) { - focusChatSessionPickerSearch(state); - } -} - -function scheduleChatSessionPickerSearch(state: AppViewState) { - clearChatSessionPickerSearchTimer(state); - const controller = getChatSessionPickerSearchController(state); - controller.timer = globalThis.setTimeout(() => { - controller.timer = null; - void applyChatSessionPickerSearch(state); - }, CHAT_SESSION_PICKER_SEARCH_DEBOUNCE_MS); -} - -function updateChatSessionPickerSearchQuery(state: AppViewState, nextQuery: string) { - state.chatSessionPickerQuery = nextQuery; - const query = normalizeOptionalString(nextQuery) ?? ""; - if (!query) { - clearChatSessionPickerSearch(state, { focus: false }); - return; - } - if (query !== state.chatSessionPickerAppliedQuery || !state.chatSessionPickerResult) { - invalidateChatSessionPickerSearchRequests(state); - state.chatSessionPickerError = null; - state.chatSessionPickerLoading = false; - scheduleChatSessionPickerSearch(state); - } else { - clearChatSessionPickerSearchTimer(state); - } - requestHostUpdate(state); -} - -async function loadMoreChatSessionPickerResults(state: AppViewState) { - let result = state.chatSessionPickerResult; - let offset = resolveNextChatSessionOffset(result); - let visibleCount = resolveChatSessionPickerRows(state, result).length; - const seenOffsets = new Set(); - while (offset !== null && !seenOffsets.has(offset)) { - seenOffsets.add(offset); - const next = await loadChatSessionPickerPage(state, { - query: state.chatSessionPickerAppliedQuery, - offset, - append: true, - }); - if (!next) { - return; - } - result = next; - const nextVisibleCount = resolveChatSessionPickerRows(state, result).length; - if (nextVisibleCount > visibleCount) { - return; - } - visibleCount = nextVisibleCount; - offset = resolveNextChatSessionOffset(result); - } -} - -function resolveChatSessionRow( - state: AppViewState, - sessionKey: string, -): SessionsListResult["sessions"][number] | undefined { - return ( - state.sessionsResult?.sessions.find((row) => row.key === sessionKey) ?? - state.chatSessionPickerResult?.sessions.find((row) => row.key === sessionKey) - ); -} - -function resolveChatSessionPickerResult(state: AppViewState): SessionsListResult | null { - if ( - state.chatSessionPickerResult || - state.chatSessionPickerAppliedQuery || - state.chatSessionPickerOpen - ) { - return state.chatSessionPickerResult; - } - return state.sessionsResult; -} - -function resolveChatSessionPickerRows( - state: AppViewState, - result: SessionsListResult | null, -): { row: SessionsListResult["sessions"][number]; label: string }[] { - const rowsByKey = new Map((result?.sessions ?? []).map((row) => [row.key, row] as const)); - return resolveSessionOptionGroups(state, state.sessionKey, result) - .flatMap((group) => group.options) - .filter((option) => rowsByKey.has(option.key)) - .map((option) => ({ - row: rowsByKey.get(option.key)!, - label: option.label, - })); -} - -function resolveSelectedChatSessionLabel( - state: AppViewState, - sessionGroups: SessionOptionGroup[], -): string { - const row = resolveChatSessionRow(state, state.sessionKey); - const displayName = resolveSessionDisplayName(state.sessionKey, row); - if (displayName !== state.sessionKey) { - return displayName; - } - return ( - sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey) - ?.label ?? state.sessionKey - ); -} - -// Rows stay single-line minimal: relative time in the row, full detail -// (surface, model, absolute timestamp) only in the hover tooltip. -function formatChatSessionPickerTooltip( - row: SessionsListResult["sessions"][number], - label: string, -): string { - return [ - label, - normalizeOptionalString(row.surface), - [normalizeOptionalString(row.modelProvider), normalizeOptionalString(row.model)] - .filter(Boolean) - .join("/"), - formatDateTimeMs(row.updatedAt, undefined, ""), - ] - .filter(Boolean) - .join(" · "); -} - -async function patchChatSessionFromPicker(params: { - state: AppViewState; - row: SessionsListResult["sessions"][number]; - patch: { label?: string | null; archived?: boolean; pinned?: boolean }; - onSwitchSession: ChatSessionSwitchHandler; -}) { - const { state, row, patch, onSwitchSession } = params; - const patched = await patchSession(state, row.key, patch, { - ...createChatSessionsLoadOverrides(state), - showArchived: false, - }); - if (!patched) { - state.chatSessionPickerError = state.sessionsError ?? "Failed to update session"; - return; - } - if (patch.archived === true && areUiSessionKeysEquivalent(row.key, state.sessionKey)) { - const parsed = parseAgentSessionKey(row.key); - const fallbackKey = buildAgentMainSessionKey({ - agentId: parsed?.agentId ?? state.agentsList?.defaultId ?? "main", - mainKey: state.agentsList?.mainKey ?? undefined, - }); - closeChatSessionPicker(state); - onSwitchSession(state, fallbackKey); - return; - } - await loadChatSessionPickerPage(state, { - query: state.chatSessionPickerAppliedQuery, - }); -} - -function renderChatSessionPicker(params: { - state: AppViewState; - onSwitchSession: ChatSessionSwitchHandler; - surface: ChatSessionSelectSurface; - selectedSessionLabel: string; - pickerOpen: boolean; - disabled: boolean; -}) { - const { state, onSwitchSession, surface, selectedSessionLabel, pickerOpen, disabled } = params; - const pickerId = `chat-session-picker-${surface}`; - return html` -
- - ${pickerOpen ? renderChatSessionPickerPopover(state, onSwitchSession, pickerId) : ""} -
- `; -} - -function renderChatSessionPickerPopover( - state: AppViewState, - onSwitchSession: ChatSessionSwitchHandler, - pickerId: string, - options: { onSelectCurrent?: (state: AppViewState) => void } = {}, -) { - const result = resolveChatSessionPickerResult(state); - const pickerRows = resolveChatSessionPickerRows(state, result); - const controlsDisabled = !state.connected || !state.client; - const normalizedQuery = normalizeOptionalString(state.chatSessionPickerQuery) ?? ""; - const searchPending = normalizedQuery !== state.chatSessionPickerAppliedQuery; - const loadMoreDisabled = controlsDisabled || state.chatSessionPickerLoading || searchPending; - const hasQuery = - state.chatSessionPickerQuery.trim() !== "" || state.chatSessionPickerAppliedQuery.trim() !== ""; - const loadMoreOffset = resolveNextChatSessionOffset(result); - const shownCount = pickerRows.length; - const rawLoadedCount = result?.sessions.length ?? 0; - const totalCount = result?.totalCount; - const countLabel = - rawLoadedCount === shownCount && typeof totalCount === "number" && Number.isFinite(totalCount) - ? `${shownCount} / ${totalCount}` - : String(shownCount); - - return html` - - `; -} - -export function renderChatQuotaPill(state: AppViewState) { - const windows = collectQuotaWindowsFromAuthStatus( - state.modelAuthStatusResult, - isMonitoredAuthProvider, - ); - const primary = windows[0]; - if (!primary) { - return ""; - } - const secondary = windows.find( - (entry) => entry.displayName !== primary.displayName || entry.label !== primary.label, - ); - const reset = formatQuotaReset(primary.resetAt); - const detail = [primary.displayName, primary.label, reset ? `resets ${reset}` : null] - .filter(Boolean) - .join(" · "); - const secondaryDetail = secondary - ? `${secondary.displayName}${secondary.label ? ` ${secondary.label}` : ""} ${secondary.remaining}% left` - : null; - const title = [detail, secondaryDetail].filter(Boolean).join(" · "); - const severity = primary.remaining <= 10 ? "danger" : primary.remaining <= 25 ? "warn" : "ok"; - - return html` - { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - event.preventDefault(); - state.setTab("usage"); - }} - > - ${t("tabs.usage")} - ${primary.remaining}% - - `; -} - -function renderChatAgentSelect( - state: AppViewState, - onSwitchSession: ChatSessionSwitchHandler, - options = resolveChatAgentFilterOptions(state), -) { - if (options.length <= 1) { - return ""; - } - const activeAgentId = resolveChatAgentFilterId(state, state.sessionKey); - const selectedLabel = options.find((entry) => entry.id === activeAgentId)?.label ?? activeAgentId; - return html` - - `; -} - -async function refreshVisibleToolsEffectiveForCurrentSessionLazy(state: AppViewState) { - return refreshVisibleToolsEffectiveForCurrentSession(state); -} - -export function renderChatModelSelect(state: AppViewState) { - const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state); - const thinking = resolveChatThinkingSelectState(state); - const fastMode = resolveChatFastModeSelectState(state, currentOverride); - const busy = - state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; - const disabled = - !state.connected || - busy || - Boolean(state.chatModelSwitchPromises?.[state.sessionKey]) || - (state.chatModelsLoading && options.length === 0) || - !state.client; - const thinkingDisabled = - !state.connected || - busy || - !state.client || - (thinking.options.length === 0 && thinking.currentOverride === ""); - const selectedLabel = - currentOverride === "" - ? defaultLabel - : (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride); - const selectedThinkingLabel = - thinking.currentOverride === "" - ? thinking.defaultLabel - : (thinking.options.find((entry) => entry.value === thinking.currentOverride)?.label ?? - thinking.currentOverride); - const modelOptions = [{ value: "", label: defaultLabel }, ...options]; - return renderChatModelReasoningSelect({ - disabled, - modelOptions, - selectedModelLabel: selectedLabel, - selectedModelValue: currentOverride, - selectedThinkingLabel, - selectedThinkingValue: thinking.currentOverride, - fastMode, - thinkingDefaultValue: thinking.defaultValue, - thinkingDisabled, - thinkingOptions: [{ value: "", label: thinking.defaultLabel }, ...thinking.options], - onModelSelect: (next) => switchChatModel(state, next), - onFastModeSelect: (next) => switchChatFastMode(state, next), - onThinkingSelect: (next) => switchChatThinkingLevel(state, next), - }); -} - -type ChatThinkingSelectOption = { - value: string; - label: string; -}; - -type ChatThinkingSelectState = { - currentOverride: string; - defaultLabel: string; - /** Normalized default level id so the slider can mark the inherited stop. */ - defaultValue: string; - options: ChatThinkingSelectOption[]; -}; - -type ChatFastModeSelectState = { - currentOverride: "" | "on" | "off" | "auto"; - disabled: boolean; - options: ChatInlineSelectOption[]; - supported: boolean; -}; - -function resolveThinkingTargetModel(state: AppViewState): { - provider: string | null; - model: string | null; -} { - const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); - return { - provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null, - model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null, - }; -} - -function resolveProviderFromModelValue( - value: string, - catalog: AppViewState["chatModelCatalog"], -): string | null { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const separator = trimmed.indexOf("/"); - if (separator > 0) { - return trimmed.slice(0, separator).toLowerCase(); - } - return ( - catalog - .find((entry) => entry.id.trim().toLowerCase() === trimmed.toLowerCase()) - ?.provider.trim() - .toLowerCase() || null - ); -} - -function resolveChatFastModeSelectState( - state: AppViewState, - currentModelOverride: string, -): ChatFastModeSelectState { - const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); - const { provider } = resolveThinkingTargetModel(state); - const effectiveProvider = - resolveProviderFromModelValue(currentModelOverride, state.chatModelCatalog ?? []) ?? - provider?.trim().toLowerCase() ?? - null; - const currentOverride = - activeRow?.fastMode === "auto" - ? "auto" - : activeRow?.fastMode === true - ? "on" - : activeRow?.fastMode === false - ? "off" - : ""; - const supported = Boolean( - (effectiveProvider && FAST_MODE_PROVIDER_IDS.has(effectiveProvider)) || currentOverride, - ); - return { - currentOverride, - disabled: - !supported || - !state.connected || - state.chatLoading || - state.chatSending || - Boolean(state.chatRunId) || - state.chatStream !== null || - !state.client, - options: [ - { value: "", label: "Default" }, - { value: "on", label: "Fast" }, - { value: "off", label: "Standard" }, - { value: "auto", label: "Auto" }, - ], - supported, - }; -} - -function buildThinkingOptions( - levels: readonly GatewayThinkingLevelOption[], - currentOverride: string, -): ChatThinkingSelectOption[] { - const seen = new Set(); - const options: ChatThinkingSelectOption[] = []; - - const addOption = (value: string, label?: string) => { - const normalizedValue = normalizeThinkingOptionValue(value); - pushUniqueTrimmedSelectOption(options, seen, normalizedValue, () => - formatThinkingOverrideLabel(normalizedValue, label), - ); - }; - - for (const level of levels) { - addOption(level.id, level.label); - } - if (currentOverride) { - addOption(currentOverride); - } - return options; -} - -function isOffThinkingOption(value: string | null | undefined): boolean { - return normalizeThinkingOptionValue(value ?? "") === "off"; -} - -function isOffOnlyThinkingLevels(levels: readonly GatewayThinkingLevelOption[]): boolean { - return levels.every((level) => isOffThinkingOption(level.id || level.label)); -} - -function resolveThinkingLevelOptions( - activeRow: SessionsListResult["sessions"][number] | undefined, - defaults: SessionsListResult["defaults"] | undefined, - provider: string | null, - model: string | null, - catalog: readonly ThinkingCatalogEntry[], -): GatewayThinkingLevelOption[] { - const modelMatchesDefaults = sessionModelMatchesDefaults(activeRow, defaults); - const catalogEntry = - provider && model - ? catalog.find((entry) => entry.provider === provider && entry.id === model) - : undefined; - const explicitLevels = - (activeRow?.thinkingLevels?.length ? activeRow.thinkingLevels : null) ?? - (modelMatchesDefaults && defaults?.thinkingLevels?.length ? defaults.thinkingLevels : null); - if (explicitLevels) { - if (catalogEntry?.reasoning === false && isOffOnlyThinkingLevels(explicitLevels)) { - return []; - } - return explicitLevels; - } - const explicitLabels = - (activeRow?.thinkingOptions?.length ? activeRow.thinkingOptions : null) ?? - (modelMatchesDefaults && defaults?.thinkingOptions?.length ? defaults.thinkingOptions : null); - if (catalogEntry?.reasoning === false) { - if (!explicitLabels || explicitLabels.every(isOffThinkingOption)) { - return []; - } - } - const labels = - explicitLabels ?? - (provider && model ? listThinkingLevelLabels(provider, model) : listThinkingLevelLabels()); - return labels.map((label) => ({ - id: normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label), - label, - })); -} - -export function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState { - const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); - const persisted = activeRow?.thinkingLevel; - const currentOverride = - typeof persisted === "string" && persisted.trim() - ? (normalizeThinkLevel(persisted) ?? persisted.trim()) - : ""; - const defaults = state.sessionsResult?.defaults; - const { provider, model } = resolveThinkingTargetModel(state); - const levels = resolveThinkingLevelOptions( - activeRow, - defaults, - provider, - model, - state.chatModelCatalog ?? [], - ); - const defaultFromSessionDefaults = - (!activeRow || sessionModelMatchesDefaults(activeRow, defaults)) && defaults?.thinkingDefault - ? defaults.thinkingDefault - : undefined; - const defaultLevel = - activeRow?.thinkingDefault ?? - defaultFromSessionDefaults ?? - (provider && model - ? resolveThinkingDefaultForModel({ - provider, - model, - catalog: state.chatModelCatalog ?? [], - }) - : "off"); - const effectiveOverride = levels.length === 0 && currentOverride === "off" ? "" : currentOverride; - return { - currentOverride: effectiveOverride, - defaultLabel: formatInheritedThinkingLabel(defaultLevel), - defaultValue: normalizeThinkingOptionValue(defaultLevel), - options: buildThinkingOptions(levels, effectiveOverride), - }; -} - -function formatCombinedPickerModelLabel(label: string): string { - const match = /^Default \((.+)\)$/u.exec(label); - return match?.[1] ?? label; -} - -function formatCombinedPickerModelOptionLabel( - option: ChatInlineSelectOption, - selected: boolean, -): string { - return option.value === "" && selected - ? formatCombinedPickerModelLabel(option.label) - : option.label; -} - -function formatCombinedPickerThinkingLabel(label: string): string { - return label.replace(/^Inherited:\s*/u, ""); -} - -function renderChatModelReasoningSelect(params: { - fastMode: ChatFastModeSelectState; - disabled: boolean; - modelOptions: ChatInlineSelectOption[]; - selectedModelLabel: string; - selectedModelValue: string; - selectedThinkingLabel: string; - selectedThinkingValue: string; - thinkingDefaultValue: string; - thinkingDisabled: boolean; - thinkingOptions: ChatInlineSelectOption[]; - onFastModeSelect: (value: "" | "on" | "off" | "auto") => Promise; - onModelSelect: (value: string) => Promise; - onThinkingSelect: (value: string) => Promise; -}) { - const { - disabled, - fastMode, - modelOptions, - selectedModelLabel, - selectedModelValue, - selectedThinkingLabel, - selectedThinkingValue, - thinkingDefaultValue, - thinkingDisabled, - thinkingOptions, - onFastModeSelect, - onModelSelect, - onThinkingSelect, - } = params; - const triggerModel = formatCombinedPickerModelLabel(selectedModelLabel); - const triggerThinking = formatCombinedPickerThinkingLabel(selectedThinkingLabel); - const triggerTitle = `${triggerModel} · ${triggerThinking}`; - // The visible trigger stays minimal: the inherited default level is noise, - // so reasoning only appears when overridden. Title/aria keep the full pair. - const triggerLabel = - selectedThinkingValue === "" ? triggerModel : `${triggerModel} · ${triggerThinking}`; - // Reasoning renders as a discrete slider over the ordered level list the - // gateway offers (faster -> smarter). "" (inherit default) is not a stop: - // the thumb parks on the default level until the user sets an override. - const sliderStops = thinkingOptions.filter((option) => option.value !== ""); - const defaultStopIndex = sliderStops.findIndex((option) => option.value === thinkingDefaultValue); - const hasThinkingOverride = selectedThinkingValue !== ""; - const overrideStopIndex = sliderStops.findIndex( - (option) => option.value === selectedThinkingValue, - ); - const sliderIndex = Math.max(hasThinkingOverride ? overrideStopIndex : defaultStopIndex, 0); - // Inherited defaults like "adaptive" may not exist on the offered scale. - // The value label stays truthful ("Default (Adaptive)"); the thumb gets an - // unanchored style so its parked position does not read as the default. - const sliderUnanchored = !hasThinkingOverride && defaultStopIndex < 0; - const sliderFillPercent = (index: number) => - sliderStops.length > 1 ? (index / (sliderStops.length - 1)) * 100 : 0; - const reasoningValueLabel = hasThinkingOverride - ? triggerThinking - : `Default (${triggerThinking})`; - const defaultLevelLabel = formatThinkingOverrideLabel(thinkingDefaultValue); - // Keep the filled track segment glued to the thumb while dragging; the - // sessions.patch commit happens on release (change), not per input tick. - const onSliderDrag = (event: Event) => { - const input = event.currentTarget as HTMLInputElement; - input.style.setProperty("--reasoning-fill", `${sliderFillPercent(Number(input.value))}%`); - }; - const onSliderCommit = async (event: Event) => { - if (thinkingDisabled) { - return; - } - const input = event.currentTarget as HTMLInputElement; - const stop = sliderStops[Number(input.value)]; - if (!stop || stop.value === selectedThinkingValue) { - return; - } - await onThinkingSelect(stop.value); - }; - const showReasoning = sliderStops.length > 0; - // A one-entry scale is not a slider, but the lone level must stay - // selectable from the inherited default (regression guard vs the old list). - const onlyStop = sliderStops.length === 1 ? sliderStops[0] : undefined; - const showReasoningPanel = showReasoning || fastMode.supported; - return html` -
- { - if (disabled) { - event.preventDefault(); - } - }} - > - ${triggerLabel} - - -
- -
- ${repeat( - modelOptions, - (entry) => entry.value, - (entry) => { - const selected = entry.value === selectedModelValue; - return html` -
- -
- `; - }, - )} -
- ${showReasoningPanel - ? html` -
- ${showReasoning - ? html` -
- - ${reasoningValueLabel} -
- ${sliderStops.length > 1 - ? html` -
- - stop.value) - .join(",")} - aria-label=${t("chat.selectors.thinkingLevel")} - aria-valuetext=${reasoningValueLabel} - ?disabled=${thinkingDisabled} - @input=${onSliderDrag} - @change=${onSliderCommit} - /> -
- - ` - : onlyStop - ? html` - - ` - : ""} - ${hasThinkingOverride - ? html` - - ` - : ""} - ` - : ""} - ${fastMode.supported - ? html` - -
- ${repeat( - fastMode.options, - (speed) => speed.value, - (speed) => { - const speedValue = speed.value as "" | "on" | "off" | "auto"; - const speedSelected = speedValue === fastMode.currentOverride; - return html` - - `; - }, - )} -
- ` - : ""} -
- ` - : ""} -
-
- `; -} - -function patchSessionFastMode( - state: AppViewState, - sessionKey: string, - fastMode: FastMode | undefined, -) { - const current = state.sessionsResult; - if (!current) { - return; - } - state.sessionsResult = { - ...current, - sessions: current.sessions.map((row) => - row.key === sessionKey ? Object.assign({}, row, { fastMode }) : row, - ), - }; -} - -async function switchChatFastMode(state: AppViewState, nextFastMode: "" | "on" | "off" | "auto") { - if (!state.client || !state.connected) { - return; - } - const targetSessionKey = state.sessionKey; - const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey); - const previousFastMode = activeRow?.fastMode; - const next: FastMode | undefined = - nextFastMode === "" ? undefined : nextFastMode === "auto" ? "auto" : nextFastMode === "on"; - if (previousFastMode === next) { - return; - } - setChatError(state, null); - patchSessionFastMode(state, targetSessionKey, next); - try { - await state.client.request("sessions.patch", { - key: targetSessionKey, - ...scopedAgentParamsForSession(state, targetSessionKey), - fastMode: next ?? null, - }); - await refreshSessionOptions(state); - patchSessionFastMode(state, targetSessionKey, next); - } catch (err) { - patchSessionFastMode(state, targetSessionKey, previousFastMode); - setChatError(state, `Failed to set speed: ${String(err)}`); - } -} - -async function switchChatModel(state: AppViewState, nextModel: string): Promise { - if (!state.client || !state.connected) { - return false; - } - const currentOverride = resolveChatModelOverrideValue(state); - if (currentOverride === nextModel) { - return true; - } - const targetSessionKey = state.sessionKey; - const prevOverride = state.chatModelOverrides[targetSessionKey]; - setChatError(state, null); - // Write the override cache immediately so the picker stays in sync during the RPC round-trip. - state.chatModelOverrides = { - ...state.chatModelOverrides, - [targetSessionKey]: createChatModelOverride(nextModel), - }; - const client = state.client; - const switchPromiseRef: { current?: Promise } = {}; - const clearPendingSwitch = () => { - if (state.chatModelSwitchPromises?.[targetSessionKey] === switchPromiseRef.current) { - const nextSwitches = { ...state.chatModelSwitchPromises }; - delete nextSwitches[targetSessionKey]; - state.chatModelSwitchPromises = nextSwitches; - } - }; - const switchPromise: Promise = (async () => { - try { - await client.request("sessions.patch", { - key: targetSessionKey, - ...scopedAgentParamsForSession(state, targetSessionKey), - model: nextModel || null, - }); - void refreshVisibleToolsEffectiveForCurrentSessionLazy(state); - await refreshSessionOptions(state); - return true; - } catch (err) { - // Roll back so the picker reflects the actual server model. - state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride }; - setChatError(state, `Failed to set model: ${String(err)}`); - return false; - } finally { - clearPendingSwitch(); - } - })(); - switchPromiseRef.current = switchPromise; - state.chatModelSwitchPromises = { - ...state.chatModelSwitchPromises, - [targetSessionKey]: switchPromise, - }; - return switchPromise; -} - -function patchSessionThinkingLevel( - state: AppViewState, - sessionKey: string, - thinkingLevel: string | undefined, -) { - const current = state.sessionsResult; - if (!current) { - return; - } - state.sessionsResult = { - ...current, - sessions: current.sessions.map((row) => - row.key === sessionKey ? Object.assign({}, row, { thinkingLevel }) : row, - ), - }; -} - -async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) { - if (!state.client || !state.connected) { - return; - } - const targetSessionKey = state.sessionKey; - const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey); - const previousThinkingLevel = activeRow?.thinkingLevel; - const normalizedNext = - (normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined; - const normalizedPrev = - typeof previousThinkingLevel === "string" && previousThinkingLevel.trim() - ? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim()) - : undefined; - if ((normalizedPrev ?? "") === (normalizedNext ?? "")) { - return; - } - setChatError(state, null); - patchSessionThinkingLevel(state, targetSessionKey, normalizedNext); - state.chatThinkingLevel = normalizedNext ?? null; - try { - await state.client.request("sessions.patch", { - key: targetSessionKey, - ...scopedAgentParamsForSession(state, targetSessionKey), - thinkingLevel: normalizedNext ?? null, - }); - await refreshSessionOptions(state); - patchSessionThinkingLevel(state, targetSessionKey, normalizedNext); - state.chatThinkingLevel = normalizedNext ?? null; - } catch (err) { - patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel); - state.chatThinkingLevel = normalizedPrev ?? null; - setChatError(state, `Failed to set thinking level: ${String(err)}`); - } -} - -type SessionOptionEntry = { - key: string; - label: string; - scopeLabel: string; - title: string; -}; - -export type SessionOptionGroup = { - id: string; - label: string; - options: SessionOptionEntry[]; -}; - -type ChatAgentFilterOption = { - id: string; - label: string; -}; - -export function resolveChatAgentFilterId(state: AppViewState, sessionKey: string): string { - const parsed = parseAgentSessionKey(sessionKey); - return normalizeAgentId(parsed?.agentId ?? state.agentsList?.defaultId ?? "main"); -} - -function resolvePreferredSessionCandidateAgentId( - row: SessionsListResult["sessions"][number], - defaultAgentId: string, -): string | null { - if (row.kind === "global" || row.kind === "unknown" || isCronSessionKey(row.key)) { - return null; - } - if (isSubagentSessionKey(row.key) || row.spawnedBy) { - return null; - } - const parsed = parseAgentSessionKey(row.key); - return normalizeAgentId(parsed?.agentId ?? defaultAgentId); -} - -function rememberChatAgentSessionRows( - state: AppViewState, - sessions: SessionsListResult | null, -): void { - if (!sessions) { - return; - } - const rows = sessions.sessions; - const refreshedAgentId = normalizeOptionalString(state.sessionsResultAgentId); - const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); - const grouped = new Map(); - for (const row of rows) { - const agentId = resolvePreferredSessionCandidateAgentId(row, defaultAgentId); - if (!agentId) { - continue; - } - grouped.set(agentId, [...(grouped.get(agentId) ?? []), row]); - } - if (grouped.size === 0 && !refreshedAgentId) { - return; - } - state.chatAgentSessionRowsByAgent ??= {}; - if (refreshedAgentId) { - state.chatAgentSessionRowsByAgent[refreshedAgentId] = grouped.get(refreshedAgentId) ?? []; - } - for (const [agentId, agentRows] of grouped) { - state.chatAgentSessionRowsByAgent[agentId] = agentRows; - } -} - -function rowsForPreferredAgentSession( - state: AppViewState, - normalizedAgentId: string, - defaultAgentId: string, -): SessionsListResult["sessions"] { - const byKey = new Map(); - for (const row of state.chatAgentSessionRowsByAgent?.[normalizedAgentId] ?? []) { - byKey.set(row.key, row); - } - for (const row of state.sessionsResult?.sessions ?? []) { - if (resolvePreferredSessionCandidateAgentId(row, defaultAgentId) === normalizedAgentId) { - byKey.set(row.key, row); - } - } - return [...byKey.values()]; -} - -export function resolvePreferredSessionForAgent(state: AppViewState, agentId: string): string { - const normalizedAgentId = normalizeAgentId(agentId); - if (resolveChatAgentFilterId(state, state.sessionKey) === normalizedAgentId) { - return state.sessionKey; - } - const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); - const eligible = rowsForPreferredAgentSession(state, normalizedAgentId, defaultAgentId) - .filter((row) => { - if (!isSessionKeyTiedToAgent(row.key, normalizedAgentId, defaultAgentId)) { - return false; - } - return resolvePreferredSessionCandidateAgentId(row, defaultAgentId) === normalizedAgentId; - }) - .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); - if (eligible[0]?.key) { - return eligible[0].key; - } - return buildAgentMainSessionKey({ agentId: normalizedAgentId }); -} - -export function resolveChatAgentFilterOptions(state: AppViewState): ChatAgentFilterOption[] { - const seen = new Set(); - const options: ChatAgentFilterOption[] = []; - const add = (agentId: string) => { - const normalized = normalizeAgentId(agentId); - if (seen.has(normalized)) { - return; - } - seen.add(normalized); - options.push({ - id: normalized, - label: resolveAgentGroupLabel(state, normalized), - }); - }; - - add(resolveChatAgentFilterId(state, state.sessionKey)); - add(state.agentsList?.defaultId ?? "main"); - for (const agent of state.agentsList?.agents ?? []) { - add(agent.id); - } - for (const row of state.sessionsResult?.sessions ?? []) { - const parsed = parseAgentSessionKey(row.key); - if (parsed) { - add(parsed.agentId); - } - } - - return options; -} - -export function resolveSessionOptionGroups( - state: AppViewState, - sessionKey: string, - sessions: SessionsListResult | null, -): SessionOptionGroup[] { - const rows = sessions?.sessions ?? []; - const hideCron = state.sessionsHideCron ?? true; - const activeAgentId = resolveChatAgentFilterId(state, sessionKey); - const defaultAgentId = normalizeAgentId(state.agentsList?.defaultId ?? "main"); - const byKey = new Map(); - for (const row of rows) { - byKey.set(row.key, row); - } - - const seenKeys = new Set(); - const groups = new Map(); - const ensureGroup = (groupId: string, label: string): SessionOptionGroup => { - const existing = groups.get(groupId); - if (existing) { - return existing; - } - const created: SessionOptionGroup = { - id: groupId, - label, - options: [], - }; - groups.set(groupId, created); - return created; - }; - - const addOption = (key: string) => { - if (!key || seenKeys.has(key)) { - return; - } - seenKeys.add(key); - const row = byKey.get(key); - const parsed = parseAgentSessionKey(key); - const group = parsed - ? ensureGroup( - `agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`, - resolveAgentGroupLabel(state, parsed.agentId), - ) - : ensureGroup("other", "Other Sessions"); - const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key; - group.options.push({ - key, - label: resolveSessionScopedOptionLabel(key, row, parsed?.rest), - scopeLabel, - title: key, - }); - }; - - for (const row of rows) { - if ( - !isSessionKeyTiedToAgent(row.key, activeAgentId, defaultAgentId) && - row.key !== sessionKey - ) { - continue; - } - if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) { - continue; - } - if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) { - continue; - } - const isSubagent = isSubagentSessionKey(row.key) || Boolean(row.spawnedBy); - if (isSubagent && row.key !== sessionKey) { - continue; - } - addOption(row.key); - } - if (byKey.has(sessionKey)) { - addOption(sessionKey); - } else if (sessionKey) { - addOption(sessionKey); - } - - for (const group of groups.values()) { - const counts = new Map(); - for (const option of group.options) { - counts.set(option.label, (counts.get(option.label) ?? 0) + 1); - } - for (const option of group.options) { - if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) { - option.label = `${option.label} · ${option.scopeLabel}`; - } - } - } - - const allOptions = Array.from(groups.values()).flatMap((group) => - group.options.map((option) => ({ groupLabel: group.label, option })), - ); - const labels = new Map(allOptions.map(({ option }) => [option, option.label])); - const countAssignedLabels = () => { - const counts = new Map(); - for (const { option } of allOptions) { - const label = labels.get(option) ?? option.label; - counts.set(label, (counts.get(label) ?? 0) + 1); - } - return counts; - }; - const labelIncludesScopeLabel = (label: string, scopeLabel: string) => { - const trimmedScope = scopeLabel.trim(); - if (!trimmedScope) { - return false; - } - return ( - label === trimmedScope || - label.endsWith(` · ${trimmedScope}`) || - label.endsWith(` / ${trimmedScope}`) - ); - }; - - const globalCounts = countAssignedLabels(); - for (const { groupLabel, option } of allOptions) { - const currentLabel = labels.get(option) ?? option.label; - if ((globalCounts.get(currentLabel) ?? 0) <= 1) { - continue; - } - const scopedPrefix = `${groupLabel} / `; - if (currentLabel.startsWith(scopedPrefix)) { - continue; - } - // Keep the agent visible once the native select collapses to a single chosen label. - labels.set(option, `${groupLabel} / ${currentLabel}`); - } - - const scopedCounts = countAssignedLabels(); - for (const { option } of allOptions) { - const currentLabel = labels.get(option) ?? option.label; - if ((scopedCounts.get(currentLabel) ?? 0) <= 1) { - continue; - } - if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) { - continue; - } - labels.set(option, `${currentLabel} · ${option.scopeLabel}`); - } - - const finalCounts = countAssignedLabels(); - for (const { option } of allOptions) { - const currentLabel = labels.get(option) ?? option.label; - if ((finalCounts.get(currentLabel) ?? 0) <= 1) { - continue; - } - // Fall back to the full key only when every friendlier disambiguator still collides. - labels.set(option, `${currentLabel} · ${option.key}`); - } - - for (const { option } of allOptions) { - option.label = labels.get(option) ?? option.label; - } - - return Array.from(groups.values()); -} - -function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string { - const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw); - const agent = (state.agentsList?.agents ?? []).find( - (entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized, - ); - const name = - normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? ""; - return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw; -} - -function resolveSessionScopedOptionLabel( - key: string, - row?: SessionsListResult["sessions"][number], - rest?: string, -) { - const base = normalizeOptionalString(rest) ?? key; - if (!row) { - return base; - } - - const label = normalizeOptionalString(row.label) ?? ""; - const displayName = normalizeOptionalString(row.displayName) ?? ""; - if ((label && label !== key) || (displayName && displayName !== key)) { - return resolveSessionDisplayName(key, row); - } - - return base; -} diff --git a/ui/src/ui/chat/side-result-render.ts b/ui/src/ui/chat/side-result-render.ts deleted file mode 100644 index 04d7efcf15e1..000000000000 --- a/ui/src/ui/chat/side-result-render.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Control UI chat module implements side result render behavior. -import { html, nothing, type TemplateResult } from "lit"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { icons } from "../icons.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; -import { detectTextDirection } from "../text-direction.ts"; -import type { ChatSideResult } from "./side-result.ts"; - -export function renderSideResult( - sideResult: ChatSideResult | null | undefined, - onDismiss?: () => void, -): TemplateResult | typeof nothing { - if (!sideResult) { - return nothing; - } - return html` -
-
-
- BTW - Not saved to chat history -
- -
-
${sideResult.question}
-
- ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} -
-
- `; -} diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts deleted file mode 100644 index 45d5f478e135..000000000000 --- a/ui/src/ui/chat/slash-commands.node.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -// @vitest-environment node -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - parseSlashCommand, - refreshSlashCommands, - resetSlashCommandsForTest, - SLASH_COMMANDS, -} from "./slash-commands.ts"; - -afterEach(() => { - resetSlashCommandsForTest(); -}); - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function requireRecord(value: unknown, label: string): Record { - if (!isRecord(value)) { - throw new Error(`expected ${label} to be an object`); - } - return value; -} - -function requireArray(value: unknown, label: string): unknown[] { - if (!Array.isArray(value)) { - throw new Error(`expected ${label} to be an array`); - } - return value; -} - -function expectRecordFields(value: unknown, label: string, expected: Record) { - const record = requireRecord(value, label); - for (const [key, expectedValue] of Object.entries(expected)) { - expect(record[key]).toEqual(expectedValue); - } -} - -function requireCommandByName(name: string): Record { - return requireRecord( - SLASH_COMMANDS.find((entry) => entry.name === name), - `slash command ${name}`, - ); -} - -function requireCommandByKey(key: string): Record { - return requireRecord( - SLASH_COMMANDS.find((entry) => entry.key === key), - `slash command ${key}`, - ); -} - -function expectParsedSlash(input: string, commandFields: Record, args: string) { - const parsed = requireRecord(parseSlashCommand(input), `parsed ${input}`); - expectRecordFields(parsed.command, `parsed ${input} command`, commandFields); - expect(parsed.args).toBe(args); -} - -describe("parseSlashCommand", () => { - it("parses commands with an optional colon separator", () => { - expectParsedSlash("/think: high", { name: "think" }, "high"); - expectParsedSlash("/think:high", { name: "think" }, "high"); - expectParsedSlash("/help:", { name: "help" }, ""); - }); - - it("still parses space-delimited commands", () => { - expectParsedSlash("/verbose full", { name: "verbose" }, "full"); - }); - - it("parses fast commands", () => { - expectParsedSlash("/fast:on", { name: "fast" }, "on"); - }); - - it("keeps /status on the agent path", () => { - const status = SLASH_COMMANDS.find((entry) => entry.name === "status"); - expect(status?.executeLocal).not.toBe(true); - expectParsedSlash("/status", { name: "status" }, ""); - }); - - it("includes shared /tools with shared arg hints", () => { - const tools = requireCommandByName("tools"); - expectRecordFields(tools, "tools command", { - key: "tools", - description: "List available runtime tools.", - argOptions: ["compact", "verbose"], - executeLocal: false, - }); - expectParsedSlash("/tools verbose", { name: "tools" }, "verbose"); - }); - - it("parses slash aliases through the shared registry", () => { - const exportCommand = requireCommandByKey("export-session"); - expectRecordFields(exportCommand, "export-session command", { - name: "export-session", - aliases: ["export"], - executeLocal: true, - }); - expectParsedSlash("/export", { key: "export-session" }, ""); - expectParsedSlash("/export-session", { key: "export-session" }, ""); - const side = requireRecord(parseSlashCommand("/side what changed?"), "parsed /side"); - expectRecordFields(side.command, "side command", { key: "btw", name: "btw" }); - expect( - requireArray(requireRecord(side.command, "side command").aliases, "side aliases"), - ).toEqual(["side"]); - expect(side.args).toBe("what changed?"); - }); - - it("keeps canonical long-form slash names as the primary menu command", () => { - expectRecordFields(requireCommandByKey("verbose"), "verbose command", { - name: "verbose", - aliases: ["v"], - }); - const think = requireCommandByKey("think"); - expectRecordFields(think, "think command", { - name: "think", - }); - expect(requireArray(think.aliases, "think aliases")).toEqual(["thinking", "t"]); - }); - - it("keeps a single local /steer entry with the control-ui metadata", () => { - const steerEntries = SLASH_COMMANDS.filter((entry) => entry.name === "steer"); - expect(steerEntries).toHaveLength(1); - const steer = requireRecord(steerEntries[0], "steer command"); - expectRecordFields(steer, "steer command", { - key: "steer", - description: "Inject a message into the active run", - args: "", - executeLocal: true, - }); - expect(requireArray(steer.aliases, "steer aliases")).toEqual(["tell"]); - }); - - it("refreshes runtime commands from commands.list so docks, plugins, and direct skills appear", async () => { - const request = async (method: string) => { - expect(method).toBe("commands.list"); - return { - commands: [ - { - name: "dock-discord", - textAliases: ["/dock-discord", "/dock_discord"], - description: "Switch to discord for replies.", - source: "native", - scope: "both", - acceptsArgs: false, - category: "docks", - }, - { - name: "dreaming", - textAliases: ["/dreaming"], - description: "Enable or disable memory dreaming.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - { - name: "prose", - textAliases: ["/prose"], - description: "Draft polished prose.", - source: "skill", - scope: "both", - acceptsArgs: true, - }, - ], - }; - }; - - await refreshSlashCommands({ - client: { request } as never, - agentId: "main", - }); - - expectRecordFields(requireCommandByName("dock-discord"), "dock-discord command", { - aliases: ["dock_discord"], - category: "tools", - executeLocal: false, - }); - expectRecordFields(requireCommandByName("dreaming"), "dreaming command", { - key: "dreaming", - executeLocal: false, - }); - expectRecordFields(requireCommandByName("prose"), "prose command", { - key: "prose", - executeLocal: false, - }); - expectParsedSlash("/dock_discord", { name: "dock-discord" }, ""); - }); - - it("does not let remote commands collide with reserved local commands", async () => { - const request = async () => ({ - commands: [ - { - name: "redirect", - textAliases: ["/redirect"], - description: "Remote redirect impostor.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - ], - }); - - await refreshSlashCommands({ - client: { request } as never, - agentId: "main", - }); - - expectRecordFields(requireCommandByName("redirect"), "redirect command", { - key: "redirect", - executeLocal: true, - description: "Abort and restart with a new message", - }); - }); - - it("drops remote commands with unsafe identifiers before they reach the palette/parser", async () => { - const request = async () => ({ - commands: [ - { - name: "prose now", - textAliases: ["/prose now", "/safe-name"], - description: "Unsafe injected command.", - source: "skill", - scope: "both", - acceptsArgs: true, - }, - { - name: "bad:alias", - textAliases: ["/bad:alias"], - description: "Unsafe alias command.", - source: "plugin", - scope: "both", - acceptsArgs: false, - }, - ], - }); - - await refreshSlashCommands({ - client: { request } as never, - agentId: "main", - }); - - expectRecordFields(requireCommandByName("safe-name"), "safe-name command", { - name: "safe-name", - }); - expect(SLASH_COMMANDS.find((entry) => entry.name === "prose now")).toBeUndefined(); - expect(SLASH_COMMANDS.find((entry) => entry.name === "bad:alias")).toBeUndefined(); - expectParsedSlash("/safe-name", { name: "safe-name" }, ""); - }); - - it("caps remote command payload size and long metadata before it reaches UI state", async () => { - const longName = "x".repeat(260); - const longDescription = "d".repeat(2_500); - const oversizedCommand = { - name: "plugin-0", - textAliases: Array.from({ length: 25 }, (_, aliasIndex) => `/plugin-0-${aliasIndex}`), - description: longDescription, - source: "plugin" as const, - scope: "both" as const, - acceptsArgs: true, - args: Array.from({ length: 25 }, (_, argIndex) => ({ - name: `${longName}-${argIndex}`, - description: longDescription, - type: "string" as const, - choices: Array.from({ length: 55 }, (_Local, choiceIndex) => ({ - value: `${longName}-${choiceIndex}`, - label: `${longName}-${choiceIndex}`, - })), - })), - }; - const request = async () => ({ - commands: [ - oversizedCommand, - ...Array.from({ length: 519 }, (_, index) => ({ - name: `plugin-${index + 1}`, - textAliases: [`/plugin-${index + 1}`], - description: "Plugin command.", - source: "plugin" as const, - scope: "both" as const, - acceptsArgs: false, - })), - ], - }); - - await refreshSlashCommands({ - client: { request } as never, - agentId: "main", - }); - - const remoteCommands = SLASH_COMMANDS.filter((entry) => entry.name.startsWith("plugin-")); - expect(remoteCommands).toHaveLength(500); - const first = remoteCommands[0]; - expect(first.aliases).toHaveLength(19); - expect(first.description.length).toBeLessThanOrEqual(2_000); - expect(first.args?.split(" ")).toHaveLength(20); - expect(first.argOptions).toHaveLength(50); - }); - - it("requests the gateway default agent when no explicit agentId is available", async () => { - const request = vi.fn().mockResolvedValue({ - commands: [ - { - name: "pair", - textAliases: ["/pair"], - description: "Generate setup codes.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - ], - }); - - await refreshSlashCommands({ - client: { request } as never, - agentId: undefined, - }); - - expect(request).toHaveBeenCalledWith("commands.list", { - includeArgs: true, - scope: "text", - }); - expectRecordFields(requireCommandByName("pair"), "pair command", { - name: "pair", - description: "Generate setup codes.", - executeLocal: false, - tier: "standard", - }); - }); - - it("falls back safely when the gateway returns malformed command payload shapes", async () => { - const request = vi - .fn() - .mockResolvedValueOnce({ commands: { bad: "shape" } }) - .mockResolvedValueOnce({ - commands: [ - { - name: "valid", - textAliases: ["/valid"], - description: 42, - args: { nope: true }, - }, - { - name: "pair", - textAliases: ["/pair"], - description: "Generate setup codes.", - source: "plugin", - scope: "both", - acceptsArgs: true, - args: [ - { - name: "mode", - required: "yes", - choices: { broken: true }, - }, - ], - }, - ], - }); - - await refreshSlashCommands({ - client: { request } as never, - agentId: "main", - }); - expect(SLASH_COMMANDS.find((entry) => entry.name === "pair")).toBeUndefined(); - expectRecordFields(requireCommandByName("help"), "help command", { - key: "help", - name: "help", - executeLocal: true, - }); - - await refreshSlashCommands({ - client: { request } as never, - agentId: "main", - }); - expectRecordFields(requireCommandByName("valid"), "valid command", { - name: "valid", - description: "", - }); - expectRecordFields(requireCommandByName("pair"), "pair command", { - name: "pair", - }); - }); - - it("keeps local fallback commands after repeated gateway failures", async () => { - const request = vi.fn().mockRejectedValue(new Error("offline")); - const client = { request } as never; - - await refreshSlashCommands({ client, agentId: "main" }); - expectRecordFields(requireCommandByName("help"), "first fallback help command", { - key: "help", - executeLocal: true, - }); - - await refreshSlashCommands({ client, agentId: "main" }); - expect(request).toHaveBeenCalledTimes(2); - expectRecordFields(requireCommandByName("help"), "second fallback help command", { - key: "help", - executeLocal: true, - }); - }); - - it("coalesces duplicate refreshes for the same agent", async () => { - let resolveFirst: ((value: unknown) => void) | undefined; - const first = new Promise((resolve) => { - resolveFirst = resolve; - }); - const request = vi.fn().mockImplementationOnce(async () => await first); - const client = { request } as never; - - const pending = refreshSlashCommands({ - client, - agentId: "main", - }); - const duplicate = refreshSlashCommands({ - client, - agentId: "main", - }); - if (resolveFirst) { - resolveFirst({ - commands: [ - { - name: "pair", - textAliases: ["/pair"], - description: "Generate setup codes.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - ], - }); - } - await pending; - await duplicate; - - expect(request).toHaveBeenCalledTimes(1); - expectRecordFields(requireCommandByName("pair"), "pair command", { - name: "pair", - description: "Generate setup codes.", - executeLocal: false, - tier: "standard", - }); - }); - - it("ignores stale refresh responses after switching agents", async () => { - let resolveFirst: ((value: unknown) => void) | undefined; - const first = new Promise((resolve) => { - resolveFirst = resolve; - }); - const request = vi.fn((_: string, params: { agentId?: string }) => { - if (params.agentId === "main") { - return first; - } - return Promise.resolve({ - commands: [ - { - name: "pair", - textAliases: ["/pair"], - description: "Generate setup codes.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - ], - }); - }); - const client = { request } as never; - - const pending = refreshSlashCommands({ client, agentId: "main" }); - await refreshSlashCommands({ client, agentId: "other" }); - resolveFirst?.({ - commands: [ - { - name: "dreaming", - textAliases: ["/dreaming"], - description: "Enable or disable memory dreaming.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - ], - }); - await pending; - - expectRecordFields(requireCommandByName("pair"), "pair command", { - name: "pair", - description: "Generate setup codes.", - }); - expect(SLASH_COMMANDS.find((entry) => entry.name === "dreaming")).toBeUndefined(); - }); - - it("uses the fresh remote command cache for repeated refreshes", async () => { - const request = vi.fn().mockResolvedValue({ - commands: [ - { - name: "pair", - textAliases: ["/pair"], - description: "Generate setup codes.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - ], - }); - const client = { request } as never; - - await refreshSlashCommands({ client, agentId: "main" }); - await refreshSlashCommands({ client, agentId: "main" }); - - expect(request).toHaveBeenCalledTimes(1); - expectRecordFields(requireCommandByName("pair"), "pair command", { - name: "pair", - description: "Generate setup codes.", - }); - }); -}); diff --git a/ui/src/ui/chat/status-indicators.ts b/ui/src/ui/chat/status-indicators.ts deleted file mode 100644 index 3e1dc523af22..000000000000 --- a/ui/src/ui/chat/status-indicators.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Control UI chat module implements status indicators behavior. -import { html, nothing } from "lit"; -import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts"; -import { icons } from "../icons.ts"; -import { CHAT_RUN_STATUS_TOAST_DURATION_MS, type ChatRunUiStatus } from "./run-lifecycle.ts"; - -const COMPACTION_TOAST_DURATION_MS = 5000; -const FALLBACK_TOAST_DURATION_MS = 8000; - -export type ComposerRunStatus = - | ChatRunUiStatus - | { - phase: "in-progress"; - occurredAt?: number | null; - }; - -export function renderChatRunStatusIndicator(status: ComposerRunStatus | null | undefined) { - if (!status) { - return nothing; - } - if (status.phase !== "in-progress") { - const elapsed = Date.now() - status.occurredAt; - if (elapsed >= CHAT_RUN_STATUS_TOAST_DURATION_MS) { - return nothing; - } - } - const label = - status.phase === "in-progress" - ? "In progress" - : status.phase === "done" - ? "Done" - : "Interrupted"; - const icon = - status.phase === "in-progress" - ? icons.loader - : status.phase === "done" - ? icons.check - : icons.stop; - return html` - - ${icon}${label} - - `; -} - -export function renderCompactionIndicator(status: CompactionStatus | null | undefined) { - if (!status) { - return nothing; - } - if (status.phase === "active" || status.phase === "retrying") { - return html` -
- ${icons.loader} Compacting context... -
- `; - } - if (status.completedAt) { - const elapsed = Date.now() - status.completedAt; - if (elapsed < COMPACTION_TOAST_DURATION_MS) { - return html` -
- ${icons.check} Context compacted -
- `; - } - } - return nothing; -} - -export function renderFallbackIndicator(status: FallbackStatus | null | undefined) { - if (!status) { - return nothing; - } - const phase = status.phase ?? "active"; - const elapsed = Date.now() - status.occurredAt; - if (elapsed >= FALLBACK_TOAST_DURATION_MS) { - return nothing; - } - const details = [ - `Selected: ${status.selected}`, - phase === "cleared" ? `Active: ${status.selected}` : `Active: ${status.active}`, - phase === "cleared" && status.previous ? `Previous fallback: ${status.previous}` : null, - status.reason ? `Reason: ${status.reason}` : null, - status.attempts.length > 0 ? `Attempts: ${status.attempts.slice(0, 3).join(" | ")}` : null, - ] - .filter(Boolean) - .join(" • "); - const message = - phase === "cleared" - ? `Fallback cleared: ${status.selected}` - : `Fallback active: ${status.active}`; - const className = - phase === "cleared" - ? "compaction-indicator compaction-indicator--fallback-cleared" - : "compaction-indicator compaction-indicator--fallback"; - const icon = phase === "cleared" ? icons.check : icons.brain; - return html` -
- ${icon} ${message} -
- `; -} diff --git a/ui/src/ui/chat/stream-text.ts b/ui/src/ui/chat/stream-text.ts deleted file mode 100644 index 7a726c534e29..000000000000 --- a/ui/src/ui/chat/stream-text.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Control UI chat module implements stream text behavior. -export type ChatStreamSegment = { - text: string; - ts: number; - toolCallId?: string; - itemId?: string; -}; - -export function streamSegmentHasItemId(segment: { itemId?: unknown }): boolean { - return typeof segment.itemId === "string" && segment.itemId.trim().length > 0; -} - -export function streamSegmentUsesAccumulatedText(segment: { itemId?: unknown }): boolean { - return !streamSegmentHasItemId(segment); -} - -export function trimAccumulatedStreamPrefix(text: string, previousText: string | null): string { - if (!previousText || !text.startsWith(previousText)) { - return text; - } - return text.slice(previousText.length).trimStart(); -} diff --git a/ui/src/ui/chat/token-format.test.ts b/ui/src/ui/chat/token-format.test.ts deleted file mode 100644 index 5215099d194d..000000000000 --- a/ui/src/ui/chat/token-format.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Control UI tests for the compact token count formatter shared across chat surfaces. -import { describe, expect, it } from "vitest"; -import { formatCompactTokenCount } from "./token-format.ts"; - -describe("formatCompactTokenCount", () => { - it("formats values under 1,000 as-is", () => { - expect(formatCompactTokenCount(0)).toBe("0"); - expect(formatCompactTokenCount(999)).toBe("999"); - }); - - it("formats thousands with one decimal, trimming a trailing .0", () => { - expect(formatCompactTokenCount(1_000)).toBe("1k"); - expect(formatCompactTokenCount(214_500)).toBe("214.5k"); - expect(formatCompactTokenCount(99_950)).toBe("100k"); - }); - - it("formats millions with one decimal, trimming a trailing .0", () => { - expect(formatCompactTokenCount(1_000_000)).toBe("1M"); - expect(formatCompactTokenCount(1_500_000)).toBe("1.5M"); - }); - - it("rolls values that round up to 1000.0k over into the M branch instead of showing 1000k", () => { - // Regression test: 999,950-999,999 round to "1000.0" at one-decimal - // thousands precision. Before the fix, the >= 1_000_000 branch check - // ran on the raw value (which is still < 1_000_000), so these fell - // through to the k branch and displayed the nonsensical "1000k". - expect(formatCompactTokenCount(999_999)).toBe("1M"); - expect(formatCompactTokenCount(999_950)).toBe("1M"); - expect(formatCompactTokenCount(999_500)).toBe("999.5k"); - }); - - it("does not roll over values just below the rounding boundary", () => { - expect(formatCompactTokenCount(999_949)).toBe("999.9k"); - expect(formatCompactTokenCount(999_499)).toBe("999.5k"); - }); -}); diff --git a/ui/src/ui/chat/token-format.ts b/ui/src/ui/chat/token-format.ts deleted file mode 100644 index 8be968d5f3e3..000000000000 --- a/ui/src/ui/chat/token-format.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Chat surfaces share a one-decimal compact token label, e.g. 214500 -> "214.5k". -export function formatCompactTokenCount(tokens: number): string { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (tokens >= 1_000) { - // Values from 999,950-999,999 round to "1000.0" at one-decimal - // thousands precision, which would display the nonsensical "1000k" - // instead of rolling over to the M branch above. Re-check the - // rounded result before formatting. - const thousands = (tokens / 1_000).toFixed(1); - if (Number(thousands) >= 1_000) { - return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - return `${thousands.replace(/\.0$/, "")}k`; - } - return String(tokens); -} diff --git a/ui/src/ui/chat/tool-expansion-state.test.ts b/ui/src/ui/chat/tool-expansion-state.test.ts deleted file mode 100644 index fad243432913..000000000000 --- a/ui/src/ui/chat/tool-expansion-state.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Control UI tests cover tool expansion state behavior. -import { afterEach, describe, expect, it } from "vitest"; -import type { MessageGroup } from "../types/chat-types.ts"; -import { - getExpandedToolCards, - resetToolExpansionStateForTest, - syncToolCardExpansionState, -} from "./tool-expansion-state.ts"; - -afterEach(() => { - resetToolExpansionStateForTest(); -}); - -function createGroup(message: unknown, key = "assistant-1"): MessageGroup { - return { - kind: "group", - key, - role: "assistant", - messages: [{ key, message }], - timestamp: 1, - isStreaming: false, - }; -} - -describe("tool expansion state", () => { - it("expands already-visible tool cards when auto-expand turns on", () => { - const group = createGroup({ - role: "assistant", - content: [ - { - type: "toolcall", - id: "call-1", - name: "browser.open", - arguments: { url: "https://example.com" }, - }, - ], - }); - - syncToolCardExpansionState("main", [group], false); - expect(getExpandedToolCards("main").get("assistant-1:toolcard:0")).toBe(false); - - syncToolCardExpansionState("main", [group], true); - expect(getExpandedToolCards("main").get("assistant-1:toolcard:0")).toBe(true); - }); -}); diff --git a/ui/src/ui/chat/tool-expansion-state.ts b/ui/src/ui/chat/tool-expansion-state.ts deleted file mode 100644 index a3718bbada92..000000000000 --- a/ui/src/ui/chat/tool-expansion-state.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Control UI chat module implements tool expansion state behavior. -import type { ChatItem, MessageGroup } from "../types/chat-types.ts"; -import { isToolResultMessage, normalizeRoleForGrouping } from "./role-normalizer.ts"; -import { getOrCreateSessionCacheValue } from "./session-cache.ts"; -import { extractToolCardsCached } from "./tool-cards.ts"; - -const expandedToolCardsBySession = new Map>(); -const initializedToolCardsBySession = new Map>(); -const lastAutoExpandPrefBySession = new Map(); - -export function getExpandedToolCards(sessionKey: string): Map { - return getOrCreateSessionCacheValue(expandedToolCardsBySession, sessionKey, () => new Map()); -} - -function getInitializedToolCards(sessionKey: string): Set { - return getOrCreateSessionCacheValue(initializedToolCardsBySession, sessionKey, () => new Set()); -} - -export function resetToolExpansionStateForTest() { - expandedToolCardsBySession.clear(); - initializedToolCardsBySession.clear(); - lastAutoExpandPrefBySession.clear(); -} - -export function syncToolCardExpansionState( - sessionKey: string, - items: Array, - autoExpandToolCalls: boolean, -) { - const expanded = getExpandedToolCards(sessionKey); - const initialized = getInitializedToolCards(sessionKey); - const previousAutoExpand = lastAutoExpandPrefBySession.get(sessionKey) ?? false; - const currentToolCardIds = new Set(); - for (const item of items) { - if (item.kind !== "group") { - continue; - } - for (const entry of item.messages) { - const cards = extractToolCardsCached(entry.message, entry.key); - for (let cardIndex = 0; cardIndex < cards.length; cardIndex++) { - const disclosureId = `${entry.key}:toolcard:${cardIndex}`; - currentToolCardIds.add(disclosureId); - if (initialized.has(disclosureId)) { - continue; - } - expanded.set(disclosureId, autoExpandToolCalls); - initialized.add(disclosureId); - } - const messageRecord = entry.message as Record; - const role = typeof messageRecord.role === "string" ? messageRecord.role : "unknown"; - const normalizedRole = normalizeRoleForGrouping(role); - const isToolMessage = - isToolResultMessage(entry.message) || - normalizedRole === "tool" || - role.toLowerCase() === "toolresult" || - role.toLowerCase() === "tool_result" || - typeof messageRecord.toolCallId === "string" || - typeof messageRecord.tool_call_id === "string"; - if (!isToolMessage) { - continue; - } - const disclosureId = `toolmsg:${entry.key}`; - currentToolCardIds.add(disclosureId); - if (initialized.has(disclosureId)) { - continue; - } - expanded.set(disclosureId, autoExpandToolCalls); - initialized.add(disclosureId); - } - } - if (autoExpandToolCalls && !previousAutoExpand) { - for (const toolCardId of currentToolCardIds) { - expanded.set(toolCardId, true); - } - } - lastAutoExpandPrefBySession.set(sessionKey, autoExpandToolCalls); -} diff --git a/ui/src/ui/chat/tool-helpers.test.ts b/ui/src/ui/chat/tool-helpers.test.ts deleted file mode 100644 index 6aac48f145ab..000000000000 --- a/ui/src/ui/chat/tool-helpers.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -// Control UI tests cover tool helpers behavior. -import { describe, it, expect } from "vitest"; -import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers.ts"; - -const emptyStringHelperCases = [ - { name: "formatToolOutputForSidebar", resolve: formatToolOutputForSidebar }, - { name: "getTruncatedPreview", resolve: getTruncatedPreview }, -]; - -describe("tool-helpers", () => { - describe("empty string handling", () => { - it.each(emptyStringHelperCases)("$name handles empty string", ({ resolve }) => { - expect(resolve("")).toBe(""); - }); - }); - - describe("formatToolOutputForSidebar", () => { - it("formats valid JSON object as code block", () => { - const input = '{"name":"test","value":123}'; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe(`\`\`\`json -{ - "name": "test", - "value": 123 -} -\`\`\``); - }); - - it("formats valid JSON array as code block", () => { - const input = "[1, 2, 3]"; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe(`\`\`\`json -[ - 1, - 2, - 3 -] -\`\`\``); - }); - - it("handles nested JSON objects", () => { - const input = '{"outer":{"inner":"value"}}'; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe(`\`\`\`json -{ - "outer": { - "inner": "value" - } -} -\`\`\``); - }); - - it("returns plain text for non-JSON content", () => { - const input = "This is plain text output"; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe("This is plain text output"); - }); - - it("wraps block art output in a fence while preserving quiet-zone whitespace", () => { - const input = " ▀▀▀▀ \n ▄▄▄▄ \n ████ "; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe(`\`\`\` -${input} -\`\`\``); - }); - - it("returns as-is for invalid JSON starting with {", () => { - const input = "{not valid json"; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe("{not valid json"); - }); - - it("returns as-is for invalid JSON starting with [", () => { - const input = "[not valid json"; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe("[not valid json"); - }); - - it("trims whitespace before detecting JSON", () => { - const input = ' {"trimmed": true} '; - const result = formatToolOutputForSidebar(input); - - expect(result).toBe(`\`\`\`json -{ - "trimmed": true -} -\`\`\``); - }); - - it("handles whitespace-only string", () => { - const result = formatToolOutputForSidebar(" "); - expect(result).toBe(" "); - }); - }); - - describe("getTruncatedPreview", () => { - it("returns short text unchanged", () => { - const input = "Short text"; - const result = getTruncatedPreview(input); - - expect(result).toBe("Short text"); - }); - - it("truncates text longer than max chars", () => { - const input = "a".repeat(150); - const result = getTruncatedPreview(input); - - expect(result).toBe(`${"a".repeat(100)}…`); - }); - - it("truncates to max lines", () => { - const input = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; - const result = getTruncatedPreview(input); - - // Should only show first 2 lines (PREVIEW_MAX_LINES = 2) - expect(result).toBe("Line 1\nLine 2…"); - }); - - it("adds ellipsis when lines are truncated", () => { - const input = "Line 1\nLine 2\nLine 3"; - const result = getTruncatedPreview(input); - - expect(result).toBe("Line 1\nLine 2…"); - }); - - it("does not add ellipsis when all lines fit", () => { - const input = "Line 1\nLine 2"; - const result = getTruncatedPreview(input); - - expect(result).toBe("Line 1\nLine 2"); - }); - - it("handles single line within limits", () => { - const input = "Single line"; - const result = getTruncatedPreview(input); - - expect(result).toBe("Single line"); - }); - - it("truncates by chars even within line limit", () => { - // Two lines but very long content - const longLine = "x".repeat(80); - const input = `${longLine}\n${longLine}`; - const result = getTruncatedPreview(input); - - expect(result).toBe(`${"x".repeat(80)}\n${"x".repeat(19)}…`); - }); - }); -}); diff --git a/ui/src/ui/chat/tool-helpers.ts b/ui/src/ui/chat/tool-helpers.ts deleted file mode 100644 index 16675248cfae..000000000000 --- a/ui/src/ui/chat/tool-helpers.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Helper functions for tool card rendering. - */ - -import { isMarkdownBlockArtText } from "../markdown.ts"; -import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants.ts"; - -/** - * Format tool output content for display in the sidebar. - * Detects block art and JSON, wrapping content in code blocks when needed. - */ -export function formatToolOutputForSidebar(text: string): string { - if (isMarkdownBlockArtText(text)) { - return "```\n" + text + "\n```"; - } - - const trimmed = text.trim(); - // Try to detect and format JSON - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - const parsed = JSON.parse(trimmed); - return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```"; - } catch { - // Not valid JSON, return as-is - } - } - return text; -} - -/** - * Get a truncated preview of tool output text. - * Truncates to first N lines or first N characters, whichever is shorter. - */ -export function getTruncatedPreview(text: string): string { - const allLines = text.split("\n"); - const lines = allLines.slice(0, PREVIEW_MAX_LINES); - const preview = lines.join("\n"); - if (preview.length > PREVIEW_MAX_CHARS) { - return preview.slice(0, PREVIEW_MAX_CHARS) + "…"; - } - return lines.length < allLines.length ? preview + "…" : preview; -} diff --git a/ui/src/ui/chat/tool-message-refs.test.ts b/ui/src/ui/chat/tool-message-refs.test.ts deleted file mode 100644 index 838f39099e47..000000000000 --- a/ui/src/ui/chat/tool-message-refs.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Control UI tests cover tool message refs behavior. -import { describe, expect, it } from "vitest"; -import { extractToolMessageRefs } from "./tool-message-refs.ts"; - -describe("extractToolMessageRefs", () => { - it("extracts canonical toolResult ids", () => { - expect( - extractToolMessageRefs({ - role: "toolResult", - toolCallId: "call_1", - toolName: "shell", - }), - ).toEqual([{ id: "call_1" }]); - }); - - it("extracts snake-case tool ids from standalone tool messages", () => { - expect( - extractToolMessageRefs({ - role: "tool", - tool_call_id: "call_2", - tool_name: "shell", - }), - ).toEqual([{ id: "call_2" }]); - }); - - it("extracts assistant tool-call block ids", () => { - expect( - extractToolMessageRefs({ - role: "assistant", - content: [{ type: "toolcall", id: "call_3", name: "shell", arguments: {} }], - }), - ).toEqual([{ id: "call_3" }]); - }); - - it("extracts assistant tool-result block ids", () => { - expect( - extractToolMessageRefs({ - role: "assistant", - content: [{ type: "tool_result", tool_use_id: "call_4", name: "shell", content: "ok" }], - }), - ).toEqual([{ id: "call_4" }]); - }); - - it("ignores plain assistant and user messages", () => { - expect(extractToolMessageRefs({ role: "assistant", content: "hello" })).toEqual([]); - expect(extractToolMessageRefs({ role: "user", content: "hello" })).toEqual([]); - }); -}); diff --git a/ui/src/ui/chat/tool-message-refs.ts b/ui/src/ui/chat/tool-message-refs.ts deleted file mode 100644 index 37a62e2806fc..000000000000 --- a/ui/src/ui/chat/tool-message-refs.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Control UI chat module implements tool message refs behavior. -import { - isToolCallContentType, - isToolResultContentType, - resolveToolUseId, -} from "../../../../src/chat/tool-content.js"; -import { normalizeOptionalString } from "../string-coerce.ts"; -import { normalizeRoleForGrouping } from "./role-normalizer.ts"; - -const TOOL_NAME_FIELDS = ["toolName", "tool_name"] as const; -type ToolNameField = (typeof TOOL_NAME_FIELDS)[number]; -type ToolHistoryRecord = Record & Partial>; - -export type ToolMessageRef = { - id: string; -}; - -function asRecord(value: unknown): ToolHistoryRecord | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as ToolHistoryRecord) - : null; -} - -function addToolRef(refs: ToolMessageRef[], seen: Set, id: string | undefined) { - if (!id || seen.has(id)) { - return; - } - seen.add(id); - refs.push({ id }); -} - -function isToolLikeRole(role: unknown): boolean { - return typeof role === "string" && normalizeRoleForGrouping(role).toLowerCase() === "tool"; -} - -function hasToolName(message: ToolHistoryRecord): boolean { - return TOOL_NAME_FIELDS.some((field) => Boolean(normalizeOptionalString(message[field]))); -} - -function toolContentBlocks(message: Record): Record[] { - return Array.isArray(message.content) - ? message.content.filter( - (block): block is Record => Boolean(block) && typeof block === "object", - ) - : []; -} - -function isToolContentBlock(block: Record): boolean { - return isToolCallContentType(block.type) || isToolResultContentType(block.type); -} - -export function extractToolMessageRefs(message: unknown): ToolMessageRef[] { - const record = asRecord(message); - if (!record) { - return []; - } - - const refs: ToolMessageRef[] = []; - const seen = new Set(); - const blocks = toolContentBlocks(record); - const hasToolBlock = blocks.some(isToolContentBlock); - const topLevelToolId = resolveToolUseId(record); - const messageHasToolShape = isToolLikeRole(record.role) || hasToolName(record) || hasToolBlock; - - // Long term, chat.history should expose canonical toolRefs on UI messages so - // WebChat never infers provider/transcript spellings here. Until then, keep - // raw compatibility isolated at this tool-message boundary. - if (messageHasToolShape) { - addToolRef(refs, seen, topLevelToolId); - } - - for (const block of blocks) { - if (!isToolContentBlock(block)) { - continue; - } - addToolRef(refs, seen, resolveToolUseId(block) ?? topLevelToolId); - } - - return refs; -} diff --git a/ui/src/ui/control-ui-performance.test.ts b/ui/src/ui/control-ui-performance.test.ts deleted file mode 100644 index f9361aa226ce..000000000000 --- a/ui/src/ui/control-ui-performance.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -// Control UI tests cover control ui performance behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { EventLogEntry } from "./app-events.ts"; -import { - recordControlUiConnectTiming, - recordControlUiPerformanceEvent, - recordControlUiRenderTiming, - startControlUiResponsivenessObserver, -} from "./control-ui-performance.ts"; - -const originalPerformanceObserver = globalThis.PerformanceObserver; - -type ObserverCallback = ConstructorParameters[0]; - -function installPerformanceObserverMock(options: { - supportedEntryTypes: string[]; - observe?: (options: PerformanceObserverInit) => void; -}) { - let callback: ObserverCallback | null = null; - const disconnect = vi.fn(); - class MockPerformanceObserver { - static supportedEntryTypes = options.supportedEntryTypes; - constructor(nextCallback: ObserverCallback) { - callback = nextCallback; - } - observe(observeOptions: PerformanceObserverInit) { - options.observe?.(observeOptions); - } - disconnect() { - disconnect(); - } - } - Object.defineProperty(globalThis, "PerformanceObserver", { - configurable: true, - value: MockPerformanceObserver, - }); - return { - disconnect, - emit(entries: PerformanceEntry[]) { - callback?.( - { - getEntries: () => entries, - } as PerformanceObserverEntryList, - {} as PerformanceObserver, - ); - }, - }; -} - -function createHost() { - return { - tab: "chat" as const, - eventLog: [] as EventLogEntry[], - eventLogBuffer: [] as EventLogEntry[], - }; -} - -function requireBufferedEvent(host: ReturnType, index = 0) { - const entry = host.eventLogBuffer[index]; - if (!entry) { - throw new Error(`Expected buffered event ${index}`); - } - return entry; -} - -function payloadOf(entry: EventLogEntry): Record { - return entry.payload as Record; -} - -afterEach(() => { - vi.restoreAllMocks(); - Object.defineProperty(globalThis, "PerformanceObserver", { - configurable: true, - value: originalPerformanceObserver, - }); -}); - -describe("recordControlUiPerformanceEvent", () => { - it("keeps the performance event buffer bounded", () => { - const host = createHost(); - - for (let i = 0; i < 260; i += 1) { - recordControlUiPerformanceEvent(host, "control-ui.test", { i }, { console: false }); - } - - expect(host.eventLogBuffer).toHaveLength(250); - const [newestEvent] = host.eventLogBuffer; - const oldestEvent = host.eventLogBuffer.at(-1); - if (!newestEvent || !oldestEvent) { - throw new Error("Expected bounded performance event buffer entries"); - } - expect(newestEvent.payload).toEqual({ i: 259 }); - expect(oldestEvent.payload).toEqual({ i: 10 }); - }); -}); - -describe("recordControlUiConnectTiming", () => { - it("records safe connect phase payloads without auth material", () => { - vi.spyOn(console, "debug").mockImplementation(() => undefined); - const host = createHost(); - - recordControlUiConnectTiming(host, { - generation: 1, - phase: "request-sent", - durationMs: 42.2, - phaseDurationMs: 5.8, - hasChallenge: true, - usedFallback: false, - secureContext: true, - hasDeviceIdentity: true, - hasDevice: true, - hasAuthToken: true, - hasDeviceToken: false, - hasPassword: false, - }); - - const entry = requireBufferedEvent(host); - const payload = payloadOf(entry); - expect(entry.event).toBe("control-ui.connect"); - expect(payload).toEqual({ - generation: 1, - phase: "request-sent", - durationMs: 42, - phaseDurationMs: 6, - slow: false, - hasChallenge: true, - usedFallback: false, - secureContext: true, - hasDeviceIdentity: true, - hasDevice: true, - hasAuthToken: true, - hasDeviceToken: false, - hasPassword: false, - errorCode: undefined, - }); - expect(JSON.stringify(payload)).not.toContain("token-value"); - }); -}); - -describe("recordControlUiRenderTiming", () => { - it("records slow render timings after the current render turn", async () => { - vi.spyOn(console, "debug").mockImplementation(() => undefined); - const host = createHost(); - - recordControlUiRenderTiming(host, "chat", { durationMs: 20, messageCount: 150 }); - - expect(host.eventLogBuffer).toHaveLength(0); - await Promise.resolve(); - - expect(host.eventLogBuffer).toHaveLength(1); - const entry = requireBufferedEvent(host); - const payload = payloadOf(entry); - expect(entry.event).toBe("control-ui.render"); - expect(payload.surface).toBe("chat"); - expect(payload.durationMs).toBe(20); - expect(payload.messageCount).toBe(150); - expect(payload.slow).toBe(true); - }); - - it("skips render timings that stay within budget", async () => { - const host = createHost(); - - recordControlUiRenderTiming(host, "config", { durationMs: 4 }); - await Promise.resolve(); - - expect(host.eventLogBuffer).toHaveLength(0); - }); -}); - -describe("startControlUiResponsivenessObserver", () => { - it("records long animation frames with script attribution", () => { - const observe = vi.fn(); - const mock = installPerformanceObserverMock({ - supportedEntryTypes: ["longtask", "long-animation-frame"], - observe, - }); - const host = createHost(); - - const observer = startControlUiResponsivenessObserver(host); - mock.emit([ - { - name: "long-frame", - startTime: 12.4, - duration: 83.6, - blockingDuration: 42.2, - scripts: [ - { - duration: 12.1, - sourceURL: "http://localhost/assets/a.js?token=redacted", - }, - { - duration: 50.8, - invoker: "event-listener", - sourceURL: "http://localhost/assets/app.js?token=redacted#hash", - sourceFunctionName: "renderApp", - }, - ], - } as unknown as PerformanceEntry, - ]); - observer?.disconnect(); - - expect(observe).toHaveBeenCalledWith({ type: "long-animation-frame", buffered: true }); - expect(mock.disconnect).toHaveBeenCalledOnce(); - expect(host.eventLogBuffer).toHaveLength(1); - const entry = requireBufferedEvent(host); - const payload = payloadOf(entry); - expect(entry.event).toBe("control-ui.long-animation-frame"); - expect(payload.tab).toBe("chat"); - expect(payload.name).toBe("long-frame"); - expect(payload.startTimeMs).toBe(12); - expect(payload.durationMs).toBe(84); - expect(payload.blockingDurationMs).toBe(42); - expect(payload.scriptCount).toBe(2); - expect(payload.topScript).toEqual({ - durationMs: 51, - invoker: "event-listener", - sourceUrl: "/assets/app.js", - sourceFunctionName: "renderApp", - }); - }); - - it("falls back to long task entries when long animation frames are unavailable", () => { - const observe = vi.fn(); - const mock = installPerformanceObserverMock({ - supportedEntryTypes: ["longtask"], - observe, - }); - const host = createHost(); - - startControlUiResponsivenessObserver(host); - mock.emit([ - { - name: "self", - startTime: 5, - duration: 51, - } as unknown as PerformanceEntry, - { - name: "small", - startTime: 10, - duration: 49, - } as unknown as PerformanceEntry, - ]); - - expect(observe).toHaveBeenCalledWith({ type: "longtask", buffered: true }); - expect(host.eventLogBuffer).toHaveLength(1); - const entry = requireBufferedEvent(host); - const payload = payloadOf(entry); - expect(entry.event).toBe("control-ui.longtask"); - expect(payload.name).toBe("self"); - expect(payload.durationMs).toBe(51); - }); - - it("caps responsiveness events so gateway events stay visible", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const mock = installPerformanceObserverMock({ - supportedEntryTypes: ["longtask"], - }); - const host = createHost(); - - for (let i = 0; i < 225; i += 1) { - recordControlUiPerformanceEvent(host, "gateway.event", { i }, { console: false }); - } - - startControlUiResponsivenessObserver(host); - for (let i = 0; i < 80; i += 1) { - mock.emit([ - { - name: "self", - startTime: i, - duration: 51, - } as unknown as PerformanceEntry, - ]); - } - - expect(host.eventLogBuffer).toHaveLength(250); - const eventCounts = host.eventLogBuffer.reduce>((counts, entry) => { - counts[entry.event] = (counts[entry.event] ?? 0) + 1; - return counts; - }, {}); - expect(eventCounts).toEqual({ - "gateway.event": 200, - "control-ui.longtask": 50, - }); - }); - - it("returns null when responsiveness entries are unsupported or observe fails", () => { - installPerformanceObserverMock({ supportedEntryTypes: [] }); - expect(startControlUiResponsivenessObserver(createHost())).toBeNull(); - - installPerformanceObserverMock({ - supportedEntryTypes: ["longtask"], - observe: () => { - throw new Error("unsupported"); - }, - }); - expect(startControlUiResponsivenessObserver(createHost())).toBeNull(); - }); -}); diff --git a/ui/src/ui/control-ui-performance.ts b/ui/src/ui/control-ui-performance.ts deleted file mode 100644 index d52edc16ddd5..000000000000 --- a/ui/src/ui/control-ui-performance.ts +++ /dev/null @@ -1,391 +0,0 @@ -// Control UI module implements control ui performance behavior. -import type { EventLogEntry } from "./app-events.ts"; -import type { GatewayConnectTiming, GatewayRequestTiming } from "./gateway.ts"; -import type { Tab } from "./navigation.ts"; - -type ControlUiPerformanceHost = { - tab: Tab; - isConnected?: boolean; - eventLog?: unknown[]; - eventLogBuffer?: unknown[]; - requestUpdate?: () => void; - updateComplete?: Promise; - controlUiRefreshSeq?: number; - controlUiTabPaintSeq?: number; -}; - -export type ControlUiRefreshRun = { - seq: number; - tab: Tab; - startedAtMs: number; -}; - -const EVENT_LOG_LIMIT = 250; -const SLOW_RPC_MS = 1_000; -const SLOW_CONNECT_MS = 1_000; -const SLOW_RENDER_MS = 16; -const VERY_SLOW_RENDER_MS = 50; -const RESPONSIVENESS_ENTRY_MS = 50; -const RESPONSIVENESS_EVENT_LOG_LIMIT = 50; -const RENDER_EVENT_LOG_LIMIT = 50; - -type ControlUiResponsivenessObserver = { - disconnect: () => void; -}; - -type PerformanceObserverCtor = { - readonly supportedEntryTypes?: readonly string[]; - new (callback: PerformanceObserverCallback): PerformanceObserver; -}; - -type LongAnimationFrameScriptTiming = { - duration?: number; - invoker?: string; - sourceURL?: string; - sourceFunctionName?: string; -}; - -type ResponsivenessPerformanceEntry = PerformanceEntry & { - blockingDuration?: number; - scripts?: LongAnimationFrameScriptTiming[]; -}; - -export function controlUiNowMs(): number { - return typeof performance !== "undefined" && typeof performance.now === "function" - ? performance.now() - : Date.now(); -} - -export function roundedControlUiDurationMs(durationMs: number): number { - return Math.max(0, Math.round(durationMs)); -} - -function runAfterMicrotask(callback: () => void): void { - if (typeof queueMicrotask === "function") { - queueMicrotask(callback); - return; - } - void Promise.resolve().then(callback); -} - -function runAfterPaint(callback: () => void): void { - const raf = - typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" - ? window.requestAnimationFrame.bind(window) - : null; - if (!raf) { - runAfterMicrotask(callback); - return; - } - raf(() => raf(callback)); -} - -function logPerformanceEvent(event: string, payload: Record, warn: boolean) { - const logger = warn ? console.warn : console.debug; - if (typeof logger !== "function") { - return; - } - logger(`[openclaw] ${event}`, payload); -} - -export function recordControlUiPerformanceEvent( - host: ControlUiPerformanceHost, - event: string, - payload: Record, - opts?: { warn?: boolean; console?: boolean; maxBufferedEventsForType?: number }, -) { - const entry: EventLogEntry = { ts: Date.now(), event, payload }; - if (Array.isArray(host.eventLogBuffer)) { - const existingBuffer = - typeof opts?.maxBufferedEventsForType === "number" - ? keepLatestBufferedEventsForType( - host.eventLogBuffer, - event, - Math.max(0, opts.maxBufferedEventsForType - 1), - ) - : host.eventLogBuffer; - host.eventLogBuffer = [entry, ...existingBuffer].slice(0, EVENT_LOG_LIMIT); - if (host.tab === "debug" || host.tab === "overview") { - host.eventLog = host.eventLogBuffer; - } - } - if (opts?.console === false) { - return; - } - logPerformanceEvent(event, payload, opts?.warn === true); -} - -function keepLatestBufferedEventsForType( - entries: unknown[], - event: string, - maxExistingForType: number, -): unknown[] { - let keptForType = 0; - return entries.filter((entry) => { - if ( - !entry || - typeof entry !== "object" || - !("event" in entry) || - (entry as { event?: unknown }).event !== event - ) { - return true; - } - keptForType += 1; - return keptForType <= maxExistingForType; - }); -} - -export function scheduleControlUiTabVisibleTiming( - host: ControlUiPerformanceHost, - previousTab: Tab, - tab: Tab, -) { - const seq = (host.controlUiTabPaintSeq ?? 0) + 1; - host.controlUiTabPaintSeq = seq; - const startedAtMs = controlUiNowMs(); - host.requestUpdate?.(); - - const record = () => { - if (host.isConnected === false || host.controlUiTabPaintSeq !== seq || host.tab !== tab) { - return; - } - recordControlUiPerformanceEvent(host, "control-ui.tab.visible", { - previousTab, - tab, - durationMs: roundedControlUiDurationMs(controlUiNowMs() - startedAtMs), - }); - }; - - void Promise.resolve(host.updateComplete) - .catch(() => undefined) - .then(() => runAfterPaint(record)); -} - -export function scheduleControlUiAfterPaint( - host: Pick, - callback: () => void, -) { - void Promise.resolve(host.updateComplete) - .catch(() => undefined) - .then(() => runAfterPaint(callback)); -} - -export function beginControlUiRefresh( - host: ControlUiPerformanceHost, - tab: Tab, -): ControlUiRefreshRun { - const seq = (host.controlUiRefreshSeq ?? 0) + 1; - host.controlUiRefreshSeq = seq; - const run = { seq, tab, startedAtMs: controlUiNowMs() }; - recordControlUiPerformanceEvent( - host, - "control-ui.refresh", - { tab, phase: "start" }, - { console: false }, - ); - return run; -} - -export function isCurrentControlUiRefresh( - host: ControlUiPerformanceHost, - run: ControlUiRefreshRun, -): boolean { - return host.controlUiRefreshSeq === run.seq && host.tab === run.tab; -} - -export function finishControlUiRefresh( - host: ControlUiPerformanceHost, - run: ControlUiRefreshRun, - status: "ok" | "error", -) { - if (!isCurrentControlUiRefresh(host, run)) { - return; - } - recordControlUiPerformanceEvent( - host, - "control-ui.refresh", - { - tab: run.tab, - phase: "end", - status, - durationMs: roundedControlUiDurationMs(controlUiNowMs() - run.startedAtMs), - }, - { console: false }, - ); -} - -export function recordControlUiRpcTiming( - host: ControlUiPerformanceHost, - timing: GatewayRequestTiming, -) { - const durationMs = roundedControlUiDurationMs(timing.durationMs); - const warn = !timing.ok || durationMs >= SLOW_RPC_MS; - recordControlUiPerformanceEvent( - host, - "control-ui.rpc", - { - id: timing.id, - method: timing.method, - ok: timing.ok, - durationMs, - slow: durationMs >= SLOW_RPC_MS, - errorCode: timing.errorCode, - }, - { warn }, - ); -} - -export function recordControlUiConnectTiming( - host: ControlUiPerformanceHost, - timing: GatewayConnectTiming, -) { - const durationMs = roundedControlUiDurationMs(timing.durationMs); - const phaseDurationMs = roundedControlUiDurationMs(timing.phaseDurationMs); - const slow = durationMs >= SLOW_CONNECT_MS; - recordControlUiPerformanceEvent( - host, - "control-ui.connect", - { - generation: timing.generation, - phase: timing.phase, - durationMs, - phaseDurationMs, - slow, - hasChallenge: timing.hasChallenge, - usedFallback: timing.usedFallback, - secureContext: timing.secureContext, - hasDeviceIdentity: timing.hasDeviceIdentity, - hasDevice: timing.hasDevice, - hasAuthToken: timing.hasAuthToken, - hasDeviceToken: timing.hasDeviceToken, - hasPassword: timing.hasPassword, - errorCode: timing.errorCode, - }, - { warn: timing.phase === "failed" || slow, maxBufferedEventsForType: 40 }, - ); -} - -export function recordControlUiRenderTiming( - host: ControlUiPerformanceHost, - surface: string, - payload: Record, -) { - const durationMs = - typeof payload.durationMs === "number" - ? roundedControlUiDurationMs(payload.durationMs) - : undefined; - if (durationMs == null || durationMs < SLOW_RENDER_MS) { - return; - } - runAfterMicrotask(() => { - recordControlUiPerformanceEvent( - host, - "control-ui.render", - { - surface, - ...payload, - durationMs, - slow: true, - }, - { - warn: durationMs >= VERY_SLOW_RENDER_MS, - maxBufferedEventsForType: RENDER_EVENT_LOG_LIMIT, - }, - ); - }); -} - -function getPerformanceObserverCtor(): PerformanceObserverCtor | null { - const observer = globalThis.PerformanceObserver; - return typeof observer === "function" ? (observer as PerformanceObserverCtor) : null; -} - -function normalizeScriptSourceUrl(sourceUrl: string | undefined): string | undefined { - if (!sourceUrl) { - return undefined; - } - try { - const url = new URL(sourceUrl, globalThis.location?.href); - return url.pathname; - } catch { - return sourceUrl.split(/[?#]/, 1)[0]; - } -} - -function getTopLongAnimationFrameScript( - scripts: LongAnimationFrameScriptTiming[] | undefined, -): Record | undefined { - if (!Array.isArray(scripts) || scripts.length === 0) { - return undefined; - } - let topScript: LongAnimationFrameScriptTiming | undefined; - for (const script of scripts) { - if (!topScript || (script.duration ?? 0) > (topScript.duration ?? 0)) { - topScript = script; - } - } - if (!topScript) { - return undefined; - } - return { - durationMs: roundedControlUiDurationMs(topScript.duration ?? 0), - invoker: topScript.invoker, - sourceUrl: normalizeScriptSourceUrl(topScript.sourceURL), - sourceFunctionName: topScript.sourceFunctionName, - }; -} - -function recordResponsivenessEntry( - host: ControlUiPerformanceHost, - entryType: "long-animation-frame" | "longtask", - entry: ResponsivenessPerformanceEntry, -) { - const durationMs = roundedControlUiDurationMs(entry.duration); - if (durationMs < RESPONSIVENESS_ENTRY_MS) { - return; - } - recordControlUiPerformanceEvent( - host, - `control-ui.${entryType}`, - { - tab: host.tab, - name: entry.name, - startTimeMs: roundedControlUiDurationMs(entry.startTime), - durationMs, - blockingDurationMs: - typeof entry.blockingDuration === "number" - ? roundedControlUiDurationMs(entry.blockingDuration) - : undefined, - scriptCount: Array.isArray(entry.scripts) ? entry.scripts.length : undefined, - topScript: getTopLongAnimationFrameScript(entry.scripts), - }, - { warn: true, maxBufferedEventsForType: RESPONSIVENESS_EVENT_LOG_LIMIT }, - ); -} - -export function startControlUiResponsivenessObserver( - host: ControlUiPerformanceHost, -): ControlUiResponsivenessObserver | null { - const Observer = getPerformanceObserverCtor(); - const supportedEntryTypes = Observer?.supportedEntryTypes ?? []; - const entryType = supportedEntryTypes.includes("long-animation-frame") - ? "long-animation-frame" - : supportedEntryTypes.includes("longtask") - ? "longtask" - : null; - if (!Observer || !entryType) { - return null; - } - - const observer = new Observer((list) => { - for (const entry of list.getEntries() as ResponsivenessPerformanceEntry[]) { - recordResponsivenessEntry(host, entryType, entry); - } - }); - try { - observer.observe({ type: entryType, buffered: true }); - } catch { - return null; - } - return observer; -} diff --git a/ui/src/ui/controllers/agent-identity.ts b/ui/src/ui/controllers/agent-identity.ts deleted file mode 100644 index c3fc35ed6085..000000000000 --- a/ui/src/ui/controllers/agent-identity.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Control UI controller manages agent identity gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { AgentIdentityResult } from "../types.ts"; - -export type AgentIdentityState = { - client: GatewayBrowserClient | null; - connected: boolean; - agentIdentityLoading: boolean; - agentIdentityError: string | null; - agentIdentityById: Record; -}; - -export async function loadAgentIdentity(state: AgentIdentityState, agentId: string) { - if (!state.client || !state.connected || state.agentIdentityLoading) { - return; - } - if (state.agentIdentityById[agentId]) { - return; - } - state.agentIdentityLoading = true; - state.agentIdentityError = null; - try { - const res = await state.client.request("agent.identity.get", { - agentId, - }); - if (res) { - state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res }; - } - } catch (err) { - state.agentIdentityError = String(err); - } finally { - state.agentIdentityLoading = false; - } -} - -export async function loadAgentIdentities(state: AgentIdentityState, agentIds: string[]) { - if (!state.client || !state.connected || state.agentIdentityLoading) { - return; - } - const missing = agentIds.filter((id) => !state.agentIdentityById[id]); - if (missing.length === 0) { - return; - } - state.agentIdentityLoading = true; - state.agentIdentityError = null; - try { - for (const agentId of missing) { - const res = await state.client.request("agent.identity.get", { - agentId, - }); - if (res) { - state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res }; - } - } - } catch (err) { - state.agentIdentityError = String(err); - } finally { - state.agentIdentityLoading = false; - } -} diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts deleted file mode 100644 index 698e714bbbd2..000000000000 --- a/ui/src/ui/controllers/agents.ts +++ /dev/null @@ -1,263 +0,0 @@ -// Control UI controller manages agents gateway state. -import { - normalizeChatModelOverrideValue, - resolvePreferredServerChatModelValue, -} from "../chat-model-ref.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { resolveAgentIdFromSessionKey } from "../session-key.ts"; -import type { - AgentsListResult, - ChatModelOverride, - ModelCatalogEntry, - SessionsListResult, - ToolsCatalogResult, - ToolsEffectiveResult, -} from "../types.ts"; -import { saveConfig, stageDefaultAgentConfigEntry } from "./config.ts"; -import type { ConfigState } from "./config.ts"; -import { - formatMissingOperatorReadScopeMessage, - isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; - -export type AgentsState = { - client: GatewayBrowserClient | null; - connected: boolean; - agentsLoading: boolean; - agentsError: string | null; - agentsList: AgentsListResult | null; - agentsSelectedId: string | null; - toolsCatalogLoading: boolean; - toolsCatalogLoadingAgentId?: string | null; - toolsCatalogError: string | null; - toolsCatalogResult: ToolsCatalogResult | null; - toolsEffectiveLoading: boolean; - toolsEffectiveLoadingKey?: string | null; - toolsEffectiveResultKey?: string | null; - toolsEffectiveError: string | null; - toolsEffectiveResult: ToolsEffectiveResult | null; - sessionKey?: string; - sessionsResult?: SessionsListResult | null; - chatModelOverrides?: Record; - chatModelCatalog?: ModelCatalogEntry[]; - agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; -}; - -export type AgentsConfigSaveState = AgentsState & ConfigState; - -function hasSelectedAgentMismatch(state: AgentsState, agentId: string): boolean { - return Boolean(state.agentsSelectedId && state.agentsSelectedId !== agentId); -} - -function resolveToolsErrorMessage( - err: unknown, - target: "tools catalog" | "effective tools", -): string { - return isMissingOperatorReadScopeError(err) - ? formatMissingOperatorReadScopeMessage(target) - : String(err); -} - -export async function loadAgents(state: AgentsState) { - if (!state.client || !state.connected || state.agentsLoading) { - return; - } - state.agentsLoading = true; - state.agentsError = null; - try { - const res = await state.client.request("agents.list", {}); - if (res) { - state.agentsList = res; - const selected = state.agentsSelectedId; - if (!selected || !res.agents.some((entry) => entry.id === selected)) { - state.agentsSelectedId = res.defaultId ?? res.agents[0]?.id ?? null; - } - } - } catch (err) { - if (isMissingOperatorReadScopeError(err)) { - state.agentsList = null; - state.agentsError = formatMissingOperatorReadScopeMessage("agent list"); - } else { - state.agentsError = String(err); - } - } finally { - state.agentsLoading = false; - } -} - -export async function loadToolsCatalog(state: AgentsState, agentId: string) { - const resolvedAgentId = agentId.trim(); - if ( - !state.client || - !state.connected || - !resolvedAgentId || - (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) - ) { - return; - } - const shouldIgnoreResponse = () => - state.toolsCatalogLoadingAgentId !== resolvedAgentId || - hasSelectedAgentMismatch(state, resolvedAgentId); - state.toolsCatalogLoading = true; - state.toolsCatalogLoadingAgentId = resolvedAgentId; - state.toolsCatalogError = null; - state.toolsCatalogResult = null; - try { - const res = await state.client.request("tools.catalog", { - agentId: resolvedAgentId, - includePlugins: true, - }); - if (shouldIgnoreResponse()) { - return; - } - state.toolsCatalogResult = res; - } catch (err) { - if (shouldIgnoreResponse()) { - return; - } - state.toolsCatalogError = resolveToolsErrorMessage(err, "tools catalog"); - } finally { - if (state.toolsCatalogLoadingAgentId === resolvedAgentId) { - state.toolsCatalogLoadingAgentId = null; - state.toolsCatalogLoading = false; - } - } -} - -export async function loadToolsEffective( - state: AgentsState, - params: { agentId: string; sessionKey: string }, -) { - const resolvedAgentId = params.agentId.trim(); - const resolvedSessionKey = params.sessionKey.trim(); - const requestKey = buildToolsEffectiveRequestKey(state, { - agentId: resolvedAgentId, - sessionKey: resolvedSessionKey, - }); - if ( - !state.client || - !state.connected || - !resolvedAgentId || - !resolvedSessionKey || - (state.toolsEffectiveLoading && state.toolsEffectiveLoadingKey === requestKey) - ) { - return; - } - const shouldIgnoreResponse = () => - state.toolsEffectiveLoadingKey !== requestKey || - hasSelectedAgentMismatch(state, resolvedAgentId); - state.toolsEffectiveLoading = true; - state.toolsEffectiveLoadingKey = requestKey; - state.toolsEffectiveResultKey = null; - state.toolsEffectiveError = null; - state.toolsEffectiveResult = null; - try { - const res = await state.client.request("tools.effective", { - agentId: resolvedAgentId, - sessionKey: resolvedSessionKey, - }); - if (shouldIgnoreResponse()) { - return; - } - state.toolsEffectiveResultKey = requestKey; - state.toolsEffectiveResult = res; - } catch (err) { - if (shouldIgnoreResponse()) { - return; - } - state.toolsEffectiveError = resolveToolsErrorMessage(err, "effective tools"); - } finally { - if (state.toolsEffectiveLoadingKey === requestKey) { - state.toolsEffectiveLoadingKey = null; - state.toolsEffectiveLoading = false; - } - } -} - -export function resetToolsEffectiveState(state: AgentsState) { - state.toolsEffectiveResult = null; - state.toolsEffectiveResultKey = null; - state.toolsEffectiveError = null; - state.toolsEffectiveLoading = false; - state.toolsEffectiveLoadingKey = null; -} - -export function buildToolsEffectiveRequestKey( - state: Pick, - params: { agentId: string; sessionKey: string }, -): string { - const resolvedAgentId = params.agentId.trim(); - const resolvedSessionKey = params.sessionKey.trim(); - const modelKey = resolveEffectiveToolsModelKey(state, resolvedSessionKey); - return `${resolvedAgentId}:${resolvedSessionKey}:model=${modelKey || "(default)"}`; -} - -export function refreshVisibleToolsEffectiveForCurrentSession( - state: AgentsState, -): Promise | undefined { - const resolvedSessionKey = state.sessionKey?.trim(); - if (!resolvedSessionKey || state.agentsPanel !== "tools" || !state.agentsSelectedId) { - return undefined; - } - const sessionAgentId = resolveAgentIdFromSessionKey(resolvedSessionKey); - if (!sessionAgentId || state.agentsSelectedId !== sessionAgentId) { - return undefined; - } - return loadToolsEffective(state, { - agentId: sessionAgentId, - sessionKey: resolvedSessionKey, - }); -} - -function resolveEffectiveToolsModelKey( - state: Pick, - sessionKey: string, -): string { - const resolvedSessionKey = sessionKey.trim(); - if (!resolvedSessionKey) { - return ""; - } - const catalog = state.chatModelCatalog ?? []; - const cachedOverride = state.chatModelOverrides?.[resolvedSessionKey]; - const defaults = state.sessionsResult?.defaults; - const defaultModel = resolvePreferredServerChatModelValue( - defaults?.model, - defaults?.modelProvider, - catalog, - ); - if (cachedOverride === null) { - return defaultModel; - } - if (cachedOverride) { - return normalizeChatModelOverrideValue(cachedOverride, catalog); - } - const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === resolvedSessionKey); - if (activeRow?.model) { - return resolvePreferredServerChatModelValue(activeRow.model, activeRow.modelProvider, catalog); - } - return defaultModel; -} - -export async function saveAgentsConfig(state: AgentsConfigSaveState) { - const selectedBefore = state.agentsSelectedId; - await saveConfig(state); - await loadAgents(state); - if (selectedBefore && state.agentsList?.agents.some((entry) => entry.id === selectedBefore)) { - state.agentsSelectedId = selectedBefore; - } -} - -export async function setDefaultAgent( - state: AgentsConfigSaveState, - agentId: string, -): Promise { - const hadPendingConfigDraft = state.configFormDirty; - // Set Default is a one-click action on a clean draft, but saveConfig serializes the - // whole form. If other edits were already dirty, keep them staged for the explicit - // Save button instead of committing unrelated pending config changes. - if (stageDefaultAgentConfigEntry(state, agentId)) { - if (!hadPendingConfigDraft && state.configFormDirty) { - await saveAgentsConfig(state); - } - } -} diff --git a/ui/src/ui/controllers/assistant-identity.test.ts b/ui/src/ui/controllers/assistant-identity.test.ts deleted file mode 100644 index d6619a344e3a..000000000000 --- a/ui/src/ui/controllers/assistant-identity.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -// @vitest-environment node -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createStorageMock } from "../../test-helpers/storage.ts"; -import { loadLocalAssistantIdentity } from "../storage.ts"; -import { loadAssistantIdentity, setAssistantAvatarOverride } from "./assistant-identity.ts"; - -function createDeferred() { - let resolve: ((value: T) => void) | undefined; - const promise = new Promise((next) => { - resolve = next; - }); - if (!resolve) { - throw new Error("Expected deferred resolver to be initialized"); - } - return { promise, resolve }; -} - -describe("loadAssistantIdentity", () => { - beforeEach(() => { - vi.stubGlobal("localStorage", createStorageMock()); - }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("ignores stale identity responses after the active session changes", async () => { - const first = createDeferred(); - const second = createDeferred(); - const request = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); - const state: Parameters[0] = { - client: { request } as never, - connected: true, - sessionKey: "agent:main:main", - assistantName: "Main", - assistantAvatar: null, - assistantAgentId: "main", - }; - - const firstLoad = loadAssistantIdentity(state); - state.sessionKey = "agent:worker:main"; - const secondLoad = loadAssistantIdentity(state); - - second.resolve({ agentId: "worker", name: "Worker", avatar: "W" }); - await secondLoad; - expect(state.assistantName).toBe("Worker"); - expect(state.assistantAgentId).toBe("worker"); - - first.resolve({ agentId: "main", name: "Main After", avatar: "M" }); - await firstLoad; - - expect(state.assistantName).toBe("Worker"); - expect(state.assistantAvatar).toBe("W"); - expect(state.assistantAgentId).toBe("worker"); - expect(request).toHaveBeenNthCalledWith(1, "agent.identity.get", { - sessionKey: "agent:main:main", - }); - expect(request).toHaveBeenNthCalledWith(2, "agent.identity.get", { - sessionKey: "agent:worker:main", - }); - }); - - it("applies a scoped identity request while its expected UI session remains active", async () => { - const request = vi.fn().mockResolvedValue({ - agentId: "alpha", - name: "Alpha", - avatar: "A", - }); - const state: Parameters[0] = { - client: { request } as never, - connected: true, - sessionKey: "main", - assistantName: "Worker", - assistantAvatar: null, - assistantAgentId: "worker", - }; - - await loadAssistantIdentity(state, { - sessionKey: "agent:alpha:main", - expectedSessionKey: "main", - }); - - expect(state.assistantName).toBe("Alpha"); - expect(state.assistantAvatar).toBe("A"); - expect(state.assistantAgentId).toBe("alpha"); - expect(request).toHaveBeenCalledWith("agent.identity.get", { - sessionKey: "agent:alpha:main", - }); - }); -}); - -describe("setAssistantAvatarOverride", () => { - beforeEach(() => { - vi.stubGlobal("localStorage", createStorageMock()); - }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("persists the assistant avatar locally and mirrors the user avatar pattern", () => { - const state: Parameters[0] = {}; - - setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy", "main"); - - expect(state.assistantAvatar).toBe("data:image/png;base64,YXZhdGFy"); - expect(state.assistantAvatarSource).toBe("data:image/png;base64,YXZhdGFy"); - expect(state.assistantAvatarStatus).toBe("data"); - expect(state.assistantAvatarReason).toBeNull(); - expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe( - "data:image/png;base64,YXZhdGFy", - ); - }); - - it("clears the local override", () => { - const state: Parameters[0] = { - assistantAvatar: "data:image/png;base64,YXZhdGFy", - assistantAvatarSource: "data:image/png;base64,YXZhdGFy", - assistantAvatarStatus: "data", - }; - setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy", "main"); - - setAssistantAvatarOverride(state, null, "main"); - - expect(state.assistantAvatar).toBeNull(); - expect(state.assistantAvatarSource).toBeNull(); - expect(state.assistantAvatarStatus).toBeNull(); - expect(state.assistantAvatarReason).toBeNull(); - expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBeNull(); - }); - - it("keeps assistant avatar overrides isolated by agent", () => { - setAssistantAvatarOverride({}, "data:image/png;base64,bWFpbg==", "main"); - setAssistantAvatarOverride({}, "data:image/png;base64,d29ya2Vy", "worker"); - - expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe( - "data:image/png;base64,bWFpbg==", - ); - expect(loadLocalAssistantIdentity({ agentId: "worker" }).avatar).toBe( - "data:image/png;base64,d29ya2Vy", - ); - - setAssistantAvatarOverride({}, null, "worker"); - - expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe( - "data:image/png;base64,bWFpbg==", - ); - expect(loadLocalAssistantIdentity({ agentId: "worker" }).avatar).toBeNull(); - }); - - it("migrates the legacy global override to the first loaded agent", () => { - localStorage.setItem( - "openclaw.control.assistant.v1", - JSON.stringify({ avatar: "data:image/png;base64,bGVnYWN5" }), - ); - - expect(loadLocalAssistantIdentity({ agentId: "main" }).avatar).toBe( - "data:image/png;base64,bGVnYWN5", - ); - expect(loadLocalAssistantIdentity({ agentId: "worker" }).avatar).toBeNull(); - }); - - it("supports prototype-like agent IDs without inherited avatar values", () => { - setAssistantAvatarOverride({}, "data:image/png;base64,Y29uc3RydWN0b3I=", "constructor"); - setAssistantAvatarOverride({}, "data:image/png;base64,cHJvdG8=", "__proto__"); - - expect(loadLocalAssistantIdentity({ agentId: "constructor" }).avatar).toBe( - "data:image/png;base64,Y29uc3RydWN0b3I=", - ); - expect(loadLocalAssistantIdentity({ agentId: "__proto__" }).avatar).toBe( - "data:image/png;base64,cHJvdG8=", - ); - expect(loadLocalAssistantIdentity({ agentId: "toString" }).avatar).toBeNull(); - }); -}); diff --git a/ui/src/ui/controllers/assistant-identity.ts b/ui/src/ui/controllers/assistant-identity.ts deleted file mode 100644 index c961acd03d68..000000000000 --- a/ui/src/ui/controllers/assistant-identity.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Control UI controller manages assistant identity gateway state. -import { normalizeAssistantIdentity } from "../assistant-identity.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { loadLocalAssistantIdentity, saveLocalAssistantIdentity } from "../storage.ts"; - -export type AssistantIdentityState = { - client: GatewayBrowserClient | null; - connected: boolean; - sessionKey: string; - assistantName: string; - assistantAvatar: string | null; - assistantAvatarSource?: string | null; - assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; - assistantAvatarReason?: string | null; - assistantAgentId: string | null; -}; - -export type AssistantAvatarOverrideState = { - assistantAvatar?: string | null; - assistantAvatarSource?: string | null; - assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; - assistantAvatarReason?: string | null; -}; - -const assistantIdentityRequestVersions = new WeakMap(); - -function beginAssistantIdentityRequest(state: AssistantIdentityState): number { - const key = state as object; - const nextVersion = (assistantIdentityRequestVersions.get(key) ?? 0) + 1; - assistantIdentityRequestVersions.set(key, nextVersion); - return nextVersion; -} - -function shouldApplyAssistantIdentityResult( - state: AssistantIdentityState, - version: number, - sessionKey: string, -): boolean { - return ( - assistantIdentityRequestVersions.get(state as object) === version && - state.sessionKey.trim() === sessionKey - ); -} - -export async function loadAssistantIdentity( - state: AssistantIdentityState, - opts?: { sessionKey?: string; expectedSessionKey?: string }, -) { - if (!state.client || !state.connected) { - return; - } - const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim(); - const expectedSessionKey = opts?.expectedSessionKey?.trim() || sessionKey; - const params = sessionKey ? { sessionKey } : {}; - const requestVersion = beginAssistantIdentityRequest(state); - try { - const res = await state.client.request("agent.identity.get", params); - if (!shouldApplyAssistantIdentityResult(state, requestVersion, expectedSessionKey)) { - return; - } - if (!res) { - return; - } - const normalized = normalizeAssistantIdentity(res); - state.assistantName = normalized.name; - state.assistantAvatar = normalized.avatar; - state.assistantAvatarSource = normalized.avatarSource ?? null; - state.assistantAvatarStatus = normalized.avatarStatus ?? null; - state.assistantAvatarReason = normalized.avatarReason ?? null; - state.assistantAgentId = normalized.agentId ?? null; - const localAvatar = loadLocalAssistantIdentity({ - agentId: state.assistantAgentId, - }).avatar; - if (localAvatar) { - state.assistantAvatar = localAvatar; - state.assistantAvatarSource = localAvatar; - state.assistantAvatarStatus = "data"; - state.assistantAvatarReason = null; - } - } catch { - // Ignore errors; keep last known identity. - } -} - -export function setAssistantAvatarOverride( - state: AssistantAvatarOverrideState, - avatar: string | null, - agentId?: string | null, -) { - saveLocalAssistantIdentity({ avatar, agentId }); - if (avatar) { - state.assistantAvatar = avatar; - state.assistantAvatarSource = avatar; - state.assistantAvatarStatus = "data"; - state.assistantAvatarReason = null; - } else { - state.assistantAvatar = null; - state.assistantAvatarSource = null; - state.assistantAvatarStatus = null; - state.assistantAvatarReason = null; - } -} diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts deleted file mode 100644 index 991a77b6bbde..000000000000 --- a/ui/src/ui/controllers/channels.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Control UI controller manages channels gateway state. -import type { ChannelsStatusSnapshot } from "../types.ts"; -import type { ChannelsState } from "./channels.types.ts"; -import { - formatMissingOperatorReadScopeMessage, - isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; - -export type { ChannelsState }; - -type LoadChannelsOptions = { - softTimeoutMs?: number; -}; - -function delay(ms: number): Promise<"timeout"> { - return new Promise((resolve) => { - setTimeout(() => resolve("timeout"), ms); - }); -} - -export async function loadChannels( - state: ChannelsState, - probe: boolean, - options: LoadChannelsOptions = {}, -) { - if (!state.client || !state.connected) { - return; - } - if (state.channelsLoading && (!state.channelsLoadingProbe || probe)) { - return; - } - const refreshSeq = (state.channelsRefreshSeq ?? 0) + 1; - state.channelsRefreshSeq = refreshSeq; - state.channelsLoading = true; - state.channelsLoadingProbe = probe; - state.channelsError = null; - const refresh = (async () => { - try { - const res = await state.client!.request("channels.status", { - probe, - timeoutMs: 8000, - }); - if (state.channelsRefreshSeq !== refreshSeq) { - return; - } - state.channelsSnapshot = res; - state.channelsLastSuccess = Date.now(); - } catch (err) { - if (state.channelsRefreshSeq !== refreshSeq) { - return; - } - if (isMissingOperatorReadScopeError(err)) { - state.channelsSnapshot = null; - state.channelsError = formatMissingOperatorReadScopeMessage("channel status"); - } else { - state.channelsError = String(err); - } - } finally { - if (state.channelsRefreshSeq === refreshSeq) { - state.channelsLoading = false; - state.channelsLoadingProbe = null; - } - } - })(); - - const softTimeoutMs = options.softTimeoutMs; - if (typeof softTimeoutMs === "number" && softTimeoutMs > 0) { - const outcome = await Promise.race([refresh.then(() => "done" as const), delay(softTimeoutMs)]); - if (outcome === "timeout") { - return; - } - return; - } - await refresh; -} - -export async function startWhatsAppLogin(state: ChannelsState, force: boolean) { - if (!state.client || !state.connected || state.whatsappBusy) { - return; - } - state.whatsappBusy = true; - try { - const res = await state.client.request<{ - message?: string; - qrDataUrl?: string; - connected?: boolean; - }>("web.login.start", { - force, - timeoutMs: 30000, - }); - state.whatsappLoginMessage = res.message ?? null; - state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; - state.whatsappLoginConnected = typeof res.connected === "boolean" ? res.connected : null; - } catch (err) { - state.whatsappLoginMessage = String(err); - state.whatsappLoginQrDataUrl = null; - state.whatsappLoginConnected = null; - } finally { - state.whatsappBusy = false; - } -} - -export async function waitWhatsAppLogin(state: ChannelsState) { - if (!state.client || !state.connected || state.whatsappBusy) { - return; - } - state.whatsappBusy = true; - try { - const res = await state.client.request<{ - message?: string; - connected?: boolean; - qrDataUrl?: string; - }>("web.login.wait", { - timeoutMs: 120000, - currentQrDataUrl: state.whatsappLoginQrDataUrl ?? undefined, - }); - state.whatsappLoginMessage = res.message ?? null; - state.whatsappLoginConnected = res.connected ?? null; - if (res.qrDataUrl) { - state.whatsappLoginQrDataUrl = res.qrDataUrl; - } else if (res.connected) { - state.whatsappLoginQrDataUrl = null; - } - } catch (err) { - state.whatsappLoginMessage = String(err); - state.whatsappLoginConnected = null; - } finally { - state.whatsappBusy = false; - } -} - -export async function logoutWhatsApp(state: ChannelsState) { - if (!state.client || !state.connected || state.whatsappBusy) { - return; - } - state.whatsappBusy = true; - try { - await state.client.request("channels.logout", { channel: "whatsapp" }); - state.whatsappLoginMessage = "Logged out."; - state.whatsappLoginQrDataUrl = null; - state.whatsappLoginConnected = null; - } catch (err) { - state.whatsappLoginMessage = String(err); - } finally { - state.whatsappBusy = false; - } -} diff --git a/ui/src/ui/controllers/channels.types.ts b/ui/src/ui/controllers/channels.types.ts deleted file mode 100644 index 29dde7b1f979..000000000000 --- a/ui/src/ui/controllers/channels.types.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Control UI type declarations define channels contracts. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { ChannelsStatusSnapshot } from "../types.ts"; - -export type ChannelsState = { - client: GatewayBrowserClient | null; - connected: boolean; - channelsLoading: boolean; - channelsLoadingProbe?: boolean | null; - channelsRefreshSeq?: number; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsError: string | null; - channelsLastSuccess: number | null; - whatsappLoginMessage: string | null; - whatsappLoginQrDataUrl: string | null; - whatsappLoginConnected: boolean | null; - whatsappBusy: boolean; -}; diff --git a/ui/src/ui/controllers/config/form-coerce.ts b/ui/src/ui/controllers/config/form-coerce.ts deleted file mode 100644 index 09bdbfc0dd90..000000000000 --- a/ui/src/ui/controllers/config/form-coerce.ts +++ /dev/null @@ -1,170 +0,0 @@ -// Control UI controller manages form coerce gateway state. -import { schemaType, type JsonSchema } from "../../views/config-form.shared.ts"; - -function coerceNumberString(value: string, integer: boolean): number | undefined | string { - const trimmed = value.trim(); - if (trimmed === "") { - return undefined; - } - const parsed = Number(trimmed); - if (!Number.isFinite(parsed)) { - return value; - } - if (integer && !Number.isInteger(parsed)) { - return value; - } - return parsed; -} - -function coerceBooleanString(value: string): boolean | string { - const trimmed = value.trim(); - if (trimmed === "true") { - return true; - } - if (trimmed === "false") { - return false; - } - return value; -} - -/** - * Walk a form value tree alongside its JSON Schema and coerce string values - * to their schema-defined types (number, boolean). - * - * HTML `` elements always produce string `.value` properties. Even - * though the form rendering code converts values correctly for most paths, - * some interactions (map-field repopulation, re-renders, paste, etc.) can - * leak raw strings into the config form state. This utility acts as a - * safety net before serialization so that `config.set` always receives - * correctly typed JSON. - */ -export function coerceFormValues(value: unknown, schema: JsonSchema): unknown { - if (value === null || value === undefined) { - return value; - } - - if (schema.allOf && schema.allOf.length > 0) { - let next: unknown = value; - for (const segment of schema.allOf) { - next = coerceFormValues(next, segment); - } - return next; - } - - const type = schemaType(schema); - - // Handle anyOf/oneOf — try to match the value against a variant - if (schema.anyOf || schema.oneOf) { - const variants = (schema.anyOf ?? schema.oneOf ?? []).filter( - (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))), - ); - - if (variants.length === 1) { - return coerceFormValues(value, variants[0]); - } - - // Try number/boolean coercion for string values - if (typeof value === "string") { - for (const variant of variants) { - const variantType = schemaType(variant); - if (variantType === "number" || variantType === "integer") { - const coerced = coerceNumberString(value, variantType === "integer"); - if (coerced === undefined || typeof coerced === "number") { - return coerced; - } - } - if (variantType === "boolean") { - const coerced = coerceBooleanString(value); - if (typeof coerced === "boolean") { - return coerced; - } - } - } - } - - // For non-string values (objects, arrays), try to recurse into matching variant - for (const variant of variants) { - const variantType = schemaType(variant); - if (variantType === "object" && typeof value === "object" && !Array.isArray(value)) { - return coerceFormValues(value, variant); - } - if (variantType === "array" && Array.isArray(value)) { - return coerceFormValues(value, variant); - } - } - - return value; - } - - if (type === "number" || type === "integer") { - if (typeof value === "string") { - const coerced = coerceNumberString(value, type === "integer"); - if (coerced === undefined || typeof coerced === "number") { - return coerced; - } - } - return value; - } - - if (type === "boolean") { - if (typeof value === "string") { - const coerced = coerceBooleanString(value); - if (typeof coerced === "boolean") { - return coerced; - } - } - return value; - } - - if (type === "string") { - // Empty string with minLength constraint should be treated as unset - // This handles cases like baseUrl where schema is z.string().min(1) - if (typeof value === "string" && value.length === 0 && schema.minLength) { - return undefined; - } - return value; - } - - if (type === "object") { - if (typeof value !== "object" || Array.isArray(value)) { - return value; - } - const obj = value as Record; - const props = schema.properties ?? {}; - const additional = - schema.additionalProperties && typeof schema.additionalProperties === "object" - ? schema.additionalProperties - : null; - const result: Record = {}; - for (const [key, val] of Object.entries(obj)) { - const propSchema = props[key] ?? additional; - const coerced = propSchema ? coerceFormValues(val, propSchema) : val; - // Omit undefined — "clear field = unset" for optional properties - if (coerced !== undefined) { - result[key] = coerced; - } - } - return result; - } - - if (type === "array") { - if (!Array.isArray(value)) { - return value; - } - if (Array.isArray(schema.items)) { - // Tuple form: each index has its own schema - const tuple = schema.items; - return value.map((item, i) => { - const s = i < tuple.length ? tuple[i] : undefined; - return s ? coerceFormValues(item, s) : item; - }); - } - const itemsSchema = schema.items; - if (!itemsSchema) { - return value; - } - return value.map((item) => coerceFormValues(item, itemsSchema)).filter((v) => v !== undefined); - } - - return value; -} diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts deleted file mode 100644 index c241261b033f..000000000000 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ /dev/null @@ -1,629 +0,0 @@ -/* @vitest-environment jsdom */ - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - CONTROL_UI_BOOTSTRAP_CONFIG_PATH, - CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, -} from "../../../../src/gateway/control-ui-contract.js"; -import { resolveUiHourCycleOptions, setUiTimeFormatPreference } from "../format.ts"; -import { loadControlUiBootstrapConfig } from "./control-ui-bootstrap.ts"; - -function requireFetchCall(fetchMock: ReturnType, index = 0) { - const call = fetchMock.mock.calls[index] as [string, RequestInit] | undefined; - if (!call) { - throw new Error(`expected fetch call #${index + 1}`); - } - return { url: call[0], init: call[1], headers: call[1].headers as Record }; -} - -describe("loadControlUiBootstrapConfig", () => { - afterEach(() => { - setUiTimeFormatPreference("auto"); - document.documentElement.removeAttribute("style"); - document.documentElement.removeAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE); - }); - - it("threads agents.defaults.timeFormat into the UI hour-cycle preference", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "Main", - assistantAvatar: "M", - assistantAgentId: "main", - timeFormat: "24", - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAvatarSource: null, - assistantAvatarStatus: null, - assistantAvatarReason: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - chatMessageMaxWidth: null, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - - expect(resolveUiHourCycleOptions()).toEqual({ hour12: false }); - - vi.unstubAllGlobals(); - }); - - it("loads assistant identity from the bootstrap endpoint", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - basePath: "/openclaw", - assistantName: "Ops", - assistantAvatar: "O", - assistantAvatarSource: "avatars/ops.png", - assistantAvatarStatus: "none", - assistantAvatarReason: "missing", - assistantAgentId: "main", - serverVersion: "2026.3.7", - localMediaPreviewRoots: ["/tmp/openclaw"], - embedSandbox: "scripts", - allowExternalEmbedUrls: true, - chatMessageMaxWidth: "min(1280px, 82%)", - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "/openclaw", - assistantName: "Assistant", - assistantAvatar: null, - assistantAvatarSource: null, - assistantAvatarStatus: null, - assistantAvatarReason: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - chatMessageMaxWidth: null, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - - const fetchCall = requireFetchCall(fetchMock); - expect(fetchCall.url).toBe(`/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`); - expect(fetchCall.init.method).toBe("GET"); - expect(state.assistantName).toBe("Ops"); - expect(state.assistantAvatar).toBe("O"); - expect(state.assistantAvatarSource).toBe("avatars/ops.png"); - expect(state.assistantAvatarStatus).toBe("none"); - expect(state.assistantAvatarReason).toBe("missing"); - expect(state.assistantAgentId).toBe("main"); - expect(state.serverVersion).toBe("2026.3.7"); - expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]); - expect(state.embedSandboxMode).toBe("scripts"); - expect(state.allowExternalEmbedUrls).toBe(true); - expect(state.chatMessageMaxWidth).toBe("min(1280px, 82%)"); - - vi.unstubAllGlobals(); - }); - - it("applies configured seamColor to Control UI accent variables", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "Main", - assistantAvatar: "M", - assistantAgentId: "main", - seamColor: "#1A2b3C", - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAvatarSource: null, - assistantAvatarStatus: null, - assistantAvatarReason: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - chatMessageMaxWidth: null, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - - const rootStyle = document.documentElement.style; - expect(rootStyle.getPropertyValue("--accent")).toBe("#1A2b3C"); - expect(rootStyle.getPropertyValue("--ring")).toBe("#1A2b3C"); - expect(rootStyle.getPropertyValue("--primary")).toBe("#1A2b3C"); - expect(rootStyle.getPropertyValue("--accent-hover")).toBe( - "color-mix(in srgb, var(--accent) 82%, white 18%)", - ); - expect(rootStyle.getPropertyValue("--accent-subtle")).toBe( - "color-mix(in srgb, var(--accent) 16%, transparent)", - ); - - vi.unstubAllGlobals(); - }); - - it("removes server seamColor variables when bootstrap color is missing or invalid", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "Main", - assistantAvatar: "M", - assistantAgentId: "main", - seamColor: "00aaee", - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "Main", - assistantAvatar: "M", - assistantAgentId: "main", - seamColor: "lobster", - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAvatarSource: null, - assistantAvatarStatus: null, - assistantAvatarReason: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - chatMessageMaxWidth: null, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#00aaee"); - - await loadControlUiBootstrapConfig(state); - expect(document.documentElement.style.getPropertyValue("--accent")).toBe(""); - expect(document.documentElement.style.getPropertyValue("--ring")).toBe(""); - expect(document.documentElement.style.getPropertyValue("--focus-ring")).toBe(""); - - vi.unstubAllGlobals(); - }); - - it("can refresh runtime bootstrap settings without clobbering session identity", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "Main", - assistantAvatar: "M", - assistantAgentId: "main", - serverVersion: "2026.4.27", - localMediaPreviewRoots: ["/tmp/openclaw"], - embedSandbox: "trusted", - allowExternalEmbedUrls: true, - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - assistantName: "Worker", - assistantAvatar: "W", - assistantAvatarSource: null, - assistantAvatarStatus: null, - assistantAvatarReason: null, - assistantAgentId: "worker", - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state, { applyIdentity: false }); - - expect(state.assistantName).toBe("Worker"); - expect(state.assistantAvatar).toBe("W"); - expect(state.assistantAgentId).toBe("worker"); - expect(state.serverVersion).toBe("2026.4.27"); - expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]); - expect(state.embedSandboxMode).toBe("trusted"); - expect(state.allowExternalEmbedUrls).toBe(true); - - vi.unstubAllGlobals(); - }); - - it("reloads the document when the terminal flips from disabled to enabled", async () => { - document.documentElement.setAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, "false"); - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ basePath: "", terminalEnabled: false }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ basePath: "", terminalEnabled: true }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - const reload = vi.fn(); - vi.stubGlobal("window", { - location: { origin: "http://localhost", reload }, - } as unknown as Window & typeof globalThis); - - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - terminalEnabled: true, - }; - - // Page served with the terminal disabled: strict CSP, flag lands false. - await loadControlUiBootstrapConfig(state, { applyIdentity: false }); - expect(state.terminalEnabled).toBe(false); - expect(reload).not.toHaveBeenCalled(); - - // The enabling gateway restart refetches bootstrap over the same document, - // whose CSP still lacks the WASM allowances — the UI must reload for them. - await loadControlUiBootstrapConfig(state, { applyIdentity: false }); - expect(reload).toHaveBeenCalledTimes(1); - // The flag stays false until the reload delivers the fresh document. - expect(state.terminalEnabled).toBe(false); - - vi.unstubAllGlobals(); - }); - - it("enables the terminal without reloading when the document CSP already allows it", async () => { - document.documentElement.setAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, "true"); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ basePath: "", terminalEnabled: true }), - }) as unknown as typeof fetch, - ); - const reload = vi.fn(); - vi.stubGlobal("window", { - location: { origin: "http://localhost", reload }, - } as unknown as Window & typeof globalThis); - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - terminalEnabled: false, - }; - - await loadControlUiBootstrapConfig(state, { applyIdentity: false }); - - expect(state.terminalEnabled).toBe(true); - expect(reload).not.toHaveBeenCalled(); - vi.unstubAllGlobals(); - }); - - it("reloads the document when disabling removes the terminal CSP allowance", async () => { - document.documentElement.setAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, "true"); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ basePath: "", terminalEnabled: false }), - }) as unknown as typeof fetch, - ); - const reload = vi.fn(); - vi.stubGlobal("window", { - location: { origin: "http://localhost", reload }, - } as unknown as Window & typeof globalThis); - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - terminalEnabled: true, - }; - - await loadControlUiBootstrapConfig(state, { applyIdentity: false }); - - expect(reload).toHaveBeenCalledTimes(1); - expect(state.terminalEnabled).toBe(true); - vi.unstubAllGlobals(); - }); - - it("does not apply default-agent bootstrap identity to an active non-default session", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "AI大管家", - assistantAvatar: "M", - assistantAgentId: "main", - serverVersion: "2026.4.27", - localMediaPreviewRoots: ["/tmp/openclaw"], - embedSandbox: "trusted", - allowExternalEmbedUrls: true, - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - sessionKey: "agent:fs-daying:main", - assistantName: "大颖", - assistantAvatar: "D", - assistantAvatarSource: null, - assistantAvatarStatus: null, - assistantAvatarReason: null, - assistantAgentId: "fs-daying", - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - - expect(state.assistantName).toBe("大颖"); - expect(state.assistantAvatar).toBe("D"); - expect(state.assistantAgentId).toBe("fs-daying"); - expect(state.serverVersion).toBe("2026.4.27"); - expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]); - expect(state.embedSandboxMode).toBe("trusted"); - expect(state.allowExternalEmbedUrls).toBe(true); - - vi.unstubAllGlobals(); - }); - - it("keeps local assistant avatar override when default-agent bootstrap identity is skipped", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "Main", - assistantAvatar: "M", - assistantAgentId: "main", - serverVersion: "2026.4.27", - localMediaPreviewRoots: [], - embedSandbox: "scripts", - allowExternalEmbedUrls: false, - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - vi.stubGlobal("localStorage", { - getItem: vi.fn(() => JSON.stringify({ avatar: "data:image/png;base64,local" })), - setItem: vi.fn(), - removeItem: vi.fn(), - } as unknown as Storage); - - const state = { - basePath: "", - sessionKey: "agent:worker:main", - assistantName: "Worker", - assistantAvatar: "W", - assistantAvatarSource: null, - assistantAvatarStatus: null, - assistantAvatarReason: null, - assistantAgentId: "worker", - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - - expect(state.assistantName).toBe("Worker"); - expect(state.assistantAvatar).toBe("data:image/png;base64,local"); - expect(state.assistantAvatarSource).toBe("data:image/png;base64,local"); - expect(state.assistantAvatarStatus).toBe("data"); - expect(state.assistantAvatarReason).toBeNull(); - expect(state.assistantAgentId).toBe("worker"); - - vi.unstubAllGlobals(); - }); - - it("ignores failures", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - - const fetchCall = requireFetchCall(fetchMock); - expect(fetchCall.url).toBe(CONTROL_UI_BOOTSTRAP_CONFIG_PATH); - expect(fetchCall.init.method).toBe("GET"); - expect(state.assistantName).toBe("Assistant"); - expect(state.embedSandboxMode).toBe("scripts"); - expect(state.allowExternalEmbedUrls).toBe(false); - - vi.unstubAllGlobals(); - }); - - it("normalizes trailing slash basePath for bootstrap fetch path", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "/openclaw/", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - }; - - await loadControlUiBootstrapConfig(state); - - const fetchCall = requireFetchCall(fetchMock); - expect(fetchCall.url).toBe(`/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`); - expect(fetchCall.init.method).toBe("GET"); - - vi.unstubAllGlobals(); - }); - - it("includes the configured auth token on bootstrap fetches", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "/openclaw", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - settings: { token: "session-token" }, - }; - - await loadControlUiBootstrapConfig(state); - - const fetchCall = requireFetchCall(fetchMock); - expect(fetchCall.url).toBe(`/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`); - expect(fetchCall.init.method).toBe("GET"); - expect(fetchCall.headers.Accept).toBe("application/json"); - expect(fetchCall.headers.Authorization).toBe("Bearer session-token"); - - vi.unstubAllGlobals(); - }); - - it("retries with the alternate shared-secret credential when the first returns 401", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ ok: false, status: 401 }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - basePath: "", - assistantName: "Ops", - assistantAvatar: null, - assistantAgentId: null, - serverVersion: "2026.4.22", - localMediaPreviewRoots: [], - embedSandbox: "scripts", - allowExternalEmbedUrls: false, - }), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - settings: { token: "stale-token" }, - password: "fresh-password", - }; - - await loadControlUiBootstrapConfig(state); - - expect(fetchMock).toHaveBeenCalledTimes(2); - const firstFetchCall = requireFetchCall(fetchMock, 0); - const secondFetchCall = requireFetchCall(fetchMock, 1); - expect(firstFetchCall.headers.Authorization).toBe("Bearer stale-token"); - expect(secondFetchCall.headers.Authorization).toBe("Bearer fresh-password"); - expect(state.assistantName).toBe("Ops"); - expect(state.serverVersion).toBe("2026.4.22"); - - vi.unstubAllGlobals(); - }); - - it("stops retrying on non-auth errors", async () => { - const fetchMock = vi.fn().mockResolvedValueOnce({ ok: false, status: 500 }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - settings: { token: "a" }, - password: "b", - }; - - await loadControlUiBootstrapConfig(state); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(state.assistantName).toBe("Assistant"); - - vi.unstubAllGlobals(); - }); - - it("does not attach auth headers to protocol-relative bootstrap URLs", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const state = { - basePath: "//evil.example", - assistantName: "Assistant", - assistantAvatar: null, - assistantAgentId: null, - localMediaPreviewRoots: [], - embedSandboxMode: "scripts" as const, - allowExternalEmbedUrls: false, - serverVersion: null, - settings: { token: "session-token" }, - }; - - await loadControlUiBootstrapConfig(state); - - const fetchCall = requireFetchCall(fetchMock); - expect(fetchCall.url).toBe(`//evil.example${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`); - expect(fetchCall.init.method).toBe("GET"); - expect(fetchCall.headers.Accept).toBe("application/json"); - expect(fetchCall.headers.Authorization).toBeUndefined(); - - vi.unstubAllGlobals(); - }); -}); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts deleted file mode 100644 index 13af3487321d..000000000000 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ /dev/null @@ -1,213 +0,0 @@ -// Control UI controller manages control ui bootstrap gateway state. -import { - CONTROL_UI_BOOTSTRAP_CONFIG_PATH, - CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, - type ControlUiBootstrapConfig, - type ControlUiEmbedSandboxMode, -} from "../../../../src/gateway/control-ui-contract.js"; -import { normalizeAssistantIdentity } from "../assistant-identity.ts"; -import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts"; -import { setUiTimeFormatPreference } from "../format.ts"; -import { normalizeBasePath } from "../navigation.ts"; -import { normalizeAgentId, parseAgentSessionKey } from "../session-key.ts"; -import { loadLocalAssistantIdentity } from "../storage.ts"; -import { normalizeOptionalString } from "../string-coerce.ts"; - -const SEAM_COLOR_CSS_VARIABLES = [ - "--ring", - "--accent", - "--accent-hover", - "--accent-muted", - "--accent-subtle", - "--accent-glow", - "--primary", - "--focus", - "--focus-ring", - "--focus-glow", -] as const; - -export type ControlUiBootstrapState = { - basePath: string; - assistantName: string; - assistantAvatar: string | null; - assistantAvatarSource?: string | null; - assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null; - assistantAvatarReason?: string | null; - assistantAgentId: string | null; - serverVersion: string | null; - localMediaPreviewRoots: string[]; - embedSandboxMode: ControlUiEmbedSandboxMode; - allowExternalEmbedUrls: boolean; - chatMessageMaxWidth?: string | null; - terminalEnabled?: boolean; - sessionKey?: string | null; - hello?: { auth?: { deviceToken?: string | null } | null } | null; - settings?: { token?: string | null } | null; - password?: string | null; -}; - -function normalizeSeamColor(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const hex = value.trim().replace(/^#/, ""); - return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; -} - -function applyControlUiSeamColor(value: unknown) { - if (typeof document === "undefined") { - return; - } - const root = document.documentElement; - const color = normalizeSeamColor(value); - if (!color) { - for (const property of SEAM_COLOR_CSS_VARIABLES) { - root.style.removeProperty(property); - } - return; - } - - root.style.setProperty("--ring", color); - root.style.setProperty("--accent", color); - root.style.setProperty("--accent-hover", "color-mix(in srgb, var(--accent) 82%, white 18%)"); - root.style.setProperty("--accent-muted", color); - root.style.setProperty("--accent-subtle", "color-mix(in srgb, var(--accent) 16%, transparent)"); - root.style.setProperty("--accent-glow", "color-mix(in srgb, var(--accent) 30%, transparent)"); - root.style.setProperty("--primary", color); - root.style.setProperty("--focus", "color-mix(in srgb, var(--ring) 22%, transparent)"); - root.style.setProperty( - "--focus-ring", - "0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent)", - ); - root.style.setProperty( - "--focus-glow", - "0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)", - ); -} - -function resolveActiveAgentId(state: ControlUiBootstrapState): string | null { - const sessionAgentId = parseAgentSessionKey(state.sessionKey)?.agentId; - if (sessionAgentId) { - return normalizeAgentId(sessionAgentId); - } - const currentAgentId = normalizeOptionalString(state.assistantAgentId); - return currentAgentId ? normalizeAgentId(currentAgentId) : null; -} - -function resolveBootstrapAgentId(value: string | null | undefined): string | null { - const normalized = normalizeOptionalString(value); - return normalized ? normalizeAgentId(normalized) : null; -} - -function applyLocalAssistantAvatarOverride(state: ControlUiBootstrapState) { - const localAvatar = loadLocalAssistantIdentity({ agentId: resolveActiveAgentId(state) }).avatar; - if (!localAvatar) { - return; - } - state.assistantAvatar = localAvatar; - state.assistantAvatarSource = localAvatar; - state.assistantAvatarStatus = "data"; - state.assistantAvatarReason = null; -} - -export async function loadControlUiBootstrapConfig( - state: ControlUiBootstrapState, - opts?: { applyIdentity?: boolean }, -) { - if (typeof window === "undefined") { - return; - } - if (typeof fetch !== "function") { - return; - } - - const basePath = normalizeBasePath(state.basePath ?? ""); - const url = basePath - ? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` - : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; - - try { - const resolvedUrl = new URL(url, window.location.origin); - const sameOrigin = resolvedUrl.origin === window.location.origin; - const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(state) : []; - // If credentials are available, try them in priority order; on 401/403 - // retry with the next candidate — recovers from a stale `settings.token` - // when the live session is authenticated via `password` (or vice versa). - // If no credentials are available, fall through with no Authorization - // header so bootstrap still works on auth-disabled deployments. - const attempts: string[] = authCandidates.length > 0 ? authCandidates : [""]; - let res: Response | null = null; - for (const candidate of attempts) { - const headers: Record = { Accept: "application/json" }; - if (candidate) { - headers.Authorization = `Bearer ${candidate}`; - } - res = await fetch(url, { method: "GET", headers, credentials: "same-origin" }); - if (res.ok) { - break; - } - if (res.status !== 401 && res.status !== 403) { - return; - } - } - if (!res || !res.ok) { - return; - } - const parsed = (await res.json()) as ControlUiBootstrapConfig; - if (opts?.applyIdentity !== false) { - const activeAgentId = resolveActiveAgentId(state); - const bootstrapAgentId = resolveBootstrapAgentId(parsed.assistantAgentId ?? null); - if (!activeAgentId || !bootstrapAgentId || activeAgentId === bootstrapAgentId) { - const normalized = normalizeAssistantIdentity({ - agentId: parsed.assistantAgentId ?? null, - name: parsed.assistantName, - avatar: parsed.assistantAvatar ?? null, - avatarSource: parsed.assistantAvatarSource ?? null, - avatarStatus: parsed.assistantAvatarStatus ?? null, - avatarReason: parsed.assistantAvatarReason ?? null, - }); - state.assistantName = normalized.name; - state.assistantAvatar = normalized.avatar; - state.assistantAvatarSource = normalized.avatarSource ?? null; - state.assistantAvatarStatus = normalized.avatarStatus ?? null; - state.assistantAvatarReason = normalized.avatarReason ?? null; - state.assistantAgentId = normalized.agentId ?? null; - } - applyLocalAssistantAvatarOverride(state); - } - state.serverVersion = parsed.serverVersion ?? null; - state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots) - ? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string") - : []; - state.embedSandboxMode = - parsed.embedSandbox === "trusted" - ? "trusted" - : parsed.embedSandbox === "strict" - ? "strict" - : "scripts"; - state.allowExternalEmbedUrls = parsed.allowExternalEmbedUrls === true; - state.chatMessageMaxWidth = - typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim() - ? parsed.chatMessageMaxWidth - : null; - // The host shell is opt-in; absent flags from older gateways stay disabled. - const terminalEnabled = parsed.terminalEnabled === true; - const documentTerminalState = document.documentElement.getAttribute( - CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, - ); - const documentTerminalEnabled = - documentTerminalState === "true" ? true : documentTerminalState === "false" ? false : null; - if (documentTerminalEnabled !== null && terminalEnabled !== documentTerminalEnabled) { - // CSP headers cannot change on a live document. Reload in either - // direction so enable gains the WASM allowance and disable removes it. - // Loop-safe: the replacement document carries the accepted state. - window.location.reload(); - return; - } - state.terminalEnabled = terminalEnabled; - applyControlUiSeamColor(parsed.seamColor); - setUiTimeFormatPreference(parsed.timeFormat); - } catch { - // Ignore bootstrap failures; UI will update identity after connecting. - } -} diff --git a/ui/src/ui/controllers/cron-filters.test.ts b/ui/src/ui/controllers/cron-filters.test.ts deleted file mode 100644 index f2cdf9cf348a..000000000000 --- a/ui/src/ui/controllers/cron-filters.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Control UI tests cover cron filters behavior. -import { describe, expect, it } from "vitest"; -import type { CronJob } from "../types.ts"; -import { getVisibleCronJobs } from "./cron.ts"; - -function job(id: string, overrides: Partial = {}): CronJob { - return { - id, - name: `Job ${id}`, - enabled: true, - createdAtMs: 0, - updatedAtMs: 0, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "test" }, - ...overrides, - }; -} - -describe("getVisibleCronJobs", () => { - it("returns all jobs when no client-side filters are active", () => { - const jobs = [job("a"), job("b", { schedule: { kind: "cron", expr: "0 9 * * *" } })]; - const visible = getVisibleCronJobs({ - cronJobs: jobs, - cronJobsScheduleKindFilter: "all", - cronJobsLastStatusFilter: "all", - }); - expect(visible).toHaveLength(2); - }); - - it("filters by schedule kind", () => { - const jobs = [ - job("a", { schedule: { kind: "at", at: "2026-03-01T08:00:00Z" } }), - job("b", { schedule: { kind: "every", everyMs: 60_000 } }), - job("c", { schedule: { kind: "cron", expr: "0 9 * * *" } }), - ]; - const visible = getVisibleCronJobs({ - cronJobs: jobs, - cronJobsScheduleKindFilter: "cron", - cronJobsLastStatusFilter: "all", - }); - expect(visible.map((entry) => entry.id)).toEqual(["c"]); - }); - - it("drops jobs with unsupported schedules before rendering", () => { - const jobs = [ - job("valid", { schedule: { kind: "cron", expr: "0 9 * * *" } }), - job("invalid", { schedule: {} as CronJob["schedule"] }), - ]; - const visible = getVisibleCronJobs({ - cronJobs: jobs, - cronJobsScheduleKindFilter: "all", - cronJobsLastStatusFilter: "all", - }); - expect(visible.map((entry) => entry.id)).toEqual(["valid"]); - }); - - it("filters by last status", () => { - const jobs = [ - job("ok", { state: { lastStatus: "ok", lastRunAtMs: 1 } }), - job("error", { state: { lastStatus: "error", lastRunAtMs: 2 } }), - job("unknown"), - ]; - const visible = getVisibleCronJobs({ - cronJobs: jobs, - cronJobsScheduleKindFilter: "all", - cronJobsLastStatusFilter: "error", - }); - expect(visible.map((entry) => entry.id)).toEqual(["error"]); - }); - - it("filters unknown and preferred last-run statuses", () => { - const jobs = [ - job("preferred", { state: { lastRunStatus: "skipped", lastRunAtMs: 1 } }), - job("legacy", { state: { lastStatus: "skipped", lastRunAtMs: 2 } }), - job("missing"), - ]; - - const skipped = getVisibleCronJobs({ - cronJobs: jobs, - cronJobsScheduleKindFilter: "all", - cronJobsLastStatusFilter: "skipped", - }); - const unknown = getVisibleCronJobs({ - cronJobs: jobs, - cronJobsScheduleKindFilter: "all", - cronJobsLastStatusFilter: "unknown", - }); - - expect(skipped.map((entry) => entry.id)).toEqual(["preferred", "legacy"]); - expect(unknown.map((entry) => entry.id)).toEqual(["missing"]); - }); - - it("combines schedule and last-status filters", () => { - const jobs = [ - job("a", { - schedule: { kind: "cron", expr: "0 9 * * *" }, - state: { lastStatus: "ok", lastRunAtMs: 1 }, - }), - job("b", { - schedule: { kind: "cron", expr: "0 10 * * *" }, - state: { lastStatus: "error", lastRunAtMs: 2 }, - }), - job("c", { - schedule: { kind: "every", everyMs: 60_000 }, - state: { lastStatus: "error", lastRunAtMs: 3 }, - }), - ]; - const visible = getVisibleCronJobs({ - cronJobs: jobs, - cronJobsScheduleKindFilter: "cron", - cronJobsLastStatusFilter: "error", - }); - expect(visible.map((entry) => entry.id)).toEqual(["b"]); - }); -}); diff --git a/ui/src/ui/controllers/debug.ts b/ui/src/ui/controllers/debug.ts deleted file mode 100644 index 4e66b93303b8..000000000000 --- a/ui/src/ui/controllers/debug.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Control UI controller manages debug gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { HealthSnapshot, StatusSummary } from "../types.ts"; - -export type DebugState = { - client: GatewayBrowserClient | null; - connected: boolean; - debugLoading: boolean; - debugStatus: StatusSummary | null; - debugHealth: HealthSnapshot | null; - debugModels: unknown[]; - debugHeartbeat: unknown; - debugCallMethod: string; - debugCallParams: string; - debugCallResult: string | null; - debugCallError: string | null; -}; - -export async function loadDebug(state: DebugState) { - if (!state.client || !state.connected) { - return; - } - if (state.debugLoading) { - return; - } - state.debugLoading = true; - try { - const [status, health, models, heartbeat] = await Promise.all([ - state.client.request("status", {}), - state.client.request("health", {}), - state.client.request("models.list", {}), - state.client.request("last-heartbeat", {}), - ]); - state.debugStatus = status as StatusSummary; - state.debugHealth = health as HealthSnapshot; - const modelPayload = models as { models?: unknown[] } | undefined; - state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : []; - state.debugHeartbeat = heartbeat; - } catch (err) { - state.debugCallError = String(err); - } finally { - state.debugLoading = false; - } -} - -export async function callDebugMethod(state: DebugState) { - if (!state.client || !state.connected) { - return; - } - state.debugCallError = null; - state.debugCallResult = null; - try { - const params = state.debugCallParams.trim() - ? (JSON.parse(state.debugCallParams) as unknown) - : {}; - const res = await state.client.request(state.debugCallMethod.trim(), params); - state.debugCallResult = JSON.stringify(res, null, 2); - } catch (err) { - state.debugCallError = String(err); - } -} diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts deleted file mode 100644 index e38cccf1bd85..000000000000 --- a/ui/src/ui/controllers/devices.ts +++ /dev/null @@ -1,223 +0,0 @@ -// Control UI controller manages devices gateway state. -import type { DevicePairSetupCodeResult } from "../../../../packages/gateway-protocol/src/index.js"; -import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth.ts"; -import { loadOrCreateDeviceIdentity } from "../device-identity.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; - -export type DeviceTokenSummary = { - role: string; - scopes?: string[]; - createdAtMs?: number; - rotatedAtMs?: number; - revokedAtMs?: number; - lastUsedAtMs?: number; -}; - -export type PendingDevice = { - requestId: string; - deviceId: string; - publicKey?: string; - displayName?: string; - role?: string; - roles?: string[]; - scopes?: string[]; - remoteIp?: string; - isRepair?: boolean; - ts?: number; -}; - -export type PairedDevice = { - deviceId: string; - publicKey?: string; - displayName?: string; - roles?: string[]; - scopes?: string[]; - remoteIp?: string; - tokens?: DeviceTokenSummary[]; - createdAtMs?: number; - approvedAtMs?: number; -}; - -export type DevicePairingList = { - pending: PendingDevice[]; - paired: PairedDevice[]; -}; - -export type DevicePairSetup = DevicePairSetupCodeResult; - -export type DevicesState = { - client: GatewayBrowserClient | null; - connected: boolean; - devicesLoading: boolean; - devicesError: string | null; - devicesList: DevicePairingList | null; - devicePairSetupOpen: boolean; - devicePairSetupLoading: boolean; - devicePairSetupError: string | null; - devicePairSetup: DevicePairSetup | null; -}; - -const devicePairSetupRequests = new WeakMap(); - -export async function openDevicePairSetup(state: DevicesState) { - state.devicePairSetupOpen = true; - await refreshDevicePairSetup(state); -} - -export async function refreshDevicePairSetup(state: DevicesState) { - const client = state.client; - if (!client || !state.connected || state.devicePairSetupLoading) { - return; - } - const requestToken = {}; - devicePairSetupRequests.set(state, requestToken); - state.devicePairSetupLoading = true; - state.devicePairSetupError = null; - try { - const result = await client.request("device.pair.setupCode", {}); - if ( - devicePairSetupRequests.get(state) !== requestToken || - state.client !== client || - !state.connected || - !state.devicePairSetupOpen - ) { - return; - } - state.devicePairSetup = result; - } catch (err) { - if ( - devicePairSetupRequests.get(state) === requestToken && - state.client === client && - state.devicePairSetupOpen - ) { - state.devicePairSetupError = String(err); - } - } finally { - // A retired request must not clear the loading state of a replacement request. - if (devicePairSetupRequests.get(state) === requestToken) { - devicePairSetupRequests.delete(state); - state.devicePairSetupLoading = false; - } - } -} - -export function closeDevicePairSetup(state: DevicesState) { - devicePairSetupRequests.delete(state); - state.devicePairSetupOpen = false; - state.devicePairSetupLoading = false; - state.devicePairSetupError = null; - state.devicePairSetup = null; -} - -export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) { - if (!state.client || !state.connected) { - return; - } - if (state.devicesLoading) { - return; - } - state.devicesLoading = true; - if (!opts?.quiet) { - state.devicesError = null; - } - try { - const res = await state.client.request<{ - pending?: Array; - paired?: Array; - }>("device.pair.list", {}); - state.devicesList = { - pending: Array.isArray(res?.pending) ? res.pending : [], - paired: Array.isArray(res?.paired) ? res.paired : [], - }; - } catch (err) { - if (!opts?.quiet) { - state.devicesError = String(err); - } - } finally { - state.devicesLoading = false; - } -} - -export async function approveDevicePairing(state: DevicesState, requestId: string) { - if (!state.client || !state.connected) { - return; - } - try { - await state.client.request("device.pair.approve", { requestId }); - await loadDevices(state); - } catch (err) { - state.devicesError = String(err); - } -} - -export async function rejectDevicePairing(state: DevicesState, requestId: string) { - if (!state.client || !state.connected) { - return; - } - const confirmed = window.confirm("Reject this device pairing request?"); - if (!confirmed) { - return; - } - try { - await state.client.request("device.pair.reject", { requestId }); - await loadDevices(state); - } catch (err) { - state.devicesError = String(err); - } -} - -export async function rotateDeviceToken( - state: DevicesState, - params: { deviceId: string; role: string; scopes?: string[] }, -) { - if (!state.client || !state.connected) { - return; - } - try { - const res = await state.client.request<{ - token?: string; - role?: string; - deviceId?: string; - scopes?: Array; - }>("device.token.rotate", params); - if (res?.token) { - const identity = await loadOrCreateDeviceIdentity(); - const role = res.role ?? params.role; - if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) { - storeDeviceAuthToken({ - deviceId: identity.deviceId, - role, - token: res.token, - scopes: res.scopes ?? params.scopes ?? [], - }); - } - window.prompt("New device token (copy and store securely):", res.token); - } - await loadDevices(state); - } catch (err) { - state.devicesError = String(err); - } -} - -export async function revokeDeviceToken( - state: DevicesState, - params: { deviceId: string; role: string }, -) { - if (!state.client || !state.connected) { - return; - } - const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`); - if (!confirmed) { - return; - } - try { - await state.client.request("device.token.revoke", params); - const identity = await loadOrCreateDeviceIdentity(); - if (params.deviceId === identity.deviceId) { - clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role }); - } - await loadDevices(state); - } catch (err) { - state.devicesError = String(err); - } -} diff --git a/ui/src/ui/controllers/exec-approvals.ts b/ui/src/ui/controllers/exec-approvals.ts deleted file mode 100644 index c1974238e962..000000000000 --- a/ui/src/ui/controllers/exec-approvals.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Control UI controller manages exec approvals gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils.ts"; - -export type ExecApprovalsDefaults = { - security?: string; - ask?: string; - askFallback?: string; - autoAllowSkills?: boolean; -}; - -export type ExecApprovalsAllowlistEntry = { - id?: string; - pattern: string; - source?: "allow-always"; - commandText?: string; - argPattern?: string; - lastUsedAt?: number; - lastUsedCommand?: string; - lastResolvedPath?: string; -}; - -export type ExecApprovalsAgent = ExecApprovalsDefaults & { - allowlist?: ExecApprovalsAllowlistEntry[]; -}; - -export type ExecApprovalsFile = { - version?: number; - socket?: { path?: string }; - defaults?: ExecApprovalsDefaults; - agents?: Record; -}; - -export type ExecApprovalsSnapshot = { - path: string; - exists: boolean; - hash: string; - file: ExecApprovalsFile; -}; - -export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string }; - -export type ExecApprovalsState = { - client: GatewayBrowserClient | null; - connected: boolean; - execApprovalsLoading: boolean; - execApprovalsSaving: boolean; - execApprovalsDirty: boolean; - execApprovalsSnapshot: ExecApprovalsSnapshot | null; - execApprovalsForm: ExecApprovalsFile | null; - execApprovalsSelectedAgent: string | null; - lastError: string | null; - chatError?: string | null; -}; - -function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): { - method: string; - params: Record; -} | null { - if (!target || target.kind === "gateway") { - return { method: "exec.approvals.get", params: {} }; - } - const nodeId = target.nodeId.trim(); - if (!nodeId) { - return null; - } - return { method: "exec.approvals.node.get", params: { nodeId } }; -} - -function resolveExecApprovalsSaveRpc( - target: ExecApprovalsTarget | null | undefined, - params: { file: ExecApprovalsFile; baseHash: string }, -): { method: string; params: Record } | null { - if (!target || target.kind === "gateway") { - return { method: "exec.approvals.set", params }; - } - const nodeId = target.nodeId.trim(); - if (!nodeId) { - return null; - } - return { method: "exec.approvals.node.set", params: { ...params, nodeId } }; -} - -export async function loadExecApprovals( - state: ExecApprovalsState, - target?: ExecApprovalsTarget | null, -) { - if (!state.client || !state.connected) { - return; - } - if (state.execApprovalsLoading) { - return; - } - state.execApprovalsLoading = true; - state.lastError = null; - state.chatError = null; - try { - const rpc = resolveExecApprovalsRpc(target); - if (!rpc) { - state.lastError = "Select a node before loading exec approvals."; - return; - } - const res = await state.client.request(rpc.method, rpc.params); - applyExecApprovalsSnapshot(state, res); - } catch (err) { - state.lastError = String(err); - } finally { - state.execApprovalsLoading = false; - } -} - -function applyExecApprovalsSnapshot(state: ExecApprovalsState, snapshot: ExecApprovalsSnapshot) { - state.execApprovalsSnapshot = snapshot; - if (!state.execApprovalsDirty) { - state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {}); - } -} - -export async function saveExecApprovals( - state: ExecApprovalsState, - target?: ExecApprovalsTarget | null, -) { - if (!state.client || !state.connected) { - return; - } - state.execApprovalsSaving = true; - state.lastError = null; - state.chatError = null; - try { - const baseHash = state.execApprovalsSnapshot?.hash; - if (!baseHash) { - state.lastError = "Exec approvals hash missing; reload and retry."; - return; - } - const file = state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}; - const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash }); - if (!rpc) { - state.lastError = "Select a node before saving exec approvals."; - return; - } - await state.client.request(rpc.method, rpc.params); - state.execApprovalsDirty = false; - await loadExecApprovals(state, target); - } catch (err) { - state.lastError = String(err); - } finally { - state.execApprovalsSaving = false; - } -} - -export function updateExecApprovalsFormValue( - state: ExecApprovalsState, - path: Array, - value: unknown, -) { - const base = cloneConfigObject( - state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, - ); - setPathValue(base, path, value); - state.execApprovalsForm = base; - state.execApprovalsDirty = true; -} - -export function removeExecApprovalsFormValue( - state: ExecApprovalsState, - path: Array, -) { - const base = cloneConfigObject( - state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, - ); - removePathValue(base, path); - state.execApprovalsForm = base; - state.execApprovalsDirty = true; -} diff --git a/ui/src/ui/controllers/health.ts b/ui/src/ui/controllers/health.ts deleted file mode 100644 index 8312a6e6e686..000000000000 --- a/ui/src/ui/controllers/health.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Control UI controller manages health gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { HealthSummary } from "../types.ts"; - -/** Default fallback returned when the gateway is unreachable or returns null. */ -const HEALTH_FALLBACK: HealthSummary = { - ok: false, - ts: 0, - durationMs: 0, - heartbeatSeconds: 0, - defaultAgentId: "", - agents: [], - sessions: { path: "", count: 0, recent: [] }, -}; - -/** State slice consumed by {@link loadHealthState}. Follows the agents/sessions convention. */ -export type HealthState = { - client: GatewayBrowserClient | null; - connected: boolean; - healthLoading: boolean; - healthResult: HealthSummary | null; - healthError: string | null; -}; - -/** - * Fetch the gateway health summary. - * - * Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller - * convention). Returns a fully-typed {@link HealthSummary}; on failure the - * caller receives a safe fallback with `ok: false` rather than `null`. - */ -export async function loadHealth(client: GatewayBrowserClient): Promise { - try { - const result = await client.request("health", {}); - return result ?? HEALTH_FALLBACK; - } catch { - return HEALTH_FALLBACK; - } -} - -/** - * State-mutating health loader (same pattern as {@link import("./agents.ts").loadAgents}). - * - * Populates `healthResult` / `healthError` on the provided state slice and - * toggles `healthLoading` around the request. - */ -export async function loadHealthState(state: HealthState): Promise { - if (!state.client || !state.connected) { - return; - } - if (state.healthLoading) { - return; - } - state.healthLoading = true; - state.healthError = null; - try { - state.healthResult = await loadHealth(state.client); - } catch (err) { - state.healthError = String(err); - } finally { - state.healthLoading = false; - } -} diff --git a/ui/src/ui/controllers/logs.ts b/ui/src/ui/controllers/logs.ts deleted file mode 100644 index 85d23f5f6559..000000000000 --- a/ui/src/ui/controllers/logs.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Control UI controller manages logs gateway state. -import { stripAnsi } from "../../../../packages/terminal-core/src/ansi.js"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { LogEntry, LogLevel } from "../types.ts"; -import { - formatMissingOperatorReadScopeMessage, - isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; - -export type LogsState = { - client: GatewayBrowserClient | null; - connected: boolean; - logsLoading: boolean; - logsError: string | null; - logsCursor: number | null; - logsFile: string | null; - logsEntries: LogEntry[]; - logsTruncated: boolean; - logsLastFetchAt: number | null; - logsLimit: number; - logsMaxBytes: number; -}; - -const LOG_BUFFER_LIMIT = 2000; -const LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); - -function stripAnsiSequences(value: string): string { - return stripAnsi(value); -} - -function parseMaybeJsonString(value: unknown) { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { - return null; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - return parsed && typeof parsed === "object" ? (parsed as Record) : null; - } catch { - return null; - } -} - -function normalizeLevel(value: unknown): LogLevel | null { - if (typeof value !== "string") { - return null; - } - const lowered = normalizeLowercaseStringOrEmpty(value) as LogLevel; - return LEVELS.has(lowered) ? lowered : null; -} - -export function parseLogLine(line: string): LogEntry { - if (!line.trim()) { - return { raw: line, message: line }; - } - try { - const obj = JSON.parse(line) as Record; - const meta = - obj && typeof obj["_meta"] === "object" && obj["_meta"] !== null - ? (obj["_meta"] as Record) - : null; - const time = - typeof obj.time === "string" ? obj.time : typeof meta?.date === "string" ? meta?.date : null; - const level = normalizeLevel(meta?.logLevelName ?? meta?.level); - - const contextCandidate = - typeof obj["0"] === "string" ? obj["0"] : typeof meta?.name === "string" ? meta?.name : null; - const contextObj = parseMaybeJsonString(contextCandidate); - let subsystem = - typeof contextObj?.subsystem === "string" - ? contextObj.subsystem - : typeof contextObj?.module === "string" - ? contextObj.module - : null; - if (!subsystem && contextCandidate && contextCandidate.length < 120) { - subsystem = contextCandidate; - } - - const message = - typeof obj["1"] === "string" - ? obj["1"] - : typeof obj["2"] === "string" - ? obj["2"] - : !contextObj && typeof obj["0"] === "string" - ? obj["0"] - : typeof obj.message === "string" - ? obj.message - : line; - - return { - raw: line, - time, - level, - subsystem: subsystem ? stripAnsiSequences(subsystem) : subsystem, - message: stripAnsiSequences(message), - meta: meta ?? undefined, - }; - } catch { - return { raw: line, message: stripAnsiSequences(line) }; - } -} - -export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet?: boolean }) { - const quiet = opts?.quiet === true; - if (!state.client || !state.connected || (state.logsLoading && !quiet)) { - return; - } - if (!quiet) { - state.logsLoading = true; - } - state.logsError = null; - try { - const res = await state.client.request("logs.tail", { - cursor: opts?.reset ? undefined : (state.logsCursor ?? undefined), - limit: state.logsLimit, - maxBytes: state.logsMaxBytes, - }); - const payload = res as { - file?: string; - cursor?: number; - lines?: unknown; - truncated?: boolean; - reset?: boolean; - }; - const lines = Array.isArray(payload.lines) - ? payload.lines.filter((line) => typeof line === "string") - : []; - const entries = lines.map(parseLogLine); - const shouldReset = opts?.reset || payload.reset || state.logsCursor == null; - state.logsEntries = shouldReset - ? entries - : [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT); - state.logsCursor = typeof payload.cursor === "number" ? payload.cursor : state.logsCursor; - state.logsFile = typeof payload.file === "string" ? payload.file : state.logsFile; - state.logsTruncated = Boolean(payload.truncated); - state.logsLastFetchAt = Date.now(); - } catch (err) { - if (isMissingOperatorReadScopeError(err)) { - state.logsEntries = []; - state.logsError = formatMissingOperatorReadScopeMessage("logs"); - } else { - state.logsError = String(err); - } - } finally { - if (!quiet) { - state.logsLoading = false; - } - } -} diff --git a/ui/src/ui/controllers/model-auth-status.ts b/ui/src/ui/controllers/model-auth-status.ts deleted file mode 100644 index 6a56c40e08aa..000000000000 --- a/ui/src/ui/controllers/model-auth-status.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Control UI controller manages model auth status gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { ModelAuthStatusResult } from "../types.ts"; - -const FALLBACK: ModelAuthStatusResult = { ts: 0, providers: [] }; - -export type ModelAuthStatusState = { - client: GatewayBrowserClient | null; - connected: boolean; - modelAuthStatusLoading: boolean; - modelAuthStatusResult: ModelAuthStatusResult | null; - modelAuthStatusError: string | null; -}; - -/** - * Fetch the current auth-status snapshot. Rethrows transport errors so the - * state wrapper can distinguish "not loaded yet" (ts === 0) from "load failed" - * (error set). - * - * Pass `{ refresh: true }` to bypass the gateway's 60s cache — useful after - * a user-initiated refresh, where serving a minute-old snapshot would - * contradict the affordance. - */ -export async function loadModelAuthStatus( - client: GatewayBrowserClient, - opts?: { refresh?: boolean }, -): Promise { - const params = opts?.refresh ? { refresh: true } : {}; - const result = await client.request("models.authStatus", params); - return result ?? FALLBACK; -} - -export async function loadModelAuthStatusState( - state: ModelAuthStatusState, - opts?: { refresh?: boolean }, -): Promise { - if (!state.client || !state.connected) { - return; - } - if (state.modelAuthStatusLoading) { - return; - } - state.modelAuthStatusLoading = true; - state.modelAuthStatusError = null; - try { - state.modelAuthStatusResult = await loadModelAuthStatus(state.client, opts); - } catch (err) { - state.modelAuthStatusError = err instanceof Error ? err.message : String(err); - state.modelAuthStatusResult = FALLBACK; - } finally { - state.modelAuthStatusLoading = false; - } -} diff --git a/ui/src/ui/controllers/nodes.ts b/ui/src/ui/controllers/nodes.ts deleted file mode 100644 index 469b16b268ce..000000000000 --- a/ui/src/ui/controllers/nodes.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Control UI controller manages nodes gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; - -export type NodesState = { - client: GatewayBrowserClient | null; - connected: boolean; - nodesLoading: boolean; - nodes: Array>; - lastError: string | null; - chatError?: string | null; -}; - -export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) { - if (!state.client || !state.connected) { - return; - } - if (state.nodesLoading) { - return; - } - state.nodesLoading = true; - if (!opts?.quiet) { - state.lastError = null; - state.chatError = null; - } - try { - const res = await state.client.request<{ nodes?: Record }>("node.list", {}); - state.nodes = Array.isArray(res.nodes) ? res.nodes : []; - } catch (err) { - if (!opts?.quiet) { - state.lastError = String(err); - } - } finally { - state.nodesLoading = false; - } -} diff --git a/ui/src/ui/controllers/presence.ts b/ui/src/ui/controllers/presence.ts deleted file mode 100644 index 5ea530dbbdbd..000000000000 --- a/ui/src/ui/controllers/presence.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Control UI controller manages presence gateway state. -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { PresenceEntry } from "../types.ts"; -import { - formatMissingOperatorReadScopeMessage, - isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; - -export type PresenceState = { - client: GatewayBrowserClient | null; - connected: boolean; - presenceLoading: boolean; - presenceEntries: PresenceEntry[]; - presenceError: string | null; - presenceStatus: string | null; -}; - -export async function loadPresence(state: PresenceState) { - if (!state.client || !state.connected) { - return; - } - if (state.presenceLoading) { - return; - } - state.presenceLoading = true; - state.presenceError = null; - state.presenceStatus = null; - try { - const res = await state.client.request("system-presence", {}); - if (Array.isArray(res)) { - state.presenceEntries = res; - state.presenceStatus = res.length === 0 ? "No instances yet." : null; - } else { - state.presenceEntries = []; - state.presenceStatus = "No presence payload."; - } - } catch (err) { - if (isMissingOperatorReadScopeError(err)) { - state.presenceEntries = []; - state.presenceStatus = null; - state.presenceError = formatMissingOperatorReadScopeMessage("instance presence"); - } else { - state.presenceError = String(err); - } - } finally { - state.presenceLoading = false; - } -} diff --git a/ui/src/ui/controllers/scope-errors.ts b/ui/src/ui/controllers/scope-errors.ts deleted file mode 100644 index e14ec9bff99f..000000000000 --- a/ui/src/ui/controllers/scope-errors.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Control UI controller manages scope errors gateway state. -import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; -import { GatewayRequestError, resolveGatewayErrorDetailCode } from "../gateway.ts"; - -export function isMissingOperatorReadScopeError(err: unknown): boolean { - if (!(err instanceof GatewayRequestError)) { - return false; - } - const detailCode = resolveGatewayErrorDetailCode(err); - // AUTH_UNAUTHORIZED is the current server signal for scope failures in RPC responses. - // The message-based fallback below catches cases where no detail code is set. - if (detailCode === ConnectErrorDetailCodes.AUTH_UNAUTHORIZED) { - return true; - } - // RPC scope failures do not yet expose a dedicated structured detail code. - // Fall back to the current gateway message until the protocol surfaces one. - return err.message.includes("missing scope: operator.read"); -} - -export function formatMissingOperatorReadScopeMessage(feature: string): string { - return `This connection is missing operator.read, so ${feature} cannot be loaded yet.`; -} diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts deleted file mode 100644 index b18e7b96c7c3..000000000000 --- a/ui/src/ui/controllers/sessions.test.ts +++ /dev/null @@ -1,3157 +0,0 @@ -// Control UI tests cover sessions behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { isSessionRunActive } from "../session-run-state.ts"; -import { - applyChatHistorySessionInfo, - applySessionsChangedEvent, - branchSessionFromCheckpoint, - createSessionAndRefresh, - deleteSessionsAndRefresh, - loadSessions, - patchSession, - parseSessionsFilterInteger, - restoreSessionFromCheckpoint, - subscribeSessions, - syncSelectedSessionMessageSubscription, - toggleSessionCompactionCheckpoints, - type SessionsState, -} from "./sessions.ts"; - -type RequestFn = (method: string, params?: unknown) => Promise; - -function createDeferred() { - let resolve: ((value: T) => void) | undefined; - let reject: ((reason?: unknown) => void) | undefined; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - if (!resolve || !reject) { - throw new Error("Expected deferred callbacks to be initialized"); - } - return { promise, resolve, reject }; -} - -if (!("window" in globalThis)) { - Object.assign(globalThis, { - window: { - confirm: () => false, - }, - }); -} - -function createState(request: RequestFn, overrides: Partial = {}): SessionsState { - return { - client: { request } as unknown as SessionsState["client"], - connected: true, - sessionsLoading: false, - sessionsResult: null, - sessionsError: null, - sessionsFilterActive: "0", - sessionsFilterLimit: "0", - sessionsIncludeGlobal: true, - sessionsIncludeUnknown: true, - sessionsShowArchived: false, - sessionsExpandedCheckpointKey: null, - sessionsCheckpointItemsByKey: {}, - sessionsCheckpointLoadingKey: null, - sessionsCheckpointBusyKey: null, - sessionsCheckpointErrorByKey: {}, - ...overrides, - }; -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("subscribeSessions", () => { - it("registers for session change events", async () => { - const request = vi.fn(async () => ({ subscribed: true })); - const state = createState(request); - - await subscribeSessions(state); - - expect(request).toHaveBeenCalledWith("sessions.subscribe", {}); - expect(state.sessionsError).toBeNull(); - }); -}); - -describe("parseSessionsFilterInteger", () => { - it("accepts safe decimal integer filters only", () => { - expect(parseSessionsFilterInteger("120")).toBe(120); - expect(parseSessionsFilterInteger(" 50 ")).toBe(50); - expect(parseSessionsFilterInteger("1e3")).toBe(0); - expect(parseSessionsFilterInteger("0x1000")).toBe(0); - expect(parseSessionsFilterInteger("1.5")).toBe(0); - expect(parseSessionsFilterInteger("9007199254740993")).toBe(0); - }); -}); - -describe("syncSelectedSessionMessageSubscription", () => { - it("subscribes to the selected session message stream", async () => { - const request = vi.fn(async () => ({ key: "agent:main:main" })); - const state = createState(request, { sessionKey: "agent:main:main" } as Partial< - SessionsState & { sessionKey: string } - >) as SessionsState & { sessionKey: string }; - - await syncSelectedSessionMessageSubscription(state); - - expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { - key: "agent:main:main", - }); - expect(state.chatSessionMessageSubscriptionKey).toBe("agent:main:main"); - expect(state.chatSessionMessageSubscriptionRequestedKey).toBe("agent:main:main"); - }); - - it("unsubscribes the previous selected session before switching streams", async () => { - const request = vi.fn(async () => ({ key: "agent:main:next" })); - const state = createState(request, { - sessionKey: "agent:main:next", - chatSessionMessageSubscriptionKey: "agent:main:previous", - } as Partial) as SessionsState & { - sessionKey: string; - }; - - await syncSelectedSessionMessageSubscription(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.messages.unsubscribe", { - key: "agent:main:previous", - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.subscribe", { - key: "agent:main:next", - }); - expect(state.chatSessionMessageSubscriptionKey).toBe("agent:main:next"); - expect(state.chatSessionMessageSubscriptionRequestedKey).toBe("agent:main:next"); - }); - - it("does not churn when the selected alias resolves to a canonical key", async () => { - const request = vi.fn(async () => ({ key: "agent:main:main" })); - const state = createState(request, { sessionKey: "main" } as Partial< - SessionsState & { sessionKey: string } - >) as SessionsState & { sessionKey: string }; - - await syncSelectedSessionMessageSubscription(state); - await syncSelectedSessionMessageSubscription(state); - - expect(request).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { key: "main" }); - expect(state.chatSessionMessageSubscriptionRequestedKey).toBe("main"); - expect(state.chatSessionMessageSubscriptionKey).toBe("agent:main:main"); - }); - - it("subscribes selected global message streams with the selected agent", async () => { - const request = vi.fn(async () => ({ key: "global" })); - const state = createState(request, { - sessionKey: "global", - assistantAgentId: "work", - } as Partial) as SessionsState & { sessionKey: string }; - - await syncSelectedSessionMessageSubscription(state); - - expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { - key: "global", - agentId: "work", - }); - expect(state.chatSessionMessageSubscriptionAgentId).toBe("work"); - }); - - it("keeps agent-scoped global alias subscriptions scoped for unsubscribe", async () => { - const request = vi.fn(async (method: string) => - method === "sessions.messages.subscribe" ? { key: "global" } : { subscribed: false }, - ); - const state = createState(request, { - sessionKey: "agent:work:main", - assistantAgentId: "main", - sessionsResult: { - ts: 1, - path: "/tmp/sessions.json", - count: 2, - sessions: [ - { key: "agent:work:main", kind: "global", updatedAt: 2 }, - { key: "agent:ops:main", kind: "global", updatedAt: 1 }, - ], - defaults: { modelProvider: null, model: null, contextTokens: null }, - totalCount: 2, - limit: 50, - offset: 0, - hasMore: false, - }, - } as Partial) as SessionsState & { sessionKey: string }; - - await syncSelectedSessionMessageSubscription(state); - state.sessionKey = "agent:ops:main"; - await syncSelectedSessionMessageSubscription(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.messages.subscribe", { - key: "agent:work:main", - agentId: "work", - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.unsubscribe", { - key: "global", - agentId: "work", - }); - expect(request).toHaveBeenNthCalledWith(3, "sessions.messages.subscribe", { - key: "agent:ops:main", - agentId: "ops", - }); - expect(state.chatSessionMessageSubscriptionAgentId).toBe("ops"); - }); - - it("uses the hello default agent for global subscriptions before agents load", async () => { - const request = vi.fn(async () => ({ key: "global" })); - const state = createState(request, { - sessionKey: "global", - hello: { snapshot: { sessionDefaults: { defaultAgentId: "ops" } } }, - } as Partial) as SessionsState & { sessionKey: string }; - - await syncSelectedSessionMessageSubscription(state); - - expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { - key: "global", - agentId: "ops", - }); - expect(state.chatSessionMessageSubscriptionAgentId).toBe("ops"); - }); - - it("ignores stale subscription completions after the selected session changes", async () => { - const firstSubscribe = createDeferred<{ key: string }>(); - const request = vi.fn(async (method: string, params?: unknown) => { - const key = (params as { key?: string } | undefined)?.key; - if (method === "sessions.messages.subscribe" && key === "agent:main:first") { - return await firstSubscribe.promise; - } - if (method === "sessions.messages.subscribe" && key === "agent:main:second") { - return { key: "agent:main:second" }; - } - if (method === "sessions.messages.unsubscribe") { - return { subscribed: false, key }; - } - throw new Error(`unexpected request: ${method} ${String(key)}`); - }); - const state = createState(request, { sessionKey: "agent:main:first" } as Partial< - SessionsState & { sessionKey: string } - >) as SessionsState & { sessionKey: string }; - - const firstSync = syncSelectedSessionMessageSubscription(state); - expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { - key: "agent:main:first", - }); - - state.sessionKey = "agent:main:second"; - await syncSelectedSessionMessageSubscription(state); - expect(state.chatSessionMessageSubscriptionRequestedKey).toBe("agent:main:second"); - expect(state.chatSessionMessageSubscriptionKey).toBe("agent:main:second"); - - firstSubscribe.resolve({ key: "agent:main:first" }); - await firstSync; - - expect(state.chatSessionMessageSubscriptionRequestedKey).toBe("agent:main:second"); - expect(state.chatSessionMessageSubscriptionKey).toBe("agent:main:second"); - expect(request).toHaveBeenCalledWith("sessions.messages.unsubscribe", { - key: "agent:main:first", - }); - }); - - it("cleans up stale selected-global subscriptions when only the selected agent changes", async () => { - const firstSubscribe = createDeferred<{ key: string }>(); - const request = vi.fn(async (method: string, params?: unknown) => { - const record = params as { key?: string; agentId?: string } | undefined; - if ( - method === "sessions.messages.subscribe" && - record?.key === "global" && - record.agentId === "work" - ) { - return await firstSubscribe.promise; - } - if ( - method === "sessions.messages.subscribe" && - record?.key === "global" && - record.agentId === "main" - ) { - return { key: "global" }; - } - if (method === "sessions.messages.unsubscribe") { - return { subscribed: false, key: record?.key }; - } - throw new Error(`unexpected request: ${method} ${String(record?.key)} ${record?.agentId}`); - }); - const state = createState(request, { - sessionKey: "global", - assistantAgentId: "work", - } as Partial) as SessionsState & { sessionKey: string }; - - const firstSync = syncSelectedSessionMessageSubscription(state); - expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { - key: "global", - agentId: "work", - }); - - state.assistantAgentId = "main"; - await syncSelectedSessionMessageSubscription(state); - expect(state.chatSessionMessageSubscriptionKey).toBe("global"); - expect(state.chatSessionMessageSubscriptionAgentId).toBe("main"); - - firstSubscribe.resolve({ key: "global" }); - await firstSync; - - expect(state.chatSessionMessageSubscriptionKey).toBe("global"); - expect(state.chatSessionMessageSubscriptionAgentId).toBe("main"); - expect(request).toHaveBeenCalledWith("sessions.messages.unsubscribe", { - key: "global", - agentId: "work", - }); - }); -}); - -describe("createSessionAndRefresh", () => { - it("creates a dashboard session and refreshes the session list", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.create") { - return { key: "agent:main:dashboard:abc" }; - } - if (method === "sessions.list") { - return { - ts: 2, - path: "(multiple)", - count: 1, - defaults: {}, - sessions: [{ key: "agent:main:dashboard:abc", kind: "direct", updatedAt: 2 }], - }; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request); - - const key = await createSessionAndRefresh( - state, - { agentId: "main", parentSessionKey: "agent:main:main" }, - { activeMinutes: 0, limit: 0, includeGlobal: true, includeUnknown: true }, - ); - - expect(key).toBe("agent:main:dashboard:abc"); - expect(request).toHaveBeenNthCalledWith(1, "sessions.create", { - agentId: "main", - parentSessionKey: "agent:main:main", - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:dashboard:abc"); - expect(state.sessionsLoading).toBe(false); - }); - - it("keeps the current state when create does not return a key", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.create") { - return {}; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request); - - const key = await createSessionAndRefresh(state); - - expect(key).toBeNull(); - expect(state.sessionsError).toBe("Error: sessions.create returned no key"); - expect(state.sessionsLoading).toBe(false); - expect(request).toHaveBeenCalledTimes(1); - }); - - it("does not start a create mutation while sessions are loading", async () => { - const request = vi.fn(async () => ({ key: "agent:main:dashboard:abc" })); - const state = createState(request, { sessionsLoading: true }); - - const key = await createSessionAndRefresh(state); - - expect(key).toBeNull(); - expect(request).not.toHaveBeenCalled(); - }); -}); - -describe("deleteSessionsAndRefresh", () => { - it("deletes multiple sessions and refreshes", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.delete") { - return { ok: true }; - } - if (method === "sessions.list") { - return undefined; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request); - vi.spyOn(window, "confirm").mockReturnValue(true); - - const deleted = await deleteSessionsAndRefresh(state, ["key-a", "key-b"]); - - expect(deleted).toEqual(["key-a", "key-b"]); - expect(request).toHaveBeenCalledTimes(3); - expect(request).toHaveBeenNthCalledWith(1, "sessions.delete", { - key: "key-a", - deleteTranscript: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.delete", { - key: "key-b", - deleteTranscript: true, - }); - expect(request).toHaveBeenNthCalledWith(3, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - expect(state.sessionsLoading).toBe(false); - }); - - it("passes selected agent scope for global deletes", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.delete") { - return { ok: true }; - } - if (method === "sessions.list") { - return undefined; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request, { - assistantAgentId: "work", - agentsList: { defaultId: "main" }, - }); - vi.spyOn(window, "confirm").mockReturnValue(true); - - const deleted = await deleteSessionsAndRefresh(state, ["global"]); - - expect(deleted).toEqual(["global"]); - expect(request).toHaveBeenNthCalledWith(1, "sessions.delete", { - key: "global", - agentId: "work", - deleteTranscript: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - agentId: "work", - }); - }); - - it("returns empty array when user cancels", async () => { - const request = vi.fn(async () => undefined); - const state = createState(request); - vi.spyOn(window, "confirm").mockReturnValue(false); - - const deleted = await deleteSessionsAndRefresh(state, ["key-a"]); - - expect(deleted).toStrictEqual([]); - expect(request).not.toHaveBeenCalled(); - }); - - it("returns partial results when some deletes fail", async () => { - const request = vi.fn(async (method: string, params?: unknown) => { - if (method === "sessions.delete") { - const p = params as { key: string }; - if (p.key === "key-b" || p.key === "key-c") { - throw new Error(`delete failed: ${p.key}`); - } - return { ok: true }; - } - if (method === "sessions.list") { - return undefined; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request); - vi.spyOn(window, "confirm").mockReturnValue(true); - - const deleted = await deleteSessionsAndRefresh(state, ["key-a", "key-b", "key-c", "key-d"]); - - expect(deleted).toEqual(["key-a", "key-d"]); - expect(state.sessionsError).toBe("Error: delete failed: key-b; Error: delete failed: key-c"); - expect(state.sessionsLoading).toBe(false); - }); - - it("returns empty array when already loading", async () => { - const request = vi.fn(async () => undefined); - const state = createState(request, { sessionsLoading: true }); - - const deleted = await deleteSessionsAndRefresh(state, ["key-a"]); - - expect(deleted).toStrictEqual([]); - expect(request).not.toHaveBeenCalled(); - }); - - it("queues refreshes requested during delete without releasing mutation loading", async () => { - let resolveDelete: () => void = () => undefined; - let signalDeleteStarted: () => void = () => undefined; - const deleteStarted = new Promise((resolve) => { - signalDeleteStarted = resolve; - }); - const deleteBlocker = new Promise((resolve) => { - resolveDelete = resolve; - }); - const request = vi.fn(async (method: string) => { - if (method === "sessions.delete") { - signalDeleteStarted(); - await deleteBlocker; - return { ok: true }; - } - if (method === "sessions.list") { - return { - ts: 2, - path: "(multiple)", - count: 0, - defaults: {}, - sessions: [], - }; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request); - vi.spyOn(window, "confirm").mockReturnValue(true); - - const deletePromise = deleteSessionsAndRefresh(state, ["key-a"]); - await deleteStarted; - expect(state.sessionsLoading).toBe(true); - - await loadSessions(state); - expect(request).toHaveBeenCalledTimes(1); - expect(state.sessionsLoading).toBe(true); - - resolveDelete(); - const deleted = await deletePromise; - - expect(deleted).toEqual(["key-a"]); - expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - expect(state.sessionsLoading).toBe(false); - }); -}); - -describe("patchSession", () => { - it("passes selected agent scope for global patches", async () => { - const request = vi.fn(async () => ({ ok: true })); - const state = createState(request, { - assistantAgentId: "work", - agentsList: { defaultId: "main" }, - }); - - await patchSession(state, "global", { fastMode: true }); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", { - key: "global", - agentId: "work", - fastMode: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - agentId: "work", - }); - }); - - it("keeps non-Sessions patch refreshes active-only after enabling archived view", async () => { - const request = vi.fn(async () => ({ ok: true })); - const state = createState(request, { - tab: "overview", - sessionsShowArchived: true, - }); - - await patchSession(state, "agent:main:main", { fastMode: true }); - - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - }); -}); - -describe("loadSessions", () => { - it("records the loaded archive scope separately from the Sessions view preference", async () => { - const request = vi.fn(async () => ({ - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 2 }], - })); - const state = createState(request, { tab: "sessions", sessionsShowArchived: true }); - - await loadSessions(state, { showArchived: false }); - - expect(state.sessionsResultShowArchived).toBe(false); - expect(request).toHaveBeenCalledWith("sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - }); - - it("reconciles the current chat without replacing the Sessions view result", async () => { - const request = vi.fn(async () => ({ - ts: 2, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:active", - kind: "direct", - updatedAt: 2, - hasActiveRun: false, - status: "done", - }, - ], - })); - const archivedResult = { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:archived", - kind: "direct" as const, - updatedAt: 1, - archived: true, - }, - ], - }; - const state = createState(request, { - tab: "sessions", - sessionKey: "agent:main:active", - chatRunId: "run-active", - sessionsShowArchived: true, - sessionsResultShowArchived: true, - sessionsResult: archivedResult, - }); - - await loadSessions(state, { - showArchived: false, - preserveSessionsViewResult: true, - }); - - expect(state.sessionsResult).toBe(archivedResult); - expect(state.chatRunId).toBeNull(); - }); - - it("hides explicitly archived sessions by default", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 2, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { key: "agent:main:main", kind: "direct", updatedAt: 2 }, - { - key: "agent:main:subagent:archived", - kind: "direct", - updatedAt: 1, - status: "done", - archived: true, - }, - ], - }; - }); - const state = createState(request); - - await loadSessions(state); - - expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ - "agent:main:main", - ]); - expect(state.sessionsResult?.count).toBe(1); - }); - - it("shows only archived sessions in the archived view", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 2, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { key: "agent:main:main", kind: "direct", updatedAt: 2 }, - { - key: "agent:main:subagent:archived", - kind: "direct", - updatedAt: 1, - status: "done", - archived: true, - }, - ], - }; - }); - const state = createState(request, { tab: "sessions", sessionsShowArchived: true }); - - await loadSessions(state); - - expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ - "agent:main:subagent:archived", - ]); - expect(state.sessionsResult?.count).toBe(1); - }); - - it("keeps terminal non-archived sessions visible by default", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 2, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { key: "agent:main:main", kind: "direct", updatedAt: 2 }, - { - key: "agent:main:subagent:done", - kind: "direct", - updatedAt: 1, - status: "done", - }, - ], - }; - }); - const state = createState(request); - - await loadSessions(state); - - expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ - "agent:main:main", - "agent:main:subagent:done", - ]); - expect(state.sessionsResult?.count).toBe(2); - }); - - it.each(["overview", "workboard"])( - "keeps %s loads active-only after the Sessions archived filter was enabled", - async (tab) => { - const request = vi.fn(async () => ({ - ts: 1, - path: "(multiple)", - count: 0, - defaults: {}, - sessions: [], - })); - const state = createState(request, { tab, sessionsShowArchived: true }); - - await loadSessions(state); - - expect(request).toHaveBeenCalledWith("sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - expect(state.sessionsResultShowArchived).toBe(false); - }, - ); - - it("keeps local run tracking while the session list reports an active terminal snapshot", async () => { - vi.useFakeTimers(); - try { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "main", - kind: "direct", - updatedAt: 2, - hasActiveRun: true, - status: "done", - }, - ], - }; - }); - const state = createState(request) as SessionsState & { - sessionKey: string; - chatRunId: string | null; - chatStream: string | null; - chatStreamStartedAt: number | null; - chatRunStatus?: unknown; - compactionStatus?: unknown; - compactionClearTimer?: ReturnType | null; - fallbackStatus?: unknown; - fallbackClearTimer?: ReturnType | null; - }; - state.sessionKey = "main"; - state.chatRunId = "run-1"; - state.chatStream = "Visible answer"; - state.chatStreamStartedAt = 123; - state.compactionStatus = { - phase: "active", - runId: "run-1", - startedAt: 100, - completedAt: null, - }; - state.compactionClearTimer = setTimeout(() => undefined, 1_000); - state.fallbackStatus = { - selected: "openai/gpt-5.5", - active: "anthropic/claude-sonnet-4-6", - attempts: [], - occurredAt: 100, - }; - state.fallbackClearTimer = setTimeout(() => undefined, 1_000); - - await loadSessions(state); - - expect(state.chatRunId).toBe("run-1"); - expect(state.chatStream).toBe("Visible answer"); - expect(state.chatStreamStartedAt).toBe(123); - expect(state.compactionStatus).toMatchObject({ phase: "active", runId: "run-1" }); - expect(state.compactionClearTimer).not.toBeNull(); - expect(state.fallbackStatus).toMatchObject({ - selected: "openai/gpt-5.5", - active: "anthropic/claude-sonnet-4-6", - }); - expect(state.fallbackClearTimer).not.toBeNull(); - expect(state.chatRunStatus).toBeUndefined(); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps stale running session list rows idle when no live run remains", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 2, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "main", - kind: "direct", - updatedAt: 2, - hasActiveRun: false, - status: "running", - }, - ], - }; - }); - const state = createState(request, { - sessionKey: "main", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "main", - kind: "direct", - updatedAt: 1, - hasActiveRun: false, - status: "done", - }, - ], - }, - } as Partial); - - await loadSessions(state); - - const current = state.sessionsResult?.sessions[0]; - expect(current).toMatchObject({ - key: "main", - hasActiveRun: false, - status: "running", - }); - expect(isSessionRunActive(current!)).toBe(false); - }); - - it("omits the active-window cutoff when archived sessions are shown", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }; - }); - const state = createState(request, { - tab: "sessions", - sessionsFilterActive: "120", - sessionsFilterLimit: "50", - sessionsShowArchived: true, - }); - - await loadSessions(state); - - expect(request).toHaveBeenCalledWith("sessions.list", { - limit: 50, - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - archived: true, - }); - }); - - it("applies the active-window cutoff while archived sessions are hidden", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }; - }); - const state = createState(request, { - sessionsFilterActive: "120", - sessionsFilterLimit: "50", - sessionsShowArchived: false, - }); - - await loadSessions(state); - - expect(request).toHaveBeenCalledWith("sessions.list", { - activeMinutes: 120, - limit: 50, - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - }); - - it("ignores non-decimal and unsafe sessions filter numbers", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }; - }); - const state = createState(request, { - sessionsFilterActive: "1e3", - sessionsFilterLimit: "9007199254740993", - sessionsShowArchived: false, - }); - - await loadSessions(state); - - expect(request).toHaveBeenCalledWith("sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - }); - - it("ignores unsafe numeric session filter overrides", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }; - }); - const state = createState(request); - - await loadSessions(state, { - activeMinutes: Number.MAX_SAFE_INTEGER + 1, - limit: Number.MAX_SAFE_INTEGER + 1, - includeGlobal: true, - includeUnknown: true, - }); - - expect(request).toHaveBeenCalledWith("sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - }); - - it("forwards scoped agent refreshes to sessions.list", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }; - }); - const state = createState(request); - - await loadSessions(state, { - activeMinutes: 0, - limit: 0, - includeGlobal: true, - includeUnknown: true, - agentId: "ops", - }); - - expect(request).toHaveBeenCalledWith("sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - agentId: "ops", - }); - }); - - it("forwards search and offset overrides to sessions.list", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 1, - path: "(multiple)", - count: 1, - totalCount: 3, - limitApplied: 1, - offset: 2, - nextOffset: null, - hasMore: false, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:dashboard:telegram", kind: "direct", updatedAt: 3 }], - }; - }); - const state = createState(request); - - await loadSessions(state, { - activeMinutes: 0, - limit: 1, - offset: 2, - search: "telegram", - includeGlobal: true, - includeUnknown: true, - }); - - expect(request).toHaveBeenCalledWith("sessions.list", { - limit: 1, - offset: 2, - search: "telegram", - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - }); - - it("appends paged session rows without duplicating existing rows", async () => { - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - return { - ts: 2, - path: "(multiple)", - count: 2, - totalCount: 4, - limitApplied: 2, - offset: 2, - nextOffset: null, - hasMore: false, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { key: "agent:main:dashboard:b", kind: "direct", updatedAt: 2 }, - { key: "agent:main:dashboard:c", kind: "direct", updatedAt: 1 }, - ], - }; - }); - const state = createState(request, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 2, - totalCount: 4, - limitApplied: 2, - nextOffset: 2, - hasMore: true, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { key: "agent:main:dashboard:a", kind: "direct", updatedAt: 4 }, - { key: "agent:main:dashboard:b", kind: "direct", updatedAt: 3 }, - ], - }, - }); - - await loadSessions(state, { limit: 2, offset: 2, append: true }); - - expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ - "agent:main:dashboard:a", - "agent:main:dashboard:b", - "agent:main:dashboard:c", - ]); - expect(state.sessionsResult?.count).toBe(3); - expect(state.sessionsResult?.totalCount).toBe(4); - expect(state.sessionsResult?.hasMore).toBe(false); - expect(state.sessionsResult?.nextOffset).toBeNull(); - }); - - it("coalesces overlapping refreshes instead of dropping the latest request", async () => { - let resolveFirst: () => void = () => undefined; - const firstBlocker = new Promise((resolve) => { - resolveFirst = resolve; - }); - const request = vi.fn(async (method: string) => { - if (method !== "sessions.list") { - throw new Error(`unexpected method: ${method}`); - } - if (request.mock.calls.length === 1) { - await firstBlocker; - return { - ts: 1, - path: "(multiple)", - count: 0, - defaults: {}, - sessions: [], - }; - } - return { - ts: 2, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }; - }); - const state = createState(request, { - sessionsFilterActive: "30", - sessionsFilterLimit: "10", - }); - - const first = loadSessions(state); - const second = loadSessions(state, { activeMinutes: 0, limit: 0 }); - expect(request).toHaveBeenCalledTimes(1); - - resolveFirst(); - await Promise.all([first, second]); - - expect(request).toHaveBeenCalledTimes(2); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", { - activeMinutes: 30, - limit: 10, - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - expect(state.sessionsResult?.ts).toBe(2); - expect(state.sessionsLoading).toBe(false); - }); - - it("refreshes expanded checkpoint cards when the row summary changes", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.list") { - return { - ts: 1, - path: "(multiple)", - count: 1, - defaults: {}, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - compactionCheckpointCount: 1, - latestCompactionCheckpoint: { - checkpointId: "checkpoint-new", - createdAt: 20, - }, - }, - ], - }; - } - if (method === "sessions.compaction.list") { - return { - ok: true, - key: "agent:main:main", - checkpoints: [ - { - checkpointId: "checkpoint-new", - sessionKey: "agent:main:main", - sessionId: "session-1", - createdAt: 20, - reason: "manual", - }, - ], - }; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request, { - sessionsExpandedCheckpointKey: "agent:main:main", - sessionsResult: { - ts: 0, - path: "(multiple)", - count: 1, - defaults: {}, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 0, - compactionCheckpointCount: 3, - latestCompactionCheckpoint: { - checkpointId: "checkpoint-old", - createdAt: 10, - }, - }, - ], - } as never, - sessionsCheckpointItemsByKey: { - "agent:main:main": [ - { - checkpointId: "checkpoint-old", - sessionKey: "agent:main:main", - sessionId: "session-old", - createdAt: 10, - reason: "manual", - }, - ] as never, - }, - }); - - await loadSessions(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.compaction.list", { - key: "agent:main:main", - }); - expect( - state.sessionsCheckpointItemsByKey["agent:main:main"]?.map((item) => item.checkpointId), - ).toEqual(["checkpoint-new"]); - }); - - it("requests selected global checkpoints with the selected agent", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.compaction.list") { - return { ok: true, key: "global", checkpoints: [] }; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request, { - sessionKey: "global", - assistantAgentId: "work", - } as Partial); - - await toggleSessionCompactionCheckpoints(state, "global"); - - expect(request).toHaveBeenCalledWith("sessions.compaction.list", { - key: "global", - agentId: "work", - }); - }); - - it("sends selected global agent scope for checkpoint branch and restore", async () => { - vi.spyOn(window, "confirm").mockReturnValue(true); - const request = vi.fn(async (method: string) => { - if (method === "sessions.list") { - return { ts: 1, path: "(multiple)", count: 0, defaults: {}, sessions: [] }; - } - if (method === "sessions.compaction.branch") { - return { ok: true, sourceKey: "global", key: "agent:work:dashboard:1" }; - } - if (method === "sessions.compaction.restore") { - return { ok: true, key: "global" }; - } - throw new Error(`unexpected method: ${method}`); - }); - const state = createState(request, { - sessionKey: "global", - assistantAgentId: "work", - } as Partial); - - await branchSessionFromCheckpoint(state, "global", "checkpoint-1"); - await restoreSessionFromCheckpoint(state, "global", "checkpoint-1"); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.compaction.branch", { - key: "global", - agentId: "work", - checkpointId: "checkpoint-1", - }); - expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - agentId: "work", - }); - expect(request).toHaveBeenNthCalledWith(3, "sessions.compaction.restore", { - key: "global", - agentId: "work", - checkpointId: "checkpoint-1", - }); - expect(request).toHaveBeenNthCalledWith(4, "sessions.list", { - includeGlobal: true, - includeUnknown: true, - configuredAgentsOnly: true, - agentId: "work", - }); - }); -}); - -describe("applySessionsChangedEvent", () => { - it.each([ - { archived: true, previousArchived: false }, - { archived: false, previousArchived: true }, - ])( - "tracks selected chat archive state when a remote event sets archived=$archived", - ({ archived, previousArchived }) => { - const key = "agent:main:review"; - const state = createState(async () => undefined, { - sessionKey: key, - selectedChatSessionArchived: previousArchived, - sessionsResultShowArchived: previousArchived, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key, kind: "direct", updatedAt: 1, archived: previousArchived }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: key, - sessionId: "sess-review", - archived, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "deleted" }); - expect(state.selectedChatSessionArchived).toBe(archived); - }, - ); - - it("reconciles against the loaded active scope after leaving archived view enabled", () => { - const state = createState(async () => undefined, { - sessionsShowArchived: true, - sessionsResultShowArchived: false, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:review", kind: "direct", updatedAt: 1 }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:review", - sessionId: "sess-review", - status: "done", - archived: false, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions).toHaveLength(1); - }); - - it("replaces stale effective fast metadata from session change events", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - effectiveFastMode: "auto", - effectiveFastModeSource: "config", - fastAutoOnSeconds: 30, - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - reason: "patch", - ts: 2, - fastMode: false, - effectiveFastMode: false, - effectiveFastModeSource: "session", - fastAutoOnSeconds: 30, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - fastMode: false, - effectiveFastMode: false, - effectiveFastModeSource: "session", - fastAutoOnSeconds: 30, - }); - }); - - it("removes deleted sessions instead of keeping archived rows visible", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 2, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { key: "agent:main:main", kind: "direct", updatedAt: 1 }, - { key: "agent:main:old", kind: "direct", updatedAt: 1 }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:old", - reason: "delete", - ts: 2, - }); - - expect(applied).toEqual({ - applied: true, - change: "deleted", - deletedSession: { key: "agent:main:old", agentId: "main", selected: false }, - }); - expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ - "agent:main:main", - ]); - expect(state.sessionsResult?.count).toBe(1); - }); - - it("removes deleted sessions from cached chat agent targets", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }], - }, - chatAgentSessionRowsByAgent: { - work: [ - { key: "agent:work:dashboard:deleted", kind: "direct", updatedAt: 3 }, - { key: "agent:work:main", kind: "direct", updatedAt: 1 }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:work:dashboard:deleted", - reason: "delete", - ts: 2, - }); - - expect(applied).toEqual({ - applied: true, - change: "deleted", - deletedSession: { - key: "agent:work:dashboard:deleted", - agentId: "work", - selected: false, - }, - }); - expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ - "agent:main:main", - ]); - expect(state.chatAgentSessionRowsByAgent?.work?.map((session) => session.key)).toEqual([ - "agent:work:main", - ]); - }); - - it("reports deletion of the selected chat even when its row is not cached", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:main:archived", - selectedChatSessionArchived: true, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:archived", - reason: "delete", - ts: 2, - }); - - expect(applied).toEqual({ - applied: true, - change: "deleted", - deletedSession: { key: "agent:main:archived", agentId: "main", selected: true }, - }); - }); - - it("reports deletion of the selected chat before a session list is loaded", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:ops:archived", - selectedChatSessionArchived: true, - sessionsResult: null, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:ops:archived", - reason: "delete", - ts: 2, - }); - - expect(applied).toEqual({ - applied: true, - change: "deleted", - deletedSession: { key: "agent:ops:archived", agentId: "ops", selected: true }, - }); - }); - - it("matches canonical global deletion events to the selected agent alias", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:work:main", - selectedChatSessionArchived: true, - sessionsResult: null, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "global", - agentId: "work", - reason: "delete", - ts: 2, - }); - - expect(applied).toEqual({ - applied: true, - change: "deleted", - deletedSession: { key: "global", agentId: "work", selected: true }, - }); - }); - - it("keeps out-of-scope session events out of scoped results", () => { - const state = createState(async () => undefined, { - sessionsResultAgentId: "work", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:work:main", kind: "direct", updatedAt: 1 }], - }, - chatAgentSessionRowsByAgent: { - ops: [{ key: "agent:ops:old", kind: "direct", updatedAt: 1 }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - session: { - key: "agent:ops:main", - kind: "direct", - agentId: "ops", - updatedAt: 2, - }, - reason: "message", - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "inserted" }); - expect(state.sessionsResult?.count).toBe(1); - expect(state.sessionsResult?.sessions.map((session) => session.key)).toEqual([ - "agent:work:main", - ]); - expect(state.chatAgentSessionRowsByAgent?.ops?.map((session) => session.key)).toEqual([ - "agent:ops:main", - "agent:ops:old", - ]); - }); - - it("does not synthesize new sessions from partial events without a store-backed row", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:ephemeral", - reason: "message", - ts: 2, - }); - - expect(applied).toEqual({ applied: false }); - expect(state.sessionsResult?.sessions).toStrictEqual([]); - }); - - it("applies partial events only to existing source-of-truth rows", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - reason: "message", - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions).toEqual([ - { key: "agent:main:main", kind: "direct", updatedAt: 1 }, - ]); - }); - - it("ignores selected-global session events for another agent", () => { - const state = createState(async () => undefined, { - sessionKey: "global", - assistantAgentId: "work", - agentsList: { defaultId: "main" }, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "global", kind: "global", updatedAt: 1, status: "done" }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "global", - agentId: "main", - reason: "send", - ts: 2, - status: "running", - }); - - expect(applied).toEqual({ applied: false }); - expect(state.sessionsResult?.sessions).toEqual([ - { key: "global", kind: "global", updatedAt: 1, status: "done" }, - ]); - }); - - it("applies selected-global session events for the current agent", () => { - const state = createState(async () => undefined, { - sessionKey: "global", - assistantAgentId: "work", - agentsList: { defaultId: "main" }, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "global", kind: "global", updatedAt: 1, status: "done" }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "global", - agentId: "work", - reason: "send", - ts: 2, - status: "running", - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions[0]).toEqual( - expect.objectContaining({ key: "global", status: "running" }), - ); - }); - - it("applies goal updates from partial events to existing rows", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - reason: "goal", - goal: { - objective: "Land the web goal UI", - status: "active", - usage: { totalTokens: 12_345 }, - tokenBudget: 50_000, - }, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions[0]?.goal).toMatchObject({ - objective: "Land the web goal UI", - status: "active", - tokenBudget: 50_000, - }); - }); - - it("clears goal updates from partial events with explicit null goals", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - goal: { - schemaVersion: 1, - id: "goal-1", - objective: "Land the web goal UI", - status: "active", - createdAt: 1, - updatedAt: 1, - tokenStart: 0, - tokensUsed: 10, - continuationTurns: 0, - }, - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - reason: "goal", - goal: null, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions[0]?.goal).toBeUndefined(); - }); - - it("drops rows that become explicitly archived while archived sessions are hidden", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:subagent:done", kind: "direct", updatedAt: 1 }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:subagent:done", - sessionId: "sess-done", - status: "done", - archived: true, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "deleted" }); - expect(state.sessionsResult?.sessions).toStrictEqual([]); - }); - - it("clears pin timestamps from unpin events", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:project", - kind: "direct", - updatedAt: 1, - pinned: true, - pinnedAt: 2, - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:project", - sessionId: "sess-project", - pinned: false, - pinnedAt: null, - ts: 3, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ pinned: false }); - expect(state.sessionsResult?.sessions[0]?.pinnedAt).toBeUndefined(); - }); - - it("keeps terminal status updates visible while archived sessions are hidden", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:subagent:done", kind: "direct", updatedAt: 1 }], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:subagent:done", - sessionId: "sess-done", - status: "done", - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions).toHaveLength(1); - expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:subagent:done"); - expect(state.sessionsResult?.sessions[0]?.status).toBe("done"); - }); - - it("clears preserved active-run flags on terminal status updates", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: true, - status: "running", - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - sessionId: "sess-main", - status: "done", - endedAt: 2, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - hasActiveRun: false, - status: "done", - endedAt: 2, - }); - }); - - it("keeps the local run active when a transcript snapshot reports plugin finalization pending", () => { - const state = { - ...createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: true, - status: "running", - }, - ], - }, - }), - sessionKey: "agent:main:main", - chatRunId: "run-before-finalize", - } as SessionsState & { sessionKey: string; chatRunId: string | null }; - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - session: { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - status: "done", - hasActiveRun: true, - }, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.chatRunId).toBe("run-before-finalize"); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - status: "done", - hasActiveRun: true, - }); - }); - - it("clears the local chat run when an applied websocket patch makes the current session terminal", () => { - const requestUpdate = vi.fn(); - const state: SessionsState & { - sessionKey: string; - chatRunId: string | null; - chatStream: string | null; - chatStreamStartedAt: number | null; - chatRunStatus?: unknown; - requestUpdate: () => void; - } = { - ...createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: true, - status: "running", - }, - ], - }, - }), - sessionKey: "agent:super:main", - chatRunId: "run-1", - chatStream: "", - chatStreamStartedAt: 1, - requestUpdate, - }; - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:main", - sessionId: "sess-main", - runId: "run-1", - status: "done", - hasActiveRun: false, - endedAt: 2, - ts: 2, - }); - - expect(applied).toEqual({ - applied: true, - change: "updated", - clearedChatRun: true, - clearedChatRunStatus: { - phase: "done", - runId: "run-1", - sessionKey: "agent:super:main", - }, - }); - expect(state.chatRunId).toBeNull(); - expect(state.chatStream).toBeNull(); - expect(state.chatStreamStartedAt).toBeNull(); - expect(state.chatRunStatus).toBeUndefined(); - expect(requestUpdate).toHaveBeenCalled(); - }); - - it("clears the local chat run when a lifecycle patch maps the client run id", () => { - const requestUpdate = vi.fn(); - const state: SessionsState & { - sessionKey: string; - chatRunId: string | null; - chatStream: string | null; - chatStreamStartedAt: number | null; - chatRunStatus?: unknown; - requestUpdate: () => void; - } = { - ...createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: true, - status: "running", - }, - ], - }, - }), - sessionKey: "agent:super:main", - chatRunId: "client-run-1", - chatStream: "", - chatStreamStartedAt: 1, - requestUpdate, - }; - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:main", - sessionId: "sess-main", - runId: "agent-run-1", - clientRunId: "client-run-1", - status: "done", - hasActiveRun: false, - endedAt: 2, - ts: 2, - }); - - expect(applied).toEqual({ - applied: true, - change: "updated", - clearedChatRun: true, - clearedChatRunStatus: { - phase: "done", - runId: "client-run-1", - sessionKey: "agent:super:main", - }, - }); - expect(state.chatRunId).toBeNull(); - expect(state.chatStream).toBeNull(); - expect(state.chatStreamStartedAt).toBeNull(); - expect(state.chatRunStatus).toBeUndefined(); - expect(requestUpdate).toHaveBeenCalled(); - }); - - it("does not clear a new local run from a send patch with stale terminal status", () => { - const requestUpdate = vi.fn(); - const state: SessionsState & { - sessionKey: string; - chatRunId: string | null; - chatStream: string | null; - chatStreamStartedAt: number | null; - requestUpdate: () => void; - } = { - ...createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: false, - status: "done", - }, - ], - }, - }), - sessionKey: "agent:super:main", - chatRunId: "run-new", - chatStream: "", - chatStreamStartedAt: 3, - requestUpdate, - }; - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:main", - sessionId: "sess-main", - reason: "send", - status: "done", - hasActiveRun: true, - updatedAt: 4, - ts: 4, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.chatRunId).toBe("run-new"); - expect(state.chatStream).toBe(""); - expect(state.chatStreamStartedAt).toBe(3); - expect(requestUpdate).not.toHaveBeenCalled(); - }); - - it("does not clear a newer local run from a runless older terminal patch", () => { - const requestUpdate = vi.fn(); - const state: SessionsState & { - sessionKey: string; - chatRunId: string | null; - chatStream: string | null; - chatStreamStartedAt: number | null; - requestUpdate: () => void; - } = { - ...createState(async () => undefined, { - sessionsResult: { - ts: 10, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 10, - hasActiveRun: true, - status: "running", - }, - ], - }, - }), - sessionKey: "agent:super:main", - chatRunId: "run-new", - chatStream: "", - chatStreamStartedAt: 20, - requestUpdate, - }; - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:main", - sessionId: "sess-main", - status: "done", - hasActiveRun: false, - endedAt: 12, - updatedAt: 12, - ts: 12, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.chatRunId).toBe("run-new"); - expect(state.chatStream).toBe(""); - expect(state.chatStreamStartedAt).toBe(20); - expect(requestUpdate).not.toHaveBeenCalled(); - }); - - it("does not clear a newer local run from an older terminal websocket patch", () => { - const requestUpdate = vi.fn(); - const state: SessionsState & { - sessionKey: string; - chatRunId: string | null; - chatStream: string | null; - chatStreamStartedAt: number | null; - requestUpdate: () => void; - } = { - ...createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: true, - status: "running", - }, - ], - }, - }), - sessionKey: "agent:super:main", - chatRunId: "run-new", - chatStream: "", - chatStreamStartedAt: 3, - requestUpdate, - }; - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:main", - sessionId: "sess-main", - runId: "run-old", - status: "done", - hasActiveRun: false, - endedAt: 2, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.chatRunId).toBe("run-new"); - expect(state.chatStream).toBe(""); - expect(state.chatStreamStartedAt).toBe(3); - expect(requestUpdate).not.toHaveBeenCalled(); - }); - - it("does not clear a new local run from unrelated session updates", () => { - const requestUpdate = vi.fn(); - const state: SessionsState & { - sessionKey: string; - chatRunId: string | null; - chatStream: string | null; - chatStreamStartedAt: number | null; - requestUpdate: () => void; - } = { - ...createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: false, - status: "done", - }, - ], - }, - }), - sessionKey: "agent:super:main", - chatRunId: "run-2", - chatStream: "", - chatStreamStartedAt: 3, - requestUpdate, - }; - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:side", - sessionId: "sess-side", - kind: "direct", - status: "running", - hasActiveRun: true, - updatedAt: 4, - ts: 4, - }); - - expect(applied).toEqual({ applied: true, change: "inserted" }); - expect(state.chatRunId).toBe("run-2"); - expect(state.chatStream).toBe(""); - expect(state.chatStreamStartedAt).toBe(3); - expect(requestUpdate).not.toHaveBeenCalled(); - }); - - it("keeps stale running session events idle after a local terminal reconcile", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: false, - status: "done", - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:main", - sessionId: "sess-main", - phase: "message", - status: "running", - updatedAt: 2, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - const current = state.sessionsResult?.sessions[0]; - expect(current).toMatchObject({ - key: "agent:super:main", - hasActiveRun: false, - status: "running", - }); - expect(isSessionRunActive(current!)).toBe(false); - }); - - it("revives active state when a new lifecycle start follows stale idle state", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:super:main", - kind: "direct", - updatedAt: 1, - hasActiveRun: false, - status: "done", - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:super:main", - sessionId: "sess-main", - phase: "start", - status: "running", - startedAt: 2, - updatedAt: 2, - ts: 2, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - const current = state.sessionsResult?.sessions[0]; - expect(current).toMatchObject({ - key: "agent:super:main", - hasActiveRun: true, - status: "running", - }); - expect(isSessionRunActive(current!)).toBe(true); - }); - - it("updates fresh context usage from websocket event payloads", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: "openai", model: "gpt-5.4", contextTokens: 200_000 }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - totalTokens: 20_000, - totalTokensFresh: true, - contextTokens: 200_000, - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - sessionId: "sess-main", - ts: 2, - totalTokens: 190_000, - totalTokensFresh: true, - contextTokens: 200_000, - model: "gpt-5.4", - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.ts).toBe(2); - expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:main"); - expect(state.sessionsResult?.sessions[0]?.totalTokens).toBe(190_000); - expect(state.sessionsResult?.sessions[0]?.totalTokensFresh).toBe(true); - expect(state.sessionsResult?.sessions[0]?.contextTokens).toBe(200_000); - expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5.4"); - }); - - it("clears old token totals when the gateway marks the measurement stale", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: 200_000 }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - totalTokens: 190_000, - totalTokensFresh: true, - contextTokens: 200_000, - }, - ], - }, - }); - - applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - sessionId: "sess-main", - totalTokensFresh: false, - contextTokens: 200_000, - }); - - expect(state.sessionsResult?.sessions[0]?.totalTokens).toBeUndefined(); - expect(state.sessionsResult?.sessions[0]?.totalTokensFresh).toBe(false); - expect(state.sessionsResult?.sessions[0]?.contextTokens).toBe(200_000); - }); - - it("keeps richer token metadata when applying lightweight chat history session info", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: "openai", model: "gpt-5.4", contextTokens: 200_000 }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - totalTokens: 190_000, - totalTokensFresh: true, - contextTokens: 200_000, - }, - ], - }, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - totalTokens: undefined, - totalTokensFresh: false, - contextTokens: undefined, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - key: "agent:main:main", - updatedAt: 2, - status: "done", - hasActiveRun: false, - totalTokens: 190_000, - totalTokensFresh: true, - contextTokens: 200_000, - }); - }); - - it("retains selected chat archive status outside active-only results", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:main:archived", - sessionsResultShowArchived: false, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:archived", - kind: "direct", - updatedAt: 1, - archived: true, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(false); - expect(state.selectedChatSessionArchived).toBe(true); - expect(state.sessionsResult?.sessions).toStrictEqual([]); - }); - - it("does not create visible rows from synthetic chat history session info", () => { - const state = createState(async () => undefined, { - sessionsResult: null, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:missing", - kind: "direct", - updatedAt: null, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(false); - expect(state.sessionsResult).toBeNull(); - }); - - it("keeps history defaults when ignoring synthetic chat history session rows", () => { - const state = createState(async () => undefined, { - sessionsResult: null, - }); - - const applied = applyChatHistorySessionInfo( - state, - { - key: "agent:main:missing", - kind: "direct", - updatedAt: null, - status: "done", - hasActiveRun: false, - }, - { - modelProvider: "openai", - model: "gpt-5.4", - contextTokens: 200_000, - thinkingLevels: [{ id: "medium", label: "Medium" }], - thinkingDefault: "medium", - }, - ); - - expect(applied).toBe(true); - expect(state.sessionsResult).toMatchObject({ - count: 0, - sessions: [], - defaults: { - modelProvider: "openai", - model: "gpt-5.4", - contextTokens: 200_000, - thinkingDefault: "medium", - }, - }); - }); - - it("updates catalog-backed thinking metadata from chat history session info", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { - modelProvider: "custom", - model: "catalog-model", - contextTokens: 200_000, - thinkingLevels: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - thinkingOptions: ["Low", "Medium", "High"], - thinkingDefault: "medium", - }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - thinkingLevels: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - thinkingOptions: ["Low", "Medium", "High"], - thinkingDefault: "medium", - }, - ], - }, - }); - - const catalogThinkingLevels = [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - { id: "xhigh", label: "Extra high" }, - ]; - const applied = applyChatHistorySessionInfo( - state, - { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - thinkingLevels: catalogThinkingLevels, - thinkingOptions: ["Low", "Medium", "High", "Extra high"], - thinkingDefault: "medium", - }, - { - modelProvider: "custom", - model: "catalog-model", - contextTokens: 200_000, - thinkingLevels: catalogThinkingLevels, - thinkingOptions: ["Low", "Medium", "High", "Extra high"], - thinkingDefault: "medium", - }, - ); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions[0]?.thinkingLevels?.map((level) => level.id)).toEqual([ - "low", - "medium", - "high", - "xhigh", - ]); - expect(state.sessionsResult?.defaults.thinkingLevels?.map((level) => level.id)).toEqual([ - "low", - "medium", - "high", - "xhigh", - ]); - }); - - it("keeps richer catalog-backed thinking metadata when chat history is lightweight", () => { - const catalogThinkingLevels = [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - { id: "xhigh", label: "Extra high" }, - ]; - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { - modelProvider: "custom", - model: "catalog-model", - contextTokens: 200_000, - thinkingLevels: catalogThinkingLevels, - thinkingOptions: ["Low", "Medium", "High", "Extra high"], - thinkingDefault: "medium", - }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - thinkingLevels: catalogThinkingLevels, - thinkingOptions: ["Low", "Medium", "High", "Extra high"], - thinkingDefault: "medium", - }, - ], - }, - }); - - const applied = applyChatHistorySessionInfo( - state, - { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - thinkingLevels: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - thinkingOptions: ["Low", "Medium", "High"], - thinkingDefault: "medium", - }, - { - modelProvider: "custom", - model: "catalog-model", - contextTokens: 200_000, - thinkingLevels: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - thinkingOptions: ["Low", "Medium", "High"], - thinkingDefault: "medium", - }, - ); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions[0]?.thinkingLevels?.map((level) => level.id)).toEqual([ - "low", - "medium", - "high", - "xhigh", - ]); - expect(state.sessionsResult?.defaults.thinkingLevels?.map((level) => level.id)).toEqual([ - "low", - "medium", - "high", - "xhigh", - ]); - }); - - it("uses incoming thinking metadata when chat history changes models", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { - modelProvider: "custom", - model: "extended-model", - contextTokens: 200_000, - thinkingLevels: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - { id: "xhigh", label: "Extra high" }, - ], - thinkingOptions: ["Low", "Medium", "High", "Extra high"], - thinkingDefault: "medium", - }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 1, - modelProvider: "custom", - model: "extended-model", - thinkingLevels: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - { id: "xhigh", label: "Extra high" }, - ], - thinkingOptions: ["Low", "Medium", "High", "Extra high"], - thinkingDefault: "medium", - }, - ], - }, - }); - - const applied = applyChatHistorySessionInfo( - state, - { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - modelProvider: "custom", - model: "basic-model", - thinkingLevels: [{ id: "off", label: "Off" }], - thinkingOptions: ["Off"], - thinkingDefault: "off", - }, - { - modelProvider: "custom", - model: "basic-model", - contextTokens: 200_000, - thinkingLevels: [{ id: "off", label: "Off" }], - thinkingOptions: ["Off"], - thinkingDefault: "off", - }, - ); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - model: "basic-model", - thinkingLevels: [{ id: "off", label: "Off" }], - thinkingOptions: ["Off"], - }); - expect(state.sessionsResult?.defaults.thinkingLevels?.map((level) => level.id)).toEqual([ - "off", - ]); - }); - - it("applies chat history session info for the selected non-default global agent", () => { - const state = createState(async () => undefined, { - sessionKey: "global", - assistantAgentId: "work", - agentsList: { defaultId: "main" }, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "global", kind: "global", updatedAt: 1, status: "running" }], - }, - chatRunId: "run-work", - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "global", - kind: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - key: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - expect(state.chatRunId).toBeNull(); - }); - - it("does not clear newer active runs from stale chat history session info", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:main:main", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 100, - startedAt: 90, - status: "running", - hasActiveRun: true, - }, - ], - }, - chatRunId: "run-active", - chatStream: "streaming", - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:main", - kind: "direct", - updatedAt: 50, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.chatRunId).toBe("run-active"); - expect(state.chatStream).toBe("streaming"); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - updatedAt: 100, - status: "running", - hasActiveRun: true, - }); - }); - - it("does not clear equal-timestamp active runs from stale chat history session info", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:main:main", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:main", - kind: "direct", - updatedAt: 100, - startedAt: 100, - status: "running", - hasActiveRun: true, - }, - ], - }, - chatRunId: "run-active", - chatStream: "streaming", - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:main", - kind: "direct", - updatedAt: 100, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.chatRunId).toBe("run-active"); - expect(state.chatStream).toBe("streaming"); - expect(state.sessionsResult?.sessions[0]).toMatchObject({ - updatedAt: 100, - status: "running", - hasActiveRun: true, - }); - }); - - it("clears current runs from canonical chat history rows outside the visible list", () => { - const state = createState(async () => undefined, { - sessionKey: "main", - sessionsResultAgentId: "work", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:work:main", kind: "direct", updatedAt: 1, status: "done" }], - }, - chatRunId: "run-main", - chatStream: "streaming", - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions.map((row) => row.key)).toEqual(["agent:work:main"]); - expect(state.chatRunId).toBeNull(); - expect(state.chatStream).toBeNull(); - }); - - it("clears alias-selected runs from first-load canonical chat history rows", () => { - const state = createState(async () => undefined, { - sessionKey: "main", - sessionsResult: null, - chatRunId: "run-main", - chatStream: "streaming", - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:main"); - expect(state.chatRunId).toBeNull(); - expect(state.chatStream).toBeNull(); - }); - - it("preserves first-load chat history scope for selected global agent rows", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:work:global", - sessionsResult: null, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "global", - kind: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResultAgentId).toBe("work"); - - const crossAgentApplied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:main", - agentId: "main", - session: { - key: "agent:main:main", - kind: "direct", - updatedAt: 3, - }, - }); - - expect(crossAgentApplied).toEqual({ applied: true, change: "inserted" }); - expect(state.sessionsResult?.sessions.map((row) => row.key)).toEqual(["global"]); - }); - - it("preserves first-load chat history scope for canonical agent rows", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:work:main", - sessionsResult: null, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:work:main", - kind: "direct", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResultAgentId).toBe("work"); - }); - - it("merges canonical chat history rows into visible legacy alias rows", () => { - const state = createState(async () => undefined, { - sessionKey: "main", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "main", kind: "direct", updatedAt: 1, status: "running" }], - }, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "agent:main:main", - kind: "direct", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.count).toBe(1); - expect(state.sessionsResult?.sessions).toEqual([ - expect.objectContaining({ - key: "main", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }), - ]); - }); - - it("merges canonical global chat history rows into selected global alias rows", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:work:main", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:work:main", kind: "global", updatedAt: 1, status: "running" }], - }, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "global", - kind: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.count).toBe(1); - expect(state.sessionsResult?.sessions).toEqual([ - expect.objectContaining({ - key: "agent:work:main", - kind: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }), - ]); - }); - - it("merges canonical global chat history rows into configured main-key alias rows", () => { - const state = createState(async () => undefined, { - sessionKey: "agent:work:inbox", - agentsList: { defaultId: "main", mainKey: "inbox" }, - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:work:inbox", kind: "global", updatedAt: 1, status: "running" }], - }, - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "global", - kind: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.count).toBe(1); - expect(state.sessionsResult?.sessions).toEqual([ - expect.objectContaining({ - key: "agent:work:inbox", - kind: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }), - ]); - }); - - it("clears current global runs even when the visible list is scoped elsewhere", () => { - const state = createState(async () => undefined, { - sessionKey: "global", - assistantAgentId: "work", - agentsList: { defaultId: "main" }, - sessionsResultAgentId: "main", - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 1, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1, status: "done" }], - }, - chatRunId: "run-work-global", - chatStream: "streaming", - }); - - const applied = applyChatHistorySessionInfo(state, { - key: "global", - kind: "global", - updatedAt: 2, - status: "done", - hasActiveRun: false, - }); - - expect(applied).toBe(true); - expect(state.sessionsResult?.sessions.map((row) => row.key)).toEqual(["agent:main:main"]); - expect(state.chatRunId).toBeNull(); - expect(state.chatStream).toBeNull(); - }); - - it("keeps updated existing rows sorted like sessions.list", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 2, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [ - { - key: "agent:main:newer", - kind: "direct", - updatedAt: 10, - }, - { - key: "agent:main:older", - kind: "direct", - updatedAt: 1, - }, - ], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:older", - ts: 2, - updatedAt: 20, - }); - - expect(applied).toEqual({ applied: true, change: "updated" }); - expect(state.sessionsResult?.sessions.map((row) => row.key)).toEqual([ - "agent:main:older", - "agent:main:newer", - ]); - }); - - it("reports when reliable websocket event payloads insert new rows", () => { - const state = createState(async () => undefined, { - sessionsResult: { - ts: 1, - path: "(multiple)", - count: 0, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: [], - }, - }); - - const applied = applySessionsChangedEvent(state, { - sessionKey: "agent:main:new", - sessionId: "sess-new", - ts: 2, - kind: "direct", - updatedAt: 2, - }); - - expect(applied).toEqual({ applied: true, change: "inserted" }); - expect(state.sessionsResult?.count).toBe(1); - expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:new"); - expect(state.sessionsResult?.sessions[0]?.kind).toBe("direct"); - expect(state.sessionsResult?.sessions[0]?.updatedAt).toBe(2); - }); -}); diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts deleted file mode 100644 index 175dd58e6db5..000000000000 --- a/ui/src/ui/controllers/sessions.ts +++ /dev/null @@ -1,1531 +0,0 @@ -// Control UI controller manages sessions gateway state. -import { - reconcileChatRunFromCurrentSessionRow, - reconcileChatRunFromSessionRow, - type ChatRunUiStatus, -} from "../chat/run-lifecycle.ts"; -import type { GatewayBrowserClient, GatewayHelloOk } from "../gateway.ts"; -import { - areUiSessionKeysEquivalent, - isUiGlobalSessionKey, - isSubagentSessionKey, - normalizeAgentId, - parseAgentSessionKey, - resolveUiDefaultAgentId, - resolveUiGlobalAliasAgentId, - resolveUiSelectedGlobalAgentId, - uiSessionRowMatchesSelectedChat, -} from "../session-key.ts"; -import { isSessionRunActive } from "../session-run-state.ts"; -import { normalizeOptionalString } from "../string-coerce.ts"; -import type { - FastMode, - GatewaySessionRow, - SessionCompactionCheckpoint, - SessionsCompactionBranchResult, - SessionsCompactionListResult, - SessionsCompactionRestoreResult, - SessionsListResult, -} from "../types.ts"; -import { - formatMissingOperatorReadScopeMessage, - isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; - -type SessionsChatRunState = { - sessionKey?: string; - chatRunId?: string | null; - chatStream?: string | null; - chatStreamStartedAt?: number | null; - requestUpdate?: () => void; -}; - -export type SessionsState = SessionsChatRunState & { - client: GatewayBrowserClient | null; - connected: boolean; - tab?: string; - sessionsLoading: boolean; - sessionsResult: SessionsListResult | null; - sessionsResultAgentId?: string | null; - sessionsResultShowArchived?: boolean; - chatAgentSessionRowsByAgent?: Record; - sessionsError: string | null; - sessionsFilterActive: string; - sessionsFilterLimit: string; - sessionsIncludeGlobal: boolean; - sessionsIncludeUnknown: boolean; - sessionsShowArchived: boolean; - sessionsExpandedCheckpointKey: string | null; - sessionsCheckpointItemsByKey: Record; - sessionsCheckpointLoadingKey: string | null; - sessionsCheckpointBusyKey: string | null; - sessionsCheckpointErrorByKey: Record; - chatSessionMessageSubscriptionKey?: string | null; - chatSessionMessageSubscriptionRequestedKey?: string | null; - chatSessionMessageSubscriptionAgentId?: string | null; - assistantAgentId?: string | null; - selectedChatSessionArchived?: boolean; - agentsList?: { defaultId?: string | null; mainKey?: string | null } | null; - hello?: GatewayHelloOk | null; -}; - -export type LoadSessionsOverrides = { - agentId?: string; - activeMinutes?: number; - limit?: number; - offset?: number; - search?: string; - includeGlobal?: boolean; - includeUnknown?: boolean; - showArchived?: boolean; - configuredAgentsOnly?: boolean; - append?: boolean; - publishChatRunStatus?: boolean; - // Background sidebar hydration (chat startup): skips the shared loading - // flag so New Session stays enabled, skips chat-run reconciliation so a - // stale row snapshot racing a send cannot clear the live stream, and - // carries the selected session's row over when the fetched page omits it. - // The filtered Sessions view must NOT set this; there a filtered or deleted - // row is expected to disappear from the list. - backgroundHydrate?: boolean; - preserveSessionsViewResult?: boolean; -}; - -type CreateSessionParams = { - agentId?: string; - label?: string; - model?: string; - parentSessionKey?: string; - emitCommandHooks?: boolean; -}; - -type CreateSessionResult = { - key?: string; -}; - -type SessionsLoadControl = { - loading: boolean; - pending: { overrides?: LoadSessionsOverrides } | null; - ownsStateLoading: boolean; -}; - -const sessionsLoadControls = new WeakMap(); -const selectedSessionMessageSubscriptionGenerations = new WeakMap(); - -function hasCurrentChatSession( - state: SessionsState, -): state is SessionsState & { sessionKey: string } { - return typeof state.sessionKey === "string" && state.sessionKey.trim() !== ""; -} - -function resultShowsArchivedSessions(state: SessionsState): boolean { - return state.sessionsResultShowArchived ?? state.sessionsShowArchived; -} - -function normalizeSubscriptionKey(value: string | null | undefined): string | null { - const normalized = typeof value === "string" ? value.trim() : ""; - return normalized ? normalized : null; -} - -function resolveSelectedGlobalAliasAgentId( - state: SessionsState, - key: string | null | undefined, -): string | null { - const row = state.sessionsResult?.sessions.find((session) => session.key === key); - return resolveUiGlobalAliasAgentId(state, key, { - rowKind: row?.kind, - requireGlobalRowForMainAlias: true, - }); -} - -function resolveSelectedSessionMessageSubscriptionAgentId( - state: SessionsState, - key: string, -): string | null { - if (isUiGlobalSessionKey(key)) { - return resolveSelectedGlobalAgentId(state); - } - return resolveSelectedGlobalAliasAgentId(state, key); -} - -function resolveSelectedGlobalAgentId(state: SessionsState): string { - const parsed = parseAgentSessionKey(state.sessionKey); - if (parsed?.agentId) { - return normalizeAgentId(parsed.agentId); - } - return resolveUiSelectedGlobalAgentId(state); -} - -function resolveChatHistorySessionResultAgentId( - state: SessionsState, - row: GatewaySessionRow, -): string | null { - const parsed = parseAgentSessionKey(row.key); - if (parsed?.agentId) { - return normalizeAgentId(parsed.agentId); - } - return isUiGlobalSessionKey(row.key) ? resolveSelectedGlobalAgentId(state) : null; -} - -function resolveDefaultGlobalAgentId(state: SessionsState): string { - return resolveUiDefaultAgentId(state); -} - -function sessionsChangedGlobalAgentMatches( - state: SessionsState, - payload: Record, - key: string, -): boolean { - if (!isUiGlobalSessionKey(key)) { - return true; - } - const eventSession = isRecord(payload.session) ? payload.session : null; - const eventAgentId = readSessionsChangedEventAgentId(payload, eventSession); - const selectedAgentId = resolveSelectedGlobalAgentId(state); - if (eventAgentId) { - return eventAgentId === selectedAgentId; - } - return selectedAgentId === resolveDefaultGlobalAgentId(state); -} - -function readSessionsChangedEventAgentId( - payload: Record, - eventSession: Record | null, -): string | null { - const rawAgentId = - (typeof payload.agentId === "string" && payload.agentId.trim()) || - (typeof eventSession?.agentId === "string" && eventSession.agentId.trim()); - return rawAgentId ? normalizeAgentId(rawAgentId) : null; -} - -function sessionsChangedResultScopeMatches( - state: SessionsState, - payload: Record, - eventSession: Record | null, - key: string, - existing: GatewaySessionRow | undefined, -): boolean { - const resultAgentId = - typeof state.sessionsResultAgentId === "string" && state.sessionsResultAgentId.trim() - ? normalizeAgentId(state.sessionsResultAgentId) - : null; - if (!resultAgentId) { - return true; - } - const eventAgentId = readSessionsChangedEventAgentId(payload, eventSession); - if (eventAgentId) { - return eventAgentId === resultAgentId; - } - const parsed = parseAgentSessionKey(key); - if (parsed?.agentId) { - return normalizeAgentId(parsed.agentId) === resultAgentId; - } - return Boolean(existing); -} - -function buildSelectedSessionMessageSubscriptionParams(state: SessionsState, key: string) { - const agentId = resolveSelectedSessionMessageSubscriptionAgentId(state, key); - return { - key, - ...(agentId ? { agentId } : {}), - }; -} - -function buildSelectedSessionRequestParams(state: SessionsState, key: string) { - const agentId = resolveSelectedSessionMessageSubscriptionAgentId(state, key); - return { - key, - ...(agentId ? { agentId } : {}), - }; -} - -function beginSelectedSessionMessageSubscriptionSync(state: SessionsState): number { - const key = state as object; - const next = (selectedSessionMessageSubscriptionGenerations.get(key) ?? 0) + 1; - selectedSessionMessageSubscriptionGenerations.set(key, next); - return next; -} - -function isCurrentSelectedSessionMessageSubscriptionSync( - state: SessionsState & { sessionKey: string }, - params: { - generation: number; - client: GatewayBrowserClient; - requestedKey: string; - requestedAgentId?: string | null; - }, -): boolean { - return ( - selectedSessionMessageSubscriptionGenerations.get(state as object) === params.generation && - state.client === params.client && - state.connected && - state.sessionKey.trim() === params.requestedKey && - resolveSelectedSessionMessageSubscriptionAgentId(state, params.requestedKey) === - (params.requestedAgentId ?? null) - ); -} - -function readSubscribedSessionMessageKey(result: unknown, fallbackKey: string): string { - const key = - result && typeof result === "object" && typeof (result as { key?: unknown }).key === "string" - ? (result as { key: string }).key.trim() - : ""; - return key || fallbackKey; -} - -async function unsubscribeSelectedSessionMessageBestEffort( - client: GatewayBrowserClient, - key: string, - agentId?: string | null, -): Promise { - try { - await client.request("sessions.messages.unsubscribe", { - key, - ...(isUiGlobalSessionKey(key) && agentId ? { agentId } : {}), - }); - } catch { - // Best-effort cleanup for stale async subscription completions. - } -} - -function sessionPatchTargetsCurrentChatRun( - state: SessionsState & { sessionKey: string }, - options: { changedSessionKey: string; eventRunId?: string }, -): boolean { - if (state.sessionKey !== options.changedSessionKey) { - return false; - } - if ( - options.eventRunId !== undefined && - state.chatRunId && - state.chatRunId !== options.eventRunId - ) { - return false; - } - if (options.eventRunId === undefined && state.chatRunId) { - return false; - } - return true; -} - -const SESSION_EVENT_ROW_FIELDS = [ - "abortedLastRun", - "childSessions", - "compactionCheckpointCount", - "contextTokens", - "displayName", - "effectiveResponseUsage", - "endedAt", - "elevatedLevel", - "effectiveFastMode", - "effectiveFastModeSource", - "fastMode", - "fastAutoOnSeconds", - "goal", - "hasActiveRun", - "inputTokens", - "kind", - "label", - "latestCompactionCheckpoint", - "model", - "modelProvider", - "outputTokens", - "reasoningLevel", - "runtimeMs", - "sessionId", - "spawnedBy", - "startedAt", - "status", - "archived", - "archivedAt", - "pinned", - "pinnedAt", - "subject", - "surface", - "systemSent", - "thinkingDefault", - "thinkingLevel", - "thinkingLevels", - "thinkingOptions", - "totalTokens", - "totalTokensFresh", - "updatedAt", - "verboseLevel", -] as const satisfies readonly (keyof GatewaySessionRow)[]; - -function getSessionsLoadControl(state: SessionsState): SessionsLoadControl { - const key = state as object; - let control = sessionsLoadControls.get(key); - if (!control) { - control = { loading: false, ownsStateLoading: false, pending: null }; - sessionsLoadControls.set(key, control); - } - return control; -} - -function takePendingSessionsLoad( - control: SessionsLoadControl, -): { overrides?: LoadSessionsOverrides } | null { - const pending = control.pending; - control.pending = null; - return pending; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object"); -} - -function hasOwn(record: Record, key: string): boolean { - return Object.hasOwn(record, key); -} - -function sanitizeChatHistorySessionRow(row: GatewaySessionRow): GatewaySessionRow { - const next: Partial = {}; - for (const [key, value] of Object.entries(row) as Array<[keyof GatewaySessionRow, unknown]>) { - if (value === undefined) { - continue; - } - if (key === "totalTokensFresh" && value === false && row.totalTokens === undefined) { - continue; - } - next[key] = value as never; - } - return next as GatewaySessionRow; -} - -export function parseSessionsFilterInteger(value: string): number { - const trimmed = value.trim(); - if (!/^\d+$/.test(trimmed)) { - return 0; - } - const parsed = Number(trimmed); - return Number.isSafeInteger(parsed) ? parsed : 0; -} - -function normalizeSessionsFilterOverride(value: number | undefined): number | undefined { - if (value === undefined) { - return undefined; - } - return Number.isSafeInteger(value) ? value : 0; -} - -function normalizeSessionKind(value: unknown): GatewaySessionRow["kind"] | undefined { - return value === "cron" || - value === "direct" || - value === "group" || - value === "global" || - value === "unknown" - ? value - : undefined; -} - -export function isArchivedSessionRow(row: GatewaySessionRow): boolean { - return row.archived === true; -} - -function filterAvailableSessionRows( - rows: GatewaySessionRow[], - options: { showArchived: boolean }, -): GatewaySessionRow[] { - return rows.filter((row) => row.key && isArchivedSessionRow(row) === options.showArchived); -} - -function projectSessionsResultForAvailability( - result: SessionsListResult, - options: { showArchived: boolean }, -): SessionsListResult { - const sessions = filterAvailableSessionRows(result.sessions, options); - return { - ...result, - count: sessions.length, - sessions, - }; -} - -function appendSessionsResult( - previous: SessionsListResult, - page: SessionsListResult, -): SessionsListResult { - const seen = new Set(); - const sessions: SessionsListResult["sessions"] = []; - for (const row of [...previous.sessions, ...page.sessions]) { - if (!row.key || seen.has(row.key)) { - continue; - } - seen.add(row.key); - sessions.push(row); - } - const totalCount = page.totalCount ?? previous.totalCount; - const hasMore = - page.hasMore ?? - (typeof totalCount === "number" && Number.isFinite(totalCount) - ? sessions.length < totalCount - : false); - const nextOffset = - page.nextOffset !== undefined ? page.nextOffset : hasMore ? sessions.length : null; - return { - ...page, - count: sessions.length, - totalCount, - hasMore, - nextOffset, - sessions, - }; -} - -// Pinned sessions float above recency everywhere a session list renders -// (sessions view, chat picker, sidebar recents); keep this the only sort. -export function compareSessionRowsByUpdatedAt(a: GatewaySessionRow, b: GatewaySessionRow): number { - const pinnedDiff = (b.pinnedAt ?? 0) - (a.pinnedAt ?? 0); - if (pinnedDiff !== 0) { - return pinnedDiff; - } - return (b.updatedAt ?? 0) - (a.updatedAt ?? 0); -} - -type ThinkingMetadataCarrier = { - modelProvider?: string | null; - model?: string | null; - thinkingLevels?: Array<{ id: string; label: string }>; - thinkingOptions?: string[]; - thinkingDefault?: string; -}; - -function thinkingMetadataModelMatches( - incoming: ThinkingMetadataCarrier, - existing: ThinkingMetadataCarrier, -): boolean { - const incomingProvider = incoming.modelProvider; - const existingProvider = existing.modelProvider; - if (incomingProvider && existingProvider && incomingProvider !== existingProvider) { - return false; - } - const incomingModel = incoming.model; - const existingModel = existing.model; - return !(incomingModel && existingModel && incomingModel !== existingModel); -} - -function preserveRicherThinkingMetadata( - incoming: T, - existing: ThinkingMetadataCarrier | undefined, -): T { - if (existing && !thinkingMetadataModelMatches(incoming, existing)) { - return incoming; - } - const existingLevels = existing?.thinkingLevels; - if (!existingLevels?.length) { - return incoming; - } - const incomingLevels = incoming.thinkingLevels; - if (incomingLevels && incomingLevels.length >= existingLevels.length) { - return incoming; - } - const existingThinkingDefault = existing?.thinkingDefault; - return { - ...incoming, - thinkingLevels: existingLevels, - ...(existing?.thinkingOptions ? { thinkingOptions: existing.thinkingOptions } : {}), - ...(incoming.thinkingDefault === undefined && existingThinkingDefault !== undefined - ? { thinkingDefault: existingThinkingDefault } - : {}), - }; -} - -function historyRowIsStaleForActiveSession( - incoming: GatewaySessionRow, - existing: GatewaySessionRow | undefined, -): boolean { - if (!existing || !isSessionRunActive(existing) || isSessionRunActive(incoming)) { - return false; - } - const existingUpdatedAt = existing.updatedAt ?? 0; - const incomingUpdatedAt = incoming.updatedAt ?? 0; - if (existingUpdatedAt >= incomingUpdatedAt) { - return true; - } - const existingStartedAt = typeof existing.startedAt === "number" ? existing.startedAt : 0; - return existingStartedAt >= incomingUpdatedAt; -} - -function isPersistedChatHistorySessionRow(row: GatewaySessionRow): boolean { - const sessionId = typeof row.sessionId === "string" ? row.sessionId.trim() : ""; - return Boolean(sessionId || typeof row.updatedAt === "number"); -} - -function sessionRowMatchesChatHistoryRow( - state: SessionsState, - existing: GatewaySessionRow, - incoming: GatewaySessionRow, -): boolean { - if (areUiSessionKeysEquivalent(existing.key, incoming.key)) { - return true; - } - return ( - isUiGlobalSessionKey(incoming.key) && - resolveSelectedGlobalAliasAgentId(state, existing.key) === resolveSelectedGlobalAgentId(state) - ); -} - -function checkpointSummarySignature( - row: - | { - compactionCheckpointCount?: number; - latestCompactionCheckpoint?: { checkpointId?: string; createdAt?: number } | null; - } - | undefined, -): string { - return `${row?.compactionCheckpointCount ?? 0}:${ - row?.latestCompactionCheckpoint?.checkpointId ?? "" - }:${row?.latestCompactionCheckpoint?.createdAt ?? 0}`; -} - -function invalidateCheckpointCacheForKey(state: SessionsState, key: string) { - if ( - !(key in state.sessionsCheckpointItemsByKey) && - !(key in state.sessionsCheckpointErrorByKey) - ) { - return; - } - const nextItems = { ...state.sessionsCheckpointItemsByKey }; - const nextErrors = { ...state.sessionsCheckpointErrorByKey }; - delete nextItems[key]; - delete nextErrors[key]; - state.sessionsCheckpointItemsByKey = nextItems; - state.sessionsCheckpointErrorByKey = nextErrors; -} - -function invalidateCachedChatAgentSessionRow(state: SessionsState, key: string): boolean { - const rowsByAgent = state.chatAgentSessionRowsByAgent; - if (!rowsByAgent) { - return false; - } - let removed = false; - for (const [agentId, rows] of Object.entries(rowsByAgent)) { - const nextRows = rows.filter((row) => row.key !== key); - if (nextRows.length === rows.length) { - continue; - } - rowsByAgent[agentId] = nextRows; - removed = true; - } - return removed; -} - -function resolveCachedChatAgentSessionRowAgentId( - state: SessionsState, - row: GatewaySessionRow, -): string | null { - if (row.kind === "global" || row.kind === "unknown" || row.kind === "cron") { - return null; - } - if (isSubagentSessionKey(row.key) || row.spawnedBy) { - return null; - } - const parsed = parseAgentSessionKey(row.key); - return normalizeAgentId(parsed?.agentId ?? state.agentsList?.defaultId ?? "main"); -} - -function upsertCachedChatAgentSessionRow(state: SessionsState, row: GatewaySessionRow): boolean { - if (isArchivedSessionRow(row)) { - return invalidateCachedChatAgentSessionRow(state, row.key); - } - const agentId = resolveCachedChatAgentSessionRowAgentId(state, row); - if (!agentId) { - return false; - } - state.chatAgentSessionRowsByAgent ??= {}; - const existingRows = state.chatAgentSessionRowsByAgent[agentId] ?? []; - state.chatAgentSessionRowsByAgent[agentId] = [ - row, - ...existingRows.filter((r) => r.key !== row.key), - ].toSorted(compareSessionRowsByUpdatedAt); - return true; -} - -async function fetchSessionCompactionCheckpoints(state: SessionsState, key: string) { - state.sessionsCheckpointLoadingKey = key; - state.sessionsCheckpointErrorByKey = { - ...state.sessionsCheckpointErrorByKey, - [key]: "", - }; - try { - const result = await state.client?.request( - "sessions.compaction.list", - buildSelectedSessionRequestParams(state, key), - ); - if (result) { - state.sessionsCheckpointItemsByKey = { - ...state.sessionsCheckpointItemsByKey, - [key]: result.checkpoints ?? [], - }; - } - } catch (err) { - state.sessionsCheckpointErrorByKey = { - ...state.sessionsCheckpointErrorByKey, - [key]: String(err), - }; - } finally { - if (state.sessionsCheckpointLoadingKey === key) { - state.sessionsCheckpointLoadingKey = null; - } - } -} - -async function withSessionsLoading( - state: SessionsState, - run: () => Promise, -): Promise { - if (state.sessionsLoading) { - return false; - } - const control = getSessionsLoadControl(state); - state.sessionsLoading = true; - state.sessionsError = null; - let drainedPendingRefresh = false; - try { - await run(); - } finally { - state.sessionsLoading = false; - const pending = takePendingSessionsLoad(control); - if (pending && state.client && state.connected) { - await loadSessions(state, pending.overrides); - drainedPendingRefresh = true; - } - } - return drainedPendingRefresh; -} - -async function runCompactionMutation( - state: SessionsState, - key: string, - checkpointId: string, - method: "sessions.compaction.branch" | "sessions.compaction.restore", - confirmMessage: string, -): Promise { - if (!state.client || !state.connected || !window.confirm(confirmMessage)) { - return null; - } - const client = state.client; - state.sessionsCheckpointBusyKey = checkpointId; - try { - const result = await client.request(method, { - ...buildSelectedSessionRequestParams(state, key), - checkpointId, - }); - await loadSessions( - state, - isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, - ); - return result; - } catch (err) { - state.sessionsError = String(err); - return null; - } finally { - if (state.sessionsCheckpointBusyKey === checkpointId) { - state.sessionsCheckpointBusyKey = null; - } - } -} - -export type SessionsChangedApplyResult = - | { applied: false } - | { - applied: true; - change: "deleted" | "inserted" | "updated"; - deletedSession?: { key: string; agentId?: string; selected: boolean }; - clearedChatRun?: boolean; - clearedChatRunStatus?: Pick; - }; - -function deletedSessionMatchesSelectedChat( - state: SessionsState, - payload: Record, - key: string, -): boolean { - if (!hasCurrentChatSession(state)) { - return false; - } - if (areUiSessionKeysEquivalent(key, state.sessionKey)) { - return true; - } - return Boolean( - isUiGlobalSessionKey(key) && - resolveUiGlobalAliasAgentId(state, state.sessionKey) && - sessionsChangedGlobalAgentMatches(state, payload, key), - ); -} - -function buildDeletedSessionChange( - state: SessionsState, - payload: Record, - eventSession: Record | null, - key: string, -) { - const parsedAgentId = parseAgentSessionKey(key)?.agentId; - const eventAgentId = readSessionsChangedEventAgentId(payload, eventSession); - const agentId = - parsedAgentId ?? - eventAgentId ?? - (isUiGlobalSessionKey(key) ? resolveDefaultGlobalAgentId(state) : undefined); - return { - key, - ...(agentId ? { agentId: normalizeAgentId(agentId) } : {}), - selected: deletedSessionMatchesSelectedChat(state, payload, key), - }; -} - -export function applySessionsChangedEvent( - state: SessionsState, - payload: unknown, -): SessionsChangedApplyResult { - if (!isRecord(payload)) { - return { applied: false }; - } - const eventSession = isRecord(payload.session) ? payload.session : null; - const source = eventSession ?? payload; - const key = - (typeof source.key === "string" && source.key.trim()) || - (typeof payload.sessionKey === "string" && payload.sessionKey.trim()) || - (typeof payload.key === "string" && payload.key.trim()) || - ""; - if (!key) { - return { applied: false }; - } - if (!sessionsChangedGlobalAgentMatches(state, payload, key)) { - return { applied: false }; - } - - if (payload.reason === "delete") { - const deletedSession = buildDeletedSessionChange(state, payload, eventSession, key); - const removedCachedRow = invalidateCachedChatAgentSessionRow(state, key); - if (!state.sessionsResult) { - return removedCachedRow || deletedSession.selected - ? { applied: true, change: "deleted", deletedSession } - : { applied: false }; - } - - const previousRows = state.sessionsResult.sessions; - const existingIndex = previousRows.findIndex((row) => row.key === key); - const existing = existingIndex >= 0 ? previousRows[existingIndex] : undefined; - if (!sessionsChangedResultScopeMatches(state, payload, eventSession, key, existing)) { - return removedCachedRow || deletedSession.selected - ? { applied: true, change: "deleted", deletedSession } - : { applied: false }; - } - if (existingIndex < 0) { - return removedCachedRow || deletedSession.selected - ? { applied: true, change: "deleted", deletedSession } - : { applied: false }; - } - state.sessionsResult = { - ...state.sessionsResult, - count: Math.max(0, state.sessionsResult.count - 1), - sessions: previousRows.filter((row) => row.key !== key), - }; - invalidateCheckpointCacheForKey(state, key); - return { applied: true, change: "deleted", deletedSession }; - } - if (!state.sessionsResult) { - return { applied: false }; - } - - const previousRows = state.sessionsResult.sessions; - const existingIndex = previousRows.findIndex((row) => row.key === key); - const existing = existingIndex >= 0 ? previousRows[existingIndex] : undefined; - const matchesResultScope = - sessionsChangedGlobalAgentMatches(state, payload, key) && - sessionsChangedResultScopeMatches(state, payload, eventSession, key, existing); - const hasReliableSource = - existingIndex >= 0 || eventSession !== null || typeof source.sessionId === "string"; - if (!hasReliableSource) { - return { applied: false }; - } - const previousCheckpointSignature = checkpointSummarySignature(existing); - const fallbackKind = normalizeSessionKind(source.kind) ?? existing?.kind ?? "unknown"; - const nextRow: GatewaySessionRow = { - ...(existing ?? { key, kind: fallbackKind, updatedAt: null }), - key, - kind: fallbackKind, - }; - const mutableNext = nextRow as unknown as Record; - for (const field of SESSION_EVENT_ROW_FIELDS) { - const hasField = hasOwn(source, field); - const hasTopLevelGoalClear = - field === "goal" && hasOwn(payload, "goal") && payload.goal === null; - if (!hasField && !hasTopLevelGoalClear) { - continue; - } - const value = hasTopLevelGoalClear ? null : source[field]; - const clearsManagementTimestamp = - (field === "archivedAt" || field === "pinnedAt") && value === null; - if (value === undefined || (field === "goal" && value === null) || clearsManagementTimestamp) { - delete mutableNext[field]; - } else { - mutableNext[field] = value; - } - } - if (!hasOwn(source, "hasActiveRun") && nextRow.status) { - if (nextRow.status === "running") { - if (payload.phase === "start") { - nextRow.hasActiveRun = true; - } - } else { - nextRow.hasActiveRun = false; - } - } - if (nextRow.totalTokensFresh === false && !hasOwn(source, "totalTokens")) { - delete nextRow.totalTokens; - } - if ( - hasOwn(source, "archived") && - hasCurrentChatSession(state) && - areUiSessionKeysEquivalent(key, state.sessionKey) && - sessionsChangedGlobalAgentMatches(state, payload, key) - ) { - state.selectedChatSessionArchived = nextRow.archived === true; - } - if (!matchesResultScope) { - return upsertCachedChatAgentSessionRow(state, nextRow) - ? { applied: true, change: existingIndex >= 0 ? "updated" : "inserted" } - : { applied: false }; - } - if (isArchivedSessionRow(nextRow) !== resultShowsArchivedSessions(state)) { - const removedCachedRow = invalidateCachedChatAgentSessionRow(state, key); - if (existingIndex < 0) { - return removedCachedRow ? { applied: true, change: "deleted" } : { applied: false }; - } - state.sessionsResult = { - ...state.sessionsResult, - count: Math.max(0, state.sessionsResult.count - 1), - sessions: previousRows.filter((row) => row.key !== key), - }; - invalidateCheckpointCacheForKey(state, key); - return { applied: true, change: "deleted" }; - } - - const nextRows = - existingIndex >= 0 - ? previousRows.map((row, index) => (index === existingIndex ? nextRow : row)) - : [nextRow, ...previousRows]; - const sessions = nextRows.toSorted(compareSessionRowsByUpdatedAt); - const eventTs = typeof payload.ts === "number" && Number.isFinite(payload.ts) ? payload.ts : null; - const eventRunId = - typeof payload.clientRunId === "string" && payload.clientRunId.trim() - ? payload.clientRunId.trim() - : typeof payload.runId === "string" && payload.runId.trim() - ? payload.runId.trim() - : undefined; - state.sessionsResult = { - ...state.sessionsResult, - ts: eventTs == null ? state.sessionsResult.ts : Math.max(state.sessionsResult.ts, eventTs), - count: existingIndex >= 0 ? state.sessionsResult.count : state.sessionsResult.count + 1, - sessions, - }; - const hasCurrentSession = hasCurrentChatSession(state); - const currentChatRunId = state.chatRunId ?? null; - const currentChatSessionKey = hasCurrentSession ? state.sessionKey : null; - const clearedChatRun = - nextRow.hasActiveRun !== true && - hasCurrentSession && - sessionPatchTargetsCurrentChatRun(state, { - changedSessionKey: key, - eventRunId, - }) && - reconcileChatRunFromCurrentSessionRow(state, { - publishRunStatus: false, - }); - - if (previousCheckpointSignature !== checkpointSummarySignature(nextRow)) { - invalidateCheckpointCacheForKey(state, key); - } - return { - applied: true, - change: existingIndex >= 0 ? "updated" : "inserted", - ...(clearedChatRun ? { clearedChatRun: true } : {}), - ...(clearedChatRun && currentChatSessionKey != null - ? { - clearedChatRunStatus: { - phase: nextRow.status === "done" ? "done" : "interrupted", - runId: currentChatRunId, - sessionKey: currentChatSessionKey, - }, - } - : {}), - }; -} - -export function applyChatHistorySessionInfo( - state: SessionsState, - row: GatewaySessionRow | undefined, - defaults?: SessionsListResult["defaults"], -): boolean { - if (!row?.key) { - return false; - } - const session = sanitizeChatHistorySessionRow(row); - if (hasCurrentChatSession(state) && areUiSessionKeysEquivalent(session.key, state.sessionKey)) { - state.selectedChatSessionArchived = session.archived === true; - } - if (!state.sessionsResult) { - if (!isPersistedChatHistorySessionRow(session)) { - if (!defaults) { - return false; - } - state.sessionsResult = { - ts: Date.now(), - path: "", - count: 0, - defaults, - sessions: [], - }; - return true; - } - const showArchived = resultShowsArchivedSessions(state); - const sessions = isArchivedSessionRow(session) === showArchived ? [session] : []; - state.sessionsResult = { - ts: Date.now(), - path: "", - count: sessions.length, - defaults: defaults ?? { - modelProvider: null, - model: null, - contextTokens: null, - }, - sessions, - }; - state.sessionsResultAgentId = resolveChatHistorySessionResultAgentId(state, session); - state.sessionsResultShowArchived = showArchived; - upsertCachedChatAgentSessionRow(state, session); - if (hasCurrentChatSession(state)) { - const reconciled = reconcileChatRunFromSessionRow(state, session, { publishRunStatus: true }); - if (!reconciled) { - reconcileChatRunFromCurrentSessionRow(state, { publishRunStatus: true }); - } - } - return true; - } - const existingVisibleSession = state.sessionsResult.sessions.find((existing) => - sessionRowMatchesChatHistoryRow(state, existing, session), - ); - if (!existingVisibleSession && !isPersistedChatHistorySessionRow(session)) { - if (defaults) { - state.sessionsResult = { - ...state.sessionsResult, - defaults: preserveRicherThinkingMetadata(defaults, state.sessionsResult.defaults), - }; - return true; - } - return false; - } - if (defaults) { - state.sessionsResult = { - ...state.sessionsResult, - defaults: preserveRicherThinkingMetadata(defaults, state.sessionsResult.defaults), - }; - } - const visibleKey = existingVisibleSession?.key ?? session.key; - const keyedVisibleSession = - visibleKey === session.key ? session : { ...session, key: visibleKey }; - const visibleSession = preserveRicherThinkingMetadata( - keyedVisibleSession, - existingVisibleSession, - ); - if (historyRowIsStaleForActiveSession(visibleSession, existingVisibleSession)) { - return true; - } - const applied = applySessionsChangedEvent(state, { - session: visibleSession, - sessionKey: visibleSession.key, - ...(isUiGlobalSessionKey(visibleSession.key) - ? { agentId: resolveSelectedGlobalAgentId(state) } - : {}), - }); - if (applied.applied) { - upsertCachedChatAgentSessionRow(state, visibleSession); - if (hasCurrentChatSession(state)) { - const reconciled = reconcileChatRunFromSessionRow(state, visibleSession, { - publishRunStatus: true, - }); - if (!reconciled) { - reconcileChatRunFromCurrentSessionRow(state, { publishRunStatus: true }); - } - } - return true; - } - const cached = upsertCachedChatAgentSessionRow(state, visibleSession); - if (hasCurrentChatSession(state)) { - const reconciled = - reconcileChatRunFromSessionRow(state, visibleSession, { publishRunStatus: true }) || - (cached && reconcileChatRunFromCurrentSessionRow(state, { publishRunStatus: true })); - return cached || reconciled; - } - return cached; -} - -export async function subscribeSessions(state: SessionsState) { - if (!state.client || !state.connected) { - return; - } - try { - await state.client.request("sessions.subscribe", {}); - } catch (err) { - state.sessionsError = String(err); - } -} - -export async function syncSelectedSessionMessageSubscription( - state: SessionsState & { sessionKey: string }, - opts?: { force?: boolean }, -) { - if (!state.client || !state.connected) { - return; - } - const client = state.client; - const nextKey = state.sessionKey.trim(); - if (!nextKey) { - return; - } - const generation = beginSelectedSessionMessageSubscriptionSync(state); - const previousRequestedKey = normalizeSubscriptionKey( - state.chatSessionMessageSubscriptionRequestedKey, - ); - const previousCanonicalKey = normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey); - const previousSelectedKey = previousRequestedKey ?? previousCanonicalKey; - const nextSubscriptionAgentId = resolveSelectedSessionMessageSubscriptionAgentId(state, nextKey); - const selectedAgentChanged = - nextSubscriptionAgentId !== null && - previousSelectedKey === nextKey && - (state.chatSessionMessageSubscriptionAgentId ?? null) !== nextSubscriptionAgentId; - const selectedKeyChanged = previousSelectedKey !== null && previousSelectedKey !== nextKey; - const shouldUnsubscribePrevious = - previousCanonicalKey !== null && (selectedKeyChanged || selectedAgentChanged); - const shouldSubscribe = - opts?.force === true || - selectedKeyChanged || - selectedAgentChanged || - previousCanonicalKey === null || - previousRequestedKey === null; - if (!shouldUnsubscribePrevious && !shouldSubscribe) { - return; - } - const isCurrent = () => - isCurrentSelectedSessionMessageSubscriptionSync(state, { - generation, - client, - requestedKey: nextKey, - requestedAgentId: nextSubscriptionAgentId, - }); - try { - if (shouldUnsubscribePrevious && previousCanonicalKey) { - await client.request("sessions.messages.unsubscribe", { - key: previousCanonicalKey, - ...(isUiGlobalSessionKey(previousCanonicalKey) && - state.chatSessionMessageSubscriptionAgentId - ? { agentId: state.chatSessionMessageSubscriptionAgentId } - : {}), - }); - if (isCurrent()) { - state.chatSessionMessageSubscriptionKey = null; - state.chatSessionMessageSubscriptionRequestedKey = null; - state.chatSessionMessageSubscriptionAgentId = null; - } - } - if (!shouldSubscribe || !isCurrent()) { - return; - } - const subscriptionParams = buildSelectedSessionMessageSubscriptionParams(state, nextKey); - const result = await client.request("sessions.messages.subscribe", subscriptionParams); - const subscribedKey = readSubscribedSessionMessageKey(result, nextKey); - const subscribedAgentId = "agentId" in subscriptionParams ? subscriptionParams.agentId : null; - if (!isCurrent()) { - const staleKeyChanged = - normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey) !== subscribedKey; - const staleAgentChanged = - isUiGlobalSessionKey(subscribedKey) && - (state.chatSessionMessageSubscriptionAgentId ?? null) !== subscribedAgentId; - if (staleKeyChanged || staleAgentChanged) { - await unsubscribeSelectedSessionMessageBestEffort(client, subscribedKey, subscribedAgentId); - } - return; - } - state.chatSessionMessageSubscriptionRequestedKey = nextKey; - state.chatSessionMessageSubscriptionKey = subscribedKey; - state.chatSessionMessageSubscriptionAgentId = subscribedAgentId; - } catch (err) { - if (isCurrent()) { - state.sessionsError = String(err); - } - } -} - -export async function loadSessions(state: SessionsState, overrides?: LoadSessionsOverrides) { - if (!state.client || !state.connected) { - return; - } - const control = getSessionsLoadControl(state); - if (control.loading) { - control.pending = { overrides }; - return; - } - if (state.sessionsLoading) { - control.pending = { overrides }; - return; - } - const client = state.client; - control.loading = true; - // Background hydrates keep the shared loading flag untouched; it disables - // New Session and drives list spinners, which must not react to them. - if (overrides?.backgroundHydrate !== true) { - control.ownsStateLoading = true; - state.sessionsLoading = true; - } - state.sessionsError = null; - let currentOverrides: LoadSessionsOverrides | undefined = overrides; - try { - for (;;) { - control.pending = null; - // A foreground request queued behind a background hydrate still owns the - // shared loading flag while it runs inside this loop. - if (currentOverrides?.backgroundHydrate !== true && !control.ownsStateLoading) { - control.ownsStateLoading = true; - state.sessionsLoading = true; - } - await loadSessionsOnce(state, client, currentOverrides); - const pending = takePendingSessionsLoad(control); - if (!pending || !state.client || !state.connected) { - break; - } - currentOverrides = pending.overrides; - } - } finally { - control.loading = false; - control.pending = null; - if (control.ownsStateLoading) { - state.sessionsLoading = false; - control.ownsStateLoading = false; - } - } -} - -async function loadSessionsOnce( - state: SessionsState, - client: NonNullable, - overrides?: LoadSessionsOverrides, -) { - await (async () => { - const previousRows = new Map( - (state.sessionsResult?.sessions ?? []).map((row) => [row.key, row] as const), - ); - const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; - const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; - const showArchived = - overrides?.showArchived ?? (state.tab === "sessions" && state.sessionsShowArchived); - const activeMinutes = showArchived - ? 0 - : (normalizeSessionsFilterOverride(overrides?.activeMinutes) ?? - parseSessionsFilterInteger(state.sessionsFilterActive)); - const limit = - normalizeSessionsFilterOverride(overrides?.limit) ?? - parseSessionsFilterInteger(state.sessionsFilterLimit); - const configuredAgentsOnly = overrides?.configuredAgentsOnly ?? true; - const params: Record = { - includeGlobal, - includeUnknown, - configuredAgentsOnly, - }; - if (showArchived) { - params.archived = true; - } - const agentId = overrides?.agentId?.trim(); - const resultAgentId = agentId ? normalizeAgentId(agentId) : null; - if (agentId) { - params.agentId = agentId; - } - if (activeMinutes > 0) { - params.activeMinutes = activeMinutes; - } - if (limit > 0) { - params.limit = limit; - } - const offset = - typeof overrides?.offset === "number" && Number.isFinite(overrides.offset) - ? Math.max(0, Math.floor(overrides.offset)) - : 0; - if (offset > 0) { - params.offset = offset; - } - const search = overrides?.search?.trim(); - if (search) { - params.search = search; - } - const res = await client.request("sessions.list", params); - if (res) { - const projected = projectSessionsResultForAvailability(res, { showArchived }); - if (overrides?.preserveSessionsViewResult === true && state.tab === "sessions") { - for (const row of projected.sessions) { - upsertCachedChatAgentSessionRow(state, row); - } - if (hasCurrentChatSession(state)) { - const selectedRow = projected.sessions.find((row) => - areUiSessionKeysEquivalent(row.key, state.sessionKey), - ); - if (selectedRow) { - reconcileChatRunFromSessionRow(state, selectedRow, { - publishRunStatus: overrides.publishChatRunStatus !== false, - }); - } - } - return; - } - let nextResult = - overrides?.append === true && offset > 0 && state.sessionsResult - ? appendSessionsResult(state.sessionsResult, projected) - : projected; - // Sidebar boot hydration must not drop the selected session's row: chat - // metadata (context ring, model overrides) and the sidebar's - // way-back-to-chat row read from sessionsResult, and a capped or - // recency-filtered page can exclude an old open session. Read the row - // from live state at commit time (not the request-start snapshot): a - // concurrent chat.history response may have installed it mid-flight. - // Exact key equivalence carries unconditionally; the looser global - // alias only carries when the previous result was scoped to the - // selected session's agent, so an agent switch cannot smuggle another - // agent's canonical "global" row into the new scope. - const currentKey = - overrides?.backgroundHydrate === true - ? normalizeOptionalString(state.sessionKey) - : undefined; - const currentAgentId = currentKey - ? normalizeAgentId( - parseAgentSessionKey(currentKey)?.agentId ?? resolveUiSelectedGlobalAgentId(state), - ) - : null; - const previousResultAgentId = state.sessionsResultAgentId - ? normalizeAgentId(state.sessionsResultAgentId) - : null; - const previousRowsLive = state.sessionsResult?.sessions ?? []; - const previousCurrentRow = currentKey - ? (previousRowsLive.find((row) => areUiSessionKeysEquivalent(row.key, currentKey)) ?? - (previousResultAgentId !== null && previousResultAgentId === currentAgentId - ? previousRowsLive.find((row) => - uiSessionRowMatchesSelectedChat(state, row.key, currentKey), - ) - : undefined)) - : undefined; - if ( - currentKey && - previousCurrentRow && - !nextResult.sessions.some((row) => - uiSessionRowMatchesSelectedChat(state, row.key, currentKey), - ) - ) { - const sessions = [...nextResult.sessions, previousCurrentRow]; - nextResult = { ...nextResult, count: sessions.length, sessions }; - } - state.sessionsResult = nextResult; - state.sessionsResultAgentId = resultAgentId; - state.sessionsResultShowArchived = showArchived; - if (hasCurrentChatSession(state) && overrides?.backgroundHydrate !== true) { - reconcileChatRunFromCurrentSessionRow(state, { - publishRunStatus: overrides?.publishChatRunStatus !== false, - }); - } - const nextKeys = new Set(state.sessionsResult.sessions.map((row) => row.key)); - for (const key of Object.keys(state.sessionsCheckpointItemsByKey)) { - if (!nextKeys.has(key)) { - invalidateCheckpointCacheForKey(state, key); - } - } - let expandedNeedsRefetch = false; - for (const row of state.sessionsResult.sessions) { - const previous = previousRows.get(row.key); - if (checkpointSummarySignature(previous) !== checkpointSummarySignature(row)) { - invalidateCheckpointCacheForKey(state, row.key); - if (state.sessionsExpandedCheckpointKey === row.key) { - expandedNeedsRefetch = true; - } - } - } - const expandedKey = state.sessionsExpandedCheckpointKey; - if ( - expandedKey && - nextKeys.has(expandedKey) && - (expandedNeedsRefetch || !state.sessionsCheckpointItemsByKey[expandedKey]) - ) { - await fetchSessionCompactionCheckpoints(state, expandedKey); - } - } - })().catch((err: unknown) => { - if (!isMissingOperatorReadScopeError(err)) { - state.sessionsError = String(err); - return; - } - state.sessionsResult = null; - state.sessionsError = formatMissingOperatorReadScopeMessage("sessions"); - }); -} - -export async function patchSession( - state: SessionsState, - key: string, - patch: { - label?: string | null; - archived?: boolean; - pinned?: boolean; - thinkingLevel?: string | null; - fastMode?: FastMode | null; - verboseLevel?: string | null; - reasoningLevel?: string | null; - }, - refreshOverrides?: LoadSessionsOverrides, -): Promise { - if (!state.client || !state.connected) { - return false; - } - const params: Record = { - key, - ...(isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), - }; - for (const field of [ - "label", - "archived", - "pinned", - "thinkingLevel", - "fastMode", - "verboseLevel", - "reasoningLevel", - ] as const) { - if (field in patch) { - params[field] = patch[field]; - } - } - try { - await state.client.request("sessions.patch", params); - await loadSessions(state, { - ...refreshOverrides, - ...(isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), - }); - return true; - } catch (err) { - state.sessionsError = String(err); - return false; - } -} - -export async function createSessionAndRefresh( - state: SessionsState, - params: CreateSessionParams = {}, - refreshOverrides?: LoadSessionsOverrides, -): Promise { - if (!state.client || !state.connected || state.sessionsLoading) { - return null; - } - const client = state.client; - let createdKey: string | null = null; - try { - await withSessionsLoading(state, async () => { - const result = await client.request("sessions.create", params); - const key = typeof result?.key === "string" ? result.key.trim() : ""; - if (!key) { - throw new Error("sessions.create returned no key"); - } - createdKey = key; - await loadSessions(state, refreshOverrides); - }); - } catch (err) { - state.sessionsError = String(err); - return null; - } - return createdKey; -} - -export async function deleteSessionsAndRefresh( - state: SessionsState, - keys: string[], -): Promise { - if (!state.client || !state.connected || keys.length === 0) { - return []; - } - const client = state.client; - if (state.sessionsLoading) { - return []; - } - const confirmed = window.confirm( - `Delete ${keys.length} ${keys.length === 1 ? "session" : "sessions"}?\n\nThis will delete the session entries and archive their transcripts.`, - ); - if (!confirmed) { - return []; - } - const deleted: string[] = []; - const deleteErrors: string[] = []; - const refreshedDuringDelete = await withSessionsLoading(state, async () => { - for (const key of keys) { - try { - await client.request("sessions.delete", { - key, - ...(isUiGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), - deleteTranscript: true, - }); - deleted.push(key); - } catch (err) { - deleteErrors.push(String(err)); - } - } - }); - if (deleted.length > 0 && !refreshedDuringDelete) { - const selectedGlobalDeleted = deleted.some((key) => isUiGlobalSessionKey(key)); - await loadSessions( - state, - selectedGlobalDeleted ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, - ); - } - if (deleteErrors.length > 0) { - state.sessionsError = deleteErrors.join("; "); - } - return deleted; -} - -export async function toggleSessionCompactionCheckpoints(state: SessionsState, key: string) { - const trimmedKey = key.trim(); - if (!trimmedKey) { - return; - } - if (state.sessionsExpandedCheckpointKey === trimmedKey) { - state.sessionsExpandedCheckpointKey = null; - return; - } - state.sessionsExpandedCheckpointKey = trimmedKey; - if (state.sessionsCheckpointItemsByKey[trimmedKey]) { - return; - } - await fetchSessionCompactionCheckpoints(state, trimmedKey); -} - -export async function branchSessionFromCheckpoint( - state: SessionsState, - key: string, - checkpointId: string, -): Promise { - const result = await runCompactionMutation( - state, - key, - checkpointId, - "sessions.compaction.branch", - "Create a new child session from this compacted checkpoint?", - ); - return result?.key ?? null; -} - -export async function restoreSessionFromCheckpoint( - state: SessionsState, - key: string, - checkpointId: string, -) { - await runCompactionMutation( - state, - key, - checkpointId, - "sessions.compaction.restore", - "Restore this session to the selected compacted checkpoint?\n\nThis replaces the current active transcript for the session key.", - ); -} diff --git a/ui/src/ui/controllers/usage.node.test.ts b/ui/src/ui/controllers/usage.node.test.ts deleted file mode 100644 index 152768d27b3d..000000000000 --- a/ui/src/ui/controllers/usage.node.test.ts +++ /dev/null @@ -1,498 +0,0 @@ -// @vitest-environment node -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - testApi, - loadSessionLogs, - loadSessionTimeSeries, - loadUsage, - type UsageState, -} from "./usage.ts"; - -type RequestFn = (method: string, params?: unknown) => Promise; - -function createState(request: RequestFn, overrides: Partial = {}): UsageState { - return { - client: { request } as unknown as UsageState["client"], - connected: true, - usageLoading: false, - usageResult: null, - usageCostSummary: null, - usageError: null, - usageStartDate: "2026-02-16", - usageEndDate: "2026-02-16", - usageScope: "family", - usageAgentId: null, - usageQuery: "", - usageSelectedSessions: [], - usageSelectedDays: [], - usageTimeSeries: null, - usageTimeSeriesLoading: false, - usageTimeSeriesCursorStart: null, - usageTimeSeriesCursorEnd: null, - usageSessionLogs: null, - usageSessionLogsLoading: false, - usageTimeZone: "local", - ...overrides, - }; -} - -function expectSpecificTimezoneCalls(request: ReturnType, startCall: number): void { - expect(request).toHaveBeenNthCalledWith(startCall, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "specific", - utcOffset: "UTC+5:30", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(startCall + 1, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "specific", - utcOffset: "UTC+5:30", - }); -} - -describe("usage controller date interpretation params", () => { - beforeEach(() => { - testApi.resetLegacyUsageDateParamsCache(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("formats UTC offsets for whole and half-hour timezones", () => { - expect(testApi.formatUtcOffset(240)).toBe("UTC-4"); - expect(testApi.formatUtcOffset(-330)).toBe("UTC+5:30"); - expect(testApi.formatUtcOffset(0)).toBe("UTC+0"); - }); - - it("sends specific mode with browser offset when usage timezone is local", async () => { - const request = vi.fn(async () => ({})); - const state = createState(request, { usageTimeZone: "local" }); - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330); - - await loadUsage(state); - - expectSpecificTimezoneCalls(request, 1); - }); - - it("sends utc mode without offset when usage timezone is utc", async () => { - const request = vi.fn(async () => ({})); - const state = createState(request, { usageTimeZone: "utc" }); - - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "utc", - }); - }); - - it("requests all-agent sessions and costs by default", async () => { - const request = vi.fn(async () => ({})); - const state = createState(request, { - usageTimeZone: "utc", - }); - - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "utc", - }); - }); - - it("passes selected agent as sessions and cost agentId", async () => { - const request = vi.fn(async () => ({})); - const state = createState(request, { - usageAgentId: "research", - usageTimeZone: "utc", - }); - - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentId: "research", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentId: "research", - mode: "utc", - }); - }); - - it("captures useful error strings in loadUsage", async () => { - const request = vi.fn(async () => { - throw new Error("request failed"); - }); - const state = createState(request); - - await loadUsage(state); - - expect(state.usageError).toBe("request failed"); - }); - - it("serializes non-Error objects without object-to-string coercion", () => { - expect(testApi.toErrorMessage({ reason: "nope" })).toBe('{"reason":"nope"}'); - }); - - it("falls back and remembers compatibility when sessions.usage rejects mode/utcOffset", async () => { - const storage = createStorageMock(); - vi.stubGlobal("localStorage", storage as unknown as Storage); - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330); - - const request = vi.fn(async (method: string, params?: unknown) => { - if (method === "sessions.usage") { - const record = (params ?? {}) as Record; - if ("mode" in record || "utcOffset" in record) { - throw new Error( - "invalid sessions.usage params: at root: unexpected property 'mode'; at root: unexpected property 'utcOffset'", - ); - } - return { sessions: [] }; - } - return {}; - }); - - const state = createState(request, { - usageTimeZone: "local", - settings: { gatewayUrl: "ws://127.0.0.1:18789" }, - }); - - await loadUsage(state); - - expectSpecificTimezoneCalls(request, 1); - expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(4, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - }); - - // Subsequent loads for the same gateway should skip mode/utcOffset immediately. - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(6, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - }); - - // Persisted flag should survive cache resets (simulating app reload). - testApi.resetLegacyUsageDateParamsCache(); - expect(testApi.shouldSendLegacyDateInterpretation(state)).toBe(false); - - vi.unstubAllGlobals(); - }); - - it("falls back and remembers compatibility when sessions.usage rejects lineage params", async () => { - const storage = createStorageMock(); - vi.stubGlobal("localStorage", storage as unknown as Storage); - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330); - - const request = vi.fn(async (method: string, params?: unknown) => { - if (method === "sessions.usage") { - const record = (params ?? {}) as Record; - if ("groupBy" in record || "includeHistorical" in record) { - throw new Error( - "invalid sessions.usage params: at root: unexpected property 'groupBy'; at root: unexpected property 'includeHistorical'", - ); - } - return { sessions: [] }; - } - return {}; - }); - - const state = createState(request, { - usageTimeZone: "local", - settings: { gatewayUrl: "ws://127.0.0.1:18789" }, - }); - - await loadUsage(state); - - expectSpecificTimezoneCalls(request, 1); - expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "specific", - utcOffset: "UTC+5:30", - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(4, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "specific", - utcOffset: "UTC+5:30", - }); - - // Subsequent loads for the same gateway should still send date params but skip lineage params. - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "specific", - utcOffset: "UTC+5:30", - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(6, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "specific", - utcOffset: "UTC+5:30", - }); - - vi.unstubAllGlobals(); - }); - - it("falls back and remembers compatibility when sessions.usage rejects agentId", async () => { - const storage = createStorageMock(); - vi.stubGlobal("localStorage", storage as unknown as Storage); - - const request = vi.fn(async (method: string, params?: unknown) => { - if (method === "sessions.usage") { - const record = (params ?? {}) as Record; - if ("agentId" in record) { - throw new Error("invalid sessions.usage params: at root: unexpected property 'agentId'"); - } - return { sessions: [] }; - } - return {}; - }); - - const state = createState(request, { - settings: { gatewayUrl: "ws://127.0.0.1:18789" }, - usageAgentId: "research", - usageTimeZone: "utc", - }); - - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentId: "research", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentId: "research", - mode: "utc", - }); - expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(4, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - mode: "utc", - }); - - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(6, "usage.cost", { - startDate: "2026-02-16", - endDate: "2026-02-16", - mode: "utc", - }); - - testApi.resetLegacyUsageDateParamsCache(); - expect(testApi.shouldSendLegacyUsageAgentParams(state)).toBe(false); - - vi.unstubAllGlobals(); - }); - - it("falls back and remembers compatibility when sessions.usage rejects agentScope", async () => { - const storage = createStorageMock(); - vi.stubGlobal("localStorage", storage as unknown as Storage); - - const request = vi.fn(async (method: string, params?: unknown) => { - if (method === "sessions.usage") { - const record = (params ?? {}) as Record; - if ("agentScope" in record) { - throw new Error( - "invalid sessions.usage params: at root: unexpected property 'agentScope'", - ); - } - return { sessions: [] }; - } - return {}; - }); - - const state = createState(request, { - settings: { gatewayUrl: "ws://127.0.0.1:18789" }, - usageTimeZone: "utc", - }); - - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - agentScope: "all", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - - await loadUsage(state); - - expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", { - startDate: "2026-02-16", - endDate: "2026-02-16", - mode: "utc", - groupBy: "family", - includeHistorical: true, - limit: 1000, - includeContextWeight: true, - }); - - testApi.resetLegacyUsageDateParamsCache(); - expect(testApi.shouldSendLegacyUsageAgentScope(state)).toBe(false); - - vi.unstubAllGlobals(); - }); - - it("keeps optional loaders resilient when requests fail", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.usage.timeseries" || method === "sessions.usage.logs") { - throw new Error("optional endpoint unavailable"); - } - return {}; - }); - const state = createState(request); - - await loadSessionTimeSeries(state, "session-1"); - await loadSessionLogs(state, "session-1"); - - expect(state.usageTimeSeries).toBeNull(); - expect(state.usageSessionLogs).toBeNull(); - expect(state.usageTimeSeriesLoading).toBe(false); - expect(state.usageSessionLogsLoading).toBe(false); - }); - - it("normalizes usage logs payloads when logs is not an array", async () => { - const request = vi.fn(async (method: string) => { - if (method === "sessions.usage.logs") { - return { logs: "unexpected-shape" }; - } - return {}; - }); - const state = createState(request); - - await loadSessionLogs(state, "session-1"); - - expect(state.usageSessionLogs).toBeNull(); - expect(state.usageSessionLogsLoading).toBe(false); - }); -}); - -function createStorageMock() { - const store = new Map(); - return { - getItem(key: string) { - return store.get(key) ?? null; - }, - setItem(key: string, value: string) { - store.set(key, value); - }, - removeItem(key: string) { - store.delete(key); - }, - clear() { - store.clear(); - }, - }; -} diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts deleted file mode 100644 index 9ac0299c120b..000000000000 --- a/ui/src/ui/controllers/usage.ts +++ /dev/null @@ -1,444 +0,0 @@ -// Control UI controller manages usage gateway state. -import { getSafeLocalStorage } from "../../local-storage.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts"; -import type { SessionLogEntry } from "../views/usage.ts"; -import { - formatMissingOperatorReadScopeMessage, - isMissingOperatorReadScopeError, -} from "./scope-errors.ts"; - -export type UsageState = { - client: GatewayBrowserClient | null; - connected: boolean; - usageLoading: boolean; - usageResult: SessionsUsageResult | null; - usageCostSummary: CostUsageSummary | null; - usageError: string | null; - usageStartDate: string; - usageEndDate: string; - usageScope: "instance" | "family"; - usageAgentId: string | null; - usageQuery: string; - usageSelectedSessions: string[]; - usageSelectedDays: string[]; - usageTimeSeries: SessionUsageTimeSeries | null; - usageTimeSeriesLoading: boolean; - usageTimeSeriesCursorStart: number | null; - usageTimeSeriesCursorEnd: number | null; - usageSessionLogs: SessionLogEntry[] | null; - usageSessionLogsLoading: boolean; - usageTimeZone: "local" | "utc"; - settings?: { gatewayUrl?: string }; -}; - -const LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY = "openclaw.control.usage.date-params.v1"; -const LEGACY_USAGE_SCOPE_PARAMS_STORAGE_KEY = "openclaw.control.usage.scope-params.v1"; -const LEGACY_USAGE_AGENT_PARAMS_STORAGE_KEY = "openclaw.control.usage.agent-params.v1"; -const LEGACY_USAGE_AGENT_SCOPE_STORAGE_KEY = "openclaw.control.usage.agent-scope.v1"; -const LEGACY_USAGE_DATE_PARAMS_MODE_RE = /unexpected property ['"]mode['"]/i; -const LEGACY_USAGE_DATE_PARAMS_OFFSET_RE = /unexpected property ['"]utcoffset['"]/i; -const LEGACY_USAGE_SCOPE_PARAMS_GROUP_BY_RE = /unexpected property ['"]groupby['"]/i; -const LEGACY_USAGE_SCOPE_PARAMS_INCLUDE_HISTORICAL_RE = - /unexpected property ['"]includehistorical['"]/i; -const LEGACY_USAGE_AGENT_PARAMS_AGENT_ID_RE = /unexpected property ['"]agentid['"]/i; -const LEGACY_USAGE_AGENT_SCOPE_RE = /unexpected property ['"]agentscope['"]/i; -const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i; - -let legacyUsageDateParamsCache: Set | null = null; -let legacyUsageScopeParamsCache: Set | null = null; -let legacyUsageAgentParamsCache: Set | null = null; -let legacyUsageAgentScopeCache: Set | null = null; - -function loadLegacyGatewayParamCache(storageKey: string): Set { - const raw = getSafeLocalStorage()?.getItem(storageKey); - if (!raw) { - return new Set(); - } - try { - const keys = (JSON.parse(raw) as { unsupportedGatewayKeys?: unknown } | null) - ?.unsupportedGatewayKeys; - if (!Array.isArray(keys)) { - return new Set(); - } - return new Set( - keys - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => entry.trim()) - .filter(Boolean), - ); - } catch { - return new Set(); - } -} - -function persistLegacyGatewayParamCache(storageKey: string, cache: Set) { - try { - getSafeLocalStorage()?.setItem( - storageKey, - JSON.stringify({ unsupportedGatewayKeys: Array.from(cache) }), - ); - } catch { - // ignore quota/private-mode failures - } -} - -function getLegacyUsageDateParamsCache(): Set { - if (!legacyUsageDateParamsCache) { - legacyUsageDateParamsCache = loadLegacyGatewayParamCache(LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY); - } - return legacyUsageDateParamsCache; -} - -function getLegacyUsageScopeParamsCache(): Set { - if (!legacyUsageScopeParamsCache) { - legacyUsageScopeParamsCache = loadLegacyGatewayParamCache( - LEGACY_USAGE_SCOPE_PARAMS_STORAGE_KEY, - ); - } - return legacyUsageScopeParamsCache; -} - -function getLegacyUsageAgentParamsCache(): Set { - if (!legacyUsageAgentParamsCache) { - legacyUsageAgentParamsCache = loadLegacyGatewayParamCache( - LEGACY_USAGE_AGENT_PARAMS_STORAGE_KEY, - ); - } - return legacyUsageAgentParamsCache; -} - -function getLegacyUsageAgentScopeCache(): Set { - if (!legacyUsageAgentScopeCache) { - legacyUsageAgentScopeCache = loadLegacyGatewayParamCache(LEGACY_USAGE_AGENT_SCOPE_STORAGE_KEY); - } - return legacyUsageAgentScopeCache; -} - -function normalizeGatewayCompatibilityKey(gatewayUrl?: string): string { - const trimmed = gatewayUrl?.trim(); - if (!trimmed) { - return "__default__"; - } - try { - const parsed = new URL(trimmed); - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return normalizeLowercaseStringOrEmpty(`${parsed.protocol}//${parsed.host}${pathname}`); - } catch { - return normalizeLowercaseStringOrEmpty(trimmed); - } -} - -function shouldSendLegacyDateInterpretation(state: UsageState): boolean { - return !getLegacyUsageDateParamsCache().has( - normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl), - ); -} - -function rememberLegacyDateInterpretation(state: UsageState) { - const cache = getLegacyUsageDateParamsCache(); - cache.add(normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl)); - persistLegacyGatewayParamCache(LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY, cache); -} - -function shouldSendLegacyUsageScopeParams(state: UsageState): boolean { - return !getLegacyUsageScopeParamsCache().has( - normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl), - ); -} - -function rememberLegacyUsageScopeParams(state: UsageState) { - const cache = getLegacyUsageScopeParamsCache(); - cache.add(normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl)); - persistLegacyGatewayParamCache(LEGACY_USAGE_SCOPE_PARAMS_STORAGE_KEY, cache); -} - -function shouldSendLegacyUsageAgentParams(state: UsageState): boolean { - return !getLegacyUsageAgentParamsCache().has( - normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl), - ); -} - -function rememberLegacyUsageAgentParams(state: UsageState) { - const cache = getLegacyUsageAgentParamsCache(); - cache.add(normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl)); - persistLegacyGatewayParamCache(LEGACY_USAGE_AGENT_PARAMS_STORAGE_KEY, cache); -} - -function shouldSendLegacyUsageAgentScope(state: UsageState): boolean { - return !getLegacyUsageAgentScopeCache().has( - normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl), - ); -} - -function rememberLegacyUsageAgentScope(state: UsageState) { - const cache = getLegacyUsageAgentScopeCache(); - cache.add(normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl)); - persistLegacyGatewayParamCache(LEGACY_USAGE_AGENT_SCOPE_STORAGE_KEY, cache); -} - -function isLegacyDateInterpretationUnsupportedError(err: unknown): boolean { - const message = toErrorMessage(err); - return ( - LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) && - (LEGACY_USAGE_DATE_PARAMS_MODE_RE.test(message) || - LEGACY_USAGE_DATE_PARAMS_OFFSET_RE.test(message)) - ); -} - -function isLegacyUsageScopeUnsupportedError(err: unknown): boolean { - const message = toErrorMessage(err); - return ( - LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) && - (LEGACY_USAGE_SCOPE_PARAMS_GROUP_BY_RE.test(message) || - LEGACY_USAGE_SCOPE_PARAMS_INCLUDE_HISTORICAL_RE.test(message)) - ); -} - -function isLegacyUsageAgentUnsupportedError(err: unknown): boolean { - const message = toErrorMessage(err); - return ( - LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) && - LEGACY_USAGE_AGENT_PARAMS_AGENT_ID_RE.test(message) - ); -} - -function isLegacyUsageAgentScopeUnsupportedError(err: unknown): boolean { - const message = toErrorMessage(err); - return ( - LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) && LEGACY_USAGE_AGENT_SCOPE_RE.test(message) - ); -} - -const formatUtcOffset = (timezoneOffsetMinutes: number): string => { - // `Date#getTimezoneOffset()` is minutes to add to local time to reach UTC. - // Convert to UTC±H[:MM] where positive means east of UTC. - const offsetFromUtcMinutes = -timezoneOffsetMinutes; - const sign = offsetFromUtcMinutes >= 0 ? "+" : "-"; - const absMinutes = Math.abs(offsetFromUtcMinutes); - const hours = Math.floor(absMinutes / 60); - const minutes = absMinutes % 60; - return minutes === 0 - ? `UTC${sign}${hours}` - : `UTC${sign}${hours}:${minutes.toString().padStart(2, "0")}`; -}; - -const buildDateInterpretationParams = (timeZone: "local" | "utc") => { - if (timeZone === "utc") { - return { mode: "utc" }; - } - return { - mode: "specific", - utcOffset: formatUtcOffset(new Date().getTimezoneOffset()), - }; -}; - -function toErrorMessage(err: unknown): string { - if (typeof err === "string") { - return err; - } - if (err instanceof Error && typeof err.message === "string" && err.message.trim()) { - return err.message; - } - if (err && typeof err === "object") { - try { - return JSON.stringify(err) || "request failed"; - } catch { - // ignore - } - } - return "request failed"; -} - -function applyUsageResults(state: UsageState, sessionsRes: unknown, costRes: unknown) { - if (sessionsRes) { - state.usageResult = sessionsRes as SessionsUsageResult; - } - if (costRes) { - state.usageCostSummary = costRes as CostUsageSummary; - } -} - -export async function loadUsage( - state: UsageState, - overrides?: { - startDate?: string; - endDate?: string; - }, -) { - // Capture client for TS18047 work around on it being possibly null - const client = state.client; - if (!client || !state.connected || state.usageLoading) { - return; - } - state.usageLoading = true; - state.usageError = null; - try { - const startDate = overrides?.startDate ?? state.usageStartDate; - const endDate = overrides?.endDate ?? state.usageEndDate; - const agentId = normalizeLowercaseStringOrEmpty(state.usageAgentId ?? "") || undefined; - const runUsageRequests = ( - includeDateInterpretation: boolean, - includeUsageScope: boolean, - includeAgentScope: boolean, - includeAllAgentScope: boolean, - ) => { - const dateInterpretation = includeDateInterpretation - ? buildDateInterpretationParams(state.usageTimeZone) - : undefined; - const usageScopeParams = includeUsageScope - ? { - groupBy: state.usageScope, - includeHistorical: state.usageScope === "family", - } - : undefined; - const agentScopeParams = agentId - ? includeAgentScope - ? { agentId } - : undefined - : includeAllAgentScope - ? { agentScope: "all" as const } - : undefined; - return Promise.all([ - client.request("sessions.usage", { - startDate, - endDate, - ...agentScopeParams, - ...dateInterpretation, - ...usageScopeParams, - limit: 1000, // Cap at 1000 sessions - includeContextWeight: true, - }), - client.request("usage.cost", { - startDate, - endDate, - ...agentScopeParams, - ...dateInterpretation, - }), - ]); - }; - - let includeDateInterpretation = shouldSendLegacyDateInterpretation(state); - let includeUsageScope = shouldSendLegacyUsageScopeParams(state); - let includeAgentScope = Boolean(agentId) && shouldSendLegacyUsageAgentParams(state); - let includeAllAgentScope = !agentId && shouldSendLegacyUsageAgentScope(state); - while (true) { - try { - const [sessionsRes, costRes] = await runUsageRequests( - includeDateInterpretation, - includeUsageScope, - includeAgentScope, - includeAllAgentScope, - ); - applyUsageResults(state, sessionsRes, costRes); - break; - } catch (err) { - if (includeAgentScope && isLegacyUsageAgentUnsupportedError(err)) { - // Older gateways reject `agentId` in `sessions.usage`. - // Remember this per gateway and retry with client-side filtering only. - rememberLegacyUsageAgentParams(state); - includeAgentScope = false; - continue; - } - if (includeAllAgentScope && isLegacyUsageAgentScopeUnsupportedError(err)) { - // Older gateways reject explicit all-agent usage scope. Retrying without - // it keeps pre-agent-scope gateways usable while current gateways prove all-agent intent. - rememberLegacyUsageAgentScope(state); - includeAllAgentScope = false; - continue; - } - if (includeUsageScope && isLegacyUsageScopeUnsupportedError(err)) { - // Older gateways reject `groupBy`/`includeHistorical` in `sessions.usage`. - // Remember this per gateway and retry with instance-compatible params. - rememberLegacyUsageScopeParams(state); - includeUsageScope = false; - continue; - } - if (includeDateInterpretation && isLegacyDateInterpretationUnsupportedError(err)) { - // Older gateways reject `mode`/`utcOffset` in `sessions.usage`. - // Remember this per gateway and retry once without those fields. - rememberLegacyDateInterpretation(state); - includeDateInterpretation = false; - continue; - } - throw err; - } - } - } catch (err) { - if (isMissingOperatorReadScopeError(err)) { - state.usageResult = null; - state.usageCostSummary = null; - state.usageError = formatMissingOperatorReadScopeMessage("usage"); - } else { - state.usageError = toErrorMessage(err); - } - } finally { - state.usageLoading = false; - } -} - -export const testApi = { - formatUtcOffset, - buildDateInterpretationParams, - toErrorMessage, - isLegacyDateInterpretationUnsupportedError, - isLegacyUsageScopeUnsupportedError, - isLegacyUsageAgentUnsupportedError, - isLegacyUsageAgentScopeUnsupportedError, - normalizeGatewayCompatibilityKey, - shouldSendLegacyDateInterpretation, - rememberLegacyDateInterpretation, - shouldSendLegacyUsageScopeParams, - rememberLegacyUsageScopeParams, - shouldSendLegacyUsageAgentParams, - rememberLegacyUsageAgentParams, - shouldSendLegacyUsageAgentScope, - rememberLegacyUsageAgentScope, - resetLegacyUsageDateParamsCache: () => { - legacyUsageDateParamsCache = null; - legacyUsageScopeParamsCache = null; - legacyUsageAgentParamsCache = null; - legacyUsageAgentScopeCache = null; - }, -}; -export { testApi as __test }; - -async function runOptionalUsageDetailRequest( - state: UsageState, - loadingKey: "usageTimeSeriesLoading" | "usageSessionLogsLoading", - run: (client: GatewayBrowserClient) => Promise, -) { - const client = state.client; - if (!client || !state.connected || state[loadingKey]) { - return; - } - state[loadingKey] = true; - try { - await run(client); - } catch { - // Silently fail - optional detail endpoints - } finally { - state[loadingKey] = false; - } -} - -export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) { - await runOptionalUsageDetailRequest(state, "usageTimeSeriesLoading", async (client) => { - state.usageTimeSeries = null; - const res = await client.request("sessions.usage.timeseries", { key: sessionKey }); - state.usageTimeSeries = res ? (res as SessionUsageTimeSeries) : null; - }); -} - -export async function loadSessionLogs(state: UsageState, sessionKey: string) { - await runOptionalUsageDetailRequest(state, "usageSessionLogsLoading", async (client) => { - state.usageSessionLogs = null; - const payload = (await client.request("sessions.usage.logs", { - key: sessionKey, - limit: 1000, - })) as { logs?: unknown } | null; - const logs = payload?.logs; - state.usageSessionLogs = Array.isArray(logs) ? (logs as SessionLogEntry[]) : null; - }); -} diff --git a/ui/src/ui/cron-payload.ts b/ui/src/ui/cron-payload.ts deleted file mode 100644 index f61a9f9508e6..000000000000 --- a/ui/src/ui/cron-payload.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Control UI module implements cron payload behavior. -import type { CronJob, CronPayload } from "./types.ts"; - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object"); -} - -export function isCronPayload(value: unknown): value is CronPayload { - if (!isRecord(value)) { - return false; - } - if (value.kind === "systemEvent") { - return typeof value.text === "string"; - } - if (value.kind === "agentTurn") { - return typeof value.message === "string"; - } - if (value.kind === "command") { - return Array.isArray(value.argv) && value.argv.every((arg) => typeof arg === "string"); - } - return false; -} - -export function getCronJobPayload(job: CronJob): CronPayload | null { - const payload = (job as { payload?: unknown }).payload; - return isCronPayload(payload) ? payload : null; -} - -export function hasCronJobPayload(job: CronJob): boolean { - return getCronJobPayload(job) !== null; -} diff --git a/ui/src/ui/cron-status.test.ts b/ui/src/ui/cron-status.test.ts deleted file mode 100644 index 018ae4668b0f..000000000000 --- a/ui/src/ui/cron-status.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Control UI tests cover cron status derivation behavior. -import { describe, expect, it } from "vitest"; -import { isCronJobActiveFailure, resolveCronJobLastRunStatus } from "./cron-status.ts"; -import type { CronJob } from "./types.ts"; - -function job(overrides: Partial = {}): CronJob { - return { - id: "job", - name: "Job", - enabled: true, - createdAtMs: 0, - updatedAtMs: 0, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - payload: { kind: "systemEvent", text: "test" }, - ...overrides, - }; -} - -describe("isCronJobActiveFailure", () => { - it("counts an enabled job whose last run errored", () => { - expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "error" } }))).toBe(true); - }); - - it("ignores a disabled job that retains historical error state", () => { - const disabled = job({ - enabled: false, - state: { lastRunStatus: "error", consecutiveErrors: 6, nextRunAtMs: undefined }, - }); - // Historical status is still preserved for detail views. - expect(resolveCronJobLastRunStatus(disabled)).toBe("error"); - expect(isCronJobActiveFailure(disabled)).toBe(false); - }); - - it("does not count enabled jobs whose last run succeeded or is unknown", () => { - expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "ok" } }))).toBe(false); - expect(isCronJobActiveFailure(job())).toBe(false); - }); -}); diff --git a/ui/src/ui/device-auth.ts b/ui/src/ui/device-auth.ts deleted file mode 100644 index 7e588f07e4c4..000000000000 --- a/ui/src/ui/device-auth.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Control UI module implements device auth behavior. -import { - clearDeviceAuthTokenFromStore, - type DeviceAuthEntry, - loadDeviceAuthTokenFromStore, - storeDeviceAuthTokenInStore, -} from "../../../src/shared/device-auth-store.js"; -import type { DeviceAuthStore } from "../../../src/shared/device-auth.js"; -import { getSafeLocalStorage } from "../local-storage.ts"; - -const STORAGE_KEY = "openclaw.device.auth.v1"; - -function readStore(): DeviceAuthStore | null { - try { - const raw = getSafeLocalStorage()?.getItem(STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as DeviceAuthStore; - if (!parsed || parsed.version !== 1) { - return null; - } - if (!parsed.deviceId || typeof parsed.deviceId !== "string") { - return null; - } - if (!parsed.tokens || typeof parsed.tokens !== "object") { - return null; - } - return parsed; - } catch { - return null; - } -} - -function writeStore(store: DeviceAuthStore) { - try { - getSafeLocalStorage()?.setItem(STORAGE_KEY, JSON.stringify(store)); - } catch { - // best-effort - } -} - -export function loadDeviceAuthToken(params: { - deviceId: string; - role: string; -}): DeviceAuthEntry | null { - return loadDeviceAuthTokenFromStore({ - adapter: { readStore, writeStore }, - deviceId: params.deviceId, - role: params.role, - }); -} - -export function storeDeviceAuthToken(params: { - deviceId: string; - role: string; - token: string; - scopes?: string[]; -}): DeviceAuthEntry { - return storeDeviceAuthTokenInStore({ - adapter: { readStore, writeStore }, - deviceId: params.deviceId, - role: params.role, - token: params.token, - scopes: params.scopes, - }); -} - -export function clearDeviceAuthToken(params: { deviceId: string; role: string }) { - clearDeviceAuthTokenFromStore({ - adapter: { readStore, writeStore }, - deviceId: params.deviceId, - role: params.role, - }); -} diff --git a/ui/src/ui/device-identity.ts b/ui/src/ui/device-identity.ts deleted file mode 100644 index d40e01c0abdb..000000000000 --- a/ui/src/ui/device-identity.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Control UI module implements device identity behavior. -import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; -import { getSafeLocalStorage } from "../local-storage.ts"; - -type StoredIdentity = { - version: 1; - deviceId: string; - publicKey: string; - privateKey: string; - createdAtMs: number; -}; - -export type DeviceIdentity = { - deviceId: string; - publicKey: string; - privateKey: string; -}; - -const STORAGE_KEY = "openclaw-device-identity-v1"; - -function base64UrlEncode(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); -} - -function base64UrlDecode(input: string): Uint8Array { - const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); - const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); - const binary = atob(padded); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - out[i] = binary.charCodeAt(i); - } - return out; -} - -function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -async function fingerprintPublicKey(publicKey: Uint8Array): Promise { - const hash = await crypto.subtle.digest("SHA-256", publicKey.slice().buffer); - return bytesToHex(new Uint8Array(hash)); -} - -async function generateIdentity(): Promise { - const privateKey = utils.randomSecretKey(); - const publicKey = await getPublicKeyAsync(privateKey); - const deviceId = await fingerprintPublicKey(publicKey); - return { - deviceId, - publicKey: base64UrlEncode(publicKey), - privateKey: base64UrlEncode(privateKey), - }; -} - -export async function loadOrCreateDeviceIdentity(): Promise { - const storage = getSafeLocalStorage(); - try { - const raw = storage?.getItem(STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as StoredIdentity; - if ( - parsed?.version === 1 && - typeof parsed.deviceId === "string" && - typeof parsed.publicKey === "string" && - typeof parsed.privateKey === "string" - ) { - const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); - if (derivedId !== parsed.deviceId) { - const updated: StoredIdentity = { - ...parsed, - deviceId: derivedId, - }; - storage?.setItem(STORAGE_KEY, JSON.stringify(updated)); - return { - deviceId: derivedId, - publicKey: parsed.publicKey, - privateKey: parsed.privateKey, - }; - } - return { - deviceId: parsed.deviceId, - publicKey: parsed.publicKey, - privateKey: parsed.privateKey, - }; - } - } - } catch { - // fall through to regenerate - } - - const identity = await generateIdentity(); - const stored: StoredIdentity = { - version: 1, - deviceId: identity.deviceId, - publicKey: identity.publicKey, - privateKey: identity.privateKey, - createdAtMs: Date.now(), - }; - storage?.setItem(STORAGE_KEY, JSON.stringify(stored)); - return identity; -} - -export async function signDevicePayload(privateKeyBase64Url: string, payload: string) { - const key = base64UrlDecode(privateKeyBase64Url); - const data = new TextEncoder().encode(payload); - const sig = await signAsync(data, key); - return base64UrlEncode(sig); -} diff --git a/ui/src/ui/dom-tooltips.test.ts b/ui/src/ui/dom-tooltips.test.ts deleted file mode 100644 index 347e2dd8c9eb..000000000000 --- a/ui/src/ui/dom-tooltips.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - clearActiveFloatingTooltips, - prepareActiveFloatingTooltipsForRender, - promoteNativeTitleTooltip, - refreshActiveFloatingTooltip, - restoreNativeTitleTooltip, -} from "./dom-tooltips.ts"; - -afterEach(() => { - vi.restoreAllMocks(); - clearActiveFloatingTooltips(); - document.querySelector(".control-ui-floating-tooltip")?.remove(); -}); - -describe("native title tooltip promotion", () => { - it("promotes button titles into custom tooltip metadata while active", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Refresh"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe(""); - expect(button.getAttribute("aria-label")).toBe("Refresh"); - expect(button.getAttribute("data-tooltip")).toBe("Refresh"); - expect(button.getAttribute("data-native-tooltip-title")).toBe("Refresh"); - expect(button.getAttribute("data-floating-tooltip-active")).toBe("true"); - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - expect(tooltip?.textContent).toBe("Refresh"); - expect(button.getAttribute("aria-describedby")).toBeNull(); - - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe("Refresh"); - expect(button.getAttribute("aria-label")).toBeNull(); - expect(button.getAttribute("data-tooltip")).toBeNull(); - expect(button.getAttribute("data-native-tooltip-title")).toBeNull(); - expect(button.getAttribute("data-floating-tooltip-active")).toBeNull(); - expect(button.getAttribute("aria-describedby")).toBeNull(); - }); - - it("preserves existing accessible labels while promoting title tooltips", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Browser fallback"; - button.setAttribute("aria-label", "Open session"); - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("aria-label")).toBe("Open session"); - }); - - it("preserves visible button names while promoting descriptive title tooltips", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Chroma family"; - button.textContent = "Claw"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - - expect(button.textContent).toBe("Claw"); - expect(button.getAttribute("aria-label")).toBeNull(); - expect(button.getAttribute("data-tooltip")).toBe("Chroma family"); - - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe("Chroma family"); - expect(button.getAttribute("aria-label")).toBeNull(); - }); - - it("preserves existing descriptions while associating the floating tooltip", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Chroma family"; - button.textContent = "Claw"; - button.setAttribute("aria-describedby", "existing-description"); - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - expect(button.getAttribute("aria-describedby")).toBe(`existing-description ${tooltip?.id}`); - - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("aria-describedby")).toBe("existing-description"); - }); - - it("does not promote rich role-button containers", () => { - const root = document.createElement("div"); - const card = document.createElement("article"); - card.setAttribute("role", "button"); - card.title = "View details"; - card.textContent = "Ready card density visual check"; - root.append(card); - - expect(promoteNativeTitleTooltip(card, root, "pointer")).toBeNull(); - expect(card.getAttribute("title")).toBe("View details"); - expect(card.getAttribute("aria-label")).toBeNull(); - }); - - it("suppresses inherited native titles while a nested custom tooltip is active", () => { - const root = document.createElement("div"); - const card = document.createElement("article"); - const button = document.createElement("button"); - card.title = "View details"; - button.className = "btn"; - button.title = "Edit card"; - card.append(button); - root.append(card); - - promoteNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe(""); - expect(button.getAttribute("data-tooltip")).toBe("Edit card"); - - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe("Edit card"); - }); - - it("preserves explicit custom tooltip metadata", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Browser fallback"; - button.setAttribute("data-tooltip", "Custom tooltip"); - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe("Browser fallback"); - expect(button.getAttribute("data-tooltip")).toBe("Custom tooltip"); - }); - - it("refreshes generated custom tooltip text after a hovered button changes title", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Show archived cards"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - button.title = "Hide archived cards"; - promoteNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("data-tooltip")).toBe("Hide archived cards"); - expect(button.getAttribute("aria-label")).toBe("Hide archived cards"); - expect(document.querySelector(".control-ui-floating-tooltip")?.textContent).toBe( - "Hide archived cards", - ); - }); - - it("refreshes the active floating tooltip after a render restores title", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Show archived cards"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - prepareActiveFloatingTooltipsForRender(root); - button.title = "Hide archived cards"; - refreshActiveFloatingTooltip(root); - - expect(button.getAttribute("title")).toBe(""); - expect(button.getAttribute("data-tooltip")).toBe("Hide archived cards"); - expect(button.getAttribute("data-native-tooltip-title")).toBe("Hide archived cards"); - expect(button.getAttribute("aria-label")).toBe("Hide archived cards"); - expect(document.querySelector(".control-ui-floating-tooltip")?.textContent).toBe( - "Hide archived cards", - ); - - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe("Hide archived cards"); - }); - - it("keeps a generated tooltip after a render leaves its title unchanged", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Refresh"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - prepareActiveFloatingTooltipsForRender(root); - refreshActiveFloatingTooltip(root); - - expect(button.getAttribute("title")).toBe(""); - expect(button.getAttribute("data-tooltip")).toBe("Refresh"); - expect(button.getAttribute("data-native-tooltip-title")).toBe("Refresh"); - expect(button.getAttribute("data-floating-tooltip-active")).toBe("true"); - expect(document.querySelector(".control-ui-floating-tooltip")?.textContent).toBe("Refresh"); - }); - - it("clears a generated tooltip after a render removes its title", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Form view can't safely edit some fields"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - prepareActiveFloatingTooltipsForRender(root); - button.title = ""; - refreshActiveFloatingTooltip(root); - - expect(button.getAttribute("title")).toBeNull(); - expect(button.getAttribute("data-tooltip")).toBeNull(); - expect(button.getAttribute("data-native-tooltip-title")).toBeNull(); - expect(button.getAttribute("data-floating-tooltip-active")).toBeNull(); - expect(button.getAttribute("aria-label")).toBeNull(); - expect(document.querySelector(".control-ui-floating-tooltip")?.dataset.open).toBe( - "false", - ); - - expect(restoreNativeTitleTooltip(button, root, "pointer")).toBeNull(); - expect(button.getAttribute("title")).toBeNull(); - }); - - it("hides the floating tooltip after the active target is removed", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "View details"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - button.remove(); - refreshActiveFloatingTooltip(root); - - expect(document.querySelector(".control-ui-floating-tooltip")?.dataset.open).toBe( - "false", - ); - }); - - it("shows explicit custom tooltip metadata without a native title", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.setAttribute("data-tooltip", "View details"); - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBe(""); - expect(button.getAttribute("data-tooltip")).toBe("View details"); - expect(button.getAttribute("data-floating-tooltip-active")).toBe("true"); - expect(document.querySelector(".control-ui-floating-tooltip")?.textContent).toBe( - "View details", - ); - - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(button.getAttribute("title")).toBeNull(); - expect(button.getAttribute("data-tooltip")).toBe("View details"); - expect(button.getAttribute("data-floating-tooltip-active")).toBeNull(); - expect(document.querySelector(".control-ui-floating-tooltip")?.dataset.open).toBe( - "false", - ); - }); - - it("hides dismissed floating tooltips from assistive technology", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "View details"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - expect(tooltip?.getAttribute("aria-hidden")).toBeNull(); - - restoreNativeTitleTooltip(button, root, "pointer"); - - expect(tooltip?.dataset.open).toBe("false"); - expect(tooltip?.getAttribute("aria-hidden")).toBe("true"); - - promoteNativeTitleTooltip(button, root, "pointer"); - - expect(tooltip?.dataset.open).toBe("true"); - expect(tooltip?.getAttribute("aria-hidden")).toBeNull(); - }); - - it("positions the floating tooltip below the button midpoint", () => { - vi.spyOn(window, "innerWidth", "get").mockReturnValue(1024); - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Delete card"; - button.getBoundingClientRect = () => - ({ - left: 300, - right: 328, - top: 10, - bottom: 38, - width: 28, - height: 28, - x: 300, - y: 10, - toJSON: () => ({}), - }) as DOMRect; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - expect(tooltip?.style.left).toBe("314px"); - expect(tooltip?.style.top).toBe("44px"); - }); - - it("repositions the active floating tooltip on viewport movement", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - let top = 10; - button.className = "btn"; - button.title = "Delete card"; - button.getBoundingClientRect = () => - ({ - left: 300, - right: 328, - top, - bottom: top + 28, - width: 28, - height: 28, - x: 300, - y: top, - toJSON: () => ({}), - }) as DOMRect; - root.append(button); - - promoteNativeTitleTooltip(button, root, "focus"); - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - expect(tooltip?.style.top).toBe("44px"); - - top = 100; - window.dispatchEvent(new Event("scroll")); - expect(tooltip?.style.top).toBe("134px"); - - restoreNativeTitleTooltip(button, root, "focus"); - top = 200; - window.dispatchEvent(new Event("scroll")); - expect(tooltip?.style.top).toBe("134px"); - }); - - it("flips the floating tooltip above buttons near the bottom viewport edge", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - const targetTop = window.innerHeight - 38; - button.className = "btn"; - button.title = "Delete card"; - button.getBoundingClientRect = () => - ({ - left: 300, - right: 328, - top: targetTop, - bottom: targetTop + 28, - width: 28, - height: 28, - x: 300, - y: targetTop, - toJSON: () => ({}), - }) as DOMRect; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - if (tooltip) { - tooltip.getBoundingClientRect = () => - ({ - left: 0, - right: 100, - top: 0, - bottom: 24, - width: 100, - height: 24, - x: 0, - y: 0, - toJSON: () => ({}), - }) as DOMRect; - } - - refreshActiveFloatingTooltip(root); - - expect(tooltip?.style.top).toBe(`${window.innerHeight - 68}px`); - }); - - it("clamps the floating tooltip away from viewport edges", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Delete card with a longer label"; - button.getBoundingClientRect = () => - ({ - left: 2, - right: 30, - top: 10, - bottom: 38, - width: 28, - height: 28, - x: 2, - y: 10, - toJSON: () => ({}), - }) as DOMRect; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - expect(Number.parseFloat(tooltip?.style.left ?? "0")).toBeGreaterThan(100); - }); - - it("does not restore while pointer movement stays inside the promoted button", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - const icon = document.createElement("span"); - button.className = "btn"; - button.title = "Stop"; - button.append(icon); - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - restoreNativeTitleTooltip(button, root, "pointer", icon); - - expect(button.getAttribute("title")).toBe(""); - expect(button.getAttribute("data-tooltip")).toBe("Stop"); - }); - - it.each([ - ["pointer", "focus"], - ["focus", "pointer"], - ] as const)( - "keeps the tooltip active after %s leaves while %s remains", - (released, remaining) => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "Refresh"; - root.append(button); - - promoteNativeTitleTooltip(button, root, "pointer"); - promoteNativeTitleTooltip(button, root, "focus"); - - expect(restoreNativeTitleTooltip(button, root, released)).toBeNull(); - expect(button.getAttribute("title")).toBe(""); - expect(button.getAttribute("data-floating-tooltip-active")).toBe("true"); - expect( - document.querySelector(".control-ui-floating-tooltip")?.dataset.open, - ).toBe("true"); - - expect(restoreNativeTitleTooltip(button, root, remaining)).toBe(button); - expect(button.getAttribute("title")).toBe("Refresh"); - expect(button.getAttribute("data-floating-tooltip-active")).toBeNull(); - }, - ); - - it("restores the remaining active tooltip owner", () => { - const root = document.createElement("div"); - const focused = document.createElement("button"); - focused.className = "btn"; - focused.title = "Focused"; - focused.textContent = "Focused button"; - const hovered = document.createElement("button"); - hovered.className = "btn"; - hovered.title = "Hovered"; - hovered.textContent = "Hovered button"; - root.append(focused, hovered); - - promoteNativeTitleTooltip(focused, root, "focus"); - promoteNativeTitleTooltip(hovered, root, "pointer"); - refreshActiveFloatingTooltip(root); - - const tooltip = document.querySelector(".control-ui-floating-tooltip"); - expect(tooltip?.textContent).toBe("Hovered"); - expect(hovered.getAttribute("aria-describedby")).toBe(tooltip?.id); - expect(focused.getAttribute("aria-describedby")).toBeNull(); - - restoreNativeTitleTooltip(hovered, root, "pointer"); - - expect(tooltip?.textContent).toBe("Focused"); - expect(tooltip?.dataset.open).toBe("true"); - expect(hovered.getAttribute("aria-describedby")).toBeNull(); - expect(focused.getAttribute("aria-describedby")).toBe(tooltip?.id); - - restoreNativeTitleTooltip(focused, root, "focus"); - - expect(tooltip?.dataset.open).toBe("false"); - expect(focused.getAttribute("aria-describedby")).toBeNull(); - }); - - it("clears active floating tooltips and restores promoted titles", () => { - const root = document.createElement("div"); - const button = document.createElement("button"); - button.className = "btn"; - button.title = "View details"; - root.append(button); - document.body.append(root); - - promoteNativeTitleTooltip(button, root, "pointer"); - clearActiveFloatingTooltips(root); - - expect(button.getAttribute("title")).toBe("View details"); - expect(button.getAttribute("data-tooltip")).toBeNull(); - expect(button.getAttribute("data-native-tooltip-title")).toBeNull(); - expect(button.getAttribute("data-floating-tooltip-active")).toBeNull(); - expect(document.querySelector(".control-ui-floating-tooltip")?.dataset.open).toBe( - "false", - ); - root.remove(); - }); -}); diff --git a/ui/src/ui/dom-tooltips.ts b/ui/src/ui/dom-tooltips.ts deleted file mode 100644 index f733fb501e43..000000000000 --- a/ui/src/ui/dom-tooltips.ts +++ /dev/null @@ -1,395 +0,0 @@ -const TITLE_TOOLTIP_SELECTOR = - "button[title], .btn[title], button[data-tooltip], .btn[data-tooltip]"; -const PROMOTED_TITLE_ATTR = "data-native-tooltip-title"; -const GENERATED_TOOLTIP_ATTR = "data-native-tooltip-generated"; -const GENERATED_ARIA_LABEL_ATTR = "data-native-tooltip-generated-aria-label"; -const GENERATED_ARIA_DESCRIBEDBY_ATTR = "data-native-tooltip-generated-aria-describedby"; -const ACTIVE_FLOATING_TOOLTIP_ATTR = "data-floating-tooltip-active"; -const FLOATING_TOOLTIP_CLASS = "control-ui-floating-tooltip"; -const FLOATING_TOOLTIP_ID = "control-ui-floating-tooltip"; - -type FloatingTooltipTrigger = "focus" | "pointer"; - -// Pointer and focus activation can overlap. Restore native title state only -// after the last active trigger leaves the element. -const activeFloatingTooltipTriggers = new WeakMap>(); -const renderPreparedFloatingTooltips = new WeakSet(); -let activeFloatingTooltipOwner: HTMLElement | null = null; -let activeFloatingTooltipRoot: ParentNode | null = null; - -function refreshFloatingTooltipForViewportChange() { - if (activeFloatingTooltipRoot) { - refreshActiveFloatingTooltip(activeFloatingTooltipRoot); - } -} - -function stopFloatingTooltipViewportTracking() { - if (!activeFloatingTooltipRoot) { - return; - } - window.removeEventListener("scroll", refreshFloatingTooltipForViewportChange, true); - window.removeEventListener("resize", refreshFloatingTooltipForViewportChange); - activeFloatingTooltipRoot = null; -} - -function startFloatingTooltipViewportTracking(root: ParentNode) { - if (activeFloatingTooltipRoot === root) { - return; - } - stopFloatingTooltipViewportTracking(); - activeFloatingTooltipRoot = root; - window.addEventListener("scroll", refreshFloatingTooltipForViewportChange, true); - window.addEventListener("resize", refreshFloatingTooltipForViewportChange); -} - -function tooltipRootContains(root: ParentNode, element: Element): boolean { - return root instanceof Node && root.contains(element); -} - -function resolveTitleTooltipTarget( - target: EventTarget | null, - root: ParentNode, -): HTMLElement | null { - if (!(target instanceof Element)) { - return null; - } - const element = target.closest(TITLE_TOOLTIP_SELECTOR); - if (!element || !tooltipRootContains(root, element)) { - return null; - } - return element; -} - -function resolvePromotedTooltipTarget( - target: EventTarget | null, - root: ParentNode, -): HTMLElement | null { - if (!(target instanceof Element)) { - return null; - } - const element = target.closest( - `[${ACTIVE_FLOATING_TOOLTIP_ATTR}], [${PROMOTED_TITLE_ATTR}]`, - ); - if (!element || !tooltipRootContains(root, element)) { - return null; - } - return element; -} - -function getTooltipText(element: HTMLElement): string { - if (element.getAttribute(GENERATED_TOOLTIP_ATTR) === "true") { - return element.getAttribute("title") || element.getAttribute("data-tooltip") || ""; - } - return element.getAttribute("data-tooltip") || element.getAttribute("title") || ""; -} - -function restorePromotedTooltipTitle(element: HTMLElement) { - const title = element.getAttribute(PROMOTED_TITLE_ATTR); - if (title) { - element.setAttribute("title", title); - } else { - element.removeAttribute("title"); - } - element.removeAttribute(PROMOTED_TITLE_ATTR); -} - -function ensurePromotedTooltipAccessibleName(element: HTMLElement, title: string | null) { - if (!title) { - return; - } - if (element.getAttribute(GENERATED_ARIA_LABEL_ATTR) === "true") { - element.setAttribute("aria-label", title); - return; - } - const hasAccessibleNameWithoutTitle = - element.hasAttribute("aria-label") || - element.hasAttribute("aria-labelledby") || - Boolean(element.textContent?.trim()) || - Boolean(element.querySelector("[aria-label], [aria-labelledby], img[alt]:not([alt=''])")); - if (hasAccessibleNameWithoutTitle) { - return; - } - element.setAttribute("aria-label", title); - element.setAttribute(GENERATED_ARIA_LABEL_ATTR, "true"); -} - -function restorePromotedTooltipAccessibleName(element: HTMLElement) { - if (element.getAttribute(GENERATED_ARIA_LABEL_ATTR) !== "true") { - return; - } - element.removeAttribute("aria-label"); - element.removeAttribute(GENERATED_ARIA_LABEL_ATTR); -} - -function clearGeneratedTooltipMetadata(element: HTMLElement) { - if (element.getAttribute(GENERATED_TOOLTIP_ATTR) !== "true") { - return; - } - element.removeAttribute("data-tooltip"); - element.removeAttribute(GENERATED_TOOLTIP_ATTR); -} - -function ensureFloatingTooltipDescription(element: HTMLElement, tooltip: HTMLElement) { - const describedBy = element.getAttribute("aria-describedby")?.split(/\s+/).filter(Boolean) ?? []; - if (describedBy.includes(tooltip.id)) { - return; - } - element.setAttribute("aria-describedby", [...describedBy, tooltip.id].join(" ")); - element.setAttribute(GENERATED_ARIA_DESCRIBEDBY_ATTR, "true"); -} - -function restoreFloatingTooltipDescription(element: HTMLElement) { - if (element.getAttribute(GENERATED_ARIA_DESCRIBEDBY_ATTR) !== "true") { - return; - } - const describedBy = - element - .getAttribute("aria-describedby") - ?.split(/\s+/) - .filter((id) => id && id !== FLOATING_TOOLTIP_ID) ?? []; - if (describedBy.length > 0) { - element.setAttribute("aria-describedby", describedBy.join(" ")); - } else { - element.removeAttribute("aria-describedby"); - } - element.removeAttribute(GENERATED_ARIA_DESCRIBEDBY_ATTR); -} - -function getFloatingTooltip(): HTMLElement { - const existing = document.querySelector(`.${FLOATING_TOOLTIP_CLASS}`); - if (existing) { - existing.id = FLOATING_TOOLTIP_ID; - return existing; - } - const tooltip = document.createElement("div"); - tooltip.id = FLOATING_TOOLTIP_ID; - tooltip.className = FLOATING_TOOLTIP_CLASS; - tooltip.setAttribute("role", "tooltip"); - tooltip.setAttribute("aria-hidden", "true"); - document.body.append(tooltip); - return tooltip; -} - -function showFloatingTooltip(element: HTMLElement, text: string) { - const tooltip = getFloatingTooltip(); - tooltip.removeAttribute("aria-hidden"); - if (activeFloatingTooltipOwner && activeFloatingTooltipOwner !== element) { - restoreFloatingTooltipDescription(activeFloatingTooltipOwner); - } - const duplicatesGeneratedAccessibleName = - element.getAttribute(GENERATED_ARIA_LABEL_ATTR) === "true" && - element.getAttribute("aria-label")?.trim() === text.trim(); - if (duplicatesGeneratedAccessibleName) { - restoreFloatingTooltipDescription(element); - } else { - ensureFloatingTooltipDescription(element, tooltip); - } - activeFloatingTooltipOwner = element; - const rect = element.getBoundingClientRect(); - const viewportWidth = window.innerWidth || document.documentElement.clientWidth; - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - const gutter = 8; - const gap = 6; - const maxTooltipWidth = Math.min(260, viewportWidth * 0.6); - const midpoint = rect.left + rect.width / 2; - const left = Math.min( - Math.max(gutter + maxTooltipWidth / 2, midpoint), - viewportWidth - gutter - maxTooltipWidth / 2, - ); - tooltip.textContent = text; - const tooltipHeight = tooltip.getBoundingClientRect().height; - const belowTop = rect.bottom + gap; - const aboveTop = rect.top - gap - tooltipHeight; - const fitsBelow = belowTop + tooltipHeight <= viewportHeight - gutter; - const preferredTop = fitsBelow ? belowTop : aboveTop; - const maxTop = Math.max(gutter, viewportHeight - gutter - tooltipHeight); - const top = Math.min(Math.max(gutter, preferredTop), maxTop); - tooltip.style.left = `${left}px`; - tooltip.style.top = `${top}px`; - tooltip.dataset.open = "true"; -} - -function hideFloatingTooltip() { - const tooltip = document.querySelector(`.${FLOATING_TOOLTIP_CLASS}`); - if (!tooltip) { - return; - } - tooltip.dataset.open = "false"; - tooltip.setAttribute("aria-hidden", "true"); -} - -// Restore source titles before Lit renders so unchanged values remain visible to -// the post-render reconciliation and intentionally cleared titles stay cleared. -export function prepareActiveFloatingTooltipsForRender(root: ParentNode): void { - for (const element of root.querySelectorAll(`[${ACTIVE_FLOATING_TOOLTIP_ATTR}]`)) { - renderPreparedFloatingTooltips.add(element); - restorePromotedTooltipTitle(element); - } -} - -function reconcilePreparedFloatingTooltips(root: ParentNode) { - for (const element of root.querySelectorAll(`[${ACTIVE_FLOATING_TOOLTIP_ATTR}]`)) { - if (!renderPreparedFloatingTooltips.delete(element)) { - continue; - } - const title = element.getAttribute("title"); - if (title) { - element.setAttribute(PROMOTED_TITLE_ATTR, title); - ensurePromotedTooltipAccessibleName(element, title); - if ( - !element.hasAttribute("data-tooltip") || - element.getAttribute(GENERATED_TOOLTIP_ATTR) === "true" - ) { - element.setAttribute("data-tooltip", title); - element.setAttribute(GENERATED_TOOLTIP_ATTR, "true"); - } - element.setAttribute("title", ""); - continue; - } - - element.removeAttribute("title"); - element.removeAttribute(PROMOTED_TITLE_ATTR); - restorePromotedTooltipAccessibleName(element); - clearGeneratedTooltipMetadata(element); - if (getTooltipText(element)) { - continue; - } - element.removeAttribute(ACTIVE_FLOATING_TOOLTIP_ATTR); - activeFloatingTooltipTriggers.delete(element); - restoreFloatingTooltipDescription(element); - if (activeFloatingTooltipOwner === element) { - activeFloatingTooltipOwner = null; - } - } -} - -export function clearActiveFloatingTooltips(root: ParentNode = document): void { - for (const element of root.querySelectorAll( - `[${ACTIVE_FLOATING_TOOLTIP_ATTR}], [${PROMOTED_TITLE_ATTR}]`, - )) { - restorePromotedTooltipTitle(element); - element.removeAttribute(ACTIVE_FLOATING_TOOLTIP_ATTR); - activeFloatingTooltipTriggers.delete(element); - renderPreparedFloatingTooltips.delete(element); - clearGeneratedTooltipMetadata(element); - restorePromotedTooltipAccessibleName(element); - restoreFloatingTooltipDescription(element); - } - if (activeFloatingTooltipOwner) { - restoreFloatingTooltipDescription(activeFloatingTooltipOwner); - } - activeFloatingTooltipOwner = null; - stopFloatingTooltipViewportTracking(); - hideFloatingTooltip(); -} - -export function promoteNativeTitleTooltip( - target: EventTarget | null, - root: ParentNode, - trigger: FloatingTooltipTrigger, -): HTMLElement | null { - const element = resolveTitleTooltipTarget(target, root); - const tooltipText = element ? getTooltipText(element) : ""; - if (!element || !tooltipText) { - return null; - } - const title = element.getAttribute("title"); - if (title) { - element.setAttribute(PROMOTED_TITLE_ATTR, title); - } - ensurePromotedTooltipAccessibleName(element, title); - if ( - !element.hasAttribute("data-tooltip") || - element.getAttribute(GENERATED_TOOLTIP_ATTR) === "true" - ) { - element.setAttribute("data-tooltip", tooltipText); - element.setAttribute(GENERATED_TOOLTIP_ATTR, "true"); - } - element.setAttribute("title", ""); - const triggers = activeFloatingTooltipTriggers.get(element) ?? new Set(); - triggers.add(trigger); - activeFloatingTooltipTriggers.set(element, triggers); - element.setAttribute(ACTIVE_FLOATING_TOOLTIP_ATTR, "true"); - startFloatingTooltipViewportTracking(root); - showFloatingTooltip(element, tooltipText); - return element; -} - -export function refreshActiveFloatingTooltip(root: ParentNode): HTMLElement | null { - reconcilePreparedFloatingTooltips(root); - const owner = activeFloatingTooltipOwner; - const element = - owner && tooltipRootContains(root, owner) && owner.hasAttribute(ACTIVE_FLOATING_TOOLTIP_ATTR) - ? owner - : root.querySelector(`[${ACTIVE_FLOATING_TOOLTIP_ATTR}]`); - if (!element) { - if (activeFloatingTooltipOwner) { - restoreFloatingTooltipDescription(activeFloatingTooltipOwner); - } - activeFloatingTooltipOwner = null; - stopFloatingTooltipViewportTracking(); - hideFloatingTooltip(); - return null; - } - startFloatingTooltipViewportTracking(root); - const tooltipText = getTooltipText(element); - if (!tooltipText) { - restorePromotedTooltipTitle(element); - element.removeAttribute(ACTIVE_FLOATING_TOOLTIP_ATTR); - activeFloatingTooltipTriggers.delete(element); - renderPreparedFloatingTooltips.delete(element); - clearGeneratedTooltipMetadata(element); - restorePromotedTooltipAccessibleName(element); - restoreFloatingTooltipDescription(element); - activeFloatingTooltipOwner = null; - return refreshActiveFloatingTooltip(root); - } - const title = element.getAttribute("title"); - if (title) { - element.setAttribute(PROMOTED_TITLE_ATTR, title); - } - ensurePromotedTooltipAccessibleName(element, title); - if ( - !element.hasAttribute("data-tooltip") || - element.getAttribute(GENERATED_TOOLTIP_ATTR) === "true" - ) { - element.setAttribute("data-tooltip", tooltipText); - element.setAttribute(GENERATED_TOOLTIP_ATTR, "true"); - } - element.setAttribute("title", ""); - showFloatingTooltip(element, tooltipText); - return element; -} - -export function restoreNativeTitleTooltip( - target: EventTarget | null, - root: ParentNode, - trigger: FloatingTooltipTrigger, - relatedTarget?: EventTarget | null, -): HTMLElement | null { - const element = resolvePromotedTooltipTarget(target, root); - if (!element) { - return null; - } - if (relatedTarget instanceof Node && element.contains(relatedTarget)) { - return null; - } - const triggers = activeFloatingTooltipTriggers.get(element); - triggers?.delete(trigger); - if (triggers?.size) { - return null; - } - activeFloatingTooltipTriggers.delete(element); - renderPreparedFloatingTooltips.delete(element); - const wasOwner = activeFloatingTooltipOwner === element; - restorePromotedTooltipTitle(element); - element.removeAttribute(ACTIVE_FLOATING_TOOLTIP_ATTR); - clearGeneratedTooltipMetadata(element); - restorePromotedTooltipAccessibleName(element); - restoreFloatingTooltipDescription(element); - if (wasOwner) { - activeFloatingTooltipOwner = null; - refreshActiveFloatingTooltip(root); - } - return element; -} diff --git a/ui/src/ui/embed-sandbox.ts b/ui/src/ui/embed-sandbox.ts deleted file mode 100644 index f44df4227207..000000000000 --- a/ui/src/ui/embed-sandbox.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Control UI module implements embed sandbox behavior. -import type { ControlUiEmbedSandboxMode } from "../../../src/gateway/control-ui-contract.js"; - -export type EmbedSandboxMode = ControlUiEmbedSandboxMode; - -export function resolveEmbedSandbox(mode: EmbedSandboxMode | null | undefined): string { - switch (mode) { - case "strict": - return ""; - case "trusted": - return "allow-scripts allow-same-origin"; - default: - return "allow-scripts"; - } -} diff --git a/ui/src/ui/lazy-view.browser.test.ts b/ui/src/ui/lazy-view.browser.test.ts deleted file mode 100644 index f0e6752450f6..000000000000 --- a/ui/src/ui/lazy-view.browser.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Control UI tests cover lazy view behavior. -import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; -import { createLazyView, renderLazyView } from "./lazy-view.ts"; - -async function flushPromises() { - await Promise.resolve(); - await Promise.resolve(); -} - -function expectButtonWithText(container: Element, text: string): HTMLButtonElement { - const button = Array.from(container.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.trim() === text, - ); - expect(button).toBeInstanceOf(HTMLButtonElement); - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`Expected button with text "${text}"`); - } - return button; -} - -describe("lazy view rendering", () => { - it("renders a loading panel until the view module resolves", async () => { - const onChange = vi.fn(); - const view = createLazyView(async () => ({ label: "Logs view" }), onChange); - const container = document.createElement("div"); - - render( - renderLazyView(view, (mod) => mod.label), - container, - ); - - expect( - container.querySelector(".lazy-view-state--loading .card-title")?.textContent?.trim(), - ).toBe("Loading panel"); - - await flushPromises(); - render( - renderLazyView(view, (mod) => mod.label), - container, - ); - - expect(onChange).toHaveBeenCalled(); - expect(container.textContent?.trim()).toBe("Logs view"); - }); - - it("renders a recoverable error panel when a lazy module import fails", async () => { - const onChange = vi.fn(); - const loader = vi - .fn<() => Promise<{ label: string }>>() - .mockRejectedValueOnce(new Error("chunk 404")) - .mockResolvedValueOnce({ label: "Recovered" }); - const view = createLazyView(loader, onChange); - const container = document.createElement("div"); - - render( - renderLazyView(view, (mod) => mod.label), - container, - ); - await flushPromises(); - render( - renderLazyView(view, (mod) => mod.label), - container, - ); - - expect( - container.querySelector(".lazy-view-state--error .card-title")?.textContent?.trim(), - ).toBe("Panel failed to load"); - expect(container.querySelector(".lazy-view-state--error .callout")?.textContent?.trim()).toBe( - "chunk 404", - ); - - const retry = expectButtonWithText(container, "Retry"); - retry.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await flushPromises(); - render( - renderLazyView(view, (mod) => mod.label), - container, - ); - - expect(loader).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenCalled(); - expect(container.textContent?.trim()).toBe("Recovered"); - }); -}); diff --git a/ui/src/ui/lazy-view.ts b/ui/src/ui/lazy-view.ts deleted file mode 100644 index db73f73a1653..000000000000 --- a/ui/src/ui/lazy-view.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Control UI module implements lazy view behavior. -import { html } from "lit"; -import { t } from "../i18n/index.ts"; - -type LazyState = { - mod: T | null; - promise: Promise | null; - error: unknown; - hasError: boolean; -}; - -export type LazyView = { - read: () => T | null; - retry: () => void; - error: () => unknown; - hasError: () => boolean; - pending: () => boolean; -}; - -export function createLazyView(loader: () => Promise, onChange?: () => void): LazyView { - const state: LazyState = { mod: null, promise: null, error: undefined, hasError: false }; - - const load = () => { - state.promise = loader() - .then( - (mod) => { - state.mod = mod; - state.error = undefined; - state.hasError = false; - }, - (error: unknown) => { - state.error = error; - state.hasError = true; - state.promise = null; - }, - ) - .finally(() => { - onChange?.(); - }); - }; - - return { - read: () => { - if (state.mod !== null) { - return state.mod; - } - if (!state.promise && !state.hasError) { - load(); - } - return null; - }, - retry: () => { - if (state.mod !== null) { - return; - } - state.error = undefined; - state.hasError = false; - state.promise = null; - load(); - onChange?.(); - }, - error: () => state.error, - hasError: () => state.hasError, - pending: () => state.promise !== null, - }; -} - -function formatLazyViewError(error: unknown): string { - if (error instanceof Error && error.message.trim()) { - return error.message; - } - if (typeof error === "string" && error.trim()) { - return error.trim(); - } - return t("lazyView.unknownError"); -} - -export function renderLazyView(view: LazyView, render: (mod: M) => unknown) { - const mod = view.read(); - if (mod !== null) { - return render(mod); - } - - if (view.hasError()) { - const error = view.error(); - return html` -
-
${t("lazyView.errorTitle")}
-
${t("lazyView.errorSubtitle")}
-
${formatLazyViewError(error)}
-
- - -
-
- `; - } - - return html` -
-
${t("lazyView.loadingTitle")}
-
${t("common.loading")}
-
- `; -} diff --git a/ui/src/ui/model-auth-helpers.ts b/ui/src/ui/model-auth-helpers.ts deleted file mode 100644 index 397a49767175..000000000000 --- a/ui/src/ui/model-auth-helpers.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Control UI module implements model auth helpers behavior. -import type { ModelAuthStatusProvider } from "./types.ts"; - -/** - * True when a provider's auth should be actively monitored on the dashboard. - * - * Includes: - * - Providers with at least one OAuth or bearer-token profile (refreshable - * credentials that can expire and need rotation) - * - Providers with status="missing" (configured-but-not-logged-in — the - * server synthesizes these so the UI can prompt for login) - * - * Excludes API-key-only providers — their credentials don't expire on a - * schedule the dashboard can meaningfully monitor. - * - * Single source of truth for both the Overview card and the attention-items - * panel. Keep the two in sync by always routing through this helper. - */ -export function isMonitoredAuthProvider(p: ModelAuthStatusProvider): boolean { - if (p.status === "missing") { - return true; - } - if (!Array.isArray(p.profiles)) { - return false; - } - return p.profiles.some((prof) => prof.type === "oauth" || prof.type === "token"); -} diff --git a/ui/src/ui/navigation-groups.test.ts b/ui/src/ui/navigation-groups.test.ts deleted file mode 100644 index f46e9c0886a3..000000000000 --- a/ui/src/ui/navigation-groups.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Control UI tests cover navigation groups behavior. -import { describe, expect, it } from "vitest"; -import { - SETTINGS_TABS, - TAB_GROUPS, - isSettingsTab, - isTabInGroup, - tabFromPath, -} from "./navigation.ts"; - -describe("TAB_GROUPS", () => { - it("collapses detailed settings slices into one sidebar entry", () => { - const settings = TAB_GROUPS.find((group) => group.label === "settings"); - expect(settings?.tabs).toEqual(["config"]); - expect(SETTINGS_TABS.every((tab) => isSettingsTab(tab))).toBe(true); - }); - - it("keeps channel management out of the primary control sidebar", () => { - const control = TAB_GROUPS.find((group) => group.label === "control"); - expect(control?.tabs).toEqual([ - "overview", - "activity", - "workboard", - "instances", - "sessions", - "usage", - "cron", - ]); - expect(SETTINGS_TABS).toContain("channels"); - }); - - it("keeps the settings group active for nested settings routes", () => { - const settings = TAB_GROUPS.find((group) => group.label === "settings"); - if (!settings) { - throw new Error("Expected settings group"); - } - - expect(isTabInGroup(settings, "appearance")).toBe(true); - expect(isTabInGroup(settings, "channels")).toBe(true); - expect(isTabInGroup(settings, "debug")).toBe(true); - expect(isTabInGroup(settings, "chat")).toBe(false); - }); - - it("routes every published settings slice", () => { - expect(tabFromPath("/communications")).toBe("communications"); - expect(tabFromPath("/appearance")).toBe("appearance"); - expect(tabFromPath("/automation")).toBe("automation"); - expect(tabFromPath("/infrastructure")).toBe("infrastructure"); - expect(tabFromPath("/ai-agents")).toBe("aiAgents"); - expect(tabFromPath("/config")).toBe("config"); - expect(tabFromPath("/channels")).toBe("channels"); - }); -}); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts deleted file mode 100644 index 8b81d55de978..000000000000 --- a/ui/src/ui/navigation.browser.test.ts +++ /dev/null @@ -1,915 +0,0 @@ -// Control UI tests cover navigation behavior. -import { describe, expect, it, vi } from "vitest"; -import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; - -registerAppMountHooks(); - -function mountApp(pathname: string) { - return mountTestApp(pathname); -} - -function nextFrame() { - return new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); -} - -function expectElement( - root: Element, - selector: string, - constructor: new () => T, -): T { - const element = root.querySelector(selector); - expect(element).toBeInstanceOf(constructor); - if (!(element instanceof constructor)) { - throw new Error(`Expected ${selector} to match ${constructor.name}`); - } - return element; -} - -function expectButtonWithText(app: ReturnType, text: string): HTMLButtonElement { - const button = Array.from(app.querySelectorAll("button")).find( - (candidate) => candidate.textContent?.trim() === text, - ); - expect(button).toBeInstanceOf(HTMLButtonElement); - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`Expected button with text "${text}"`); - } - return button; -} - -function createSessionsResult(sessions: Array>) { - return { - ts: 0, - path: "", - count: sessions.length, - defaults: { modelProvider: "openai", model: "gpt-5.5", contextTokens: null }, - sessions: sessions.map((session) => ({ - kind: "direct", - updatedAt: Date.now(), - ...session, - })), - }; -} - -async function confirmPendingGatewayChange(app: ReturnType) { - const confirmButton = expectButtonWithText(app, "Confirm"); - confirmButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await app.updateComplete; -} - -function expectConfirmedGatewayChange(app: ReturnType) { - expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); - expect(app.settings.token).toBe("abc123"); - expect(window.location.search).toBe(""); - expect(window.location.hash).toBe(""); -} - -describe("control UI routing", () => { - it("renders responsive navigation shell, drawer, and collapsed states", async () => { - const app = mountApp("/chat"); - await app.updateComplete; - - expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); - - expectElement(app, 'a.nav-item[href="/dreaming"]', HTMLAnchorElement); - }); - - it("renders the dashboard breadcrumb as an overview link", async () => { - const app = mountApp("/channels"); - await app.updateComplete; - - const breadcrumb = expectElement( - app, - "dashboard-header .dashboard-header__breadcrumb-link", - HTMLAnchorElement, - ); - expect(breadcrumb.getAttribute("href")).toBe("/overview"); - - breadcrumb.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await app.updateComplete; - - expect(app.tab).toBe("overview"); - expect(window.location.pathname).toBe("/overview"); - }); - - it("keeps the dashboard breadcrumb link inside the configured base path", async () => { - const app = mountApp("/ui/channels"); - await app.updateComplete; - - const breadcrumb = expectElement( - app, - "dashboard-header .dashboard-header__breadcrumb-link", - HTMLAnchorElement, - ); - expect(breadcrumb.getAttribute("href")).toBe("/ui/overview"); - }); - - it("renders the dreaming view on the /dreaming route", async () => { - const app = mountApp("/dreaming"); - app.dreamingStatus = { - enabled: true, - timezone: "Europe/Madrid", - verboseLogging: false, - storageMode: "inline", - separateReports: false, - shortTermCount: 2, - recallSignalCount: 1, - dailySignalCount: 1, - groundedSignalCount: 0, - totalSignalCount: 2, - phaseSignalCount: 0, - lightPhaseHitCount: 0, - remPhaseHitCount: 0, - promotedTotal: 1, - promotedToday: 1, - shortTermEntries: [], - signalEntries: [], - promotedEntries: [], - phases: { - light: { enabled: true, cron: "", managedCronPresent: false, lookbackDays: 7, limit: 20 }, - deep: { - enabled: true, - cron: "", - managedCronPresent: false, - limit: 20, - minScore: 0.75, - minRecallCount: 3, - minUniqueQueries: 2, - recencyHalfLifeDays: 7, - }, - rem: { - enabled: true, - cron: "", - managedCronPresent: false, - lookbackDays: 7, - limit: 20, - minPatternStrength: 0.6, - }, - }, - }; - app.dreamDiaryPath = "DREAMS.md"; - app.dreamDiaryContent = [ - "# Dream Diary", - "", - "", - "", - "---", - "", - "*January 1, 2026*", - "", - "What Happened", - "1. Stable operator rule surfaced.", - "", - "", - ].join("\n"); - app.requestUpdate(); - await app.updateComplete; - - expect(app.tab).toBe("dreams"); - expectElement(app, ".dreams__tab", HTMLElement); - expectElement(app, ".dreams__lobster", HTMLElement); - }); - - it("requires confirmation before sending dreaming restart patch", async () => { - const app = mountApp("/dreaming"); - const request = vi.fn(async (method: string) => { - if (method === "config.schema.lookup") { - return { - schema: { - additionalProperties: true, - }, - children: [{ key: "dreaming" }], - }; - } - if (method === "config.patch") { - return { ok: true }; - } - if (method === "config.get") { - return { - hash: "hash-2", - config: { - plugins: { - slots: { - memory: "memory-core", - }, - entries: { - "memory-core": { - config: { - dreaming: { - enabled: true, - }, - }, - }, - }, - }, - }, - }; - } - if (method === "doctor.memory.status") { - return { - dreaming: { - enabled: true, - timezone: "UTC", - verboseLogging: false, - storageMode: "inline", - separateReports: false, - shortTermCount: 0, - recallSignalCount: 0, - dailySignalCount: 0, - groundedSignalCount: 0, - totalSignalCount: 0, - phaseSignalCount: 0, - lightPhaseHitCount: 0, - remPhaseHitCount: 0, - promotedTotal: 0, - promotedToday: 0, - shortTermEntries: [], - signalEntries: [], - promotedEntries: [], - phases: { - light: { - enabled: true, - cron: "", - managedCronPresent: false, - lookbackDays: 7, - limit: 20, - }, - deep: { - enabled: true, - cron: "", - managedCronPresent: false, - limit: 20, - minScore: 0.75, - minRecallCount: 3, - minUniqueQueries: 2, - recencyHalfLifeDays: 7, - }, - rem: { - enabled: true, - cron: "", - managedCronPresent: false, - lookbackDays: 7, - limit: 20, - minPatternStrength: 0.6, - }, - }, - }, - }; - } - return {}; - }); - - app.client = { - request, - stop: vi.fn(), - } as unknown as NonNullable; - app.connected = true; - app.configSnapshot = { - hash: "hash-1", - config: { - plugins: { - slots: { - memory: "memory-core", - }, - entries: { - "memory-core": { - config: { - dreaming: { - enabled: true, - }, - }, - }, - }, - }, - }, - }; - app.dreamingStatus = { - enabled: true, - timezone: "UTC", - verboseLogging: false, - storageMode: "inline", - separateReports: false, - shortTermCount: 0, - recallSignalCount: 0, - dailySignalCount: 0, - groundedSignalCount: 0, - totalSignalCount: 0, - phaseSignalCount: 0, - lightPhaseHitCount: 0, - remPhaseHitCount: 0, - promotedTotal: 0, - promotedToday: 0, - shortTermEntries: [], - signalEntries: [], - promotedEntries: [], - phases: { - light: { enabled: true, cron: "", managedCronPresent: false, lookbackDays: 7, limit: 20 }, - deep: { - enabled: true, - cron: "", - managedCronPresent: false, - limit: 20, - minScore: 0.75, - minRecallCount: 3, - minUniqueQueries: 2, - recencyHalfLifeDays: 7, - }, - rem: { - enabled: true, - cron: "", - managedCronPresent: false, - lookbackDays: 7, - limit: 20, - minPatternStrength: 0.6, - }, - }, - }; - app.requestUpdate(); - await app.updateComplete; - - const toggle = expectElement(app, ".dreams__phase-toggle--on", HTMLButtonElement); - toggle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await app.updateComplete; - - expect(request.mock.calls.some((call) => call[0] === "config.patch")).toBe(false); - const confirmRestart = expectButtonWithText(app, "Confirm Restart"); - confirmRestart.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - - await nextFrame(); - await app.updateComplete; - - const patchCall = request.mock.calls.find((call) => call[0] === "config.patch") as - | [string, { baseHash?: string }] - | undefined; - expect(patchCall?.[1].baseHash).toBe("hash-1"); - }); - - it("renders the refreshed top navigation shell", async () => { - const app = mountApp("/chat"); - await app.updateComplete; - - expectElement(app, ".topnav-shell", HTMLElement); - expectElement(app, ".topnav-shell__content", HTMLElement); - expectElement(app, ".topnav-shell__actions", HTMLElement); - expect(app.querySelector(".topnav-shell .brand-title")).toBeNull(); - - expectElement(app, ".sidebar-shell", HTMLElement); - expectElement(app, ".sidebar-shell__header", HTMLElement); - expectElement(app, ".sidebar-shell__body", HTMLElement); - expectElement(app, ".sidebar-shell__footer", HTMLElement); - expectElement(app, ".sidebar-brand", HTMLElement); - expectElement(app, ".sidebar-brand__logo", HTMLElement); - expectElement(app, ".sidebar-brand__copy", HTMLElement); - - app.hello = { - ok: true, - server: { version: "1.2.3" }, - } as never; - app.requestUpdate(); - await app.updateComplete; - - const status = expectElement(app, ".sidebar-status", HTMLElement); - const statusDot = expectElement(app, ".sidebar-status__dot", HTMLElement); - expect(statusDot.getAttribute("aria-label")).toBe("Gateway status: Online"); - expect(statusDot.getAttribute("title")).toBe("Gateway status: Online"); - expect([...statusDot.classList]).toEqual([ - "sidebar-status__dot", - "sidebar-connection-status--online", - ]); - // The gateway version intentionally stays out of the persistent sidebar; - // it lives in Settings (Quick Settings footer). - expect(status.textContent).not.toContain("1.2.3"); - expect(app.querySelector(".sidebar-version")).toBeNull(); - - app.applySettings({ ...app.settings, navWidth: 360 }); - await app.updateComplete; - - expect(app.querySelector(".sidebar-resizer")).toBeNull(); - const shell = expectElement(app, ".shell", HTMLElement); - expect(shell.style.getPropertyValue("--shell-nav-width")).toBe(""); - - const split = expectElement(app, ".chat-split-container", HTMLElement); - split.classList.add("chat-split-container--open"); - await app.updateComplete; - expect([...split.classList]).toEqual(["chat-split-container", "chat-split-container--open"]); - - expectElement(app, ".chat-main", HTMLElement); - - const topShell = expectElement(app, ".topnav-shell", HTMLElement); - const content = expectElement(app, ".topnav-shell__content", HTMLElement); - - expect([...topShell.classList]).toEqual(["topnav-shell"]); - expect([...content.classList]).toEqual(["topnav-shell__content"]); - expectElement(topShell, ".topbar-nav-toggle", HTMLElement); - expect(topShell.children[1]).toBe(content); - expectElement(topShell, ".topnav-shell__actions", HTMLElement); - - const toggle = expectElement(app, ".topbar-nav-toggle", HTMLElement); - const actions = expectElement(app, ".topnav-shell__actions", HTMLElement); - - expect([...toggle.classList]).toEqual(["sidebar-menu-trigger", "topbar-nav-toggle"]); - expect([...actions.classList]).toEqual(["topnav-shell__actions"]); - expect(topShell.firstElementChild).toBe(toggle); - expect(topShell.querySelector(".topbar-nav-toggle")).toBe(toggle); - expectElement(actions, ".topbar-search", HTMLElement); - expect(toggle.getAttribute("aria-label")).toBe("Expand sidebar"); - - const nav = expectElement(app, ".shell-nav", HTMLElement); - - expect([...shell.classList]).toEqual(["shell", "shell--chat"]); - toggle.click(); - await app.updateComplete; - - expect([...shell.classList]).toEqual(["shell", "shell--chat", "shell--nav-drawer-open"]); - expect([...nav.classList]).toEqual(["shell-nav"]); - expect(toggle.getAttribute("aria-expanded")).toBe("true"); - - const drawerClose = expectElement( - app, - ".sidebar-shell__header .nav-collapse-toggle", - HTMLButtonElement, - ); - expect(drawerClose.getAttribute("aria-label")).toBe("Collapse sidebar"); - drawerClose.click(); - await app.updateComplete; - - expect([...shell.classList]).toEqual(["shell", "shell--chat"]); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - - toggle.click(); - await app.updateComplete; - expect([...shell.classList]).toEqual(["shell", "shell--chat", "shell--nav-drawer-open"]); - - const link = expectElement(app, 'a.nav-item[href="/config"]', HTMLAnchorElement); - link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); - - await app.updateComplete; - expect(app.tab).toBe("config"); - expect([...shell.classList]).toEqual(["shell"]); - - app.applySettings({ ...app.settings, navCollapsed: true }); - await app.updateComplete; - - expect(app.querySelector(".nav-section__label")).toBeNull(); - expect(app.querySelector(".sidebar-brand__logo")).toBeNull(); - - expectElement(app, ".sidebar-shell__footer", HTMLElement); - expectElement(app, ".sidebar-utility-link", HTMLElement); - - const item = expectElement(app, ".sidebar .nav-item", HTMLElement); - const header = expectElement(app, ".sidebar-shell__header", HTMLElement); - const sidebar = expectElement(app, ".sidebar", HTMLElement); - - expect([...sidebar.classList]).toEqual(["sidebar", "sidebar--collapsed"]); - expectElement(item, ".nav-item__icon", HTMLElement); - expect(item.querySelector(".nav-item__text")).toBeNull(); - expect(app.querySelector(".sidebar-brand__copy")).toBeNull(); - expectElement(header, ".nav-collapse-toggle", HTMLElement); - }); - - it("hides child nav items when the active group is collapsed", async () => { - const app = mountApp("/dreaming"); - await app.updateComplete; - - app.applySettings({ - ...app.settings, - navGroupsCollapsed: { ...app.settings.navGroupsCollapsed, agent: true }, - }); - await app.updateComplete; - - const dreamingLink = expectElement(app, 'a.nav-item[href="/dreaming"]', HTMLAnchorElement); - const section = dreamingLink.closest(".nav-section"); - expect(section).toBeInstanceOf(HTMLElement); - if (!(section instanceof HTMLElement)) { - throw new Error("Expected dreaming link to be inside a nav section"); - } - - expect([...section.classList]).toContain("nav-section--collapsed"); - expect( - section - .querySelector(".nav-section__label") - ?.getAttribute("aria-expanded"), - ).toBe("false"); - }); - - it("shows recent sessions in the sidebar and switches through them", async () => { - const app = mountApp("/overview"); - app.sessionKey = "agent:main:second"; - app.sessionsResult = createSessionsResult([ - { key: "global", kind: "global", label: "Global", updatedAt: Date.now() }, - { key: "unknown", kind: "unknown", label: "Unknown", updatedAt: Date.now() - 10_000 }, - { key: "cron:daily", kind: "cron", label: "Daily cron", updatedAt: Date.now() - 20_000 }, - { - key: "agent:main:subagent:task", - label: "Subagent", - spawnedBy: "agent:main:second", - updatedAt: Date.now() - 25_000, - }, - { key: "agent:main:first", label: "First workspace", updatedAt: Date.now() - 5 * 60_000 }, - { key: "agent:main:second", label: "Second workspace", updatedAt: Date.now() - 30_000 }, - ]) as typeof app.sessionsResult; - await app.updateComplete; - - const recent = Array.from(app.querySelectorAll(".sidebar-recent-session")); - expect(recent.map((entry) => entry.textContent?.replace(/\s+/g, " ").trim())).toEqual([ - "Second workspace just now", - "First workspace 5m ago", - ]); - - const recentSection = expectElement(app, ".sidebar-recent-sessions", HTMLElement); - const recentToggle = expectElement( - recentSection, - ".sidebar-recent-sessions__label", - HTMLButtonElement, - ); - expect(recentToggle.getAttribute("aria-expanded")).toBe("true"); - - recentToggle.click(); - await app.updateComplete; - - expect(app.settings.recentSessionsCollapsed).toBe(true); - expect(recentToggle.getAttribute("aria-expanded")).toBe("false"); - expect([...recentSection.classList]).toContain("sidebar-recent-sessions--collapsed"); - - recentToggle.click(); - await app.updateComplete; - - expect(app.settings.recentSessionsCollapsed).toBe(false); - expect(recentToggle.getAttribute("aria-expanded")).toBe("true"); - expect([...recentSection.classList]).not.toContain("sidebar-recent-sessions--collapsed"); - - recent[1] - ?.querySelector("a.sidebar-recent-session__link") - ?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); - await app.updateComplete; - - expect(app.tab).toBe("chat"); - expect(app.sessionKey).toBe("agent:main:first"); - expect(window.location.pathname).toBe("/chat"); - expect(window.location.search).toBe("?session=agent%3Amain%3Afirst"); - }); - - it("keeps the provider quota pill reachable from the sidebar footer (regression #93041)", async () => { - const app = mountApp("/overview"); - app.modelAuthStatusResult = { - ts: Date.now(), - providers: [ - { - provider: "openai", - displayName: "Codex", - status: "ok", - profiles: [{ profileId: "codex", type: "oauth", status: "ok" }], - usage: { - windows: [ - { label: "5h", usedPercent: 42 }, - { label: "Week", usedPercent: 71 }, - ], - }, - }, - ], - } as typeof app.modelAuthStatusResult; - await app.updateComplete; - - const pill = app.querySelector( - '.sidebar-quota [data-chat-provider-usage="true"]', - ); - expect(pill).toBeInstanceOf(HTMLAnchorElement); - expect(pill?.textContent?.replace(/\s+/g, " ").trim()).toBe("Usage 29%"); - expect(pill?.getAttribute("href")).toBe("/usage"); - }); - - it("keeps the active session pinned even when the session list omits it", async () => { - const app = mountApp("/chat"); - app.sessionKey = "agent:main:oldest"; - app.sessionsResult = createSessionsResult( - // The active key is intentionally absent: the pinned row must survive - // capped or filtered session lists as the way back to the open chat. - Array.from({ length: 11 }, (_, index) => ({ - key: `agent:main:recent-${index}`, - label: `Recent ${index}`, - updatedAt: Date.now() - index * 1_000, - })), - ) as typeof app.sessionsResult; - await app.updateComplete; - - const rows = Array.from(app.querySelectorAll(".sidebar-recent-session")); - // Pinned active row plus the nine-row recents cap. - expect(rows).toHaveLength(10); - expect(rows[0]?.dataset.sessionKey).toBe("agent:main:oldest"); - expect([...rows[0].classList]).toContain("sidebar-recent-session--active"); - }); - - it("creates a new chat session from the sidebar", async () => { - const app = mountApp("/overview"); - app.sessionKey = "agent:main:main"; - app.sessionsResult = createSessionsResult([ - { key: "agent:main:main", label: "Main Session" }, - ]) as typeof app.sessionsResult; - app.client = { - stop: vi.fn(), - request: vi.fn(async (method: string) => { - if (method === "sessions.create") { - return { key: "agent:main:fresh" }; - } - if (method === "sessions.list") { - return createSessionsResult([ - { key: "agent:main:fresh", label: "Fresh session" }, - { key: "agent:main:main", label: "Main Session" }, - ]); - } - return null; - }), - } as unknown as typeof app.client; - await app.updateComplete; - - expectButtonWithText(app, "New session").click(); - - await vi.waitFor(() => { - expect(app.sessionKey).toBe("agent:main:fresh"); - }); - expect(app.tab).toBe("chat"); - expect(window.location.pathname).toBe("/chat"); - expect(app.client?.["request"]).toHaveBeenCalledWith("sessions.create", { - agentId: "main", - parentSessionKey: "agent:main:main", - emitCommandHooks: true, - }); - }); - - it("closes composer view settings on Escape, outside pointerdown, and tab changes", async () => { - const app = mountApp("/chat"); - await app.updateComplete; - - const toggle = expectElement(app, ".chat-settings-chip", HTMLButtonElement); - const dropdown = expectElement(app, ".chat-settings-popover", HTMLElement); - - toggle.focus(); - toggle.click(); - await app.updateComplete; - - expect(app.chatMobileControlsOpen).toBe(true); - expect(toggle.getAttribute("aria-expanded")).toBe("true"); - expect([...toggle.classList]).toEqual(["chat-settings-chip", "chat-settings-chip--open"]); - expect([...dropdown.classList]).toEqual([ - "chat-settings-popover", - "chat-settings-popover--open", - ]); - - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - await app.updateComplete; - await nextFrame(); - - expect(app.chatMobileControlsOpen).toBe(false); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - expect([...dropdown.classList]).toEqual(["chat-settings-popover"]); - expect(document.activeElement).toBe(toggle); - - toggle.click(); - await app.updateComplete; - app.requestUpdate(); - await app.updateComplete; - - const openDropdown = expectElement(app, ".chat-settings-popover", HTMLElement); - expect(app.chatMobileControlsOpen).toBe(true); - expect([...openDropdown.classList]).toEqual([ - "chat-settings-popover", - "chat-settings-popover--open", - ]); - - document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); - await app.updateComplete; - - const closedDropdown = expectElement(app, ".chat-settings-popover", HTMLElement); - expect(app.chatMobileControlsOpen).toBe(false); - expect([...closedDropdown.classList]).toEqual(["chat-settings-popover"]); - - expectElement(app, ".chat-settings-chip", HTMLButtonElement).click(); - await app.updateComplete; - expect(app.chatMobileControlsOpen).toBe(true); - - app.setTab("channels"); - await app.updateComplete; - expect(app.chatMobileControlsOpen).toBe(false); - }); - - it("preserves session navigation without hiding the page chrome", async () => { - const app = mountApp("/sessions?session=agent:main:subagent:task-123"); - app.sessionsResult = createSessionsResult([ - // The active subagent session stays listed in the sidebar even though - // subagent sessions are otherwise filtered from recents. - { - key: "agent:main:subagent:task-123", - label: "Subagent task", - spawnedBy: "agent:main:main", - }, - { key: "agent:main:main", label: "Main workspace" }, - ]) as typeof app.sessionsResult; - await app.updateComplete; - - const activeRow = expectElement( - app, - '.sidebar-recent-session[data-session-key="agent:main:subagent:task-123"] a.sidebar-recent-session__link', - HTMLAnchorElement, - ); - activeRow.dispatchEvent( - new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), - ); - - await app.updateComplete; - expect(app.tab).toBe("chat"); - expect(app.sessionKey).toBe("agent:main:subagent:task-123"); - expect(window.location.pathname).toBe("/chat"); - expect(window.location.search).toBe("?session=agent%3Amain%3Asubagent%3Atask-123"); - - const shell = expectElement(app, ".shell", HTMLElement); - const topbar = expectElement(app, ".topbar", HTMLElement); - expect([...shell.classList]).toEqual(["shell", "shell--chat"]); - expect(topbar.hasAttribute("inert")).toBe(false); - expect(topbar.hasAttribute("aria-hidden")).toBe(false); - expect(app.querySelector(".content-header")).toBeNull(); - - app.setTab("channels"); - - await app.updateComplete; - expect(app.tab).toBe("channels"); - expect([...shell.classList]).toEqual(["shell"]); - expect(topbar.hasAttribute("inert")).toBe(false); - expect(topbar.hasAttribute("aria-hidden")).toBe(false); - const channelsContentHeader = expectElement(app, ".content-header", HTMLElement); - expect(channelsContentHeader.hasAttribute("inert")).toBe(false); - expect(channelsContentHeader.hasAttribute("aria-hidden")).toBe(false); - - const chatRow = expectElement( - app, - '.sidebar-recent-session[data-session-key="agent:main:subagent:task-123"] a.sidebar-recent-session__link', - HTMLAnchorElement, - ); - chatRow.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); - - await app.updateComplete; - expect(app.tab).toBe("chat"); - expect([...shell.classList]).toEqual(["shell", "shell--chat"]); - expect(topbar.hasAttribute("inert")).toBe(false); - expect(topbar.hasAttribute("aria-hidden")).toBe(false); - expect(app.querySelector(".content-header")).toBeNull(); - }); - - it("auto-scrolls chat history to the latest message", async () => { - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - queueMicrotask(() => callback(performance.now())); - return 1; - }); - const app = mountApp("/chat"); - await app.updateComplete; - - const initialContainer = app.querySelector(".chat-thread"); - expect(initialContainer).toBeInstanceOf(HTMLElement); - const initialThread = initialContainer!; - initialThread.style.maxHeight = "180px"; - initialThread.style.overflow = "auto"; - let scrollTop = 0; - Object.defineProperty(initialThread, "clientHeight", { - configurable: true, - get: () => 180, - }); - Object.defineProperty(initialThread, "scrollHeight", { - configurable: true, - get: () => 2400, - }); - Object.defineProperty(initialThread, "scrollTop", { - configurable: true, - get: () => scrollTop, - set: (value: number) => { - scrollTop = value; - }, - }); - initialThread.scrollTo = ((options?: ScrollToOptions | number, y?: number) => { - const top = - typeof options === "number" ? (y ?? 0) : typeof options?.top === "number" ? options.top : 0; - scrollTop = Math.max(0, Math.min(top, 2400 - 180)); - }) as typeof initialThread.scrollTo; - - app.chatMessages = Array.from({ length: 3 }, (_, index) => ({ - role: "assistant", - content: `Line ${index}`, - timestamp: Date.now() + index, - })); - - await app.updateComplete; - for (let i = 0; i < 6; i++) { - await nextFrame(); - } - - const container = app.querySelector(".chat-thread"); - expect(container).toBeInstanceOf(HTMLElement); - const thread = container!; - let finalScrollTop = 0; - Object.defineProperty(thread, "clientHeight", { - value: 180, - configurable: true, - }); - Object.defineProperty(thread, "scrollHeight", { - value: 960, - configurable: true, - }); - Object.defineProperty(thread, "scrollTop", { - configurable: true, - get: () => finalScrollTop, - set: (value: number) => { - finalScrollTop = value; - }, - }); - Object.defineProperty(thread, "scrollTo", { - configurable: true, - value: ({ top }: { top: number }) => { - finalScrollTop = top; - }, - }); - const targetScrollTop = thread.scrollHeight; - expect(targetScrollTop).toBeGreaterThan(thread.clientHeight); - app.chatMessages = [ - ...app.chatMessages, - { - role: "assistant", - content: "Line 3", - timestamp: Date.now() + 3, - }, - ]; - await app.updateComplete; - for (let i = 0; i < 10; i++) { - if (thread.scrollTop === targetScrollTop) { - break; - } - await nextFrame(); - } - expect(thread.scrollTop).toBe(targetScrollTop); - }); - - it("hydrates hash tokens, preserves same-scope URL edits, and reloads after gateway changes", async () => { - const app = mountApp("/ui/overview#token=abc123"); - await app.updateComplete; - - expect(app.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - expect(window.location.pathname).toBe("/ui/overview"); - expect(window.location.hash).toBe(""); - app.remove(); - - const refreshed = mountApp("/ui/overview"); - await refreshed.updateComplete; - - expect(refreshed.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - - const gatewayUrlInput = expectElement( - refreshed, - 'input[placeholder="ws://100.x.y.z:18789"]', - HTMLInputElement, - ); - - const sameScopeUrl = `${refreshed.settings.gatewayUrl}/`; - gatewayUrlInput.value = sameScopeUrl; - gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true })); - await refreshed.updateComplete; - - expect(refreshed.settings.gatewayUrl).toBe(sameScopeUrl); - expect(refreshed.settings.token).toBe("abc123"); - - gatewayUrlInput.value = "wss://missing-token.example/openclaw"; - gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true })); - await refreshed.updateComplete; - - expect(refreshed.settings.gatewayUrl).toBe("wss://missing-token.example/openclaw"); - expect(refreshed.settings.token).toBe(""); - - sessionStorage.setItem( - "openclaw.control.token.v1:wss://other-gateway.example/openclaw", - "other-token", - ); - gatewayUrlInput.value = "wss://other-gateway.example/openclaw"; - gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true })); - await refreshed.updateComplete; - - expect(refreshed.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); - expect(refreshed.settings.token).toBe("other-token"); - }); - - it("keeps a hash token pending until the gateway URL change is confirmed", async () => { - const app = mountApp( - "/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw#token=abc123", - ); - await app.updateComplete; - - expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw"); - expect(app.settings.token).toBe(""); - - await confirmPendingGatewayChange(app); - - expectConfirmedGatewayChange(app); - }); -}); diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts deleted file mode 100644 index 1aac2e123ac3..000000000000 --- a/ui/src/ui/navigation.ts +++ /dev/null @@ -1,244 +0,0 @@ -// Control UI module implements navigation behavior. -import { t } from "../i18n/index.ts"; -import type { IconName } from "./icons.js"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; - -export const TAB_GROUPS = [ - { label: "chat", tabs: ["chat"] }, - { - label: "control", - tabs: ["overview", "activity", "workboard", "instances", "sessions", "usage", "cron"], - }, - { label: "agent", tabs: ["agents", "skills", "skillWorkshop", "nodes", "dreams"] }, - { - label: "settings", - tabs: ["config"], - }, -] as const; - -export type Tab = - | "agents" - | "activity" - | "overview" - | "workboard" - | "channels" - | "instances" - | "sessions" - | "usage" - | "cron" - | "skills" - | "skillWorkshop" - | "nodes" - | "chat" - | "config" - | "communications" - | "appearance" - | "automation" - | "mcp" - | "infrastructure" - | "aiAgents" - | "debug" - | "logs" - | "dreams"; - -export const SETTINGS_TABS = [ - "config", - "channels", - "communications", - "appearance", - "automation", - "mcp", - "infrastructure", - "aiAgents", - "debug", - "logs", -] as const satisfies readonly Tab[]; - -const TAB_PATHS: Record = { - agents: "/agents", - activity: "/activity", - overview: "/overview", - workboard: "/workboard", - channels: "/channels", - instances: "/instances", - sessions: "/sessions", - usage: "/usage", - cron: "/cron", - skills: "/skills", - skillWorkshop: "/skills/workshop", - nodes: "/nodes", - chat: "/chat", - config: "/config", - communications: "/communications", - appearance: "/appearance", - automation: "/automation", - mcp: "/mcp", - infrastructure: "/infrastructure", - aiAgents: "/ai-agents", - debug: "/debug", - logs: "/logs", - dreams: "/dreaming", -}; - -const PATH_ALIASES: Record = { - "/dreams": "dreams", -}; - -const PATH_TO_TAB = new Map([ - ...Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab] as const), - ...Object.entries(PATH_ALIASES), -]); - -export function normalizeBasePath(basePath: string): string { - if (!basePath) { - return ""; - } - let base = basePath.trim(); - if (!base.startsWith("/")) { - base = `/${base}`; - } - if (base === "/") { - return ""; - } - if (base.endsWith("/")) { - base = base.slice(0, -1); - } - return base; -} - -export function normalizePath(path: string): string { - if (!path) { - return "/"; - } - let normalized = path.trim(); - if (!normalized.startsWith("/")) { - normalized = `/${normalized}`; - } - if (normalized.length > 1 && normalized.endsWith("/")) { - normalized = normalized.slice(0, -1); - } - return normalized; -} - -export function pathForTab(tab: Tab, basePath = ""): string { - const base = normalizeBasePath(basePath); - const path = TAB_PATHS[tab]; - return base ? `${base}${path}` : path; -} - -export function isSettingsTab(tab: Tab): boolean { - return (SETTINGS_TABS as readonly Tab[]).includes(tab); -} - -export function isTabInGroup(group: (typeof TAB_GROUPS)[number], tab: Tab): boolean { - if (group.label === "settings") { - return isSettingsTab(tab); - } - return (group.tabs as readonly Tab[]).includes(tab); -} - -export function tabFromPath(pathname: string, basePath = ""): Tab | null { - const base = normalizeBasePath(basePath); - let path = pathname || "/"; - if (base) { - if (path === base) { - path = "/"; - } else if (path.startsWith(`${base}/`)) { - path = path.slice(base.length); - } - } - let normalized = normalizeLowercaseStringOrEmpty(normalizePath(path)); - if (normalized.endsWith("/index.html")) { - normalized = "/"; - } - if (normalized === "/") { - return "chat"; - } - return PATH_TO_TAB.get(normalized) ?? null; -} - -export function inferBasePathFromPathname(pathname: string): string { - let normalized = normalizePath(pathname); - if (normalized.endsWith("/index.html")) { - normalized = normalizePath(normalized.slice(0, -"/index.html".length)); - } - if (normalized === "/") { - return ""; - } - const segments = normalized.split("/").filter(Boolean); - if (segments.length === 0) { - return ""; - } - for (let i = 0; i < segments.length; i++) { - const candidate = normalizeLowercaseStringOrEmpty(`/${segments.slice(i).join("/")}`); - if (PATH_TO_TAB.has(candidate)) { - const prefix = segments.slice(0, i); - return prefix.length ? `/${prefix.join("/")}` : ""; - } - } - return `/${segments.join("/")}`; -} - -export function iconForTab(tab: Tab): IconName { - switch (tab) { - case "agents": - return "folder"; - case "chat": - return "messageSquare"; - case "overview": - return "barChart"; - case "activity": - return "activity"; - case "workboard": - return "folder"; - case "channels": - return "link"; - case "instances": - return "radio"; - case "sessions": - return "fileText"; - case "usage": - return "barChart"; - case "cron": - return "loader"; - case "skills": - return "zap"; - case "skillWorkshop": - return "wrench"; - case "nodes": - return "monitor"; - case "config": - return "settings"; - case "communications": - return "send"; - case "appearance": - return "spark"; - case "automation": - return "terminal"; - case "mcp": - return "wrench"; - case "infrastructure": - return "globe"; - case "aiAgents": - return "brain"; - case "debug": - return "bug"; - case "logs": - return "scrollText"; - case "dreams": - return "moon"; - default: - return "folder"; - } -} - -export function titleForTab(tab: Tab) { - if (tab === "config") { - return t("nav.settings"); - } - return t(`tabs.${tab}`); -} - -export function subtitleForTab(tab: Tab) { - return t(`subtitles.${tab}`); -} diff --git a/ui/src/ui/push-subscription.ts b/ui/src/ui/push-subscription.ts deleted file mode 100644 index 88694cd3703c..000000000000 --- a/ui/src/ui/push-subscription.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Control UI module implements push subscription behavior. -import type { GatewayBrowserClient } from "./gateway.ts"; - -/** Timeout (ms) for service-worker readiness. */ -const SW_READY_TIMEOUT = 10_000; - -/** - * Await service-worker readiness with a timeout so callers don't hang - * indefinitely when registration fails or sw.js is unreachable. - */ -function swReady(): Promise { - return Promise.race([ - navigator.serviceWorker.ready, - new Promise((_, reject) => { - setTimeout(() => reject(new Error("Service worker not ready (timed out)")), SW_READY_TIMEOUT); - }), - ]); -} - -/** - * URL-safe base64 string to Uint8Array (for applicationServerKey). - */ -function urlBase64ToUint8Array(base64String: string): Uint8Array { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); - const raw = atob(base64); - const output = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) { - output[i] = raw.charCodeAt(i); - } - return output; -} - -/** - * Check if the browser already has an active push subscription. - */ -export async function getExistingSubscription(): Promise { - if (!("serviceWorker" in navigator)) { - return null; - } - const registration = await swReady(); - return await registration.pushManager.getSubscription(); -} - -/** - * Subscribe to web push notifications. - * Requests notification permission if not already granted, fetches VAPID key - * from the gateway, subscribes with the PushManager, and registers with the - * gateway. If gateway registration fails, the local PushManager subscription - * is rolled back to avoid local/server state divergence. - */ -export async function subscribeToWebPush( - client: GatewayBrowserClient, -): Promise<{ subscriptionId: string }> { - // Request permission. - const permission = await Notification.requestPermission(); - if (permission !== "granted") { - throw new Error(`Notification permission ${permission}`); - } - - // Get VAPID public key from gateway. - const vapidRes = await client.request("push.web.vapidPublicKey", {}); - const vapidPublicKey = (vapidRes as { vapidPublicKey: string }).vapidPublicKey; - if (!vapidPublicKey) { - throw new Error("Failed to retrieve VAPID public key"); - } - - // Subscribe via PushManager. - const registration = await swReady(); - const pushSubscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer, - }); - - const subJson = pushSubscription.toJSON(); - if (!subJson.endpoint || !subJson.keys?.p256dh || !subJson.keys?.auth) { - throw new Error("Invalid push subscription from browser"); - } - - // Register with gateway — roll back local subscription on failure. - try { - const registerRes = await client.request("push.web.subscribe", { - endpoint: subJson.endpoint, - keys: { - p256dh: subJson.keys.p256dh, - auth: subJson.keys.auth, - }, - }); - - return registerRes as { subscriptionId: string }; - } catch (err) { - // Gateway registration failed — unsubscribe locally to keep state consistent. - try { - await pushSubscription.unsubscribe(); - } catch { - // Best-effort rollback. - } - throw err; - } -} - -/** - * Unsubscribe from web push notifications. - * Always unsubscribes locally even if the gateway request fails, to avoid - * leaving the browser subscribed with no server-side record. - */ -export async function unsubscribeFromWebPush(client: GatewayBrowserClient): Promise { - const registration = await swReady(); - const subscription = await registration.pushManager.getSubscription(); - - if (subscription) { - // Notify gateway (best-effort — always unsubscribe locally afterward). - try { - await client.request("push.web.unsubscribe", { - endpoint: subscription.endpoint, - }); - } catch { - // Gateway may be unreachable; still unsubscribe locally. - } - await subscription.unsubscribe(); - } -} - -/** - * Send a test web push notification via the gateway. - */ -export async function sendTestWebPush( - client: GatewayBrowserClient, - options?: { title?: string; body?: string }, -): Promise { - await client.request("push.web.test", { - title: options?.title, - body: options?.body, - }); -} diff --git a/ui/src/ui/sidebar-content.ts b/ui/src/ui/sidebar-content.ts deleted file mode 100644 index 71459336fbb7..000000000000 --- a/ui/src/ui/sidebar-content.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Control UI module implements sidebar content behavior. -export type SidebarFullMessageRequest = { - sessionKey: string; - agentId?: string; - messageId: string; - kind: "assistant_message" | "tool_output"; -}; - -export type MarkdownSidebarContent = { - kind: "markdown"; - content: string; - rawText?: string | null; - fullMessageRequest?: SidebarFullMessageRequest; - unavailableReason?: "not_found" | "oversized" | "not_visible" | null; -}; - -export type CanvasSidebarContent = { - kind: "canvas"; - docId: string; - title?: string; - entryUrl: string; - preferredHeight?: number; - rawText?: string | null; - fullMessageRequest?: SidebarFullMessageRequest; - unavailableReason?: "not_found" | "oversized" | "not_visible" | null; -}; - -export type ImageSidebarContent = { - kind: "image"; - title: string; - src: string; - mimeType?: string | null; - rawText?: string | null; - fullMessageRequest?: SidebarFullMessageRequest; - unavailableReason?: "not_found" | "oversized" | "not_visible" | null; -}; - -export type SidebarContent = MarkdownSidebarContent | CanvasSidebarContent | ImageSidebarContent; diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts deleted file mode 100644 index 2e6e71761daf..000000000000 --- a/ui/src/ui/test-helpers/app-mount.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Control UI test helper supports app mount setup. -import { afterEach, beforeEach, vi } from "vitest"; -import { i18n } from "../../i18n/index.ts"; -import { getSafeLocalStorage, getSafeSessionStorage } from "../../local-storage.ts"; -import { createStorageMock } from "../../test-helpers/storage.ts"; -import "../app.ts"; -import type { OpenClawApp } from "../app.ts"; - -class MockWebSocket { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - - readyState = MockWebSocket.OPEN; - - addEventListener() {} - - close() { - this.readyState = MockWebSocket.CLOSED; - } - - send() {} -} - -function createMatchMediaMock(width: number) { - return vi.fn((query: string) => { - const maxWidthMatch = query.match(/\(max-width:\s*(\d+)px\)/); - const minWidthMatch = query.match(/\(min-width:\s*(\d+)px\)/); - const matches = - (maxWidthMatch ? width <= Number.parseInt(maxWidthMatch[1] ?? "0", 10) : true) && - (minWidthMatch ? width >= Number.parseInt(minWidthMatch[1] ?? "0", 10) : true); - return { - matches, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }; - }); -} - -const mountedApps = new Set(); - -function collectMountedApps() { - return new Set([ - ...mountedApps, - ...document.querySelectorAll("openclaw-app"), - ]); -} - -function nextMicrotask() { - return Promise.resolve(); -} - -function nextTimer() { - return new Promise((resolve) => { - window.setTimeout(resolve, 0); - }); -} - -function nextFrame() { - return new Promise((resolve) => { - if (typeof window.requestAnimationFrame !== "function") { - window.setTimeout(resolve, 0); - return; - } - window.requestAnimationFrame(() => resolve()); - }); -} - -async function waitForAppUpdates(apps: Iterable) { - for (const app of apps) { - await app.updateComplete; - } -} - -async function drainAppWork(apps: Iterable) { - const snapshot = [...apps]; - await nextMicrotask(); - await waitForAppUpdates(snapshot); - await nextFrame(); - await nextMicrotask(); - await nextFrame(); - await nextMicrotask(); - await waitForAppUpdates(snapshot); - await nextTimer(); - await nextMicrotask(); - await waitForAppUpdates(snapshot); -} - -async function cleanupMountedApps() { - const apps = collectMountedApps(); - await drainAppWork(apps); - for (const app of apps) { - app.remove(); - } - document.body.replaceChildren(); - mountedApps.clear(); - await drainAppWork(apps); -} - -export function mountApp(pathname: string) { - window.history.replaceState({}, "", pathname); - const app = document.createElement("openclaw-app") as OpenClawApp; - mountedApps.add(app); - document.body.append(app); - app.connected = true; - app.requestUpdate(); - return app; -} - -export function registerAppMountHooks() { - beforeEach(async () => { - const localStorage = createStorageMock(); - const sessionStorage = createStorageMock(); - const matchMedia = createMatchMediaMock(390); - window["__OPENCLAW_CONTROL_UI_BASE_PATH__"] = undefined; - vi.stubGlobal("localStorage", localStorage); - vi.stubGlobal("sessionStorage", sessionStorage); - vi.stubGlobal("matchMedia", matchMedia); - Object.defineProperty(window, "localStorage", { - value: localStorage, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "sessionStorage", { - value: sessionStorage, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "matchMedia", { - value: matchMedia, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "innerWidth", { - value: 390, - writable: true, - configurable: true, - }); - Object.defineProperty(window, "innerHeight", { - value: 844, - writable: true, - configurable: true, - }); - getSafeLocalStorage()?.clear(); - getSafeSessionStorage()?.clear(); - document.body.innerHTML = ""; - await i18n.setLocale("en"); - vi.stubGlobal("WebSocket", MockWebSocket as unknown as typeof WebSocket); - vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})) as unknown as typeof fetch); - }); - - afterEach(async () => { - await cleanupMountedApps(); - window["__OPENCLAW_CONTROL_UI_BASE_PATH__"] = undefined; - getSafeLocalStorage()?.clear(); - getSafeSessionStorage()?.clear(); - await i18n.setLocale("en"); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - await nextTimer(); - await nextMicrotask(); - }); -} diff --git a/ui/src/ui/thinking-labels.ts b/ui/src/ui/thinking-labels.ts deleted file mode 100644 index 65d3b99465cb..000000000000 --- a/ui/src/ui/thinking-labels.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Control UI module implements thinking labels behavior. -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; -import { normalizeThinkLevel } from "./thinking.ts"; - -export function normalizeThinkingOptionValue(raw: string): string { - return normalizeThinkLevel(raw) ?? normalizeLowercaseStringOrEmpty(raw); -} - -export function formatInheritedThinkingLabel(effectiveLevel: string | null | undefined): string { - const normalized = effectiveLevel ? normalizeThinkingOptionValue(effectiveLevel) : "off"; - return `Inherited: ${formatThinkingLevelDisplayLabel(normalized)}`; -} - -export function formatThinkingOverrideLabel(value: string, label?: string | null): string { - const normalized = normalizeThinkingOptionValue(value); - if (!normalized || normalized === "off") { - return "Off"; - } - return formatThinkingLevelDisplayLabel(label?.trim() || normalized); -} - -function formatThinkingLevelDisplayLabel(value: string): string { - const raw = normalizeLowercaseStringOrEmpty(value); - if (["on", "enable", "enabled"].includes(raw)) { - return "On"; - } - const normalized = normalizeThinkingOptionValue(value); - switch (normalized) { - case "adaptive": - return "Adaptive"; - case "minimal": - return "Minimal"; - case "low": - return "Low"; - case "medium": - return "Medium"; - case "high": - return "High"; - case "xhigh": - return "Extra high"; - case "max": - return "Maximum"; - default: - return value.charAt(0).toUpperCase() + value.slice(1); - } -} diff --git a/ui/src/ui/thinking.ts b/ui/src/ui/thinking.ts deleted file mode 100644 index a516b1c51a69..000000000000 --- a/ui/src/ui/thinking.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Control UI module implements thinking behavior. -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; - -export type ThinkingCatalogEntry = { - provider: string; - id: string; - reasoning?: boolean; -}; - -const BASE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; - -export function normalizeThinkLevel(raw?: string | null): string | undefined { - if (!raw) { - return undefined; - } - const key = normalizeLowercaseStringOrEmpty(raw); - const collapsed = key.replace(/[\s_-]+/g, ""); - if (collapsed === "adaptive" || collapsed === "auto") { - return "adaptive"; - } - if (collapsed === "max") { - return "max"; - } - if (collapsed === "xhigh" || collapsed === "extrahigh") { - return "xhigh"; - } - if (key === "off" || key === "none") { - return "off"; - } - if (["on", "enable", "enabled"].includes(key)) { - return "low"; - } - if (["min", "minimal"].includes(key)) { - return "minimal"; - } - if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) { - return "low"; - } - if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) { - return "medium"; - } - if (["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest"].includes(key)) { - return "high"; - } - if (key === "think") { - return "minimal"; - } - return undefined; -} - -export function listThinkingLevelLabels( - provider?: string | null, - model?: string | null, -): readonly string[] { - void provider; - void model; - return BASE_THINKING_LEVELS; -} - -export function formatThinkingLevels(provider?: string | null, model?: string | null): string { - return listThinkingLevelLabels(provider, model).join(", "); -} - -export function resolveThinkingDefaultForModel(params: { - provider: string; - model: string; - catalog?: ThinkingCatalogEntry[]; -}): string { - const candidate = params.catalog?.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - return candidate?.reasoning ? "low" : "off"; -} diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts deleted file mode 100644 index 57e9cc69cc48..000000000000 --- a/ui/src/ui/ui-types.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Control UI module implements ui types behavior. -export type ChatAttachment = { - id: string; - dataUrl?: string; - previewUrl?: string; - mimeType: string; - fileName?: string; - sizeBytes?: number; -}; - -export type ChatQueueSkillWorkshopRevision = { - proposalId: string; - agentId?: string; -}; - -export type ChatQueueItem = { - id: string; - text: string; - createdAt: number; - kind?: "queued" | "steered"; - attachments?: ChatAttachment[]; - refreshSessions?: boolean; - localCommandArgs?: string; - localCommandName?: string; - pendingRunId?: string; - sendAttempts?: number; - sendError?: string; - sendRunId?: string; - sendState?: "waiting-model" | "sending" | "waiting-reconnect" | "failed"; - sendSubmittedAtMs?: number; - sendRequestStartedAtMs?: number; - sessionKey?: string; - agentId?: string; - skillWorkshopRevision?: ChatQueueSkillWorkshopRevision; -}; - -export type ChatSessionRefreshTarget = { - sessionKey: string; - agentId?: string; -}; - -export const CRON_CHANNEL_LAST = "last"; - -export type CronFormState = { - name: string; - description: string; - agentId: string; - sessionKey: string; - clearAgent: boolean; - enabled: boolean; - deleteAfterRun: boolean; - // on-exit jobs are shown read-only in the form (the form can't edit a watched - // command); the schedule is preserved verbatim on save, never rebuilt. - scheduleKind: "at" | "every" | "cron" | "on-exit"; - scheduleAt: string; - everyAmount: string; - everyUnit: "minutes" | "hours" | "days"; - cronExpr: string; - cronTz: string; - scheduleExact: boolean; - staggerAmount: string; - staggerUnit: "seconds" | "minutes"; - sessionTarget: "main" | "isolated" | "current" | `session:${string}`; - wakeMode: "next-heartbeat" | "now"; - payloadKind: "systemEvent" | "agentTurn"; - payloadLocked: boolean; - payloadText: string; - payloadModel: string; - payloadThinking: string; - payloadLightContext: boolean; - deliveryMode: "none" | "announce" | "webhook"; - deliveryChannel: string; - deliveryTo: string; - deliveryAccountId: string; - deliveryBestEffort: boolean; - failureAlertMode: "inherit" | "disabled" | "custom"; - failureAlertAfter: string; - failureAlertCooldownSeconds: string; - failureAlertChannel: string; - failureAlertTo: string; - failureAlertDeliveryMode: "announce" | "webhook"; - failureAlertAccountId: string; - timeoutSeconds: string; -}; diff --git a/ui/src/ui/usage-cache-status.ts b/ui/src/ui/usage-cache-status.ts deleted file mode 100644 index ff72e9d97700..000000000000 --- a/ui/src/ui/usage-cache-status.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Control UI module implements usage cache status behavior. -import { t } from "../i18n/index.ts"; -import type { SessionsUsageResult } from "./usage-types.ts"; - -export type UsageCacheStatus = SessionsUsageResult["cacheStatus"]; - -export function getUsageCacheRefreshTitle(cacheStatus: UsageCacheStatus): string | null { - if ( - !cacheStatus || - (cacheStatus.status !== "refreshing" && - cacheStatus.status !== "stale" && - cacheStatus.status !== "partial") - ) { - return null; - } - return t("usage.cacheStatus.title", { - status: t(`usage.cacheStatus.status.${cacheStatus.status}`), - pending: String(cacheStatus.pendingFiles), - stale: String(cacheStatus.staleFiles), - cached: String(cacheStatus.cachedFiles), - }); -} diff --git a/ui/src/ui/views/agents.types.ts b/ui/src/ui/views/agents.types.ts deleted file mode 100644 index c47ade0509c3..000000000000 --- a/ui/src/ui/views/agents.types.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Control UI type declarations define agents contracts. -export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; diff --git a/ui/src/ui/views/channel-config-extras.ts b/ui/src/ui/views/channel-config-extras.ts deleted file mode 100644 index d03b8f740b5f..000000000000 --- a/ui/src/ui/views/channel-config-extras.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Control UI view renders channel config extras screen content. -import { t } from "../../i18n/index.ts"; - -export function resolveChannelConfigValue( - configForm: Record | null | undefined, - channelId: string, -): Record | null { - if (!configForm) { - return null; - } - const channels = (configForm.channels ?? {}) as Record; - const fromChannels = channels[channelId]; - if (fromChannels && typeof fromChannels === "object") { - return fromChannels as Record; - } - const fallback = configForm[channelId]; - if (fallback && typeof fallback === "object") { - return fallback as Record; - } - return null; -} - -export function formatChannelExtraValue(raw: unknown): string { - if (raw == null) { - return t("common.na"); - } - if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") { - return String(raw); - } - try { - return JSON.stringify(raw); - } catch { - return t("common.na"); - } -} - -export function resolveChannelExtras(params: { - configForm: Record | null | undefined; - channelId: string; - fields: readonly string[]; -}): Array<{ label: string; value: string }> { - const value = resolveChannelConfigValue(params.configForm, params.channelId); - if (!value) { - return []; - } - return params.fields.flatMap((field) => { - if (!(field in value)) { - return []; - } - return [{ label: field, value: formatChannelExtraValue(value[field]) }]; - }); -} diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts deleted file mode 100644 index 5d00154882ae..000000000000 --- a/ui/src/ui/views/chat.ts +++ /dev/null @@ -1,3043 +0,0 @@ -// Control UI view renders chat screen content. -import { html, nothing, type TemplateResult } from "lit"; -import { guard } from "lit/directives/guard.js"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { ref } from "lit/directives/ref.js"; -import { repeat } from "lit/directives/repeat.js"; -import { t } from "../../i18n/index.ts"; -import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts"; -import { - getChatAttachmentPreviewUrl, - registerChatAttachmentPayload, - releaseChatAttachmentPayload, -} from "../chat/attachment-payload-store.ts"; -import { - CHAT_ATTACHMENT_ACCEPT, - isSupportedChatAttachmentFile, -} from "../chat/attachment-support.ts"; -import { buildChatItems, type BuildChatItemsProps } from "../chat/build-chat-items.ts"; -import { renderChatQueue } from "../chat/chat-queue.ts"; -import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts"; -import { renderWelcomeState, resolveAssistantDisplayAvatar } from "../chat/chat-welcome.ts"; -import { copyToClipboard } from "../chat/clipboard.ts"; -import { decodeCodeBlockCopyPayload } from "../chat/code-block-copy-payload.ts"; -import { renderContextNotice } from "../chat/context-notice.ts"; -import { DeletedMessages } from "../chat/deleted-messages.ts"; -import { exportChatMarkdown } from "../chat/export.ts"; -import { - getAssistantAttachmentAvailabilityRenderVersion, - renderMessageGroup, - renderStreamGroup, - type StreamGroupPart, -} from "../chat/grouped-render.ts"; -import { CHAT_HISTORY_RENDER_LIMIT } from "../chat/history-limits.ts"; -import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../chat/input-history.ts"; -import { PinnedMessages } from "../chat/pinned-messages.ts"; -import { getPinnedMessageSummary } from "../chat/pinned-summary.ts"; -import { - REALTIME_TALK_FALLBACK_PROVIDERS, - listSelectableRealtimeTalkProviders, - resolveControlUiRealtimeTalkProviderTransports, - type RealtimeTalkCatalogProvider, -} from "../chat/realtime-talk-catalog.ts"; -import type { RealtimeTalkConversationEntry } from "../chat/realtime-talk-conversation.ts"; -import type { RealtimeTalkStatus } from "../chat/realtime-talk.ts"; -import { renderChatRunControls } from "../chat/run-controls.ts"; -import type { ChatRunUiStatus } from "../chat/run-lifecycle.ts"; -import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts"; -import { renderSideResult } from "../chat/side-result-render.ts"; -import type { ChatSideResult } from "../chat/side-result.ts"; -import { - CATEGORY_LABELS, - SLASH_COMMANDS, - getHiddenCommandCount, - getSlashCommandCompletions, - type SlashCommandCategory, - type SlashCommandDef, -} from "../chat/slash-commands.ts"; -import { - renderChatRunStatusIndicator, - renderCompactionIndicator, - renderFallbackIndicator, -} from "../chat/status-indicators.ts"; -import type { ChatStreamSegment } from "../chat/stream-text.ts"; -import { getExpandedToolCards, syncToolCardExpansionState } from "../chat/tool-expansion-state.ts"; -import type { EmbedSandboxMode } from "../embed-sandbox.ts"; -import { icons } from "../icons.ts"; -import { formatGoalDetail, formatGoalSummary } from "../session-goal.ts"; -import type { SidebarContent } from "../sidebar-content.ts"; -import { detectTextDirection } from "../text-direction.ts"; -import type { SessionWorkspaceListResult, SessionGoal, SessionsListResult } from "../types.ts"; -import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; -import { resolveLocalUserName } from "../user-identity.ts"; -import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; -import "../components/resizable-divider.ts"; - -const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [ - "a[href]", - "button", - "input", - "select", - "textarea", - "summary", - "[contenteditable='true']", - "[role='button']", - "[role='listbox']", - "[role='option']", -].join(","); - -function hasTerminalRunStatus(status: ChatRunUiStatus | null | undefined): boolean { - return status?.phase === "done" || status?.phase === "interrupted"; -} - -function isCurrentSessionSubmittedProgress( - item: ChatQueueItem, - sessionKey: string, - status: ChatRunUiStatus | null | undefined, -): boolean { - return ( - item.sessionKey === sessionKey && - !item.pendingRunId && - (item.sendState === "sending" || item.sendState === "waiting-model") && - (status == null || item.sendRunId !== status.runId) - ); -} - -export type ChatProps = { - sessionKey: string; - onSessionKeyChange: (next: string) => void; - thinkingLevel: string | null; - showThinking: boolean; - showToolCalls: boolean; - loading: boolean; - sending: boolean; - canAbort?: boolean; - runStatus?: ChatRunUiStatus | null; - compactionStatus?: CompactionStatus | null; - fallbackStatus?: FallbackStatus | null; - messages: unknown[]; - sideResult?: ChatSideResult | null; - toolMessages: unknown[]; - streamSegments: ChatStreamSegment[]; - stream: string | null; - streamStartedAt: number | null; - assistantAvatarUrl?: string | null; - draft: string; - queue: ChatQueueItem[]; - realtimeTalkActive?: boolean; - realtimeTalkStatus?: RealtimeTalkStatus; - realtimeTalkDetail?: string | null; - realtimeTalkTranscript?: string | null; - realtimeTalkConversation?: RealtimeTalkConversationEntry[]; - realtimeTalkOptionsOpen?: boolean; - realtimeTalkCatalogProviders?: RealtimeTalkCatalogProvider[] | null; - realtimeTalkOptions?: { - provider: string; - model: string; - voice: string; - transport: string; - vadThreshold: string; - silenceDurationMs: string; - prefixPaddingMs: string; - reasoningEffort: string; - }; - connected: boolean; - canSend: boolean; - disabledReason: string | null; - error: string | null; - sessions: SessionsListResult | null; - focusMode?: boolean; - sidebarOpen?: boolean; - sidebarContent?: SidebarContent | null; - sidebarError?: string | null; - splitRatio?: number; - canvasPluginSurfaceUrl?: string | null; - embedSandboxMode?: EmbedSandboxMode; - allowExternalEmbedUrls?: boolean; - assistantName: string; - assistantAvatar: string | null; - userName?: string | null; - userAvatar?: string | null; - localMediaPreviewRoots?: string[]; - assistantAttachmentAuthToken?: string | null; - autoExpandToolCalls?: boolean; - attachments?: ChatAttachment[]; - onAttachmentsChange?: (attachments: ChatAttachment[]) => void; - showNewMessages?: boolean; - onScrollToBottom?: () => void; - onAssistantAttachmentLoaded?: () => void; - onRefresh: () => void; - onToggleFocusMode?: () => void; - getDraft?: () => string; - onDraftChange: (next: string) => void; - onRequestUpdate?: () => void; - onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult; - onSlashIntent?: () => void | Promise; - onSend: () => void; - onCompact?: () => void | Promise; - onOpenSessionCheckpoints?: () => void | Promise; - onToggleRealtimeTalk?: () => void; - onToggleRealtimeTalkOptions?: () => void; - onRealtimeTalkOptionsChange?: ( - next: Partial>, - ) => void; - onDismissError?: () => void; - onDismissRealtimeTalkError?: () => void; - onAbort?: () => void; - onQueueRemove: (id: string) => void; - onQueueRetry?: (id: string) => void; - onQueueSteer?: (id: string) => void; - onDismissSideResult?: () => void; - onNewSession: () => void; - onClearHistory?: () => void; - agentsList: { - agents: Array<{ id: string; name?: string; identity?: { name?: string; avatarUrl?: string } }>; - defaultId?: string; - } | null; - currentAgentId: string; - fullMessageAgentId?: string; - onAgentChange: (agentId: string) => void; - onNavigateToAgent?: () => void; - onSessionSelect?: (sessionKey: string) => void; - onOpenSidebar?: (content: SidebarContent) => void; - onCloseSidebar?: () => void; - onSplitRatioChange?: (ratio: number) => void; - onChatScroll?: (event: Event) => void; - basePath?: string; - composerControls?: TemplateResult | typeof nothing | ReturnType; - /** Selected message to reply to (set via right-click or keyboard shortcut). */ - replyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null; - /** Clear the current reply target. */ - onClearReply?: () => void; - /** Set the reply target from a message element. */ - onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void; - sessionWorkspace?: { - collapsed: boolean; - sessionKey: string; - list: SessionWorkspaceListResult | null; - loading: boolean; - error: string | null; - activeId: string | null; - onToggleCollapsed: () => void; - onRefresh: () => void; - onBrowsePath: (path: string) => void; - onCopyPath: (path: string) => void; - onOpenFile: (path: string) => void; - onSearch: (search: string) => void; - onOpenArtifact: (artifactId: string) => void; - }; -}; - -const pinnedMessagesMap = new Map(); -const deletedMessagesMap = new Map(); -const SLASH_MENU_LISTBOX_ID = "chat-slash-menu-listbox"; -const SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID = "chat-slash-active-announcement"; -type TalkSelectOption = { label: string; value: string }; - -const TALK_VOICE_OPTIONS: TalkSelectOption[] = [ - { label: "Default", value: "" }, - { label: "Alloy", value: "alloy" }, - { label: "Ash", value: "ash" }, - { label: "Ballad", value: "ballad" }, - { label: "Coral", value: "coral" }, - { label: "Echo", value: "echo" }, - { label: "Sage", value: "sage" }, - { label: "Shimmer", value: "shimmer" }, - { label: "Verse", value: "verse" }, - { label: "Marin", value: "marin" }, - { label: "Cedar", value: "cedar" }, -]; -const TALK_SENSITIVITY_OPTIONS: TalkSelectOption[] = [ - { label: "Default", value: "" }, - { label: "Low", value: "0.65" }, - { label: "Medium", value: "0.5" }, - { label: "High", value: "0.35" }, -]; -const TALK_PROVIDER_AUTO_OPTION: TalkSelectOption = { label: "Auto", value: "" }; -const TALK_PROVIDER_FALLBACK_OPTIONS: TalkSelectOption[] = [ - TALK_PROVIDER_AUTO_OPTION, - ...REALTIME_TALK_FALLBACK_PROVIDERS.map((provider) => ({ - label: provider.label, - value: provider.id, - })), -]; -const TALK_TRANSPORT_OPTIONS: TalkSelectOption[] = [ - { label: "Auto", value: "" }, - { label: "WebRTC", value: "webrtc" }, - { label: "Gateway relay", value: "gateway-relay" }, - { label: "Provider WebSocket", value: "provider-websocket" }, -]; -const TALK_REASONING_OPTIONS: TalkSelectOption[] = [ - { label: "Default", value: "" }, - { label: "Minimal", value: "minimal" }, - { label: "Low", value: "low" }, - { label: "Medium", value: "medium" }, - { label: "High", value: "high" }, -]; -const INITIAL_CHAT_HISTORY_RENDER_WINDOW = 30; -const CHAT_HISTORY_RENDER_WINDOW_BATCH = 30; -const CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX = 48; - -function getPinnedMessages(sessionKey: string): PinnedMessages { - return getOrCreateSessionCacheValue( - pinnedMessagesMap, - sessionKey, - () => new PinnedMessages(sessionKey), - ); -} - -function getDeletedMessages(sessionKey: string): DeletedMessages { - return getOrCreateSessionCacheValue( - deletedMessagesMap, - sessionKey, - () => new DeletedMessages(sessionKey), - ); -} - -function renderNativeTalkSelect(params: { - label: string; - value: string; - options: TalkSelectOption[]; - onSelect: (value: string) => void; -}) { - return html` - - `; -} - -function renderRealtimeTalkOptions(props: ChatProps) { - const options = props.realtimeTalkOptions; - const onChange = props.onRealtimeTalkOptionsChange; - if (!props.realtimeTalkOptionsOpen || !options || !onChange) { - return nothing; - } - const catalogProviders = props.realtimeTalkCatalogProviders; - const selectableProviders = listSelectableRealtimeTalkProviders(catalogProviders ?? []); - const providerOptions: TalkSelectOption[] = catalogProviders - ? [ - TALK_PROVIDER_AUTO_OPTION, - ...selectableProviders.map((provider) => ({ label: provider.label, value: provider.id })), - ] - : TALK_PROVIDER_FALLBACK_OPTIONS; - const selectedCatalogProvider = options.provider - ? selectableProviders.find((provider) => provider.id === options.provider) - : null; - const selectedProviderTransports = selectedCatalogProvider - ? resolveControlUiRealtimeTalkProviderTransports(selectedCatalogProvider) - : undefined; - const transportOptions: TalkSelectOption[] = selectedProviderTransports - ? [ - { label: "Auto", value: "" }, - ...TALK_TRANSPORT_OPTIONS.filter( - (opt) => opt.value !== "" && selectedProviderTransports.includes(opt.value), - ), - ] - : TALK_TRANSPORT_OPTIONS; - const update = (key: keyof NonNullable) => (event: Event) => { - const value = (event.currentTarget as HTMLInputElement | HTMLSelectElement).value; - onChange({ [key]: value }); - }; - const isDefaultSensitivity = options.vadThreshold === ""; - const isPresetSensitivity = ["0.65", "0.5", "0.35"].includes(options.vadThreshold); - const isCustomSensitivity = !isDefaultSensitivity && !isPresetSensitivity; - const sensitivityValue = isDefaultSensitivity - ? "" - : isPresetSensitivity - ? options.vadThreshold - : "__custom"; - const sensitivityOptions = isCustomSensitivity - ? [...TALK_SENSITIVITY_OPTIONS, { label: "Custom", value: "__custom" }] - : TALK_SENSITIVITY_OPTIONS; - const updateSensitivity = (value: string) => { - if (value !== "__custom") { - onChange({ vadThreshold: value }); - } - }; - return html` -
-
- ${renderNativeTalkSelect({ - label: "Voice", - value: options.voice, - options: TALK_VOICE_OPTIONS, - onSelect: (voice) => onChange({ voice }), - })} - - ${renderNativeTalkSelect({ - label: "Sensitivity", - value: sensitivityValue, - options: sensitivityOptions, - onSelect: updateSensitivity, - })} -
-
- Advanced -
- ${renderNativeTalkSelect({ - label: "Provider", - value: options.provider, - options: providerOptions, - onSelect: (provider) => { - const selectedProvider = selectableProviders.find((entry) => entry.id === provider); - const transports = selectedProvider - ? resolveControlUiRealtimeTalkProviderTransports(selectedProvider) - : null; - const transport = options.transport; - onChange( - transports && transport && !transports.includes(transport) - ? { provider, transport: "" } - : { provider }, - ); - }, - })} - ${renderNativeTalkSelect({ - label: "Transport", - value: options.transport, - options: transportOptions, - onSelect: (transport) => onChange({ transport }), - })} - ${renderNativeTalkSelect({ - label: "Reasoning", - value: options.reasoningEffort, - options: TALK_REASONING_OPTIONS, - onSelect: (reasoningEffort) => onChange({ reasoningEffort }), - })} - - - -
-
-
- `; -} - -function renderRealtimeTalkConversation(props: ChatProps) { - const entries = props.realtimeTalkConversation ?? []; - if (entries.length === 0) { - return nothing; - } - return html` -
- ${repeat( - entries, - (entry) => entry.id, - (entry) => { - const label = - entry.role === "user" ? props.userName?.trim() || "You" : props.assistantName; - return html` -
- ${label} - ${entry.text} - ${entry.isStreaming - ? html`` - : nothing} -
- `; - }, - )} -
- `; -} - -type PendingClearedSubmittedDraft = { - key: string; - value: string; -}; - -interface ChatEphemeralState { - slashMenuOpen: boolean; - slashMenuItems: SlashCommandDef[]; - slashMenuIndex: number; - slashMenuMode: "command" | "args"; - slashMenuCommand: SlashCommandDef | null; - slashMenuArgItems: string[]; - slashMenuExpanded: boolean; - slashCommandRefreshPending: boolean; - searchOpen: boolean; - searchQuery: string; - pinnedExpanded: boolean; - composerComposing: boolean; - composerInputIntentKey: string | null; - pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null; - historyRenderSessionKey: string | null; - historyRenderMessagesRef: unknown[] | null; - historyRenderMessageCount: number; - historyRenderLimit: number; - historyRenderLastScrollTop: number | null; - historyRenderExpansionFrame: number | null; - historyRenderAnchorAdjustment: { - scrollHeight: number; - scrollTop: number; - } | null; - historyRenderAnchorFrame: number | null; -} - -function createChatEphemeralState(): ChatEphemeralState { - return { - slashMenuOpen: false, - slashMenuItems: [], - slashMenuIndex: 0, - slashMenuMode: "command", - slashMenuCommand: null, - slashMenuArgItems: [], - slashMenuExpanded: false, - slashCommandRefreshPending: false, - searchOpen: false, - searchQuery: "", - pinnedExpanded: false, - composerComposing: false, - composerInputIntentKey: null, - pendingClearedSubmittedDraft: null, - historyRenderSessionKey: null, - historyRenderMessagesRef: null, - historyRenderMessageCount: 0, - historyRenderLimit: 0, - historyRenderLastScrollTop: null, - historyRenderExpansionFrame: null, - historyRenderAnchorAdjustment: null, - historyRenderAnchorFrame: null, - }; -} - -const vs = createChatEphemeralState(); - -type CachedChatItems = { - input: BuildChatItemsProps | null; - items: ReturnType; -}; - -type ComposerDraftMirror = { - hostDraft: string; - value: string; -}; - -const chatItemsBySession = new Map(); -const composerDraftMirrors = new Map(); - -function composerDraftMirrorKey(props: Pick): string { - return `${props.currentAgentId}\u0000${props.sessionKey}`; -} - -function getComposerDraftMirror(props: ChatProps): ComposerDraftMirror { - const mirror = getOrCreateSessionCacheValue( - composerDraftMirrors, - composerDraftMirrorKey(props), - () => ({ - hostDraft: props.draft, - value: props.draft, - }), - ); - if (mirror.hostDraft !== props.draft) { - mirror.hostDraft = props.draft; - mirror.value = props.draft; - } - return mirror; -} - -function commitComposerDraft(props: ChatProps, value: string): void { - const mirror = getComposerDraftMirror(props); - mirror.value = value; - if (mirror.hostDraft === value) { - return; - } - mirror.hostDraft = value; - props.onDraftChange(value); -} - -function markComposerInputIntent(key: string): void { - vs.composerInputIntentKey = key; -} - -function consumeComposerInputIntent(key: string): boolean { - if (vs.composerInputIntentKey !== key) { - return false; - } - vs.composerInputIntentKey = null; - return true; -} - -function clearPendingClearedSubmittedDraft(key: string): void { - if (vs.pendingClearedSubmittedDraft?.key === key) { - vs.pendingClearedSubmittedDraft = null; - } -} - -function isExplicitComposerInsertion(event: InputEvent): boolean { - return event.inputType === "insertFromPaste" || event.inputType === "insertFromDrop"; -} - -function suppressStaleSubmittedDraftReplay( - target: HTMLTextAreaElement, - event: InputEvent, - draftMirror: ComposerDraftMirror, - hasInputIntent: boolean, -): boolean { - const pending = vs.pendingClearedSubmittedDraft; - if (!pending) { - return false; - } - if (target.value !== pending.value || hasInputIntent || isExplicitComposerInsertion(event)) { - return false; - } - - target.value = draftMirror.value; - adjustTextareaHeight(target); - return true; -} - -function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsProps): boolean { - return ( - previous.sessionKey === next.sessionKey && - previous.messages === next.messages && - previous.toolMessages === next.toolMessages && - previous.streamSegments === next.streamSegments && - previous.stream === next.stream && - previous.streamStartedAt === next.streamStartedAt && - previous.queue === next.queue && - previous.showToolCalls === next.showToolCalls && - previous.searchOpen === next.searchOpen && - previous.searchQuery === next.searchQuery && - previous.historyRenderLimit === next.historyRenderLimit - ); -} - -function buildCachedChatItems(input: BuildChatItemsProps): ReturnType { - const cached = getOrCreateSessionCacheValue(chatItemsBySession, input.sessionKey, () => ({ - input: null, - items: [], - })); - if (cached.input && sameChatItemsInput(cached.input, input)) { - return cached.items; - } - const items = buildChatItems(input); - cached.input = input; - cached.items = items; - return items; -} - -type RenderChatItem = ReturnType[number]; -type StreamRunRenderItem = { kind: "stream-run"; key: string; parts: StreamGroupPart[] }; - -// Fold each contiguous run of in-flight stream/reading-indicator items into a -// single group so segmented replies render under one assistant avatar instead -// of one bubble per segment (#63956). Any message/group/divider breaks the run, -// so interleaved tool calls keep their own groups. -function coalesceStreamRuns( - items: ReturnType, -): Array { - const result: Array = []; - let run: StreamGroupPart[] = []; - const flush = () => { - const [first] = run; - if (first) { - result.push({ kind: "stream-run", key: `stream-run:${first.key}`, parts: run }); - run = []; - } - }; - for (const item of items) { - if (item.kind === "stream" || item.kind === "reading-indicator") { - run.push(item); - continue; - } - flush(); - result.push(item); - } - flush(); - return result; -} - -function deletedChatItemsSignature( - deleted: DeletedMessages, - chatItems: ReturnType, -): string { - const deletedKeys = chatItems - .map((item) => item.key) - .filter((key) => deleted.has(key)) - .toSorted(); - return deletedKeys.length === 0 ? "" : deletedKeys.join("\u0000"); -} - -function stableBooleanMapSignature(values: ReadonlyMap): string { - if (values.size === 0) { - return ""; - } - return Array.from(values) - .toSorted(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => `${key}:${value ? "1" : "0"}`) - .join("\u0000"); -} - -/** - * Reset chat view ephemeral state when navigating away. - * Clears search/slash UI that should not survive navigation. - */ -export function resetChatViewState() { - if (vs.historyRenderExpansionFrame != null) { - cancelAnimationFrame(vs.historyRenderExpansionFrame); - } - if (vs.historyRenderAnchorFrame != null) { - cancelAnimationFrame(vs.historyRenderAnchorFrame); - } - Object.assign(vs, createChatEphemeralState()); - chatItemsBySession.clear(); - composerDraftMirrors.clear(); -} - -function resolveChatHistoryRenderCap(messageCount: number): number { - return Math.min(Math.max(0, messageCount), CHAT_HISTORY_RENDER_LIMIT); -} - -function shouldRenderFullChatHistoryWindow(messageCount: number): boolean { - return ( - messageCount <= INITIAL_CHAT_HISTORY_RENDER_WINDOW || - (vs.searchOpen && vs.searchQuery.trim().length > 0) - ); -} - -function resolveChatHistoryRenderWindow(props: ChatProps): number { - const messages = Array.isArray(props.messages) ? props.messages : []; - const cap = resolveChatHistoryRenderCap(messages.length); - const sessionChanged = vs.historyRenderSessionKey !== props.sessionKey; - const refChanged = vs.historyRenderMessagesRef !== messages; - const previousCount = vs.historyRenderMessageCount; - if (sessionChanged || (refChanged && previousCount === 0)) { - vs.historyRenderLastScrollTop = null; - } - - if (cap === 0) { - vs.historyRenderSessionKey = props.sessionKey; - vs.historyRenderMessagesRef = messages; - vs.historyRenderMessageCount = messages.length; - vs.historyRenderLimit = 0; - vs.historyRenderLastScrollTop = null; - return 0; - } - - if (shouldRenderFullChatHistoryWindow(messages.length)) { - vs.historyRenderSessionKey = props.sessionKey; - vs.historyRenderMessagesRef = messages; - vs.historyRenderMessageCount = messages.length; - vs.historyRenderLimit = cap; - return cap; - } - - if (sessionChanged || (refChanged && previousCount === 0)) { - vs.historyRenderLimit = Math.min(INITIAL_CHAT_HISTORY_RENDER_WINDOW, cap); - } else if (refChanged) { - const grewBy = messages.length - previousCount; - if (vs.historyRenderLimit >= previousCount) { - vs.historyRenderLimit = cap; - } else if (grewBy > 0 && grewBy <= CHAT_HISTORY_RENDER_WINDOW_BATCH) { - vs.historyRenderLimit = Math.min(cap, vs.historyRenderLimit + grewBy); - } else { - vs.historyRenderLimit = Math.min( - Math.max(vs.historyRenderLimit, INITIAL_CHAT_HISTORY_RENDER_WINDOW), - cap, - ); - } - } - - vs.historyRenderSessionKey = props.sessionKey; - vs.historyRenderMessagesRef = messages; - vs.historyRenderMessageCount = messages.length; - vs.historyRenderLimit = Math.min(Math.max(1, vs.historyRenderLimit), cap); - return vs.historyRenderLimit; -} - -function maybeExpandChatHistoryRenderWindow(event: Event, requestUpdate: () => void) { - const target = event.currentTarget; - if (!(target instanceof HTMLElement)) { - return; - } - const scrollTop = Math.max(0, target.scrollTop); - const previousScrollTop = vs.historyRenderLastScrollTop; - vs.historyRenderLastScrollTop = scrollTop; - const distanceFromBottom = Math.max(0, target.scrollHeight - scrollTop - target.clientHeight); - const isTop = scrollTop <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX; - const isBottomAutoScroll = - scrollTop > 0 && distanceFromBottom <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX; - const isTopScrollUp = - isTop && - (scrollTop === 0 || - (!isBottomAutoScroll && (previousScrollTop == null || scrollTop < previousScrollTop))); - if (!isTopScrollUp) { - return; - } - const cap = resolveChatHistoryRenderCap(vs.historyRenderMessageCount); - if (vs.historyRenderLimit >= cap) { - return; - } - vs.historyRenderAnchorAdjustment = { - scrollHeight: target.scrollHeight, - scrollTop, - }; - scheduleChatHistoryRenderAnchorPreservation(target); - vs.historyRenderLimit = Math.min(cap, vs.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH); - requestUpdate(); -} - -function scheduleChatHistoryRenderAnchorPreservation(thread: HTMLElement) { - const adjustment = vs.historyRenderAnchorAdjustment; - if (!adjustment || vs.historyRenderAnchorFrame != null) { - return; - } - vs.historyRenderAnchorFrame = requestAnimationFrame(() => { - vs.historyRenderAnchorFrame = null; - vs.historyRenderAnchorAdjustment = null; - const heightDelta = thread.scrollHeight - adjustment.scrollHeight; - if (heightDelta <= 0) { - return; - } - thread.scrollTop = adjustment.scrollTop + heightDelta; - }); -} - -function scheduleChatHistoryRenderWindowFill( - thread: HTMLElement | null, - requestUpdate: () => void, - scrollToBottom: () => void, -) { - if (!thread || vs.historyRenderExpansionFrame != null) { - return; - } - const cap = resolveChatHistoryRenderCap(vs.historyRenderMessageCount); - if (vs.historyRenderLimit >= cap) { - return; - } - vs.historyRenderExpansionFrame = requestAnimationFrame(() => { - vs.historyRenderExpansionFrame = null; - const nextCap = resolveChatHistoryRenderCap(vs.historyRenderMessageCount); - if (vs.historyRenderLimit >= nextCap) { - return; - } - const canScroll = thread.scrollHeight - thread.clientHeight > 1; - if (canScroll) { - return; - } - vs.historyRenderLimit = Math.min( - nextCap, - vs.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH, - ); - requestUpdate(); - scrollToBottom(); - }); -} - -function adjustTextareaHeight(el: HTMLTextAreaElement) { - el.style.height = "auto"; - el.style.height = `${Math.min(el.scrollHeight, 150)}px`; -} - -function focusComposerFromChrome(event: MouseEvent, connected: boolean) { - if (!connected || event.defaultPrevented) { - return; - } - const target = event.target; - const currentTarget = event.currentTarget; - if (!(target instanceof Element) || !(currentTarget instanceof HTMLElement)) { - return; - } - if (target.closest(COMPOSER_CHROME_INTERACTIVE_SELECTOR)) { - return; - } - currentTarget - .querySelector(".agent-chat__composer-combobox > textarea") - ?.focus({ preventScroll: true }); -} - -function clickComposerFileInput(event: MouseEvent) { - const target = event.currentTarget; - if (!(target instanceof HTMLElement)) { - return; - } - target - .closest(".agent-chat__input") - ?.querySelector(".agent-chat__file-input") - ?.click(); -} - -function restoreHistoryCaret(target: HTMLTextAreaElement, direction: "up" | "down") { - requestAnimationFrame(() => { - if (document.activeElement !== target) { - return; - } - adjustTextareaHeight(target); - const caret = direction === "up" ? 0 : target.value.length; - target.selectionStart = caret; - target.selectionEnd = caret; - }); -} - -function generateAttachmentId(): string { - return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; -} - -function chatAttachmentFromFile(file: File, dataUrl: string): ChatAttachment { - const attachment = { - id: generateAttachmentId(), - mimeType: file.type || "application/octet-stream", - fileName: file.name || undefined, - sizeBytes: file.size, - }; - return registerChatAttachmentPayload({ attachment, dataUrl, file }); -} - -function dataImageClipboardFile(dataUrl: string): { file: File; dataUrl: string } | null { - const match = /^\s*data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)\s*$/i.exec(dataUrl); - if (!match) { - return null; - } - const mimeType = match[1].toLowerCase(); - if (!isSupportedChatAttachmentFile({ name: "pasted-image", type: mimeType })) { - return null; - } - const base64 = match[2].replace(/\s+/g, ""); - try { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; - return { - file: new File([bytes], `pasted-image.${extension}`, { type: mimeType }), - dataUrl: `data:${mimeType};base64,${base64}`, - }; - } catch { - return null; - } -} - -function isImageAttachment(att: ChatAttachment): boolean { - return att.mimeType.startsWith("image/"); -} - -function handlePaste(e: ClipboardEvent, props: ChatProps) { - const items = e.clipboardData?.items; - if (!items || !props.onAttachmentsChange) { - return; - } - const imageItems: DataTransferItem[] = []; - for (const item of Array.from(items)) { - if (item.type.startsWith("image/")) { - imageItems.push(item); - } - } - if (imageItems.length === 0) { - const text = e.clipboardData?.getData("text/plain"); - const pasted = text ? dataImageClipboardFile(text) : null; - if (!pasted) { - return; - } - e.preventDefault(); - props.onAttachmentsChange([ - ...(props.attachments ?? []), - chatAttachmentFromFile(pasted.file, pasted.dataUrl), - ]); - return; - } - e.preventDefault(); - for (const item of imageItems) { - const file = item.getAsFile(); - if (!file) { - continue; - } - const reader = new FileReader(); - reader.addEventListener("load", () => { - const dataUrl = reader.result as string; - const newAttachment = chatAttachmentFromFile(file, dataUrl); - const current = props.attachments ?? []; - props.onAttachmentsChange?.([...current, newAttachment]); - }); - reader.readAsDataURL(file); - } -} - -function handleFileSelect(e: Event, props: ChatProps) { - const input = e.target as HTMLInputElement; - if (!input.files || !props.onAttachmentsChange) { - return; - } - const current = props.attachments ?? []; - const additions: ChatAttachment[] = []; - let pending = 0; - for (const file of input.files) { - if (!isSupportedChatAttachmentFile(file)) { - continue; - } - pending++; - const reader = new FileReader(); - reader.addEventListener("load", () => { - additions.push(chatAttachmentFromFile(file, reader.result as string)); - pending--; - if (pending === 0) { - props.onAttachmentsChange?.([...current, ...additions]); - } - }); - reader.readAsDataURL(file); - } - input.value = ""; -} - -function handleDrop(e: DragEvent, props: ChatProps) { - e.preventDefault(); - const files = e.dataTransfer?.files; - if (!files || !props.onAttachmentsChange) { - return; - } - const current = props.attachments ?? []; - const additions: ChatAttachment[] = []; - let pending = 0; - for (const file of files) { - if (!isSupportedChatAttachmentFile(file)) { - continue; - } - pending++; - const reader = new FileReader(); - reader.addEventListener("load", () => { - additions.push(chatAttachmentFromFile(file, reader.result as string)); - pending--; - if (pending === 0) { - props.onAttachmentsChange?.([...current, ...additions]); - } - }); - reader.readAsDataURL(file); - } -} - -function renderAttachmentPreview(props: ChatProps): TemplateResult | typeof nothing { - const attachments = props.attachments ?? []; - if (attachments.length === 0) { - return nothing; - } - return html` -
- ${attachments.map( - (att) => html` -
- ${isImageAttachment(att) && getChatAttachmentPreviewUrl(att) - ? html`Attachment preview` - : html` -
- ${icons.paperclip} - ${att.fileName ?? "Attached file"} -
- `} - -
- `, - )} -
- `; -} - -function renderChatGoal(goal: SessionGoal | undefined): TemplateResult | typeof nothing { - if (!goal) { - return nothing; - } - return html` -
- ${formatGoalSummary(goal)} - ${goal.objective} -
- `; -} - -function formatWorkspaceFileSize(file: { size?: number }): string { - const size = file.size; - if (typeof size !== "number" || !Number.isFinite(size) || size < 0) { - return ""; - } - if (size >= 1024 * 1024) { - return `${(size / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")} MB`; - } - if (size >= 1024) { - return `${(size / 1024).toFixed(1).replace(/\.0$/, "")} KB`; - } - return `${size} B`; -} - -function renderWorkspaceArtifactSize(artifact: { sizeBytes?: number }): string { - return formatWorkspaceFileSize({ size: artifact.sizeBytes }); -} - -function renderWorkspaceRailSection( - title: string, - content: TemplateResult | typeof nothing, -): TemplateResult | typeof nothing { - if (content === nothing) { - return nothing; - } - return html` -
-
${title}
- ${content} -
- `; -} - -function renderSessionWorkspaceRail( - sessionWorkspace: NonNullable | undefined, -): TemplateResult | typeof nothing { - if (!sessionWorkspace) { - return nothing; - } - if (sessionWorkspace.collapsed) { - return html` - - `; - } - const files = sessionWorkspace.list?.files ?? []; - const modifiedFiles = files.filter((file) => file.kind === "modified"); - const readFiles = files.filter((file) => file.kind === "read"); - const artifacts = sessionWorkspace.list?.artifacts ?? []; - const browser = sessionWorkspace.list?.browser ?? null; - const hasSessionItems = files.length > 0 || artifacts.length > 0; - const hasBrowserItems = (browser?.entries.length ?? 0) > 0; - const hasItems = hasSessionItems || hasBrowserItems; - const renderPathActions = ( - path: string, - options: { preview?: boolean } = {}, - ): TemplateResult => html` - - ${options.preview === false - ? nothing - : html``} - - - `; - const renderSessionSummary = (): TemplateResult | typeof nothing => { - if (!sessionWorkspace.list) { - return nothing; - } - const browserCount = browser?.entries.length ?? 0; - return html` -
- ${t("chat.workspaceFiles.changedCount", { count: String(modifiedFiles.length) })} - ${t("chat.workspaceFiles.readCount", { count: String(readFiles.length) })} - ${t("chat.workspaceFiles.artifactCount", { count: String(artifacts.length) })} - ${t("chat.workspaceFiles.browserCount", { count: String(browserCount) })} -
- `; - }; - const renderFileRows = (rows: typeof files): TemplateResult | typeof nothing => - rows.length === 0 - ? nothing - : html` -
- ${rows.map((file) => { - const size = formatWorkspaceFileSize(file); - const itemId = `file:${file.path}`; - const isActive = itemId === sessionWorkspace.activeId; - return html` -
- - ${file.missing - ? html`${t("chat.workspaceFiles.missing")}` - : nothing} - ${renderPathActions(file.path)} -
- `; - })} -
- `; - const renderBrowserBadge = ( - sessionKind: "modified" | "read" | "mixed" | undefined, - ): TemplateResult | typeof nothing => { - if (!sessionKind) { - return nothing; - } - const label = - sessionKind === "modified" - ? t("chat.workspaceFiles.changed") - : sessionKind === "read" - ? t("chat.workspaceFiles.read") - : t("chat.workspaceFiles.session"); - return html`${label}`; - }; - const renderBrowserBreadcrumbs = (): TemplateResult | typeof nothing => { - if (!browser || browser.search) { - return nothing; - } - const parts = browser.path ? browser.path.split("/").filter(Boolean) : []; - let currentPath = ""; - return html` -
- - ${parts.map((part) => { - currentPath = currentPath ? `${currentPath}/${part}` : part; - const pathForPart = currentPath; - return html` - / - - `; - })} -
- `; - }; - const renderBrowserRows = (): TemplateResult => { - const entries = browser?.entries ?? []; - const parentPath = browser?.parentPath; - return html` -
-
- -
- ${renderBrowserBreadcrumbs()} - ${browser?.search - ? html`
- ${t("chat.workspaceFiles.searchResults")} -
` - : nothing} -
- ${!browser?.search && parentPath != null - ? html` -
- -
- ` - : nothing} - ${entries.length === 0 - ? html`
- ${browser?.search - ? t("chat.workspaceFiles.noSearchResults") - : t("chat.workspaceFiles.noBrowserFiles")} -
` - : entries.map((entry) => { - const size = entry.kind === "file" ? formatWorkspaceFileSize(entry) : ""; - const itemId = `file:${entry.path}`; - const isActive = itemId === sessionWorkspace.activeId; - const canPreview = entry.kind === "file" && Boolean(entry.sessionKind); - return html` -
- - ${renderBrowserBadge(entry.sessionKind)} - ${entry.kind === "file" - ? renderPathActions(entry.path, { preview: canPreview }) - : nothing} -
- `; - })} -
- ${browser?.truncated - ? html`
- ${t("chat.workspaceFiles.truncated")} -
` - : nothing} -
- `; - }; - const renderArtifactRows = (): TemplateResult | typeof nothing => - artifacts.length === 0 - ? nothing - : html` -
- ${artifacts.map((artifact) => { - const size = renderWorkspaceArtifactSize(artifact); - const itemId = `artifact:${artifact.id}`; - const isActive = itemId === sessionWorkspace.activeId; - const isImage = artifact.mimeType?.startsWith("image/"); - return html` -
- - - - -
- `; - })} -
- `; - return html` - - `; -} - -function resetSlashMenuState(): void { - vs.slashMenuMode = "command"; - vs.slashMenuCommand = null; - vs.slashMenuArgItems = []; - vs.slashMenuItems = []; - vs.slashMenuExpanded = false; -} - -function hasVisibleSlashMenuState(): boolean { - return ( - vs.slashMenuOpen || - vs.slashMenuMode !== "command" || - vs.slashMenuCommand !== null || - vs.slashMenuArgItems.length > 0 || - vs.slashMenuItems.length > 0 || - vs.slashMenuExpanded - ); -} - -function closeSlashMenuIfNeeded(requestUpdate: () => void): void { - if (!hasVisibleSlashMenuState()) { - return; - } - vs.slashMenuOpen = false; - resetSlashMenuState(); - requestUpdate(); -} - -function requestSlashCommandRefresh( - value: string, - props: ChatProps, - requestUpdate: () => void, - getCurrentValue?: () => string, -): void { - if (!props.onSlashIntent || vs.slashCommandRefreshPending) { - return; - } - const refresh = props.onSlashIntent(); - if (!refresh || typeof refresh.then !== "function") { - return; - } - vs.slashCommandRefreshPending = true; - void Promise.resolve(refresh).finally(() => { - vs.slashCommandRefreshPending = false; - const nextValue = getCurrentValue?.() ?? props.getDraft?.() ?? value; - if (!nextValue.startsWith("/")) { - closeSlashMenuIfNeeded(requestUpdate); - return; - } - updateSlashMenu(nextValue, requestUpdate, props, { skipSlashIntent: true }); - }); -} - -function updateSlashMenu( - value: string, - requestUpdate: () => void, - props: ChatProps, - opts: { skipSlashIntent?: boolean } = {}, - getCurrentValue?: () => string, -): void { - // Arg mode: /command - const argMatch = value.match(/^\/(\S+)\s(.*)$/); - if (argMatch) { - if (!opts.skipSlashIntent) { - requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue); - } - const cmdName = argMatch[1].toLowerCase(); - const argFilter = argMatch[2].toLowerCase(); - const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName); - if (cmd?.argOptions?.length) { - const filtered = argFilter - ? cmd.argOptions.filter((opt) => opt.toLowerCase().startsWith(argFilter)) - : cmd.argOptions; - if (filtered.length > 0) { - vs.slashMenuMode = "args"; - vs.slashMenuCommand = cmd; - vs.slashMenuArgItems = filtered; - vs.slashMenuOpen = true; - vs.slashMenuIndex = 0; - vs.slashMenuItems = []; - requestUpdate(); - return; - } - } - closeSlashMenuIfNeeded(requestUpdate); - return; - } - - // Command mode: /partial-command - const match = value.match(/^\/(\S*)$/); - if (match) { - if (!opts.skipSlashIntent) { - requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue); - } - const items = getSlashCommandCompletions(match[1], { showAll: vs.slashMenuExpanded }); - vs.slashMenuItems = items; - vs.slashMenuOpen = items.length > 0; - vs.slashMenuIndex = 0; - vs.slashMenuMode = "command"; - vs.slashMenuCommand = null; - vs.slashMenuArgItems = []; - } else { - closeSlashMenuIfNeeded(requestUpdate); - return; - } - requestUpdate(); -} - -function selectSlashCommand( - cmd: SlashCommandDef, - props: ChatProps, - requestUpdate: () => void, -): void { - // Transition to arg picker when the command has fixed options - if (cmd.argOptions?.length) { - commitComposerDraft(props, `/${cmd.name} `); - vs.slashMenuMode = "args"; - vs.slashMenuCommand = cmd; - vs.slashMenuArgItems = cmd.argOptions; - vs.slashMenuOpen = true; - vs.slashMenuIndex = 0; - vs.slashMenuItems = []; - requestUpdate(); - return; - } - - vs.slashMenuOpen = false; - resetSlashMenuState(); - - if (cmd.executeLocal && !cmd.args) { - commitComposerDraft(props, `/${cmd.name}`); - requestUpdate(); - if (props.connected && props.canSend) { - props.onSend(); - } - } else { - commitComposerDraft(props, `/${cmd.name} `); - requestUpdate(); - } -} - -function tabCompleteSlashCommand( - cmd: SlashCommandDef, - props: ChatProps, - requestUpdate: () => void, -): void { - // Tab: fill in the command text without executing - if (cmd.argOptions?.length) { - commitComposerDraft(props, `/${cmd.name} `); - vs.slashMenuMode = "args"; - vs.slashMenuCommand = cmd; - vs.slashMenuArgItems = cmd.argOptions; - vs.slashMenuOpen = true; - vs.slashMenuIndex = 0; - vs.slashMenuItems = []; - requestUpdate(); - return; - } - - vs.slashMenuOpen = false; - resetSlashMenuState(); - commitComposerDraft(props, cmd.args ? `/${cmd.name} ` : `/${cmd.name}`); - requestUpdate(); -} - -function selectSlashArg( - arg: string, - props: ChatProps, - requestUpdate: () => void, - execute: boolean, -): void { - const cmdName = vs.slashMenuCommand?.name ?? ""; - vs.slashMenuOpen = false; - resetSlashMenuState(); - commitComposerDraft(props, `/${cmdName} ${arg}`); - requestUpdate(); - if (execute && props.connected && props.canSend) { - props.onSend(); - } -} - -function slashOptionIdSegment(value: string): string { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9_-]+/gu, "-") - .replace(/^-+|-+$/gu, "") || "item" - ); -} - -function getSlashCommandOptionId(cmd: SlashCommandDef): string { - return `chat-slash-option-command-${slashOptionIdSegment(cmd.name)}`; -} - -function getSlashArgOptionId(commandName: string, arg: string): string { - return `chat-slash-option-arg-${slashOptionIdSegment(commandName)}-${slashOptionIdSegment(arg)}`; -} - -function isSlashMenuVisible(): boolean { - if (!vs.slashMenuOpen) { - return false; - } - if (vs.slashMenuMode === "args") { - return Boolean(vs.slashMenuCommand && vs.slashMenuArgItems.length > 0); - } - return vs.slashMenuItems.length > 0; -} - -function getActiveSlashMenuOptionId(): string | null { - if (!isSlashMenuVisible()) { - return null; - } - if (vs.slashMenuMode === "args") { - const commandName = vs.slashMenuCommand?.name; - const arg = vs.slashMenuArgItems[vs.slashMenuIndex]; - return commandName && arg ? getSlashArgOptionId(commandName, arg) : null; - } - const cmd = vs.slashMenuItems[vs.slashMenuIndex]; - return cmd ? getSlashCommandOptionId(cmd) : null; -} - -function getActiveSlashMenuOptionLabel(): string { - if (!isSlashMenuVisible()) { - return ""; - } - if (vs.slashMenuMode === "args") { - const commandName = vs.slashMenuCommand?.name; - const arg = vs.slashMenuArgItems[vs.slashMenuIndex]; - return commandName && arg ? `/${commandName} ${arg}` : ""; - } - const cmd = vs.slashMenuItems[vs.slashMenuIndex]; - if (!cmd) { - return ""; - } - const command = `/${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}`; - return `${command} ${cmd.description}`; -} - -function scrollActiveSlashMenuOptionIntoView(): void { - const activeId = getActiveSlashMenuOptionId(); - if (!activeId) { - return; - } - requestAnimationFrame(() => { - const activeOption = document.getElementById(activeId); - const menu = activeOption?.closest(".slash-menu"); - if (!activeOption || !menu) { - return; - } - const menuBounds = menu.getBoundingClientRect(); - const optionBounds = activeOption.getBoundingClientRect(); - // scrollIntoView also moves the short-landscape composer and page. Keep - // keyboard navigation owned by the menu so textarea focus stays stable. - if (optionBounds.top < menuBounds.top) { - menu.scrollTop -= menuBounds.top - optionBounds.top; - } else if (optionBounds.bottom > menuBounds.bottom) { - menu.scrollTop += optionBounds.bottom - menuBounds.bottom; - } - }); -} - -function tokenEstimate(draft: string): string | null { - if (draft.length < 100) { - return null; - } - return `~${Math.ceil(draft.length / 4)} tokens`; -} - -/** - * Export chat markdown - delegates to shared utility. - */ -function exportMarkdown(props: ChatProps): void { - exportChatMarkdown(props.messages, props.assistantName); -} - -function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing { - if (!vs.searchOpen) { - return nothing; - } - return html` - - `; -} - -function renderPinnedSection( - props: ChatProps, - pinned: PinnedMessages, - requestUpdate: () => void, -): TemplateResult | typeof nothing { - const userRoleLabel = resolveLocalUserName({ - name: props.userName ?? null, - avatar: props.userAvatar ?? null, - }); - const messages = Array.isArray(props.messages) ? props.messages : []; - const entries: Array<{ index: number; text: string; role: string }> = []; - for (const idx of pinned.indices) { - const msg = messages[idx] as Record | undefined; - if (!msg) { - continue; - } - const text = getPinnedMessageSummary(msg); - const role = typeof msg.role === "string" ? msg.role : "unknown"; - entries.push({ index: idx, text, role }); - } - if (entries.length === 0) { - return nothing; - } - return html` -
- - ${vs.pinnedExpanded - ? html` -
- ${entries.map( - ({ index, text, role }) => html` -
- ${role === "user" ? userRoleLabel : "Assistant"} - ${text.slice(0, 100)}${text.length > 100 ? "..." : ""} - -
- `, - )} -
- ` - : nothing} -
- `; -} - -function renderSlashMenu( - requestUpdate: () => void, - props: ChatProps, - draft: string, -): TemplateResult | typeof nothing { - if (!vs.slashMenuOpen) { - return nothing; - } - - // Arg-picker mode: show options for the selected command - if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0) { - return html` -
-
-
- /${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description} -
- ${vs.slashMenuArgItems.map( - (arg, i) => html` -
selectSlashArg(arg, props, requestUpdate, true)} - @mouseenter=${() => { - vs.slashMenuIndex = i; - requestUpdate(); - }} - > - ${vs.slashMenuCommand?.icon - ? html`${icons[vs.slashMenuCommand.icon]}` - : nothing} - ${arg} - /${vs.slashMenuCommand?.name} ${arg} -
- `, - )} -
- -
- `; - } - - // Command mode: show grouped commands - if (vs.slashMenuItems.length === 0) { - return nothing; - } - - const grouped = new Map< - SlashCommandCategory, - Array<{ cmd: SlashCommandDef; globalIdx: number }> - >(); - for (let i = 0; i < vs.slashMenuItems.length; i++) { - const cmd = vs.slashMenuItems[i]; - const cat = cmd.category ?? "session"; - let list = grouped.get(cat); - if (!list) { - list = []; - grouped.set(cat, list); - } - list.push({ cmd, globalIdx: i }); - } - - const sections: TemplateResult[] = []; - for (const [cat, entries] of grouped) { - sections.push(html` -
-
${CATEGORY_LABELS[cat]}
- ${entries.map( - ({ cmd, globalIdx }) => html` -
selectSlashCommand(cmd, props, requestUpdate)} - @mouseenter=${() => { - vs.slashMenuIndex = globalIdx; - requestUpdate(); - }} - > - ${cmd.icon ? html`${icons[cmd.icon]}` : nothing} - /${cmd.name} - ${cmd.args ? html`${cmd.args}` : nothing} - ${cmd.description} - ${cmd.argOptions?.length - ? html`${cmd.argOptions.length} options` - : cmd.executeLocal && !cmd.args - ? html` instant ` - : nothing} -
- `, - )} -
- `); - } - - const hiddenCount = vs.slashMenuExpanded ? 0 : getHiddenCommandCount(); - - return html` -
- ${sections} - ${hiddenCount > 0 - ? html`` - : nothing} - -
- `; -} - -let activeReplyContextMenu: HTMLElement | null = null; -let contextMenuDocumentClickHandler: ((e: MouseEvent) => void) | null = null; -let contextMenuKeydownHandler: ((e: KeyboardEvent) => void) | null = null; - -function removeReplyContextMenu() { - activeReplyContextMenu?.remove(); - activeReplyContextMenu = null; - document.querySelector(".chat-reply-context-menu")?.remove(); - if (contextMenuDocumentClickHandler) { - document.removeEventListener("click", contextMenuDocumentClickHandler); - contextMenuDocumentClickHandler = null; - } - if (contextMenuKeydownHandler) { - document.removeEventListener("keydown", contextMenuKeydownHandler); - contextMenuKeydownHandler = null; - } -} - -function stableReplyMessageId(senderLabel: string | undefined, text: string): string { - const source = `${senderLabel ?? ""}\n${text}`; - let hash = 0x811c9dc5; - for (let index = 0; index < source.length; index += 1) { - hash ^= source.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return `reply:${(hash >>> 0).toString(16)}`; -} - -function createReplyContextMenuButton(onClick: () => void): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.setAttribute("role", "menuitem"); - button.setAttribute("aria-label", "Reply to message"); - - const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - icon.setAttribute("viewBox", "0 0 24 24"); - icon.setAttribute("width", "16"); - icon.setAttribute("height", "16"); - icon.setAttribute("fill", "currentColor"); - icon.setAttribute("stroke", "none"); - icon.setAttribute("aria-hidden", "true"); - icon.setAttribute("focusable", "false"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"); - icon.appendChild(path); - - const label = document.createElement("span"); - label.textContent = "Reply"; - - button.append(icon, label); - button.addEventListener("click", onClick); - return button; -} - -export function renderChat(props: ChatProps) { - const canCompose = props.connected && props.canSend; - const isBusy = props.sending || props.stream !== null; - const canAbort = Boolean(props.canAbort && props.onAbort); - const hasTerminalStatus = hasTerminalRunStatus(props.runStatus); - const showAbortableUi = canAbort && !hasTerminalStatus; - const showSubmittedProgressUi = props.queue.some((item) => - isCurrentSessionSubmittedProgress(item, props.sessionKey, props.runStatus), - ); - const composerRunStatus = - showAbortableUi || showSubmittedProgressUi - ? { phase: "in-progress" as const } - : props.runStatus; - const compactBusy = - props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying"; - const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey); - const reasoningLevel = activeSession?.reasoningLevel ?? "off"; - const showReasoning = props.showThinking && reasoningLevel !== "off"; - const assistantIdentity = { - name: props.assistantName, - avatar: resolveAssistantDisplayAvatar(props), - }; - const draftMirror = getComposerDraftMirror(props); - const visibleDraft = draftMirror.value; - let composerTextarea: HTMLTextAreaElement | null = null; - const pinned = getPinnedMessages(props.sessionKey); - const deleted = getDeletedMessages(props.sessionKey); - const hasAttachments = (props.attachments?.length ?? 0) > 0; - const tokens = tokenEstimate(visibleDraft); - const composerControls = props.composerControls; - - const placeholder = !props.connected - ? t("chat.composer.placeholderDisconnected") - : !canCompose && props.disabledReason - ? props.disabledReason - : hasAttachments - ? t("chat.composer.placeholderWithAttachments") - : t("chat.composer.placeholder", { name: props.assistantName || "agent" }); - - const requestUpdate = props.onRequestUpdate ?? (() => {}); - const splitRatio = props.splitRatio ?? 0.6; - const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); - const displayStream = props.stream ?? null; - const historyRenderLimit = resolveChatHistoryRenderWindow(props); - - const handleCodeBlockCopy = (e: Event) => { - const btn = (e.target as HTMLElement).closest(".code-block-copy"); - if (!btn) { - return; - } - const button = btn as HTMLElement; - const code = decodeCodeBlockCopyPayload(button.dataset.code ?? "", button.dataset.codeEncoding); - void copyToClipboard(code).then((copied) => { - if (!copied) { - return; - } - btn.classList.add("copied"); - setTimeout(() => btn.classList.remove("copied"), 1500); - }); - }; - const handleChatContextMenu = (e: MouseEvent, p: ChatProps) => { - const bubble = (e.target as HTMLElement).closest(".chat-bubble"); - if (!bubble) { - return; - } - if (typeof p.onSetReply !== "function") { - return; - } - const group = bubble.closest(".chat-group"); - if (!group) { - return; - } - // Skip streaming messages and reading indicators - if ( - group.querySelector(".chat-reading-indicator") || - group.querySelector(".chat-bubble.streaming") - ) { - return; - } - const senderEl = group.querySelector(".chat-sender-name"); - const senderLabel = senderEl?.textContent?.trim() ?? undefined; - const text = (bubble as HTMLElement).dataset.messageText?.trim().slice(0, 500) ?? ""; - if (!text) { - return; - } - e.preventDefault(); - e.stopPropagation(); - const messageId = - (bubble as HTMLElement).dataset.messageId?.trim() || stableReplyMessageId(senderLabel, text); - removeReplyContextMenu(); - const menu = document.createElement("div"); - menu.className = "chat-reply-context-menu"; - menu.setAttribute("role", "menu"); - menu.setAttribute("aria-label", "Message actions"); - menu.style.left = `${e.clientX}px`; - menu.style.top = `${e.clientY}px`; - const button = createReplyContextMenuButton(() => { - p.onSetReply?.({ messageId, text, senderLabel }); - removeReplyContextMenu(); - composerTextarea?.focus(); - }); - menu.append(button); - document.body.appendChild(menu); - activeReplyContextMenu = menu; - // Clamp menu position within the viewport - const menuRect = menu.getBoundingClientRect(); - let left = e.clientX; - let top = e.clientY; - if (left + menuRect.width > window.innerWidth) { - left = window.innerWidth - menuRect.width - 8; - } - if (top + menuRect.height > window.innerHeight) { - top = window.innerHeight - menuRect.height - 8; - } - menu.style.left = `${Math.max(0, left)}px`; - menu.style.top = `${Math.max(0, top)}px`; - button.focus(); - requestAnimationFrame(() => { - if (!menu.isConnected || activeReplyContextMenu !== menu) { - return; - } - contextMenuDocumentClickHandler = (ev: MouseEvent) => { - if (!menu.contains(ev.target as Node | null)) { - removeReplyContextMenu(); - } - }; - const handleKeydown = (ev: KeyboardEvent) => { - if (ev.key === "Escape") { - ev.preventDefault(); - ev.stopPropagation(); - removeReplyContextMenu(); - composerTextarea?.focus(); - } - }; - contextMenuKeydownHandler = handleKeydown; - document.addEventListener("click", contextMenuDocumentClickHandler); - document.addEventListener("keydown", handleKeydown); - }); - }; - const handleChatThreadScroll = (event: Event) => { - maybeExpandChatHistoryRenderWindow(event, requestUpdate); - props.onChatScroll?.(event); - }; - - const chatItems = buildCachedChatItems({ - sessionKey: props.sessionKey, - messages: props.messages, - toolMessages: props.toolMessages, - streamSegments: props.streamSegments, - stream: displayStream, - streamStartedAt: props.streamStartedAt, - queue: props.queue, - showToolCalls: props.showToolCalls, - searchOpen: vs.searchOpen, - searchQuery: vs.searchQuery, - historyRenderLimit, - }); - syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls)); - const expandedToolCards = getExpandedToolCards(props.sessionKey); - const toggleToolCardExpanded = (toolCardId: string) => { - expandedToolCards.set(toolCardId, !expandedToolCards.get(toolCardId)); - requestUpdate(); - }; - const hasRealtimeTalkConversation = (props.realtimeTalkConversation?.length ?? 0) > 0; - const isEmpty = chatItems.length === 0 && !props.loading && !hasRealtimeTalkConversation; - const showLoadingSkeleton = props.loading && chatItems.length === 0; - const threadContextWindow = - activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null; - - const thread = html` -
{ - const threadElement = element instanceof HTMLElement ? element : null; - scheduleChatHistoryRenderWindowFill( - threadElement, - requestUpdate, - props.onScrollToBottom ?? (() => {}), - ); - })} - @scroll=${handleChatThreadScroll} - @click=${handleCodeBlockCopy} - @contextmenu=${(e: MouseEvent) => handleChatContextMenu(e, props)} - > -
- ${showLoadingSkeleton - ? html` -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ` - : nothing} - ${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing} - ${isEmpty && vs.searchOpen - ? html`
No matching messages
` - : nothing} - ${guard( - [ - chatItems, - deletedChatItemsSignature(deleted, chatItems), - stableBooleanMapSignature(expandedToolCards), - getAssistantAttachmentAvailabilityRenderVersion(), - props.sessionKey, - props.fullMessageAgentId, - showReasoning, - props.showToolCalls, - Boolean(props.autoExpandToolCalls), - props.assistantName, - assistantIdentity.avatar, - props.userName, - props.userAvatar, - props.basePath, - (props.localMediaPreviewRoots ?? []).join("\u0000"), - props.assistantAttachmentAuthToken, - props.canvasPluginSurfaceUrl, - props.embedSandboxMode ?? "scripts", - props.allowExternalEmbedUrls ?? false, - threadContextWindow, - ], - () => - repeat( - coalesceStreamRuns(chatItems), - (item) => item.key, - (item) => { - if (item.kind === "divider") { - return html` -
- - ${item.description || item.action - ? html` -
- ${item.description - ? html` - ${item.description} - ` - : nothing} - ${item.action?.kind === "session-checkpoints" && - props.onOpenSessionCheckpoints - ? html` - - ` - : nothing} -
- ` - : nothing} -
- `; - } - if (item.kind === "stream-run") { - return renderStreamGroup(item.parts, { - onOpenSidebar: props.onOpenSidebar, - assistant: assistantIdentity, - basePath: props.basePath, - authToken: props.assistantAttachmentAuthToken ?? null, - }); - } - if (item.kind === "group") { - if (deleted.has(item.key)) { - return nothing; - } - return renderMessageGroup(item, { - onOpenSidebar: props.onOpenSidebar, - sessionKey: props.sessionKey, - agentId: props.fullMessageAgentId, - showReasoning, - showToolCalls: props.showToolCalls, - autoExpandToolCalls: Boolean(props.autoExpandToolCalls), - isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId), - onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => { - expandedToolCards.set( - messageId, - !(expanded ?? expandedToolCards.get(messageId) ?? false), - ); - requestUpdate(); - }, - isToolExpanded: (toolCardId: string) => - expandedToolCards.get(toolCardId) ?? false, - onToggleToolExpanded: toggleToolCardExpanded, - onRequestUpdate: requestUpdate, - onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded, - assistantName: props.assistantName, - assistantAvatar: assistantIdentity.avatar, - userName: props.userName ?? null, - userAvatar: props.userAvatar ?? null, - basePath: props.basePath, - localMediaPreviewRoots: props.localMediaPreviewRoots ?? [], - assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null, - canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, - embedSandboxMode: props.embedSandboxMode ?? "scripts", - allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false, - contextWindow: threadContextWindow, - onDelete: () => { - deleted.delete(item.key); - requestUpdate(); - }, - }); - } - return nothing; - }, - ), - )} - ${renderRealtimeTalkConversation(props)} -
-
- `; - - const syncComposerDraftAfterSend = (target: HTMLTextAreaElement | null) => { - const hostDraft = props.getDraft?.(); - if (typeof hostDraft !== "string") { - return; - } - const mirrorKey = composerDraftMirrorKey(props); - const submittedDraft = draftMirror.value; - const clearedSubmittedDraft = - hostDraft === "" && submittedDraft !== "" && target?.value === submittedDraft; - // Sends can clear the host draft synchronously before Lit rerenders; keep - // the local mirror aligned so the submitted text does not stay editable. - draftMirror.hostDraft = hostDraft; - draftMirror.value = hostDraft; - if (clearedSubmittedDraft) { - vs.pendingClearedSubmittedDraft = { - key: mirrorKey, - value: submittedDraft, - }; - } else { - clearPendingClearedSubmittedDraft(mirrorKey); - } - if (target && target.value !== hostDraft) { - target.value = hostDraft; - adjustTextareaHeight(target); - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - // IME navigation keys belong to the browser; downstream handlers can - // prevent them or commit the in-progress composition as a host draft. - if (vs.composerComposing || e.isComposing || e.keyCode === 229) { - return; - } - - // Slash menu navigation — arg mode - if (vs.slashMenuOpen && vs.slashMenuMode === "args" && vs.slashMenuArgItems.length > 0) { - const len = vs.slashMenuArgItems.length; - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len; - requestUpdate(); - scrollActiveSlashMenuOptionIntoView(); - return; - case "ArrowUp": - e.preventDefault(); - vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; - requestUpdate(); - scrollActiveSlashMenuOptionIntoView(); - return; - case "Tab": - e.preventDefault(); - selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, false); - return; - case "Enter": - e.preventDefault(); - selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, true); - return; - case "Escape": - e.preventDefault(); - vs.slashMenuOpen = false; - resetSlashMenuState(); - requestUpdate(); - return; - } - } - - // Slash menu navigation — command mode - if (vs.slashMenuOpen && vs.slashMenuItems.length > 0) { - const len = vs.slashMenuItems.length; - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len; - requestUpdate(); - scrollActiveSlashMenuOptionIntoView(); - return; - case "ArrowUp": - e.preventDefault(); - vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; - requestUpdate(); - scrollActiveSlashMenuOptionIntoView(); - return; - case "Tab": - e.preventDefault(); - tabCompleteSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate); - return; - case "Enter": - e.preventDefault(); - selectSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate); - return; - case "Escape": - e.preventDefault(); - vs.slashMenuOpen = false; - resetSlashMenuState(); - requestUpdate(); - return; - } - } - - if (e.key === "Escape" && props.sideResult && !vs.searchOpen) { - e.preventDefault(); - props.onDismissSideResult?.(); - return; - } - - if ((e.key === "ArrowUp" || e.key === "ArrowDown") && props.onHistoryKeydown) { - const target = e.target as HTMLTextAreaElement; - commitComposerDraft(props, target.value); - const result = props.onHistoryKeydown({ - key: e.key, - selectionStart: target.selectionStart, - selectionEnd: target.selectionEnd, - valueLength: target.value.length, - altKey: e.altKey, - ctrlKey: e.ctrlKey, - metaKey: e.metaKey, - shiftKey: e.shiftKey, - isComposing: e.isComposing, - keyCode: e.keyCode, - }); - if (result.handled) { - if (result.preventDefault) { - e.preventDefault(); - } - if (result.restoreCaret) { - restoreHistoryCaret(target, result.restoreCaret); - } - return; - } - } - - // Cmd+F for search - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "f") { - e.preventDefault(); - vs.searchOpen = !vs.searchOpen; - if (!vs.searchOpen) { - vs.searchQuery = ""; - } - requestUpdate(); - return; - } - - // Send on Enter (without shift) - if (e.key === "Enter" && !e.shiftKey) { - if (!canCompose) { - return; - } - e.preventDefault(); - const target = e.target as HTMLTextAreaElement; - commitComposerDraft(props, target.value); - props.onSend(); - syncComposerDraftAfterSend(target); - } - }; - - const syncComposerValue = ( - target: HTMLTextAreaElement, - options: { forceCommit?: boolean } = {}, - ) => { - adjustTextareaHeight(target); - draftMirror.value = target.value; - const hostDraftNeeded = isBusy || showAbortableUi || props.queue.length > 0; - if ( - options.forceCommit || - hostDraftNeeded || - target.value.startsWith("/") || - hasVisibleSlashMenuState() - ) { - commitComposerDraft(props, target.value); - } - updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value); - }; - const handleBeforeInput = (e: InputEvent) => { - if (!vs.composerComposing && !e.isComposing) { - markComposerInputIntent(composerDraftMirrorKey(props)); - } - }; - const handleInput = (e: InputEvent) => { - const target = e.target as HTMLTextAreaElement; - const mirrorKey = composerDraftMirrorKey(props); - const hasInputIntent = consumeComposerInputIntent(mirrorKey); - if (vs.composerComposing || e.isComposing) { - // Skip adjustTextareaHeight during IME composition — each pinyin - // keystroke fires `input` and the height read/write forces a - // synchronous reflow that blocks the composition thread. - // Resize runs once in handleCompositionEnd → syncComposerValue. - draftMirror.value = target.value; - return; - } - if (suppressStaleSubmittedDraftReplay(target, e, draftMirror, hasInputIntent)) { - return; - } - syncComposerValue(target); - }; - const handleCompositionEnd = (e: CompositionEvent) => { - vs.composerComposing = false; - syncComposerValue(e.target as HTMLTextAreaElement, { forceCommit: true }); - }; - const handleBlur = (e: FocusEvent) => { - const target = e.target as HTMLTextAreaElement; - commitComposerDraft(props, target.value); - }; - const handleSend = () => { - if (!canCompose) { - return; - } - commitComposerDraft(props, draftMirror.value); - props.onSend(); - syncComposerDraftAfterSend(composerTextarea); - }; - const slashMenuVisible = canCompose && isSlashMenuVisible(); - const activeSlashMenuOptionId = getActiveSlashMenuOptionId(); - const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel(); - const chatColumnFooter = html` - ${renderChatQueue({ - queue: props.queue, - canAbort: showAbortableUi, - onQueueRetry: canCompose ? props.onQueueRetry : undefined, - onQueueSteer: canCompose ? props.onQueueSteer : undefined, - onQueueRemove: props.onQueueRemove, - })} - ${renderSideResult(props.sideResult, props.onDismissSideResult)} - ${props.showNewMessages - ? html` - - ` - : nothing} - - -
focusComposerFromChrome(event, canCompose)} - > - ${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing} - ${renderAttachmentPreview(props)} - ${props.replyTarget - ? html` -
- ${icons.messageSquare} - Replying to ${props.replyTarget.senderLabel ?? "message"} - ${props.replyTarget.text.slice(0, 120)}${props.replyTarget.text.length > 120 - ? "…" - : ""} - -
- ` - : nothing} -
- ${renderFallbackIndicator(props.fallbackStatus)} - ${renderCompactionIndicator(props.compactionStatus)} ${renderChatGoal(activeSession?.goal)} -
- - { - if (canCompose) { - handleFileSelect(e, props); - } - }} - /> - - ${renderRealtimeTalkOptions(props)} - ${props.realtimeTalkActive || props.realtimeTalkDetail || props.realtimeTalkTranscript - ? html` -
- - ${props.realtimeTalkDetail ?? - ((props.realtimeTalkConversation?.length ?? 0) === 0 - ? props.realtimeTalkTranscript - : null) ?? - (props.realtimeTalkStatus === "thinking" - ? "Asking OpenClaw..." - : props.realtimeTalkStatus === "connecting" - ? "Connecting Talk..." - : "Talk live")} - - ${props.realtimeTalkStatus === "error" && props.onDismissRealtimeTalkError - ? html` - - ` - : nothing} -
- ` - : nothing} - -
- - ${activeSlashMenuOptionLabel} -
- -
-
- - - ${props.onToggleRealtimeTalk - ? html` - - ` - : nothing} - ${props.onToggleRealtimeTalkOptions - ? html` - - ` - : nothing} - ${tokens ? html`${tokens}` : nothing} - ${renderChatRunStatusIndicator(composerRunStatus)} -
- - ${composerControls && composerControls !== nothing - ? html`
${composerControls}
` - : nothing} - ${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, { - compactBusy, - compactDisabled: !canCompose || isBusy || showAbortableUi, - onCompact: props.onCompact, - })} - ${renderChatRunControls({ - canAbort: showAbortableUi, - connected: canCompose, - draft: visibleDraft, - hasMessages: props.messages.length > 0, - isBusy, - sending: props.sending, - onAbort: props.onAbort, - onExport: () => exportMarkdown(props), - onNewSession: props.onNewSession, - onSend: handleSend, - onStoreDraft: () => {}, - showSecondary: false, - })} -
-
- `; - - return html` -
{ - e.preventDefault(); - if (canCompose) { - handleDrop(e, props); - } - }} - @dragover=${(e: DragEvent) => e.preventDefault()} - @keydown=${(e: KeyboardEvent) => { - if (e.key === "Escape" && props.replyTarget && !e.defaultPrevented) { - e.preventDefault(); - props.onClearReply?.(); - } - }} - > - ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} - ${ - props.error - ? html` - - ` - : nothing - } - ${ - props.focusMode && props.onToggleFocusMode - ? html` - - ` - : nothing - } - ${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)} - -
- ${renderSessionWorkspaceRail(props.sessionWorkspace)} -
-
-
- ${thread} ${chatColumnFooter} -
- - ${ - sidebarOpen - ? html` - props.onSplitRatioChange?.(e.detail.splitRatio)} - > -
- ${renderMarkdownSidebar({ - content: props.sidebarContent ?? null, - error: props.sidebarError ?? null, - canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, - embedSandboxMode: props.embedSandboxMode ?? "scripts", - allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false, - onClose: props.onCloseSidebar!, - onViewRawText: () => { - if (!props.onOpenSidebar) { - return; - } - const rawContent = buildRawSidebarContent(props.sidebarContent); - if (rawContent) { - props.onOpenSidebar(rawContent); - } - }, - })} -
- ` - : nothing - } -
- -
-
- `; -} diff --git a/ui/src/ui/views/command-palette.test.ts b/ui/src/ui/views/command-palette.test.ts deleted file mode 100644 index cec41ec3eb1e..000000000000 --- a/ui/src/ui/views/command-palette.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -// Control UI tests cover command palette behavior. -import { nothing, render } from "lit"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "../../i18n/index.ts"; -import { refreshSlashCommands, resetSlashCommandsForTest } from "../chat/slash-commands.ts"; -import { - getFilteredPaletteItems, - getPaletteItems, - renderCommandPalette, - type CommandPaletteProps, -} from "./command-palette.ts"; - -let container: HTMLDivElement; - -const showModalDescriptor = Object.getOwnPropertyDescriptor( - HTMLDialogElement.prototype, - "showModal", -); - -function nextFrame() { - return new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); -} - -function installDialogPolyfill() { - Object.defineProperty(HTMLDialogElement.prototype, "showModal", { - configurable: true, - value(this: HTMLDialogElement) { - this.setAttribute("open", ""); - }, - }); -} - -function restoreShowModalDescriptor() { - if (showModalDescriptor) { - Object.defineProperty(HTMLDialogElement.prototype, "showModal", showModalDescriptor); - return; - } - delete (HTMLDialogElement.prototype as Partial).showModal; -} - -function expectPaletteInput(): HTMLInputElement { - const input = container.querySelector("#cmd-palette-input"); - if (!(input instanceof HTMLInputElement)) { - throw new Error("Expected command palette input"); - } - return input; -} - -function expectPaletteDialog(): HTMLDialogElement { - const dialog = container.querySelector("dialog.cmd-palette-overlay"); - if (!(dialog instanceof HTMLDialogElement)) { - throw new Error("Expected command palette dialog"); - } - return dialog; -} - -function createProps(overrides: Partial = {}): CommandPaletteProps { - return { - open: true, - query: "", - activeIndex: 0, - onToggle: () => undefined, - onQueryChange: () => undefined, - onActiveIndexChange: () => undefined, - onNavigate: () => undefined, - onSlashCommand: () => undefined, - ...overrides, - }; -} - -async function renderPalette(overrides: Partial = {}) { - const props = createProps(overrides); - render(renderCommandPalette(props), container); - await nextFrame(); - return props; -} - -beforeEach(() => { - installDialogPolyfill(); - container = document.createElement("div"); - document.body.append(container); -}); - -afterEach(async () => { - render(nothing, container); - container.remove(); - restoreShowModalDescriptor(); - vi.restoreAllMocks(); - resetSlashCommandsForTest(); - await i18n.setLocale("en"); -}); - -describe("command palette", () => { - it("builds slash items from the live runtime command list", async () => { - const request = async (method: string) => { - expect(method).toBe("commands.list"); - return { - commands: [ - { - name: "pair", - textAliases: ["/pair"], - description: "Generate setup codes and approve device pairing requests.", - source: "plugin", - scope: "both", - acceptsArgs: true, - }, - { - name: "prose", - textAliases: ["/prose"], - description: "Draft polished prose.", - source: "skill", - scope: "both", - acceptsArgs: true, - }, - ], - }; - }; - - await refreshSlashCommands({ - client: { request } as never, - agentId: "main", - }); - - const items = getPaletteItems(); - const pair = items.find((item) => item.id === "slash:pair"); - const prose = items.find((item) => item.id === "slash:prose"); - expect(pair?.label).toBe("/pair"); - expect(prose?.label).toBe("/prose"); - }); - - it("requests slash command hydration when the palette opens", async () => { - const onOpen = vi.fn(); - - await renderPalette({ onOpen }); - expect(onOpen).toHaveBeenCalledTimes(1); - - render(renderCommandPalette(createProps({ onOpen, query: "overview" })), container); - await nextFrame(); - expect(onOpen).toHaveBeenCalledTimes(1); - }); - - it("matches localized base item labels and descriptions", async () => { - await i18n.setLocale("zh-CN"); - - const configItem = getPaletteItems().find((item) => item.id === "nav-config"); - const debugItem = getFilteredPaletteItems("切换调试").find((item) => item.id === "skill-debug"); - expect(configItem?.label).toBe("设置"); - expect(debugItem?.id).toBe("skill-debug"); - }); - - it("renders a labelled modal combobox with listbox options", async () => { - await renderPalette({ query: "overview", activeIndex: 0 }); - - const dialog = container.querySelector("dialog.cmd-palette-overlay"); - expect(dialog?.open).toBe(true); - expect(dialog?.hasAttribute("role")).toBe(false); - expect(dialog?.hasAttribute("aria-modal")).toBe(false); - expect(dialog?.getAttribute("aria-labelledby")).toBe("cmd-palette-label"); - - const label = container.querySelector("#cmd-palette-label"); - const input = container.querySelector("#cmd-palette-input"); - const listbox = container.querySelector("#cmd-palette-listbox"); - expect(label?.textContent).toBe("Type a command…"); - expect(label?.getAttribute("for")).toBe("cmd-palette-input"); - expect(input?.getAttribute("role")).toBe("combobox"); - expect(input?.getAttribute("aria-autocomplete")).toBe("list"); - expect(input?.getAttribute("aria-expanded")).toBe("true"); - expect(input?.getAttribute("aria-controls")).toBe("cmd-palette-listbox"); - expect(input?.getAttribute("aria-activedescendant")).toBe("cmd-palette-option-nav-overview"); - expect(document.activeElement).toBe(input); - - expect(listbox?.getAttribute("role")).toBe("listbox"); - const option = listbox?.querySelector("#cmd-palette-option-nav-overview"); - expect(option?.getAttribute("role")).toBe("option"); - expect(option?.getAttribute("aria-selected")).toBe("true"); - }); - - it("traps Tab on the combobox and restores focus on Escape", async () => { - const returnTarget = document.createElement("button"); - returnTarget.textContent = "Open palette"; - document.body.append(returnTarget); - returnTarget.focus(); - const onToggle = vi.fn(); - - await renderPalette({ onToggle }); - const input = expectPaletteInput(); - expect(document.activeElement).toBe(input); - - const tab = new KeyboardEvent("keydown", { - key: "Tab", - bubbles: true, - cancelable: true, - }); - input.dispatchEvent(tab); - expect(tab.defaultPrevented).toBe(true); - expect(document.activeElement).toBe(input); - - const escape = new KeyboardEvent("keydown", { - key: "Escape", - bubbles: true, - cancelable: true, - }); - input.dispatchEvent(escape); - expect(escape.defaultPrevented).toBe(true); - expect(onToggle).toHaveBeenCalledTimes(1); - - await nextFrame(); - expect(document.activeElement).toBe(returnTarget); - returnTarget.remove(); - }); - - it("does not toggle twice when Escape is followed by dialog cancel", async () => { - const onToggle = vi.fn(); - await renderPalette({ onToggle }); - const dialog = expectPaletteDialog(); - const input = expectPaletteInput(); - expect(dialog.open).toBe(true); - - input.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Escape", - bubbles: true, - cancelable: true, - }), - ); - dialog.dispatchEvent(new Event("cancel", { cancelable: true })); - - expect(onToggle).toHaveBeenCalledTimes(1); - }); -}); diff --git a/ui/src/ui/views/connect-command.ts b/ui/src/ui/views/connect-command.ts deleted file mode 100644 index cb88ea347b05..000000000000 --- a/ui/src/ui/views/connect-command.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Control UI view renders connect command screen content. -import { html } from "lit"; -import { t } from "../../i18n/index.ts"; -import { renderCopyButton } from "../chat/copy-as-markdown.ts"; - -async function copyCommand(command: string) { - try { - await navigator.clipboard.writeText(command); - } catch { - // Best effort only; the explicit copy button provides visible feedback. - } -} - -export function renderConnectCommand(command: string) { - const copyLabel = t("overview.connection.copyCommand"); - return html` - - `; -} diff --git a/ui/src/ui/views/exec-approval.browser.test.ts b/ui/src/ui/views/exec-approval.browser.test.ts deleted file mode 100644 index e444adbbbe92..000000000000 --- a/ui/src/ui/views/exec-approval.browser.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Control UI tests cover exec approval behavior. -import { html, render } from "lit"; -import { expect, test } from "vitest"; -import { i18n } from "../../i18n/index.ts"; -import type { AppViewState } from "../app-view-state.ts"; -import { renderExecApprovalPrompt } from "./exec-approval.ts"; - -const root = document.createElement("div"); -document.body.append(root); - -test("renders command spans in Chromium approval modal", async () => { - await i18n.setLocale("en"); - render( - renderExecApprovalPrompt({ - execApprovalQueue: [ - { - id: "approval-browser-1", - kind: "exec", - request: { - command: 'ls | grep "stuff" | python -c \'print("hi")\'', - host: "gateway", - security: "allowlist", - ask: "always", - commandSpans: [ - { startIndex: 0, endIndex: 2 }, - { startIndex: 20, endIndex: 29 }, - ], - }, - createdAtMs: Date.now() - 1_000, - expiresAtMs: Date.now() + 60_000, - }, - ], - execApprovalBusy: false, - execApprovalError: null, - handleExecApprovalDecision: async () => undefined, - } as unknown as AppViewState), - root, - ); - - const spans = [...root.querySelectorAll(".exec-approval-command-span")].map( - (span) => span.textContent, - ); - - expect(spans).toEqual(["ls", "python -c"]); - - render(html``, root); -}); diff --git a/ui/src/ui/views/exec-approval.test.ts b/ui/src/ui/views/exec-approval.test.ts deleted file mode 100644 index 0c788c95a76c..000000000000 --- a/ui/src/ui/views/exec-approval.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -/* @vitest-environment jsdom */ - -import { nothing, render } from "lit"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "../../i18n/index.ts"; -import { getRenderedModalDialog, installDialogPolyfill } from "../../test-helpers/modal-dialog.ts"; -import { createStorageMock } from "../../test-helpers/storage.ts"; -import type { AppViewState } from "../app-view-state.ts"; -import type { ExecApprovalRequest } from "../controllers/exec-approval.ts"; -import { renderDreamingRestartConfirmation } from "./dreaming-restart-confirmation.ts"; -import { renderExecApprovalPrompt } from "./exec-approval.ts"; -import { renderGatewayUrlConfirmation } from "./gateway-url-confirmation.ts"; - -let container: HTMLDivElement; -let restoreDialogPolyfill: () => void; - -async function getRenderedDialog() { - return await getRenderedModalDialog(container); -} - -function dispatchEscape(target: EventTarget) { - target.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Escape", - bubbles: true, - cancelable: true, - composed: true, - }), - ); -} - -function createExecRequest(): ExecApprovalRequest { - return { - id: "approval-1", - kind: "exec", - request: { - command: "echo hello", - host: "gateway", - cwd: "/tmp/openclaw", - security: "workspace-write", - ask: "on-request", - }, - createdAtMs: Date.now() - 1_000, - expiresAtMs: Date.now() + 60_000, - }; -} - -function createExecState( - overrides: Partial< - Pick< - AppViewState, - "execApprovalBusy" | "execApprovalError" | "execApprovalQueue" | "handleExecApprovalDecision" - > - > = {}, -): AppViewState { - return { - execApprovalQueue: [createExecRequest()], - execApprovalBusy: false, - execApprovalError: null, - handleExecApprovalDecision: vi.fn(async () => undefined), - ...overrides, - } as unknown as AppViewState; -} - -describe("approval and confirmation modals", () => { - beforeEach(async () => { - restoreDialogPolyfill = installDialogPolyfill(); - vi.stubGlobal("localStorage", createStorageMock()); - await i18n.setLocale("en"); - container = document.createElement("div"); - document.body.append(container); - }); - - afterEach(async () => { - render(nothing, container); - container.remove(); - await i18n.setLocale("en"); - restoreDialogPolyfill(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it("renders exec approval as a labelled modal", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-29T00:00:00.000Z")); - render(renderExecApprovalPrompt(createExecState()), container); - vi.useRealTimers(); - - const { modal, dialog } = await getRenderedDialog(); - - expect(dialog.getAttribute("aria-modal")).toBe("true"); - expect(dialog.getAttribute("aria-labelledby")).toBe("openclaw-modal-dialog-label"); - expect(dialog.getAttribute("aria-describedby")).toBe("openclaw-modal-dialog-description"); - expect(modal.shadowRoot?.querySelector("#openclaw-modal-dialog-label")?.textContent).toBe( - "Exec approval needed", - ); - expect( - modal.shadowRoot?.querySelector("#openclaw-modal-dialog-description")?.textContent?.trim(), - ).toBe("expires in 1m"); - expect(container.querySelector("#exec-approval-title")?.textContent?.trim()).toBe( - "Exec approval needed", - ); - expect(container.querySelector("#exec-approval-description")?.textContent?.trim()).toBe( - "expires in 1m", - ); - }); - - it("renders command spans in exec approvals", async () => { - const request = createExecRequest(); - request.request.command = 'ls | grep "stuff" | python -c \'print("hi")\''; - request.request.commandSpans = [ - { startIndex: 0, endIndex: 2 }, - { startIndex: 5, endIndex: 5 }, - { startIndex: 8.5, endIndex: 10 }, - { startIndex: 20, endIndex: 29 }, - { startIndex: 30, endIndex: 200 }, - ]; - - render(renderExecApprovalPrompt(createExecState({ execApprovalQueue: [request] })), container); - - await getRenderedDialog(); - - const spans = [...container.querySelectorAll(".exec-approval-command-span")].map( - (span) => span.textContent, - ); - expect(spans).toEqual(["ls", "python -c"]); - }); - - it("does not render a visible neutral dismiss action", async () => { - render(renderExecApprovalPrompt(createExecState()), container); - - await getRenderedDialog(); - - expect( - Array.from(container.querySelectorAll(".exec-approval-actions button")).map((button) => - button.textContent?.trim(), - ), - ).toEqual(["Allow once", "Always allow", "Deny"]); - }); - - it("hides unavailable exec approval decisions", async () => { - const request = createExecRequest(); - request.request.ask = "always"; - request.request.allowedDecisions = ["allow-once", "deny"]; - - render(renderExecApprovalPrompt(createExecState({ execApprovalQueue: [request] })), container); - - await getRenderedDialog(); - - expect( - Array.from(container.querySelectorAll(".exec-approval-actions button")).map((button) => - button.textContent?.trim(), - ), - ).toEqual(["Allow once", "Deny"]); - expect(container.querySelector(".exec-approval-warning")?.textContent?.trim()).toBe( - "The effective approval policy requires approval every time, so Allow Always is unavailable.", - ); - }); - - it("falls back to ask when exec approval decisions are omitted", async () => { - const request = createExecRequest(); - request.request.ask = "always"; - request.request.allowedDecisions = undefined; - - render(renderExecApprovalPrompt(createExecState({ execApprovalQueue: [request] })), container); - - await getRenderedDialog(); - - expect( - Array.from(container.querySelectorAll(".exec-approval-actions button")).map((button) => - button.textContent?.trim(), - ), - ).toEqual(["Allow once", "Deny"]); - }); - - it("keeps durable exec approval when the request allows it", async () => { - const request = createExecRequest(); - request.request.allowedDecisions = ["allow-once", "allow-always", "deny"]; - - render(renderExecApprovalPrompt(createExecState({ execApprovalQueue: [request] })), container); - - await getRenderedDialog(); - - expect( - Array.from(container.querySelectorAll(".exec-approval-actions button")).map((button) => - button.textContent?.trim(), - ), - ).toEqual(["Allow once", "Always allow", "Deny"]); - expect(container.querySelector(".exec-approval-warning")).toBeNull(); - }); - - it("does not show exec policy warning for restricted plugin approvals", async () => { - const request: ExecApprovalRequest = { - id: "plugin-approval-1", - kind: "plugin", - request: { - command: "Plugin approval", - allowedDecisions: ["allow-once", "deny"], - }, - pluginTitle: "Plugin approval", - createdAtMs: Date.now() - 1_000, - expiresAtMs: Date.now() + 60_000, - }; - - render(renderExecApprovalPrompt(createExecState({ execApprovalQueue: [request] })), container); - - await getRenderedDialog(); - - expect( - Array.from(container.querySelectorAll(".exec-approval-actions button")).map((button) => - button.textContent?.trim(), - ), - ).toEqual(["Allow once", "Deny"]); - expect(container.querySelector(".exec-approval-warning")).toBeNull(); - }); - - it("maps Escape to exec denial when approval is idle", async () => { - const handleExecApprovalDecision = vi.fn(async () => undefined); - render(renderExecApprovalPrompt(createExecState({ handleExecApprovalDecision })), container); - - const { dialog } = await getRenderedDialog(); - - dispatchEscape(dialog); - - expect(handleExecApprovalDecision).toHaveBeenCalledWith("deny"); - }); - - it("does not dispatch an extra exec decision from Escape while busy", async () => { - const handleExecApprovalDecision = vi.fn(async () => undefined); - render( - renderExecApprovalPrompt( - createExecState({ execApprovalBusy: true, handleExecApprovalDecision }), - ), - container, - ); - - const { dialog } = await getRenderedDialog(); - dispatchEscape(dialog); - - expect(handleExecApprovalDecision).not.toHaveBeenCalled(); - }); - - it("does not dispatch denied from Escape when denial is unavailable", async () => { - const request = createExecRequest(); - request.request.allowedDecisions = ["allow-once"]; - const handleExecApprovalDecision = vi.fn(async () => undefined); - render( - renderExecApprovalPrompt( - createExecState({ execApprovalQueue: [request], handleExecApprovalDecision }), - ), - container, - ); - - const { dialog } = await getRenderedDialog(); - dispatchEscape(dialog); - - expect(handleExecApprovalDecision).not.toHaveBeenCalled(); - }); - - it("renders exec approval chrome from the active locale", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-29T00:00:00.000Z")); - await i18n.setLocale("zh-CN"); - const active: ExecApprovalRequest = { - id: "approval-1", - kind: "exec", - request: { - command: "pnpm check:changed", - host: "gateway", - agentId: "main", - sessionKey: "main", - cwd: "/tmp/project", - resolvedPath: "/tmp/project", - security: "workspace-write", - ask: "on-request", - }, - createdAtMs: Date.now(), - expiresAtMs: Date.now() + 61_000, - }; - const queued: ExecApprovalRequest = { - ...active, - id: "approval-2", - createdAtMs: Date.now() + 1, - expiresAtMs: Date.now() + 62_000, - }; - - render( - renderExecApprovalPrompt(createExecState({ execApprovalQueue: [active, queued] })), - container, - ); - - expect(container.querySelector("#exec-approval-title")?.textContent?.trim()).toBe( - "需要 Exec 审批", - ); - expect(container.querySelector("#exec-approval-description")?.textContent?.trim()).toBe( - "1m 后过期", - ); - expect(container.querySelector(".exec-approval-queue")?.textContent?.trim()).toBe("2 个待处理"); - expect(container.querySelector(".exec-approval-command")?.textContent?.trim()).toBe( - "pnpm check:changed", - ); - expect( - Array.from(container.querySelectorAll(".exec-approval-meta-row")).map((row) => { - const [label, value] = Array.from(row.querySelectorAll("span")).map((span) => - span.textContent?.trim(), - ); - return { label, value }; - }), - ).toEqual([ - { label: "主机", value: "gateway" }, - { label: "代理", value: "main" }, - { label: "会话", value: "main" }, - { label: "CWD", value: "/tmp/project" }, - { label: "已解析", value: "/tmp/project" }, - { label: "安全", value: "workspace-write" }, - { label: "询问策略", value: "on-request" }, - ]); - expect( - Array.from(container.querySelectorAll(".exec-approval-actions button")).map((button) => - button.textContent?.trim(), - ), - ).toEqual(["允许一次", "始终允许", "拒绝"]); - }); - - it("uses the shared modal primitive for gateway URL confirmation and cancels on Escape", async () => { - const handleGatewayUrlCancel = vi.fn(); - render( - renderGatewayUrlConfirmation({ - pendingGatewayUrl: "wss://gateway.example/openclaw", - handleGatewayUrlConfirm: vi.fn(), - handleGatewayUrlCancel, - } as unknown as AppViewState), - container, - ); - - const { dialog } = await getRenderedDialog(); - - dispatchEscape(dialog); - - expect(handleGatewayUrlCancel).toHaveBeenCalledTimes(1); - }); - - it("uses the shared modal primitive for dreaming restart confirmation and cancels on Escape", async () => { - const onCancel = vi.fn(); - render( - renderDreamingRestartConfirmation({ - open: true, - loading: false, - onConfirm: vi.fn(), - onCancel, - hasError: false, - }), - container, - ); - - const { dialog } = await getRenderedDialog(); - - dispatchEscape(dialog); - - expect(onCancel).toHaveBeenCalledTimes(1); - }); - - it("does not cancel dreaming restart from Escape while loading", async () => { - const onCancel = vi.fn(); - render( - renderDreamingRestartConfirmation({ - open: true, - loading: true, - onConfirm: vi.fn(), - onCancel, - hasError: false, - }), - container, - ); - - const { dialog } = await getRenderedDialog(); - dispatchEscape(dialog); - - expect(onCancel).not.toHaveBeenCalled(); - }); -}); diff --git a/ui/src/ui/views/gateway-url-confirmation.ts b/ui/src/ui/views/gateway-url-confirmation.ts deleted file mode 100644 index 0d62b9756ca7..000000000000 --- a/ui/src/ui/views/gateway-url-confirmation.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Control UI view renders gateway url confirmation screen content. -import { html, nothing } from "lit"; -import { t } from "../../i18n/index.ts"; -import type { AppViewState } from "../app-view-state.ts"; -import "../components/modal-dialog.ts"; - -export function renderGatewayUrlConfirmation(state: AppViewState) { - const { pendingGatewayUrl } = state; - if (!pendingGatewayUrl) { - return nothing; - } - const titleId = "gateway-url-confirmation-title"; - const descriptionId = "gateway-url-confirmation-description"; - const title = t("channels.gatewayUrlConfirmation.title"); - const description = t("channels.gatewayUrlConfirmation.subtitle"); - - return html` - state.handleGatewayUrlCancel()} - > -
-
-
-
${title}
-
${description}
-
-
-
${pendingGatewayUrl}
-
- ${t("channels.gatewayUrlConfirmation.warning")} -
-
- - -
-
-
- `; -} diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts deleted file mode 100644 index 5864f85d7ac9..000000000000 --- a/ui/src/ui/views/login-gate.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -/* @vitest-environment jsdom */ - -import { render } from "lit"; -import { beforeEach, describe, expect, it } from "vitest"; -import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; -import { i18n } from "../../i18n/index.ts"; -import type { AppViewState } from "../app-view-state.ts"; -import { renderLoginGate, resolveLoginFailureFeedback } from "./login-gate.ts"; - -function createState(overrides: Partial = {}): AppViewState { - return { - basePath: "", - connected: false, - lastError: null, - lastErrorCode: null, - loginShowGatewayToken: false, - loginShowGatewayPassword: false, - password: "", - settings: { - gatewayUrl: "ws://127.0.0.1:18789", - token: "", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatShowThinking: true, - chatShowToolCalls: true, - splitRatio: 0.6, - navCollapsed: false, - navWidth: 220, - navGroupsCollapsed: {}, - borderRadius: 50, - locale: "en", - }, - applySettings: () => undefined, - connect: () => undefined, - ...overrides, - } as unknown as AppViewState; -} - -describe("resolveLoginFailureFeedback", () => { - beforeEach(async () => { - await i18n.setLocale("en"); - }); - - it("explains missing auth credentials", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "disconnected (4008): connect failed", - lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, - hasToken: false, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("auth-required"); - expect(feedback?.title).toBe("Auth required"); - expect(feedback?.summary).toBe( - "The Gateway is reachable, but it needs a matching token or password before this browser can connect.", - ); - expect(feedback?.steps).toEqual([ - "Paste the token from openclaw dashboard --no-open or enter the configured password.", - "If no token is configured, run openclaw doctor --generate-gateway-token on the gateway host.", - "Click Connect again after updating the credential.", - ]); - }); - - it("explains rejected stale credentials", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "unauthorized: gateway token mismatch", - lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, - hasToken: true, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("auth-failed"); - expect(feedback?.summary).toBe( - "The supplied credential was rejected. The most common cause is a stale token or a token copied from another Gateway URL.", - ); - expect(feedback?.steps).toEqual([ - "Run openclaw dashboard --no-open and open the fresh URL or paste its token.", - "Replace stale token/password values; do not reuse a token from another Gateway URL.", - "Use one matching auth mode at a time: gateway token for token mode, password for password mode.", - ]); - }); - - it("explains auth rate limits without encouraging retries", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "too many failed authentication attempts", - lastErrorCode: ConnectErrorDetailCodes.AUTH_RATE_LIMITED, - hasToken: true, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("auth-rate-limited"); - expect(feedback?.title).toBe("Too many failed attempts"); - expect(feedback?.steps).toEqual([ - "Stop retrying from this tab for a moment.", - "Wait for the auth limiter to cool down, then reconnect with the corrected credential.", - "If this is a shared host, check other clients for repeated bad retries.", - ]); - }); - - it("preserves pairing request ids in the approval command", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "scope upgrade pending approval (requestId: req-123)", - lastErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED, - hasToken: true, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("pairing-required"); - expect(feedback?.title).toBe("Scope upgrade pending"); - expect(feedback?.summary).toBe( - "This browser is already known, but the requested access changed and needs a fresh approval.", - ); - expect(feedback?.steps).toEqual([ - "Run openclaw devices list on the Gateway host.", - "Approve this request: openclaw devices approve req-123.", - "Reconnect after the approval completes.", - ]); - }); - - it("explains insecure HTTP device identity failures", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "device identity required", - lastErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - hasToken: true, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("insecure-context"); - expect(feedback?.steps).toEqual([ - "Use HTTPS/Tailscale Serve, or open http://127.0.0.1:18789 on the Gateway host.", - "For local token-only compatibility, set gateway.controlUi.allowInsecureAuth: true.", - "Avoid disabling device auth for remote HTTP access.", - ]); - }); - - it("explains browser WebSocket security failures as insecure context", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: - "Browser refused the Gateway WebSocket for security reasons. Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789.", - lastErrorCode: "BROWSER_WEBSOCKET_SECURITY_ERROR", - hasToken: true, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("insecure-context"); - expect(feedback?.rawError).toBe( - "Browser refused the Gateway WebSocket for security reasons. Use wss:// when the Control UI is served over HTTPS/Tailscale Serve, or open the loopback dashboard at http://127.0.0.1:18789.", - ); - expect(feedback?.steps).toEqual([ - "Use HTTPS/Tailscale Serve, or open http://127.0.0.1:18789 on the Gateway host.", - "For local token-only compatibility, set gateway.controlUi.allowInsecureAuth: true.", - "Avoid disabling device auth for remote HTTP access.", - ]); - }); - - it("keeps generic browser WebSocket constructor failures on the network path", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "Could not create the Gateway WebSocket: constructor failed", - lastErrorCode: "BROWSER_WEBSOCKET_CONSTRUCTOR_ERROR", - hasToken: false, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("network"); - expect(feedback?.steps).toEqual([ - "Confirm the Gateway is running with openclaw status or openclaw gateway run.", - "Check the WebSocket URL and use wss:// when the Gateway is behind HTTPS/Tailscale Serve.", - "Reopen the dashboard with openclaw dashboard --no-open to recopy the current URL and auth details.", - ]); - }); - - it("explains browser origin rejections", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "origin not allowed", - lastErrorCode: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, - hasToken: true, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("origin-not-allowed"); - expect(feedback?.steps).toEqual([ - "Add this browser origin to gateway.controlUi.allowedOrigins.", - "Use full origins such as http://localhost:5173, not wildcard patterns.", - "Restart or reload the Gateway after changing allowed origins.", - ]); - }); - - it("explains protocol mismatch without requiring a gateway protocol change", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "protocol mismatch", - lastErrorCode: null, - hasToken: true, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("protocol-mismatch"); - expect(feedback?.summary).toBe( - "The served Control UI and the running Gateway do not agree on the supported connection protocol.", - ); - expect(feedback?.steps).toEqual([ - "Reopen the served dashboard with openclaw dashboard so the UI and Gateway come from the same install.", - "If using pnpm ui:dev, rebuild or restart the dev UI against the current checkout.", - "Restart the Gateway after updating OpenClaw so it serves the current protocol.", - ]); - }); - - it("falls back to connection diagnostics for generic close errors", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: "disconnected (1006): no reason", - lastErrorCode: null, - hasToken: false, - hasPassword: false, - }); - - expect(feedback?.kind).toBe("network"); - expect(feedback?.steps).toEqual([ - "Confirm the Gateway is running with openclaw status or openclaw gateway run.", - "Check the WebSocket URL and use wss:// when the Gateway is behind HTTPS/Tailscale Serve.", - "Reopen the dashboard with openclaw dashboard --no-open to recopy the current URL and auth details.", - ]); - }); - - it("redacts credential-shaped values from displayed raw errors", () => { - const feedback = resolveLoginFailureFeedback({ - connected: false, - lastError: - "failed ws://host/openclaw#token=secret-token Authorization: Bearer secret-bearer token=inline-secret", - lastErrorCode: null, - hasToken: false, - hasPassword: false, - }); - - expect(feedback?.rawError).toBe( - "failed ws://host/openclaw#[redacted-credential] Authorization: Bearer [redacted] token=[redacted]", - ); - }); -}); - -describe("renderLoginGate", () => { - beforeEach(async () => { - await i18n.setLocale("en"); - }); - - it("renders an accessible structured failure panel with raw error details", async () => { - const container = document.createElement("div"); - const state = createState({ - lastError: "protocol mismatch", - settings: { - ...createState().settings, - token: "stale-token", - }, - }); - - render(renderLoginGate(state), container); - await Promise.resolve(); - - const alert = container.querySelector('[role="alert"]'); - expect(alert?.dataset.kind).toBe("protocol-mismatch"); - expect(alert?.querySelector(".login-gate__failure-title")?.textContent?.trim()).toBe( - "Protocol mismatch", - ); - expect(alert?.querySelector(".login-gate__failure-summary")?.textContent?.trim()).toBe( - "The served Control UI and the running Gateway do not agree on the supported connection protocol.", - ); - expect( - Array.from(alert?.querySelectorAll(".login-gate__failure-steps li") ?? []).map((step) => - step.textContent?.trim(), - ), - ).toEqual([ - "Reopen the served dashboard with openclaw dashboard so the UI and Gateway come from the same install.", - "If using pnpm ui:dev, rebuild or restart the dev UI against the current checkout.", - "Restart the Gateway after updating OpenClaw so it serves the current protocol.", - ]); - expect(alert?.querySelector("details summary")?.textContent?.trim()).toBe("Raw error"); - expect(alert?.querySelector(".login-gate__failure-raw")?.textContent?.trim()).toBe( - "protocol mismatch", - ); - - const docsLink = alert?.querySelector(".login-gate__failure-docs"); - expect(docsLink?.textContent?.trim()).toBe("Control UI auth docs"); - expect(docsLink?.getAttribute("href")).toBe( - "https://docs.openclaw.ai/web/control-ui#debuggingtesting-dev-server--remote-gateway", - ); - }); -}); diff --git a/ui/src/ui/views/markdown-sidebar.ts b/ui/src/ui/views/markdown-sidebar.ts deleted file mode 100644 index fa3a5e60739e..000000000000 --- a/ui/src/ui/views/markdown-sidebar.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Control UI view renders markdown sidebar screen content. -import { html, nothing } from "lit"; -import { keyed } from "lit/directives/keyed.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { resolveCanvasIframeUrl } from "../canvas-url.ts"; -import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts"; -import { icons } from "../icons.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; -import type { SidebarContent } from "../sidebar-content.ts"; - -function resolveSidebarCanvasSandbox( - content: SidebarContent, - embedSandboxMode: EmbedSandboxMode, -): string { - return content.kind === "canvas" ? resolveEmbedSandbox(embedSandboxMode) : "allow-scripts"; -} - -export type MarkdownSidebarProps = { - content: SidebarContent | null; - error: string | null; - onClose: () => void; - onViewRawText: () => void; - canvasPluginSurfaceUrl?: string | null; - embedSandboxMode?: EmbedSandboxMode; - allowExternalEmbedUrls?: boolean; -}; - -export function renderMarkdownSidebar(props: MarkdownSidebarProps) { - const content = props.content; - const markdownHtml = - content?.kind === "markdown" && content.content.trim() - ? toSanitizedMarkdownHtml(content.content) - : ""; - const canvasSandbox = - content?.kind === "canvas" - ? resolveSidebarCanvasSandbox(content, props.embedSandboxMode ?? "scripts") - : ""; - const canvasSrc = - content?.kind === "canvas" - ? resolveCanvasIframeUrl( - content.entryUrl, - props.canvasPluginSurfaceUrl, - props.allowExternalEmbedUrls ?? false, - ) - : null; - const title = - content?.kind === "canvas" - ? content.title?.trim() || "Render Preview" - : content?.kind === "image" - ? content.title.trim() || "Image Preview" - : content?.kind === "markdown" - ? "Markdown Preview" - : "Tool Details"; - return html` - - `; -} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts deleted file mode 100644 index 8d7daf670f13..000000000000 --- a/ui/src/ui/views/overview.ts +++ /dev/null @@ -1,483 +0,0 @@ -// Control UI view renders overview screen content. -import { html, nothing } from "lit"; -import { t, i18n, SUPPORTED_LOCALES, type Locale, isSupportedLocale } from "../../i18n/index.ts"; -import type { EventLogEntry } from "../app-events.ts"; -import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; -import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; -import type { GatewayHelloOk } from "../gateway.ts"; -import { icons } from "../icons.ts"; -import { resolveGatewayTokenForUrlEdit, type UiSettings } from "../storage.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; -import type { - AttentionItem, - CronJob, - CronStatus, - ModelAuthStatusResult, - SessionsListResult, - SessionsUsageResult, - SkillStatusReport, -} from "../types.ts"; -import { renderConnectCommand } from "./connect-command.ts"; -import { renderOverviewAttention } from "./overview-attention.ts"; -import { renderOverviewCards } from "./overview-cards.ts"; -import { renderOverviewEventLog } from "./overview-event-log.ts"; -import { - resolveAuthHintKind, - type PairingHint, - resolvePairingHint, - shouldShowInsecureContextHint, -} from "./overview-hints.ts"; -import { renderOverviewLogTail } from "./overview-log-tail.ts"; - -export type OverviewProps = { - connected: boolean; - hello: GatewayHelloOk | null; - settings: UiSettings; - password: string; - lastError: string | null; - lastErrorCode: string | null; - presenceCount: number; - sessionsCount: number | null; - cronEnabled: boolean | null; - cronNext: number | null; - lastChannelsRefresh: number | null; - warnQueryToken: boolean; - // New dashboard data - modelAuthStatus: ModelAuthStatusResult | null; - usageResult: SessionsUsageResult | null; - sessionsResult: SessionsListResult | null; - skillsReport: SkillStatusReport | null; - cronJobs: CronJob[]; - cronStatus: CronStatus | null; - attentionItems: AttentionItem[]; - eventLog: EventLogEntry[]; - overviewLogLines: string[]; - showGatewayToken: boolean; - showGatewayPassword: boolean; - onSettingsChange: (next: UiSettings) => void; - onPasswordChange: (next: string) => void; - onSessionKeyChange: (next: string) => void; - onToggleGatewayTokenVisibility: () => void; - onToggleGatewayPasswordVisibility: () => void; - onConnect: () => void; - onRefresh: () => void; - onNavigate: (tab: string) => void; - onRefreshLogs: () => void; -}; - -const PAIRING_HINT_COPY: Record< - PairingHint["kind"], - { - titleKey: string | null; - summaryKey: string | null; - } -> = { - "pairing-required": { - titleKey: null, - summaryKey: null, - }, - "scope-upgrade-pending": { - titleKey: "overview.pairing.scopeUpgradeTitle", - summaryKey: "overview.pairing.scopeUpgradeSummary", - }, - "role-upgrade-pending": { - titleKey: "overview.pairing.roleUpgradeTitle", - summaryKey: "overview.pairing.roleUpgradeSummary", - }, - "metadata-upgrade-pending": { - titleKey: "overview.pairing.metadataUpgradeTitle", - summaryKey: "overview.pairing.metadataUpgradeSummary", - }, -}; - -export function renderOverview(props: OverviewProps) { - const snapshot = props.hello?.snapshot as - | { - uptimeMs?: number; - authMode?: "none" | "token" | "password" | "trusted-proxy"; - } - | undefined; - const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); - const tickIntervalMs = props.hello?.policy?.tickIntervalMs; - const tick = tickIntervalMs - ? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s` - : t("common.na"); - const authMode = snapshot?.authMode; - const isTrustedProxy = authMode === "trusted-proxy"; - - const pairingHint = (() => { - const pairingState = resolvePairingHint(props.connected, props.lastError, props.lastErrorCode); - if (!pairingState) { - return null; - } - const copy = PAIRING_HINT_COPY[pairingState.kind]; - const title = copy.titleKey ? t(copy.titleKey) : t("overview.pairing.hint"); - return html` -
- ${title} - ${copy.summaryKey - ? html`
${t(copy.summaryKey)}
` - : nothing} -
- ${pairingState.requestId - ? html`openclaw devices approve ${pairingState.requestId}
` - : nothing} - openclaw devices list -
-
${t("overview.pairing.mobileHint")}
- -
- `; - })(); - - const authHint = (() => { - const authHintKind = resolveAuthHintKind({ - connected: props.connected, - lastError: props.lastError, - lastErrorCode: props.lastErrorCode, - hasToken: Boolean(props.settings.token.trim()), - hasPassword: Boolean(props.password.trim()), - }); - if (authHintKind == null) { - return null; - } - if (authHintKind === "required") { - return html` -
- ${t("overview.auth.required")} -
- openclaw dashboard --no-open → tokenized URL
- openclaw doctor --generate-gateway-token → set token -
- -
- `; - } - return html` -
- ${t("overview.auth.failed", { command: "openclaw dashboard --no-open" })} - -
- `; - })(); - - const insecureContextHint = (() => { - if (props.connected || !props.lastError) { - return null; - } - const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true; - if (isSecureContext) { - return null; - } - if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) { - return null; - } - return html` -
- ${t("overview.insecure.hint", { url: "http://127.0.0.1:18789" })} -
- ${t("overview.insecure.stayHttp", { - config: "gateway.controlUi.allowInsecureAuth: true", - })} -
- -
- `; - })(); - - const queryTokenHint = (() => { - if (props.connected || !props.lastError || !props.warnQueryToken) { - return null; - } - const lower = normalizeLowercaseStringOrEmpty(props.lastError); - const authFailed = lower.includes("unauthorized") || lower.includes("device identity required"); - if (!authFailed) { - return null; - } - return html` -
- Auth token must be passed as a URL fragment: - #token=<token>. Query parameters (?token=) may appear in server logs. -
- `; - })(); - - const currentLocale = isSupportedLocale(props.settings.locale) - ? props.settings.locale - : i18n.getLocale(); - - return html` -
-
-
${t("overview.access.title")}
-
${t("overview.access.subtitle")}
-
- - ${isTrustedProxy - ? "" - : html` - - - `} - - -
-
- - - ${isTrustedProxy - ? t("overview.access.trustedProxy") - : t("overview.access.connectHint")} -
- ${!props.connected - ? html` - - ` - : nothing} -
- -
-
${t("overview.snapshot.title")}
-
${t("overview.snapshot.subtitle")}
-
-
-
${t("overview.snapshot.status")}
-
- ${props.connected ? t("common.ok") : t("common.offline")} -
-
-
-
${t("overview.snapshot.uptime")}
-
${uptime}
-
-
-
${t("overview.snapshot.tickInterval")}
-
${tick}
-
-
-
${t("overview.snapshot.lastChannelsRefresh")}
-
- ${props.lastChannelsRefresh - ? formatRelativeTimestamp(props.lastChannelsRefresh) - : t("common.na")} -
-
-
- ${props.lastError - ? html`
-
${props.lastError}
- ${pairingHint ?? ""} ${authHint ?? ""} ${insecureContextHint ?? ""} - ${queryTokenHint ?? ""} -
` - : html` -
- ${t("overview.snapshot.channelsHint")} -
- `} -
-
- -
- - ${renderOverviewCards({ - usageResult: props.usageResult, - sessionsResult: props.sessionsResult, - skillsReport: props.skillsReport, - cronJobs: props.cronJobs, - cronStatus: props.cronStatus, - modelAuthStatus: props.modelAuthStatus, - presenceCount: props.presenceCount, - onNavigate: props.onNavigate, - })} - ${renderOverviewAttention({ items: props.attentionItems })} - -
- -
- ${renderOverviewEventLog({ - events: props.eventLog, - })} - ${renderOverviewLogTail({ - lines: props.overviewLogLines, - onRefreshLogs: props.onRefreshLogs, - })} -
- `; -} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index a9488c357856..5ec94857aa28 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -160,6 +160,15 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] { ]; } +export function resolveExternalPackageAliasesForVite(): ControlUiViteAlias[] { + return [ + { + find: "@openclaw/uirouter", + replacement: path.join(repoRoot, "node_modules", "@openclaw", "uirouter", "dist", "index.js"), + }, + ]; +} + export function resolveTsconfigPathAliasesForVite(): ControlUiViteAlias[] { const raw = fs.readFileSync(path.join(repoRoot, "tsconfig.json"), "utf8"); const parsed = JSON.parse(raw) as { @@ -184,7 +193,7 @@ function normalizeViteImporterPath(importer: string): string { } export function controlUiBrowserOnlySharedModuleAliases(): Plugin { - const browserRedactPath = path.join(here, "src/ui/browser-redact.ts"); + const browserRedactPath = path.join(here, "src/lib/browser-redact.ts"); const sharedRedactImporters = new Set([ path.join(repoRoot, "src/agents/tool-display-common.ts"), path.join(repoRoot, "src/agents/tool-display-exec.ts"), @@ -213,7 +222,7 @@ function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin { closeBundle() { const swPath = path.join(outDir, "sw.js"); const publicSwPath = path.join(here, "public/sw.js"); - const source = fs.readFileSync(fs.existsSync(swPath) ? swPath : publicSwPath, "utf8"); + const source = fs.readFileSync(publicSwPath, "utf8"); const placeholder = '"__OPENCLAW_CONTROL_UI_BUILD_ID__"'; const updated = source.replace(placeholder, JSON.stringify(buildId)); if (updated === source) { @@ -228,7 +237,8 @@ function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin { export default function controlUiViteConfig(): UserConfig { const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim(); const base = envBase ? normalizeBase(envBase) : "./"; - const bootstrapConfigPath = base === "./" ? "/control-ui-config.json" : `${base}control-ui-config.json`; + const bootstrapConfigPath = + base === "./" ? "/control-ui-config.json" : `${base}control-ui-config.json`; const controlUiBuildId = resolveControlUiBuildId(); return { base, @@ -247,6 +257,7 @@ export default function controlUiViteConfig(): UserConfig { resolve: { alias: [ { find: "json5", replacement: json5EsmPath }, + ...resolveExternalPackageAliasesForVite(), ...resolveSourcePackageAliasesForVite(), ...resolveTsconfigPathAliasesForVite(), ], diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 723536c8eccf..ee0225db63a3 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -16,7 +16,7 @@ const repoRoot = path.resolve(here, ".."); const workspaceSourceAliases = [ { find: "../logging/redact.js", - replacement: path.resolve(here, "src/ui/browser-redact.ts"), + replacement: path.resolve(here, "src/lib/browser-redact.ts"), }, { find: "openclaw/plugin-sdk/test-fixtures", @@ -62,7 +62,7 @@ const sharedUiTestConfig = { const nodeDrivenBrowserLayoutTests = [ "src/ui/chat/sidebar-session-picker.browser.test.ts", "src/ui/chat/chat-responsive.browser.test.ts", - "src/ui/form-controls.browser.test.ts", + "src/components/form-controls.browser.test.ts", "src/ui/views/sessions.browser.test.ts", ] as const; const chromiumExecutableOverrideEnvKey = "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH";