diff --git a/.gitignore b/.gitignore index 4defa8acb33..4f8abcaa94f 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,11 @@ dist/protocol.schema.json # Synthing **/.stfolder/ .dev-state +docs/superpowers/plans/2026-03-10-collapsed-side-nav.md +docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md +.gitignore +test/config-form.analyze.telegram.test.ts +ui/src/ui/theme-variants.browser.test.ts +ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png +ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png +ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png diff --git a/AGENTS.md b/AGENTS.md index 69b0df68faa..45eed9ec2ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ - Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys. +- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse"). ## Release Channels (Naming) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3dce87855e..7e15a973c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ Docs: https://docs.openclaw.ai - Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff - Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi +- Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev. ### Fixes +- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc. +- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc. - Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (`GHSA-99qw-6mr3-36qr`)(#44174) Thanks @lintsinghua and @vincentkoc. - Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt. - Kimi Coding/provider config: respect explicit `models.providers["kimi-coding"].baseUrl` when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin. @@ -32,6 +35,8 @@ Docs: https://docs.openclaw.ai - Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc. - Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc. - Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc. +- Security/commands: require sender ownership for `/config` and `/debug` so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (`GHSA-r7vr-gr74-94p8`)(#44305) Thanks @tdjackey and @vincentkoc. +- Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (`GHSA-rqpp-rjj8-7wv8`)(#44306) Thanks @LUOYEcode and @vincentkoc. - Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc. - Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc. - Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`. (`GHSA-wcxr-59v9-rxr8`)(#43754) Thanks @tdjackey and @vincentkoc. @@ -44,8 +49,19 @@ Docs: https://docs.openclaw.ai - Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc. - Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc. - Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth `429` responses. (`GHSA-5m9r-p9g7-679c`)(#44173) Thanks @zpbrent and @vincentkoc. +- Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap `pnpm`/`npm exec`/`npx` script runners before approval binding. (`GHSA-57jw-9722-6rf2`)(`GHSA-jvqh-rfmh-jh27`)(`GHSA-x7pp-23xv-mmr4`)(`GHSA-jc5j-vg4r-j5jx`)(#44247) Thanks @tdjackey and @vincentkoc. - Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman. - Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub. +- Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman. +- Context engine/session routing: forward optional `sessionKey` through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman. +- Agents/failover: classify z.ai `network_error` stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev. +- Memory/session sync: add mode-aware post-compaction session reindexing with `agents.defaults.compaction.postIndexSync` plus `agents.defaults.memorySearch.sync.sessions.postCompactionForce`, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz. +- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. +- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666. +- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras. +- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras. +- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write. +- Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted. ## 2026.3.11 @@ -137,6 +153,7 @@ Docs: https://docs.openclaw.ai - Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting. - Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth. - Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set. +- Sandbox/sessions_spawn: restore real workspace handoff for read-only sandboxed sessions so spawned subagents mount the configured workspace at `/agent` instead of inheriting the sandbox copy. Related #40582. - Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. - Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. - Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey. @@ -178,6 +195,8 @@ Docs: https://docs.openclaw.ai - Status/context windows: normalize provider-qualified override cache keys so `/status` resolves the active provider's configured context window even when `models.providers` keys use mixed case or surrounding whitespace. (#36389) Thanks @haoruilee. - ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692) - Agents/embedded runner: recover canonical allowlisted tool names from malformed `toolCallId` and malformed non-blank tool-name variants before dispatch, while failing closed on ambiguous matches. (#34485) thanks @yuweuii. +- Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke. +- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode. ## 2026.3.8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7808db9cf8..a4bb0e17361 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,6 +92,7 @@ Welcome to the lobster tank! 🦞 - Describe what & why - Reply to or resolve bot review conversations you addressed before asking for review again - **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes) +- Use American English spelling and grammar in code, comments, docs, and UI strings ## Review Conversations Are Author-Owned diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b26cefeb11c..b4a697d5a5a 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2198,7 +2198,7 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi { id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 192000, @@ -2238,7 +2238,7 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on { id: "MiniMax-M2.5", name: "MiniMax M2.5", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 }, contextWindow: 200000, diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index f060c637de8..8cdc5b028f6 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -151,7 +151,7 @@ Configure manually via `openclaw.json`: { id: "minimax-m2.5-gs32", name: "MiniMax M2.5 GS32", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 196608, diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 1548ce5496a..1ddd1d9ceef 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -69,6 +69,7 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format"); + expect(resolveFailoverReasonFromError({ status: 422 })).toBe("format"); // Keep the status-only path behavior-preserving and conservative. expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull(); expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout"); @@ -162,6 +163,44 @@ describe("failover-error", () => { ).toBe("billing"); }); + it("treats HTTP 422 as format error", () => { + expect( + resolveFailoverReasonFromError({ + status: 422, + message: "check open ai req parameter error", + }), + ).toBe("format"); + expect( + resolveFailoverReasonFromError({ + status: 422, + message: "Unprocessable Entity", + }), + ).toBe("format"); + }); + + it("treats 422 with billing message as billing instead of format", () => { + expect( + resolveFailoverReasonFromError({ + status: 422, + message: "insufficient credits", + }), + ).toBe("billing"); + }); + + it("classifies OpenRouter 'requires more credits' text as billing", () => { + expect( + resolveFailoverReasonFromError({ + message: "This model requires more credits to use", + }), + ).toBe("billing"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message: "This model require more credits", + }), + ).toBe("billing"); + }); + it("treats zhipuai weekly/monthly limit exhausted as rate_limit", () => { expect( resolveFailoverReasonFromError({ @@ -204,6 +243,13 @@ describe("failover-error", () => { message: "Workspace spend limit reached. Contact your admin.", }), ).toBe("rate_limit"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message: + "You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access. Learn more: https://zenmux.ai/docs/guide/subscription.html", + }), + ).toBe("rate_limit"); expect( resolveFailoverReasonFromError({ status: 402, @@ -289,6 +335,9 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ message: "stop reason: error" })).toBe("timeout"); expect(resolveFailoverReasonFromError({ message: "reason: abort" })).toBe("timeout"); expect(resolveFailoverReasonFromError({ message: "reason: error" })).toBe("timeout"); + expect( + resolveFailoverReasonFromError({ message: "Unhandled stop reason: network_error" }), + ).toBe("timeout"); }); it("infers timeout from connection/network error messages", () => { diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 16f60c4b844..c509c4b0acf 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -285,6 +285,7 @@ describe("memory search config", () => { deltaBytes: 100000, deltaMessages: 50, includeResetArchives: false, + postCompactionForce: true, }); }); diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 9d6042905ba..69d2b1114c2 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -62,6 +62,7 @@ export type ResolvedMemorySearchConfig = { deltaBytes: number; deltaMessages: number; includeResetArchives: boolean; + postCompactionForce: boolean; }; }; query: { @@ -254,6 +255,10 @@ function mergeConfig( overrides?.sync?.sessions?.includeResetArchives ?? defaults?.sync?.sessions?.includeResetArchives ?? DEFAULT_SESSION_INCLUDE_RESET_ARCHIVES, + postCompactionForce: + overrides?.sync?.sessions?.postCompactionForce ?? + defaults?.sync?.sessions?.postCompactionForce ?? + true, }, }; const query = { @@ -321,6 +326,7 @@ function mergeConfig( ); const deltaBytes = clampInt(sync.sessions.deltaBytes, 0, Number.MAX_SAFE_INTEGER); const deltaMessages = clampInt(sync.sessions.deltaMessages, 0, Number.MAX_SAFE_INTEGER); + const postCompactionForce = sync.sessions.postCompactionForce; return { enabled, sources, @@ -343,6 +349,7 @@ function mergeConfig( deltaBytes, deltaMessages, includeResetArchives: Boolean(sync.sessions.includeResetArchives), + postCompactionForce, }, }, query: { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 3318a115949..7bbd8ed8ba7 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -1,3 +1,4 @@ +import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelFallbackValues, @@ -36,7 +37,6 @@ const ANTHROPIC_MODEL_ALIASES: Record = { "sonnet-4.6": "claude-sonnet-4-6", "sonnet-4.5": "claude-sonnet-4-5", }; -const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; function normalizeAliasKey(value: string): string { return value.trim().toLowerCase(); @@ -629,8 +629,8 @@ export function resolveThinkingDefault(params: { model: string; catalog?: ModelCatalogEntry[]; }): ThinkLevel { - const normalizedProvider = normalizeProviderId(params.provider); - const modelLower = params.model.toLowerCase(); + const _normalizedProvider = normalizeProviderId(params.provider); + const _modelLower = params.model.toLowerCase(); const configuredModels = params.cfg.agents?.defaults?.models; const canonicalKey = modelKey(params.provider, params.model); const legacyKey = legacyModelKey(params.provider, params.model); @@ -652,21 +652,11 @@ export function resolveThinkingDefault(params: { if (configured) { return configured; } - const isAnthropicFamilyModel = - normalizedProvider === "anthropic" || - normalizedProvider === "amazon-bedrock" || - modelLower.includes("anthropic/") || - modelLower.includes(".anthropic."); - if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) { - return "adaptive"; - } - const candidate = params.catalog?.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); - if (candidate?.reasoning) { - return "low"; - } - return "off"; + return resolveThinkingDefaultForModel({ + provider: params.provider, + model: params.model, + catalog: params.catalog, + }); } /** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */ diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index 60c3624c3c1..b84d4e363d6 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -66,6 +66,42 @@ describe("models-config merge helpers", () => { }); }); + it("preserves implicit provider headers when explicit config adds extra headers", () => { + const merged = mergeProviderModels( + { + baseUrl: "https://api.example.com", + api: "anthropic-messages", + headers: { "User-Agent": "claude-code/0.1.0" }, + models: [ + { + id: "k2p5", + name: "Kimi for Coding", + input: ["text", "image"], + reasoning: true, + }, + ], + } as unknown as ProviderConfig, + { + baseUrl: "https://api.example.com", + api: "anthropic-messages", + headers: { "X-Kimi-Tenant": "tenant-a" }, + models: [ + { + id: "k2p5", + name: "Kimi for Coding", + input: ["text", "image"], + reasoning: true, + }, + ], + } as unknown as ProviderConfig, + ); + + expect(merged.headers).toEqual({ + "User-Agent": "claude-code/0.1.0", + "X-Kimi-Tenant": "tenant-a", + }); + }); + it("replaces stale baseUrl when model api surface changes", () => { const merged = mergeWithExistingProviderSecrets({ nextProviders: { diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts index e227ee413d5..da4f0e8a005 100644 --- a/src/agents/models-config.merge.ts +++ b/src/agents/models-config.merge.ts @@ -39,8 +39,27 @@ export function mergeProviderModels( ): ProviderConfig { const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; + const implicitHeaders = + implicit.headers && typeof implicit.headers === "object" && !Array.isArray(implicit.headers) + ? implicit.headers + : undefined; + const explicitHeaders = + explicit.headers && typeof explicit.headers === "object" && !Array.isArray(explicit.headers) + ? explicit.headers + : undefined; if (implicitModels.length === 0) { - return { ...implicit, ...explicit }; + return { + ...implicit, + ...explicit, + ...(implicitHeaders || explicitHeaders + ? { + headers: { + ...implicitHeaders, + ...explicitHeaders, + }, + } + : {}), + }; } const implicitById = new Map( @@ -93,6 +112,14 @@ export function mergeProviderModels( return { ...implicit, ...explicit, + ...(implicitHeaders || explicitHeaders + ? { + headers: { + ...implicitHeaders, + ...explicitHeaders, + }, + } + : {}), models: mergedModels, }; } diff --git a/src/agents/models-config.providers.kimi-coding.test.ts b/src/agents/models-config.providers.kimi-coding.test.ts index 4467dfc4cab..91ca62f34e2 100644 --- a/src/agents/models-config.providers.kimi-coding.test.ts +++ b/src/agents/models-config.providers.kimi-coding.test.ts @@ -26,6 +26,7 @@ describe("kimi-coding implicit provider (#22409)", () => { const provider = buildKimiCodingProvider(); expect(provider.api).toBe("anthropic-messages"); expect(provider.baseUrl).toBe("https://api.kimi.com/coding/"); + expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" }); expect(provider.models).toBeDefined(); expect(provider.models.length).toBeGreaterThan(0); expect(provider.models[0].id).toBe("k2p5"); @@ -65,4 +66,33 @@ describe("kimi-coding implicit provider (#22409)", () => { envSnapshot.restore(); } }); + + it("merges explicit kimi-coding headers on top of the built-in user agent", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["KIMI_API_KEY"]); + process.env.KIMI_API_KEY = "test-key"; + + try { + const providers = await resolveImplicitProvidersForTest({ + agentDir, + explicitProviders: { + "kimi-coding": { + baseUrl: "https://api.kimi.com/coding/", + api: "anthropic-messages", + headers: { + "User-Agent": "custom-kimi-client/1.0", + "X-Kimi-Tenant": "tenant-a", + }, + models: buildKimiCodingProvider().models, + }, + }, + }); + expect(providers?.["kimi-coding"]?.headers).toEqual({ + "User-Agent": "custom-kimi-client/1.0", + "X-Kimi-Tenant": "tenant-a", + }); + } finally { + envSnapshot.restore(); + } + }); }); diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts index c525cb32f53..a0aa879c727 100644 --- a/src/agents/models-config.providers.static.ts +++ b/src/agents/models-config.providers.static.ts @@ -95,6 +95,7 @@ const MOONSHOT_DEFAULT_COST = { }; const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; +const KIMI_CODING_USER_AGENT = "claude-code/0.1.0"; const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5"; const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; @@ -186,7 +187,7 @@ const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray = [ { id: "MiniMax-M2.5", name: "MiniMax-M2.5", - reasoning: false, + reasoning: true, input: ["text"], cost: MODELSTUDIO_DEFAULT_COST, contextWindow: 1_000_000, @@ -308,6 +309,9 @@ export function buildKimiCodingProvider(): ProviderConfig { return { baseUrl: KIMI_CODING_BASE_URL, api: "anthropic-messages", + headers: { + "User-Agent": KIMI_CODING_USER_AGENT, + }, models: [ { id: KIMI_CODING_DEFAULT_MODEL_ID, diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 0c8b961afe1..86f52c3a871 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -667,12 +667,24 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ }; }), withApiKey("kimi-coding", async ({ apiKey, explicitProvider }) => { + const builtInProvider = buildKimiCodingProvider(); const explicitBaseUrl = explicitProvider?.baseUrl; + const explicitHeaders = isRecord(explicitProvider?.headers) + ? (explicitProvider.headers as ProviderConfig["headers"]) + : undefined; return { - ...buildKimiCodingProvider(), + ...builtInProvider, ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim() ? { baseUrl: explicitBaseUrl.trim() } : {}), + ...(explicitHeaders + ? { + headers: { + ...builtInProvider.headers, + ...explicitHeaders, + }, + } + : {}), apiKey, }; }), diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index b71ad3a7d78..3cbefadbce8 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -110,6 +110,9 @@ describe("isBillingErrorMessage", () => { // Venice returns "Insufficient USD or Diem balance" which has extra words // between "insufficient" and "balance" "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", + // OpenRouter returns "requires more credits" for underfunded accounts + "This model requires more credits to use", + "This endpoint require more credits", ]; for (const sample of samples) { expect(isBillingErrorMessage(sample)).toBe(true); @@ -503,6 +506,18 @@ describe("isTransientHttpError", () => { }); describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 422 as format error", () => { + expect(classifyFailoverReasonFromHttpStatus(422)).toBe("format"); + expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe( + "format", + ); + expect(classifyFailoverReasonFromHttpStatus(422, "Unprocessable Entity")).toBe("format"); + }); + + it("treats 422 with billing message as billing instead of format", () => { + expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing"); + }); + it("treats HTTP 499 as transient for structured errors", () => { expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); @@ -576,6 +591,19 @@ describe("isFailoverErrorMessage", () => { } }); + it("matches z.ai network_error stop reason as timeout", () => { + const samples = [ + "Unhandled stop reason: network_error", + "stop reason: network_error", + "reason: network_error", + ]; + for (const sample of samples) { + expect(isTimeoutErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("timeout"); + expect(isFailoverErrorMessage(sample)).toBe(true); + } + }); + it("does not classify MALFORMED_FUNCTION_CALL as timeout", () => { const sample = "Unhandled stop reason: MALFORMED_FUNCTION_CALL"; expect(isTimeoutErrorMessage(sample)).toBe(false); @@ -705,6 +733,8 @@ describe("classifyFailoverReason", () => { "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", ), ).toBe("billing"); + // OpenRouter "requires more credits" billing text + expect(classifyFailoverReason("This model requires more credits to use")).toBe("billing"); }); it("classifies internal and compatibility error messages", () => { diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 28fcf328e87..6e38d831ad9 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -288,6 +288,13 @@ function hasExplicit402BillingSignal(text: string): boolean { ); } +function hasQuotaRefreshWindowSignal(text: string): boolean { + return ( + text.includes("subscription quota limit") && + (text.includes("automatic quota refresh") || text.includes("rolling time window")) + ); +} + function hasRetryable402TransientSignal(text: string): boolean { const hasPeriodicHint = includesAnyHint(text, PERIODIC_402_HINTS); const hasSpendLimit = text.includes("spend limit") || text.includes("spending limit"); @@ -313,6 +320,10 @@ function classify402Message(message: string): PaymentRequiredFailoverReason { return "billing"; } + if (hasQuotaRefreshWindowSignal(normalized)) { + return "rate_limit"; + } + if (hasExplicit402BillingSignal(normalized)) { return "billing"; } @@ -420,7 +431,7 @@ export function classifyFailoverReasonFromHttpStatus( if (status === 529) { return "overloaded"; } - if (status === 400) { + if (status === 400 || status === 422) { // Some providers return quota/balance errors under HTTP 400, so do not // let the generic format fallback mask an explicit billing signal. if (message && isBillingErrorMessage(message)) { diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index ffe0c428f55..9f6e83e9461 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -47,9 +47,9 @@ const ERROR_PATTERNS = { /\benotfound\b/i, /\beai_again\b/i, /without sending (?:any )?chunks?/i, - /\bstop reason:\s*(?:abort|error|malformed_response)\b/i, - /\breason:\s*(?:abort|error|malformed_response)\b/i, - /\bunhandled stop reason:\s*(?:abort|error|malformed_response)\b/i, + /\bstop reason:\s*(?:abort|error|malformed_response|network_error)\b/i, + /\breason:\s*(?:abort|error|malformed_response|network_error)\b/i, + /\bunhandled stop reason:\s*(?:abort|error|malformed_response|network_error)\b/i, ], billing: [ /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i, @@ -60,6 +60,7 @@ const ERROR_PATTERNS = { "plans & billing", "insufficient balance", "insufficient usd or diem balance", + /requires?\s+more\s+credits/i, ], authPermanent: [ /api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i, diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 1785abfb843..04ada5e9ba6 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -695,6 +695,33 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]?.tool_choice).toBe("auto"); }); + it("disables thinking instead of broadening pinned Moonshot tool_choice", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + tool_choice: { type: "tool", name: "read" }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "moonshot", "kimi-k2.5", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "moonshot", + id: "kimi-k2.5", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toEqual({ type: "disabled" }); + expect(payloads[0]?.tool_choice).toEqual({ type: "tool", name: "read" }); + }); + it("respects explicit Moonshot thinking param from model config", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { @@ -732,6 +759,85 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]?.thinking).toEqual({ type: "disabled" }); }); + it("applies Moonshot payload compatibility to Ollama Kimi cloud models", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { tool_choice: "required" }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "ollama", "kimi-k2.5:cloud", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "ollama", + id: "kimi-k2.5:cloud", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toEqual({ type: "enabled" }); + expect(payloads[0]?.tool_choice).toBe("auto"); + }); + + it("maps thinkingLevel=off for Ollama Kimi cloud models through Moonshot compatibility", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "ollama", "kimi-k2.5:cloud", undefined, "off"); + + const model = { + api: "openai-completions", + provider: "ollama", + id: "kimi-k2.5:cloud", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toEqual({ type: "disabled" }); + }); + + it("disables thinking instead of broadening pinned Ollama Kimi cloud tool_choice", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + tool_choice: { type: "function", function: { name: "read" } }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "ollama", "kimi-k2.5:cloud", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "ollama", + id: "kimi-k2.5:cloud", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.thinking).toEqual({ type: "disabled" }); + expect(payloads[0]?.tool_choice).toEqual({ + type: "function", + function: { name: "read" }, + }); + }); + it("does not rewrite tool schema for kimi-coding (native Anthropic format)", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 9292028b5d4..3e59f14af35 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -4,41 +4,67 @@ import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; const { hookRunner, ensureRuntimePluginsLoaded, + resolveContextEngineMock, resolveModelMock, sessionCompactImpl, triggerInternalHook, sanitizeSessionHistoryMock, contextEngineCompactMock, -} = vi.hoisted(() => ({ - hookRunner: { - hasHooks: vi.fn(), - runBeforeCompaction: vi.fn(), - runAfterCompaction: vi.fn(), - }, - ensureRuntimePluginsLoaded: vi.fn(), - resolveModelMock: vi.fn(() => ({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })), - sessionCompactImpl: vi.fn(async () => ({ - summary: "summary", - firstKeptEntryId: "entry-1", - tokensBefore: 120, - details: { ok: true }, - })), - triggerInternalHook: vi.fn(), - sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages), - contextEngineCompactMock: vi.fn(async () => ({ + getMemorySearchManagerMock, + resolveMemorySearchConfigMock, + resolveSessionAgentIdMock, +} = vi.hoisted(() => { + const contextEngineCompactMock = vi.fn(async () => ({ ok: true as boolean, compacted: true as boolean, reason: undefined as string | undefined, result: { summary: "engine-summary", tokensAfter: 50 } as | { summary: string; tokensAfter: number } | undefined, - })), -})); + })); + + return { + hookRunner: { + hasHooks: vi.fn(), + runBeforeCompaction: vi.fn(), + runAfterCompaction: vi.fn(), + }, + ensureRuntimePluginsLoaded: vi.fn(), + resolveContextEngineMock: vi.fn(async () => ({ + info: { ownsCompaction: true }, + compact: contextEngineCompactMock, + })), + resolveModelMock: vi.fn(() => ({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + })), + sessionCompactImpl: vi.fn(async () => ({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, + })), + triggerInternalHook: vi.fn(), + sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages), + contextEngineCompactMock, + getMemorySearchManagerMock: vi.fn(async () => ({ + manager: { + sync: vi.fn(async () => {}), + }, + })), + resolveMemorySearchConfigMock: vi.fn(() => ({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, + })), + resolveSessionAgentIdMock: vi.fn(() => "main"), + }; +}); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookRunner, @@ -135,10 +161,7 @@ vi.mock("../session-write-lock.js", () => ({ vi.mock("../../context-engine/index.js", () => ({ ensureContextEnginesInitialized: vi.fn(), - resolveContextEngine: vi.fn(async () => ({ - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - })), + resolveContextEngine: resolveContextEngineMock, })); vi.mock("../../process/command-queue.js", () => ({ @@ -211,9 +234,18 @@ vi.mock("../agent-paths.js", () => ({ })); vi.mock("../agent-scope.js", () => ({ + resolveSessionAgentId: resolveSessionAgentIdMock, resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), })); +vi.mock("../memory-search.js", () => ({ + resolveMemorySearchConfig: resolveMemorySearchConfigMock, +})); + +vi.mock("../../memory/index.js", () => ({ + getMemorySearchManager: getMemorySearchManagerMock, +})); + vi.mock("../date-time.js", () => ({ formatUserTime: vi.fn(() => ""), resolveUserTimeFormat: vi.fn(() => ""), @@ -314,6 +346,23 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { return params.messages; }); + getMemorySearchManagerMock.mockReset(); + getMemorySearchManagerMock.mockResolvedValue({ + manager: { + sync: vi.fn(async () => {}), + }, + }); + resolveMemorySearchConfigMock.mockReset(); + resolveMemorySearchConfigMock.mockReturnValue({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: true, + }, + }, + }); + resolveSessionAgentIdMock.mockReset(); + resolveSessionAgentIdMock.mockReturnValue("main"); unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); }); @@ -452,6 +501,161 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { } }); + it("skips sync in await mode when postCompactionForce is false", async () => { + const sync = vi.fn(async () => {}); + getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); + resolveMemorySearchConfigMock.mockReturnValue({ + sources: ["sessions"], + sync: { + sessions: { + postCompactionForce: false, + }, + }, + }); + + const result = await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + config: { + agents: { + defaults: { + compaction: { + postIndexSync: "await", + }, + }, + }, + } as never, + }); + + expect(result.ok).toBe(true); + expect(resolveSessionAgentIdMock).toHaveBeenCalledWith({ + sessionKey: "agent:main:session-1", + config: expect.any(Object), + }); + expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); + expect(sync).not.toHaveBeenCalled(); + }); + + it("awaits post-compaction memory sync in await mode when postCompactionForce is true", async () => { + let releaseSync: (() => void) | undefined; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const sync = vi.fn(() => syncGate); + getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); + let settled = false; + + const resultPromise = compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + config: { + agents: { + defaults: { + compaction: { + postIndexSync: "await", + }, + }, + }, + } as never, + }); + + void resultPromise.then(() => { + settled = true; + }); + await vi.waitFor(() => { + expect(sync).toHaveBeenCalledWith({ + reason: "post-compaction", + sessionFiles: ["/tmp/session.jsonl"], + }); + }); + expect(settled).toBe(false); + releaseSync?.(); + const result = await resultPromise; + expect(result.ok).toBe(true); + expect(settled).toBe(true); + }); + + it("skips post-compaction memory sync when the mode is off", async () => { + const sync = vi.fn(async () => {}); + getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); + + const result = await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + config: { + agents: { + defaults: { + compaction: { + postIndexSync: "off", + }, + }, + }, + } as never, + }); + + expect(result.ok).toBe(true); + expect(resolveSessionAgentIdMock).not.toHaveBeenCalled(); + expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); + expect(sync).not.toHaveBeenCalled(); + }); + + it("fires post-compaction memory sync without awaiting it in async mode", async () => { + const sync = vi.fn(async () => {}); + let resolveManager: ((value: { manager: { sync: typeof sync } }) => void) | undefined; + const managerGate = new Promise<{ manager: { sync: typeof sync } }>((resolve) => { + resolveManager = resolve; + }); + getMemorySearchManagerMock.mockImplementation(() => managerGate); + let settled = false; + + const resultPromise = compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + config: { + agents: { + defaults: { + compaction: { + postIndexSync: "async", + }, + }, + }, + } as never, + }); + + await vi.waitFor(() => { + expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); + }); + void resultPromise.then(() => { + settled = true; + }); + await vi.waitFor(() => { + expect(settled).toBe(true); + }); + expect(sync).not.toHaveBeenCalled(); + resolveManager?.({ manager: { sync } }); + await managerGate; + await vi.waitFor(() => { + expect(sync).toHaveBeenCalledWith({ + reason: "post-compaction", + sessionFiles: ["/tmp/session.jsonl"], + }); + }); + const result = await resultPromise; + expect(result.ok).toBe(true); + }); + it("registers the Ollama api provider before compaction", async () => { resolveModelMock.mockReturnValue({ model: { @@ -493,6 +697,11 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); hookRunner.runAfterCompaction.mockReset(); + resolveContextEngineMock.mockReset(); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: true }, + compact: contextEngineCompactMock, + }); contextEngineCompactMock.mockReset(); contextEngineCompactMock.mockResolvedValue({ ok: true, @@ -546,8 +755,47 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { ); }); + it("emits a transcript update and post-compaction memory sync on the engine-owned path", async () => { + const listener = vi.fn(); + const cleanup = onSessionTranscriptUpdate(listener); + const sync = vi.fn(async () => {}); + getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); + + try { + const result = await compactEmbeddedPiSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: " /tmp/session.jsonl ", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + enqueue: (task) => task(), + config: { + agents: { + defaults: { + compaction: { + postIndexSync: "await", + }, + }, + }, + } as never, + }); + + expect(result.ok).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ sessionFile: "/tmp/session.jsonl" }); + expect(sync).toHaveBeenCalledWith({ + reason: "post-compaction", + sessionFiles: ["/tmp/session.jsonl"], + }); + } finally { + cleanup(); + } + }); + it("does not fire after_compaction when compaction fails", async () => { hookRunner.hasHooks.mockReturnValue(true); + const sync = vi.fn(async () => {}); + getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); contextEngineCompactMock.mockResolvedValue({ ok: false, compacted: false, @@ -567,6 +815,44 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { expect(result.ok).toBe(false); expect(hookRunner.runBeforeCompaction).toHaveBeenCalled(); expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled(); + expect(sync).not.toHaveBeenCalled(); + }); + + it("does not duplicate transcript updates or sync in the wrapper when the engine delegates compaction", async () => { + const listener = vi.fn(); + const cleanup = onSessionTranscriptUpdate(listener); + const sync = vi.fn(async () => {}); + getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + }); + + try { + const result = await compactEmbeddedPiSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + enqueue: (task) => task(), + config: { + agents: { + defaults: { + compaction: { + postIndexSync: "await", + }, + }, + }, + } as never, + }); + + expect(result.ok).toBe(true); + expect(listener).not.toHaveBeenCalled(); + expect(sync).not.toHaveBeenCalled(); + } finally { + cleanup(); + } }); it("catches and logs hook exceptions without aborting compaction", async () => { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index a62ed2eecb0..1207a0c3b0b 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -18,6 +18,7 @@ import { import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; +import { getMemorySearchManager } from "../../memory/index.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; @@ -30,7 +31,7 @@ import { resolveUserPath } from "../../utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; -import { resolveSessionAgentIds } from "../agent-scope.js"; +import { resolveSessionAgentId, resolveSessionAgentIds } from "../agent-scope.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js"; @@ -39,6 +40,7 @@ import { ensureCustomApiRegistered } from "../custom-api-registry.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawDocsPath } from "../docs-path.js"; +import { resolveMemorySearchConfig } from "../memory-search.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; @@ -268,6 +270,95 @@ function classifyCompactionReason(reason?: string): string { return "unknown"; } +function resolvePostCompactionIndexSyncMode(config?: OpenClawConfig): "off" | "async" | "await" { + const mode = config?.agents?.defaults?.compaction?.postIndexSync; + if (mode === "off" || mode === "async" || mode === "await") { + return mode; + } + return "async"; +} + +async function runPostCompactionSessionMemorySync(params: { + config?: OpenClawConfig; + sessionKey?: string; + sessionFile: string; +}): Promise { + if (!params.config) { + return; + } + try { + const sessionFile = params.sessionFile.trim(); + if (!sessionFile) { + return; + } + const agentId = resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.config, + }); + const resolvedMemory = resolveMemorySearchConfig(params.config, agentId); + if (!resolvedMemory || !resolvedMemory.sources.includes("sessions")) { + return; + } + if (!resolvedMemory.sync.sessions.postCompactionForce) { + return; + } + const { manager } = await getMemorySearchManager({ + cfg: params.config, + agentId, + }); + if (!manager?.sync) { + return; + } + const syncTask = manager.sync({ + reason: "post-compaction", + sessionFiles: [sessionFile], + }); + await syncTask; + } catch (err) { + log.warn(`memory sync skipped (post-compaction): ${String(err)}`); + } +} + +function syncPostCompactionSessionMemory(params: { + config?: OpenClawConfig; + sessionKey?: string; + sessionFile: string; + mode: "off" | "async" | "await"; +}): Promise { + if (params.mode === "off" || !params.config) { + return Promise.resolve(); + } + + const syncTask = runPostCompactionSessionMemorySync({ + config: params.config, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + }); + if (params.mode === "await") { + return syncTask; + } + void syncTask; + return Promise.resolve(); +} + +async function runPostCompactionSideEffects(params: { + config?: OpenClawConfig; + sessionKey?: string; + sessionFile: string; +}): Promise { + const sessionFile = params.sessionFile.trim(); + if (!sessionFile) { + return; + } + emitSessionTranscriptUpdate(sessionFile); + await syncPostCompactionSessionMemory({ + config: params.config, + sessionKey: params.sessionKey, + sessionFile, + mode: resolvePostCompactionIndexSyncMode(params.config), + }); +} + /** * Core compaction logic without lane queueing. * Use this when already inside a session/global lane to avoid deadlocks. @@ -809,7 +900,11 @@ export async function compactEmbeddedPiSessionDirect( const result = await compactWithSafetyTimeout(() => session.compact(params.customInstructions), ); - emitSessionTranscriptUpdate(params.sessionFile); + await runPostCompactionSideEffects({ + config: params.config, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + }); // Estimate tokens after compaction by summing token estimates for remaining messages let tokensAfter: number | undefined; try { @@ -991,6 +1086,7 @@ export async function compactEmbeddedPiSession( } const result = await contextEngine.compact({ sessionId: params.sessionId, + sessionKey: params.sessionKey, sessionFile: params.sessionFile, tokenBudget: ceCtxInfo.tokens, currentTokenCount: params.currentTokenCount, @@ -998,6 +1094,13 @@ export async function compactEmbeddedPiSession( force: params.trigger === "manual", runtimeContext: params as Record, }); + if (engineOwnsCompaction && result.ok && result.compacted) { + await runPostCompactionSideEffects({ + config: params.config, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + }); + } if (result.ok && result.compacted && hookRunner?.hasHooks("after_compaction")) { try { await hookRunner.runAfterCompaction( diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 8f36792f393..56ee8946cbd 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -16,6 +16,7 @@ import { createMoonshotThinkingWrapper, createSiliconFlowThinkingWrapper, resolveMoonshotThinkingType, + shouldApplyMoonshotPayloadCompat, shouldApplySiliconFlowThinkingOffCompat, } from "./moonshot-stream-wrappers.js"; import { @@ -373,7 +374,7 @@ export function applyExtraParamsToAgent( agent.streamFn = createSiliconFlowThinkingWrapper(agent.streamFn); } - if (provider === "moonshot") { + if (shouldApplyMoonshotPayloadCompat({ provider, modelId })) { const moonshotThinkingType = resolveMoonshotThinkingType({ configuredThinking: merged?.thinking, thinkingLevel, diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 062369d9a96..7c3279e314a 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -915,6 +915,43 @@ describe("resolveModel", () => { }); }); + it("lets provider config override registry-found kimi user agent headers", () => { + mockDiscoveredModel({ + provider: "kimi-coding", + modelId: "k2p5", + templateModel: { + ...buildForwardCompatTemplate({ + id: "k2p5", + name: "Kimi for Coding", + provider: "kimi-coding", + api: "anthropic-messages", + baseUrl: "https://api.kimi.com/coding/", + }), + headers: { "User-Agent": "claude-code/0.1.0" }, + }, + }); + + const cfg = { + models: { + providers: { + "kimi-coding": { + headers: { + "User-Agent": "custom-kimi-client/1.0", + "X-Kimi-Tenant": "tenant-a", + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModel("kimi-coding", "k2p5", "/tmp/agent", cfg); + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + "User-Agent": "custom-kimi-client/1.0", + "X-Kimi-Tenant": "tenant-a", + }); + }); + it("does not override when no provider config exists", () => { mockDiscoveredModel({ provider: "anthropic", diff --git a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts index 282b0960a9d..c066a168a0f 100644 --- a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts @@ -35,6 +35,14 @@ function isMoonshotToolChoiceCompatible(toolChoice: unknown): boolean { return false; } +function isPinnedToolChoice(toolChoice: unknown): boolean { + if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) { + return false; + } + const typeValue = (toolChoice as Record).type; + return typeValue === "tool" || typeValue === "function"; +} + export function shouldApplySiliconFlowThinkingOffCompat(params: { provider: string; modelId: string; @@ -47,6 +55,27 @@ export function shouldApplySiliconFlowThinkingOffCompat(params: { ); } +export function shouldApplyMoonshotPayloadCompat(params: { + provider: string; + modelId: string; +}): boolean { + const normalizedProvider = params.provider.trim().toLowerCase(); + const normalizedModelId = params.modelId.trim().toLowerCase(); + + if (normalizedProvider === "moonshot") { + return true; + } + + // Ollama Cloud exposes Kimi variants through OpenAI-compatible model IDs such + // as `kimi-k2.5:cloud`, but they still need the same payload normalization as + // native Moonshot endpoints when thinking/tool_choice are enabled together. + return ( + normalizedProvider === "ollama" && + normalizedModelId.startsWith("kimi-k") && + normalizedModelId.includes(":cloud") + ); +} + export function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefined): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { @@ -103,7 +132,11 @@ export function createMoonshotThinkingWrapper( effectiveThinkingType === "enabled" && !isMoonshotToolChoiceCompatible(payloadObj.tool_choice) ) { - payloadObj.tool_choice = "auto"; + if (payloadObj.tool_choice === "required") { + payloadObj.tool_choice = "auto"; + } else if (isPinnedToolChoice(payloadObj.tool_choice)) { + payloadObj.thinking = { type: "disabled" }; + } } } return originalOnPayload?.(payload, model); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 5111fc6d9f9..7db6e2f61c8 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1053,6 +1053,7 @@ export async function runEmbeddedPiAgent( try { compactResult = await contextEngine.compact({ sessionId: params.sessionId, + sessionKey: params.sessionKey, sessionFile: params.sessionFile, tokenBudget: ctxInfo.tokens, ...(observedOverflowTokens !== undefined diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts index 3801231f1f2..2d0e8900b8c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Api, Model } from "@mariozechner/pi-ai"; import type { AuthStorage, @@ -9,6 +10,14 @@ import type { ToolDefinition, } from "@mariozechner/pi-coding-agent"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + AssembleResult, + BootstrapResult, + CompactResult, + ContextEngineInfo, + IngestBatchResult, + IngestResult, +} from "../../../context-engine/types.js"; import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js"; @@ -23,7 +32,7 @@ const hoisted = vi.hoisted(() => { getLeafEntry: vi.fn(() => null), branch: vi.fn(), resetLeaf: vi.fn(), - buildSessionContext: vi.fn(() => ({ messages: [] })), + buildSessionContext: vi.fn<() => { messages: AgentMessage[] }>(() => ({ messages: [] })), appendCustomEntry: vi.fn(), }; return { @@ -240,6 +249,14 @@ function createSubscriptionMock() { }; } +const testModel = { + api: "openai-completions", + provider: "openai", + compat: {}, + contextWindow: 8192, + input: ["text"], +} as unknown as Model; + describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { const tempPaths: string[] = []; @@ -326,14 +343,6 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { }, ); - const model = { - api: "openai-completions", - provider: "openai", - compat: {}, - contextWindow: 8192, - input: ["text"], - } as unknown as Model; - const result = await runEmbeddedAttempt({ sessionId: "embedded-session", sessionKey: "agent:main:main", @@ -346,7 +355,7 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { runId: "run-1", provider: "openai", modelId: "gpt-test", - model, + model: testModel, authStorage: {} as AuthStorage, modelRegistry: {} as ModelRegistry, thinkLevel: "off", @@ -372,3 +381,243 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { ); }); }); + +describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { + const tempPaths: string[] = []; + const sessionKey = "agent:main:discord:channel:test-ctx-engine"; + + beforeEach(() => { + hoisted.createAgentSessionMock.mockReset(); + hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager); + hoisted.resolveSandboxContextMock.mockReset(); + hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(createSubscriptionMock); + hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ + release: async () => {}, + }); + hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); + hoisted.sessionManager.branch.mockReset(); + hoisted.sessionManager.resetLeaf.mockReset(); + hoisted.sessionManager.appendCustomEntry.mockReset(); + }); + + afterEach(async () => { + while (tempPaths.length > 0) { + const target = tempPaths.pop(); + if (target) { + await fs.rm(target, { recursive: true, force: true }); + } + } + }); + + // Build a minimal real attempt harness so lifecycle hooks run against + // the actual runner flow instead of a hand-written wrapper. + async function runAttemptWithContextEngine(contextEngine: { + bootstrap?: (params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + }) => Promise; + assemble: (params: { + sessionId: string; + sessionKey?: string; + messages: AgentMessage[]; + tokenBudget?: number; + }) => Promise; + afterTurn?: (params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + messages: AgentMessage[]; + prePromptMessageCount: number; + tokenBudget?: number; + runtimeContext?: Record; + }) => Promise; + ingestBatch?: (params: { + sessionId: string; + sessionKey?: string; + messages: AgentMessage[]; + }) => Promise; + ingest?: (params: { + sessionId: string; + sessionKey?: string; + message: AgentMessage; + }) => Promise; + compact?: (params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + tokenBudget?: number; + }) => Promise; + info?: Partial; + }) { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-workspace-")); + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-agent-")); + const sessionFile = path.join(workspaceDir, "session.jsonl"); + tempPaths.push(workspaceDir, agentDir); + await fs.writeFile(sessionFile, "", "utf8"); + const seedMessages: AgentMessage[] = [ + { role: "user", content: "seed", timestamp: 1 } as AgentMessage, + ]; + const infoId = contextEngine.info?.id ?? "test-context-engine"; + const infoName = contextEngine.info?.name ?? "Test Context Engine"; + const infoVersion = contextEngine.info?.version ?? "0.0.1"; + + hoisted.sessionManager.buildSessionContext + .mockReset() + .mockReturnValue({ messages: seedMessages }); + + hoisted.createAgentSessionMock.mockImplementation(async () => { + const session: MutableSession = { + sessionId: "embedded-session", + messages: [], + isCompacting: false, + isStreaming: false, + agent: { + replaceMessages: (messages: unknown[]) => { + session.messages = [...messages]; + }, + }, + prompt: async () => { + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + abort: async () => {}, + dispose: () => {}, + steer: async () => {}, + }; + + return { session }; + }); + + return await runEmbeddedAttempt({ + sessionId: "embedded-session", + sessionKey, + sessionFile, + workspaceDir, + agentDir, + config: {}, + prompt: "hello", + timeoutMs: 10_000, + runId: "run-context-engine-forwarding", + provider: "openai", + modelId: "gpt-test", + model: testModel, + authStorage: {} as AuthStorage, + modelRegistry: {} as ModelRegistry, + thinkLevel: "off", + senderIsOwner: true, + disableMessageTool: true, + contextTokenBudget: 2048, + contextEngine: { + ...contextEngine, + ingest: + contextEngine.ingest ?? + (async () => ({ + ingested: true, + })), + compact: + contextEngine.compact ?? + (async () => ({ + ok: false, + compacted: false, + reason: "not used in this test", + })), + info: { + id: infoId, + name: infoName, + version: infoVersion, + }, + }, + }); + } + + it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => { + const bootstrap = vi.fn(async (_params: { sessionKey?: string }) => ({ bootstrapped: true })); + const assemble = vi.fn( + async ({ messages }: { messages: AgentMessage[]; sessionKey?: string }) => ({ + messages, + estimatedTokens: 1, + }), + ); + const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {}); + + const result = await runAttemptWithContextEngine({ + bootstrap, + assemble, + afterTurn, + }); + + expect(result.promptError).toBeNull(); + expect(bootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + }), + ); + expect(assemble).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + }), + ); + expect(afterTurn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + }), + ); + }); + + it("forwards sessionKey to ingestBatch when afterTurn is absent", async () => { + const bootstrap = vi.fn(async (_params: { sessionKey?: string }) => ({ bootstrapped: true })); + const assemble = vi.fn( + async ({ messages }: { messages: AgentMessage[]; sessionKey?: string }) => ({ + messages, + estimatedTokens: 1, + }), + ); + const ingestBatch = vi.fn( + async (_params: { sessionKey?: string; messages: AgentMessage[] }) => ({ ingestedCount: 1 }), + ); + + const result = await runAttemptWithContextEngine({ + bootstrap, + assemble, + ingestBatch, + }); + + expect(result.promptError).toBeNull(); + expect(ingestBatch).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + }), + ); + }); + + it("forwards sessionKey to per-message ingest when ingestBatch is absent", async () => { + const bootstrap = vi.fn(async (_params: { sessionKey?: string }) => ({ bootstrapped: true })); + const assemble = vi.fn( + async ({ messages }: { messages: AgentMessage[]; sessionKey?: string }) => ({ + messages, + estimatedTokens: 1, + }), + ); + const ingest = vi.fn(async (_params: { sessionKey?: string; message: AgentMessage }) => ({ + ingested: true, + })); + + const result = await runAttemptWithContextEngine({ + bootstrap, + assemble, + ingest, + }); + + expect(result.promptError).toBeNull(); + expect(ingest).toHaveBeenCalled(); + expect( + ingest.mock.calls.every((call) => { + const params = call[0]; + return params.sessionKey === sessionKey; + }), + ).toBe(true); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a1a00992f43..08c9b26f6f4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1502,6 +1502,10 @@ export async function runEmbeddedAttempt( runId: params.runId, agentDir, workspaceDir: effectiveWorkspace, + // When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points + // at the sandbox copy. Spawned subagents should inherit the real workspace instead. + spawnWorkspaceDir: + sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? resolvedWorkspace : undefined, config: params.config, abortSignal: runAbortController.signal, modelProvider: params.model.provider, @@ -1737,6 +1741,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.bootstrap({ sessionId: params.sessionId, + sessionKey: params.sessionKey, sessionFile: params.sessionFile, }); } catch (bootstrapErr) { @@ -2089,6 +2094,7 @@ export async function runEmbeddedAttempt( try { const assembled = await params.contextEngine.assemble({ sessionId: params.sessionId, + sessionKey: params.sessionKey, messages: activeSession.messages, tokenBudget: params.contextTokenBudget, }); @@ -2604,6 +2610,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.afterTurn({ sessionId: sessionIdUsed, + sessionKey: params.sessionKey, sessionFile: params.sessionFile, messages: messagesSnapshot, prePromptMessageCount, @@ -2621,6 +2628,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.ingestBatch({ sessionId: sessionIdUsed, + sessionKey: params.sessionKey, messages: newMessages, }); } catch (ingestErr) { @@ -2631,6 +2639,7 @@ export async function runEmbeddedAttempt( try { await params.contextEngine.ingest({ sessionId: sessionIdUsed, + sessionKey: params.sessionKey, message: msg, }); } catch (ingestErr) { diff --git a/src/agents/pi-embedded-runner/runs.test.ts b/src/agents/pi-embedded-runner/runs.test.ts index 73201749317..d9bf90f961d 100644 --- a/src/agents/pi-embedded-runner/runs.test.ts +++ b/src/agents/pi-embedded-runner/runs.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { __testing, abortEmbeddedPiRun, @@ -105,4 +106,35 @@ describe("pi-embedded runner run registry", () => { vi.useRealTimers(); } }); + + it("shares active run state across distinct module instances", async () => { + const runsA = await importFreshModule( + import.meta.url, + "./runs.js?scope=shared-a", + ); + const runsB = await importFreshModule( + import.meta.url, + "./runs.js?scope=shared-b", + ); + const handle = { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => false, + abort: vi.fn(), + }; + + runsA.__testing.resetActiveEmbeddedRuns(); + runsB.__testing.resetActiveEmbeddedRuns(); + + try { + runsA.setActiveEmbeddedRun("session-shared", handle); + expect(runsB.isEmbeddedPiRunActive("session-shared")).toBe(true); + + runsB.clearActiveEmbeddedRun("session-shared", handle); + expect(runsA.isEmbeddedPiRunActive("session-shared")).toBe(false); + } finally { + runsA.__testing.resetActiveEmbeddedRuns(); + runsB.__testing.resetActiveEmbeddedRuns(); + } + }); }); diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 6b62b9b59ed..0d4cecc8372 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -3,6 +3,7 @@ import { logMessageQueued, logSessionStateChange, } from "../../logging/diagnostic.js"; +import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; type EmbeddedPiQueueHandle = { queueMessage: (text: string) => Promise; @@ -11,12 +12,23 @@ type EmbeddedPiQueueHandle = { abort: () => void; }; -const ACTIVE_EMBEDDED_RUNS = new Map(); type EmbeddedRunWaiter = { resolve: (ended: boolean) => void; timer: NodeJS.Timeout; }; -const EMBEDDED_RUN_WAITERS = new Map>(); + +/** + * Use global singleton state so busy/streaming checks stay consistent even + * when the bundler emits multiple copies of this module into separate chunks. + */ +const EMBEDDED_RUN_STATE_KEY = Symbol.for("openclaw.embeddedRunState"); + +const embeddedRunState = resolveGlobalSingleton(EMBEDDED_RUN_STATE_KEY, () => ({ + activeRuns: new Map(), + waiters: new Map>(), +})); +const ACTIVE_EMBEDDED_RUNS = embeddedRunState.activeRuns; +const EMBEDDED_RUN_WAITERS = embeddedRunState.waiters; export function queueEmbeddedPiMessage(sessionId: string, text: string): boolean { const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); diff --git a/src/auto-reply/reply/command-gates.ts b/src/auto-reply/reply/command-gates.ts index 49cf21c6861..1f0b441f51a 100644 --- a/src/auto-reply/reply/command-gates.ts +++ b/src/auto-reply/reply/command-gates.ts @@ -1,6 +1,7 @@ import type { CommandFlagKey } from "../../config/commands.js"; import { isCommandFlagEnabled } from "../../config/commands.js"; import { logVerbose } from "../../globals.js"; +import { redactIdentifier } from "../../logging/redact-identifier.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import type { ReplyPayload } from "../types.js"; import type { CommandHandlerResult, HandleCommandsParams } from "./commands-types.js"; @@ -13,7 +14,20 @@ export function rejectUnauthorizedCommand( return null; } logVerbose( - `Ignoring ${commandLabel} from unauthorized sender: ${params.command.senderId || ""}`, + `Ignoring ${commandLabel} from unauthorized sender: ${redactIdentifier(params.command.senderId)}`, + ); + return { shouldContinue: false }; +} + +export function rejectNonOwnerCommand( + params: HandleCommandsParams, + commandLabel: string, +): CommandHandlerResult | null { + if (params.command.senderIsOwner) { + return null; + } + logVerbose( + `Ignoring ${commandLabel} from non-owner sender: ${redactIdentifier(params.command.senderId)}`, ); return { shouldContinue: false }; } diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index 0d00358e582..96b5a5d9be5 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -22,7 +22,9 @@ import { setConfigOverride, unsetConfigOverride, } from "../../config/runtime-overrides.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { + rejectNonOwnerCommand, rejectUnauthorizedCommand, requireCommandFlagEnabled, requireGatewayClientScopeForInternalChannel, @@ -43,6 +45,12 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma if (unauthorized) { return unauthorized; } + const allowInternalReadOnlyShow = + configCommand.action === "show" && isInternalMessageChannel(params.command.channel); + const nonOwner = allowInternalReadOnlyShow ? null : rejectNonOwnerCommand(params, "/config"); + if (nonOwner) { + return nonOwner; + } const disabled = requireCommandFlagEnabled(params.cfg, { label: "/config", configKey: "config", @@ -197,6 +205,10 @@ export const handleDebugCommand: CommandHandler = async (params, allowTextComman if (unauthorized) { return unauthorized; } + const nonOwner = rejectNonOwnerCommand(params, "/debug"); + if (nonOwner) { + return nonOwner; + } const disabled = requireCommandFlagEnabled(params.cfg, { label: "/debug", configKey: "debug", diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 073cc36488c..8f48029fd18 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -181,6 +181,9 @@ describe("handleCommands gating", () => { commands: { config: false, debug: false, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, }) as OpenClawConfig, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/config is disabled", }, { @@ -191,6 +194,9 @@ describe("handleCommands gating", () => { commands: { config: false, debug: false, text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, }) as OpenClawConfig, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/debug is disabled", }, { @@ -223,6 +229,9 @@ describe("handleCommands gating", () => { channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; }, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/config is disabled", }, { @@ -239,6 +248,9 @@ describe("handleCommands gating", () => { channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig; }, + applyParams: (params: ReturnType) => { + params.command.senderIsOwner = true; + }, expectedText: "/debug is disabled", }, ]); @@ -670,6 +682,36 @@ describe("extractMessageText", () => { }); }); +describe("handleCommands /config owner gating", () => { + it("blocks /config show from authorized non-owner senders", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/config show", cfg); + params.command.senderIsOwner = false; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }); + + it("keeps /config show working for owners", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackreaction: ":)" } }, + }); + const params = buildParams("/config show messages.ackReaction", cfg); + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config messages.ackreaction"); + }); +}); + describe("handleCommands /config configWrites gating", () => { it("blocks /config set when channel config writes are disabled", async () => { const cfg = { @@ -677,6 +719,7 @@ describe("handleCommands /config configWrites gating", () => { channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, } as OpenClawConfig; const params = buildParams('/config set messages.ackReaction=":)"', cfg); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Config writes are disabled"); @@ -704,6 +747,7 @@ describe("handleCommands /config configWrites gating", () => { Surface: "telegram", }, ); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); @@ -720,6 +764,7 @@ describe("handleCommands /config configWrites gating", () => { Provider: "telegram", Surface: "telegram", }); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain( @@ -738,6 +783,7 @@ describe("handleCommands /config configWrites gating", () => { GatewayClientScopes: ["operator.write"], }); params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("requires operator.admin"); @@ -757,6 +803,7 @@ describe("handleCommands /config configWrites gating", () => { GatewayClientScopes: ["operator.write"], }); params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = false; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Config messages.ackreaction"); @@ -780,6 +827,7 @@ describe("handleCommands /config configWrites gating", () => { GatewayClientScopes: ["operator.write", "operator.admin"], }); params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(writeConfigFileMock).toHaveBeenCalledOnce(); @@ -822,6 +870,7 @@ describe("handleCommands /config configWrites gating", () => { }, ); params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Config updated"); @@ -830,6 +879,32 @@ describe("handleCommands /config configWrites gating", () => { }); }); +describe("handleCommands /debug owner gating", () => { + it("blocks /debug show from authorized non-owner senders", async () => { + const cfg = { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/debug show", cfg); + params.command.senderIsOwner = false; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + }); + + it("keeps /debug show working for owners", async () => { + const cfg = { + commands: { debug: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/debug show", cfg); + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Debug overrides"); + }); +}); + describe("handleCommands bash alias", () => { it("routes !poll and !stop through the /bash handler", async () => { const cfg = { diff --git a/src/auto-reply/reply/inbound-dedupe.test.ts b/src/auto-reply/reply/inbound-dedupe.test.ts new file mode 100644 index 00000000000..c71aeb598dd --- /dev/null +++ b/src/auto-reply/reply/inbound-dedupe.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; +import type { MsgContext } from "../templating.js"; +import { resetInboundDedupe } from "./inbound-dedupe.js"; + +const sharedInboundContext: MsgContext = { + Provider: "discord", + Surface: "discord", + From: "discord:user-1", + To: "channel:c1", + OriginatingChannel: "discord", + OriginatingTo: "channel:c1", + SessionKey: "agent:main:discord:channel:c1", + MessageSid: "msg-1", +}; + +describe("inbound dedupe", () => { + afterEach(() => { + resetInboundDedupe(); + }); + + it("shares dedupe state across distinct module instances", async () => { + const inboundA = await importFreshModule( + import.meta.url, + "./inbound-dedupe.js?scope=shared-a", + ); + const inboundB = await importFreshModule( + import.meta.url, + "./inbound-dedupe.js?scope=shared-b", + ); + + inboundA.resetInboundDedupe(); + inboundB.resetInboundDedupe(); + + try { + expect(inboundA.shouldSkipDuplicateInbound(sharedInboundContext)).toBe(false); + expect(inboundB.shouldSkipDuplicateInbound(sharedInboundContext)).toBe(true); + } finally { + inboundA.resetInboundDedupe(); + inboundB.resetInboundDedupe(); + } + }); +}); diff --git a/src/auto-reply/reply/inbound-dedupe.ts b/src/auto-reply/reply/inbound-dedupe.ts index 0e4740261b9..04744217c7e 100644 --- a/src/auto-reply/reply/inbound-dedupe.ts +++ b/src/auto-reply/reply/inbound-dedupe.ts @@ -1,15 +1,24 @@ import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { createDedupeCache, type DedupeCache } from "../../infra/dedupe.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; import type { MsgContext } from "../templating.js"; const DEFAULT_INBOUND_DEDUPE_TTL_MS = 20 * 60_000; const DEFAULT_INBOUND_DEDUPE_MAX = 5000; -const inboundDedupeCache = createDedupeCache({ - ttlMs: DEFAULT_INBOUND_DEDUPE_TTL_MS, - maxSize: DEFAULT_INBOUND_DEDUPE_MAX, -}); +/** + * Keep inbound dedupe shared across bundled chunks so the same provider + * message cannot bypass dedupe by entering through a different chunk copy. + */ +const INBOUND_DEDUPE_CACHE_KEY = Symbol.for("openclaw.inboundDedupeCache"); + +const inboundDedupeCache = resolveGlobalSingleton(INBOUND_DEDUPE_CACHE_KEY, () => + createDedupeCache({ + ttlMs: DEFAULT_INBOUND_DEDUPE_TTL_MS, + maxSize: DEFAULT_INBOUND_DEDUPE_MAX, + }), +); const normalizeProvider = (value?: string | null) => value?.trim().toLowerCase() || ""; diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index e8e93b3dd6d..1e2fb33e4e0 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -1,4 +1,5 @@ import { defaultRuntime } from "../../../runtime.js"; +import { resolveGlobalMap } from "../../../shared/global-singleton.js"; import { buildCollectPrompt, beginQueueDrain, @@ -15,7 +16,11 @@ import type { FollowupRun } from "./types.js"; // Persists the most recent runFollowup callback per queue key so that // enqueueFollowupRun can restart a drain that finished and deleted the queue. -const FOLLOWUP_RUN_CALLBACKS = new Map Promise>(); +const FOLLOWUP_DRAIN_CALLBACKS_KEY = Symbol.for("openclaw.followupDrainCallbacks"); + +const FOLLOWUP_RUN_CALLBACKS = resolveGlobalMap Promise>( + FOLLOWUP_DRAIN_CALLBACKS_KEY, +); export function clearFollowupDrainCallback(key: string): void { FOLLOWUP_RUN_CALLBACKS.delete(key); diff --git a/src/auto-reply/reply/queue/enqueue.ts b/src/auto-reply/reply/queue/enqueue.ts index 7743048a77b..11da0db98fc 100644 --- a/src/auto-reply/reply/queue/enqueue.ts +++ b/src/auto-reply/reply/queue/enqueue.ts @@ -1,13 +1,22 @@ import { createDedupeCache } from "../../../infra/dedupe.js"; +import { resolveGlobalSingleton } from "../../../shared/global-singleton.js"; import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js"; import { kickFollowupDrainIfIdle } from "./drain.js"; import { getExistingFollowupQueue, getFollowupQueue } from "./state.js"; import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js"; -const RECENT_QUEUE_MESSAGE_IDS = createDedupeCache({ - ttlMs: 5 * 60 * 1000, - maxSize: 10_000, -}); +/** + * Keep queued message-id dedupe shared across bundled chunks so redeliveries + * are rejected no matter which chunk receives the enqueue call. + */ +const RECENT_QUEUE_MESSAGE_IDS_KEY = Symbol.for("openclaw.recentQueueMessageIds"); + +const RECENT_QUEUE_MESSAGE_IDS = resolveGlobalSingleton(RECENT_QUEUE_MESSAGE_IDS_KEY, () => + createDedupeCache({ + ttlMs: 5 * 60 * 1000, + maxSize: 10_000, + }), +); function buildRecentMessageIdKey(run: FollowupRun, queueKey: string): string | undefined { const messageId = run.messageId?.trim(); diff --git a/src/auto-reply/reply/queue/state.ts b/src/auto-reply/reply/queue/state.ts index 73f7ed946bc..44208e727dd 100644 --- a/src/auto-reply/reply/queue/state.ts +++ b/src/auto-reply/reply/queue/state.ts @@ -1,3 +1,4 @@ +import { resolveGlobalMap } from "../../../shared/global-singleton.js"; import { applyQueueRuntimeSettings } from "../../../utils/queue-helpers.js"; import type { FollowupRun, QueueDropPolicy, QueueMode, QueueSettings } from "./types.js"; @@ -18,7 +19,13 @@ export const DEFAULT_QUEUE_DEBOUNCE_MS = 1000; export const DEFAULT_QUEUE_CAP = 20; export const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize"; -export const FOLLOWUP_QUEUES = new Map(); +/** + * Share followup queues across bundled chunks so busy-session enqueue/drain + * logic observes one queue registry per process. + */ +const FOLLOWUP_QUEUES_KEY = Symbol.for("openclaw.followupQueues"); + +export const FOLLOWUP_QUEUES = resolveGlobalMap(FOLLOWUP_QUEUES_KEY); export function getExistingFollowupQueue(key: string): FollowupQueueState | undefined { const cleaned = key.trim(); diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 575ac7f1780..d0fd692c2e1 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; @@ -743,6 +744,71 @@ describe("followup queue deduplication", () => { expect(calls).toHaveLength(1); }); + it("deduplicates same message_id across distinct enqueue module instances", async () => { + const enqueueA = await importFreshModule( + import.meta.url, + "./queue/enqueue.js?scope=dedupe-a", + ); + const enqueueB = await importFreshModule( + import.meta.url, + "./queue/enqueue.js?scope=dedupe-b", + ); + const { clearSessionQueues } = await import("./queue.js"); + const key = `test-dedup-cross-module-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + done.resolve(); + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueA.resetRecentQueuedMessageIdDedupe(); + enqueueB.resetRecentQueuedMessageIdDedupe(); + + try { + expect( + enqueueA.enqueueFollowupRun( + key, + createRun({ + prompt: "first", + messageId: "same-id", + originatingChannel: "signal", + originatingTo: "+10000000000", + }), + settings, + ), + ).toBe(true); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + await new Promise((resolve) => setImmediate(resolve)); + + expect( + enqueueB.enqueueFollowupRun( + key, + createRun({ + prompt: "first-redelivery", + messageId: "same-id", + originatingChannel: "signal", + originatingTo: "+10000000000", + }), + settings, + ), + ).toBe(false); + expect(calls).toHaveLength(1); + } finally { + clearSessionQueues([key]); + enqueueA.resetRecentQueuedMessageIdDedupe(); + enqueueB.resetRecentQueuedMessageIdDedupe(); + } + }); + it("does not collide recent message-id keys when routing contains delimiters", async () => { const key = `test-dedup-key-collision-${Date.now()}`; const calls: FollowupRun[] = []; @@ -1264,6 +1330,55 @@ describe("followup queue drain restart after idle window", () => { expect(calls[1]?.prompt).toBe("after-idle"); }); + it("restarts an idle drain across distinct enqueue and drain module instances", async () => { + const drainA = await importFreshModule( + import.meta.url, + "./queue/drain.js?scope=restart-a", + ); + const enqueueB = await importFreshModule( + import.meta.url, + "./queue/enqueue.js?scope=restart-b", + ); + const { clearSessionQueues } = await import("./queue.js"); + const key = `test-idle-window-cross-module-${Date.now()}`; + const calls: FollowupRun[] = []; + const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 }; + const firstProcessed = createDeferred(); + + enqueueB.resetRecentQueuedMessageIdDedupe(); + + try { + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length === 1) { + firstProcessed.resolve(); + } + }; + + enqueueB.enqueueFollowupRun(key, createRun({ prompt: "before-idle" }), settings); + drainA.scheduleFollowupDrain(key, runFollowup); + await firstProcessed.promise; + + await new Promise((resolve) => setImmediate(resolve)); + + enqueueB.enqueueFollowupRun(key, createRun({ prompt: "after-idle" }), settings); + + await vi.waitFor( + () => { + expect(calls).toHaveLength(2); + }, + { timeout: 1_000 }, + ); + + expect(calls[0]?.prompt).toBe("before-idle"); + expect(calls[1]?.prompt).toBe("after-idle"); + } finally { + clearSessionQueues([key]); + drainA.clearFollowupDrainCallback(key); + enqueueB.resetRecentQueuedMessageIdDedupe(); + } + }); + it("does not double-drain when a message arrives while drain is still running", async () => { const key = `test-no-double-drain-${Date.now()}`; const calls: FollowupRun[] = []; diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 359082c2616..d4814a263e9 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -4,6 +4,7 @@ import { listThinkingLevels, normalizeReasoningLevel, normalizeThinkLevel, + resolveThinkingDefaultForModel, } from "./thinking.js"; describe("normalizeThinkLevel", () => { @@ -84,6 +85,40 @@ describe("listThinkingLevelLabels", () => { }); }); +describe("resolveThinkingDefaultForModel", () => { + it("defaults Claude 4.6 models to adaptive", () => { + expect( + resolveThinkingDefaultForModel({ provider: "anthropic", model: "claude-opus-4-6" }), + ).toBe("adaptive"); + }); + + it("treats Bedrock Anthropic aliases as adaptive", () => { + expect( + resolveThinkingDefaultForModel({ provider: "aws-bedrock", model: "claude-sonnet-4-6" }), + ).toBe("adaptive"); + }); + + it("defaults reasoning-capable catalog models to low", () => { + expect( + resolveThinkingDefaultForModel({ + provider: "openai", + model: "gpt-5.4", + catalog: [{ provider: "openai", id: "gpt-5.4", reasoning: true }], + }), + ).toBe("low"); + }); + + it("defaults to off when no adaptive or reasoning hint is present", () => { + expect( + resolveThinkingDefaultForModel({ + provider: "openai", + model: "gpt-4.1-mini", + catalog: [{ provider: "openai", id: "gpt-4.1-mini", reasoning: false }], + }), + ).toBe("off"); + }); +}); + describe("normalizeReasoningLevel", () => { it("accepts on/off", () => { expect(normalizeReasoningLevel("on")).toBe("on"); diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 0a0f87c16e7..faaf5e39b13 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -5,6 +5,13 @@ export type ElevatedLevel = "off" | "on" | "ask" | "full"; export type ElevatedMode = "off" | "ask" | "full"; export type ReasoningLevel = "off" | "on" | "stream"; export type UsageDisplayLevel = "off" | "tokens" | "full"; +export type ThinkingCatalogEntry = { + provider: string; + id: string; + reasoning?: boolean; +}; + +const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; function normalizeProviderId(provider?: string | null): string { if (!provider) { @@ -14,6 +21,9 @@ function normalizeProviderId(provider?: string | null): string { if (normalized === "z.ai" || normalized === "z-ai") { return "zai"; } + if (normalized === "bedrock" || normalized === "aws-bedrock") { + return "amazon-bedrock"; + } return normalized; } @@ -130,6 +140,30 @@ export function formatXHighModelHint(): string { return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`; } +export function resolveThinkingDefaultForModel(params: { + provider: string; + model: string; + catalog?: ThinkingCatalogEntry[]; +}): ThinkLevel { + const normalizedProvider = normalizeProviderId(params.provider); + const modelLower = params.model.trim().toLowerCase(); + const isAnthropicFamilyModel = + normalizedProvider === "anthropic" || + normalizedProvider === "amazon-bedrock" || + modelLower.includes("anthropic/") || + modelLower.includes(".anthropic."); + if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) { + return "adaptive"; + } + const candidate = params.catalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + if (candidate?.reasoning) { + return "low"; + } + return "off"; +} + type OnOffFullLevel = "off" | "on" | "full"; function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined { diff --git a/src/commands/auth-choice-legacy.ts b/src/commands/auth-choice-legacy.ts index e93e920503f..d14ab4c6322 100644 --- a/src/commands/auth-choice-legacy.ts +++ b/src/commands/auth-choice-legacy.ts @@ -5,8 +5,6 @@ export const AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI: ReadonlyArray = [ "oauth", "claude-cli", "codex-cli", - "minimax-cloud", - "minimax", ]; export function normalizeLegacyOnboardAuthChoice( diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 077fee024b9..e5bab0e29fe 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -57,7 +57,7 @@ const AUTH_CHOICE_GROUP_DEFS: { value: "minimax", label: "MiniMax", hint: "M2.5 (recommended)", - choices: ["minimax-portal", "minimax-api", "minimax-api-key-cn", "minimax-api-lightning"], + choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"], }, { value: "moonshot", @@ -291,9 +291,24 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ label: "Xiaomi API key", }, { - value: "minimax-portal", - label: "MiniMax OAuth", - hint: "Oauth plugin for MiniMax", + value: "minimax-global-oauth", + label: "MiniMax Global — OAuth (minimax.io)", + hint: "Only supports OAuth for the coding plan", + }, + { + value: "minimax-global-api", + label: "MiniMax Global — API Key (minimax.io)", + hint: "sk-api- or sk-cp- keys supported", + }, + { + value: "minimax-cn-oauth", + label: "MiniMax CN — OAuth (minimaxi.com)", + hint: "Only supports OAuth for the coding plan", + }, + { + value: "minimax-cn-api", + label: "MiniMax CN — API Key (minimaxi.com)", + hint: "sk-api- or sk-cp- keys supported", }, { value: "qwen-portal", label: "Qwen OAuth" }, { @@ -307,17 +322,6 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ label: "OpenCode Zen catalog", hint: "Claude, GPT, Gemini via opencode.ai/zen", }, - { value: "minimax-api", label: "MiniMax M2.5" }, - { - value: "minimax-api-key-cn", - label: "MiniMax M2.5 (CN)", - hint: "China endpoint (api.minimaxi.com)", - }, - { - value: "minimax-api-lightning", - label: "MiniMax M2.5 Highspeed", - hint: "Official fast tier (legacy: Lightning)", - }, { value: "qianfan-api-key", label: "Qianfan API key" }, { value: "modelstudio-api-key-cn", diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts index 5998fde9484..9b5442b108c 100644 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { createAuthTestLifecycle, @@ -10,23 +9,6 @@ import { setupAuthTestEnv, } from "./test-wizard-helpers.js"; -function createMinimaxPrompter( - params: { - text?: WizardPrompter["text"]; - confirm?: WizardPrompter["confirm"]; - select?: WizardPrompter["select"]; - } = {}, -): WizardPrompter { - return createWizardPrompter( - { - text: params.text, - confirm: params.confirm, - select: params.select, - }, - { defaultSelect: "oauth" }, - ); -} - describe("applyAuthChoiceMiniMax", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", @@ -56,27 +38,25 @@ describe("applyAuthChoiceMiniMax", () => { async function runMiniMaxChoice(params: { authChoice: Parameters[0]["authChoice"]; opts?: Parameters[0]["opts"]; - env?: { apiKey?: string; oauthToken?: string }; - prompter?: Parameters[0]; + env?: { apiKey?: string }; + prompterText?: () => Promise; }) { const agentDir = await setupTempState(); resetMiniMaxEnv(); if (params.env?.apiKey !== undefined) { process.env.MINIMAX_API_KEY = params.env.apiKey; } - if (params.env?.oauthToken !== undefined) { - process.env.MINIMAX_OAUTH_TOKEN = params.env.oauthToken; - } const text = vi.fn(async () => "should-not-be-used"); const confirm = vi.fn(async () => true); const result = await applyAuthChoiceMiniMax({ authChoice: params.authChoice, config: {}, - prompter: createMinimaxPrompter({ - text, + // Pass select: undefined so ref-mode uses the non-interactive fallback (same as old test behavior). + prompter: createWizardPrompter({ + text: params.prompterText ?? text, confirm, - ...params.prompter, + select: undefined, }), runtime: createExitThrowingRuntime(), setDefaultModel: true, @@ -94,7 +74,7 @@ describe("applyAuthChoiceMiniMax", () => { const result = await applyAuthChoiceMiniMax({ authChoice: "openrouter-api-key", config: {}, - prompter: createMinimaxPrompter(), + prompter: createWizardPrompter({}), runtime: createExitThrowingRuntime(), setDefaultModel: true, }); @@ -104,61 +84,52 @@ describe("applyAuthChoiceMiniMax", () => { it.each([ { - caseName: "uses opts token for minimax-api without prompt", - authChoice: "minimax-api" as const, + caseName: "uses opts token for minimax-global-api without prompt", + authChoice: "minimax-global-api" as const, tokenProvider: "minimax", token: "mm-opts-token", - profileId: "minimax:default", - provider: "minimax", + profileId: "minimax:global", expectedModel: "minimax/MiniMax-M2.5", }, { - caseName: - "uses opts token for minimax-api-key-cn with trimmed/case-insensitive tokenProvider", - authChoice: "minimax-api-key-cn" as const, - tokenProvider: " MINIMAX-CN ", + caseName: "uses opts token for minimax-cn-api with trimmed/case-insensitive tokenProvider", + authChoice: "minimax-cn-api" as const, + tokenProvider: " MINIMAX ", token: "mm-cn-opts-token", - profileId: "minimax-cn:default", - provider: "minimax-cn", - expectedModel: "minimax-cn/MiniMax-M2.5", + profileId: "minimax:cn", + expectedModel: "minimax/MiniMax-M2.5", }, - ])( - "$caseName", - async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => { - const { agentDir, result, text, confirm } = await runMiniMaxChoice({ - authChoice, - opts: { - tokenProvider, - token, - }, - }); + ])("$caseName", async ({ authChoice, tokenProvider, token, profileId, expectedModel }) => { + const { agentDir, result, text, confirm } = await runMiniMaxChoice({ + authChoice, + opts: { tokenProvider, token }, + }); - expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.[profileId]).toMatchObject({ - provider, - mode: "api_key", - }); - expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( - expectedModel, - ); - expect(text).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result?.config.auth?.profiles?.[profileId]).toMatchObject({ + provider: "minimax", + mode: "api_key", + }); + expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( + expectedModel, + ); + expect(text).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); - const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.[profileId]?.key).toBe(token); - }, - ); + const parsed = await readAuthProfiles(agentDir); + expect(parsed.profiles?.[profileId]?.key).toBe(token); + }); it.each([ { - name: "uses env token for minimax-api-key-cn as plaintext by default", + name: "uses env token for minimax-cn-api as plaintext by default", opts: undefined, expectKey: "mm-env-token", expectKeyRef: undefined, expectConfirmCalls: 1, }, { - name: "uses env token for minimax-api-key-cn as keyRef in ref mode", + name: "uses env token for minimax-cn-api as keyRef in ref mode", opts: { secretInputMode: "ref" as const }, // pragma: allowlist secret expectKey: undefined, expectKeyRef: { @@ -170,54 +141,68 @@ describe("applyAuthChoiceMiniMax", () => { }, ])("$name", async ({ opts, expectKey, expectKeyRef, expectConfirmCalls }) => { const { agentDir, result, text, confirm } = await runMiniMaxChoice({ - authChoice: "minimax-api-key-cn", + authChoice: "minimax-cn-api", opts, env: { apiKey: "mm-env-token" }, // pragma: allowlist secret }); expect(result).not.toBeNull(); if (!opts) { - expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({ - provider: "minimax-cn", + expect(result?.config.auth?.profiles?.["minimax:cn"]).toMatchObject({ + provider: "minimax", mode: "api_key", }); expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( - "minimax-cn/MiniMax-M2.5", + "minimax/MiniMax-M2.5", ); } expect(text).not.toHaveBeenCalled(); expect(confirm).toHaveBeenCalledTimes(expectConfirmCalls); const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe(expectKey); + expect(parsed.profiles?.["minimax:cn"]?.key).toBe(expectKey); if (expectKeyRef) { - expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual(expectKeyRef); + expect(parsed.profiles?.["minimax:cn"]?.keyRef).toEqual(expectKeyRef); } else { - expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined(); + expect(parsed.profiles?.["minimax:cn"]?.keyRef).toBeUndefined(); } }); - it("uses minimax-api-lightning default model", async () => { + it("minimax-global-api uses minimax:global profile and minimax/MiniMax-M2.5 model", async () => { const { agentDir, result, text, confirm } = await runMiniMaxChoice({ - authChoice: "minimax-api-lightning", + authChoice: "minimax-global-api", opts: { tokenProvider: "minimax", - token: "mm-lightning-token", + token: "mm-global-token", }, }); expect(result).not.toBeNull(); - expect(result?.config.auth?.profiles?.["minimax:default"]).toMatchObject({ + expect(result?.config.auth?.profiles?.["minimax:global"]).toMatchObject({ provider: "minimax", mode: "api_key", }); expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe( - "minimax/MiniMax-M2.5-highspeed", + "minimax/MiniMax-M2.5", ); + expect(result?.config.models?.providers?.minimax?.baseUrl).toContain("minimax.io"); expect(text).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); const parsed = await readAuthProfiles(agentDir); - expect(parsed.profiles?.["minimax:default"]?.key).toBe("mm-lightning-token"); + expect(parsed.profiles?.["minimax:global"]?.key).toBe("mm-global-token"); + }); + + it("minimax-cn-api sets CN baseUrl", async () => { + const { result } = await runMiniMaxChoice({ + authChoice: "minimax-cn-api", + opts: { + tokenProvider: "minimax", + token: "mm-cn-token", + }, + }); + + expect(result).not.toBeNull(); + expect(result?.config.models?.providers?.minimax?.baseUrl).toContain("minimaxi.com"); }); }); diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index 86e5a485afd..1a381b908b8 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -12,130 +12,93 @@ import { applyMinimaxApiConfigCn, applyMinimaxApiProviderConfig, applyMinimaxApiProviderConfigCn, - applyMinimaxConfig, - applyMinimaxProviderConfig, setMinimaxApiKey, } from "./onboard-auth.js"; export async function applyAuthChoiceMiniMax( params: ApplyAuthChoiceParams, ): Promise { - let nextConfig = params.config; - let agentModelOverride: string | undefined; - const applyProviderDefaultModel = createAuthChoiceDefaultModelApplierForMutableState( - params, - () => nextConfig, - (config) => (nextConfig = config), - () => agentModelOverride, - (model) => (agentModelOverride = model), - ); - const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); - const ensureMinimaxApiKey = async (opts: { - profileId: string; - promptMessage: string; - }): Promise => { + // OAuth paths — delegate to plugin, no API key needed + if (params.authChoice === "minimax-global-oauth") { + return await applyAuthChoicePluginProvider(params, { + authChoice: "minimax-global-oauth", + pluginId: "minimax-portal-auth", + providerId: "minimax-portal", + methodId: "oauth", + label: "MiniMax", + }); + } + + if (params.authChoice === "minimax-cn-oauth") { + return await applyAuthChoicePluginProvider(params, { + authChoice: "minimax-cn-oauth", + pluginId: "minimax-portal-auth", + providerId: "minimax-portal", + methodId: "oauth-cn", + label: "MiniMax CN", + }); + } + + // API key paths + if (params.authChoice === "minimax-global-api" || params.authChoice === "minimax-cn-api") { + const isCn = params.authChoice === "minimax-cn-api"; + const profileId = isCn ? "minimax:cn" : "minimax:global"; + const keyLink = isCn + ? "https://platform.minimaxi.com/user-center/basic-information/interface-key" + : "https://platform.minimax.io/user-center/basic-information/interface-key"; + const promptMessage = `Enter MiniMax ${isCn ? "CN " : ""}API key (sk-api- or sk-cp-)\n${keyLink}`; + + let nextConfig = params.config; + let agentModelOverride: string | undefined; + const applyProviderDefaultModel = createAuthChoiceDefaultModelApplierForMutableState( + params, + () => nextConfig, + (config) => (nextConfig = config), + () => agentModelOverride, + (model) => (agentModelOverride = model), + ); + const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode); + + // Warn when both Global and CN share the same `minimax` provider entry — configuring one + // overwrites the other's baseUrl. Only show when the other profile is already present. + const otherProfileId = isCn ? "minimax:global" : "minimax:cn"; + const hasOtherProfile = Boolean(nextConfig.auth?.profiles?.[otherProfileId]); + const noteMessage = hasOtherProfile + ? `Note: Global and CN both use the "minimax" provider entry. Saving this key will overwrite the existing ${isCn ? "Global" : "CN"} endpoint (${otherProfileId}).` + : undefined; + await ensureApiKeyFromOptionEnvOrPrompt({ token: params.opts?.token, tokenProvider: params.opts?.tokenProvider, secretInputMode: requestedSecretInputMode, config: nextConfig, - expectedProviders: ["minimax", "minimax-cn"], + // Accept "minimax-cn" as a legacy tokenProvider alias for the CN path. + expectedProviders: isCn ? ["minimax", "minimax-cn"] : ["minimax"], provider: "minimax", envLabel: "MINIMAX_API_KEY", - promptMessage: opts.promptMessage, + promptMessage, normalize: normalizeApiKeyInput, validate: validateApiKeyInput, prompter: params.prompter, + noteMessage, setCredential: async (apiKey, mode) => - setMinimaxApiKey(apiKey, params.agentDir, opts.profileId, { secretInputMode: mode }), - }); - }; - const applyMinimaxApiVariant = async (opts: { - profileId: string; - provider: "minimax" | "minimax-cn"; - promptMessage: string; - modelRefPrefix: "minimax" | "minimax-cn"; - modelId: string; - applyDefaultConfig: ( - config: ApplyAuthChoiceParams["config"], - modelId: string, - ) => ApplyAuthChoiceParams["config"]; - applyProviderConfig: ( - config: ApplyAuthChoiceParams["config"], - modelId: string, - ) => ApplyAuthChoiceParams["config"]; - }): Promise => { - await ensureMinimaxApiKey({ - profileId: opts.profileId, - promptMessage: opts.promptMessage, + setMinimaxApiKey(apiKey, params.agentDir, profileId, { secretInputMode: mode }), }); + nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: opts.profileId, - provider: opts.provider, + profileId, + provider: "minimax", mode: "api_key", }); - const modelRef = `${opts.modelRefPrefix}/${opts.modelId}`; + await applyProviderDefaultModel({ - defaultModel: modelRef, - applyDefaultConfig: (config) => opts.applyDefaultConfig(config, opts.modelId), - applyProviderConfig: (config) => opts.applyProviderConfig(config, opts.modelId), - }); - return { config: nextConfig, agentModelOverride }; - }; - if (params.authChoice === "minimax-portal") { - // Let user choose between Global/CN endpoints - const endpoint = await params.prompter.select({ - message: "Select MiniMax endpoint", - options: [ - { value: "oauth", label: "Global", hint: "OAuth for international users" }, - { value: "oauth-cn", label: "CN", hint: "OAuth for users in China" }, - ], + defaultModel: "minimax/MiniMax-M2.5", + applyDefaultConfig: (config) => + isCn ? applyMinimaxApiConfigCn(config) : applyMinimaxApiConfig(config), + applyProviderConfig: (config) => + isCn ? applyMinimaxApiProviderConfigCn(config) : applyMinimaxApiProviderConfig(config), }); - return await applyAuthChoicePluginProvider(params, { - authChoice: "minimax-portal", - pluginId: "minimax-portal-auth", - providerId: "minimax-portal", - methodId: endpoint, - label: "MiniMax", - }); - } - - if ( - params.authChoice === "minimax-cloud" || - params.authChoice === "minimax-api" || - params.authChoice === "minimax-api-lightning" - ) { - return await applyMinimaxApiVariant({ - profileId: "minimax:default", - provider: "minimax", - promptMessage: "Enter MiniMax API key", - modelRefPrefix: "minimax", - modelId: - params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-highspeed" : "MiniMax-M2.5", - applyDefaultConfig: applyMinimaxApiConfig, - applyProviderConfig: applyMinimaxApiProviderConfig, - }); - } - - if (params.authChoice === "minimax-api-key-cn") { - return await applyMinimaxApiVariant({ - profileId: "minimax-cn:default", - provider: "minimax-cn", - promptMessage: "Enter MiniMax China API key", - modelRefPrefix: "minimax-cn", - modelId: "MiniMax-M2.5", - applyDefaultConfig: applyMinimaxApiConfigCn, - applyProviderConfig: applyMinimaxApiProviderConfigCn, - }); - } - - if (params.authChoice === "minimax") { - await applyProviderDefaultModel({ - defaultModel: "lmstudio/minimax-m2.5-gs32", - applyDefaultConfig: applyMinimaxConfig, - applyProviderConfig: applyMinimaxProviderConfig, - }); return { config: nextConfig, agentModelOverride }; } diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 7ebc0b24ea1..8adf42f3984 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -34,11 +34,10 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "huggingface-api-key": "huggingface", "github-copilot": "github-copilot", "copilot-proxy": "copilot-proxy", - "minimax-cloud": "minimax", - "minimax-api": "minimax", - "minimax-api-key-cn": "minimax-cn", - "minimax-api-lightning": "minimax", - minimax: "lmstudio", + "minimax-global-oauth": "minimax-portal", + "minimax-global-api": "minimax", + "minimax-cn-oauth": "minimax-portal", + "minimax-cn-api": "minimax", "opencode-zen": "opencode", "opencode-go": "opencode-go", "xai-api-key": "xai", @@ -46,7 +45,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "qwen-portal": "qwen-portal", "volcengine-api-key": "volcengine", "byteplus-api-key": "byteplus", - "minimax-portal": "minimax-portal", "qianfan-api-key": "qianfan", "custom-api-key": "custom", }; diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 6cdf32fa1d2..8651d5d024d 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -208,8 +208,8 @@ describe("applyAuthChoice", () => { it("prompts and writes provider API key for common providers", async () => { const scenarios: Array<{ authChoice: - | "minimax-api" - | "minimax-api-key-cn" + | "minimax-global-api" + | "minimax-cn-api" | "synthetic-api-key" | "huggingface-api-key"; promptContains: string; @@ -220,17 +220,17 @@ describe("applyAuthChoice", () => { expectedModelPrefix?: string; }> = [ { - authChoice: "minimax-api" as const, + authChoice: "minimax-global-api" as const, promptContains: "Enter MiniMax API key", - profileId: "minimax:default", + profileId: "minimax:global", provider: "minimax", token: "sk-minimax-test", }, { - authChoice: "minimax-api-key-cn" as const, - promptContains: "Enter MiniMax China API key", - profileId: "minimax-cn:default", - provider: "minimax-cn", + authChoice: "minimax-cn-api" as const, + promptContains: "Enter MiniMax CN API key", + profileId: "minimax:cn", + provider: "minimax", token: "sk-minimax-test", expectedBaseUrl: MINIMAX_CN_API_BASE_URL, }, @@ -1243,7 +1243,7 @@ describe("applyAuthChoice", () => { it("writes portal OAuth credentials for plugin providers", async () => { const scenarios: Array<{ - authChoice: "qwen-portal" | "minimax-portal"; + authChoice: "qwen-portal" | "minimax-global-oauth"; label: string; authId: string; authLabel: string; @@ -1268,7 +1268,7 @@ describe("applyAuthChoice", () => { apiKey: "qwen-oauth", // pragma: allowlist secret }, { - authChoice: "minimax-portal", + authChoice: "minimax-global-oauth", label: "MiniMax", authId: "oauth", authLabel: "MiniMax OAuth (Global)", @@ -1278,7 +1278,6 @@ describe("applyAuthChoice", () => { api: "anthropic-messages", defaultModel: "minimax-portal/MiniMax-M2.5", apiKey: "minimax-oauth", // pragma: allowlist secret - selectValue: "oauth", }, ]; for (const scenario of scenarios) { diff --git a/src/commands/onboard-auth.config-minimax.ts b/src/commands/onboard-auth.config-minimax.ts index 04c109f7e56..14ec734592b 100644 --- a/src/commands/onboard-auth.config-minimax.ts +++ b/src/commands/onboard-auth.config-minimax.ts @@ -1,5 +1,4 @@ import type { OpenClawConfig } from "../config/config.js"; -import { toAgentModelListLike } from "../config/model-input.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import { applyAgentDefaultModelPrimary, @@ -7,154 +6,10 @@ import { } from "./onboard-auth.config-shared.js"; import { buildMinimaxApiModelDefinition, - buildMinimaxModelDefinition, - DEFAULT_MINIMAX_BASE_URL, - DEFAULT_MINIMAX_CONTEXT_WINDOW, - DEFAULT_MINIMAX_MAX_TOKENS, MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL, - MINIMAX_HOSTED_COST, - MINIMAX_HOSTED_MODEL_ID, - MINIMAX_HOSTED_MODEL_REF, - MINIMAX_LM_STUDIO_COST, } from "./onboard-auth.models.js"; -export function applyMinimaxProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models["anthropic/claude-opus-4-6"] = { - ...models["anthropic/claude-opus-4-6"], - alias: models["anthropic/claude-opus-4-6"]?.alias ?? "Opus", - }; - models["lmstudio/minimax-m2.5-gs32"] = { - ...models["lmstudio/minimax-m2.5-gs32"], - alias: models["lmstudio/minimax-m2.5-gs32"]?.alias ?? "Minimax", - }; - - const providers = { ...cfg.models?.providers }; - if (!providers.lmstudio) { - providers.lmstudio = { - baseUrl: "http://127.0.0.1:1234/v1", - apiKey: "lmstudio", - api: "openai-responses", - models: [ - buildMinimaxModelDefinition({ - id: "minimax-m2.5-gs32", - name: "MiniMax M2.5 GS32", - reasoning: false, - cost: MINIMAX_LM_STUDIO_COST, - contextWindow: 196608, - maxTokens: 8192, - }), - ], - }; - } - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applyMinimaxHostedProviderConfig( - cfg: OpenClawConfig, - params?: { baseUrl?: string }, -): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[MINIMAX_HOSTED_MODEL_REF] = { - ...models[MINIMAX_HOSTED_MODEL_REF], - alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax", - }; - - const providers = { ...cfg.models?.providers }; - const hostedModel = buildMinimaxModelDefinition({ - id: MINIMAX_HOSTED_MODEL_ID, - cost: MINIMAX_HOSTED_COST, - contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, - maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, - }); - const existingProvider = providers.minimax; - const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; - const hasHostedModel = existingModels.some((model) => model.id === MINIMAX_HOSTED_MODEL_ID); - const mergedModels = hasHostedModel ? existingModels : [...existingModels, hostedModel]; - providers.minimax = { - ...existingProvider, - baseUrl: params?.baseUrl?.trim() || DEFAULT_MINIMAX_BASE_URL, - apiKey: "minimax", - api: "openai-completions", - models: mergedModels.length > 0 ? mergedModels : [hostedModel], - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applyMinimaxConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyMinimaxProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, "lmstudio/minimax-m2.5-gs32"); -} - -export function applyMinimaxHostedConfig( - cfg: OpenClawConfig, - params?: { baseUrl?: string }, -): OpenClawConfig { - const next = applyMinimaxHostedProviderConfig(cfg, params); - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: { - ...toAgentModelListLike(next.agents?.defaults?.model), - primary: MINIMAX_HOSTED_MODEL_REF, - }, - }, - }, - }; -} - -// MiniMax Anthropic-compatible API (platform.minimax.io/anthropic) -export function applyMinimaxApiProviderConfig( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_API_BASE_URL, - }); -} - -export function applyMinimaxApiConfig( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_API_BASE_URL, - }); -} - -// MiniMax China API (api.minimaxi.com) -export function applyMinimaxApiProviderConfigCn( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { - providerId: "minimax-cn", - modelId, - baseUrl: MINIMAX_CN_API_BASE_URL, - }); -} - -export function applyMinimaxApiConfigCn( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiConfigWithBaseUrl(cfg, { - providerId: "minimax-cn", - modelId, - baseUrl: MINIMAX_CN_API_BASE_URL, - }); -} - type MinimaxApiProviderConfigParams = { providerId: string; modelId: string; @@ -193,17 +48,7 @@ function applyMinimaxApiProviderConfigWithBaseUrl( alias: "Minimax", }; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - models: { mode: cfg.models?.mode ?? "merge", providers }, - }; + return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); } function applyMinimaxApiConfigWithBaseUrl( @@ -213,3 +58,49 @@ function applyMinimaxApiConfigWithBaseUrl( const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params); return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`); } + +// MiniMax Global API (platform.minimax.io/anthropic) +export function applyMinimaxApiProviderConfig( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_API_BASE_URL, + }); +} + +export function applyMinimaxApiConfig( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_API_BASE_URL, + }); +} + +// MiniMax CN API (api.minimaxi.com/anthropic) — same provider id, different baseUrl +export function applyMinimaxApiProviderConfigCn( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_CN_API_BASE_URL, + }); +} + +export function applyMinimaxApiConfigCn( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_CN_API_BASE_URL, + }); +} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index cda460b6c19..f51e61a8cee 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -50,10 +50,6 @@ export { applyMinimaxApiConfigCn, applyMinimaxApiProviderConfig, applyMinimaxApiProviderConfigCn, - applyMinimaxConfig, - applyMinimaxHostedConfig, - applyMinimaxHostedProviderConfig, - applyMinimaxProviderConfig, } from "./onboard-auth.config-minimax.js"; export { diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 9606b70259f..0c0e2f38fad 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -183,16 +183,16 @@ describe("onboard (non-interactive): provider auth", () => { it("stores MiniMax API key and uses global baseUrl by default", async () => { await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "minimax-api", + authChoice: "minimax-global-api", minimaxApiKey: "sk-minimax-test", // pragma: allowlist secret }); - expect(cfg.auth?.profiles?.["minimax:default"]?.provider).toBe("minimax"); - expect(cfg.auth?.profiles?.["minimax:default"]?.mode).toBe("api_key"); + expect(cfg.auth?.profiles?.["minimax:global"]?.provider).toBe("minimax"); + expect(cfg.auth?.profiles?.["minimax:global"]?.mode).toBe("api_key"); expect(cfg.models?.providers?.minimax?.baseUrl).toBe(MINIMAX_API_BASE_URL); expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); await expectApiKeyProfile({ - profileId: "minimax:default", + profileId: "minimax:global", provider: "minimax", key: "sk-minimax-test", }); @@ -202,17 +202,17 @@ describe("onboard (non-interactive): provider auth", () => { it("supports MiniMax CN API endpoint auth choice", async () => { await withOnboardEnv("openclaw-onboard-minimax-cn-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "minimax-api-key-cn", + authChoice: "minimax-cn-api", minimaxApiKey: "sk-minimax-test", // pragma: allowlist secret }); - expect(cfg.auth?.profiles?.["minimax-cn:default"]?.provider).toBe("minimax-cn"); - expect(cfg.auth?.profiles?.["minimax-cn:default"]?.mode).toBe("api_key"); - expect(cfg.models?.providers?.["minimax-cn"]?.baseUrl).toBe(MINIMAX_CN_API_BASE_URL); - expect(cfg.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5"); + expect(cfg.auth?.profiles?.["minimax:cn"]?.provider).toBe("minimax"); + expect(cfg.auth?.profiles?.["minimax:cn"]?.mode).toBe("api_key"); + expect(cfg.models?.providers?.minimax?.baseUrl).toBe(MINIMAX_CN_API_BASE_URL); + expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); await expectApiKeyProfile({ - profileId: "minimax-cn:default", - provider: "minimax-cn", + profileId: "minimax:cn", + provider: "minimax", key: "sk-minimax-test", }); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index af119c12efe..a4a5cc6e4c4 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -21,7 +21,6 @@ import { applyKimiCodeConfig, applyMinimaxApiConfig, applyMinimaxApiConfigCn, - applyMinimaxConfig, applyMoonshotConfig, applyMoonshotConfigCn, applyOpencodeGoConfig, @@ -863,22 +862,37 @@ export async function applyNonInteractiveAuthChoice(params: { return applyVeniceConfig(nextConfig); } - if ( - authChoice === "minimax-cloud" || - authChoice === "minimax-api" || - authChoice === "minimax-api-key-cn" || - authChoice === "minimax-api-lightning" - ) { - const isCn = authChoice === "minimax-api-key-cn"; - const providerId = isCn ? "minimax-cn" : "minimax"; - const profileId = `${providerId}:default`; + // Legacy aliases: these choice values were removed; fail with an actionable message so + // existing CI automation gets a clear error instead of silently exiting 0 with no auth. + const REMOVED_MINIMAX_CHOICES: Record = { + minimax: "minimax-global-api", + "minimax-api": "minimax-global-api", + "minimax-cloud": "minimax-global-api", + "minimax-api-lightning": "minimax-global-api", + "minimax-api-key-cn": "minimax-cn-api", + }; + if (Object.prototype.hasOwnProperty.call(REMOVED_MINIMAX_CHOICES, authChoice as string)) { + const replacement = REMOVED_MINIMAX_CHOICES[authChoice as string]; + runtime.error( + `"${authChoice as string}" is no longer supported. Use --auth-choice ${replacement} instead.`, + ); + runtime.exit(1); + return null; + } + + if (authChoice === "minimax-global-api" || authChoice === "minimax-cn-api") { + const isCn = authChoice === "minimax-cn-api"; + const profileId = isCn ? "minimax:cn" : "minimax:global"; const resolved = await resolveApiKey({ - provider: providerId, + provider: "minimax", cfg: baseConfig, flagValue: opts.minimaxApiKey, flagName: "--minimax-api-key", envVar: "MINIMAX_API_KEY", runtime, + // Disable profile fallback: both regions share provider "minimax", so an existing + // Global profile key must not be silently reused when configuring CN (and vice versa). + allowProfile: false, }); if (!resolved) { return null; @@ -892,18 +906,10 @@ export async function applyNonInteractiveAuthChoice(params: { } nextConfig = applyAuthProfileConfig(nextConfig, { profileId, - provider: providerId, + provider: "minimax", mode: "api_key", }); - const modelId = - authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-highspeed" : "MiniMax-M2.5"; - return isCn - ? applyMinimaxApiConfigCn(nextConfig, modelId) - : applyMinimaxApiConfig(nextConfig, modelId); - } - - if (authChoice === "minimax") { - return applyMinimaxConfig(nextConfig); + return isCn ? applyMinimaxApiConfigCn(nextConfig) : applyMinimaxApiConfig(nextConfig); } if (authChoice === "opencode-zen") { @@ -1091,7 +1097,8 @@ export async function applyNonInteractiveAuthChoice(params: { authChoice === "chutes" || authChoice === "openai-codex" || authChoice === "qwen-portal" || - authChoice === "minimax-portal" + authChoice === "minimax-global-oauth" || + authChoice === "minimax-cn-oauth" ) { runtime.error("OAuth requires interactive mode."); runtime.exit(1); diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts index 7610727097f..53df8cdc4c8 100644 --- a/src/commands/onboard-provider-auth-flags.ts +++ b/src/commands/onboard-provider-auth-flags.ts @@ -126,7 +126,7 @@ export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray }, { optionKey: "minimaxApiKey", - authChoice: "minimax-api", + authChoice: "minimax-global-api", cliFlag: "--minimax-api-key", cliOption: "--minimax-api-key ", description: "MiniMax API key", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 40a02e85c15..ef92d5ba02f 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -35,12 +35,10 @@ export type AuthChoice = | "zai-global" | "zai-cn" | "xiaomi-api-key" - | "minimax-cloud" - | "minimax" - | "minimax-api" - | "minimax-api-key-cn" - | "minimax-api-lightning" - | "minimax-portal" + | "minimax-global-oauth" + | "minimax-global-api" + | "minimax-cn-oauth" + | "minimax-cn-api" | "opencode-zen" | "opencode-go" | "github-copilot" diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 623868196c0..c271e0f7e9a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -934,6 +934,8 @@ export const FIELD_HELP: Record = { "Requires at least this many appended transcript messages before reindex is triggered (default: 50). Lower this for near-real-time transcript recall, or raise it to reduce indexing churn.", "agents.defaults.memorySearch.sync.sessions.includeResetArchives": "Includes reset transcript archives (`*.jsonl.reset.`) in builtin session-memory indexing (default: false). Enable only when reset snapshots should remain searchable.", + "agents.defaults.memorySearch.sync.sessions.postCompactionForce": + "Forces a session memory-search reindex after compaction-triggered transcript updates (default: true). Keep enabled when compacted summaries must be immediately searchable, or disable to reduce write-time indexing pressure.", ui: "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.", "ui.seamColor": "Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", @@ -1037,6 +1039,8 @@ export const FIELD_HELP: Record = { "Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.", "agents.defaults.compaction.qualityGuard.maxRetries": "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", + "agents.defaults.compaction.postIndexSync": + 'Controls post-compaction session memory reindex mode: "off", "async", or "await" (default: "async"). Use "await" for strongest freshness, "async" for lower compaction latency, and "off" only when session-memory sync is handled elsewhere.', "agents.defaults.compaction.postCompactionSections": 'AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use "Session Startup"/"Red Lines" with legacy fallback to "Every Session"/"Safety"; set to [] to disable reinjection entirely.', "agents.defaults.compaction.model": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index b28428a198a..0d3c5306206 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -356,6 +356,8 @@ export const FIELD_LABELS: Record = { "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", "agents.defaults.memorySearch.sync.sessions.includeResetArchives": "Include Reset Session Archives", + "agents.defaults.memorySearch.sync.sessions.postCompactionForce": + "Force Reindex After Compaction", "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", @@ -471,6 +473,7 @@ export const FIELD_LABELS: Record = { "agents.defaults.compaction.qualityGuard": "Compaction Quality Guard", "agents.defaults.compaction.qualityGuard.enabled": "Compaction Quality Guard Enabled", "agents.defaults.compaction.qualityGuard.maxRetries": "Compaction Quality Guard Max Retries", + "agents.defaults.compaction.postIndexSync": "Compaction Post-Index Sync", "agents.defaults.compaction.postCompactionSections": "Post-Compaction Context Sections", "agents.defaults.compaction.model": "Compaction Model Override", "agents.defaults.compaction.memoryFlush": "Compaction Memory Flush", diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index aee55706572..8d924c8feae 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -1,3 +1,4 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -10,7 +11,8 @@ import { } from "./targets.js"; async function resolveRealStorePath(sessionsDir: string): Promise { - return await fs.realpath(path.join(sessionsDir, "sessions.json")); + // Match the native realpath behavior used by both discovery paths. + return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json")); } describe("resolveSessionStoreTargets", () => { diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 0a676f98ddf..c647a17e41f 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -68,8 +68,8 @@ function resolveValidatedDiscoveredStorePathSync(params: { if (stat.isSymbolicLink() || !stat.isFile()) { return undefined; } - const realStorePath = fsSync.realpathSync(storePath); - const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync(params.agentsRoot); + const realStorePath = fsSync.realpathSync.native(storePath); + const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync.native(params.agentsRoot); return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined; } catch (err) { if (shouldSkipDiscoveryError(err)) { @@ -153,7 +153,7 @@ export function resolveAllAgentSessionStoreTargetsSync( return cached; } try { - const realAgentsRoot = fsSync.realpathSync(agentsRoot); + const realAgentsRoot = fsSync.realpathSync.native(agentsRoot); realAgentsRoots.set(agentsRoot, realAgentsRoot); return realAgentsRoot; } catch (err) { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 5abaab2c169..11d1809c86a 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -287,6 +287,7 @@ export type AgentDefaultsConfig = { }; export type AgentCompactionMode = "default" | "safeguard"; +export type AgentCompactionPostIndexSyncMode = "off" | "async" | "await"; export type AgentCompactionIdentifierPolicy = "strict" | "off" | "custom"; export type AgentCompactionQualityGuardConfig = { /** Enable compaction summary quality audits and regeneration retries. Default: false. */ @@ -314,6 +315,8 @@ export type AgentCompactionConfig = { identifierInstructions?: string; /** Optional quality-audit retries for safeguard compaction summaries. */ qualityGuard?: AgentCompactionQualityGuardConfig; + /** Post-compaction session memory index sync mode. */ + postIndexSync?: AgentCompactionPostIndexSyncMode; /** Pre-compaction memory flush (agentic turn). Default: enabled. */ memoryFlush?: AgentCompactionMemoryFlushConfig; /** diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index d06b95c2ed4..9e662fe957d 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -404,6 +404,8 @@ export type MemorySearchConfig = { deltaMessages?: number; /** Include reset transcript archives (`*.jsonl.reset.`) in session indexing. */ includeResetArchives?: boolean; + /** Force session reindex after compaction-triggered transcript updates (default: true). */ + postCompactionForce?: boolean; }; }; /** Query behavior. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 242d6959729..02148736e2a 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -103,6 +103,7 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), + postIndexSync: z.enum(["off", "async", "await"]).optional(), postCompactionSections: z.array(z.string()).optional(), model: z.string().optional(), memoryFlush: z diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index bbf4cbcc4c0..a2ed1197a20 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -650,6 +650,7 @@ export const MemorySearchSchema = z deltaBytes: z.number().int().nonnegative().optional(), deltaMessages: z.number().int().nonnegative().optional(), includeResetArchives: z.boolean().optional(), + postCompactionForce: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d68ac63759c..2b2fccee310 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -104,8 +104,8 @@ export const TelegramDirectSchema = z const TelegramCustomCommandSchema = z .object({ - command: z.string().transform(normalizeTelegramCommandName), - description: z.string().transform(normalizeTelegramCommandDescription), + command: z.string().overwrite(normalizeTelegramCommandName), + description: z.string().overwrite(normalizeTelegramCommandDescription), }) .strict(); diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 3d9b7dc4fc1..cd0f2f50439 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -61,6 +61,7 @@ class MockContextEngine implements ContextEngine { async ingest(_params: { sessionId: string; + sessionKey?: string; message: AgentMessage; isHeartbeat?: boolean; }): Promise { @@ -69,6 +70,7 @@ class MockContextEngine implements ContextEngine { async assemble(params: { sessionId: string; + sessionKey?: string; messages: AgentMessage[]; tokenBudget?: number; }): Promise { @@ -81,6 +83,7 @@ class MockContextEngine implements ContextEngine { async compact(_params: { sessionId: string; + sessionKey?: string; sessionFile: string; tokenBudget?: number; compactionTarget?: "budget" | "threshold"; diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index ffeb5cab9bd..0485a4feae4 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -26,6 +26,7 @@ export class LegacyContextEngine implements ContextEngine { async ingest(_params: { sessionId: string; + sessionKey?: string; message: AgentMessage; isHeartbeat?: boolean; }): Promise { @@ -35,6 +36,7 @@ export class LegacyContextEngine implements ContextEngine { async assemble(params: { sessionId: string; + sessionKey?: string; messages: AgentMessage[]; tokenBudget?: number; }): Promise { @@ -49,6 +51,7 @@ export class LegacyContextEngine implements ContextEngine { async afterTurn(_params: { sessionId: string; + sessionKey?: string; sessionFile: string; messages: AgentMessage[]; prePromptMessageCount: number; @@ -62,6 +65,7 @@ export class LegacyContextEngine implements ContextEngine { async compact(params: { sessionId: string; + sessionKey?: string; sessionFile: string; tokenBudget?: number; force?: boolean; diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index b886190a1e0..7ddd695b5b6 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -72,13 +72,18 @@ export interface ContextEngine { /** * Initialize engine state for a session, optionally importing historical context. */ - bootstrap?(params: { sessionId: string; sessionFile: string }): Promise; + bootstrap?(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + }): Promise; /** * Ingest a single message into the engine's store. */ ingest(params: { sessionId: string; + sessionKey?: string; message: AgentMessage; /** True when the message belongs to a heartbeat run. */ isHeartbeat?: boolean; @@ -89,6 +94,7 @@ export interface ContextEngine { */ ingestBatch?(params: { sessionId: string; + sessionKey?: string; messages: AgentMessage[]; /** True when the batch belongs to a heartbeat run. */ isHeartbeat?: boolean; @@ -101,6 +107,7 @@ export interface ContextEngine { */ afterTurn?(params: { sessionId: string; + sessionKey?: string; sessionFile: string; messages: AgentMessage[]; /** Number of messages that existed before the prompt was sent. */ @@ -121,6 +128,7 @@ export interface ContextEngine { */ assemble(params: { sessionId: string; + sessionKey?: string; messages: AgentMessage[]; tokenBudget?: number; }): Promise; @@ -131,6 +139,7 @@ export interface ContextEngine { */ compact(params: { sessionId: string; + sessionKey?: string; sessionFile: string; tokenBudget?: number; /** Force compaction even below the default trigger threshold. */ diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index ded56348733..8220cccb062 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -16,6 +16,7 @@ import { resolveGatewayCredentialsFromValues } from "./credentials.js"; import { isLocalishHost, isLoopbackAddress, + resolveRequestClientIp, isTrustedProxyAddress, resolveClientIp, } from "./net.js"; @@ -105,23 +106,6 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { }); } -function resolveRequestClientIp( - req?: IncomingMessage, - trustedProxies?: string[], - allowRealIpFallback = false, -): string | undefined { - if (!req) { - return undefined; - } - return resolveClientIp({ - remoteAddr: req.socket?.remoteAddress ?? "", - forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), - realIp: headerValue(req.headers?.["x-real-ip"]), - trustedProxies, - allowRealIpFallback, - }); -} - export function isLocalDirectRequest( req?: IncomingMessage, trustedProxies?: string[], diff --git a/src/gateway/hooks-test-helpers.ts b/src/gateway/hooks-test-helpers.ts index ca0988edbfe..0351b829f28 100644 --- a/src/gateway/hooks-test-helpers.ts +++ b/src/gateway/hooks-test-helpers.ts @@ -26,9 +26,11 @@ export function createGatewayRequest(params: { method?: string; remoteAddress?: string; host?: string; + headers?: Record; }): IncomingMessage { const headers: Record = { host: params.host ?? "localhost:18789", + ...params.headers, }; if (params.authorization) { headers.authorization = params.authorization; diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 957056babcd..32751369f23 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -99,7 +99,7 @@ function resolveKnownAgentIds(cfg: OpenClawConfig, defaultAgentId: string): Set< return known; } -function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { +export function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { if (!Array.isArray(raw)) { return undefined; } diff --git a/src/gateway/net.ts b/src/gateway/net.ts index db8779606a5..3ea32fc1659 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage } from "node:http"; import net from "node:net"; import os from "node:os"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; @@ -184,6 +185,27 @@ export function resolveClientIp(params: { return undefined; } +function headerValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +export function resolveRequestClientIp( + req?: IncomingMessage, + trustedProxies?: string[], + allowRealIpFallback = false, +): string | undefined { + if (!req) { + return undefined; + } + return resolveClientIp({ + remoteAddr: req.socket?.remoteAddress ?? "", + forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), + realIp: headerValue(req.headers?.["x-real-ip"]), + trustedProxies, + allowRealIpFallback, + }); +} + export function isLocalGatewayAddress(ip: string | undefined): boolean { if (isLoopbackAddress(ip)) { return true; diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts index 0452cab7b9a..4a8c1ec3490 100644 --- a/src/gateway/server-http.hooks-request-timeout.test.ts +++ b/src/gateway/server-http.hooks-request-timeout.test.ts @@ -1,7 +1,9 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { createSubsystemLogger } from "../logging/subsystem.js"; -import { createGatewayRequest, createHooksConfig } from "./hooks-test-helpers.js"; +import { + createHookRequest, + createHooksHandler, + createResponse, +} from "./server-http.test-harness.js"; const { readJsonBodyMock } = vi.hoisted(() => ({ readJsonBodyMock: vi.fn(), @@ -15,64 +17,6 @@ vi.mock("./hooks.js", async (importOriginal) => { }; }); -import { createHooksRequestHandler } from "./server-http.js"; - -type HooksHandlerDeps = Parameters[0]; - -function createRequest(params?: { - authorization?: string; - remoteAddress?: string; - url?: string; -}): IncomingMessage { - return createGatewayRequest({ - method: "POST", - path: params?.url ?? "/hooks/wake", - host: "127.0.0.1:18789", - authorization: params?.authorization ?? "Bearer hook-secret", - remoteAddress: params?.remoteAddress, - }); -} - -function createResponse(): { - res: ServerResponse; - end: ReturnType; - setHeader: ReturnType; -} { - const setHeader = vi.fn(); - const end = vi.fn(); - const res = { - statusCode: 200, - setHeader, - end, - } as unknown as ServerResponse; - return { res, end, setHeader }; -} - -function createHandler(params?: { - dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"]; - dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"]; - bindHost?: string; -}) { - return createHooksRequestHandler({ - getHooksConfig: () => createHooksConfig(), - bindHost: params?.bindHost ?? "127.0.0.1", - port: 18789, - logHooks: { - warn: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - error: vi.fn(), - } as unknown as ReturnType, - dispatchWakeHook: - params?.dispatchWakeHook ?? - ((() => { - return; - }) as HooksHandlerDeps["dispatchWakeHook"]), - dispatchAgentHook: - params?.dispatchAgentHook ?? ((() => "run-1") as HooksHandlerDeps["dispatchAgentHook"]), - }); -} - describe("createHooksRequestHandler timeout status mapping", () => { beforeEach(() => { readJsonBodyMock.mockClear(); @@ -82,8 +26,8 @@ describe("createHooksRequestHandler timeout status mapping", () => { readJsonBodyMock.mockResolvedValue({ ok: false, error: "request body timeout" }); const dispatchWakeHook = vi.fn(); const dispatchAgentHook = vi.fn(() => "run-1"); - const handler = createHandler({ dispatchWakeHook, dispatchAgentHook }); - const req = createRequest(); + const handler = createHooksHandler({ dispatchWakeHook, dispatchAgentHook }); + const req = createHookRequest(); const { res, end } = createResponse(); const handled = await handler(req, res); @@ -96,10 +40,10 @@ describe("createHooksRequestHandler timeout status mapping", () => { }); test("shares hook auth rate-limit bucket across ipv4 and ipv4-mapped ipv6 forms", async () => { - const handler = createHandler(); + const handler = createHooksHandler({ bindHost: "127.0.0.1" }); for (let i = 0; i < 20; i++) { - const req = createRequest({ + const req = createHookRequest({ authorization: "Bearer wrong", remoteAddress: "1.2.3.4", }); @@ -109,7 +53,7 @@ describe("createHooksRequestHandler timeout status mapping", () => { expect(res.statusCode).toBe(401); } - const mappedReq = createRequest({ + const mappedReq = createHookRequest({ authorization: "Bearer wrong", remoteAddress: "::ffff:1.2.3.4", }); @@ -121,11 +65,41 @@ describe("createHooksRequestHandler timeout status mapping", () => { expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String)); }); + test("uses trusted proxy forwarded client ip for hook auth throttling", async () => { + const handler = createHooksHandler({ + getClientIpConfig: () => ({ trustedProxies: ["10.0.0.1"] }), + }); + + for (let i = 0; i < 20; i++) { + const req = createHookRequest({ + authorization: "Bearer wrong", + remoteAddress: "10.0.0.1", + headers: { "x-forwarded-for": "1.2.3.4" }, + }); + const { res } = createResponse(); + const handled = await handler(req, res); + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + } + + const forwardedReq = createHookRequest({ + authorization: "Bearer wrong", + remoteAddress: "10.0.0.1", + headers: { "x-forwarded-for": "1.2.3.4, 10.0.0.1" }, + }); + const { res: forwardedRes, setHeader } = createResponse(); + const handled = await handler(forwardedReq, forwardedRes); + + expect(handled).toBe(true); + expect(forwardedRes.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String)); + }); + test.each(["0.0.0.0", "::"])( "does not throw when bindHost=%s while parsing non-hook request URL", async (bindHost) => { - const handler = createHandler({ bindHost }); - const req = createRequest({ url: "/" }); + const handler = createHooksHandler({ bindHost }); + const req = createHookRequest({ url: "/" }); const { res, end } = createResponse(); const handled = await handler(req, res); diff --git a/src/gateway/server-http.test-harness.ts b/src/gateway/server-http.test-harness.ts index 24612d60b1f..1adf863e461 100644 --- a/src/gateway/server-http.test-harness.ts +++ b/src/gateway/server-http.test-harness.ts @@ -9,6 +9,7 @@ import { withTempConfig } from "./test-temp-config.js"; export type GatewayHttpServer = ReturnType; export type GatewayServerOptions = Partial[0]>; +type HooksHandlerDeps = Parameters[0]; export const AUTH_NONE: ResolvedGatewayAuth = { mode: "none", @@ -30,6 +31,7 @@ export function createRequest(params: { method?: string; remoteAddress?: string; host?: string; + headers?: Record; }): IncomingMessage { return createGatewayRequest({ path: params.path, @@ -37,6 +39,23 @@ export function createRequest(params: { method: params.method, remoteAddress: params.remoteAddress, host: params.host, + headers: params.headers, + }); +} + +export function createHookRequest(params?: { + authorization?: string; + remoteAddress?: string; + url?: string; + headers?: Record; +}): IncomingMessage { + return createRequest({ + method: "POST", + path: params?.url ?? "/hooks/wake", + host: "127.0.0.1:18789", + authorization: params?.authorization ?? "Bearer hook-secret", + remoteAddress: params?.remoteAddress, + headers: params?.headers, }); } @@ -162,10 +181,20 @@ export function createCanonicalizedChannelPluginHandler() { }); } -export function createHooksHandler(bindHost: string) { +export function createHooksHandler( + params: + | string + | { + dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"]; + dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"]; + bindHost?: string; + getClientIpConfig?: HooksHandlerDeps["getClientIpConfig"]; + }, +) { + const options = typeof params === "string" ? { bindHost: params } : params; return createHooksRequestHandler({ getHooksConfig: () => createHooksConfig(), - bindHost, + bindHost: options.bindHost ?? "127.0.0.1", port: 18789, logHooks: { warn: vi.fn(), @@ -173,8 +202,9 @@ export function createHooksHandler(bindHost: string) { info: vi.fn(), error: vi.fn(), } as unknown as ReturnType, - dispatchWakeHook: () => {}, - dispatchAgentHook: () => "run-1", + getClientIpConfig: options.getClientIpConfig, + dispatchWakeHook: options.dispatchWakeHook ?? (() => {}), + dispatchAgentHook: options.dispatchAgentHook ?? (() => "run-1"), }); } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 89db12bc24e..ad3a0e305fa 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -52,6 +52,7 @@ import { } from "./hooks.js"; import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; import { getBearerToken } from "./http-utils.js"; +import { resolveRequestClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { @@ -79,6 +80,11 @@ type HookDispatchers = { dispatchAgentHook: (value: HookAgentDispatchPayload) => string; }; +export type HookClientIpConfig = Readonly<{ + trustedProxies?: string[]; + allowRealIpFallback?: boolean; +}>; + function sendJson(res: ServerResponse, status: number, body: unknown) { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -351,9 +357,10 @@ export function createHooksRequestHandler( bindHost: string; port: number; logHooks: SubsystemLogger; + getClientIpConfig?: () => HookClientIpConfig; } & HookDispatchers, ): HooksRequestHandler { - const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook } = opts; + const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook, getClientIpConfig } = opts; const hookAuthLimiter = createAuthRateLimiter({ maxAttempts: HOOK_AUTH_FAILURE_LIMIT, windowMs: HOOK_AUTH_FAILURE_WINDOW_MS, @@ -364,7 +371,14 @@ export function createHooksRequestHandler( }); const resolveHookClientKey = (req: IncomingMessage): string => { - return normalizeRateLimitClientIp(req.socket?.remoteAddress); + const clientIpConfig = getClientIpConfig?.(); + const clientIp = + resolveRequestClientIp( + req, + clientIpConfig?.trustedProxies, + clientIpConfig?.allowRealIpFallback === true, + ) ?? req.socket?.remoteAddress; + return normalizeRateLimitClientIp(clientIp); }; return async (req, res) => { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 1d3d1c85977..6e6cf9e92e3 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,3 +1,4 @@ +import { exec } from "node:child_process"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { @@ -529,4 +530,19 @@ export const configHandlers: GatewayRequestHandlers = { undefined, ); }, + "config.openFile": ({ params, respond }) => { + if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) { + return; + } + const configPath = createConfigIO().configPath; + const platform = process.platform; + const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open"; + exec(`${cmd} ${JSON.stringify(configPath)}`, (err) => { + if (err) { + respond(true, { ok: false, path: configPath, error: err.message }, undefined); + return; + } + respond(true, { ok: true, path: configPath }, undefined); + }); + }, }; diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 73e8129e189..f9cfb9111fe 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -22,9 +22,12 @@ import type { GatewayReloadPlan } from "./config-reload.js"; import { resolveHooksConfig } from "./hooks.js"; import { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js"; +import type { HookClientIpConfig } from "./server-http.js"; +import { resolveHookClientIpConfig } from "./server/hooks.js"; type GatewayHotReloadState = { hooksConfig: ReturnType; + hookClientIpConfig: HookClientIpConfig; heartbeatRunner: HeartbeatRunner; cronState: GatewayCronState; browserControl: Awaited> | null; @@ -64,6 +67,7 @@ export function createGatewayReloadHandlers(params: { params.logHooks.warn(`hooks config reload failed: ${String(err)}`); } } + nextState.hookClientIpConfig = resolveHookClientIpConfig(nextConfig); if (plan.restartHeartbeat) { nextState.heartbeatRunner.updateConfig(nextConfig); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 52832de93b8..a569b896e54 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -23,7 +23,11 @@ import { createToolEventRecipientRegistry, } from "./server-chat.js"; import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; -import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; +import { + attachGatewayUpgradeHandler, + createGatewayHttpServer, + type HookClientIpConfig, +} from "./server-http.js"; import type { DedupeEntry } from "./server-shared.js"; import { createGatewayHooksRequestHandler } from "./server/hooks.js"; import { listenGatewayHttpServer } from "./server/http-listen.js"; @@ -53,6 +57,7 @@ export async function createGatewayRuntimeState(params: { rateLimiter?: AuthRateLimiter; gatewayTls?: GatewayTlsRuntime; hooksConfig: () => HooksConfigResolved | null; + getHookClientIpConfig: () => HookClientIpConfig; pluginRegistry: PluginRegistry; deps: CliDeps; canvasRuntime: RuntimeEnv; @@ -113,6 +118,7 @@ export async function createGatewayRuntimeState(params: { const handleHooksRequest = createGatewayHooksRequestHandler({ deps: params.deps, getHooksConfig: params.hooksConfig, + getClientIpConfig: params.getHookClientIpConfig, bindHost: params.bindHost, port: params.port, logHooks: params.logHooks, diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index d63b62b8b88..8c6ea06978c 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -6,6 +6,7 @@ import { getFreePort, openWs, originForPort, + rpcReq, restoreGatewayToken, startGatewayServer, testState, @@ -62,6 +63,24 @@ describe("gateway auth compatibility baseline", () => { } }); + test("clears client-declared scopes for shared-token operator connects", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + token: "secret", + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok).toBe(true); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(false); + expect(adminRes.error?.message).toBe("missing scope: operator.admin"); + } finally { + ws.close(); + } + }); + test("returns stable token-missing details for control ui without token", async () => { const ws = await openWs(port, { origin: originForPort(port) }); try { @@ -163,6 +182,24 @@ describe("gateway auth compatibility baseline", () => { ws.close(); } }); + + test("clears client-declared scopes for shared-password operator connects", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + password: "secret", + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok).toBe(true); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(false); + expect(adminRes.error?.message).toBe("missing scope: operator.admin"); + } finally { + ws.close(); + } + }); }); describe("none mode", () => { diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 12698faf3bf..44863f61f31 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -91,6 +91,11 @@ export function registerControlUiAndPairingSuite(): void { expect(health.ok).toBe(true); }; + const expectAdminRpcOk = async (ws: WebSocket) => { + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(true); + }; + const connectControlUiWithoutDeviceAndExpectOk = async (params: { ws: WebSocket; token?: string; @@ -104,6 +109,7 @@ export function registerControlUiAndPairingSuite(): void { }); expect(res.ok).toBe(true); await expectStatusAndHealthOk(params.ws); + await expectAdminRpcOk(params.ws); }; const createOperatorIdentityFixture = async (identityPrefix: string) => { @@ -217,6 +223,9 @@ export function registerControlUiAndPairingSuite(): void { } if (tc.expectStatusChecks) { await expectStatusAndHealthOk(ws); + if (tc.role === "operator") { + await expectAdminRpcOk(ws); + } } ws.close(); }); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 898cdc6fe87..9b3941d1432 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -107,6 +107,7 @@ import { incrementPresenceVersion, refreshGatewayHealthSnapshot, } from "./server/health-state.js"; +import { resolveHookClientIpConfig } from "./server/hooks.js"; import { createReadinessChecker } from "./server/readiness.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; import { @@ -511,6 +512,7 @@ export async function startGatewayServer( tailscaleMode, } = runtimeConfig; let hooksConfig = runtimeConfig.hooksConfig; + let hookClientIpConfig = resolveHookClientIpConfig(cfgAtStart); const canvasHostEnabled = runtimeConfig.canvasHostEnabled; // Create auth rate limiters used by connect/auth flows. @@ -613,6 +615,7 @@ export async function startGatewayServer( rateLimiter: authRateLimiter, gatewayTls, hooksConfig: () => hooksConfig, + getHookClientIpConfig: () => hookClientIpConfig, pluginRegistry, deps, canvasRuntime, @@ -954,6 +957,7 @@ export async function startGatewayServer( broadcast, getState: () => ({ hooksConfig, + hookClientIpConfig, heartbeatRunner, cronState, browserControl, @@ -961,6 +965,7 @@ export async function startGatewayServer( }), setState: (nextState) => { hooksConfig = nextState.hooksConfig; + hookClientIpConfig = nextState.hookClientIpConfig; heartbeatRunner = nextState.heartbeatRunner; cronState = nextState.cronState; cron = cronState.cron; diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 3b159c680af..0ba718adcc3 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { CliDeps } from "../../cli/deps.js"; -import { loadConfig } from "../../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../../config/config.js"; import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js"; import { runCronIsolatedAgentTurn } from "../../cron/isolated-agent.js"; import type { CronJob } from "../../cron/types.js"; @@ -12,18 +12,26 @@ import { type HookAgentDispatchPayload, type HooksConfigResolved, } from "../hooks.js"; -import { createHooksRequestHandler } from "../server-http.js"; +import { createHooksRequestHandler, type HookClientIpConfig } from "../server-http.js"; type SubsystemLogger = ReturnType; +export function resolveHookClientIpConfig(cfg: OpenClawConfig): HookClientIpConfig { + return { + trustedProxies: cfg.gateway?.trustedProxies, + allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true, + }; +} + export function createGatewayHooksRequestHandler(params: { deps: CliDeps; getHooksConfig: () => HooksConfigResolved | null; + getClientIpConfig: () => HookClientIpConfig; bindHost: string; port: number; logHooks: SubsystemLogger; }) { - const { deps, getHooksConfig, bindHost, port, logHooks } = params; + const { deps, getHooksConfig, getClientIpConfig, bindHost, port, logHooks } = params; const dispatchWakeHook = (value: { text: string; mode: "now" | "next-heartbeat" }) => { const sessionKey = resolveMainSessionKeyFromConfig(); @@ -108,6 +116,7 @@ export function createGatewayHooksRequestHandler(params: { bindHost, port, logHooks, + getClientIpConfig, dispatchAgentHook, dispatchWakeHook, }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 7cd7e6450cb..0c71ee9dfe8 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -643,15 +643,12 @@ export function attachGatewayWsMessageHandler(params: { close(1008, truncateCloseReason(authMessage)); }; const clearUnboundScopes = () => { - if (scopes.length > 0 && !controlUiAuthPolicy.allowBypass && !sharedAuthOk) { + if (scopes.length > 0) { scopes = []; connectParams.scopes = scopes; } }; const handleMissingDeviceIdentity = (): boolean => { - if (!device) { - clearUnboundScopes(); - } const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, role, @@ -670,6 +667,9 @@ export function attachGatewayWsMessageHandler(params: { hasSharedAuth, isLocalClient, }); + if (!device && (!isControlUi || decision.kind !== "allow")) { + clearUnboundScopes(); + } if (decision.kind === "allow") { return true; } diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index af90c96d1b9..3c69ce1bcd7 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -22,6 +22,10 @@ import { resolveSessionStoreKey, } from "./session-utils.js"; +function resolveSyncRealpath(filePath: string): string { + return fs.realpathSync.native(filePath); +} + function createSymlinkOrSkip(targetPath: string, linkPath: string): boolean { try { fs.symlinkSync(targetPath, linkPath); @@ -287,7 +291,7 @@ describe("gateway session utils", () => { const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:retired-agent:main" }); - expect(target.storePath).toBe(fs.realpathSync(retiredStorePath)); + expect(target.storePath).toBe(resolveSyncRealpath(retiredStorePath)); }); }); @@ -316,7 +320,7 @@ describe("gateway session utils", () => { const loaded = loadSessionEntry("agent:retired-agent:main"); - expect(loaded.storePath).toBe(fs.realpathSync(retiredStorePath)); + expect(loaded.storePath).toBe(resolveSyncRealpath(retiredStorePath)); expect(loaded.entry?.sessionId).toBe("sess-retired"); }); } finally { diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 23371056b18..dcb0b061073 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -461,6 +461,391 @@ describe("memory index", () => { } }); + it("targets explicit session files during post-compaction sync", async () => { + const stateDir = path.join(fixtureRoot, `state-targeted-${randomUUID()}`); + const sessionDir = path.join(stateDir, "agents", "main", "sessions"); + const firstSessionPath = path.join(sessionDir, "targeted-first.jsonl"); + const secondSessionPath = path.join(sessionDir, "targeted-second.jsonl"); + const storePath = path.join(workspaceDir, `index-targeted-${randomUUID()}.sqlite`); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + firstSessionPath, + `${JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "first transcript v1" }] }, + })}\n`, + ); + await fs.writeFile( + secondSessionPath, + `${JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "second transcript v1" }] }, + })}\n`, + ); + + try { + const result = await getMemorySearchManager({ + cfg: createCfg({ + storePath, + sources: ["sessions"], + sessionMemory: true, + }), + agentId: "main", + }); + const manager = requireManager(result); + await manager.sync?.({ reason: "test" }); + + const db = ( + manager as unknown as { + db: { + prepare: (sql: string) => { + get: (path: string, source: string) => { hash: string } | undefined; + }; + }; + } + ).db; + const getSessionHash = (sessionPath: string) => + db + .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`) + .get(sessionPath, "sessions")?.hash; + + const firstOriginalHash = getSessionHash("sessions/targeted-first.jsonl"); + const secondOriginalHash = getSessionHash("sessions/targeted-second.jsonl"); + + await fs.writeFile( + firstSessionPath, + `${JSON.stringify({ + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "first transcript v2 after compaction" }], + }, + })}\n`, + ); + await fs.writeFile( + secondSessionPath, + `${JSON.stringify({ + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "second transcript v2 should stay untouched" }], + }, + })}\n`, + ); + + await manager.sync?.({ + reason: "post-compaction", + sessionFiles: [firstSessionPath], + }); + + expect(getSessionHash("sessions/targeted-first.jsonl")).not.toBe(firstOriginalHash); + expect(getSessionHash("sessions/targeted-second.jsonl")).toBe(secondOriginalHash); + await manager.close?.(); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("preserves unrelated dirty sessions after targeted post-compaction sync", async () => { + const stateDir = path.join(fixtureRoot, `state-targeted-dirty-${randomUUID()}`); + const sessionDir = path.join(stateDir, "agents", "main", "sessions"); + const firstSessionPath = path.join(sessionDir, "targeted-dirty-first.jsonl"); + const secondSessionPath = path.join(sessionDir, "targeted-dirty-second.jsonl"); + const storePath = path.join(workspaceDir, `index-targeted-dirty-${randomUUID()}.sqlite`); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + firstSessionPath, + `${JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "first transcript v1" }] }, + })}\n`, + ); + await fs.writeFile( + secondSessionPath, + `${JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "second transcript v1" }] }, + })}\n`, + ); + + try { + const manager = requireManager( + await getMemorySearchManager({ + cfg: createCfg({ + storePath, + sources: ["sessions"], + sessionMemory: true, + }), + agentId: "main", + }), + ); + await manager.sync({ reason: "test" }); + + const db = ( + manager as unknown as { + db: { + prepare: (sql: string) => { + get: (path: string, source: string) => { hash: string } | undefined; + }; + }; + } + ).db; + const getSessionHash = (sessionPath: string) => + db + .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`) + .get(sessionPath, "sessions")?.hash; + + const firstOriginalHash = getSessionHash("sessions/targeted-dirty-first.jsonl"); + const secondOriginalHash = getSessionHash("sessions/targeted-dirty-second.jsonl"); + + await fs.writeFile( + firstSessionPath, + `${JSON.stringify({ + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "first transcript v2 after compaction" }], + }, + })}\n`, + ); + await fs.writeFile( + secondSessionPath, + `${JSON.stringify({ + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "second transcript v2 still pending" }], + }, + })}\n`, + ); + + const internal = manager as unknown as { + sessionsDirty: boolean; + sessionsDirtyFiles: Set; + }; + internal.sessionsDirty = true; + internal.sessionsDirtyFiles.add(secondSessionPath); + + await manager.sync({ + reason: "post-compaction", + sessionFiles: [firstSessionPath], + }); + + expect(getSessionHash("sessions/targeted-dirty-first.jsonl")).not.toBe(firstOriginalHash); + expect(getSessionHash("sessions/targeted-dirty-second.jsonl")).toBe(secondOriginalHash); + expect(internal.sessionsDirtyFiles.has(secondSessionPath)).toBe(true); + expect(internal.sessionsDirty).toBe(true); + + await manager.sync({ reason: "test" }); + + expect(getSessionHash("sessions/targeted-dirty-second.jsonl")).not.toBe(secondOriginalHash); + await manager.close?.(); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + await fs.rm(storePath, { force: true }); + } + }); + + it("queues targeted session sync when another sync is already in progress", async () => { + const stateDir = path.join(fixtureRoot, `state-targeted-queued-${randomUUID()}`); + const sessionDir = path.join(stateDir, "agents", "main", "sessions"); + const sessionPath = path.join(sessionDir, "targeted-queued.jsonl"); + const storePath = path.join(workspaceDir, `index-targeted-queued-${randomUUID()}.sqlite`); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + sessionPath, + `${JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "queued transcript v1" }] }, + })}\n`, + ); + + try { + const manager = requireManager( + await getMemorySearchManager({ + cfg: createCfg({ + storePath, + sources: ["sessions"], + sessionMemory: true, + }), + agentId: "main", + }), + ); + await manager.sync({ reason: "test" }); + + const db = ( + manager as unknown as { + db: { + prepare: (sql: string) => { + get: (path: string, source: string) => { hash: string } | undefined; + }; + }; + } + ).db; + const getSessionHash = (sessionRelPath: string) => + db + .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`) + .get(sessionRelPath, "sessions")?.hash; + const originalHash = getSessionHash("sessions/targeted-queued.jsonl"); + + const internal = manager as unknown as { + runSyncWithReadonlyRecovery: (params?: { + reason?: string; + sessionFiles?: string[]; + }) => Promise; + }; + const originalRunSync = internal.runSyncWithReadonlyRecovery.bind(manager); + let releaseBusySync: (() => void) | undefined; + const busyGate = new Promise((resolve) => { + releaseBusySync = resolve; + }); + internal.runSyncWithReadonlyRecovery = async (params) => { + if (params?.reason === "busy-sync") { + await busyGate; + } + return await originalRunSync(params); + }; + + const busySyncPromise = manager.sync({ reason: "busy-sync" }); + await fs.writeFile( + sessionPath, + `${JSON.stringify({ + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "queued transcript v2 after compaction" }], + }, + })}\n`, + ); + + const targetedSyncPromise = manager.sync({ + reason: "post-compaction", + sessionFiles: [sessionPath], + }); + + releaseBusySync?.(); + await Promise.all([busySyncPromise, targetedSyncPromise]); + + expect(getSessionHash("sessions/targeted-queued.jsonl")).not.toBe(originalHash); + await manager.close?.(); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + await fs.rm(storePath, { force: true }); + } + }); + + it("runs a full reindex after fallback activates during targeted sync", async () => { + const stateDir = path.join(fixtureRoot, `state-targeted-fallback-${randomUUID()}`); + const sessionDir = path.join(stateDir, "agents", "main", "sessions"); + const sessionPath = path.join(sessionDir, "targeted-fallback.jsonl"); + const storePath = path.join(workspaceDir, `index-targeted-fallback-${randomUUID()}.sqlite`); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = stateDir; + + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile( + sessionPath, + `${JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "fallback transcript v1" }] }, + })}\n`, + ); + + try { + const manager = requireManager( + await getMemorySearchManager({ + cfg: createCfg({ + storePath, + sources: ["sessions"], + sessionMemory: true, + }), + agentId: "main", + }), + ); + await manager.sync({ reason: "test" }); + + const internal = manager as unknown as { + syncSessionFiles: (params: { + targetSessionFiles?: string[]; + needsFullReindex: boolean; + }) => Promise; + shouldFallbackOnError: (message: string) => boolean; + activateFallbackProvider: (reason: string) => Promise; + runUnsafeReindex: (params: { + reason?: string; + force?: boolean; + progress?: unknown; + }) => Promise; + }; + const originalSyncSessionFiles = internal.syncSessionFiles.bind(manager); + const originalShouldFallbackOnError = internal.shouldFallbackOnError.bind(manager); + const originalActivateFallbackProvider = internal.activateFallbackProvider.bind(manager); + const originalRunUnsafeReindex = internal.runUnsafeReindex.bind(manager); + + internal.syncSessionFiles = async (params) => { + if (params.targetSessionFiles?.length) { + throw new Error("embedding backend failed"); + } + return await originalSyncSessionFiles(params); + }; + internal.shouldFallbackOnError = () => true; + const activateFallbackProvider = vi.fn(async () => true); + internal.activateFallbackProvider = activateFallbackProvider; + const runUnsafeReindex = vi.fn(async () => {}); + internal.runUnsafeReindex = runUnsafeReindex; + + await manager.sync({ + reason: "post-compaction", + sessionFiles: [sessionPath], + }); + + expect(activateFallbackProvider).toHaveBeenCalledWith("embedding backend failed"); + expect(runUnsafeReindex).toHaveBeenCalledWith({ + reason: "post-compaction", + force: true, + progress: undefined, + }); + + internal.syncSessionFiles = originalSyncSessionFiles; + internal.shouldFallbackOnError = originalShouldFallbackOnError; + internal.activateFallbackProvider = originalActivateFallbackProvider; + internal.runUnsafeReindex = originalRunUnsafeReindex; + await manager.close?.(); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + await fs.rm(storePath, { force: true }); + } + }); + it("reindexes when the embedding model changes", async () => { const base = createCfg({ storePath: indexModelPath }); const baseAgents = base.agents!; diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index 5125caab7ee..6932cf832d6 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -153,6 +153,8 @@ export abstract class MemoryManagerSyncOps { protected abstract sync(params?: { reason?: string; force?: boolean; + forceSessions?: boolean; + sessionFile?: string; progress?: (update: MemorySyncProgressUpdate) => void; }): Promise; protected abstract withTimeout( @@ -628,6 +630,35 @@ export abstract class MemoryManagerSyncOps { return resolvedFile.startsWith(`${resolvedDir}${path.sep}`); } + private normalizeTargetSessionFiles(sessionFiles?: string[]): Set | null { + if (!sessionFiles || sessionFiles.length === 0) { + return null; + } + const normalized = new Set(); + for (const sessionFile of sessionFiles) { + const trimmed = sessionFile.trim(); + if (!trimmed) { + continue; + } + const resolved = path.resolve(trimmed); + if (this.isSessionFileForAgent(resolved)) { + normalized.add(resolved); + } + } + return normalized.size > 0 ? normalized : null; + } + + private clearSyncedSessionFiles(targetSessionFiles?: Iterable | null) { + if (!targetSessionFiles) { + this.sessionsDirtyFiles.clear(); + } else { + for (const targetSessionFile of targetSessionFiles) { + this.sessionsDirtyFiles.delete(targetSessionFile); + } + } + this.sessionsDirty = this.sessionsDirtyFiles.size > 0; + } + protected ensureIntervalSync() { const minutes = this.settings.sync.intervalMinutes; if (!minutes || minutes <= 0 || this.intervalTimer) { @@ -657,12 +688,15 @@ export abstract class MemoryManagerSyncOps { } private shouldSyncSessions( - params?: { reason?: string; force?: boolean }, + params?: { reason?: string; force?: boolean; sessionFiles?: string[] }, needsFullReindex = false, ) { if (!this.sources.has("sessions")) { return false; } + if (params?.sessionFiles?.some((sessionFile) => sessionFile.trim().length > 0)) { + return true; + } if (params?.force) { return true; } @@ -769,6 +803,7 @@ export abstract class MemoryManagerSyncOps { private async syncSessionFiles(params: { needsFullReindex: boolean; + targetSessionFiles?: string[]; progress?: MemorySyncProgressState; }) { // FTS-only mode: skip embedding sync (no provider) @@ -777,21 +812,31 @@ export abstract class MemoryManagerSyncOps { return; } - const files = await listSessionFilesForAgent(this.agentId, { - includeResetArchives: this.settings.sync.sessions.includeResetArchives, - }); - const activePaths = new Set(files.map((file) => sessionPathForFile(file))); - const sessionRowsBefore = params.needsFullReindex - ? [] - : (this.db.prepare(`SELECT path FROM files WHERE source = ?`).all("sessions") as Array<{ - path: string; - }>); + const targetSessionFiles = params.needsFullReindex + ? null + : this.normalizeTargetSessionFiles(params.targetSessionFiles); + const files = targetSessionFiles + ? Array.from(targetSessionFiles) + : await listSessionFilesForAgent(this.agentId, { + includeResetArchives: this.settings.sync.sessions.includeResetArchives, + }); + const activePaths = targetSessionFiles + ? null + : new Set(files.map((file) => sessionPathForFile(file))); + const sessionRowsBefore = + activePaths === null || params.needsFullReindex + ? [] + : (this.db.prepare(`SELECT path FROM files WHERE source = ?`).all("sessions") as Array<{ + path: string; + }>); const knownPaths = new Set(sessionRowsBefore.map((row) => row.path)); - const indexAll = params.needsFullReindex || this.sessionsDirtyFiles.size === 0; + const indexAll = + params.needsFullReindex || Boolean(targetSessionFiles) || this.sessionsDirtyFiles.size === 0; log.debug("memory sync: indexing session files", { files: files.length, indexAll, dirtyFiles: this.sessionsDirtyFiles.size, + targetedFiles: targetSessionFiles?.size ?? 0, batch: this.batch.enabled, concurrency: this.getIndexConcurrency(), }); @@ -854,6 +899,11 @@ export abstract class MemoryManagerSyncOps { }); await runWithConcurrency(tasks, this.getIndexConcurrency()); + if (activePaths === null) { + // Targeted syncs only refresh the requested transcripts and should not + // prune unrelated session rows without a full directory enumeration. + return; + } for (const stale of sessionRowsBefore) { if (activePaths.has(stale.path)) { continue; @@ -909,6 +959,7 @@ export abstract class MemoryManagerSyncOps { protected async runSync(params?: { reason?: string; force?: boolean; + sessionFiles?: string[]; progress?: (update: MemorySyncProgressUpdate) => void; }) { const progress = params?.progress ? this.createSyncProgress(params.progress) : undefined; @@ -924,8 +975,47 @@ export abstract class MemoryManagerSyncOps { const configuredSources = this.resolveConfiguredSourcesForMeta(); const sessionsSourceEnabled = configuredSources.includes("sessions"); const configuredScopeHash = this.resolveConfiguredScopeHash(); + const targetSessionFiles = this.normalizeTargetSessionFiles(params?.sessionFiles); + const hasTargetSessionFiles = targetSessionFiles !== null; + if (hasTargetSessionFiles && targetSessionFiles && this.sources.has("sessions")) { + // Post-compaction refreshes should only update the explicit transcript files and + // leave broader reindex/dirty-work decisions to the regular sync path. + try { + await this.syncSessionFiles({ + needsFullReindex: false, + targetSessionFiles: Array.from(targetSessionFiles), + progress: progress ?? undefined, + }); + this.clearSyncedSessionFiles(targetSessionFiles); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + const activated = + this.shouldFallbackOnError(reason) && (await this.activateFallbackProvider(reason)); + if (activated) { + if ( + process.env.OPENCLAW_TEST_FAST === "1" && + process.env.OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX === "1" + ) { + await this.runUnsafeReindex({ + reason: params?.reason, + force: true, + progress: progress ?? undefined, + }); + } else { + await this.runSafeReindex({ + reason: params?.reason, + force: true, + progress: progress ?? undefined, + }); + } + return; + } + throw err; + } + return; + } const needsFullReindex = - params?.force || + (params?.force && !hasTargetSessionFiles) || !meta || (this.provider && meta.model !== this.provider.model) || (this.provider && meta.provider !== this.provider.id) || @@ -962,7 +1052,8 @@ export abstract class MemoryManagerSyncOps { } const shouldSyncMemory = - this.sources.has("memory") && (params?.force || needsFullReindex || this.dirty); + this.sources.has("memory") && + ((!hasTargetSessionFiles && params?.force) || needsFullReindex || this.dirty); const shouldSyncSessions = this.shouldSyncSessions(params, needsFullReindex); if (shouldSyncMemory) { @@ -971,7 +1062,11 @@ export abstract class MemoryManagerSyncOps { } if (shouldSyncSessions) { - await this.syncSessionFiles({ needsFullReindex, progress: progress ?? undefined }); + await this.syncSessionFiles({ + needsFullReindex, + targetSessionFiles: targetSessionFiles ? Array.from(targetSessionFiles) : undefined, + progress: progress ?? undefined, + }); this.sessionsDirty = false; this.sessionsDirtyFiles.clear(); } else if (this.sessionsDirtyFiles.size > 0) { diff --git a/src/memory/manager.ts b/src/memory/manager.ts index e79f83c570a..61e2cd71af8 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -125,6 +125,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem >(); private sessionWarm = new Set(); private syncing: Promise | null = null; + private queuedSessionFiles = new Set(); + private queuedSessionSync: Promise | null = null; private readonlyRecoveryAttempts = 0; private readonlyRecoverySuccesses = 0; private readonlyRecoveryFailures = 0; @@ -452,12 +454,16 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem async sync(params?: { reason?: string; force?: boolean; + sessionFiles?: string[]; progress?: (update: MemorySyncProgressUpdate) => void; }): Promise { if (this.closed) { return; } if (this.syncing) { + if (params?.sessionFiles?.some((sessionFile) => sessionFile.trim().length > 0)) { + return this.enqueueTargetedSessionSync(params.sessionFiles); + } return this.syncing; } this.syncing = this.runSyncWithReadonlyRecovery(params).finally(() => { @@ -466,6 +472,36 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem return this.syncing ?? Promise.resolve(); } + private enqueueTargetedSessionSync(sessionFiles?: string[]): Promise { + for (const sessionFile of sessionFiles ?? []) { + const trimmed = sessionFile.trim(); + if (trimmed) { + this.queuedSessionFiles.add(trimmed); + } + } + if (this.queuedSessionFiles.size === 0) { + return this.syncing ?? Promise.resolve(); + } + if (!this.queuedSessionSync) { + this.queuedSessionSync = (async () => { + try { + await this.syncing?.catch(() => undefined); + while (!this.closed && this.queuedSessionFiles.size > 0) { + const queuedSessionFiles = Array.from(this.queuedSessionFiles); + this.queuedSessionFiles.clear(); + await this.sync({ + reason: "queued-session-files", + sessionFiles: queuedSessionFiles, + }); + } + } finally { + this.queuedSessionSync = null; + } + })(); + } + return this.queuedSessionSync; + } + private isReadonlyDbError(err: unknown): boolean { const readonlyPattern = /attempt to write a readonly database|database is read-only|SQLITE_READONLY/i; @@ -518,6 +554,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem private async runSyncWithReadonlyRecovery(params?: { reason?: string; force?: boolean; + sessionFiles?: string[]; progress?: (update: MemorySyncProgressUpdate) => void; }): Promise { try { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index e8aefb37078..1f368a176c9 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -867,8 +867,12 @@ export class QmdMemoryManager implements MemorySearchManager { async sync(params?: { reason?: string; force?: boolean; + sessionFiles?: string[]; progress?: (update: MemorySyncProgressUpdate) => void; }): Promise { + if (params?.sessionFiles?.some((sessionFile) => sessionFile.trim().length > 0)) { + log.debug("qmd sync ignoring targeted sessionFiles hint; running regular update"); + } if (params?.progress) { params.progress({ completed: 0, total: 1, label: "Updating QMD index…" }); } diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index ea581b5d6da..6cc8d9f20a4 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -181,6 +181,7 @@ class FallbackMemoryManager implements MemorySearchManager { async sync(params?: { reason?: string; force?: boolean; + sessionFiles?: string[]; progress?: (update: MemorySyncProgressUpdate) => void; }) { if (!this.primaryFailed) { diff --git a/src/memory/types.ts b/src/memory/types.ts index 287ee6ac5a6..880384df71a 100644 --- a/src/memory/types.ts +++ b/src/memory/types.ts @@ -72,6 +72,7 @@ export interface MemorySearchManager { sync?(params?: { reason?: string; force?: boolean; + sessionFiles?: string[]; progress?: (update: MemorySyncProgressUpdate) => void; }): Promise; probeEmbeddingAvailability(): Promise; diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 3e1736000aa..010e7b5e4ef 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -6,6 +6,7 @@ import { formatExecCommand } from "../infra/system-run-command.js"; import { buildSystemRunApprovalPlan, hardenApprovedExecutionPaths, + resolveMutableFileOperandSnapshotSync, } from "./invoke-system-run-plan.js"; type PathTokenSetup = { @@ -94,6 +95,36 @@ function withFakeRuntimeBin(params: { binName: string; run: () => T }): T { } } +function withFakeRuntimeBins(params: { binNames: string[]; run: () => T }): T { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-bins-")); + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + for (const binName of params.binNames) { + const runtimePath = + process.platform === "win32" + ? path.join(binDir, `${binName}.cmd`) + : path.join(binDir, binName); + const runtimeBody = + process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; + fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); + if (process.platform !== "win32") { + fs.chmodSync(runtimePath, 0o755); + } + } + const oldPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + try { + return params.run(); + } finally { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + describe("hardenApprovedExecutionPaths", () => { const cases: HardeningCase[] = [ { @@ -318,16 +349,67 @@ describe("hardenApprovedExecutionPaths", () => { initialBody: 'console.log("SAFE");\n', expectedArgvIndex: 2, }, + { + name: "pnpm exec tsx file", + argv: ["pnpm", "exec", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 3, + }, + { + name: "pnpm js shim exec tsx file", + argv: ["./pnpm.js", "exec", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 3, + }, + { + name: "pnpm exec double-dash tsx file", + argv: ["pnpm", "exec", "--", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 4, + }, + { + name: "npx tsx file", + argv: ["npx", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 2, + }, + { + name: "bunx tsx file", + argv: ["bunx", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 2, + }, + { + name: "npm exec tsx file", + argv: ["npm", "exec", "--", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 4, + }, ]; for (const runtimeCase of mutableOperandCases) { it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { - withFakeRuntimeBin({ - binName: runtimeCase.binName!, + const binNames = runtimeCase.binName + ? [runtimeCase.binName] + : ["bunx", "pnpm", "npm", "npx", "tsx"]; + withFakeRuntimeBins({ + binNames, run: () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-")); const fixture = createScriptOperandFixture(tmp, runtimeCase); fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + const executablePath = fixture.command[0]; + if (executablePath?.endsWith("pnpm.js")) { + const shimPath = path.join(tmp, "pnpm.js"); + fs.writeFileSync(shimPath, "#!/usr/bin/env node\nconsole.log('shim')\n"); + fs.chmodSync(shimPath, 0o755); + } try { const prepared = buildSystemRunApprovalPlan({ command: fixture.command, @@ -441,4 +523,75 @@ describe("hardenApprovedExecutionPaths", () => { }, }); }); + + it("rejects node inline import operands that cannot be bound to one stable file", () => { + withFakeRuntimeBin({ + binName: "node", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-import-inline-")); + try { + fs.writeFileSync(path.join(tmp, "main.mjs"), 'console.log("SAFE")\n'); + fs.writeFileSync(path.join(tmp, "preload.mjs"), 'console.log("SAFE")\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["node", "--import=./preload.mjs", "./main.mjs"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("rejects shell payloads that hide mutable interpreter scripts", () => { + withFakeRuntimeBin({ + binName: "node", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-inline-shell-node-")); + try { + fs.writeFileSync(path.join(tmp, "run.js"), 'console.log("SAFE")\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["sh", "-lc", "node ./run.js"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("captures the real shell script operand after value-taking shell flags", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-option-value-")); + try { + const scriptPath = path.join(tmp, "run.sh"); + fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); + fs.writeFileSync(path.join(tmp, "errexit"), "decoy\n"); + const snapshot = resolveMutableFileOperandSnapshotSync({ + argv: ["/bin/bash", "-o", "errexit", "./run.sh"], + cwd: tmp, + shellCommand: null, + }); + expect(snapshot).toEqual({ + ok: true, + snapshot: { + argvIndex: 3, + path: fs.realpathSync(scriptPath), + sha256: expect.any(String), + }, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 1b46312c3a1..afcc2963e9d 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -19,6 +19,7 @@ import { resolveInlineCommandMatch, } from "../infra/shell-inline-command.js"; import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; +import { splitShellArgs } from "../utils/shell-argv.js"; export type ApprovedCwdSnapshot = { cwd: string; @@ -125,6 +126,47 @@ const DENO_RUN_OPTIONS_WITH_VALUE = new Set([ "-L", ]); +const NODE_OPTIONS_WITH_FILE_VALUE = new Set([ + "-r", + "--experimental-loader", + "--import", + "--loader", + "--require", +]); + +const POSIX_SHELL_OPTIONS_WITH_VALUE = new Set([ + "--init-file", + "--rcfile", + "--startup-script", + "-o", +]); + +const NPM_EXEC_OPTIONS_WITH_VALUE = new Set([ + "--cache", + "--package", + "--prefix", + "--script-shell", + "--userconfig", + "--workspace", + "-p", + "-w", +]); + +const NPM_EXEC_FLAG_OPTIONS = new Set([ + "--no", + "--quiet", + "--ws", + "--workspaces", + "--yes", + "-q", + "-y", +]); + +type FileOperandCollection = { + hits: number[]; + sawOptionValueFile: boolean; +}; + function normalizeString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -225,10 +267,129 @@ function unwrapArgvForMutableOperand(argv: string[]): { argv: string[]; baseInde current = shellMultiplexerUnwrap.argv; continue; } + const packageManagerUnwrap = unwrapKnownPackageManagerExecInvocation(current); + if (packageManagerUnwrap) { + baseIndex += current.length - packageManagerUnwrap.length; + current = packageManagerUnwrap; + continue; + } return { argv: current, baseIndex }; } } +function unwrapKnownPackageManagerExecInvocation(argv: string[]): string[] | null { + const executable = normalizePackageManagerExecToken(argv[0] ?? ""); + switch (executable) { + case "npm": + return unwrapNpmExecInvocation(argv); + case "npx": + case "bunx": + return unwrapDirectPackageExecInvocation(argv); + case "pnpm": + return unwrapPnpmExecInvocation(argv); + default: + return null; + } +} + +function normalizePackageManagerExecToken(token: string): string { + const normalized = normalizeExecutableToken(token); + if (!normalized) { + return normalized; + } + return normalized.replace(/\.(?:c|m)?js$/i, ""); +} + +function unwrapPnpmExecInvocation(argv: string[]): string[] | null { + let idx = 1; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (token === "--") { + idx += 1; + continue; + } + if (!token.startsWith("-")) { + if (token !== "exec" || idx + 1 >= argv.length) { + return null; + } + const tail = argv.slice(idx + 1); + return tail[0] === "--" ? (tail.length > 1 ? tail.slice(1) : null) : tail; + } + if ((token === "-C" || token === "--dir" || token === "--filter") && !token.includes("=")) { + idx += 2; + continue; + } + idx += 1; + } + return null; +} + +function unwrapDirectPackageExecInvocation(argv: string[]): string[] | null { + let idx = 1; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (!token.startsWith("-")) { + return argv.slice(idx); + } + const [flag] = token.toLowerCase().split("=", 2); + if (flag === "-c" || flag === "--call") { + return null; + } + if (NPM_EXEC_OPTIONS_WITH_VALUE.has(flag)) { + idx += token.includes("=") ? 1 : 2; + continue; + } + if (NPM_EXEC_FLAG_OPTIONS.has(flag)) { + idx += 1; + continue; + } + return null; + } + return null; +} + +function unwrapNpmExecInvocation(argv: string[]): string[] | null { + let idx = 1; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (!token.startsWith("-")) { + if (token !== "exec") { + return null; + } + idx += 1; + break; + } + if ( + (token === "-C" || token === "--prefix" || token === "--userconfig") && + !token.includes("=") + ) { + idx += 2; + continue; + } + idx += 1; + } + if (idx >= argv.length) { + return null; + } + const tail = argv.slice(idx); + if (tail[0] === "--") { + return tail.length > 1 ? tail.slice(1) : null; + } + return unwrapDirectPackageExecInvocation(["npx", ...tail]); +} + function resolvePosixShellScriptOperandIndex(argv: string[]): number | null { if ( resolveInlineCommandMatch(argv, POSIX_INLINE_COMMAND_FLAGS, { @@ -254,6 +415,13 @@ function resolvePosixShellScriptOperandIndex(argv: string[]): number | null { return null; } if (!afterDoubleDash && token.startsWith("-")) { + const [flag] = token.toLowerCase().split("=", 2); + if (POSIX_SHELL_OPTIONS_WITH_VALUE.has(flag)) { + if (!token.includes("=")) { + i += 1; + } + continue; + } continue; } return i; @@ -330,7 +498,8 @@ function collectExistingFileOperandIndexes(params: { argv: string[]; startIndex: number; cwd: string | undefined; -}): number[] { + optionsWithFileValue?: ReadonlySet; +}): FileOperandCollection { let afterDoubleDash = false; const hits: number[] = []; for (let i = params.startIndex; i < params.argv.length; i += 1) { @@ -349,28 +518,45 @@ function collectExistingFileOperandIndexes(params: { continue; } if (token === "-") { - return []; + return { hits: [], sawOptionValueFile: false }; } if (token.startsWith("-")) { + const [flag, inlineValue] = token.split("=", 2); + if (params.optionsWithFileValue?.has(flag.toLowerCase())) { + if (inlineValue && resolvesToExistingFileSync(inlineValue, params.cwd)) { + hits.push(i); + return { hits, sawOptionValueFile: true }; + } + const nextToken = params.argv[i + 1]?.trim() ?? ""; + if (!inlineValue && nextToken && resolvesToExistingFileSync(nextToken, params.cwd)) { + hits.push(i + 1); + return { hits, sawOptionValueFile: true }; + } + } continue; } if (resolvesToExistingFileSync(token, params.cwd)) { hits.push(i); } } - return hits; + return { hits, sawOptionValueFile: false }; } function resolveGenericInterpreterScriptOperandIndex(params: { argv: string[]; cwd: string | undefined; + optionsWithFileValue?: ReadonlySet; }): number | null { - const hits = collectExistingFileOperandIndexes({ + const collection = collectExistingFileOperandIndexes({ argv: params.argv, startIndex: 1, cwd: params.cwd, + optionsWithFileValue: params.optionsWithFileValue, }); - return hits.length === 1 ? hits[0] : null; + if (collection.sawOptionValueFile) { + return null; + } + return collection.hits.length === 1 ? collection.hits[0] : null; } function resolveBunScriptOperandIndex(params: { @@ -462,16 +648,39 @@ function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined) const genericIndex = resolveGenericInterpreterScriptOperandIndex({ argv: unwrapped.argv, cwd, + optionsWithFileValue: + executable === "node" || executable === "nodejs" ? NODE_OPTIONS_WITH_FILE_VALUE : undefined, }); return genericIndex === null ? null : unwrapped.baseIndex + genericIndex; } +function shellPayloadNeedsStableBinding(shellCommand: string, cwd: string | undefined): boolean { + const argv = splitShellArgs(shellCommand); + if (!argv || argv.length === 0) { + return false; + } + const snapshot = resolveMutableFileOperandSnapshotSync({ + argv, + cwd, + shellCommand: null, + }); + if (!snapshot.ok) { + return true; + } + if (snapshot.snapshot) { + return true; + } + const firstToken = argv[0]?.trim() ?? ""; + return resolvesToExistingFileSync(firstToken, cwd); +} + function requiresStableInterpreterApprovalBindingWithShellCommand(params: { argv: string[]; shellCommand: string | null; + cwd: string | undefined; }): boolean { if (params.shellCommand !== null) { - return false; + return shellPayloadNeedsStableBinding(params.shellCommand, params.cwd); } const unwrapped = unwrapArgvForMutableOperand(params.argv); const executable = normalizeExecutableToken(unwrapped.argv[0] ?? ""); @@ -495,6 +704,7 @@ export function resolveMutableFileOperandSnapshotSync(params: { requiresStableInterpreterApprovalBindingWithShellCommand({ argv: params.argv, shellCommand: params.shellCommand, + cwd: params.cwd, }) ) { return { diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index 16766eabcd3..b6e6f17cd85 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; const diagnosticMocks = vi.hoisted(() => ({ logLaneEnqueue: vi.fn(), @@ -334,4 +335,42 @@ describe("command queue", () => { resetAllLanes(); await expect(enqueueCommand(async () => "ok")).resolves.toBe("ok"); }); + + it("shares lane state across distinct module instances", async () => { + const commandQueueA = await importFreshModule( + import.meta.url, + "./command-queue.js?scope=shared-a", + ); + const commandQueueB = await importFreshModule( + import.meta.url, + "./command-queue.js?scope=shared-b", + ); + const lane = `shared-state-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + let release!: () => void; + const blocker = new Promise((resolve) => { + release = resolve; + }); + + commandQueueA.resetAllLanes(); + + try { + const task = commandQueueA.enqueueCommandInLane(lane, async () => { + await blocker; + return "done"; + }); + + await vi.waitFor(() => { + expect(commandQueueB.getQueueSize(lane)).toBe(1); + expect(commandQueueB.getActiveTaskCount()).toBe(1); + }); + + release(); + await expect(task).resolves.toBe("done"); + expect(commandQueueB.getQueueSize(lane)).toBe(0); + } finally { + release(); + commandQueueA.resetAllLanes(); + } + }); }); diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 7b4a386bdad..956b386a6bf 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -1,4 +1,5 @@ import { diagnosticLogger as diag, logLaneDequeue, logLaneEnqueue } from "../logging/diagnostic.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { CommandLane } from "./lanes.js"; /** * Dedicated error type thrown when a queued command is rejected because @@ -23,9 +24,6 @@ export class GatewayDrainingError extends Error { } } -// Set while gateway is draining for restart; new enqueues are rejected. -let gatewayDraining = false; - // Minimal in-process queue to serialize command executions. // Default lane ("main") preserves the existing behavior. Additional lanes allow // low-risk parallelism (e.g. cron jobs) without interleaving stdin / logs for @@ -49,11 +47,20 @@ type LaneState = { generation: number; }; -const lanes = new Map(); -let nextTaskId = 1; +/** + * Keep queue runtime state on globalThis so every bundled entry/chunk shares + * the same lanes, counters, and draining flag in production builds. + */ +const COMMAND_QUEUE_STATE_KEY = Symbol.for("openclaw.commandQueueState"); + +const queueState = resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({ + gatewayDraining: false, + lanes: new Map(), + nextTaskId: 1, +})); function getLaneState(lane: string): LaneState { - const existing = lanes.get(lane); + const existing = queueState.lanes.get(lane); if (existing) { return existing; } @@ -65,7 +72,7 @@ function getLaneState(lane: string): LaneState { draining: false, generation: 0, }; - lanes.set(lane, created); + queueState.lanes.set(lane, created); return created; } @@ -105,7 +112,7 @@ function drainLane(lane: string) { ); } logLaneDequeue(lane, waitedMs, state.queue.length); - const taskId = nextTaskId++; + const taskId = queueState.nextTaskId++; const taskGeneration = state.generation; state.activeTaskIds.add(taskId); void (async () => { @@ -148,7 +155,7 @@ function drainLane(lane: string) { * `GatewayDrainingError` instead of being silently killed on shutdown. */ export function markGatewayDraining(): void { - gatewayDraining = true; + queueState.gatewayDraining = true; } export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) { @@ -166,7 +173,7 @@ export function enqueueCommandInLane( onWait?: (waitMs: number, queuedAhead: number) => void; }, ): Promise { - if (gatewayDraining) { + if (queueState.gatewayDraining) { return Promise.reject(new GatewayDrainingError()); } const cleaned = lane.trim() || CommandLane.Main; @@ -198,7 +205,7 @@ export function enqueueCommand( export function getQueueSize(lane: string = CommandLane.Main) { const resolved = lane.trim() || CommandLane.Main; - const state = lanes.get(resolved); + const state = queueState.lanes.get(resolved); if (!state) { return 0; } @@ -207,7 +214,7 @@ export function getQueueSize(lane: string = CommandLane.Main) { export function getTotalQueueSize() { let total = 0; - for (const s of lanes.values()) { + for (const s of queueState.lanes.values()) { total += s.queue.length + s.activeTaskIds.size; } return total; @@ -215,7 +222,7 @@ export function getTotalQueueSize() { export function clearCommandLane(lane: string = CommandLane.Main) { const cleaned = lane.trim() || CommandLane.Main; - const state = lanes.get(cleaned); + const state = queueState.lanes.get(cleaned); if (!state) { return 0; } @@ -242,9 +249,9 @@ export function clearCommandLane(lane: string = CommandLane.Main) { * `enqueueCommandInLane()` call (which may never come). */ export function resetAllLanes(): void { - gatewayDraining = false; + queueState.gatewayDraining = false; const lanesToDrain: string[] = []; - for (const state of lanes.values()) { + for (const state of queueState.lanes.values()) { state.generation += 1; state.activeTaskIds.clear(); state.draining = false; @@ -264,7 +271,7 @@ export function resetAllLanes(): void { */ export function getActiveTaskCount(): number { let total = 0; - for (const s of lanes.values()) { + for (const s of queueState.lanes.values()) { total += s.activeTaskIds.size; } return total; @@ -283,7 +290,7 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea const POLL_INTERVAL_MS = 50; const deadline = Date.now() + timeoutMs; const activeAtStart = new Set(); - for (const state of lanes.values()) { + for (const state of queueState.lanes.values()) { for (const taskId of state.activeTaskIds) { activeAtStart.add(taskId); } @@ -297,7 +304,7 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea } let hasPending = false; - for (const state of lanes.values()) { + for (const state of queueState.lanes.values()) { for (const taskId of state.activeTaskIds) { if (activeAtStart.has(taskId)) { hasPending = true; diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index cf12ac2f9ba..79a701c5489 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -21,6 +21,7 @@ import { } from "../config/model-input.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; +import { resolveAllowedAgentIds } from "../gateway/hooks.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, @@ -663,6 +664,7 @@ export function collectHooksHardeningFindings( const allowRequestSessionKey = cfg.hooks?.allowRequestSessionKey === true; const defaultSessionKey = typeof cfg.hooks?.defaultSessionKey === "string" ? cfg.hooks.defaultSessionKey.trim() : ""; + const allowedAgentIds = resolveAllowedAgentIds(cfg.hooks?.allowedAgentIds); const allowedPrefixes = Array.isArray(cfg.hooks?.allowedSessionKeyPrefixes) ? cfg.hooks.allowedSessionKeyPrefixes .map((prefix) => prefix.trim()) @@ -681,6 +683,18 @@ export function collectHooksHardeningFindings( }); } + if (allowedAgentIds === undefined) { + findings.push({ + checkId: "hooks.allowed_agent_ids_unrestricted", + severity: remoteExposure ? "critical" : "warn", + title: "Hook agent routing allows any configured agent", + detail: + "hooks.allowedAgentIds is unset or includes '*', so authenticated hook callers may route to any configured agent id.", + remediation: + 'Set hooks.allowedAgentIds to an explicit allowlist (for example, ["hooks", "main"]) or [] to deny explicit agent routing.', + }); + } + if (allowRequestSessionKey) { findings.push({ checkId: "hooks.request_session_key_enabled", diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 1c696bf6e1f..2546feae947 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -2656,6 +2656,52 @@ description: test skill expectFinding(res, "hooks.default_session_key_unset", "warn"); }); + it("scores unrestricted hooks.allowedAgentIds by gateway exposure", async () => { + const baseHooks = { + enabled: true, + token: "shared-gateway-token-1234567890", + defaultSessionKey: "hook:ingress", + } satisfies NonNullable; + const cases: Array<{ + name: string; + cfg: OpenClawConfig; + expectedSeverity: "warn" | "critical"; + }> = [ + { + name: "local exposure", + cfg: { hooks: baseHooks }, + expectedSeverity: "warn", + }, + { + name: "remote exposure", + cfg: { gateway: { bind: "lan" }, hooks: baseHooks }, + expectedSeverity: "critical", + }, + ]; + await Promise.all( + cases.map(async (testCase) => { + const res = await audit(testCase.cfg); + expect( + hasFinding(res, "hooks.allowed_agent_ids_unrestricted", testCase.expectedSeverity), + testCase.name, + ).toBe(true); + }), + ); + }); + + it("treats wildcard hooks.allowedAgentIds as unrestricted routing", async () => { + const res = await audit({ + hooks: { + enabled: true, + token: "shared-gateway-token-1234567890", + defaultSessionKey: "hook:ingress", + allowedAgentIds: ["*"], + }, + }); + + expectFinding(res, "hooks.allowed_agent_ids_unrestricted", "warn"); + }); + it("scores hooks request sessionKey override by gateway exposure", async () => { const baseHooks = { enabled: true, diff --git a/src/shared/global-singleton.test.ts b/src/shared/global-singleton.test.ts new file mode 100644 index 00000000000..0f0a29c506c --- /dev/null +++ b/src/shared/global-singleton.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveGlobalMap, resolveGlobalSingleton } from "./global-singleton.js"; + +const TEST_KEY = Symbol("global-singleton:test"); +const TEST_MAP_KEY = Symbol("global-singleton:test-map"); + +afterEach(() => { + delete (globalThis as Record)[TEST_KEY]; + delete (globalThis as Record)[TEST_MAP_KEY]; +}); + +describe("resolveGlobalSingleton", () => { + it("reuses an initialized singleton", () => { + const create = vi.fn(() => ({ value: 1 })); + + const first = resolveGlobalSingleton(TEST_KEY, create); + const second = resolveGlobalSingleton(TEST_KEY, create); + + expect(first).toBe(second); + expect(create).toHaveBeenCalledTimes(1); + }); + + it("does not re-run the factory when undefined was already stored", () => { + const create = vi.fn(() => undefined); + + expect(resolveGlobalSingleton(TEST_KEY, create)).toBeUndefined(); + expect(resolveGlobalSingleton(TEST_KEY, create)).toBeUndefined(); + expect(create).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveGlobalMap", () => { + it("reuses the same map instance", () => { + const first = resolveGlobalMap(TEST_MAP_KEY); + const second = resolveGlobalMap(TEST_MAP_KEY); + + expect(first).toBe(second); + }); +}); diff --git a/src/shared/global-singleton.ts b/src/shared/global-singleton.ts new file mode 100644 index 00000000000..3e896429fa5 --- /dev/null +++ b/src/shared/global-singleton.ts @@ -0,0 +1,13 @@ +export function resolveGlobalSingleton(key: symbol, create: () => T): T { + const globalStore = globalThis as Record; + if (Object.prototype.hasOwnProperty.call(globalStore, key)) { + return globalStore[key] as T; + } + const created = create(); + globalStore[key] = created; + return created; +} + +export function resolveGlobalMap(key: symbol): Map { + return resolveGlobalSingleton(key, () => new Map()); +} diff --git a/src/slack/sent-thread-cache.test.ts b/src/slack/sent-thread-cache.test.ts index 05af1958895..7421a7277e3 100644 --- a/src/slack/sent-thread-cache.test.ts +++ b/src/slack/sent-thread-cache.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; import { clearSlackThreadParticipationCache, hasSlackThreadParticipation, @@ -49,6 +50,29 @@ describe("slack sent-thread-cache", () => { expect(hasSlackThreadParticipation("A1", "C456", "1700000000.000002")).toBe(false); }); + it("shares thread participation across distinct module instances", async () => { + const cacheA = await importFreshModule( + import.meta.url, + "./sent-thread-cache.js?scope=shared-a", + ); + const cacheB = await importFreshModule( + import.meta.url, + "./sent-thread-cache.js?scope=shared-b", + ); + + cacheA.clearSlackThreadParticipationCache(); + + try { + cacheA.recordSlackThreadParticipation("A1", "C123", "1700000000.000001"); + expect(cacheB.hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(true); + + cacheB.clearSlackThreadParticipationCache(); + expect(cacheA.hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(false); + } finally { + cacheA.clearSlackThreadParticipationCache(); + } + }); + it("expired entries return false and are cleaned up on read", () => { recordSlackThreadParticipation("A1", "C123", "1700000000.000001"); // Advance time past the 24-hour TTL diff --git a/src/slack/sent-thread-cache.ts b/src/slack/sent-thread-cache.ts index 7fe8037c797..b3c2a3c2441 100644 --- a/src/slack/sent-thread-cache.ts +++ b/src/slack/sent-thread-cache.ts @@ -1,3 +1,5 @@ +import { resolveGlobalMap } from "../shared/global-singleton.js"; + /** * In-memory cache of Slack threads the bot has participated in. * Used to auto-respond in threads without requiring @mention after the first reply. @@ -7,7 +9,13 @@ const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const MAX_ENTRIES = 5000; -const threadParticipation = new Map(); +/** + * Keep Slack thread participation shared across bundled chunks so thread + * auto-reply gating does not diverge between prepare/dispatch call paths. + */ +const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation"); + +const threadParticipation = resolveGlobalMap(SLACK_THREAD_PARTICIPATION_KEY); function makeKey(accountId: string, channelId: string, threadTs: string): string { return `${accountId}:${channelId}:${threadTs}`; diff --git a/src/telegram/draft-stream.test.ts b/src/telegram/draft-stream.test.ts index 58990c41abf..07221ccc644 100644 --- a/src/telegram/draft-stream.test.ts +++ b/src/telegram/draft-stream.test.ts @@ -1,6 +1,7 @@ import type { Bot } from "grammy"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createTelegramDraftStream } from "./draft-stream.js"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { __testing, createTelegramDraftStream } from "./draft-stream.js"; type TelegramDraftStreamParams = Parameters[0]; @@ -65,6 +66,10 @@ function createForceNewMessageHarness(params: { throttleMs?: number } = {}) { } describe("createTelegramDraftStream", () => { + afterEach(() => { + __testing.resetTelegramDraftStreamForTests(); + }); + it("sends stream preview message with message_thread_id when provided", async () => { const api = createMockDraftApi(); const stream = createForumDraftStream(api); @@ -355,6 +360,46 @@ describe("createTelegramDraftStream", () => { expect(api.editMessageText).not.toHaveBeenCalled(); }); + it("shares draft-id allocation across distinct module instances", async () => { + const draftA = await importFreshModule( + import.meta.url, + "./draft-stream.js?scope=shared-a", + ); + const draftB = await importFreshModule( + import.meta.url, + "./draft-stream.js?scope=shared-b", + ); + const apiA = createMockDraftApi(); + const apiB = createMockDraftApi(); + + draftA.__testing.resetTelegramDraftStreamForTests(); + + try { + const streamA = draftA.createTelegramDraftStream({ + api: apiA as unknown as Bot["api"], + chatId: 123, + thread: { id: 42, scope: "dm" }, + previewTransport: "draft", + }); + const streamB = draftB.createTelegramDraftStream({ + api: apiB as unknown as Bot["api"], + chatId: 123, + thread: { id: 42, scope: "dm" }, + previewTransport: "draft", + }); + + streamA.update("Message A"); + await streamA.flush(); + streamB.update("Message B"); + await streamB.flush(); + + expect(apiA.sendMessageDraft.mock.calls[0]?.[1]).toBe(1); + expect(apiB.sendMessageDraft.mock.calls[0]?.[1]).toBe(2); + } finally { + draftA.__testing.resetTelegramDraftStreamForTests(); + } + }); + it("creates new message after forceNewMessage is called", async () => { const { api, stream } = createForceNewMessageHarness(); diff --git a/src/telegram/draft-stream.ts b/src/telegram/draft-stream.ts index ddb0595312b..afab4680e96 100644 --- a/src/telegram/draft-stream.ts +++ b/src/telegram/draft-stream.ts @@ -1,5 +1,6 @@ import type { Bot } from "grammy"; import { createFinalizableDraftLifecycle } from "../channels/draft-stream-controls.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js"; @@ -21,11 +22,20 @@ type TelegramSendMessageDraft = ( }, ) => Promise; -let nextDraftId = 0; +/** + * Keep draft-id allocation shared across bundled chunks so concurrent preview + * lanes do not accidentally reuse draft ids when code-split entries coexist. + */ +const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState"); + +const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({ + nextDraftId: 0, +})); function allocateTelegramDraftId(): number { - nextDraftId = nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : nextDraftId + 1; - return nextDraftId; + draftStreamState.nextDraftId = + draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1; + return draftStreamState.nextDraftId; } function resolveSendMessageDraftApi(api: Bot["api"]): TelegramSendMessageDraft | undefined { @@ -441,3 +451,9 @@ export function createTelegramDraftStream(params: { sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number", }; } + +export const __testing = { + resetTelegramDraftStreamForTests() { + draftStreamState.nextDraftId = 0; + }, +}; diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 2bd6556ee42..f2875af1dc0 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1,5 +1,6 @@ import type { Bot } from "grammy"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; import { getTelegramSendTestMocks, importTelegramSendModule, @@ -88,6 +89,29 @@ describe("sent-message-cache", () => { clearSentMessageCache(); expect(wasSentByBot(123, 1)).toBe(false); }); + + it("shares sent-message state across distinct module instances", async () => { + const cacheA = await importFreshModule( + import.meta.url, + "./sent-message-cache.js?scope=shared-a", + ); + const cacheB = await importFreshModule( + import.meta.url, + "./sent-message-cache.js?scope=shared-b", + ); + + cacheA.clearSentMessageCache(); + + try { + cacheA.recordSentMessage(123, 1); + expect(cacheB.wasSentByBot(123, 1)).toBe(true); + + cacheB.clearSentMessageCache(); + expect(cacheA.wasSentByBot(123, 1)).toBe(false); + } finally { + cacheA.clearSentMessageCache(); + } + }); }); describe("buildInlineKeyboard", () => { diff --git a/src/telegram/sent-message-cache.ts b/src/telegram/sent-message-cache.ts index 0380f245454..974510669e7 100644 --- a/src/telegram/sent-message-cache.ts +++ b/src/telegram/sent-message-cache.ts @@ -1,3 +1,5 @@ +import { resolveGlobalMap } from "../shared/global-singleton.js"; + /** * In-memory cache of sent message IDs per chat. * Used to identify bot's own messages for reaction filtering ("own" mode). @@ -9,7 +11,13 @@ type CacheEntry = { timestamps: Map; }; -const sentMessages = new Map(); +/** + * Keep sent-message tracking shared across bundled chunks so Telegram reaction + * filters see the same sent-message history regardless of which chunk recorded it. + */ +const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages"); + +const sentMessages = resolveGlobalMap(TELEGRAM_SENT_MESSAGES_KEY); function getChatKey(chatId: number | string): string { return String(chatId); diff --git a/src/telegram/thread-bindings.test.ts b/src/telegram/thread-bindings.test.ts index 4479fc78661..fc32ace254b 100644 --- a/src/telegram/thread-bindings.test.ts +++ b/src/telegram/thread-bindings.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { importFreshModule } from "../../test/helpers/import-fresh.js"; import { resolveStateDir } from "../config/paths.js"; import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; import { @@ -79,6 +80,53 @@ describe("telegram thread bindings", () => { }); }); + it("shares binding state across distinct module instances", async () => { + const bindingsA = await importFreshModule( + import.meta.url, + "./thread-bindings.js?scope=shared-a", + ); + const bindingsB = await importFreshModule( + import.meta.url, + "./thread-bindings.js?scope=shared-b", + ); + + bindingsA.__testing.resetTelegramThreadBindingsForTests(); + + try { + const managerA = bindingsA.createTelegramThreadBindingManager({ + accountId: "shared-runtime", + persist: false, + enableSweeper: false, + }); + const managerB = bindingsB.createTelegramThreadBindingManager({ + accountId: "shared-runtime", + persist: false, + enableSweeper: false, + }); + + expect(managerB).toBe(managerA); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:main:subagent:child-shared", + targetKind: "subagent", + conversation: { + channel: "telegram", + accountId: "shared-runtime", + conversationId: "-100200300:topic:44", + }, + placement: "current", + }); + + expect( + bindingsB + .getTelegramThreadBindingManager("shared-runtime") + ?.getByConversationId("-100200300:topic:44")?.targetSessionKey, + ).toBe("agent:main:subagent:child-shared"); + } finally { + bindingsA.__testing.resetTelegramThreadBindingsForTests(); + } + }); + it("updates lifecycle windows by session key", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); diff --git a/src/telegram/thread-bindings.ts b/src/telegram/thread-bindings.ts index 68218e9045d..ea2fd11ac1e 100644 --- a/src/telegram/thread-bindings.ts +++ b/src/telegram/thread-bindings.ts @@ -13,6 +13,7 @@ import { type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; import { normalizeAccountId } from "../routing/session-key.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0; @@ -62,8 +63,26 @@ export type TelegramThreadBindingManager = { stop: () => void; }; -const MANAGERS_BY_ACCOUNT_ID = new Map(); -const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); +type TelegramThreadBindingsState = { + managersByAccountId: Map; + bindingsByAccountConversation: Map; +}; + +/** + * Keep Telegram thread binding state shared across bundled chunks so routing, + * binding lookups, and binding mutations all observe the same live registry. + */ +const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState"); + +const threadBindingsState = resolveGlobalSingleton( + TELEGRAM_THREAD_BINDINGS_STATE_KEY, + () => ({ + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + }), +); +const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId; +const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation; function normalizeDurationMs(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { diff --git a/test/helpers/import-fresh.ts b/test/helpers/import-fresh.ts new file mode 100644 index 00000000000..577e25cd856 --- /dev/null +++ b/test/helpers/import-fresh.ts @@ -0,0 +1,8 @@ +export async function importFreshModule( + from: string, + specifier: string, +): Promise { + // Vitest keys module instances by the full URL string, including the query + // suffix. These tests rely on that behavior to emulate code-split chunks. + return (await import(/* @vite-ignore */ new URL(specifier, from).href)) as TModule; +} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index cd273965829..634647bfea2 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const en: TranslationMap = { common: { - version: "Version", health: "Health", ok: "OK", offline: "Offline", @@ -147,10 +146,6 @@ export const en: TranslationMap = { refreshAll: "Refresh All", terminal: "Terminal", }, - streamMode: { - active: "Stream mode — values redacted", - disable: "Disable", - }, palette: { placeholder: "Type a command…", noResults: "No results", @@ -158,7 +153,7 @@ export const en: TranslationMap = { }, login: { subtitle: "Gateway Dashboard", - passwordPlaceholder: "optional", // pragma: allowlist secret + passwordPlaceholder: "optional", }, chat: { disconnected: "Disconnected from gateway.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index f656793e78b..39df62971ae 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const pt_BR: TranslationMap = { common: { - version: "Versão", health: "Saúde", ok: "OK", offline: "Offline", @@ -12,7 +11,6 @@ export const pt_BR: TranslationMap = { disabled: "Desativado", na: "n/a", docs: "Docs", - theme: "Tema", resources: "Recursos", search: "Pesquisar", }, @@ -149,10 +147,6 @@ export const pt_BR: TranslationMap = { refreshAll: "Atualizar Tudo", terminal: "Terminal", }, - streamMode: { - active: "Modo stream — valores ocultos", - disable: "Desativar", - }, palette: { placeholder: "Digite um comando…", noResults: "Sem resultados", @@ -160,7 +154,7 @@ export const pt_BR: TranslationMap = { }, login: { subtitle: "Painel do Gateway", - passwordPlaceholder: "opcional", // pragma: allowlist secret + passwordPlaceholder: "opcional", }, chat: { disconnected: "Desconectado do gateway.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index ef3cd77ae17..80478794882 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const zh_CN: TranslationMap = { common: { - version: "版本", health: "健康状况", ok: "正常", offline: "离线", @@ -12,7 +11,6 @@ export const zh_CN: TranslationMap = { disabled: "已禁用", na: "不适用", docs: "文档", - theme: "主题", resources: "资源", search: "搜索", }, @@ -146,10 +144,6 @@ export const zh_CN: TranslationMap = { refreshAll: "全部刷新", terminal: "终端", }, - streamMode: { - active: "流模式 — 数据已隐藏", - disable: "禁用", - }, palette: { placeholder: "输入命令…", noResults: "无结果", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 580f8a3de92..b3d4b97050f 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const zh_TW: TranslationMap = { common: { - version: "版本", health: "健康狀況", ok: "正常", offline: "離線", @@ -12,7 +11,6 @@ export const zh_TW: TranslationMap = { disabled: "已禁用", na: "不適用", docs: "文檔", - theme: "主題", resources: "資源", search: "搜尋", }, @@ -146,10 +144,6 @@ export const zh_TW: TranslationMap = { refreshAll: "全部刷新", terminal: "終端", }, - streamMode: { - active: "串流模式 — 數據已隱藏", - disable: "禁用", - }, palette: { placeholder: "輸入指令…", noResults: "無結果", diff --git a/ui/src/styles.css b/ui/src/styles.css index 16b327f3a73..80ddd985eda 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2,4 +2,5 @@ @import "./styles/layout.css"; @import "./styles/layout.mobile.css"; @import "./styles/components.css"; +@import "./styles/chat.css"; @import "./styles/config.css"; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index ffef3f69a23..3d1d77435c9 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,78 +1,78 @@ :root { - /* Background - Warmer dark with depth */ - --bg: #12141a; - --bg-accent: #14161d; - --bg-elevated: #1a1d25; - --bg-hover: #262a35; - --bg-muted: #262a35; + /* Background - Deep, rich dark with layered depth */ + --bg: #0e1015; + --bg-accent: #13151b; + --bg-elevated: #191c24; + --bg-hover: #1f2330; + --bg-muted: #1f2330; - /* Card / Surface - More contrast between levels */ - --card: #181b22; - --card-foreground: #f4f4f5; - --card-highlight: rgba(255, 255, 255, 0.05); - --popover: #181b22; - --popover-foreground: #f4f4f5; + /* Card / Surface - Clear hierarchy between levels */ + --card: #161920; + --card-foreground: #f0f0f2; + --card-highlight: rgba(255, 255, 255, 0.04); + --popover: #191c24; + --popover-foreground: #f0f0f2; /* Panel */ - --panel: #12141a; - --panel-strong: #1a1d25; - --panel-hover: #262a35; - --chrome: rgba(18, 20, 26, 0.95); - --chrome-strong: rgba(18, 20, 26, 0.98); + --panel: #0e1015; + --panel-strong: #191c24; + --panel-hover: #1f2330; + --chrome: rgba(14, 16, 21, 0.96); + --chrome-strong: rgba(14, 16, 21, 0.98); - /* Text - Slightly warmer */ - --text: #e4e4e7; - --text-strong: #fafafa; - --chat-text: #e4e4e7; - --muted: #71717a; - --muted-strong: #52525b; - --muted-foreground: #71717a; + /* Text - Clean contrast */ + --text: #d4d4d8; + --text-strong: #f4f4f5; + --chat-text: #d4d4d8; + --muted: #636370; + --muted-strong: #4e4e5a; + --muted-foreground: #636370; - /* Border - Subtle but defined */ - --border: #27272a; - --border-strong: #3f3f46; - --border-hover: #52525b; - --input: #27272a; + /* Border - Whisper-thin, barely there */ + --border: #1e2028; + --border-strong: #2e3040; + --border-hover: #3e4050; + --input: #1e2028; --ring: #ff5c5c; /* Accent - Punchy signature red */ --accent: #ff5c5c; --accent-hover: #ff7070; --accent-muted: #ff5c5c; - --accent-subtle: rgba(255, 92, 92, 0.15); + --accent-subtle: rgba(255, 92, 92, 0.1); --accent-foreground: #fafafa; - --accent-glow: rgba(255, 92, 92, 0.25); + --accent-glow: rgba(255, 92, 92, 0.2); --primary: #ff5c5c; --primary-foreground: #ffffff; - /* Secondary - Teal accent for variety */ - --secondary: #1e2028; - --secondary-foreground: #f4f4f5; + /* Secondary */ + --secondary: #161920; + --secondary-foreground: #f0f0f2; --accent-2: #14b8a6; --accent-2-muted: rgba(20, 184, 166, 0.7); - --accent-2-subtle: rgba(20, 184, 166, 0.15); + --accent-2-subtle: rgba(20, 184, 166, 0.1); - /* Semantic - More saturated */ + /* Semantic */ --ok: #22c55e; --ok-muted: rgba(34, 197, 94, 0.75); - --ok-subtle: rgba(34, 197, 94, 0.12); + --ok-subtle: rgba(34, 197, 94, 0.08); --destructive: #ef4444; --destructive-foreground: #fafafa; --warn: #f59e0b; --warn-muted: rgba(245, 158, 11, 0.75); - --warn-subtle: rgba(245, 158, 11, 0.12); + --warn-subtle: rgba(245, 158, 11, 0.08); --danger: #ef4444; --danger-muted: rgba(239, 68, 68, 0.75); - --danger-subtle: rgba(239, 68, 68, 0.12); + --danger-subtle: rgba(239, 68, 68, 0.08); --info: #3b82f6; - /* Focus - With glow */ - --focus: rgba(255, 92, 92, 0.25); - --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); - --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); + /* Focus */ + --focus: rgba(255, 92, 92, 0.2); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 60%, transparent); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow); /* Grid */ - --grid-line: rgba(255, 255, 255, 0.04); + --grid-line: rgba(255, 255, 255, 0.03); /* Theme transition */ --theme-switch-x: 50%; @@ -81,111 +81,153 @@ /* Typography */ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; - --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-display: var(--font-body); - /* Shadows - Richer with subtle color */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); - --shadow-glow: 0 0 30px var(--accent-glow); + /* Shadows - Subtle, layered depth */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.4); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 24px var(--accent-glow); - /* Radii - Slightly larger for friendlier feel */ + /* Radii - Slightly larger for modern feel */ --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; --radius-full: 9999px; - --radius: 8px; + --radius: 10px; - /* Transitions - Snappy but smooth */ + /* Transitions - Crisp and responsive */ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); - --duration-fast: 120ms; - --duration-normal: 200ms; - --duration-slow: 350ms; + --duration-fast: 100ms; + --duration-normal: 180ms; + --duration-slow: 300ms; color-scheme: dark; } -/* Light theme - Clean with subtle warmth */ -:root[data-theme="light"] { - --bg: #fafafa; - --bg-accent: #f5f5f5; +/* Light theme tokens apply to every light-mode family. */ +:root[data-theme-mode="light"] { + --bg: #f8f9fa; + --bg-accent: #f1f3f5; --bg-elevated: #ffffff; - --bg-hover: #f0f0f0; - --bg-muted: #f0f0f0; - --bg-content: #f5f5f5; + --bg-hover: #eceef0; + --bg-muted: #eceef0; + --bg-content: #f1f3f5; --card: #ffffff; - --card-foreground: #18181b; - --card-highlight: rgba(0, 0, 0, 0.03); + --card-foreground: #1a1a1e; + --card-highlight: rgba(0, 0, 0, 0.02); --popover: #ffffff; - --popover-foreground: #18181b; + --popover-foreground: #1a1a1e; - --panel: #fafafa; - --panel-strong: #f5f5f5; - --panel-hover: #ebebeb; - --chrome: rgba(250, 250, 250, 0.95); - --chrome-strong: rgba(250, 250, 250, 0.98); + --panel: #f8f9fa; + --panel-strong: #f1f3f5; + --panel-hover: #e6e8eb; + --chrome: rgba(248, 249, 250, 0.96); + --chrome-strong: rgba(248, 249, 250, 0.98); - --text: #3f3f46; - --text-strong: #18181b; - --chat-text: #3f3f46; - --muted: #71717a; - --muted-strong: #52525b; - --muted-foreground: #71717a; + --text: #3c3c43; + --text-strong: #1a1a1e; + --chat-text: #3c3c43; + --muted: #8e8e93; + --muted-strong: #636366; + --muted-foreground: #8e8e93; - --border: #e4e4e7; - --border-strong: #d4d4d8; - --border-hover: #a1a1aa; - --input: #e4e4e7; + --border: #e5e5ea; + --border-strong: #d1d1d6; + --border-hover: #aeaeb2; + --input: #e5e5ea; --accent: #dc2626; --accent-hover: #ef4444; --accent-muted: #dc2626; - --accent-subtle: rgba(220, 38, 38, 0.12); + --accent-subtle: rgba(220, 38, 38, 0.08); --accent-foreground: #ffffff; - --accent-glow: rgba(220, 38, 38, 0.15); + --accent-glow: rgba(220, 38, 38, 0.1); --primary: #dc2626; --primary-foreground: #ffffff; - --secondary: #f4f4f5; - --secondary-foreground: #3f3f46; + --secondary: #f1f3f5; + --secondary-foreground: #3c3c43; --accent-2: #0d9488; --accent-2-muted: rgba(13, 148, 136, 0.75); - --accent-2-subtle: rgba(13, 148, 136, 0.12); + --accent-2-subtle: rgba(13, 148, 136, 0.08); --ok: #16a34a; --ok-muted: rgba(22, 163, 74, 0.75); - --ok-subtle: rgba(22, 163, 74, 0.1); + --ok-subtle: rgba(22, 163, 74, 0.08); --destructive: #dc2626; --destructive-foreground: #fafafa; --warn: #d97706; --warn-muted: rgba(217, 119, 6, 0.75); - --warn-subtle: rgba(217, 119, 6, 0.1); + --warn-subtle: rgba(217, 119, 6, 0.08); --danger: #dc2626; --danger-muted: rgba(220, 38, 38, 0.75); - --danger-subtle: rgba(220, 38, 38, 0.1); + --danger-subtle: rgba(220, 38, 38, 0.08); --info: #2563eb; - --focus: rgba(220, 38, 38, 0.2); - --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); + --focus: rgba(220, 38, 38, 0.15); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 12px var(--accent-glow); - --grid-line: rgba(0, 0, 0, 0.05); + --grid-line: rgba(0, 0, 0, 0.04); - /* Light shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); - --shadow-glow: 0 0 24px var(--accent-glow); + /* Light shadows - Subtle, clean */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.1); + --shadow-glow: 0 0 20px var(--accent-glow); color-scheme: light; } +/* Theme families override accent tokens while keeping shared surfaces/layout. */ +:root[data-theme="openknot"] { + --ring: #14b8a6; + --accent: #14b8a6; + --accent-hover: #2dd4bf; + --accent-muted: #14b8a6; + --accent-subtle: rgba(20, 184, 166, 0.12); + --accent-glow: rgba(20, 184, 166, 0.22); + --primary: #14b8a6; +} + +:root[data-theme="openknot-light"] { + --ring: #0d9488; + --accent: #0d9488; + --accent-hover: #0f766e; + --accent-muted: #0d9488; + --accent-subtle: rgba(13, 148, 136, 0.1); + --accent-glow: rgba(13, 148, 136, 0.14); + --primary: #0d9488; +} + +:root[data-theme="dash"] { + --ring: #3b82f6; + --accent: #3b82f6; + --accent-hover: #60a5fa; + --accent-muted: #3b82f6; + --accent-subtle: rgba(59, 130, 246, 0.14); + --accent-glow: rgba(59, 130, 246, 0.22); + --primary: #3b82f6; +} + +:root[data-theme="dash-light"] { + --ring: #2563eb; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-muted: #2563eb; + --accent-subtle: rgba(37, 99, 235, 0.1); + --accent-glow: rgba(37, 99, 235, 0.14); + --primary: #2563eb; +} + * { box-sizing: border-box; } @@ -197,8 +239,8 @@ body { body { margin: 0; - font: 400 14px/1.55 var(--font-body); - letter-spacing: -0.02em; + font: 400 13.5px/1.55 var(--font-body); + letter-spacing: -0.01em; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; @@ -267,10 +309,10 @@ select { color: var(--text-strong); } -/* Scrollbar styling */ +/* Scrollbar styling - Minimal, barely visible */ ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 6px; + height: 6px; } ::-webkit-scrollbar-track { @@ -278,12 +320,12 @@ select { } ::-webkit-scrollbar-thumb { - background: var(--border); + background: rgba(255, 255, 255, 0.08); border-radius: var(--radius-full); } ::-webkit-scrollbar-thumb:hover { - background: var(--border-strong); + background: rgba(255, 255, 255, 0.14); } /* Animations - Polished with spring feel */ @@ -338,6 +380,42 @@ select { } } +/* Skeleton loading primitives */ +.skeleton { + background: linear-gradient(90deg, var(--bg-muted) 25%, var(--bg-hover) 50%, var(--bg-muted) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius-md); +} + +.skeleton-line { + height: 14px; + border-radius: var(--radius-sm); +} + +.skeleton-line--short { + width: 40%; +} + +.skeleton-line--medium { + width: 65%; +} + +.skeleton-line--long { + width: 85%; +} + +.skeleton-stat { + height: 28px; + width: 60px; + border-radius: var(--radius-sm); +} + +.skeleton-block { + height: 48px; + border-radius: var(--radius-md); +} + @keyframes pulse-subtle { 0%, 100% { diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index c43743267a9..cd482f46f7c 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -5,9 +5,9 @@ /* Chat Group Layout - default (assistant/other on left) */ .chat-group { display: flex; - gap: 12px; + gap: 10px; align-items: flex-start; - margin-bottom: 16px; + margin-bottom: 14px; margin-left: 4px; margin-right: 16px; } @@ -54,6 +54,55 @@ opacity: 0.7; } +/* ── Group footer action buttons (TTS, delete) ── */ +.chat-group-footer button { + background: none; + border: none; + cursor: pointer; + padding: 2px; + border-radius: var(--radius-sm, 4px); + color: var(--muted); + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease-out, + color 120ms ease-out, + background 120ms ease-out; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.chat-group:hover .chat-group-footer button { + opacity: 0.6; + pointer-events: auto; +} + +.chat-group-footer button:hover { + opacity: 1 !important; + background: var(--bg-hover, rgba(255, 255, 255, 0.08)); +} + +.chat-group-footer button svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-tts-btn--active { + opacity: 1 !important; + pointer-events: auto !important; + color: var(--accent, #3b82f6); +} + +.chat-group-delete:hover { + color: var(--danger, #ef4444) !important; +} + /* Chat divider (e.g., compaction marker) */ .chat-divider { display: flex; @@ -83,22 +132,24 @@ /* Avatar Styles */ .chat-avatar { - width: 40px; - height: 40px; - border-radius: 8px; + width: 36px; + height: 36px; + border-radius: 10px; background: var(--panel-strong); display: grid; place-items: center; font-weight: 600; - font-size: 14px; + font-size: 13px; flex-shrink: 0; - align-self: flex-end; /* Align with last message in group */ - margin-bottom: 4px; /* Optical alignment */ + align-self: flex-end; + margin-bottom: 4px; + border: 1px solid var(--border); } .chat-avatar.user { background: var(--accent-subtle); color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 20%, transparent); } .chat-avatar.assistant { @@ -127,14 +178,14 @@ img.chat-avatar { .chat-bubble { position: relative; display: inline-block; - border: 1px solid transparent; + border: 1px solid var(--border); background: var(--card); border-radius: var(--radius-lg); padding: 10px 14px; box-shadow: none; transition: - background 150ms ease-out, - border-color 150ms ease-out; + background var(--duration-fast) ease-out, + border-color var(--duration-fast) ease-out; max-width: 100%; word-wrap: break-word; } @@ -244,7 +295,7 @@ img.chat-avatar { } /* Light mode: restore borders */ -:root[data-theme="light"] .chat-bubble { +:root[data-theme-mode="light"] .chat-bubble { border-color: var(--border); box-shadow: inset 0 1px 0 var(--card-highlight); } @@ -259,7 +310,7 @@ img.chat-avatar { border-color: transparent; } -:root[data-theme="light"] .chat-group.user .chat-bubble { +:root[data-theme-mode="light"] .chat-group.user .chat-bubble { border-color: rgba(234, 88, 12, 0.2); background: rgba(251, 146, 60, 0.12); } @@ -298,3 +349,125 @@ img.chat-avatar { transform: translateY(0); } } + +/* ── Message metadata (tokens, cost, model, context %) ── */ +.msg-meta { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + line-height: 1; + color: var(--muted); + margin-top: 4px; + flex-wrap: wrap; +} + +.msg-meta__tokens, +.msg-meta__cache, +.msg-meta__cost, +.msg-meta__ctx, +.msg-meta__model { + display: inline-flex; + align-items: center; + gap: 2px; + white-space: nowrap; +} + +.msg-meta__model { + background: var(--bg-hover, rgba(255, 255, 255, 0.06)); + padding: 1px 6px; + border-radius: var(--radius-sm, 4px); + font-family: var(--font-mono, monospace); +} + +.msg-meta__cost { + color: var(--ok, #22c55e); +} + +.msg-meta__ctx--warn { + color: var(--warning, #eab308); +} + +.msg-meta__ctx--danger { + color: var(--danger, #ef4444); +} + +/* ── Delete confirmation popover ── */ +.chat-delete-wrap { + position: relative; + display: inline-flex; +} + +.chat-delete-confirm { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + background: var(--card, #1a1a1a); + border: 1px solid var(--border, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-md, 8px); + padding: 12px; + min-width: 200px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 100; + animation: scale-in 0.15s ease-out; +} + +.chat-delete-confirm__text { + margin: 0 0 8px; + font-size: 13px; + font-weight: 500; + color: var(--fg, #fff); +} + +.chat-delete-confirm__remember { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--muted, #888); + margin-bottom: 10px; + cursor: pointer; + user-select: none; +} + +.chat-delete-confirm__check { + width: 14px; + height: 14px; + accent-color: var(--accent, #3b82f6); + cursor: pointer; +} + +.chat-delete-confirm__actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.chat-delete-confirm__cancel, +.chat-delete-confirm__yes { + border: none; + border-radius: var(--radius-sm, 4px); + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease-out; +} + +.chat-delete-confirm__cancel { + background: var(--bg-hover, rgba(255, 255, 255, 0.08)); + color: var(--muted, #888); +} + +.chat-delete-confirm__cancel:hover { + background: rgba(255, 255, 255, 0.12); +} + +.chat-delete-confirm__yes { + background: var(--danger, #ef4444); + color: #fff; +} + +.chat-delete-confirm__yes:hover { + background: #dc2626; +} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 25fa6742b4a..6d12698d6b2 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -219,17 +219,17 @@ } /* Light theme attachment overrides */ -:root[data-theme="light"] .chat-attachments { +:root[data-theme-mode="light"] .chat-attachments { background: #f8fafc; border-color: rgba(16, 24, 40, 0.1); } -:root[data-theme="light"] .chat-attachment { +:root[data-theme-mode="light"] .chat-attachment { border-color: rgba(16, 24, 40, 0.15); background: #fff; } -:root[data-theme="light"] .chat-attachment__remove { +:root[data-theme-mode="light"] .chat-attachment__remove { background: rgba(0, 0, 0, 0.6); } @@ -267,7 +267,7 @@ flex: 1; } -:root[data-theme="light"] .chat-compose { +:root[data-theme-mode="light"] .chat-compose { background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); } @@ -322,6 +322,340 @@ box-sizing: border-box; } +.agent-chat__input { + position: relative; + display: flex; + flex-direction: column; + margin: 0 18px 14px; + padding: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + flex-shrink: 0; + overflow: hidden; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.agent-chat__input:focus-within { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent); +} + +@supports (backdrop-filter: blur(1px)) { + .agent-chat__input { + backdrop-filter: blur(12px) saturate(1.6); + -webkit-backdrop-filter: blur(12px) saturate(1.6); + } +} + +.agent-chat__input > textarea { + width: 100%; + min-height: 40px; + max-height: 150px; + resize: none; + padding: 12px 14px 8px; + border: none; + background: transparent; + color: var(--text); + font-size: 0.92rem; + font-family: inherit; + line-height: 1.4; + outline: none; + box-sizing: border-box; +} + +.agent-chat__input > textarea::placeholder { + color: var(--muted); +} + +.agent-chat__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.agent-chat__toolbar-left, +.agent-chat__toolbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +.agent-chat__input-btn, +.agent-chat__toolbar .btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; + padding: 0; + transition: all var(--duration-fast) ease; +} + +.agent-chat__input-btn svg, +.agent-chat__toolbar .btn-ghost svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.agent-chat__input-btn:hover:not(:disabled), +.agent-chat__toolbar .btn-ghost:hover:not(:disabled) { + color: var(--text); + background: var(--bg-hover); +} + +.agent-chat__input-btn:disabled, +.agent-chat__toolbar .btn-ghost:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.agent-chat__input-btn--active { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.agent-chat__input-divider { + width: 1px; + height: 16px; + background: var(--border); + margin: 0 4px; +} + +.agent-chat__token-count { + font-size: 0.7rem; + color: var(--muted); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} + +.chat-send-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-md); + border: none; + background: var(--accent); + color: var(--accent-foreground); + cursor: pointer; + flex-shrink: 0; + transition: + background var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; + padding: 0; +} + +.chat-send-btn svg { + width: 15px; + height: 15px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-send-btn:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: 0 2px 10px rgba(255, 92, 92, 0.25); +} + +.chat-send-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.chat-send-btn--stop { + background: var(--danger); +} + +.chat-send-btn--stop:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 85%, #fff); +} + +.slash-menu { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + max-height: 320px; + overflow-y: auto; + background: var(--popover); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 30; + margin-bottom: 4px; + padding: 6px; + scrollbar-width: thin; +} + +.slash-menu-group + .slash-menu-group { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.slash-menu-group__label { + padding: 4px 10px 2px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--accent); + opacity: 0.7; +} + +.slash-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.slash-menu-item:hover, +.slash-menu-item--active { + background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover)); +} + +.slash-menu-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + color: var(--accent); + opacity: 0.7; +} + +.slash-menu-icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.slash-menu-item--active .slash-menu-icon, +.slash-menu-item:hover .slash-menu-icon { + opacity: 1; +} + +.slash-menu-name { + font-size: 0.82rem; + font-weight: 600; + font-family: var(--mono); + color: var(--accent); + white-space: nowrap; +} + +.slash-menu-args { + font-size: 0.75rem; + color: var(--muted); + font-family: var(--mono); + opacity: 0.65; +} + +.slash-menu-desc { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-size: 0.75rem; + color: var(--muted); +} + +.slash-menu-item--active .slash-menu-name { + color: var(--accent-hover); +} + +.slash-menu-item--active .slash-menu-desc { + color: var(--text); +} + +.chat-attachments-preview { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.chat-attachment-thumb { + position: relative; + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border); +} + +.chat-attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.chat-attachment-remove { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.chat-attachment-file { + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.72rem; + color: var(--muted); +} + +.agent-chat__file-input { + display: none; +} + /* Chat controls - moved to content-header area, left aligned */ .chat-controls { display: flex; @@ -363,7 +697,7 @@ font-weight: 300; } -:root[data-theme="light"] .chat-controls__separator { +:root[data-theme-mode="light"] .chat-controls__separator { color: rgba(16, 24, 40, 0.3); } @@ -373,34 +707,34 @@ } /* Light theme icon button overrides */ -:root[data-theme="light"] .btn--icon { +:root[data-theme-mode="light"] .btn--icon { background: #ffffff; border-color: var(--border); box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); color: var(--muted); } -:root[data-theme="light"] .btn--icon:hover { +:root[data-theme-mode="light"] .btn--icon:hover { background: #ffffff; border-color: var(--border-strong); color: var(--text); } /* Light theme icon button overrides */ -:root[data-theme="light"] .btn--icon { +:root[data-theme-mode="light"] .btn--icon { background: #ffffff; border-color: var(--border); box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); color: var(--muted); } -:root[data-theme="light"] .btn--icon:hover { +:root[data-theme-mode="light"] .btn--icon:hover { background: #ffffff; border-color: var(--border-strong); color: var(--text); } -:root[data-theme="light"] .chat-controls .btn--icon.active { +:root[data-theme-mode="light"] .chat-controls .btn--icon.active { border-color: var(--accent); background: var(--accent-subtle); color: var(--accent); @@ -438,7 +772,7 @@ } /* Light theme thinking indicator override */ -:root[data-theme="light"] .chat-controls__thinking { +:root[data-theme-mode="light"] .chat-controls__thinking { background: rgba(255, 255, 255, 0.9); border-color: rgba(16, 24, 40, 0.15); } @@ -479,3 +813,119 @@ min-width: 120px; } } + +/* Chat loading skeleton */ +.chat-loading-skeleton { + padding: 4px 0; + animation: fade-in 0.3s var(--ease-out); +} + +/* Welcome state (new session) */ +.agent-chat__welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 12px; + padding: 48px 24px; + flex: 1; + min-height: 0; +} + +.agent-chat__welcome-glow { + display: none; +} + +.agent-chat__welcome h2 { + font-size: 20px; + font-weight: 600; + margin: 0; + color: var(--foreground); +} + +.agent-chat__avatar--logo { + width: 48px; + height: 48px; + border-radius: 14px; + background: var(--panel-strong); + border: 1px solid var(--border); + display: grid; + place-items: center; + overflow: hidden; +} + +.agent-chat__avatar--logo img { + width: 32px; + height: 32px; + object-fit: contain; +} + +.agent-chat__badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.agent-chat__badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 100px; + padding: 4px 12px; +} + +.agent-chat__badge img { + width: 14px; + height: 14px; + object-fit: contain; +} + +.agent-chat__hint { + font-size: 13px; + color: var(--muted); + margin: 0; +} + +.agent-chat__hint kbd { + display: inline-block; + padding: 1px 6px; + font-size: 11px; + font-family: var(--font-mono); + background: var(--panel-strong); + border: 1px solid var(--border); + border-radius: 4px; +} + +.agent-chat__suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + max-width: 480px; + margin-top: 8px; +} + +.agent-chat__suggestion { + font-size: 13px; + padding: 8px 16px; + border-radius: 100px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--foreground); + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s; +} + +.agent-chat__suggestion:hover { + background: var(--panel-strong); + border-color: var(--accent); +} diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 6598af7a072..56224fabf9e 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -13,7 +13,7 @@ line-height: 1.4; } -:root[data-theme="light"] .chat-thinking { +:root[data-theme-mode="light"] .chat-thinking { border-color: rgba(16, 24, 40, 0.25); background: rgba(16, 24, 40, 0.04); } @@ -97,24 +97,24 @@ background: rgba(255, 255, 255, 0.04); } -:root[data-theme="light"] .chat-text :where(blockquote) { +:root[data-theme-mode="light"] .chat-text :where(blockquote) { background: rgba(0, 0, 0, 0.03); } -:root[data-theme="light"] .chat-text :where(blockquote blockquote) { +:root[data-theme-mode="light"] .chat-text :where(blockquote blockquote) { background: rgba(0, 0, 0, 0.05); } -:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) { +:root[data-theme-mode="light"] .chat-text :where(blockquote blockquote blockquote) { background: rgba(0, 0, 0, 0.04); } -:root[data-theme="light"] .chat-text :where(:not(pre) > code) { +:root[data-theme-mode="light"] .chat-text :where(:not(pre) > code) { background: rgba(0, 0, 0, 0.08); border: 1px solid rgba(0, 0, 0, 0.1); } -:root[data-theme="light"] .chat-text :where(pre) { +:root[data-theme-mode="light"] .chat-text :where(pre) { background: rgba(0, 0, 0, 0.05); border: 1px solid rgba(0, 0, 0, 0.1); } diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 6384db115f0..2115c8387ce 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -1,15 +1,13 @@ /* Tool Card Styles */ .chat-tool-card { border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - margin-top: 8px; + border-radius: var(--radius-md); + padding: 10px 12px; + margin-top: 6px; background: var(--card); - box-shadow: inset 0 1px 0 var(--card-highlight); transition: - border-color 150ms ease-out, - background 150ms ease-out; - /* Fixed max-height to ensure cards don't expand too much */ + border-color var(--duration-fast) ease-out, + background var(--duration-fast) ease-out; max-height: 120px; overflow: hidden; } @@ -154,6 +152,265 @@ word-break: break-word; } +.chat-tools-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--muted); + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-tools-summary::-webkit-details-marker { + display: none; +} + +.chat-tools-summary::before { + content: "▸"; + font-size: 10px; + flex-shrink: 0; + transition: transform 150ms ease; +} + +.chat-tools-collapse[open] > .chat-tools-summary::before { + transform: rotate(90deg); +} + +.chat-tools-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 50%, transparent); +} + +.chat-tools-summary__icon { + display: inline-flex; + align-items: center; + width: 14px; + height: 14px; + color: var(--accent); + opacity: 0.7; + flex-shrink: 0; +} + +.chat-tools-summary__icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-tools-summary__count { + font-weight: 600; + color: var(--text); +} + +.chat-tools-summary__names { + color: var(--muted); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-tools-collapse__body { + padding: 4px 12px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); +} + +.chat-tools-collapse__body .chat-tool-card:first-child { + margin-top: 8px; +} + +.chat-json-collapse { + margin-top: 4px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--secondary) 60%, transparent); + overflow: hidden; +} + +.chat-json-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; + list-style: none; + transition: + color 150ms ease, + background 150ms ease; +} + +.chat-json-summary::-webkit-details-marker { + display: none; +} + +.chat-json-summary::before { + content: "▸"; + font-size: 10px; + flex-shrink: 0; + transition: transform 150ms ease; +} + +.chat-json-collapse[open] > .chat-json-summary::before { + transform: rotate(90deg); +} + +.chat-json-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 50%, transparent); +} + +.chat-json-badge { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.4; + flex-shrink: 0; +} + +.chat-json-label { + font-family: var(--mono); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-json-content { + margin: 0; + padding: 10px 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + color: var(--text); + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.chat-json-content code { + font-family: inherit; + font-size: inherit; +} + +.chat-tool-msg-collapse { + margin-top: 2px; +} + +.chat-tool-msg-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; + list-style: none; + border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-hover) 35%, transparent); + transition: + color 150ms ease, + background 150ms ease, + border-color 150ms ease; +} + +.chat-tool-msg-summary::-webkit-details-marker { + display: none; +} + +.chat-tool-msg-summary::before { + content: "▸"; + font-size: 10px; + flex-shrink: 0; + transition: transform 150ms ease; +} + +.chat-tool-msg-collapse[open] > .chat-tool-msg-summary::before { + transform: rotate(90deg); +} + +.chat-tool-msg-summary:hover { + color: var(--text); + background: color-mix(in srgb, var(--bg-hover) 60%, transparent); + border-color: color-mix(in srgb, var(--border-strong) 70%, transparent); +} + +.chat-tool-msg-summary__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + color: var(--accent); + opacity: 0.75; + flex-shrink: 0; +} + +.chat-tool-msg-summary__icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-tool-msg-summary__label { + font-weight: 600; + color: var(--text); + flex-shrink: 0; +} + +.chat-tool-msg-summary__names { + font-family: var(--mono); + font-size: 11px; + opacity: 0.85; + flex: 1 1 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.chat-tool-msg-summary__preview { + font-family: var(--mono); + font-size: 11px; + opacity: 0.85; + flex: 1 1 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.chat-tool-msg-body { + padding-top: 8px; +} + /* Reading Indicator */ .chat-reading-indicator { background: transparent; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 126972ca003..d1dc29ca04e 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,5 +1,136 @@ @import "./chat.css"; +/* =========================================== + Login Gate + =========================================== */ + +.login-gate { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + padding: 24px; +} + +.login-gate__theme { + position: fixed; + top: 16px; + right: 16px; + z-index: 10; +} + +.login-gate__card { + width: min(520px, 100%); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 32px; + animation: scale-in 0.25s var(--ease-out); +} + +.login-gate__header { + text-align: center; + margin-bottom: 24px; +} + +.login-gate__logo { + width: 48px; + height: 48px; + margin-bottom: 12px; +} + +.login-gate__title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.login-gate__sub { + color: var(--muted); + font-size: 14px; + margin-top: 4px; +} + +.login-gate__form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-gate__secret-row { + display: flex; + align-items: center; + gap: 8px; +} + +.login-gate__secret-row input { + flex: 1; +} + +.login-gate__secret-row .btn--icon { + width: 40px; + min-width: 40px; + height: 40px; +} + +.login-gate__connect { + margin-top: 4px; + width: 100%; + justify-content: center; + padding: 10px 16px; + font-size: 15px; + font-weight: 600; +} + +.login-gate__help { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.login-gate__help-title { + font-weight: 600; + font-size: 12px; + margin-bottom: 10px; + color: var(--fg); +} + +.login-gate__steps { + margin: 0; + padding-left: 20px; + font-size: 12px; + line-height: 1.6; + color: var(--muted); +} + +.login-gate__steps li { + margin-bottom: 6px; +} + +.login-gate__steps li:last-child { + margin-bottom: 0; +} + +.login-gate__steps code { + display: block; + margin: 4px 0 2px; + padding: 5px 10px; + font-family: var(--font-mono); + font-size: 11px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--fg); + user-select: all; +} + +.login-gate__docs { + margin-top: 10px; + font-size: 11px; +} + /* =========================================== Update Banner =========================================== */ @@ -29,6 +160,31 @@ background: rgba(239, 68, 68, 0.15); } +.update-banner__close { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 8px; + padding: 2px; + background: none; + border: none; + cursor: pointer; + color: var(--danger); + opacity: 0.7; + transition: opacity 0.15s; +} +.update-banner__close:hover { + opacity: 1; +} +.update-banner__close svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; +} + /* =========================================== Cards - Refined with depth =========================================== */ @@ -37,22 +193,16 @@ border: 1px solid var(--border); background: var(--card); border-radius: var(--radius-lg); - padding: 20px; - animation: rise 0.35s var(--ease-out) backwards; + padding: 18px; + animation: rise 0.25s var(--ease-out) backwards; transition: border-color var(--duration-normal) var(--ease-out), - box-shadow var(--duration-normal) var(--ease-out), - transform var(--duration-normal) var(--ease-out); - box-shadow: - var(--shadow-sm), - inset 0 1px 0 var(--card-highlight); + box-shadow var(--duration-normal) var(--ease-out); } .card:hover { border-color: var(--border-strong); - box-shadow: - var(--shadow-md), - inset 0 1px 0 var(--card-highlight); + box-shadow: var(--shadow-sm); } .card-title { @@ -81,14 +231,10 @@ transition: border-color var(--duration-normal) var(--ease-out), box-shadow var(--duration-normal) var(--ease-out); - box-shadow: inset 0 1px 0 var(--card-highlight); } .stat:hover { border-color: var(--border-strong); - box-shadow: - var(--shadow-sm), - inset 0 1px 0 var(--card-highlight); } .stat-label { @@ -216,12 +362,12 @@ .pill { display: inline-flex; align-items: center; - gap: 6px; + gap: 5px; border: 1px solid var(--border); - padding: 6px 12px; + padding: 5px 11px; border-radius: var(--radius-full); background: var(--secondary); - font-size: 13px; + font-size: 12px; font-weight: 500; transition: border-color var(--duration-fast) ease; } @@ -237,66 +383,105 @@ } /* =========================================== - Theme Toggle + Theme Orb =========================================== */ -.theme-toggle { - --theme-item: 28px; - --theme-gap: 2px; - --theme-pad: 4px; +.theme-orb { position: relative; + display: inline-flex; + align-items: center; } -.theme-toggle__track { - position: relative; - display: grid; - grid-template-columns: repeat(3, var(--theme-item)); - gap: var(--theme-gap); - padding: var(--theme-pad); +.theme-orb__trigger { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; border-radius: var(--radius-full); border: 1px solid var(--border); - background: var(--secondary); -} - -.theme-toggle__indicator { - position: absolute; - top: 50%; - left: var(--theme-pad); - width: var(--theme-item); - height: var(--theme-item); - border-radius: var(--radius-full); - transform: translateY(-50%) - translateX(calc(var(--theme-index, 0) * (var(--theme-item) + var(--theme-gap)))); - background: var(--accent); - transition: transform var(--duration-normal) var(--ease-out); - z-index: 0; -} - -.theme-toggle__button { - height: var(--theme-item); - width: var(--theme-item); - display: grid; - place-items: center; - border: 0; - border-radius: var(--radius-full); - background: transparent; - color: var(--muted); + background: var(--card); cursor: pointer; - position: relative; - z-index: 1; - transition: color var(--duration-fast) ease; + font-size: 14px; + line-height: 1; + padding: 0; + transition: + border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); } -.theme-toggle__button:hover { - color: var(--text); +.theme-orb__trigger:hover { + border-color: var(--border-strong); + transform: scale(1.08); } -.theme-toggle__button.active { - color: var(--accent-foreground); +.theme-orb__trigger:focus-visible { + outline: none; + border-color: var(--ring); + box-shadow: var(--focus-ring); } -.theme-toggle__button.active .theme-icon { - stroke: var(--accent-foreground); +.theme-orb__menu { + position: absolute; + right: 0; + top: calc(100% + 6px); + display: flex; + gap: 2px; + padding: 4px; + border-radius: var(--radius-full); + background: var(--card); + border: 1px solid var(--border); + box-shadow: var(--shadow-md); + opacity: 0; + visibility: hidden; + transform: scale(0.4) translateY(-8px); + transform-origin: top right; + pointer-events: none; + transition: + opacity var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); +} + +.theme-orb--open .theme-orb__menu { + opacity: 1; + visibility: visible; + transform: scale(1) translateY(0); + pointer-events: auto; +} + +.theme-orb__option { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-full); + border: 1.5px solid transparent; + background: transparent; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; + transition: + background var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.theme-orb__option:hover { + background: var(--bg-hover); + transform: scale(1.12); +} + +.theme-orb__option--active { + border-color: var(--accent); + background: var(--accent-subtle); +} + +.theme-orb__option:focus-visible { + outline: none; + box-shadow: var(--focus-ring); } .theme-icon { @@ -342,10 +527,10 @@ display: inline-flex; align-items: center; justify-content: center; - gap: 8px; + gap: 6px; border: 1px solid var(--border); background: var(--bg-elevated); - padding: 9px 16px; + padding: 8px 14px; border-radius: var(--radius-md); font-size: 13px; font-weight: 500; @@ -354,21 +539,16 @@ transition: border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out), - box-shadow var(--duration-fast) var(--ease-out), - transform var(--duration-fast) var(--ease-out); + box-shadow var(--duration-fast) var(--ease-out); } .btn:hover { background: var(--bg-hover); border-color: var(--border-strong); - transform: translateY(-1px); - box-shadow: var(--shadow-sm); } .btn:active { background: var(--secondary); - transform: translateY(0); - box-shadow: none; } .btn svg { @@ -386,15 +566,13 @@ border-color: var(--accent); background: var(--accent); color: var(--primary-foreground); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px rgba(255, 92, 92, 0.25); } .btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); - box-shadow: - var(--shadow-md), - 0 0 20px var(--accent-glow); + box-shadow: 0 2px 12px rgba(255, 92, 92, 0.3); } /* Keyboard shortcut badge (shadcn style) */ @@ -418,11 +596,11 @@ background: rgba(255, 255, 255, 0.2); } -:root[data-theme="light"] .btn-kbd { +:root[data-theme-mode="light"] .btn-kbd { background: rgba(0, 0, 0, 0.08); } -:root[data-theme="light"] .btn.primary .btn-kbd { +:root[data-theme-mode="light"] .btn.primary .btn-kbd { background: rgba(255, 255, 255, 0.25); } @@ -969,29 +1147,29 @@ } } -:root[data-theme="light"] .field input, -:root[data-theme="light"] .field textarea, -:root[data-theme="light"] .field select { +:root[data-theme-mode="light"] .field input, +:root[data-theme-mode="light"] .field textarea, +:root[data-theme-mode="light"] .field select { background: var(--card); border-color: var(--input); } -:root[data-theme="light"] .btn { +:root[data-theme-mode="light"] .btn { background: var(--bg); border-color: var(--input); } -:root[data-theme="light"] .btn:hover { +:root[data-theme-mode="light"] .btn:hover { background: var(--bg-hover); } -:root[data-theme="light"] .btn.active { +:root[data-theme-mode="light"] .btn.active { border-color: var(--accent); background: var(--accent-subtle); color: var(--accent); } -:root[data-theme="light"] .btn.primary { +:root[data-theme-mode="light"] .btn.primary { background: var(--accent); border-color: var(--accent); } @@ -1117,10 +1295,10 @@ max-width: 100%; } -:root[data-theme="light"] .code-block, -:root[data-theme="light"] .list-item, -:root[data-theme="light"] .table-row, -:root[data-theme="light"] .chip { +:root[data-theme-mode="light"] .code-block, +:root[data-theme-mode="light"] .list-item, +:root[data-theme-mode="light"] .table-row, +:root[data-theme-mode="light"] .chip { background: var(--bg); } @@ -1496,6 +1674,339 @@ font-size: 11px; } +/* =========================================== + Data Table + =========================================== */ + +.data-table-wrapper { + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.data-table-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-elevated); +} + +.data-table-search { + flex: 1; + min-width: 0; +} + +.data-table-search input { + width: 100%; + padding: 6px 10px; + font-size: 13px; + color: var(--text); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + transition: border-color var(--duration-fast) ease; +} + +.data-table-search input:focus { + border-color: var(--border-strong); + box-shadow: var(--focus-ring); +} + +.data-table-search input::placeholder { + color: var(--muted); +} + +.data-table-container { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.data-table thead { + position: sticky; + top: 0; + z-index: 1; +} + +.data-table th { + padding: 10px 12px; + text-align: left; + font-weight: 600; + font-size: 12px; + color: var(--muted); + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + white-space: nowrap; + user-select: none; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.data-table th[data-sortable] { + cursor: pointer; + transition: color var(--duration-fast) ease; +} + +.data-table th[data-sortable]:hover { + color: var(--text); +} + +.data-table-sort-icon { + display: inline-flex; + vertical-align: middle; + margin-left: 4px; + opacity: 0.4; + transition: opacity var(--duration-fast) ease; +} + +.data-table-sort-icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; +} + +.data-table th[data-sortable]:hover .data-table-sort-icon { + opacity: 0.7; +} + +.data-table th[data-sort-dir="asc"] .data-table-sort-icon, +.data-table th[data-sort-dir="desc"] .data-table-sort-icon { + opacity: 1; + color: var(--text); +} + +.data-table th[data-sort-dir="desc"] .data-table-sort-icon svg { + transform: rotate(180deg); +} + +.data-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + color: var(--text); + vertical-align: middle; +} + +.data-table tbody tr { + transition: background var(--duration-fast) ease; +} + +.data-table tbody tr:hover { + background: var(--bg-hover); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +/* Badges for session kind */ +.data-table-badge { + display: inline-block; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + border-radius: var(--radius-full); + letter-spacing: 0.02em; +} + +.data-table-badge--direct { + color: var(--accent-2); + background: var(--accent-2-subtle); +} + +.data-table-badge--group { + color: var(--info); + background: rgba(59, 130, 246, 0.1); +} + +.data-table-badge--global { + color: var(--warn); + background: var(--warn-subtle); +} + +.data-table-badge--unknown { + color: var(--muted); + background: var(--bg-hover); +} + +/* Pagination */ +.data-table-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-top: 1px solid var(--border); + background: var(--bg-elevated); + font-size: 13px; + color: var(--muted); +} + +.data-table-pagination__controls { + display: flex; + align-items: center; + gap: 8px; +} + +.data-table-pagination__controls button { + padding: 4px 12px; + font-size: 13px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--card); + color: var(--text); + cursor: pointer; + transition: + background var(--duration-fast) ease, + border-color var(--duration-fast) ease; +} + +.data-table-pagination__controls button:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.data-table-pagination__controls button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Row actions */ +.data-table-row-actions { + position: relative; +} + +.data-table-row-actions__trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease, + border-color var(--duration-fast) ease; +} + +.data-table-row-actions__trigger svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; +} + +.data-table-row-actions__trigger:hover { + background: var(--bg-hover); + color: var(--text); + border-color: var(--border); +} + +.data-table-row-actions__menu { + position: absolute; + right: 0; + top: 100%; + z-index: 42; + min-width: 140px; + background: var(--popover); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + padding: 4px; + animation: fade-in var(--duration-fast) ease; +} + +.data-table-row-actions__menu a, +.data-table-row-actions__menu button { + display: block; + width: 100%; + padding: 8px 12px; + font-size: 13px; + text-align: left; + text-decoration: none; + color: var(--text); + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.data-table-row-actions__menu a:hover, +.data-table-row-actions__menu button:hover { + background: var(--bg-hover); +} + +.data-table-row-actions__menu button.danger { + color: var(--danger); +} + +.data-table-row-actions__menu button.danger:hover { + background: var(--danger-subtle); +} + +/* Click-away overlay for open menus */ +.data-table-overlay { + position: fixed; + inset: 0; + z-index: 40; + background: transparent; +} + +/* Inline form fields for filter bars */ +.field-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text); +} + +.field-inline span { + color: var(--muted); + font-weight: 500; + white-space: nowrap; +} + +.field-inline input[type="text"], +.field-inline input:not([type]) { + padding: 6px 10px; + font-size: 13px; + color: var(--text); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + transition: border-color var(--duration-fast) ease; +} + +.field-inline input:focus { + border-color: var(--border-strong); + box-shadow: var(--focus-ring); +} + +.field-inline.checkbox { + gap: 4px; + cursor: pointer; +} + +.field-inline.checkbox input[type="checkbox"] { + accent-color: var(--accent); +} + /* =========================================== Log Stream =========================================== */ @@ -1757,7 +2268,7 @@ min-width: 0; } -:root[data-theme="light"] .chat-bubble { +:root[data-theme-mode="light"] .chat-bubble { border-color: var(--border); background: var(--bg); } @@ -1767,7 +2278,7 @@ background: var(--accent-subtle); } -:root[data-theme="light"] .chat-line.user .chat-bubble { +:root[data-theme-mode="light"] .chat-line.user .chat-bubble { border-color: rgba(234, 88, 12, 0.2); background: rgba(251, 146, 60, 0.12); } @@ -1777,7 +2288,7 @@ background: var(--secondary); } -:root[data-theme="light"] .chat-line.assistant .chat-bubble { +:root[data-theme-mode="light"] .chat-line.assistant .chat-bubble { border-color: var(--border); background: var(--bg-muted); } @@ -1912,7 +2423,7 @@ background: var(--secondary); } -:root[data-theme="light"] .chat-text :where(:not(pre) > code) { +:root[data-theme-mode="light"] .chat-text :where(:not(pre) > code) { background: var(--bg-muted); } @@ -1925,7 +2436,7 @@ overflow: auto; } -:root[data-theme="light"] .chat-text :where(pre) { +:root[data-theme-mode="light"] .chat-text :where(pre) { background: var(--bg-muted); } @@ -1968,7 +2479,7 @@ gap: 4px; } -:root[data-theme="light"] .chat-tool-card { +:root[data-theme-mode="light"] .chat-tool-card { background: var(--bg-muted); } @@ -2026,7 +2537,7 @@ background: var(--card); } -:root[data-theme="light"] .chat-tool-card__output { +:root[data-theme-mode="light"] .chat-tool-card__output { background: var(--bg); } @@ -2230,8 +2741,8 @@ .agents-layout { display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - gap: 16px; + grid-template-columns: 1fr; + gap: 14px; } .agents-sidebar { @@ -2240,9 +2751,151 @@ align-self: start; } +.agents-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.agents-toolbar-row { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.agents-toolbar-label { + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.agents-control-row { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.agents-control-select { + flex: 1; + min-width: 0; + max-width: 280px; +} + +.agents-select { + width: 100%; + padding: 7px 32px 7px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background-color: var(--bg-accent); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + font-size: 13px; + font-weight: 500; + cursor: pointer; + outline: none; + appearance: none; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +:root[data-theme-mode="light"] .agents-select { + background-color: white; +} + +.agents-select:focus { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.agents-control-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.agents-refresh-btn { + white-space: nowrap; +} + +.agent-actions-wrap { + position: relative; +} + +.agent-actions-toggle { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elevated); + color: var(--muted); + font-size: 14px; + cursor: pointer; + transition: + background var(--duration-fast) ease, + border-color var(--duration-fast) ease; +} + +.agent-actions-toggle:hover { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.agent-actions-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 10; + min-width: 160px; + padding: 4px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + box-shadow: var(--shadow-md); + display: grid; + gap: 1px; +} + +.agent-actions-menu button { + display: block; + width: 100%; + padding: 7px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font-size: 12px; + text-align: left; + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.agent-actions-menu button:hover:not(:disabled) { + background: var(--bg-hover); +} + +.agent-actions-menu button:disabled { + color: var(--muted); + cursor: not-allowed; + opacity: 0.5; +} + .agents-main { display: grid; - gap: 16px; + gap: 14px; } .agent-list { @@ -2254,13 +2907,13 @@ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 12px; + gap: 10px; width: 100%; text-align: left; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--card); - padding: 10px 12px; + padding: 8px 12px; cursor: pointer; transition: border-color var(--duration-fast) ease; } @@ -2324,13 +2977,13 @@ .agent-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 16px; + gap: 12px; align-items: center; } .agent-header-main { display: flex; - gap: 16px; + gap: 12px; align-items: center; } @@ -2343,32 +2996,48 @@ .agent-tabs { display: flex; - gap: 8px; + gap: 6px; flex-wrap: wrap; + padding-bottom: 2px; + border-bottom: 1px solid var(--border); } .agent-tab { - border: 1px solid var(--border); - border-radius: var(--radius-full); - padding: 6px 14px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 6px 12px; font-size: 12px; font-weight: 600; - background: var(--secondary); + color: var(--muted); + background: transparent; cursor: pointer; transition: border-color var(--duration-fast) ease, - background var(--duration-fast) ease; + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.agent-tab:hover { + color: var(--text); + background: var(--bg-hover); } .agent-tab.active { - background: var(--accent); - border-color: var(--accent); - color: white; + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 25%, transparent); + color: var(--accent); +} + +.agent-tab-count { + margin-left: 4px; + font-size: 10px; + font-weight: 700; + opacity: 0.7; } .agents-overview-grid { display: grid; - gap: 14px; + gap: 12px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } @@ -2390,7 +3059,69 @@ .agent-model-select { display: grid; - gap: 12px; + gap: 10px; +} + +.agent-model-fields { + display: grid; + gap: 10px; +} + +.workspace-link { + display: inline-flex; + align-items: center; + gap: 4px; + border: none; + background: transparent; + color: var(--accent); + font-family: var(--mono); + font-size: 12px; + padding: 2px 0; + cursor: pointer; + word-break: break-all; + text-align: left; + transition: opacity var(--duration-fast) ease; +} + +.workspace-link:hover { + opacity: 0.75; + text-decoration: underline; +} + +.agent-model-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.agent-chip-input { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-accent); + min-height: 38px; + cursor: text; + transition: border-color var(--duration-fast) ease; +} + +.agent-chip-input:focus-within { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.agent-chip-input input { + flex: 1; + min-width: 120px; + border: none; + background: transparent; + outline: none; + font-size: 13px; + padding: 0; } .agent-model-meta { @@ -2401,8 +3132,8 @@ .agent-files-grid { display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - gap: 16px; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + gap: 14px; } .agent-files-list { @@ -2451,6 +3182,19 @@ background: var(--card); } +.agent-file-field { + min-height: clamp(320px, 56vh, 720px); +} + +.field textarea.agent-file-textarea { + min-height: clamp(320px, 56vh, 720px); + transition: filter var(--duration-fast) ease; +} + +.field textarea.agent-file-textarea:not(:focus) { + filter: blur(6px); +} + .agent-file-header { display: flex; justify-content: space-between; @@ -2605,10 +3349,6 @@ } @media (max-width: 980px) { - .agents-layout { - grid-template-columns: 1fr; - } - .agent-header { grid-template-columns: 1fr; } @@ -2625,3 +3365,404 @@ grid-template-columns: 1fr; } } + +@media (max-width: 600px) { + .agents-toolbar-row { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .agents-control-select { + max-width: none; + } + + .agents-toolbar-label { + display: none; + } +} + +.cmd-palette-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: min(20vh, 160px); + background: rgba(0, 0, 0, 0.5); + animation: fade-in 0.12s ease-out; +} + +.cmd-palette { + width: min(560px, 90vw); + overflow: hidden; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + animation: scale-in 0.15s ease-out; +} + +.cmd-palette__input { + width: 100%; + padding: 14px 18px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + font-size: 15px; + outline: none; +} + +.cmd-palette__input::placeholder { + color: var(--muted); +} + +.cmd-palette__results { + max-height: 320px; + overflow-y: auto; + padding: 6px 0; +} + +.cmd-palette__group-label { + padding: 8px 18px 4px; + color: var(--muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cmd-palette__item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 18px; + font-size: 14px; + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.cmd-palette__item:hover, +.cmd-palette__item--active { + background: var(--bg-hover); +} + +.cmd-palette__item .nav-item__icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.cmd-palette__item .nav-item__icon svg { + width: 100%; + height: 100%; +} + +.cmd-palette__item-desc { + margin-left: auto; + font-size: 12px; +} + +.cmd-palette__empty { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 18px; + color: var(--muted); + font-size: 13px; +} + +.cmd-palette__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 8px 18px; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--muted); +} + +.cmd-palette__footer kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 5px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + font-family: var(--mono); + font-size: 10px; + line-height: 1.4; +} + +/* =========================================== + Overview Cards + =========================================== */ + +.ov-cards { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.ov-card { + display: grid; + gap: 6px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); + cursor: pointer; + text-align: left; + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-fast) var(--ease-out); + animation: rise 0.25s var(--ease-out) backwards; +} + +.ov-card:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.ov-card:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.ov-card__label { + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ov-card__value { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.15; + color: var(--text-strong); +} + +.ov-card__hint { + font-size: 12px; + color: var(--muted); + line-height: 1.35; +} + +.ov-card__hint .danger { + color: var(--danger); +} + +/* Stagger entrance */ +.ov-cards .ov-card:nth-child(1) { + animation-delay: 0ms; +} +.ov-cards .ov-card:nth-child(2) { + animation-delay: 50ms; +} +.ov-cards .ov-card:nth-child(3) { + animation-delay: 100ms; +} +.ov-cards .ov-card:nth-child(4) { + animation-delay: 150ms; +} + +/* ── Attention items ── */ +.ov-attention-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ov-attention-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--bg-hover); + border: 1px solid var(--border); +} + +.ov-attention-item.warn { + border-color: var(--warning-subtle, rgba(234, 179, 8, 0.2)); + background: rgba(234, 179, 8, 0.05); +} + +.ov-attention-item.danger { + border-color: var(--danger-subtle, rgba(239, 68, 68, 0.2)); + background: rgba(239, 68, 68, 0.05); +} + +.ov-attention-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 18px; + color: var(--muted); + margin-top: 1px; +} + +.ov-attention-item.warn .ov-attention-icon { + color: var(--warning, #eab308); +} + +.ov-attention-item.danger .ov-attention-icon { + color: var(--danger, #ef4444); +} + +.ov-attention-icon svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ov-attention-body { + flex: 1; + min-width: 0; +} + +.ov-attention-title { + font-size: 13px; + font-weight: 500; +} + +.ov-attention-link { + font-size: 12px; + color: var(--accent, #3b82f6); + text-decoration: none; +} + +.ov-attention-link:hover { + text-decoration: underline; +} + +/* Recent sessions widget */ +.ov-recent { + margin-top: 18px; +} + +.ov-recent__title { + font-size: 13px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 10px; +} + +.ov-recent__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} + +.ov-recent__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 12px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + font-size: 13px; + align-items: center; + transition: border-color var(--duration-fast) ease; +} + +.ov-recent__row:hover { + border-color: var(--border-strong); +} + +.ov-recent__key { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.ov-recent__model { + color: var(--muted); + font-size: 12px; + font-family: var(--mono); +} + +.ov-recent__time { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.blur-digits { + filter: blur(4px); + user-select: none; +} + +/* Section divider */ +.ov-section-divider { + border-top: 1px solid var(--border); + margin: 18px 0 0; +} + +/* Access grid */ +.ov-access-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.ov-access-grid__full { + grid-column: 1 / -1; +} + +/* Bottom grid (event log + log tail) */ +.ov-bottom-grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); +} + +@media (max-width: 600px) { + .ov-cards { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .ov-card { + padding: 12px; + } + + .ov-card__value { + font-size: 18px; + } + + .ov-bottom-grid { + grid-template-columns: 1fr; + } + + .ov-access-grid { + grid-template-columns: 1fr; + } + + .ov-recent__row { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index f33c05f94fa..c05bdcbe98e 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -1,25 +1,38 @@ /* =========================================== - Config Page - Carbon Design System + Config Page =========================================== */ /* Layout Container */ .config-layout { display: grid; - grid-template-columns: 260px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); gap: 0; height: calc(100vh - 160px); - margin: 0 -16px -32px; /* preserve margin-top: 0 for onboarding mode */ + margin: 0 -16px -32px; border-radius: var(--radius-xl); border: 1px solid var(--border); background: var(--panel); - overflow: hidden; /* fallback for older browsers */ + overflow: hidden; overflow: clip; + animation: config-enter 0.3s var(--ease-out); +} + +@keyframes config-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } } /* Mobile: adjust margins to match mobile .content padding (4px 4px 16px) */ @media (max-width: 600px) { .config-layout { - margin: 0; /* safest: no negative margin cancellation on mobile */ + margin: 0; + /* safest: no negative margin cancellation on mobile */ } } @@ -30,48 +43,11 @@ } } -/* =========================================== - Sidebar - =========================================== */ - -.config-sidebar { - display: flex; - flex-direction: column; - background: var(--bg-accent); - border-right: 1px solid var(--border); - min-height: 0; - overflow: hidden; -} - -:root[data-theme="light"] .config-sidebar { - background: var(--bg-hover); -} - -.config-sidebar__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 18px; - border-bottom: 1px solid var(--border); -} - -.config-sidebar__title { - font-weight: 600; - font-size: 14px; - letter-spacing: -0.01em; -} - -.config-sidebar__footer { - margin-top: auto; - padding: 14px; - border-top: 1px solid var(--border); -} - /* Search */ .config-search { display: grid; - gap: 6px; - padding: 12px 14px 10px; + gap: 5px; + padding: 10px 12px 8px; border-bottom: 1px solid var(--border); } @@ -92,11 +68,11 @@ .config-search__input { width: 100%; - padding: 11px 36px 11px 42px; + padding: 8px 34px 8px 38px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-elevated); - font-size: 13px; + font-size: 12.5px; outline: none; transition: border-color var(--duration-fast) ease, @@ -114,11 +90,11 @@ background: var(--bg-hover); } -:root[data-theme="light"] .config-search__input { +:root[data-theme-mode="light"] .config-search__input { background: white; } -:root[data-theme="light"] .config-search__input:focus { +:root[data-theme-mode="light"] .config-search__input:focus { background: white; } @@ -149,221 +125,28 @@ color: var(--text); } -.config-search__hint { - display: grid; - gap: 6px; -} - -.config-search__hint-label { - font-size: 10px; - font-weight: 600; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.03em; - white-space: nowrap; -} - -.config-search__tag-picker { - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - transition: - border-color var(--duration-fast) ease, - box-shadow var(--duration-fast) ease, - background var(--duration-fast) ease; -} - -.config-search__tag-picker[open] { - border-color: var(--accent); - box-shadow: var(--focus-ring); - background: var(--bg-hover); -} - -:root[data-theme="light"] .config-search__tag-picker { - background: white; -} - -.config-search__tag-trigger { - list-style: none; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-height: 30px; - padding: 6px 8px; - cursor: pointer; -} - -.config-search__tag-trigger::-webkit-details-marker { - display: none; -} - -.config-search__tag-placeholder { - font-size: 11px; - color: var(--muted); -} - -.config-search__tag-chips { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - min-width: 0; -} - -.config-search__tag-chip { - display: inline-flex; - align-items: center; - border: 1px solid var(--border); - border-radius: var(--radius-full); - padding: 2px 7px; - font-size: 10px; - font-weight: 500; - color: var(--text); - background: var(--bg); -} - -.config-search__tag-chip--count { - color: var(--muted); -} - -.config-search__tag-caret { - color: var(--muted); - font-size: 12px; - line-height: 1; -} - -.config-search__tag-picker[open] .config-search__tag-caret { - transform: rotate(180deg); -} - -.config-search__tag-menu { - max-height: 104px; - overflow-y: auto; - border-top: 1px solid var(--border); - padding: 6px; - display: grid; - gap: 6px; -} - -.config-search__tag-option { - display: block; - width: 100%; - border: 1px solid transparent; - border-radius: var(--radius-sm); - padding: 6px 8px; - background: transparent; - color: var(--muted); - font-size: 11px; - text-align: left; - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease, - border-color var(--duration-fast) ease; -} - -.config-search__tag-option:hover { - background: var(--bg-hover); - color: var(--text); -} - -.config-search__tag-option.active { - background: var(--accent-subtle); - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 34%, transparent); -} - -/* Navigation */ -.config-nav { - flex: 1; - overflow-y: auto; - padding: 10px; -} - -.config-nav__item { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - padding: 11px 14px; - border: none; - border-radius: var(--radius-md); - background: transparent; - color: var(--muted); - font-size: 13px; - font-weight: 500; - text-align: left; - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease; -} - -.config-nav__item:hover { - background: var(--bg-hover); - color: var(--text); -} - -:root[data-theme="light"] .config-nav__item:hover { - background: rgba(0, 0, 0, 0.04); -} - -.config-nav__item.active { - background: var(--accent-subtle); - color: var(--accent); -} - -.config-nav__icon { - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - font-size: 15px; - opacity: 0.7; -} - -.config-nav__item:hover .config-nav__icon, -.config-nav__item.active .config-nav__icon { - opacity: 1; -} - -.config-nav__icon svg { - width: 18px; - height: 18px; - stroke: currentColor; - fill: none; -} - -.config-nav__label { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - /* Mode Toggle */ .config-mode-toggle { display: flex; - padding: 4px; + padding: 3px; background: var(--bg-elevated); border-radius: var(--radius-md); border: 1px solid var(--border); + gap: 1px; } -:root[data-theme="light"] .config-mode-toggle { +:root[data-theme-mode="light"] .config-mode-toggle { background: white; } .config-mode-toggle__btn { flex: 1; - padding: 9px 14px; + padding: 6px 12px; border: none; - border-radius: var(--radius-sm); + border-radius: calc(var(--radius-md) - 3px); background: transparent; color: var(--muted); - font-size: 12px; + font-size: 11px; font-weight: 600; cursor: pointer; transition: @@ -372,14 +155,15 @@ box-shadow var(--duration-fast) ease; } -.config-mode-toggle__btn:hover { +.config-mode-toggle__btn:hover:not(.active) { color: var(--text); + background: var(--bg-hover); } .config-mode-toggle__btn.active { background: var(--accent); color: white; - box-shadow: var(--shadow-sm); + box-shadow: 0 1px 3px rgba(255, 92, 92, 0.2); } /* =========================================== @@ -392,7 +176,8 @@ min-height: 0; min-width: 0; background: var(--panel); - overflow: hidden; /* fallback for older browsers */ + overflow: hidden; + /* fallback for older browsers */ overflow: clip; } @@ -401,8 +186,8 @@ display: flex; align-items: center; justify-content: space-between; - gap: 14px; - padding: 14px 22px; + gap: 12px; + padding: 10px 20px; background: var(--bg-accent); border-bottom: 1px solid var(--border); flex-shrink: 0; @@ -410,7 +195,7 @@ z-index: 2; } -:root[data-theme="light"] .config-actions { +:root[data-theme-mode="light"] .config-actions { background: var(--bg-hover); } @@ -418,40 +203,125 @@ .config-actions__right { display: flex; align-items: center; - gap: 10px; + gap: 8px; } .config-changes-badge { - padding: 6px 14px; + padding: 4px 10px; border-radius: var(--radius-full); background: var(--accent-subtle); - border: 1px solid rgba(255, 77, 77, 0.3); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); color: var(--accent); - font-size: 12px; + font-size: 11px; font-weight: 600; + animation: badge-enter 0.2s var(--ease-out); +} + +@keyframes badge-enter { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } } .config-status { - font-size: 13px; + font-size: 12.5px; color: var(--muted); } +.config-top-tabs { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: var(--bg-accent); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +:root[data-theme-mode="light"] .config-top-tabs { + background: var(--bg-hover); +} + +.config-search--top { + padding: 0; + border-bottom: none; + min-width: 200px; + max-width: 320px; + flex: 0 1 320px; +} + +.config-top-tabs__scroller { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1 1 auto; + flex-wrap: wrap; +} + +.config-top-tabs__tab { + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: var(--radius-full); + padding: 5px 12px; + background: var(--bg-elevated); + color: var(--muted); + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease, + color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +:root[data-theme-mode="light"] .config-top-tabs__tab { + background: white; +} + +.config-top-tabs__tab:hover { + color: var(--text); + border-color: var(--border-strong); + background: var(--bg-hover); +} + +.config-top-tabs__tab.active { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + background: var(--accent-subtle); +} + +.config-top-tabs__right { + display: flex; + justify-content: flex-end; + flex-shrink: 0; + min-width: 0; +} + /* Diff Panel */ .config-diff { - margin: 18px 22px 0; - border: 1px solid rgba(255, 77, 77, 0.25); + margin: 12px 20px 0; + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); border-radius: var(--radius-lg); background: var(--accent-subtle); overflow: hidden; + animation: badge-enter 0.2s var(--ease-out); } .config-diff__summary { display: flex; align-items: center; justify-content: space-between; - padding: 14px 18px; + padding: 10px 16px; cursor: pointer; - font-size: 13px; + font-size: 12px; font-weight: 600; color: var(--accent); list-style: none; @@ -477,23 +347,23 @@ } .config-diff__content { - padding: 0 18px 18px; + padding: 0 16px 16px; display: grid; - gap: 10px; + gap: 8px; } .config-diff__item { display: flex; align-items: baseline; - gap: 14px; - padding: 10px 14px; + gap: 12px; + padding: 8px 12px; border-radius: var(--radius-md); background: var(--bg-elevated); - font-size: 12px; + font-size: 11.5px; font-family: var(--mono); } -:root[data-theme="light"] .config-diff__item { +:root[data-theme-mode="light"] .config-diff__item { background: white; } @@ -528,23 +398,27 @@ .config-section-hero { display: flex; align-items: center; - gap: 16px; + gap: 14px; padding: 16px 22px; border-bottom: 1px solid var(--border); background: var(--bg-accent); } -:root[data-theme="light"] .config-section-hero { +:root[data-theme-mode="light"] .config-section-hero { background: var(--bg-hover); } .config-section-hero__icon { - width: 30px; - height: 30px; + width: 28px; + height: 28px; color: var(--accent); display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-md); + background: var(--accent-subtle); + padding: 5px; + flex-shrink: 0; } .config-section-hero__icon svg { @@ -556,74 +430,176 @@ .config-section-hero__text { display: grid; - gap: 3px; + gap: 2px; min-width: 0; } .config-section-hero__title { - font-size: 16px; - font-weight: 600; - letter-spacing: -0.01em; + font-size: 15px; + font-weight: 650; + letter-spacing: -0.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .config-section-hero__desc { - font-size: 13px; - color: var(--muted); -} - -/* Subsection Nav */ -.config-subnav { - display: flex; - gap: 8px; - padding: 12px 22px 14px; - border-bottom: 1px solid var(--border); - background: var(--bg-accent); - overflow-x: auto; -} - -:root[data-theme="light"] .config-subnav { - background: var(--bg-hover); -} - -.config-subnav__item { - border: 1px solid transparent; - border-radius: var(--radius-full); - padding: 7px 14px; font-size: 12px; - font-weight: 600; color: var(--muted); - background: var(--bg-elevated); - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease, - border-color var(--duration-fast) ease; - white-space: nowrap; -} - -:root[data-theme="light"] .config-subnav__item { - background: white; -} - -.config-subnav__item:hover { - color: var(--text); - border-color: var(--border); -} - -.config-subnav__item.active { - color: var(--accent); - border-color: rgba(255, 77, 77, 0.4); - background: var(--accent-subtle); + line-height: 1.4; } /* Content Area */ .config-content { flex: 1; overflow-y: auto; - padding: 22px; + padding: 20px 22px; + min-width: 0; + scroll-behavior: smooth; +} + +/* =========================================== + Appearance Section + =========================================== */ + +.settings-appearance { + display: grid; + gap: 18px; +} + +.settings-appearance__section { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + padding: 18px; + display: grid; + gap: 14px; +} + +.settings-appearance__heading { + margin: 0; + font-size: 15px; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--text-strong); +} + +.settings-appearance__hint { + margin: -8px 0 0; + font-size: 12.5px; + color: var(--muted); + line-height: 1.45; +} + +.settings-theme-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; +} + +.settings-theme-card { + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + min-height: 64px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg); + color: var(--text); + text-align: left; + cursor: pointer; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + transform var(--duration-fast) ease; +} + +.settings-theme-card:hover { + border-color: var(--border-strong); + background: var(--bg-hover); + transform: translateY(-1px); +} + +.settings-theme-card--active { + border-color: color-mix(in srgb, var(--accent) 35%, transparent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent); +} + +.settings-theme-card__icon, +.settings-theme-card__check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: var(--accent); +} + +.settings-theme-card__icon svg, +.settings-theme-card__check svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; +} + +.settings-theme-card__label { + font-size: 13px; + font-weight: 600; + color: var(--text-strong); +} + +.settings-info-grid { + display: grid; + gap: 10px; +} + +.settings-info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); +} + +.settings-info-row__label { + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.settings-info-row__value { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 13px; + font-weight: 500; + color: var(--text); + text-align: right; +} + +.settings-status-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: var(--muted); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--muted) 14%, transparent); +} + +.settings-status-dot--ok { + background: var(--ok); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 14%, transparent); } .config-raw-field textarea { @@ -639,18 +615,19 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 18px; + gap: 14px; padding: 80px 24px; color: var(--muted); + animation: fade-in 0.2s var(--ease-out); } .config-loading__spinner { - width: 40px; - height: 40px; - border: 3px solid var(--border); + width: 32px; + height: 32px; + border: 2.5px solid var(--border); border-top-color: var(--accent); border-radius: var(--radius-full); - animation: spin 0.75s linear infinite; + animation: spin 0.7s linear infinite; } @keyframes spin { @@ -665,19 +642,22 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 18px; + gap: 16px; padding: 80px 24px; text-align: center; + animation: fade-in 0.3s var(--ease-out); } .config-empty__icon { - font-size: 56px; - opacity: 0.35; + font-size: 48px; + opacity: 0.25; } .config-empty__text { color: var(--muted); - font-size: 15px; + font-size: 14px; + max-width: 320px; + line-height: 1.5; } /* =========================================== @@ -686,43 +666,71 @@ .config-form--modern { display: grid; - gap: 20px; + gap: 14px; + width: 100%; + min-width: 0; } .config-section-card { + width: 100%; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-elevated); overflow: hidden; - transition: border-color var(--duration-fast) ease; + transition: + border-color var(--duration-normal) ease, + box-shadow var(--duration-normal) ease; + animation: section-card-enter 0.25s var(--ease-out) backwards; +} + +@keyframes section-card-enter { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } .config-section-card:hover { border-color: var(--border-strong); + box-shadow: var(--shadow-sm); } -:root[data-theme="light"] .config-section-card { +:root[data-theme-mode="light"] .config-section-card { background: white; } +:root[data-theme-mode="light"] .config-section-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + .config-section-card__header { display: flex; - align-items: flex-start; - gap: 16px; - padding: 20px 22px; + align-items: center; + gap: 14px; + padding: 18px 20px; background: var(--bg-accent); border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .config-section-card__header { +:root[data-theme-mode="light"] .config-section-card__header { background: var(--bg-hover); } .config-section-card__icon { - width: 34px; - height: 34px; + width: 30px; + height: 30px; color: var(--accent); flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + background: var(--accent-subtle); + padding: 6px; } .config-section-card__icon svg { @@ -737,23 +745,44 @@ .config-section-card__title { margin: 0; - font-size: 17px; - font-weight: 600; - letter-spacing: -0.01em; + font-size: 14px; + font-weight: 650; + letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .config-section-card__desc { - margin: 5px 0 0; - font-size: 13px; + margin: 3px 0 0; + font-size: 12px; color: var(--muted); line-height: 1.45; } .config-section-card__content { - padding: 18px; + padding: 16px 18px; + min-width: 0; +} + +/* Staggered entrance for sequential cards */ +.config-form--modern .config-section-card:nth-child(1) { + animation-delay: 0ms; +} +.config-form--modern .config-section-card:nth-child(2) { + animation-delay: 40ms; +} +.config-form--modern .config-section-card:nth-child(3) { + animation-delay: 80ms; +} +.config-form--modern .config-section-card:nth-child(4) { + animation-delay: 120ms; +} +.config-form--modern .config-section-card:nth-child(5) { + animation-delay: 160ms; +} +.config-form--modern .config-section-card:nth-child(n + 6) { + animation-delay: 200ms; } /* =========================================== @@ -782,13 +811,14 @@ } .cfg-field__label { - font-size: 13px; + font-size: 12.5px; font-weight: 600; color: var(--text); + letter-spacing: -0.005em; } .cfg-field__help { - font-size: 12px; + font-size: 11.5px; color: var(--muted); line-height: 1.45; } @@ -811,7 +841,7 @@ white-space: nowrap; } -:root[data-theme="light"] .cfg-tag { +:root[data-theme-mode="light"] .cfg-tag { background: white; } @@ -828,11 +858,11 @@ .cfg-input { flex: 1; - padding: 11px 14px; - border: 1px solid var(--border-strong); + padding: 8px 12px; + border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-accent); - font-size: 14px; + font-size: 13px; outline: none; transition: border-color var(--duration-fast) ease, @@ -842,7 +872,11 @@ .cfg-input::placeholder { color: var(--muted); - opacity: 0.7; + opacity: 0.6; +} + +.cfg-input:hover:not(:focus) { + border-color: var(--border-strong); } .cfg-input:focus { @@ -851,26 +885,31 @@ background: var(--bg-hover); } -:root[data-theme="light"] .cfg-input { +:root[data-theme-mode="light"] .cfg-input { background: white; + border-color: var(--border); } -:root[data-theme="light"] .cfg-input:focus { +:root[data-theme-mode="light"] .cfg-input:hover:not(:focus) { + border-color: var(--border-strong); +} + +:root[data-theme-mode="light"] .cfg-input:focus { background: white; } .cfg-input--sm { - padding: 9px 12px; - font-size: 13px; + padding: 6px 10px; + font-size: 12px; } .cfg-input__reset { - padding: 10px 14px; + padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-elevated); color: var(--muted); - font-size: 14px; + font-size: 13px; cursor: pointer; transition: background var(--duration-fast) ease, @@ -890,8 +929,8 @@ /* Textarea */ .cfg-textarea { width: 100%; - padding: 12px 14px; - border: 1px solid var(--border-strong); + padding: 10px 14px; + border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-accent); font-family: var(--mono); @@ -904,39 +943,49 @@ box-shadow var(--duration-fast) ease; } +.cfg-textarea:hover:not(:focus) { + border-color: var(--border-strong); +} + .cfg-textarea:focus { border-color: var(--accent); box-shadow: var(--focus-ring); } -:root[data-theme="light"] .cfg-textarea { +:root[data-theme-mode="light"] .cfg-textarea { background: white; + border-color: var(--border); } .cfg-textarea--sm { - padding: 10px 12px; + padding: 8px 12px; font-size: 12px; } /* Number Input */ .cfg-number { display: inline-flex; - border: 1px solid var(--border-strong); + border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; background: var(--bg-accent); + transition: border-color var(--duration-fast) ease; } -:root[data-theme="light"] .cfg-number { +.cfg-number:hover { + border-color: var(--border-strong); +} + +:root[data-theme-mode="light"] .cfg-number { background: white; } .cfg-number__btn { - width: 44px; + width: 38px; border: none; background: var(--bg-elevated); color: var(--text); - font-size: 18px; + font-size: 16px; font-weight: 300; cursor: pointer; transition: background var(--duration-fast) ease; @@ -951,24 +1000,25 @@ cursor: not-allowed; } -:root[data-theme="light"] .cfg-number__btn { +:root[data-theme-mode="light"] .cfg-number__btn { background: var(--bg-hover); } -:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) { +:root[data-theme-mode="light"] .cfg-number__btn:hover:not(:disabled) { background: var(--border); } .cfg-number__input { - width: 85px; - padding: 11px; + width: 72px; + padding: 9px; border: none; border-left: 1px solid var(--border); border-right: 1px solid var(--border); background: transparent; - font-size: 14px; + font-size: 13px; text-align: center; outline: none; + appearance: textfield; -moz-appearance: textfield; } @@ -980,14 +1030,14 @@ /* Select */ .cfg-select { - padding: 11px 40px 11px 14px; - border: 1px solid var(--border-strong); + padding: 8px 36px 8px 12px; + border: 1px solid var(--border); border-radius: var(--radius-md); background-color: var(--bg-accent); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; - background-position: right 12px center; - font-size: 14px; + background-position: right 10px center; + font-size: 13px; cursor: pointer; outline: none; appearance: none; @@ -996,35 +1046,41 @@ box-shadow var(--duration-fast) ease; } +.cfg-select:hover:not(:focus) { + border-color: var(--border-strong); +} + .cfg-select:focus { border-color: var(--accent); box-shadow: var(--focus-ring); } -:root[data-theme="light"] .cfg-select { +:root[data-theme-mode="light"] .cfg-select { background-color: white; + border-color: var(--border); } /* Segmented Control */ .cfg-segmented { display: inline-flex; - padding: 4px; + padding: 3px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-accent); + gap: 1px; } -:root[data-theme="light"] .cfg-segmented { +:root[data-theme-mode="light"] .cfg-segmented { background: var(--bg-hover); } .cfg-segmented__btn { - padding: 9px 18px; + padding: 6px 14px; border: none; - border-radius: var(--radius-sm); + border-radius: calc(var(--radius-md) - 3px); background: transparent; color: var(--muted); - font-size: 13px; + font-size: 12px; font-weight: 500; cursor: pointer; transition: @@ -1035,12 +1091,13 @@ .cfg-segmented__btn:hover:not(:disabled):not(.active) { color: var(--text); + background: var(--bg-hover); } .cfg-segmented__btn.active { background: var(--accent); color: white; - box-shadow: var(--shadow-sm); + box-shadow: 0 1px 3px rgba(255, 92, 92, 0.2); } .cfg-segmented__btn:disabled { @@ -1053,10 +1110,10 @@ display: flex; align-items: center; justify-content: space-between; - gap: 18px; - padding: 16px 18px; + gap: 14px; + padding: 12px 14px; border: 1px solid var(--border); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); background: var(--bg-accent); cursor: pointer; transition: @@ -1074,11 +1131,11 @@ cursor: not-allowed; } -:root[data-theme="light"] .cfg-toggle-row { +:root[data-theme-mode="light"] .cfg-toggle-row { background: white; } -:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) { +:root[data-theme-mode="light"] .cfg-toggle-row:hover:not(.disabled) { background: var(--bg-hover); } @@ -1089,15 +1146,15 @@ .cfg-toggle-row__label { display: block; - font-size: 14px; + font-size: 12.5px; font-weight: 500; color: var(--text); } .cfg-toggle-row__help { display: block; - margin-top: 3px; - font-size: 12px; + margin-top: 2px; + font-size: 11px; color: var(--muted); line-height: 1.45; } @@ -1117,33 +1174,33 @@ .cfg-toggle__track { display: block; - width: 50px; - height: 28px; + width: 40px; + height: 22px; background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: var(--radius-full); position: relative; transition: - background var(--duration-normal) ease, - border-color var(--duration-normal) ease; + background var(--duration-normal) var(--ease-out), + border-color var(--duration-normal) var(--ease-out); } -:root[data-theme="light"] .cfg-toggle__track { +:root[data-theme-mode="light"] .cfg-toggle__track { background: var(--border); } .cfg-toggle__track::after { content: ""; position: absolute; - top: 3px; - left: 3px; - width: 20px; - height: 20px; + top: 2px; + left: 2px; + width: 16px; + height: 16px; background: var(--text); border-radius: var(--radius-full); box-shadow: var(--shadow-sm); transition: - transform var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-spring), background var(--duration-normal) ease; } @@ -1153,7 +1210,7 @@ } .cfg-toggle input:checked + .cfg-toggle__track::after { - transform: translateX(22px); + transform: translateX(18px); background: var(--ok); } @@ -1164,12 +1221,17 @@ /* Object (collapsible) */ .cfg-object { border: 1px solid var(--border); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); background: transparent; overflow: hidden; + transition: border-color var(--duration-fast) ease; } -:root[data-theme="light"] .cfg-object { +.cfg-object:hover { + border-color: var(--border-strong); +} + +:root[data-theme-mode="light"] .cfg-object { background: transparent; } @@ -1180,10 +1242,8 @@ padding: 10px 12px; cursor: pointer; list-style: none; - transition: - background var(--duration-fast) ease, - border-color var(--duration-fast) ease; - border-radius: var(--radius-md); + transition: background var(--duration-fast) ease; + border-radius: calc(var(--radius-md) - 1px); } .cfg-object__header:hover { @@ -1195,7 +1255,7 @@ } .cfg-object__title { - font-size: 14px; + font-size: 13px; font-weight: 600; color: var(--text); } @@ -1251,7 +1311,7 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-array__header { +:root[data-theme-mode="light"] .cfg-array__header { background: var(--bg-hover); } @@ -1276,7 +1336,7 @@ border-radius: var(--radius-full); } -:root[data-theme="light"] .cfg-array__count { +:root[data-theme-mode="light"] .cfg-array__count { background: white; } @@ -1347,7 +1407,7 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-array__item-header { +:root[data-theme-mode="light"] .cfg-array__item-header { background: var(--bg-hover); } @@ -1411,7 +1471,7 @@ border-bottom: 1px solid var(--border); } -:root[data-theme="light"] .cfg-map__header { +:root[data-theme-mode="light"] .cfg-map__header { background: var(--bg-hover); } @@ -1472,7 +1532,7 @@ background: var(--bg-accent); } -:root[data-theme="light"] .cfg-map__item { +:root[data-theme-mode="light"] .cfg-map__item { background: white; } @@ -1542,42 +1602,6 @@ =========================================== */ @media (max-width: 768px) { - .config-layout { - grid-template-columns: 1fr; - } - - .config-sidebar { - border-right: none; - border-bottom: 1px solid var(--border); - } - - .config-sidebar__header { - padding: 14px 16px; - } - - .config-nav { - display: flex; - flex-wrap: nowrap; - gap: 6px; - padding: 10px 14px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - - .config-nav__item { - flex: 0 0 auto; - padding: 9px 14px; - white-space: nowrap; - } - - .config-nav__label { - display: inline; - } - - .config-sidebar__footer { - display: none; - } - .config-actions { flex-wrap: wrap; padding: 14px 16px; @@ -1589,28 +1613,63 @@ justify-content: center; } + .config-top-tabs { + flex-wrap: wrap; + padding: 12px 16px; + } + + .config-search--top { + flex: 1 1 100%; + max-width: none; + } + + .config-top-tabs__scroller { + flex: 1 1 100%; + } + + .config-top-tabs__right { + flex: 1 1 100%; + } + + .config-top-tabs__right .config-mode-toggle { + width: 100%; + } + + .config-top-tabs__right .config-mode-toggle__btn { + flex: 1 1 50%; + } + .config-section-hero { padding: 14px 16px; } - .config-subnav { - padding: 10px 16px 12px; + .config-content { + padding: 16px; } - .config-content { - padding: 18px; + .settings-theme-grid { + grid-template-columns: 1fr; + } + + .settings-info-row { + align-items: flex-start; + flex-direction: column; + } + + .settings-info-row__value { + text-align: left; } .config-section-card__header { - padding: 16px 18px; + padding: 14px 16px; } .config-section-card__content { - padding: 18px; + padding: 14px 16px; } .cfg-toggle-row { - padding: 14px 16px; + padding: 12px 14px; } .cfg-map__item { @@ -1628,16 +1687,6 @@ } @media (max-width: 480px) { - .config-nav__icon { - width: 26px; - height: 26px; - font-size: 17px; - } - - .config-nav__label { - display: none; - } - .config-section-card__icon { width: 30px; height: 30px; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index b939c27c29d..2114ea2565b 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -6,7 +6,8 @@ --shell-pad: 16px; --shell-gap: 16px; --shell-nav-width: 220px; - --shell-topbar-height: 56px; + --shell-nav-rail-width: 72px; + --shell-topbar-height: 52px; --shell-focus-duration: 200ms; --shell-focus-ease: var(--ease-out); height: 100vh; @@ -17,7 +18,7 @@ "topbar topbar" "nav content"; gap: 0; - animation: dashboard-enter 0.4s var(--ease-out); + animation: dashboard-enter 0.3s var(--ease-out); transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease); overflow: hidden; } @@ -41,7 +42,7 @@ } .shell--nav-collapsed { - grid-template-columns: 0px minmax(0, 1fr); + grid-template-columns: var(--shell-nav-rail-width) minmax(0, 1fr); } .shell--chat-focus { @@ -84,7 +85,9 @@ padding: 0 20px; height: var(--shell-topbar-height); border-bottom: 1px solid var(--border); - background: var(--bg); + background: color-mix(in srgb, var(--bg) 85%, transparent); + backdrop-filter: blur(12px) saturate(1.6); + -webkit-backdrop-filter: blur(12px) saturate(1.6); } .topbar-left { @@ -113,12 +116,12 @@ .brand { display: flex; align-items: center; - gap: 10px; + gap: 8px; } .brand-logo { - width: 28px; - height: 28px; + width: 26px; + height: 26px; flex-shrink: 0; } @@ -131,11 +134,11 @@ .brand-text { display: flex; flex-direction: column; - gap: 1px; + gap: 0; } .brand-title { - font-size: 16px; + font-size: 15px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; @@ -143,10 +146,10 @@ } .brand-sub { - font-size: 10px; + font-size: 9px; font-weight: 500; color: var(--muted); - letter-spacing: 0.05em; + letter-spacing: 0.06em; text-transform: uppercase; line-height: 1; } @@ -179,93 +182,389 @@ height: 6px; } -.topbar-status .theme-toggle { - --theme-item: 24px; - --theme-gap: 2px; - --theme-pad: 3px; +.topbar-status .theme-orb__trigger { + width: 26px; + height: 26px; + font-size: 13px; } -.topbar-status .theme-icon { - width: 12px; - height: 12px; +/* Topbar search trigger */ +.topbar-search { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--muted); + font-size: 13px; + cursor: pointer; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease, + color var(--duration-fast) ease; + min-width: 180px; +} + +.topbar-search:hover { + border-color: var(--border-strong); + background: var(--bg-hover); + color: var(--text); +} + +.topbar-search:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.topbar-search__label { + flex: 1; + text-align: left; +} + +.topbar-search__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + font-family: var(--mono); + font-size: 11px; + line-height: 1; + color: var(--muted); +} + +.topbar-theme-mode { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-elevated) 70%, transparent); +} + +.topbar-theme-mode__btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid transparent; + border-radius: calc(var(--radius-md) - 1px); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: + color var(--duration-fast) ease, + background var(--duration-fast) ease, + border-color var(--duration-fast) ease; +} + +.topbar-theme-mode__btn:hover { + color: var(--text); + background: var(--bg-hover); +} + +.topbar-theme-mode__btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.topbar-theme-mode__btn--active { + color: var(--accent); + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 25%, transparent); +} + +.topbar-theme-mode__btn svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.75px; + stroke-linecap: round; + stroke-linejoin: round; } /* =========================================== - Navigation Sidebar + Navigation Sidebar (shadcn-inspired) =========================================== */ -.nav { +/* Sidebar wrapper – occupies the "nav" grid area */ +.shell-nav { grid-area: nav; + display: flex; + min-height: 0; + overflow: hidden; + transition: width var(--shell-focus-duration) var(--shell-focus-ease); +} + +/* The sidebar panel itself */ +.sidebar { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; + background: var(--bg); +} + +:root[data-theme-mode="light"] .sidebar { + background: var(--panel); +} + +/* Collapsed: icon-only rail */ +.sidebar--collapsed { + width: var(--shell-nav-rail-width); + min-width: var(--shell-nav-rail-width); + flex: 0 0 var(--shell-nav-rail-width); + border-right: 1px solid color-mix(in srgb, var(--border-strong) 72%, transparent); +} + +/* Header: brand + collapse toggle */ +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 14px 14px 6px; + flex-shrink: 0; +} + +.sidebar--collapsed .sidebar-header { + justify-content: center; + padding: 12px 10px 6px; +} + +/* Brand lockup */ +.sidebar-brand { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.sidebar-brand__logo { + width: 22px; + height: 22px; + flex-shrink: 0; + border-radius: 6px; +} + +.sidebar-brand__title { + font-size: 14px; + font-weight: 700; + letter-spacing: -0.025em; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Scrollable nav body */ +.sidebar-nav { + flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 16px 12px; - background: var(--bg); - scrollbar-width: none; /* Firefox */ - transition: - width var(--shell-focus-duration) var(--shell-focus-ease), - padding var(--shell-focus-duration) var(--shell-focus-ease), - opacity var(--shell-focus-duration) var(--shell-focus-ease); - min-height: 0; + padding: 4px 8px; + scrollbar-width: none; } -.nav::-webkit-scrollbar { - display: none; /* Chrome/Safari */ +.sidebar-nav::-webkit-scrollbar { + display: none; } -.shell--chat-focus .nav { - width: 0; +.sidebar--collapsed .sidebar-nav { + padding: 4px 8px; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* Collapsed sidebar: centre icons, hide text */ +.sidebar--collapsed .nav-group__label { + display: none; +} + +.sidebar--collapsed .nav-group { + gap: 4px; + margin-bottom: 0; +} + +/* In collapsed sidebar, always show nav items (icon-only) regardless of group collapse state */ +.sidebar--collapsed .nav-group--collapsed .nav-group__items { + display: grid; +} + +.sidebar--collapsed .nav-item { + justify-content: center; + width: 44px; + height: 42px; padding: 0; - border-width: 0; - overflow: hidden; - pointer-events: none; - opacity: 0; + margin: 0 auto; + border-radius: 16px; } -.nav--collapsed { +.sidebar--collapsed .nav-item__icon { + width: 18px; + height: 18px; + opacity: 0.78; +} + +.sidebar--collapsed .nav-item__icon svg { + width: 18px; + height: 18px; +} + +.sidebar--collapsed .nav-item__text { + display: none; +} + +.sidebar--collapsed .nav-item__external-icon { + display: none; +} + +/* Footer: docs link + version */ +.sidebar-footer { + flex-shrink: 0; + padding: 8px; + border-top: 1px solid var(--border); +} + +.sidebar--collapsed .sidebar-footer { + padding: 12px 8px 10px; +} + +.sidebar-footer__docs-block { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.sidebar--collapsed .sidebar-footer__docs-block { + align-items: center; + gap: 10px; +} + +.sidebar--collapsed .sidebar-footer .nav-item { + justify-content: center; + width: 44px; + height: 44px; + padding: 0; +} + +.sidebar-version { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 10px; +} + +.sidebar-version__text { + font-size: 11px; + color: var(--muted); + font-weight: 500; + letter-spacing: 0.02em; +} + +.sidebar-version__dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--accent) 78%, white 22%); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); + opacity: 1; + margin: 0 auto; +} + +/* Drag-to-resize handle */ +.sidebar-resizer { + width: 3px; + cursor: col-resize; + flex-shrink: 0; + background: transparent; + transition: background var(--duration-fast) ease; + position: relative; +} + +.sidebar-resizer::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 3px; + background: transparent; + transition: background var(--duration-fast) ease; +} + +.sidebar-resizer:hover::after { + background: var(--accent); + opacity: 0.35; +} + +.sidebar-resizer:active::after { + background: var(--accent); + opacity: 0.6; +} + +/* Shell-level collapsed / focus overrides */ +.shell--nav-collapsed .shell-nav { + width: var(--shell-nav-rail-width); + min-width: var(--shell-nav-rail-width); +} + +.shell--chat-focus .shell-nav { width: 0; min-width: 0; - padding: 0; overflow: hidden; - border: none; - opacity: 0; pointer-events: none; + opacity: 0; } /* Nav collapse toggle */ .nav-collapse-toggle { - width: 32px; - height: 32px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; - border-radius: var(--radius-md); + border-radius: var(--radius-sm); cursor: pointer; transition: background var(--duration-fast) ease, - border-color var(--duration-fast) ease; - margin-bottom: 16px; + border-color var(--duration-fast) ease, + color var(--duration-fast) ease; + margin-bottom: 0; + color: var(--muted); } .nav-collapse-toggle:hover { background: var(--bg-hover); - border-color: var(--border); + color: var(--text); } .nav-collapse-toggle__icon { display: flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; - color: var(--muted); - transition: color var(--duration-fast) ease; + width: 16px; + height: 16px; + color: inherit; } .nav-collapse-toggle__icon svg { - width: 18px; - height: 18px; + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -274,14 +573,14 @@ } .nav-collapse-toggle:hover .nav-collapse-toggle__icon { - color: var(--text); + color: inherit; } /* Nav groups */ .nav-group { - margin-bottom: 20px; + margin-bottom: 12px; display: grid; - gap: 2px; + gap: 1px; } .nav-group:last-child { @@ -297,53 +596,67 @@ display: none; } -/* Nav label */ -.nav-label { +.nav-group__label { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; - padding: 6px 10px; - font-size: 11px; - font-weight: 500; + padding: 5px 10px; + font-size: 10px; + font-weight: 600; color: var(--muted); - margin-bottom: 4px; + margin-bottom: 2px; background: transparent; border: none; cursor: pointer; text-align: left; + text-transform: uppercase; + letter-spacing: 0.06em; border-radius: var(--radius-sm); transition: color var(--duration-fast) ease, background var(--duration-fast) ease; } -.nav-label:hover { +.nav-group__label:hover { color: var(--text); background: var(--bg-hover); } -.nav-label--static { +.nav-group__label--static { cursor: default; } -.nav-label--static:hover { +.nav-group__label--static:hover { color: var(--muted); background: transparent; } -.nav-label__text { +.nav-group__label-text { flex: 1; } -.nav-label__chevron { +.nav-group__chevron { + display: inline-flex; + align-items: center; + justify-content: center; font-size: 10px; opacity: 0.5; transition: transform var(--duration-fast) ease; } -.nav-group--collapsed .nav-label__chevron { +.nav-group__chevron svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nav-group--collapsed .nav-group__chevron { transform: rotate(-90deg); } @@ -353,8 +666,8 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 10px; - padding: 8px 10px; + gap: 8px; + padding: 7px 10px; border-radius: var(--radius-md); border: 1px solid transparent; background: transparent; @@ -368,19 +681,19 @@ } .nav-item__icon { - width: 16px; - height: 16px; + width: 15px; + height: 15px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - opacity: 0.7; + opacity: 0.6; transition: opacity var(--duration-fast) ease; } .nav-item__icon svg { - width: 16px; - height: 16px; + width: 15px; + height: 15px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -390,7 +703,7 @@ .nav-item__text { font-size: 13px; - font-weight: 500; + font-weight: 450; white-space: nowrap; } @@ -401,26 +714,91 @@ } .nav-item:hover .nav-item__icon { - opacity: 1; + opacity: 0.9; } -.nav-item.active { +.nav-item.active, +.nav-item--active { color: var(--text-strong); background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 15%, transparent); } -.nav-item.active .nav-item__icon { +.nav-item.active .nav-item__icon, +.nav-item--active .nav-item__icon { opacity: 1; color: var(--accent); } +.sidebar--collapsed .nav-item--active::before, +.sidebar--collapsed .nav-item.active::before { + content: ""; + position: absolute; + left: 6px; + top: 11px; + bottom: 11px; + width: 2px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 78%, transparent); +} + +.sidebar--collapsed .nav-item.active, +.sidebar--collapsed .nav-item--active { + background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%); + border-color: color-mix(in srgb, var(--accent) 12%, var(--border) 88%); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent); +} + +.sidebar--collapsed .nav-collapse-toggle { + width: 44px; + height: 34px; + margin-bottom: 0; + border-color: color-mix(in srgb, var(--border-strong) 74%, transparent); + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--bg-elevated) 92%, transparent); + box-shadow: + inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent), + 0 8px 18px color-mix(in srgb, black 16%, transparent); +} + +.sidebar--collapsed .nav-collapse-toggle:hover { + border-color: color-mix(in srgb, var(--border-strong) 72%, transparent); + background: color-mix(in srgb, var(--bg-elevated) 96%, transparent); +} + +.nav-item__external-icon { + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + opacity: 0; + transition: opacity var(--duration-fast) ease; +} + +.nav-item__external-icon svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nav-item:hover .nav-item__external-icon { + opacity: 0.5; +} + /* =========================================== Content Area =========================================== */ .content { grid-area: content; - padding: 12px 16px 32px; + padding: 16px 20px 32px; display: block; min-height: 0; overflow-y: auto; @@ -428,10 +806,10 @@ } .content > * + * { - margin-top: 24px; + margin-top: 20px; } -:root[data-theme="light"] .content { +:root[data-theme-mode="light"] .content { background: var(--bg-content); } @@ -473,19 +851,19 @@ } .page-title { - font-size: 26px; - font-weight: 700; - letter-spacing: -0.035em; - line-height: 1.15; + font-size: 22px; + font-weight: 650; + letter-spacing: -0.03em; + line-height: 1.2; color: var(--text-strong); } .page-sub { color: var(--muted); - font-size: 14px; + font-size: 13px; font-weight: 400; - margin-top: 6px; - letter-spacing: -0.01em; + margin-top: 4px; + letter-spacing: -0.005em; } .page-meta { @@ -577,18 +955,6 @@ "content"; } - .nav { - position: static; - max-height: none; - display: flex; - gap: 6px; - overflow-x: auto; - border-right: none; - border-bottom: 1px solid var(--border); - padding: 10px 14px; - background: var(--bg); - } - .nav-group { grid-auto-flow: column; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 450a83608c6..b871fe1d440 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -2,45 +2,102 @@ Mobile Layout =========================================== */ -/* Tablet: Horizontal nav */ +/* Tablet and smaller: collapse the left nav into a horizontal rail. */ @media (max-width: 1100px) { - .nav { + .shell, + .shell--nav-collapsed { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: var(--shell-topbar-height) auto minmax(0, 1fr); + grid-template-areas: + "topbar" + "nav" + "content"; + } + + .shell--chat-focus { + grid-template-rows: var(--shell-topbar-height) 0 minmax(0, 1fr); + } + + .shell-nav, + .shell--nav-collapsed .shell-nav { + width: auto; + min-width: 0; + border-bottom: 1px solid var(--border); + } + + .sidebar, + .sidebar--collapsed { + width: auto; + min-width: 0; + flex: 1 1 auto; + flex-direction: row; + align-items: center; + border-right: none; + } + + .sidebar-header, + .sidebar--collapsed .sidebar-header { + justify-content: flex-start; + padding: 8px 10px; + flex: 0 0 auto; + } + + .sidebar-brand { + display: none; + } + + .sidebar-nav, + .sidebar--collapsed .sidebar-nav { + flex: 1 1 auto; display: flex; flex-direction: row; flex-wrap: nowrap; - gap: 4px; - padding: 10px 14px; + gap: 8px; + padding: 8px 10px 8px 0; overflow-x: auto; + overflow-y: hidden; -webkit-overflow-scrolling: touch; scrollbar-width: none; } - .nav::-webkit-scrollbar { + .sidebar-nav::-webkit-scrollbar, + .sidebar--collapsed .sidebar-nav::-webkit-scrollbar { display: none; } + .nav-group, + .nav-group__items, + .sidebar--collapsed .nav-group, + .sidebar--collapsed .nav-group__items { + display: contents; + } + .nav-group { - display: contents; + margin-bottom: 0; } - .nav-group__items { - display: contents; - } - - .nav-label { + .sidebar-nav .nav-group__label { display: none; } - .nav-group--collapsed .nav-group__items { - display: contents; - } - - .nav-item { + .nav-item, + .sidebar--collapsed .nav-item { + margin: 0; padding: 8px 14px; font-size: 13px; border-radius: var(--radius-md); white-space: nowrap; - flex-shrink: 0; + flex: 0 0 auto; + } + + .sidebar--collapsed .nav-item--active::before, + .sidebar--collapsed .nav-item.active::before { + content: none; + } + + .sidebar-footer, + .sidebar--collapsed .sidebar-footer { + display: none; } } @@ -94,24 +151,17 @@ display: none; } - /* Nav */ - .nav { - padding: 8px 10px; - gap: 4px; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; + .shell-nav { + border-bottom-width: 0; } - .nav::-webkit-scrollbar { - display: none; + .sidebar-header { + padding: 6px 8px; } - .nav-group { - display: contents; - } - - .nav-label { - display: none; + .sidebar-nav { + gap: 6px; + padding: 6px 8px 6px 0; } .nav-item { @@ -239,6 +289,26 @@ font-size: 14px; } + .agent-chat__input { + margin: 0 8px 10px; + } + + .agent-chat__toolbar { + padding: 4px 8px; + } + + .agent-chat__input-btn, + .agent-chat__toolbar .btn-ghost { + width: 28px; + height: 28px; + } + + .agent-chat__input-btn svg, + .agent-chat__toolbar .btn-ghost svg { + width: 14px; + height: 14px; + } + /* Log stream */ .log-stream { border-radius: var(--radius-md); @@ -288,16 +358,10 @@ font-size: 11px; } - /* Theme toggle */ - .theme-toggle { - --theme-item: 24px; - --theme-gap: 2px; - --theme-pad: 3px; - } - - .theme-icon { - width: 12px; - height: 12px; + .theme-orb__trigger { + width: 26px; + height: 26px; + font-size: 13px; } } @@ -315,10 +379,6 @@ font-size: 13px; } - .nav { - padding: 6px 8px; - } - .nav-item { padding: 6px 8px; font-size: 11px; @@ -361,14 +421,9 @@ font-size: 10px; } - .theme-toggle { - --theme-item: 22px; - --theme-gap: 2px; - --theme-pad: 2px; - } - - .theme-icon { - width: 11px; - height: 11px; + .theme-orb__trigger { + width: 24px; + height: 24px; + font-size: 12px; } } diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 1e824fb4feb..791bdd639ba 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -3,25 +3,33 @@ import { scheduleChatScroll } from "./app-scroll.ts"; import { setLastActiveSessionKey } from "./app-settings.ts"; import { resetToolStream } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; +import { executeSlashCommand } from "./chat/slash-command-executor.ts"; +import { parseSlashCommand } from "./chat/slash-commands.ts"; import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts"; import { loadSessions } from "./controllers/sessions.ts"; -import type { GatewayHelloOk } from "./gateway.ts"; +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; export type ChatHost = { + client: GatewayBrowserClient | null; + chatMessages: unknown[]; + chatStream: string | null; connected: boolean; chatMessage: string; chatAttachments: ChatAttachment[]; chatQueue: ChatQueueItem[]; chatRunId: string | null; chatSending: boolean; + lastError?: string | null; sessionKey: string; basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; refreshSessionsAfterChat: Set; + /** Callback for slash-command side effects that need app-level access. */ + onSlashAction?: (action: string) => void; }; export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; @@ -73,6 +81,7 @@ function enqueueChatMessage( text: string, attachments?: ChatAttachment[], refreshSessions?: boolean, + localCommand?: { args: string; name: string }, ) { const trimmed = text.trim(); const hasAttachments = Boolean(attachments && attachments.length > 0); @@ -87,6 +96,8 @@ function enqueueChatMessage( createdAt: Date.now(), attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined, refreshSessions, + localCommandArgs: localCommand?.args, + localCommandName: localCommand?.name, }, ]; } @@ -143,12 +154,25 @@ async function flushChatQueue(host: ChatHost) { return; } host.chatQueue = rest; - const ok = await sendChatMessageNow(host, next.text, { - attachments: next.attachments, - refreshSessions: next.refreshSessions, - }); + let ok = false; + try { + if (next.localCommandName) { + await dispatchSlashCommand(host, next.localCommandName, next.localCommandArgs ?? ""); + ok = true; + } else { + ok = await sendChatMessageNow(host, next.text, { + attachments: next.attachments, + refreshSessions: next.refreshSessions, + }); + } + } catch (err) { + host.lastError = String(err); + } if (!ok) { host.chatQueue = [next, ...host.chatQueue]; + } else if (host.chatQueue.length > 0) { + // Continue draining — local commands don't block on server response + void flushChatQueue(host); } } @@ -170,7 +194,6 @@ export async function handleSendChat( const attachmentsToSend = messageOverride == null ? attachments : []; const hasAttachments = attachmentsToSend.length > 0; - // Allow sending with just attachments (no message text required) if (!message && !hasAttachments) { return; } @@ -180,10 +203,35 @@ export async function handleSendChat( return; } + // Intercept local slash commands (/status, /model, /compact, etc.) + const parsed = parseSlashCommand(message); + if (parsed?.command.executeLocal) { + if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.name)) { + if (messageOverride == null) { + host.chatMessage = ""; + host.chatAttachments = []; + } + enqueueChatMessage(host, message, undefined, isChatResetCommand(message), { + args: parsed.args, + name: parsed.command.name, + }); + return; + } + const prevDraft = messageOverride == null ? previousDraft : undefined; + if (messageOverride == null) { + host.chatMessage = ""; + host.chatAttachments = []; + } + await dispatchSlashCommand(host, parsed.command.name, parsed.args, { + previousDraft: prevDraft, + restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + }); + return; + } + const refreshSessions = isChatResetCommand(message); if (messageOverride == null) { host.chatMessage = ""; - // Clear attachments when sending host.chatAttachments = []; } @@ -202,11 +250,99 @@ export async function handleSendChat( }); } +function shouldQueueLocalSlashCommand(name: string): boolean { + return !["stop", "focus", "export"].includes(name); +} + +// ── Slash Command Dispatch ── + +async function dispatchSlashCommand( + host: ChatHost, + name: string, + args: string, + sendOpts?: { previousDraft?: string; restoreDraft?: boolean }, +) { + switch (name) { + case "stop": + await handleAbortChat(host); + return; + case "new": + await sendChatMessageNow(host, "/new", { + refreshSessions: true, + previousDraft: sendOpts?.previousDraft, + restoreDraft: sendOpts?.restoreDraft, + }); + return; + case "reset": + await sendChatMessageNow(host, "/reset", { + refreshSessions: true, + previousDraft: sendOpts?.previousDraft, + restoreDraft: sendOpts?.restoreDraft, + }); + return; + case "clear": + await clearChatHistory(host); + return; + case "focus": + host.onSlashAction?.("toggle-focus"); + return; + case "export": + host.onSlashAction?.("export"); + return; + } + + if (!host.client) { + return; + } + + const result = await executeSlashCommand(host.client, host.sessionKey, name, args); + + if (result.content) { + injectCommandResult(host, result.content); + } + + if (result.action === "refresh") { + await refreshChat(host); + } + + scheduleChatScroll(host as unknown as Parameters[0]); +} + +async function clearChatHistory(host: ChatHost) { + if (!host.client || !host.connected) { + return; + } + try { + await host.client.request("sessions.reset", { key: host.sessionKey }); + host.chatMessages = []; + host.chatStream = null; + host.chatRunId = null; + await loadChatHistory(host as unknown as OpenClawApp); + } catch (err) { + host.lastError = String(err); + } + scheduleChatScroll(host as unknown as Parameters[0]); +} + +function injectCommandResult(host: ChatHost, content: string) { + host.chatMessages = [ + ...host.chatMessages, + { + role: "system", + content, + timestamp: Date.now(), + }, + ]; +} + export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) { await Promise.all([ loadChatHistory(host as unknown as OpenClawApp), loadSessions(host as unknown as OpenClawApp, { - activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + activeMinutes: 0, + limit: 0, + includeGlobal: false, + includeUnknown: false, }), refreshChatAvatar(host), ]); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index e5285bab93b..ee761fe85e0 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -14,7 +14,7 @@ import { import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; -import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; +import { loadAgents } from "./controllers/agents.ts"; import { loadAssistantIdentity } from "./controllers/assistant-identity.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts"; @@ -26,6 +26,7 @@ import { parseExecApprovalResolved, removeExecApproval, } from "./controllers/exec-approval.ts"; +import { loadHealthState } from "./controllers/health.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { @@ -39,7 +40,7 @@ import type { UiSettings } from "./storage.ts"; import type { AgentsListResult, PresenceEntry, - HealthSnapshot, + HealthSummary, StatusSummary, UpdateAvailable, } from "./types.ts"; @@ -81,10 +82,10 @@ type GatewayHost = { agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; - toolsCatalogLoading: boolean; - toolsCatalogError: string | null; - toolsCatalogResult: import("./types.ts").ToolsCatalogResult | null; - debugHealth: HealthSnapshot | null; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; + debugHealth: HealthSummary | null; assistantName: string; assistantAvatar: string | null; assistantAgentId: string | null; @@ -221,7 +222,7 @@ export function connectGateway(host: GatewayHost) { resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAgents(host as unknown as OpenClawApp); - void loadToolsCatalog(host as unknown as OpenClawApp); + void loadHealthState(host as unknown as OpenClawApp); void loadNodes(host as unknown as OpenClawApp, { quiet: true }); void loadDevices(host as unknown as OpenClawApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); @@ -326,7 +327,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { { ts: Date.now(), event: evt.event, payload: evt.payload }, ...host.eventLogBuffer, ].slice(0, 250); - if (host.tab === "debug") { + if (host.tab === "debug" || host.tab === "overview") { host.eventLog = host.eventLogBuffer; } @@ -406,7 +407,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { const snapshot = hello.snapshot as | { presence?: PresenceEntry[]; - health?: HealthSnapshot; + health?: HealthSummary; sessionDefaults?: SessionDefaultsSnapshot; updateAvailable?: UpdateAvailable; } @@ -416,6 +417,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { } if (snapshot?.health) { host.debugHealth = snapshot.health; + host.healthResult = snapshot.health; } if (snapshot?.sessionDefaults) { applySessionDefaults(host, snapshot.sessionDefaults); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 0678706cd04..0a2003fac34 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,15 +1,17 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; +import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; import { t } from "../i18n/index.ts"; import { refreshChat } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { OpenClawApp } from "./app.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; +import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; -import type { ThemeMode } from "./theme.ts"; +import type { ThemeMode, ThemeName } from "./theme.ts"; import type { SessionsListResult } from "./types.ts"; type SessionDefaultsSnapshot = { @@ -49,10 +51,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) export function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); + const isActive = state.tab === tab; + const collapsed = state.settings.navCollapsed; return html` { if ( event.defaultPrevented || @@ -77,7 +81,7 @@ export function renderTab(state: AppViewState, tab: Tab) { title=${titleForTab(tab)} > - ${titleForTab(tab)} + ${!collapsed ? html`${titleForTab(tab)}` : nothing} `; } @@ -122,23 +126,52 @@ function renderCronFilterIcon(hiddenCount: number) { `; } +export function renderChatSessionSelect(state: AppViewState) { + const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); + return html` +
+ +
+ `; +} + export function renderChatControls(state: AppViewState) { - const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); const hideCron = state.sessionsHideCron ?? true; const hiddenCronCount = hideCron ? countHiddenCronSessions(state.sessionKey, state.sessionsResult) : 0; - const sessionOptions = resolveSessionOptions( - state.sessionKey, - state.sessionsResult, - mainSessionKey, - hideCron, - ); const disableThinkingToggle = state.onboarding; const disableFocusToggle = state.onboarding; const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const focusActive = state.onboarding ? true : state.settings.chatFocusMode; - // Refresh icon const refreshIcon = html` -
-
- - - - -
+
+ ${THEME_MODE_OPTIONS.map( + (opt) => html` + + `, + )}
`; } -function renderSunIcon() { - return html` - - `; -} +export function renderThemeToggle(state: AppViewState) { + const setOpen = (orb: HTMLElement, nextOpen: boolean) => { + orb.classList.toggle("theme-orb--open", nextOpen); + const trigger = orb.querySelector(".theme-orb__trigger"); + const menu = orb.querySelector(".theme-orb__menu"); + if (trigger) { + trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false"); + } + if (menu) { + menu.setAttribute("aria-hidden", nextOpen ? "false" : "true"); + } + }; -function renderMoonIcon() { - return html` - - `; -} + const toggleOpen = (e: Event) => { + const orb = (e.currentTarget as HTMLElement).closest(".theme-orb"); + if (!orb) { + return; + } + const isOpen = orb.classList.contains("theme-orb--open"); + if (isOpen) { + setOpen(orb, false); + } else { + setOpen(orb, true); + const close = (ev: MouseEvent) => { + if (!orb.contains(ev.target as Node)) { + setOpen(orb, false); + document.removeEventListener("click", close); + } + }; + requestAnimationFrame(() => document.addEventListener("click", close)); + } + }; + + const pick = (opt: ThemeOption, e: Event) => { + const orb = (e.currentTarget as HTMLElement).closest(".theme-orb"); + if (orb) { + setOpen(orb, false); + } + if (opt.id !== state.theme) { + const context: ThemeTransitionContext = { element: orb ?? undefined }; + state.setTheme(opt.id, context); + } + }; -function renderMonitorIcon() { return html` - +
+ + +
`; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 1214bcc93a6..1b5390adc15 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,9 +1,17 @@ import { html, nothing } from "lit"; -import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import { + buildAgentMainSessionKey, + parseAgentSessionKey, +} from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; -import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; +import { + renderChatControls, + renderChatSessionSelect, + renderTab, + renderTopbarThemeModeToggle, +} from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; @@ -16,6 +24,7 @@ import { ensureAgentConfigEntry, findAgentConfigEntryIndex, loadConfig, + openConfigFile, runUpdate, saveConfig, updateConfigFormValue, @@ -65,6 +74,7 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import "./components/dashboard-header.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; @@ -75,23 +85,53 @@ import { resolveModelPrimary, sortLocaleStrings, } from "./views/agents-utils.ts"; -import { renderAgents } from "./views/agents.ts"; -import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; +import { renderCommandPalette } from "./views/command-palette.ts"; import { renderConfig } from "./views/config.ts"; -import { renderCron } from "./views/cron.ts"; -import { renderDebug } from "./views/debug.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; -import { renderInstances } from "./views/instances.ts"; -import { renderLogs } from "./views/logs.ts"; -import { renderNodes } from "./views/nodes.ts"; +import { renderLoginGate } from "./views/login-gate.ts"; import { renderOverview } from "./views/overview.ts"; -import { renderSessions } from "./views/sessions.ts"; -import { renderSkills } from "./views/skills.ts"; -const AVATAR_DATA_RE = /^data:/i; -const AVATAR_HTTP_RE = /^https?:\/\//i; +// Lazy-loaded view modules – deferred so the initial bundle stays small. +// Each loader resolves once; subsequent calls return the cached module. +type LazyState = { mod: T | null; promise: Promise | null }; + +let _pendingUpdate: (() => void) | undefined; + +function createLazy(loader: () => Promise): () => T | null { + const s: LazyState = { mod: null, promise: null }; + return () => { + if (s.mod) { + return s.mod; + } + if (!s.promise) { + s.promise = loader().then((m) => { + s.mod = m; + _pendingUpdate?.(); + return m; + }); + } + return null; + }; +} + +const lazyAgents = createLazy(() => import("./views/agents.ts")); +const lazyChannels = createLazy(() => import("./views/channels.ts")); +const lazyCron = createLazy(() => import("./views/cron.ts")); +const lazyDebug = createLazy(() => import("./views/debug.ts")); +const lazyInstances = createLazy(() => import("./views/instances.ts")); +const lazyLogs = createLazy(() => import("./views/logs.ts")); +const lazyNodes = createLazy(() => import("./views/nodes.ts")); +const lazySessions = createLazy(() => import("./views/sessions.ts")); +const lazySkills = createLazy(() => import("./views/skills.ts")); + +function lazyRender(getter: () => M | null, render: (mod: M) => unknown) { + const mod = getter(); + return mod ? render(mod) : nothing; +} + +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", @@ -130,6 +170,126 @@ function uniquePreserveOrder(values: string[]): string[] { return output; } +type DismissedUpdateBanner = { + latestVersion: string; + channel: string | null; + dismissedAtMs: number; +}; + +function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { + try { + const raw = localStorage.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 { + localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); + } catch { + // ignore + } +} + +const AVATAR_DATA_RE = /^data:/i; +const AVATAR_HTTP_RE = /^https?:\/\//i; +const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] 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", +] as const; +const AI_AGENTS_SECTION_KEYS = [ + "agents", + "models", + "skills", + "tools", + "memory", + "session", +] as const; +type CommunicationSectionKey = (typeof COMMUNICATION_SECTION_KEYS)[number]; +type AppearanceSectionKey = (typeof APPEARANCE_SECTION_KEYS)[number]; +type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number]; +type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; +type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number]; + +const NAV_WIDTH_MIN = 200; +const NAV_WIDTH_MAX = 400; + +function handleNavResizeStart(e: MouseEvent, state: AppViewState) { + e.preventDefault(); + const startX = e.clientX; + const startWidth = state.settings.navWidth; + + const onMove = (ev: MouseEvent) => { + const delta = ev.clientX - startX; + const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta))); + state.applySettings({ ...state.settings, navWidth: next }); + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); +} + function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -147,16 +307,22 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { - const openClawVersion = - (typeof state.hello?.server?.version === "string" && state.hello.server.version.trim()) || - state.updateAvailable?.currentVersion || - t("common.na"); - const availableUpdate = - state.updateAvailable && - state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion - ? state.updateAvailable - : null; - const versionStatusClass = availableUpdate ? "warn" : "ok"; + 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; @@ -234,77 +400,116 @@ export function renderApp(state: AppViewState) { : rawDeliveryToSuggestions; return html` -
+ ${renderCommandPalette({ + open: state.paletteOpen, + query: state.paletteQuery, + activeIndex: state.paletteActiveIndex, + 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.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `; + }, + })} +
-
- -
- -
-
OPENCLAW
-
Gateway Dashboard
-
-
-
+ +
-
- - ${t("common.version")} - ${openClawVersion} -
-
- - ${t("common.health")} - ${state.connected ? t("common.ok") : t("common.offline")} -
- ${renderThemeToggle(state)} + ${renderTopbarThemeModeToggle(state)}
-
- ${ - params.toolsCatalogError - ? html` -
- Could not load runtime tool catalog. Showing fallback list. -
- ` - : nothing - } ${ !params.configForm ? html` @@ -188,6 +199,22 @@ export function renderAgentTools(params: { ` : nothing } + ${ + params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError + ? html` +
Loading runtime tool catalog…
+ ` + : nothing + } + ${ + params.toolsCatalogError + ? html` +
+ Could not load runtime tool catalog. Showing built-in fallback list instead. +
+ ` + : nothing + }
@@ -235,50 +262,27 @@ export function renderAgentTools(params: {
- ${sections.map( + ${toolSections.map( (section) => html`
${section.label} ${ - "source" in section && section.source === "plugin" - ? html` - plugin - ` + section.source === "plugin" && section.pluginId + ? html`plugin:${section.pluginId}` : nothing }
${section.tools.map((tool) => { const { allowed } = resolveAllowed(tool.id); - const catalogTool = tool as { - source?: "core" | "plugin"; - pluginId?: string; - optional?: boolean; - }; - const source = - catalogTool.source === "plugin" - ? catalogTool.pluginId - ? `plugin:${catalogTool.pluginId}` - : "plugin" - : "core"; - const isOptional = catalogTool.optional === true; return html`
-
- ${tool.label} - ${source} - ${ - isOptional - ? html` - optional - ` - : nothing - } -
+
${tool.label}
${tool.description}
+ ${renderToolBadges(section, tool)}
-
- - +
+
+ + + +
diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 556b1c98247..45b39e5a77b 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -1,18 +1,157 @@ import { html } from "lit"; -import { - listCoreToolSections, - PROFILE_OPTIONS as TOOL_PROFILE_OPTIONS, -} from "../../../../src/agents/tool-catalog.js"; import { expandToolGroups, normalizeToolName, resolveToolProfilePolicy, } from "../../../../src/agents/tool-policy-shared.js"; -import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts"; +import type { + AgentIdentityResult, + AgentsFilesListResult, + AgentsListResult, + ToolCatalogProfile, + ToolsCatalogResult, +} from "../types.ts"; -export const TOOL_SECTIONS = listCoreToolSections(); +export type AgentToolEntry = { + id: string; + label: string; + description: string; + source?: "core" | "plugin"; + pluginId?: string; + optional?: boolean; + defaultProfiles?: string[]; +}; -export const PROFILE_OPTIONS = TOOL_PROFILE_OPTIONS; +export type AgentToolSection = { + id: string; + label: string; + source?: "core" | "plugin"; + pluginId?: string; + tools: AgentToolEntry[]; +}; + +export const FALLBACK_TOOL_SECTIONS: AgentToolSection[] = [ + { + id: "fs", + label: "Files", + tools: [ + { id: "read", label: "read", description: "Read file contents" }, + { id: "write", label: "write", description: "Create or overwrite files" }, + { id: "edit", label: "edit", description: "Make precise edits" }, + { id: "apply_patch", label: "apply_patch", description: "Patch files (OpenAI)" }, + ], + }, + { + id: "runtime", + label: "Runtime", + tools: [ + { id: "exec", label: "exec", description: "Run shell commands" }, + { id: "process", label: "process", description: "Manage background processes" }, + ], + }, + { + id: "web", + label: "Web", + tools: [ + { id: "web_search", label: "web_search", description: "Search the web" }, + { id: "web_fetch", label: "web_fetch", description: "Fetch web content" }, + ], + }, + { + id: "memory", + label: "Memory", + tools: [ + { id: "memory_search", label: "memory_search", description: "Semantic search" }, + { id: "memory_get", label: "memory_get", description: "Read memory files" }, + ], + }, + { + id: "sessions", + label: "Sessions", + tools: [ + { id: "sessions_list", label: "sessions_list", description: "List sessions" }, + { id: "sessions_history", label: "sessions_history", description: "Session history" }, + { id: "sessions_send", label: "sessions_send", description: "Send to session" }, + { id: "sessions_spawn", label: "sessions_spawn", description: "Spawn sub-agent" }, + { id: "session_status", label: "session_status", description: "Session status" }, + ], + }, + { + id: "ui", + label: "UI", + tools: [ + { id: "browser", label: "browser", description: "Control web browser" }, + { id: "canvas", label: "canvas", description: "Control canvases" }, + ], + }, + { + id: "messaging", + label: "Messaging", + tools: [{ id: "message", label: "message", description: "Send messages" }], + }, + { + id: "automation", + label: "Automation", + tools: [ + { id: "cron", label: "cron", description: "Schedule tasks" }, + { id: "gateway", label: "gateway", description: "Gateway control" }, + ], + }, + { + id: "nodes", + label: "Nodes", + tools: [{ id: "nodes", label: "nodes", description: "Nodes + devices" }], + }, + { + id: "agents", + label: "Agents", + tools: [{ id: "agents_list", label: "agents_list", description: "List agents" }], + }, + { + id: "media", + label: "Media", + tools: [{ id: "image", label: "image", description: "Image understanding" }], + }, +]; + +export const PROFILE_OPTIONS = [ + { id: "minimal", label: "Minimal" }, + { id: "coding", label: "Coding" }, + { id: "messaging", label: "Messaging" }, + { id: "full", label: "Full" }, +] as const; + +export function resolveToolSections( + toolsCatalogResult: ToolsCatalogResult | null, +): AgentToolSection[] { + if (toolsCatalogResult?.groups?.length) { + return toolsCatalogResult.groups.map((group) => ({ + id: group.id, + label: group.label, + source: group.source, + pluginId: group.pluginId, + tools: group.tools.map((tool) => ({ + id: tool.id, + label: tool.label, + description: tool.description, + source: tool.source, + pluginId: tool.pluginId, + optional: tool.optional, + defaultProfiles: [...tool.defaultProfiles], + })), + })); + } + return FALLBACK_TOOL_SECTIONS; +} + +export function resolveToolProfileOptions( + toolsCatalogResult: ToolsCatalogResult | null, +): readonly ToolCatalogProfile[] | typeof PROFILE_OPTIONS { + if (toolsCatalogResult?.profiles?.length) { + return toolsCatalogResult.profiles; + } + return PROFILE_OPTIONS; +} type ToolPolicy = { allow?: string[]; @@ -55,6 +194,30 @@ export function normalizeAgentLabel(agent: { return agent.name?.trim() || agent.identity?.name?.trim() || agent.id; } +const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i; + +export function resolveAgentAvatarUrl( + agent: { identity?: { avatar?: string; avatarUrl?: string } }, + agentIdentity?: AgentIdentityResult | null, +): string | null { + const url = + agentIdentity?.avatar?.trim() ?? + agent.identity?.avatarUrl?.trim() ?? + agent.identity?.avatar?.trim(); + if (!url) { + return null; + } + if (AVATAR_URL_RE.test(url)) { + return url; + } + return null; +} + +export function agentLogoUrl(basePath: string): string { + const base = basePath?.trim() ? basePath.replace(/\/$/, "") : ""; + return base ? `${base}/favicon.svg` : "/favicon.svg"; +} + function isLikelyEmoji(value: string) { const trimmed = value.trim(); if (!trimmed) { @@ -106,6 +269,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) { return defaultId && agentId === defaultId ? "default" : null; } +export function agentAvatarHue(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i += 1) { + hash = (hash * 31 + id.charCodeAt(i)) | 0; + } + return ((hash % 360) + 360) % 360; +} + export function formatBytes(bytes?: number) { if (bytes == null || !Number.isFinite(bytes)) { return "-"; @@ -138,7 +309,7 @@ export type AgentContext = { workspace: string; model: string; identityName: string; - identityEmoji: string; + identityAvatar: string; skillsLabel: string; isDefault: boolean; }; @@ -164,14 +335,14 @@ export function buildAgentContext( agent.name?.trim() || config.entry?.name || agent.id; - const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-"; + const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—"; const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; const skillCount = skillFilter?.length ?? null; return { workspace, model: modelLabel, identityName, - identityEmoji, + identityAvatar, skillsLabel: skillFilter ? `${skillCount} selected` : "all skills", isDefault: Boolean(defaultId && agent.id === defaultId), }; diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index 891190d9abb..63917b0f732 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -9,64 +9,78 @@ import type { SkillStatusReport, ToolsCatalogResult, } from "../types.ts"; +import { renderAgentOverview } from "./agents-panels-overview.ts"; import { renderAgentFiles, renderAgentChannels, renderAgentCron, } from "./agents-panels-status-files.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; -import { - agentBadgeText, - buildAgentContext, - buildModelOptions, - normalizeAgentLabel, - normalizeModelValue, - parseFallbackList, - resolveAgentConfig, - resolveAgentEmoji, - resolveEffectiveModelFallbacks, - resolveModelLabel, - resolveModelPrimary, -} from "./agents-utils.ts"; +import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; +export type ConfigState = { + form: Record | null; + loading: boolean; + saving: boolean; + dirty: boolean; +}; + +export type ChannelsState = { + snapshot: ChannelsStatusSnapshot | null; + loading: boolean; + error: string | null; + lastSuccess: number | null; +}; + +export type CronState = { + status: CronStatus | null; + jobs: CronJob[]; + loading: boolean; + error: string | null; +}; + +export type AgentFilesState = { + list: AgentsFilesListResult | null; + loading: boolean; + error: string | null; + active: string | null; + contents: Record; + drafts: Record; + saving: boolean; +}; + +export type AgentSkillsState = { + report: SkillStatusReport | null; + loading: boolean; + error: string | null; + agentId: string | null; + filter: string; +}; + +export type ToolsCatalogState = { + loading: boolean; + error: string | null; + result: ToolsCatalogResult | null; +}; + export type AgentsProps = { + basePath: string; loading: boolean; error: string | null; agentsList: AgentsListResult | null; selectedAgentId: string | null; activePanel: AgentsPanel; - configForm: Record | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - channelsLoading: boolean; - channelsError: string | null; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsLastSuccess: number | null; - cronLoading: boolean; - cronStatus: CronStatus | null; - cronJobs: CronJob[]; - cronError: string | null; - agentFilesLoading: boolean; - agentFilesError: string | null; - agentFilesList: AgentsFilesListResult | null; - agentFileActive: string | null; - agentFileContents: Record; - agentFileDrafts: Record; - agentFileSaving: boolean; + config: ConfigState; + channels: ChannelsState; + cron: CronState; + agentFiles: AgentFilesState; agentIdentityLoading: boolean; agentIdentityError: string | null; agentIdentityById: Record; - agentSkillsLoading: boolean; - agentSkillsReport: SkillStatusReport | null; - agentSkillsError: string | null; - agentSkillsAgentId: string | null; - toolsCatalogLoading: boolean; - toolsCatalogError: string | null; - toolsCatalogResult: ToolsCatalogResult | null; - skillsFilter: string; + agentSkills: AgentSkillsState; + toolsCatalog: ToolsCatalogState; onRefresh: () => void; onSelectAgent: (agentId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; @@ -83,20 +97,13 @@ export type AgentsProps = { onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; onChannelsRefresh: () => void; onCronRefresh: () => void; + onCronRunNow: (jobId: string) => void; onSkillsFilterChange: (next: string) => void; onSkillsRefresh: () => void; onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void; onAgentSkillsClear: (agentId: string) => void; onAgentSkillsDisableAll: (agentId: string) => void; -}; - -export type AgentContext = { - workspace: string; - model: string; - identityName: string; - identityEmoji: string; - skillsLabel: string; - isDefault: boolean; + onSetDefault: (agentId: string) => void; }; export function renderAgents(props: AgentsProps) { @@ -107,49 +114,96 @@ export function renderAgents(props: AgentsProps) { ? (agents.find((agent) => agent.id === selectedId) ?? null) : null; + const channelEntryCount = props.channels.snapshot + ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length + : null; + const cronJobCount = selectedId + ? props.cron.jobs.filter((j) => j.agentId === selectedId).length + : null; + const tabCounts: Record = { + files: props.agentFiles.list?.files?.length ?? null, + skills: props.agentSkills.report?.skills?.length ?? null, + channels: channelEntryCount, + cron: cronJobCount || null, + }; + return html`
-
-
-
-
Agents
-
${agents.length} configured.
+
+
+ Agent +
+
+ +
+
+ ${ + selectedAgent + ? html` +
+ + ${ + actionsMenuOpen + ? html` +
+ + +
+ ` + : nothing + } +
+ ` + : nothing + } + +
-
${ props.error - ? html`
${props.error}
` + ? html`
${props.error}
` : nothing } -
- ${ - agents.length === 0 - ? html` -
No agents found.
- ` - : agents.map((agent) => { - const badge = agentBadgeText(agent.id, defaultId); - const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null); - return html` - - `; - }) - } -
${ @@ -161,29 +215,26 @@ export function renderAgents(props: AgentsProps) {
` : html` - ${renderAgentHeader( - selectedAgent, - defaultId, - props.agentIdentityById[selectedAgent.id] ?? null, - )} - ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))} + ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)} ${ props.activePanel === "overview" ? renderAgentOverview({ agent: selectedAgent, + basePath: props.basePath, defaultId, - configForm: props.configForm, - agentFilesList: props.agentFilesList, + configForm: props.config.form, + agentFilesList: props.agentFiles.list, agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, agentIdentityError: props.agentIdentityError, agentIdentityLoading: props.agentIdentityLoading, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, + configLoading: props.config.loading, + configSaving: props.config.saving, + configDirty: props.config.dirty, onConfigReload: props.onConfigReload, onConfigSave: props.onConfigSave, onModelChange: props.onModelChange, onModelFallbacksChange: props.onModelFallbacksChange, + onSelectPanel: props.onSelectPanel, }) : nothing } @@ -191,13 +242,13 @@ export function renderAgents(props: AgentsProps) { props.activePanel === "files" ? renderAgentFiles({ agentId: selectedAgent.id, - agentFilesList: props.agentFilesList, - agentFilesLoading: props.agentFilesLoading, - agentFilesError: props.agentFilesError, - agentFileActive: props.agentFileActive, - agentFileContents: props.agentFileContents, - agentFileDrafts: props.agentFileDrafts, - agentFileSaving: props.agentFileSaving, + agentFilesList: props.agentFiles.list, + agentFilesLoading: props.agentFiles.loading, + agentFilesError: props.agentFiles.error, + agentFileActive: props.agentFiles.active, + agentFileContents: props.agentFiles.contents, + agentFileDrafts: props.agentFiles.drafts, + agentFileSaving: props.agentFiles.saving, onLoadFiles: props.onLoadFiles, onSelectFile: props.onSelectFile, onFileDraftChange: props.onFileDraftChange, @@ -210,13 +261,13 @@ export function renderAgents(props: AgentsProps) { props.activePanel === "tools" ? renderAgentTools({ agentId: selectedAgent.id, - configForm: props.configForm, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, - toolsCatalogLoading: props.toolsCatalogLoading, - toolsCatalogError: props.toolsCatalogError, - toolsCatalogResult: props.toolsCatalogResult, + configForm: props.config.form, + configLoading: props.config.loading, + configSaving: props.config.saving, + configDirty: props.config.dirty, + toolsCatalogLoading: props.toolsCatalog.loading, + toolsCatalogError: props.toolsCatalog.error, + toolsCatalogResult: props.toolsCatalog.result, onProfileChange: props.onToolsProfileChange, onOverridesChange: props.onToolsOverridesChange, onConfigReload: props.onConfigReload, @@ -228,15 +279,15 @@ export function renderAgents(props: AgentsProps) { props.activePanel === "skills" ? renderAgentSkills({ agentId: selectedAgent.id, - report: props.agentSkillsReport, - loading: props.agentSkillsLoading, - error: props.agentSkillsError, - activeAgentId: props.agentSkillsAgentId, - configForm: props.configForm, - configLoading: props.configLoading, - configSaving: props.configSaving, - configDirty: props.configDirty, - filter: props.skillsFilter, + report: props.agentSkills.report, + loading: props.agentSkills.loading, + error: props.agentSkills.error, + activeAgentId: props.agentSkills.agentId, + configForm: props.config.form, + configLoading: props.config.loading, + configSaving: props.config.saving, + configDirty: props.config.dirty, + filter: props.agentSkills.filter, onFilterChange: props.onSkillsFilterChange, onRefresh: props.onSkillsRefresh, onToggle: props.onAgentSkillToggle, @@ -252,16 +303,16 @@ export function renderAgents(props: AgentsProps) { ? renderAgentChannels({ context: buildAgentContext( selectedAgent, - props.configForm, - props.agentFilesList, + props.config.form, + props.agentFiles.list, defaultId, props.agentIdentityById[selectedAgent.id] ?? null, ), - configForm: props.configForm, - snapshot: props.channelsSnapshot, - loading: props.channelsLoading, - error: props.channelsError, - lastSuccess: props.channelsLastSuccess, + configForm: props.config.form, + snapshot: props.channels.snapshot, + loading: props.channels.loading, + error: props.channels.error, + lastSuccess: props.channels.lastSuccess, onRefresh: props.onChannelsRefresh, }) : nothing @@ -271,17 +322,18 @@ export function renderAgents(props: AgentsProps) { ? renderAgentCron({ context: buildAgentContext( selectedAgent, - props.configForm, - props.agentFilesList, + props.config.form, + props.agentFiles.list, defaultId, props.agentIdentityById[selectedAgent.id] ?? null, ), agentId: selectedAgent.id, - jobs: props.cronJobs, - status: props.cronStatus, - loading: props.cronLoading, - error: props.cronError, + jobs: props.cron.jobs, + status: props.cron.status, + loading: props.cron.loading, + error: props.cron.error, onRefresh: props.onCronRefresh, + onRunNow: props.onCronRunNow, }) : nothing } @@ -292,33 +344,13 @@ export function renderAgents(props: AgentsProps) { `; } -function renderAgentHeader( - agent: AgentsListResult["agents"][number], - defaultId: string | null, - agentIdentity: AgentIdentityResult | null, -) { - const badge = agentBadgeText(agent.id, defaultId); - const displayName = normalizeAgentLabel(agent); - const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing."; - const emoji = resolveAgentEmoji(agent, agentIdentity); - return html` -
-
-
${emoji || displayName.slice(0, 1)}
-
-
${displayName}
-
${subtitle}
-
-
-
-
${agent.id}
- ${badge ? html`${badge}` : nothing} -
-
- `; -} +let actionsMenuOpen = false; -function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) { +function renderAgentTabs( + active: AgentsPanel, + onSelect: (panel: AgentsPanel) => void, + counts: Record, +) { const tabs: Array<{ id: AgentsPanel; label: string }> = [ { id: "overview", label: "Overview" }, { id: "files", label: "Files" }, @@ -336,164 +368,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => type="button" @click=${() => onSelect(tab.id)} > - ${tab.label} + ${tab.label}${counts[tab.id] != null ? html`${counts[tab.id]}` : nothing} `, )}
`; } - -function renderAgentOverview(params: { - agent: AgentsListResult["agents"][number]; - defaultId: string | null; - configForm: Record | null; - agentFilesList: AgentsFilesListResult | null; - agentIdentity: AgentIdentityResult | null; - agentIdentityLoading: boolean; - agentIdentityError: string | null; - configLoading: boolean; - configSaving: boolean; - configDirty: boolean; - onConfigReload: () => void; - onConfigSave: () => void; - onModelChange: (agentId: string, modelId: string | null) => void; - onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; -}) { - const { - agent, - configForm, - agentFilesList, - agentIdentity, - agentIdentityLoading, - agentIdentityError, - configLoading, - configSaving, - configDirty, - onConfigReload, - onConfigSave, - onModelChange, - onModelFallbacksChange, - } = params; - const config = resolveAgentConfig(configForm, agent.id); - const workspaceFromFiles = - agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; - const workspace = - workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; - const model = config.entry?.model - ? resolveModelLabel(config.entry?.model) - : resolveModelLabel(config.defaults?.model); - const defaultModel = resolveModelLabel(config.defaults?.model); - const modelPrimary = - resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null); - const defaultPrimary = - resolveModelPrimary(config.defaults?.model) || - (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); - const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; - const modelFallbacks = resolveEffectiveModelFallbacks( - config.entry?.model, - config.defaults?.model, - ); - const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : ""; - const identityName = - agentIdentity?.name?.trim() || - agent.identity?.name?.trim() || - agent.name?.trim() || - config.entry?.name || - "-"; - const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity); - const identityEmoji = resolvedEmoji || "-"; - const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; - const skillCount = skillFilter?.length ?? null; - const identityStatus = agentIdentityLoading - ? "Loading…" - : agentIdentityError - ? "Unavailable" - : ""; - const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); - - return html` -
-
Overview
-
Workspace paths and identity metadata.
-
-
-
Workspace
-
${workspace}
-
-
-
Primary Model
-
${model}
-
-
-
Identity Name
-
${identityName}
- ${identityStatus ? html`
${identityStatus}
` : nothing} -
-
-
Default
-
${isDefault ? "yes" : "no"}
-
-
-
Identity Emoji
-
${identityEmoji}
-
-
-
Skills Filter
-
${skillFilter ? `${skillCount} selected` : "all skills"}
-
-
- -
-
Model Selection
-
- - -
-
- - -
-
-
- `; -} diff --git a/ui/src/ui/views/bottom-tabs.ts b/ui/src/ui/views/bottom-tabs.ts new file mode 100644 index 00000000000..b8dfbebf39c --- /dev/null +++ b/ui/src/ui/views/bottom-tabs.ts @@ -0,0 +1,33 @@ +import { html } from "lit"; +import { icons } from "../icons.ts"; +import type { Tab } from "../navigation.ts"; + +export type BottomTabsProps = { + activeTab: Tab; + onTabChange: (tab: Tab) => void; +}; + +const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [ + { id: "overview", label: "Dashboard", icon: "barChart" }, + { id: "chat", label: "Chat", icon: "messageSquare" }, + { id: "sessions", label: "Sessions", icon: "fileText" }, + { id: "config", label: "Settings", icon: "settings" }, +]; + +export function renderBottomTabs(props: BottomTabsProps) { + return html` + + `; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index d67acd77485..4565aae8adf 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -46,6 +46,9 @@ function createProps(overrides: Partial = {}): ChatProps { onSend: () => undefined, onQueueRemove: () => undefined, onNewSession: () => undefined, + agentsList: null, + currentAgentId: "", + onAgentChange: () => undefined, ...overrides, }; } diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 516042c27f1..db0b924322d 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,17 +1,37 @@ -import { html, nothing } from "lit"; +import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; +import { + CHAT_ATTACHMENT_ACCEPT, + isSupportedChatAttachmentMimeType, +} from "../chat/attachment-support.ts"; +import { DeletedMessages } from "../chat/deleted-messages.ts"; +import { exportChatMarkdown } from "../chat/export.ts"; import { renderMessageGroup, renderReadingIndicatorGroup, renderStreamingGroup, } from "../chat/grouped-render.ts"; +import { InputHistory } from "../chat/input-history.ts"; import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts"; +import { PinnedMessages } from "../chat/pinned-messages.ts"; +import { getPinnedMessageSummary } from "../chat/pinned-summary.ts"; +import { messageMatchesSearchQuery } from "../chat/search-match.ts"; +import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts"; +import { + CATEGORY_LABELS, + SLASH_COMMANDS, + getSlashCommandCompletions, + type SlashCommandCategory, + type SlashCommandDef, +} from "../chat/slash-commands.ts"; +import { isSttSupported, startStt, stopStt } from "../chat/speech.ts"; import { icons } from "../icons.ts"; import { detectTextDirection } from "../text-direction.ts"; -import type { SessionsListResult } from "../types.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; import type { ChatItem, MessageGroup } from "../types/chat-types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; +import { agentLogoUrl } from "./agents-utils.ts"; import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; import "../components/resizable-divider.ts"; @@ -54,49 +74,124 @@ export type ChatProps = { disabledReason: string | null; error: string | null; sessions: SessionsListResult | null; - // Focus mode focusMode: boolean; - // Sidebar state sidebarOpen?: boolean; sidebarContent?: string | null; sidebarError?: string | null; splitRatio?: number; assistantName: string; assistantAvatar: string | null; - // Image attachments attachments?: ChatAttachment[]; onAttachmentsChange?: (attachments: ChatAttachment[]) => void; - // Scroll control showNewMessages?: boolean; onScrollToBottom?: () => void; - // Event handlers onRefresh: () => void; onToggleFocusMode: () => void; + getDraft?: () => string; onDraftChange: (next: string) => void; + onRequestUpdate?: () => void; onSend: () => void; onAbort?: () => void; onQueueRemove: (id: string) => void; onNewSession: () => void; + onClearHistory?: () => void; + agentsList: { + agents: Array<{ id: string; name?: string; identity?: { name?: string; avatarUrl?: string } }>; + defaultId?: string; + } | null; + currentAgentId: string; + onAgentChange: (agentId: string) => void; + onNavigateToAgent?: () => void; + onSessionSelect?: (sessionKey: string) => void; onOpenSidebar?: (content: string) => void; onCloseSidebar?: () => void; onSplitRatioChange?: (ratio: number) => void; onChatScroll?: (event: Event) => void; + basePath?: string; }; const COMPACTION_TOAST_DURATION_MS = 5000; const FALLBACK_TOAST_DURATION_MS = 8000; +// Persistent instances keyed by session +const inputHistories = new Map(); +const pinnedMessagesMap = new Map(); +const deletedMessagesMap = new Map(); + +function getInputHistory(sessionKey: string): InputHistory { + return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory()); +} + +function getPinnedMessages(sessionKey: string): PinnedMessages { + return getOrCreateSessionCacheValue( + pinnedMessagesMap, + sessionKey, + () => new PinnedMessages(sessionKey), + ); +} + +function getDeletedMessages(sessionKey: string): DeletedMessages { + return getOrCreateSessionCacheValue( + deletedMessagesMap, + sessionKey, + () => new DeletedMessages(sessionKey), + ); +} + +interface ChatEphemeralState { + sttRecording: boolean; + sttInterimText: string; + slashMenuOpen: boolean; + slashMenuItems: SlashCommandDef[]; + slashMenuIndex: number; + slashMenuMode: "command" | "args"; + slashMenuCommand: SlashCommandDef | null; + slashMenuArgItems: string[]; + searchOpen: boolean; + searchQuery: string; + pinnedExpanded: boolean; +} + +function createChatEphemeralState(): ChatEphemeralState { + return { + sttRecording: false, + sttInterimText: "", + slashMenuOpen: false, + slashMenuItems: [], + slashMenuIndex: 0, + slashMenuMode: "command", + slashMenuCommand: null, + slashMenuArgItems: [], + searchOpen: false, + searchQuery: "", + pinnedExpanded: false, + }; +} + +const vs = createChatEphemeralState(); + +/** + * Reset chat view ephemeral state when navigating away. + * Stops STT recording and clears search/slash UI that should not survive navigation. + */ +export function resetChatViewState() { + if (vs.sttRecording) { + stopStt(); + } + Object.assign(vs, createChatEphemeralState()); +} + +export const cleanupChatModuleState = resetChatViewState; + function adjustTextareaHeight(el: HTMLTextAreaElement) { el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; + el.style.height = `${Math.min(el.scrollHeight, 150)}px`; } function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) { if (!status) { return nothing; } - - // Show "compacting..." while active if (status.active) { return html`
@@ -104,8 +199,6 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
`; } - - // Show "compaction complete" briefly after completion if (status.completedAt) { const elapsed = Date.now() - status.completedAt; if (elapsed < COMPACTION_TOAST_DURATION_MS) { @@ -116,7 +209,6 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un `; } } - return nothing; } @@ -148,17 +240,59 @@ function renderFallbackIndicator(status: FallbackIndicatorStatus | null | undefi : "compaction-indicator compaction-indicator--fallback"; const icon = phase === "cleared" ? icons.check : icons.brain; return html` -
+
${icon} ${message}
`; } +/** + * Compact notice when context usage reaches 85%+. + * Progressively shifts from amber (85%) to red (90%+). + */ +function renderContextNotice( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +) { + const used = session?.inputTokens ?? 0; + const limit = session?.contextTokens ?? defaultContextTokens ?? 0; + if (!used || !limit) { + return nothing; + } + const ratio = used / limit; + if (ratio < 0.85) { + return nothing; + } + const pct = Math.min(Math.round(ratio * 100), 100); + // Lerp from amber (#d97706) at 85% to red (#dc2626) at 95%+ + const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); + // RGB: amber(217,119,6) → red(220,38,38) + const r = Math.round(217 + (220 - 217) * t); + const g = Math.round(119 + (38 - 119) * t); + const b = Math.round(6 + (38 - 6) * t); + const color = `rgb(${r}, ${g}, ${b})`; + const bgOpacity = 0.08 + 0.08 * t; + const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; + return html` +
+ + ${pct}% context used + ${formatTokensCompact(used)} / ${formatTokensCompact(limit)} +
+ `; +} + +/** Format token count compactly (e.g. 128000 → "128k"). */ +function formatTokensCompact(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} + function generateAttachmentId(): string { return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } @@ -168,7 +302,6 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) { if (!items || !props.onAttachmentsChange) { return; } - const imageItems: DataTransferItem[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -176,19 +309,15 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) { imageItems.push(item); } } - if (imageItems.length === 0) { 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; @@ -204,33 +333,86 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) { } } -function renderAttachmentPreview(props: ChatProps) { +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 (!isSupportedChatAttachmentMimeType(file.type)) { + continue; + } + pending++; + const reader = new FileReader(); + reader.addEventListener("load", () => { + additions.push({ + id: generateAttachmentId(), + dataUrl: reader.result as string, + mimeType: file.type, + }); + 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 (!isSupportedChatAttachmentMimeType(file.type)) { + continue; + } + pending++; + const reader = new FileReader(); + reader.addEventListener("load", () => { + additions.push({ + id: generateAttachmentId(), + dataUrl: reader.result as string, + mimeType: file.type, + }); + 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` -
- Attachment preview +
+ Attachment preview + >×
`, )} @@ -238,6 +420,379 @@ function renderAttachmentPreview(props: ChatProps) { `; } +function resetSlashMenuState(): void { + vs.slashMenuMode = "command"; + vs.slashMenuCommand = null; + vs.slashMenuArgItems = []; + vs.slashMenuItems = []; +} + +function updateSlashMenu(value: string, requestUpdate: () => void): void { + // Arg mode: /command + const argMatch = value.match(/^\/(\S+)\s(.*)$/); + if (argMatch) { + 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; + } + } + vs.slashMenuOpen = false; + resetSlashMenuState(); + requestUpdate(); + return; + } + + // Command mode: /partial-command + const match = value.match(/^\/(\S*)$/); + if (match) { + const items = getSlashCommandCompletions(match[1]); + vs.slashMenuItems = items; + vs.slashMenuOpen = items.length > 0; + vs.slashMenuIndex = 0; + vs.slashMenuMode = "command"; + vs.slashMenuCommand = null; + vs.slashMenuArgItems = []; + } else { + vs.slashMenuOpen = false; + resetSlashMenuState(); + } + requestUpdate(); +} + +function selectSlashCommand( + cmd: SlashCommandDef, + props: ChatProps, + requestUpdate: () => void, +): void { + // Transition to arg picker when the command has fixed options + if (cmd.argOptions?.length) { + props.onDraftChange(`/${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) { + props.onDraftChange(`/${cmd.name}`); + requestUpdate(); + props.onSend(); + } else { + props.onDraftChange(`/${cmd.name} `); + requestUpdate(); + } +} + +function tabCompleteSlashCommand( + cmd: SlashCommandDef, + props: ChatProps, + requestUpdate: () => void, +): void { + // Tab: fill in the command text without executing + if (cmd.argOptions?.length) { + props.onDraftChange(`/${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(); + props.onDraftChange(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(); + props.onDraftChange(`/${cmdName} ${arg}`); + requestUpdate(); + if (execute) { + props.onSend(); + } +} + +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); +} + +const WELCOME_SUGGESTIONS = [ + "What can you do?", + "Summarize my recent sessions", + "Help me configure a channel", + "Check system health", +]; + +function renderWelcomeState(props: ChatProps): TemplateResult { + const name = props.assistantName || "Assistant"; + const avatar = props.assistantAvatar ?? props.assistantAvatarUrl; + const logoUrl = agentLogoUrl(props.basePath ?? ""); + + return html` +
+
+ ${ + avatar + ? html`${name}` + : html`` + } +

${name}

+
+ Ready to chat +
+

+ Type a message below · / for commands +

+
+ ${WELCOME_SUGGESTIONS.map( + (text) => html` + + `, + )} +
+
+ `; +} + +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 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" ? "You" : "Assistant"} + ${text.slice(0, 100)}${text.length > 100 ? "..." : ""} + +
+ `, + )} +
+ ` + : nothing + } +
+ `; +} + +function renderSlashMenu( + requestUpdate: () => void, + props: ChatProps, +): 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 + } +
+ `, + )} +
+ `); + } + + return html` +
+ ${sections} + +
+ `; +} + export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; @@ -249,32 +804,93 @@ export function renderChat(props: ChatProps) { name: props.assistantName, avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, }; - + const pinned = getPinnedMessages(props.sessionKey); + const deleted = getDeletedMessages(props.sessionKey); + const inputHistory = getInputHistory(props.sessionKey); const hasAttachments = (props.attachments?.length ?? 0) > 0; - const composePlaceholder = props.connected + const tokens = tokenEstimate(props.draft); + + const placeholder = props.connected ? hasAttachments ? "Add a message or paste more images..." - : "Message (↩ to send, Shift+↩ for line breaks, paste images)" - : "Connect to the gateway to start chatting…"; + : `Message ${props.assistantName || "agent"} (Enter to send)` + : "Connect to the gateway to start chatting..."; + + const requestUpdate = props.onRequestUpdate ?? (() => {}); + const getDraft = props.getDraft ?? (() => props.draft); const splitRatio = props.splitRatio ?? 0.6; const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); + + const handleCodeBlockCopy = (e: Event) => { + const btn = (e.target as HTMLElement).closest(".code-block-copy"); + if (!btn) { + return; + } + const code = (btn as HTMLElement).dataset.code ?? ""; + navigator.clipboard.writeText(code).then( + () => { + btn.classList.add("copied"); + setTimeout(() => btn.classList.remove("copied"), 1500); + }, + () => {}, + ); + }; + + const chatItems = buildChatItems(props); + const isEmpty = chatItems.length === 0 && !props.loading; + const thread = html`
+
${ props.loading ? html` -
Loading chat…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ` + : nothing + } + ${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing} + ${ + isEmpty && vs.searchOpen + ? html` +
No matching messages
` : nothing } ${repeat( - buildChatItems(props), + chatItems, (item) => item.key, (item) => { if (item.kind === "divider") { @@ -286,39 +902,168 @@ export function renderChat(props: ChatProps) {
`; } - if (item.kind === "reading-indicator") { - return renderReadingIndicatorGroup(assistantIdentity); + return renderReadingIndicatorGroup(assistantIdentity, props.basePath); } - if (item.kind === "stream") { return renderStreamingGroup( item.text, item.startedAt, props.onOpenSidebar, assistantIdentity, + props.basePath, ); } - if (item.kind === "group") { + if (deleted.has(item.key)) { + return nothing; + } return renderMessageGroup(item, { onOpenSidebar: props.onOpenSidebar, showReasoning, assistantName: props.assistantName, assistantAvatar: assistantIdentity.avatar, + basePath: props.basePath, + contextWindow: + activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null, + onDelete: () => { + deleted.delete(item.key); + requestUpdate(); + }, }); } - return nothing; }, )} +
`; - return html` -
- ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} + const handleKeyDown = (e: KeyboardEvent) => { + // 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(); + return; + case "ArrowUp": + e.preventDefault(); + vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; + requestUpdate(); + 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(); + return; + case "ArrowUp": + e.preventDefault(); + vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; + requestUpdate(); + 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; + } + } + + // Input history (only when input is empty) + if (!props.draft.trim()) { + if (e.key === "ArrowUp") { + const prev = inputHistory.up(); + if (prev !== null) { + e.preventDefault(); + props.onDraftChange(prev); + } + return; + } + if (e.key === "ArrowDown") { + const next = inputHistory.down(); + e.preventDefault(); + props.onDraftChange(next ?? ""); + 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 (e.isComposing || e.keyCode === 229) { + return; + } + if (!props.connected) { + return; + } + e.preventDefault(); + if (canCompose) { + if (props.draft.trim()) { + inputHistory.push(props.draft); + } + props.onSend(); + } + } + }; + + const handleInput = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + adjustTextareaHeight(target); + updateSlashMenu(target.value, requestUpdate); + inputHistory.reset(); + props.onDraftChange(target.value); + }; + + return html` +
handleDrop(e, props)} + @dragover=${(e: DragEvent) => e.preventDefault()} + > + ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} ${props.error ? html`
${props.error}
` : nothing} ${ @@ -337,9 +1082,10 @@ export function renderChat(props: ChatProps) { : nothing } -
+ ${renderSearchBar(requestUpdate)} + ${renderPinnedSection(props, pinned, requestUpdate)} + +
- New messages ${icons.arrowDown} + ${icons.arrowDown} New messages ` : nothing } -
+ +
+ ${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)} -
- -
+ + handleFileSelect(e, props)} + /> + + ${vs.sttRecording && vs.sttInterimText ? html`
${vs.sttInterimText}
` : nothing} + + + +
+
- + + ${ + isSttSupported() + ? html` + + ` + : nothing + } + + ${tokens ? html`${tokens}` : nothing} +
+ +
+ ${nothing /* search hidden for now */} + ${ + canAbort + ? nothing + : html` + + ` + } + + + ${ + canAbort && (isBusy || props.sending) + ? html` + + ` + : html` + + ` + }
@@ -567,6 +1402,11 @@ function buildChatItems(props: ChatProps): Array { continue; } + // Apply search filter if active + if (vs.searchOpen && vs.searchQuery.trim() && !messageMatchesSearchQuery(msg, vs.searchQuery)) { + continue; + } + items.push({ kind: "message", key: messageKey(msg, i), diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/ui/views/command-palette.ts new file mode 100644 index 00000000000..ec79f022873 --- /dev/null +++ b/ui/src/ui/views/command-palette.ts @@ -0,0 +1,263 @@ +import { html, nothing } from "lit"; +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"; + +type PaletteItem = { + id: string; + label: string; + icon: IconName; + category: "search" | "navigation" | "skills"; + action: string; + description?: string; +}; + +const SLASH_PALETTE_ITEMS: PaletteItem[] = SLASH_COMMANDS.map((command) => ({ + id: `slash:${command.name}`, + label: `/${command.name}`, + icon: command.icon ?? "terminal", + category: "search", + action: `/${command.name}`, + description: command.description, +})); + +const PALETTE_ITEMS: PaletteItem[] = [ + ...SLASH_PALETTE_ITEMS, + { + id: "nav-overview", + label: "Overview", + icon: "barChart", + category: "navigation", + action: "nav:overview", + }, + { + id: "nav-sessions", + label: "Sessions", + icon: "fileText", + category: "navigation", + action: "nav:sessions", + }, + { + id: "nav-cron", + label: "Scheduled", + icon: "scrollText", + category: "navigation", + action: "nav:cron", + }, + { id: "nav-skills", label: "Skills", icon: "zap", category: "navigation", action: "nav:skills" }, + { + id: "nav-config", + label: "Settings", + icon: "settings", + category: "navigation", + action: "nav:config", + }, + { + id: "nav-agents", + label: "Agents", + icon: "folder", + category: "navigation", + action: "nav:agents", + }, + { + id: "skill-shell", + label: "Shell Command", + icon: "monitor", + category: "skills", + action: "/skill shell", + description: "Run shell", + }, + { + id: "skill-debug", + label: "Debug Mode", + icon: "bug", + category: "skills", + action: "/verbose full", + description: "Toggle debug", + }, +]; + +export function getPaletteItems(): readonly PaletteItem[] { + return PALETTE_ITEMS; +} + +export type CommandPaletteProps = { + open: boolean; + query: string; + activeIndex: number; + onToggle: () => void; + onQueryChange: (query: string) => void; + onActiveIndexChange: (index: number) => void; + onNavigate: (tab: string) => void; + onSlashCommand: (command: string) => void; +}; + +function filteredItems(query: string): PaletteItem[] { + if (!query) { + return PALETTE_ITEMS; + } + const q = query.toLowerCase(); + return PALETTE_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(q) || + (item.description?.toLowerCase().includes(q) ?? false), + ); +} + +function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> { + const map = new Map(); + for (const item of items) { + const group = map.get(item.category) ?? []; + group.push(item); + map.set(item.category, group); + } + return [...map.entries()]; +} + +let previouslyFocused: Element | null = null; + +function saveFocus() { + previouslyFocused = document.activeElement; +} + +function restoreFocus() { + if (previouslyFocused && previouslyFocused instanceof HTMLElement) { + requestAnimationFrame(() => previouslyFocused && (previouslyFocused as HTMLElement).focus()); + } + previouslyFocused = null; +} + +function selectItem(item: PaletteItem, props: CommandPaletteProps) { + if (item.action.startsWith("nav:")) { + props.onNavigate(item.action.slice(4)); + } else { + props.onSlashCommand(item.action); + } + props.onToggle(); + restoreFocus(); +} + +function scrollActiveIntoView() { + requestAnimationFrame(() => { + const el = document.querySelector(".cmd-palette__item--active"); + el?.scrollIntoView({ block: "nearest" }); + }); +} + +function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) { + const items = filteredItems(props.query); + if (items.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter")) { + return; + } + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + props.onActiveIndexChange((props.activeIndex + 1) % items.length); + scrollActiveIntoView(); + break; + case "ArrowUp": + e.preventDefault(); + props.onActiveIndexChange((props.activeIndex - 1 + items.length) % items.length); + scrollActiveIntoView(); + break; + case "Enter": + e.preventDefault(); + if (items[props.activeIndex]) { + selectItem(items[props.activeIndex], props); + } + break; + case "Escape": + e.preventDefault(); + props.onToggle(); + restoreFocus(); + break; + } +} + +const CATEGORY_LABELS: Record = { + search: "Search", + navigation: "Navigation", + skills: "Skills", +}; + +function focusInput(el: Element | undefined) { + if (el) { + saveFocus(); + requestAnimationFrame(() => (el as HTMLInputElement).focus()); + } +} + +export function renderCommandPalette(props: CommandPaletteProps) { + if (!props.open) { + return nothing; + } + + const items = filteredItems(props.query); + const grouped = groupItems(items); + + return html` +
{ + props.onToggle(); + restoreFocus(); + }}> +
e.stopPropagation()} + @keydown=${(e: KeyboardEvent) => handleKeydown(e, props)} + > + { + props.onQueryChange((e.target as HTMLInputElement).value); + props.onActiveIndexChange(0); + }} + /> +
+ ${ + grouped.length === 0 + ? html`
+ ${icons.search} + ${t("overview.palette.noResults")} +
` + : grouped.map( + ([category, groupedItems]) => html` +
${CATEGORY_LABELS[category] ?? category}
+ ${groupedItems.map((item) => { + const globalIndex = items.indexOf(item); + const isActive = globalIndex === props.activeIndex; + return html` +
{ + e.stopPropagation(); + selectItem(item, props); + }} + @mouseenter=${() => props.onActiveIndexChange(globalIndex)} + > + ${icons[item.icon]} + ${item.label} + ${ + item.description + ? html`${item.description}` + : nothing + } +
+ `; + })} + `, + ) + } +
+ +
+
+ `; +} diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts index 05c3bb5f1f0..82071bb4f6b 100644 --- a/ui/src/ui/views/config-form.analyze.ts +++ b/ui/src/ui/views/config-form.analyze.ts @@ -249,11 +249,21 @@ function normalizeUnion( return res; } - const primitiveTypes = new Set(["string", "number", "integer", "boolean"]); + const renderableUnionTypes = new Set([ + "string", + "number", + "integer", + "boolean", + "object", + "array", + ]); if ( remaining.length > 0 && literals.length === 0 && - remaining.every((entry) => entry.type && primitiveTypes.has(String(entry.type))) + remaining.every((entry) => { + const type = schemaType(entry); + return Boolean(type) && renderableUnionTypes.has(String(type)); + }) ) { return { schema: { diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index bd02be896ea..e7758e1c29a 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -1,10 +1,13 @@ import { html, nothing, type TemplateResult } from "lit"; +import { icons as sharedIcons } from "../icons.ts"; import type { ConfigUiHints } from "../types.ts"; import { defaultValue, + hasSensitiveConfigData, hintForPath, humanize, pathKey, + REDACTED_PLACEHOLDER, schemaType, type JsonSchema, } from "./config-form.shared.ts"; @@ -100,11 +103,77 @@ type FieldMeta = { tags: string[]; }; +type SensitiveRenderParams = { + path: Array; + value: unknown; + hints: ConfigUiHints; + revealSensitive: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; +}; + +type SensitiveRenderState = { + isSensitive: boolean; + isRedacted: boolean; + isRevealed: boolean; + canReveal: boolean; +}; + export type ConfigSearchCriteria = { text: string; tags: string[]; }; +function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState { + const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints); + const isRevealed = + isSensitive && + (params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false)); + return { + isSensitive, + isRedacted: isSensitive && !isRevealed, + isRevealed, + canReveal: isSensitive, + }; +} + +function renderSensitiveToggleButton(params: { + path: Array; + state: SensitiveRenderState; + disabled: boolean; + onToggleSensitivePath?: (path: Array) => void; +}): TemplateResult | typeof nothing { + const { state } = params; + if (!state.isSensitive || !params.onToggleSensitivePath) { + return nothing; + } + return html` + + `; +} + function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean { return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0)); } @@ -331,6 +400,9 @@ export function renderNode(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult | typeof nothing { const { schema, value, path, hints, unsupported, disabled, onPatch } = params; @@ -440,6 +512,20 @@ export function renderNode(params: { }); } } + + // Complex union (e.g. array | object) — render as JSON textarea + return renderJsonTextarea({ + schema, + value, + path, + hints, + disabled, + showLabel, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + onToggleSensitivePath: params.onToggleSensitivePath, + onPatch, + }); } // Enum - use segmented for small, dropdown for large @@ -537,6 +623,9 @@ function renderTextInput(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; inputType: "text" | "number"; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { @@ -544,17 +633,22 @@ function renderTextInput(params: { const showLabel = params.showLabel ?? true; const hint = hintForPath(path, hints); const { label, help, tags } = resolveFieldMeta(path, schema, hints); - const isSensitive = - (hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim()); - const placeholder = - hint?.placeholder ?? - // oxlint-disable typescript/no-base-to-string - (isSensitive - ? "••••" - : schema.default !== undefined - ? `Default: ${String(schema.default)}` - : ""); - const displayValue = value ?? ""; + const sensitiveState = getSensitiveRenderState({ + path, + value, + hints, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + }); + const placeholder = sensitiveState.isRedacted + ? REDACTED_PLACEHOLDER + : (hint?.placeholder ?? + // oxlint-disable typescript/no-base-to-string + (schema.default !== undefined ? `Default: ${String(schema.default)}` : "")); + const displayValue = sensitiveState.isRedacted ? "" : (value ?? ""); + const effectiveDisabled = disabled || sensitiveState.isRedacted; + const effectiveInputType = + sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType; return html`
@@ -563,12 +657,16 @@ function renderTextInput(params: { ${renderTags(tags)}
{ + if (sensitiveState.isRedacted) { + return; + } const raw = (e.target as HTMLInputElement).value; if (inputType === "number") { if (raw.trim() === "") { @@ -582,13 +680,19 @@ function renderTextInput(params: { onPatch(path, raw); }} @change=${(e: Event) => { - if (inputType === "number") { + if (inputType === "number" || sensitiveState.isRedacted) { return; } const raw = (e.target as HTMLInputElement).value; onPatch(path, raw.trim()); }} /> + ${renderSensitiveToggleButton({ + path, + state: sensitiveState, + disabled, + onToggleSensitivePath: params.onToggleSensitivePath, + })} ${ schema.default !== undefined ? html` @@ -596,7 +700,7 @@ function renderTextInput(params: { type="button" class="cfg-input__reset" title="Reset to default" - ?disabled=${disabled} + ?disabled=${effectiveDisabled} @click=${() => onPatch(path, schema.default)} >↺ ` @@ -702,6 +806,73 @@ function renderSelect(params: { `; } +function renderJsonTextarea(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + disabled: boolean; + showLabel?: boolean; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const { label, help, tags } = resolveFieldMeta(path, schema, hints); + const fallback = jsonValue(value); + const sensitiveState = getSensitiveRenderState({ + path, + value, + hints, + revealSensitive: params.revealSensitive ?? false, + isSensitivePathRevealed: params.isSensitivePathRevealed, + }); + const displayValue = sensitiveState.isRedacted ? "" : fallback; + const effectiveDisabled = disabled || sensitiveState.isRedacted; + + return html` +
+ ${showLabel ? html`` : nothing} + ${help ? html`
${help}
` : nothing} + ${renderTags(tags)} +
+ + ${renderSensitiveToggleButton({ + path, + state: sensitiveState, + disabled, + onToggleSensitivePath: params.onToggleSensitivePath, + })} +
+
+ `; +} + function renderObject(params: { schema: JsonSchema; value: unknown; @@ -711,9 +882,24 @@ function renderObject(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { - const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params; + const { + schema, + value, + path, + hints, + unsupported, + disabled, + onPatch, + searchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, + } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = @@ -754,6 +940,9 @@ function renderObject(params: { unsupported, disabled, searchCriteria: childSearchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, }), )} @@ -768,6 +957,9 @@ function renderObject(params: { disabled, reservedKeys: reserved, searchCriteria: childSearchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, }) : nothing @@ -818,9 +1010,24 @@ function renderArray(params: { disabled: boolean; showLabel?: boolean; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { - const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params; + const { + schema, + value, + path, + hints, + unsupported, + disabled, + onPatch, + searchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, + } = params; const showLabel = params.showLabel ?? true; const { label, help, tags } = resolveFieldMeta(path, schema, hints); const selfMatched = @@ -900,6 +1107,9 @@ function renderArray(params: { disabled, searchCriteria: childSearchCriteria, showLabel: false, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, })}
@@ -922,6 +1132,9 @@ function renderMapField(params: { disabled: boolean; reservedKeys: Set; searchCriteria?: ConfigSearchCriteria; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { const { @@ -934,6 +1147,9 @@ function renderMapField(params: { reservedKeys, onPatch, searchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, } = params; const anySchema = isAnySchema(schema); const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key)); @@ -985,6 +1201,13 @@ function renderMapField(params: { ${visibleEntries.map(([key, entryValue]) => { const valuePath = [...path, key]; const fallback = jsonValue(entryValue); + const sensitiveState = getSensitiveRenderState({ + path: valuePath, + value: entryValue, + hints, + revealSensitive: revealSensitive ?? false, + isSensitivePathRevealed, + }); return html`
@@ -1028,26 +1251,40 @@ function renderMapField(params: { ${ anySchema ? html` - + rows="2" + .value=${sensitiveState.isRedacted ? "" : fallback} + ?disabled=${disabled || sensitiveState.isRedacted} + ?readonly=${sensitiveState.isRedacted} + @change=${(e: Event) => { + if (sensitiveState.isRedacted) { + return; + } + const target = e.target as HTMLTextAreaElement; + const raw = target.value.trim(); + if (!raw) { + onPatch(valuePath, undefined); + return; + } + try { + onPatch(valuePath, JSON.parse(raw)); + } catch { + target.value = fallback; + } + }} + > + ${renderSensitiveToggleButton({ + path: valuePath, + state: sensitiveState, + disabled, + onToggleSensitivePath, + })} +
` : renderNode({ schema, @@ -1058,6 +1295,9 @@ function renderMapField(params: { disabled, searchCriteria, showLabel: false, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, onPatch, }) } diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 124ca50a585..07d78963d61 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -13,6 +13,9 @@ export type ConfigFormProps = { searchQuery?: string; activeSection?: string | null; activeSubsection?: string | null; + revealSensitive?: boolean; + isSensitivePathRevealed?: (path: Array) => boolean; + onToggleSensitivePath?: (path: Array) => void; onPatch: (path: Array, value: unknown) => void; }; @@ -431,6 +434,9 @@ export function renderConfigForm(props: ConfigFormProps) { disabled: props.disabled ?? false, showLabel: false, searchCriteria, + revealSensitive: props.revealSensitive ?? false, + isSensitivePathRevealed: props.isSensitivePathRevealed, + onToggleSensitivePath: props.onToggleSensitivePath, onPatch: props.onPatch, })}
@@ -466,6 +472,9 @@ export function renderConfigForm(props: ConfigFormProps) { disabled: props.disabled ?? false, showLabel: false, searchCriteria, + revealSensitive: props.revealSensitive ?? false, + isSensitivePathRevealed: props.isSensitivePathRevealed, + onToggleSensitivePath: props.onToggleSensitivePath, onPatch: props.onPatch, })}
diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts index 366671041da..b535c49e25f 100644 --- a/ui/src/ui/views/config-form.shared.ts +++ b/ui/src/ui/views/config-form.shared.ts @@ -1,4 +1,4 @@ -import type { ConfigUiHints } from "../types.ts"; +import type { ConfigUiHint, ConfigUiHints } from "../types.ts"; export type JsonSchema = { type?: string | string[]; @@ -94,3 +94,110 @@ export function humanize(raw: string) { .replace(/\s+/g, " ") .replace(/^./, (m) => m.toUpperCase()); } + +const SENSITIVE_KEY_WHITELIST_SUFFIXES = [ + "maxtokens", + "maxoutputtokens", + "maxinputtokens", + "maxcompletiontokens", + "contexttokens", + "totaltokens", + "tokencount", + "tokenlimit", + "tokenbudget", + "passwordfile", +] as const; + +const SENSITIVE_PATTERNS = [ + /token$/i, + /password/i, + /secret/i, + /api.?key/i, + /serviceaccount(?:ref)?$/i, +]; + +const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/; + +export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]"; + +function isEnvVarPlaceholder(value: string): boolean { + return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim()); +} + +export function isSensitiveConfigPath(path: string): boolean { + const lowerPath = path.toLowerCase(); + const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix)); + return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); +} + +function isSensitiveLeafValue(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0 && !isEnvVarPlaceholder(value); + } + return value !== undefined && value !== null; +} + +function isHintSensitive(hint: ConfigUiHint | undefined): boolean { + return hint?.sensitive ?? false; +} + +export function hasSensitiveConfigData( + value: unknown, + path: Array, + hints: ConfigUiHints, +): boolean { + const key = pathKey(path); + const hint = hintForPath(path, hints); + const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key); + + if (pathIsSensitive && isSensitiveLeafValue(value)) { + return true; + } + + if (Array.isArray(value)) { + return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints)); + } + + if (value && typeof value === "object") { + return Object.entries(value as Record).some(([childKey, childValue]) => + hasSensitiveConfigData(childValue, [...path, childKey], hints), + ); + } + + return false; +} + +export function countSensitiveConfigValues( + value: unknown, + path: Array, + hints: ConfigUiHints, +): number { + if (value == null) { + return 0; + } + + const key = pathKey(path); + const hint = hintForPath(path, hints); + const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key); + + if (pathIsSensitive && isSensitiveLeafValue(value)) { + return 1; + } + + if (Array.isArray(value)) { + return value.reduce( + (count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints), + 0, + ); + } + + if (value && typeof value === "object") { + return Object.entries(value as Record).reduce( + (count, [childKey, childValue]) => + count + countSensitiveConfigValues(childValue, [...path, childKey], hints), + 0, + ); + } + + return 0; +} diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 889d046f942..138c1654e6d 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -1,5 +1,6 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import type { ThemeMode, ThemeName } from "../theme.ts"; import { renderConfig } from "./config.ts"; describe("config view", () => { @@ -35,6 +36,13 @@ describe("config view", () => { onApply: vi.fn(), onUpdate: vi.fn(), onSubsectionChange: vi.fn(), + version: "2026.3.11", + theme: "claw" as ThemeName, + themeMode: "system" as ThemeMode, + setTheme: vi.fn(), + setThemeMode: vi.fn(), + gatewayUrl: "", + assistantName: "OpenClaw", }); function findActionButtons(container: HTMLElement): { diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 5fa88c53aac..aede197a705 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,8 +1,17 @@ -import { html, nothing } from "lit"; +import { html, nothing, type TemplateResult } from "lit"; +import { icons } from "../icons.ts"; +import type { ThemeTransitionContext } from "../theme-transition.ts"; +import type { ThemeMode, ThemeName } from "../theme.ts"; import type { ConfigUiHints } from "../types.ts"; -import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; +import { + countSensitiveConfigValues, + humanize, + pathKey, + REDACTED_PLACEHOLDER, + schemaType, + type JsonSchema, +} from "./config-form.shared.ts"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; -import { getTagFilters, replaceTagFilters } from "./config-search.ts"; export type ConfigProps = { raw: string; @@ -18,6 +27,7 @@ export type ConfigProps = { schemaLoading: boolean; uiHints: ConfigUiHints; formMode: "form" | "raw"; + showModeToggle?: boolean; formValue: Record | null; originalValue: Record | null; searchQuery: string; @@ -33,26 +43,21 @@ export type ConfigProps = { onSave: () => void; onApply: () => void; onUpdate: () => void; + onOpenFile?: () => void; + version: string; + theme: ThemeName; + themeMode: ThemeMode; + setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void; + setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void; + gatewayUrl: string; + assistantName: string; + configPath?: string | null; + navRootLabel?: string; + includeSections?: string[]; + excludeSections?: string[]; + includeVirtualSections?: boolean; }; -const TAG_SEARCH_PRESETS = [ - "security", - "auth", - "network", - "access", - "privacy", - "observability", - "performance", - "reliability", - "storage", - "models", - "media", - "automation", - "channels", - "tools", - "advanced", -] as const; - // SVG Icons for sidebar (Lucide-style) const sidebarIcons = { all: html` @@ -273,6 +278,19 @@ const sidebarIcons = { `, + __appearance__: html` + + + + + + + + + + + + `, default: html` @@ -281,35 +299,137 @@ const sidebarIcons = { `, }; -// Section definitions -const SECTIONS: Array<{ key: string; label: string }> = [ - { key: "env", label: "Environment" }, - { key: "update", label: "Updates" }, - { key: "agents", label: "Agents" }, - { key: "auth", label: "Authentication" }, - { key: "channels", label: "Channels" }, - { key: "messages", label: "Messages" }, - { key: "commands", label: "Commands" }, - { key: "hooks", label: "Hooks" }, - { key: "skills", label: "Skills" }, - { key: "tools", label: "Tools" }, - { key: "gateway", label: "Gateway" }, - { key: "wizard", label: "Setup Wizard" }, -]; - -type SubsectionEntry = { - key: string; +// Categorised section definitions +type SectionCategory = { + id: string; label: string; - description?: string; - order: number; + sections: Array<{ key: string; label: string }>; }; -const ALL_SUBSECTION = "__all__"; +const SECTION_CATEGORIES: SectionCategory[] = [ + { + id: "core", + label: "Core", + sections: [ + { key: "env", label: "Environment" }, + { key: "auth", label: "Authentication" }, + { key: "update", label: "Updates" }, + { key: "meta", label: "Meta" }, + { key: "logging", label: "Logging" }, + ], + }, + { + id: "ai", + label: "AI & Agents", + sections: [ + { key: "agents", label: "Agents" }, + { key: "models", label: "Models" }, + { key: "skills", label: "Skills" }, + { key: "tools", label: "Tools" }, + { key: "memory", label: "Memory" }, + { key: "session", label: "Session" }, + ], + }, + { + id: "communication", + label: "Communication", + sections: [ + { key: "channels", label: "Channels" }, + { key: "messages", label: "Messages" }, + { key: "broadcast", label: "Broadcast" }, + { key: "talk", label: "Talk" }, + { key: "audio", label: "Audio" }, + ], + }, + { + id: "automation", + label: "Automation", + sections: [ + { key: "commands", label: "Commands" }, + { key: "hooks", label: "Hooks" }, + { key: "bindings", label: "Bindings" }, + { key: "cron", label: "Cron" }, + { key: "approvals", label: "Approvals" }, + { key: "plugins", label: "Plugins" }, + ], + }, + { + id: "infrastructure", + label: "Infrastructure", + sections: [ + { key: "gateway", label: "Gateway" }, + { key: "web", label: "Web" }, + { key: "browser", label: "Browser" }, + { key: "nodeHost", label: "NodeHost" }, + { key: "canvasHost", label: "CanvasHost" }, + { key: "discovery", label: "Discovery" }, + { key: "media", label: "Media" }, + ], + }, + { + id: "appearance", + label: "Appearance", + sections: [ + { key: "__appearance__", label: "Appearance" }, + { key: "ui", label: "UI" }, + { key: "wizard", label: "Setup Wizard" }, + ], + }, +]; + +// Flat lookup: all categorised keys +const CATEGORISED_KEYS = new Set(SECTION_CATEGORIES.flatMap((c) => c.sections.map((s) => s.key))); function getSectionIcon(key: string) { return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default; } +function scopeSchemaSections( + schema: JsonSchema | null, + params: { include?: ReadonlySet | null; exclude?: ReadonlySet | null }, +): JsonSchema | null { + if (!schema || schemaType(schema) !== "object" || !schema.properties) { + return schema; + } + const include = params.include; + const exclude = params.exclude; + const nextProps: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + if (include && include.size > 0 && !include.has(key)) { + continue; + } + if (exclude && exclude.size > 0 && exclude.has(key)) { + continue; + } + nextProps[key] = value; + } + return { ...schema, properties: nextProps }; +} + +function scopeUnsupportedPaths( + unsupportedPaths: string[], + params: { include?: ReadonlySet | null; exclude?: ReadonlySet | null }, +): string[] { + const include = params.include; + const exclude = params.exclude; + if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) { + return unsupportedPaths; + } + return unsupportedPaths.filter((entry) => { + if (entry === "") { + return true; + } + const [top] = entry.split("."); + if (include && include.size > 0) { + return include.has(top); + } + if (exclude && exclude.size > 0) { + return !exclude.has(top); + } + return true; + }); +} + function resolveSectionMeta( key: string, schema?: JsonSchema, @@ -327,26 +447,6 @@ function resolveSectionMeta( }; } -function resolveSubsections(params: { - key: string; - schema: JsonSchema | undefined; - uiHints: ConfigUiHints; -}): SubsectionEntry[] { - const { key, schema, uiHints } = params; - if (!schema || schemaType(schema) !== "object" || !schema.properties) { - return []; - } - const entries = Object.entries(schema.properties).map(([subKey, node]) => { - const hint = hintForPath([key, subKey], uiHints); - const label = hint?.label ?? node.title ?? humanize(subKey); - const description = hint?.help ?? node.description ?? ""; - const order = hint?.order ?? 50; - return { key: subKey, label, description, order }; - }); - entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key))); - return entries; -} - function computeDiff( original: Record | null, current: Record | null, @@ -402,237 +502,280 @@ function truncateValue(value: unknown, maxLen = 40): string { return str.slice(0, maxLen - 3) + "..."; } +function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string { + return truncateValue(value); +} + +type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult }; +const THEME_OPTIONS: ThemeOption[] = [ + { id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap }, + { id: "knot", label: "Knot", description: "Knot family", icon: icons.link }, + { id: "dash", label: "Dash", description: "Field family", icon: icons.barChart }, +]; + +function renderAppearanceSection(props: ConfigProps) { + const MODE_OPTIONS: Array<{ + id: ThemeMode; + label: string; + description: string; + icon: TemplateResult; + }> = [ + { id: "system", label: "System", description: "Follow OS light or dark", icon: icons.monitor }, + { id: "light", label: "Light", description: "Force light mode", icon: icons.sun }, + { id: "dark", label: "Dark", description: "Force dark mode", icon: icons.moon }, + ]; + + return html` +
+
+

Theme

+

Choose a theme family.

+
+ ${THEME_OPTIONS.map( + (opt) => html` + + `, + )} +
+
+ +
+

Mode

+

Choose light or dark mode for the selected theme.

+
+ ${MODE_OPTIONS.map( + (opt) => html` + + `, + )} +
+
+ +
+

Connection

+
+
+ Gateway + ${props.gatewayUrl || "-"} +
+
+ Status + + + ${props.connected ? "Connected" : "Offline"} + +
+ ${ + props.assistantName + ? html` +
+ Assistant + ${props.assistantName} +
+ ` + : nothing + } +
+
+
+ `; +} + +interface ConfigEphemeralState { + rawRevealed: boolean; + envRevealed: boolean; + validityDismissed: boolean; + revealedSensitivePaths: Set; +} + +function createConfigEphemeralState(): ConfigEphemeralState { + return { + rawRevealed: false, + envRevealed: false, + validityDismissed: false, + revealedSensitivePaths: new Set(), + }; +} + +const cvs = createConfigEphemeralState(); + +function isSensitivePathRevealed(path: Array): boolean { + const key = pathKey(path); + return key ? cvs.revealedSensitivePaths.has(key) : false; +} + +function toggleSensitivePathReveal(path: Array) { + const key = pathKey(path); + if (!key) { + return; + } + if (cvs.revealedSensitivePaths.has(key)) { + cvs.revealedSensitivePaths.delete(key); + } else { + cvs.revealedSensitivePaths.add(key); + } +} + +export function resetConfigViewStateForTests() { + Object.assign(cvs, createConfigEphemeralState()); +} + export function renderConfig(props: ConfigProps) { + const showModeToggle = props.showModeToggle ?? false; const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; - const analysis = analyzeConfigSchema(props.schema); + 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 rawAnalysis = analyzeConfigSchema(props.schema); + const analysis = { + schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }), + unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }), + }; const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false; + const formMode = showModeToggle ? props.formMode : "form"; + const envSensitiveVisible = cvs.envRevealed; - // Get available sections from schema + // Build categorised nav from schema - only include sections that exist in the schema const schemaProps = analysis.schema?.properties ?? {}; - const availableSections = SECTIONS.filter((s) => s.key in schemaProps); - // Add any sections in schema but not in our list - const knownKeys = new Set(SECTIONS.map((s) => s.key)); + const VIRTUAL_SECTIONS = new Set(["__appearance__"]); + const visibleCategories = SECTION_CATEGORIES.map((cat) => ({ + ...cat, + sections: cat.sections.filter( + (s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps, + ), + })).filter((cat) => cat.sections.length > 0); + + // Catch any schema keys not in our categories const extraSections = Object.keys(schemaProps) - .filter((k) => !knownKeys.has(k)) + .filter((k) => !CATEGORISED_KEYS.has(k)) .map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) })); - const allSections = [...availableSections, ...extraSections]; + const otherCategory: SectionCategory | null = + extraSections.length > 0 ? { id: "other", label: "Other", sections: extraSections } : null; + const isVirtualSection = + includeVirtualSections && + props.activeSection != null && + VIRTUAL_SECTIONS.has(props.activeSection); const activeSectionSchema = - props.activeSection && analysis.schema && schemaType(analysis.schema) === "object" + props.activeSection && + !isVirtualSection && + analysis.schema && + schemaType(analysis.schema) === "object" ? analysis.schema.properties?.[props.activeSection] : undefined; - const activeSectionMeta = props.activeSection - ? resolveSectionMeta(props.activeSection, activeSectionSchema) - : null; - const subsections = props.activeSection - ? resolveSubsections({ - key: props.activeSection, - schema: activeSectionSchema, - uiHints: props.uiHints, - }) - : []; - const allowSubnav = - props.formMode === "form" && Boolean(props.activeSection) && subsections.length > 0; - const isAllSubsection = props.activeSubsection === ALL_SUBSECTION; - const effectiveSubsection = props.searchQuery - ? null - : isAllSubsection - ? null - : (props.activeSubsection ?? subsections[0]?.key ?? null); + const activeSectionMeta = + props.activeSection && !isVirtualSection + ? resolveSectionMeta(props.activeSection, activeSectionSchema) + : null; + // Config subsections are always rendered as a single page per section. + const effectiveSubsection = null; + + const topTabs = [ + { key: null as string | null, label: props.navRootLabel ?? "Settings" }, + ...[...visibleCategories, ...(otherCategory ? [otherCategory] : [])].flatMap((cat) => + cat.sections.map((s) => ({ key: s.key, label: s.label })), + ), + ]; // Compute diff for showing changes (works for both form and raw modes) - const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; - const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw; - const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges; + const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; + const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw; + const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges; // Save/apply buttons require actual changes to be enabled. // Note: formUnsafe warns about unsupported schema paths but shouldn't block saving. const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema); const canSave = - props.connected && - !props.saving && - hasChanges && - (props.formMode === "raw" ? true : canSaveForm); + props.connected && !props.saving && hasChanges && (formMode === "raw" ? true : canSaveForm); const canApply = props.connected && !props.applying && !props.updating && hasChanges && - (props.formMode === "raw" ? true : canSaveForm); + (formMode === "raw" ? true : canSaveForm); const canUpdate = props.connected && !props.applying && !props.updating; - const selectedTags = new Set(getTagFilters(props.searchQuery)); + + const showAppearanceOnRoot = + includeVirtualSections && + formMode === "form" && + props.activeSection === null && + Boolean(include?.has("__appearance__")); return html`
- - - -
-
${ hasChanges ? html` - ${ - props.formMode === "raw" - ? "Unsaved changes" - : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}` - } - ` + ${ + formMode === "raw" + ? "Unsaved changes" + : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}` + } + ` : html` No changes ` }
+ ${ + props.onOpenFile + ? html` + + ` + : nothing + }
+
+ ${ + formMode === "form" + ? html` + + ` + : nothing + } + +
+ ${topTabs.map( + (tab) => html` + + `, + )} +
+ +
+ ${ + showModeToggle + ? html` +
+ + +
+ ` + : nothing + } +
+
+ + ${ + validity === "invalid" && !cvs.validityDismissed + ? html` +
+ + + + + + Your configuration is invalid. Some settings may not work as expected. + +
+ ` + : nothing + } + ${ - hasChanges && props.formMode === "form" + hasChanges && formMode === "form" ? html`
@@ -691,11 +938,11 @@ export function renderConfig(props: ConfigProps) {
${change.path}
${truncateValue(change.from)}${renderDiffValue(change.path, change.from, props.uiHints)} ${truncateValue(change.to)}${renderDiffValue(change.path, change.to, props.uiHints)}
@@ -706,12 +953,12 @@ export function renderConfig(props: ConfigProps) { ` : nothing } - ${ - activeSectionMeta && props.formMode === "form" - ? html` -
-
- ${getSectionIcon(props.activeSection ?? "")} + ${ + activeSectionMeta && formMode === "form" + ? html` +
+
+ ${getSectionIcon(props.activeSection ?? "")}
@@ -725,43 +972,40 @@ export function renderConfig(props: ConfigProps) { : nothing }
+ ${ + props.activeSection === "env" + ? html` + + ` + : nothing + }
` - : nothing - } - ${ - allowSubnav - ? html` -
- - ${subsections.map( - (entry) => html` - - `, - )} -
- ` - : nothing - } - + : nothing + }
${ - props.formMode === "form" - ? html` + props.activeSection === "__appearance__" + ? includeVirtualSections + ? renderAppearanceSection(props) + : nothing + : formMode === "form" + ? html` + ${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing} ${ props.schemaLoading ? html` @@ -780,28 +1024,75 @@ export function renderConfig(props: ConfigProps) { searchQuery: props.searchQuery, activeSection: props.activeSection, activeSubsection: effectiveSubsection, + revealSensitive: + props.activeSection === "env" ? envSensitiveVisible : false, + isSensitivePathRevealed, + onToggleSensitivePath: (path) => { + toggleSensitivePathReveal(path); + props.onRawChange(props.raw); + }, }) } - ${ - formUnsafe - ? html` -
- Form view can't safely edit some fields. Use Raw to avoid losing config entries. -
- ` - : nothing - } - ` - : html` - ` + : (() => { + const sensitiveCount = countSensitiveConfigValues( + props.formValue, + [], + props.uiHints, + ); + const blurred = sensitiveCount > 0 && !cvs.rawRevealed; + return html` + ${ + formUnsafe + ? html` +
+ Your config contains fields the form editor can't safely represent. Use Raw mode to edit those + entries. +
+ ` + : nothing + } + + `; + })() }
diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 296a692d115..836b72dbbcc 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -360,7 +360,9 @@ export function renderCron(props: CronProps) { props.runsScope === "all" ? t("cron.jobList.allJobs") : (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob")); - const runs = props.runs; + const runs = props.runs.toSorted((a, b) => + props.runsSortDir === "asc" ? a.ts - b.ts : b.ts - a.ts, + ); const runStatusOptions = getRunStatusOptions(); const runDeliveryOptions = getRunDeliveryOptions(); const selectedStatusLabels = runStatusOptions @@ -1569,7 +1571,7 @@ function renderJob(job: CronJob, props: CronProps) { ?disabled=${props.busy} @click=${(event: Event) => { event.stopPropagation(); - selectAnd(() => props.onLoadRuns(job.id)); + props.onLoadRuns(job.id); }} > ${t("cron.jobList.history")} diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 3379e881345..f63e9be8267 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -34,7 +34,7 @@ export function renderDebug(props: DebugProps) { critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues"; return html` -
+
diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts index df5fe5fd4fe..9648c7a4572 100644 --- a/ui/src/ui/views/instances.ts +++ b/ui/src/ui/views/instances.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; -import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts"; +import { icons } from "../icons.ts"; +import { formatPresenceAge } from "../presenter.ts"; import type { PresenceEntry } from "../types.ts"; export type InstancesProps = { @@ -10,7 +11,11 @@ export type InstancesProps = { onRefresh: () => void; }; +let hostsRevealed = false; + export function renderInstances(props: InstancesProps) { + const masked = !hostsRevealed; + return html`
@@ -18,9 +23,24 @@ export function renderInstances(props: InstancesProps) {
Connected Instances
Presence beacons from the gateway and clients.
- +
+ + +
${ props.lastError @@ -42,16 +62,18 @@ export function renderInstances(props: InstancesProps) { ? html`
No instances reported yet.
` - : props.entries.map((entry) => renderEntry(entry)) + : props.entries.map((entry) => renderEntry(entry, masked)) }
`; } -function renderEntry(entry: PresenceEntry) { +function renderEntry(entry: PresenceEntry, masked: boolean) { const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a"; const mode = entry.mode ?? "unknown"; + const host = entry.host ?? "unknown host"; + const ip = entry.ip ?? null; const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; const scopesLabel = @@ -63,8 +85,12 @@ function renderEntry(entry: PresenceEntry) { return html`
-
${entry.host ?? "unknown host"}
-
${formatPresenceSummary(entry)}
+
+ ${host} +
+
+ ${ip ? html`${ip} ` : nothing}${mode} ${entry.version ?? ""} +
${mode} ${roles.map((role) => html`${role}`)} diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts new file mode 100644 index 00000000000..d63a12c047e --- /dev/null +++ b/ui/src/ui/views/login-gate.ts @@ -0,0 +1,132 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { renderThemeToggle } from "../app-render.helpers.ts"; +import type { AppViewState } from "../app-view-state.ts"; +import { icons } from "../icons.ts"; +import { normalizeBasePath } from "../navigation.ts"; + +export function renderLoginGate(state: AppViewState) { + const basePath = normalizeBasePath(state.basePath ?? ""); + const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg"; + + return html` + + `; +} diff --git a/ui/src/ui/views/overview-attention.ts b/ui/src/ui/views/overview-attention.ts new file mode 100644 index 00000000000..8e09ce1c19f --- /dev/null +++ b/ui/src/ui/views/overview-attention.ts @@ -0,0 +1,61 @@ +import { html, nothing } from "lit"; +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"; + +export type OverviewAttentionProps = { + items: AttentionItem[]; +}; + +function severityClass(severity: string) { + if (severity === "error") { + return "danger"; + } + if (severity === "warning") { + return "warn"; + } + return ""; +} + +function attentionIcon(name: string) { + if (name in icons) { + return icons[name as IconName]; + } + return icons.radio; +} + +export function renderOverviewAttention(props: OverviewAttentionProps) { + if (props.items.length === 0) { + return nothing; + } + + return html` +
+
${t("overview.attention.title")}
+
+ ${props.items.map( + (item) => html` +
+ ${attentionIcon(item.icon)} +
+
${item.title}
+
${item.description}
+
+ ${ + item.href + ? html`${t("common.docs")}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts new file mode 100644 index 00000000000..61e98e94781 --- /dev/null +++ b/ui/src/ui/views/overview-cards.ts @@ -0,0 +1,162 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { t } from "../../i18n/index.ts"; +import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; +import { formatNextRun } from "../presenter.ts"; +import type { + SessionsUsageResult, + SessionsListResult, + SkillStatusReport, + CronJob, + CronStatus, +} from "../types.ts"; + +export type OverviewCardsProps = { + usageResult: SessionsUsageResult | null; + sessionsResult: SessionsListResult | null; + skillsReport: SkillStatusReport | null; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + presenceCount: number; + onNavigate: (tab: string) => void; +}; + +const DIGIT_RUN = /\d{3,}/g; + +function blurDigits(value: string): TemplateResult { + const escaped = value.replace(/&/g, "&").replace(//g, ">"); + const blurred = escaped.replace(DIGIT_RUN, (m) => `${m}`); + return html`${unsafeHTML(blurred)}`; +} + +type StatCard = { + kind: string; + tab: string; + label: string; + value: string | TemplateResult; + hint: string | TemplateResult; +}; + +function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) { + return html` + + `; +} + +function renderSkeletonCards() { + return html` +
+ ${[0, 1, 2, 3].map( + (i) => html` +
+ + + +
+ `, + )} +
+ `; +} + +export function renderOverviewCards(props: OverviewCardsProps) { + const dataLoaded = + props.usageResult != null || props.sessionsResult != null || props.skillsReport != null; + if (!dataLoaded) { + return renderSkeletonCards(); + } + + const totals = props.usageResult?.totals; + const totalCost = formatCost(totals?.totalCost); + const totalTokens = formatTokens(totals?.totalTokens); + const totalMessages = totals ? String(props.usageResult?.aggregates?.messages?.total ?? 0) : "0"; + const sessionCount = props.sessionsResult?.count ?? null; + + const skills = props.skillsReport?.skills ?? []; + const enabledSkills = skills.filter((s) => !s.disabled).length; + const blockedSkills = skills.filter((s) => s.blockedByAllowlist).length; + const totalSkills = skills.length; + + const cronEnabled = props.cronStatus?.enabled ?? null; + const cronNext = props.cronStatus?.nextWakeAtMs ?? null; + const cronJobCount = props.cronJobs.length; + const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length; + + const cronValue = + cronEnabled == null + ? t("common.na") + : cronEnabled + ? `${cronJobCount} jobs` + : t("common.disabled"); + + const cronHint = + failedCronCount > 0 + ? html`${failedCronCount} failed` + : cronNext + ? t("overview.stats.cronNext", { time: formatNextRun(cronNext) }) + : ""; + + const cards: StatCard[] = [ + { + kind: "cost", + tab: "usage", + label: t("overview.cards.cost"), + value: totalCost, + hint: `${totalTokens} tokens · ${totalMessages} msgs`, + }, + { + kind: "sessions", + tab: "sessions", + label: t("overview.stats.sessions"), + value: String(sessionCount ?? t("common.na")), + hint: t("overview.stats.sessionsHint"), + }, + { + kind: "skills", + tab: "skills", + label: t("overview.cards.skills"), + value: `${enabledSkills}/${totalSkills}`, + hint: blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`, + }, + { + kind: "cron", + tab: "cron", + label: t("overview.stats.cron"), + value: cronValue, + hint: cronHint, + }, + ]; + + const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? []; + + return html` +
+ ${cards.map((c) => renderStatCard(c, props.onNavigate))} +
+ + ${ + sessions.length > 0 + ? html` +
+

${t("overview.cards.recentSessions")}

+
    + ${sessions.map( + (s) => html` +
  • + ${blurDigits(s.displayName || s.label || s.key)} + ${s.model ?? ""} + ${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""} +
  • + `, + )} +
+
+ ` + : nothing + } + `; +} diff --git a/ui/src/ui/views/overview-event-log.ts b/ui/src/ui/views/overview-event-log.ts new file mode 100644 index 00000000000..04079f5243a --- /dev/null +++ b/ui/src/ui/views/overview-event-log.ts @@ -0,0 +1,42 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import type { EventLogEntry } from "../app-events.ts"; +import { icons } from "../icons.ts"; +import { formatEventPayload } from "../presenter.ts"; + +export type OverviewEventLogProps = { + events: EventLogEntry[]; +}; + +export function renderOverviewEventLog(props: OverviewEventLogProps) { + if (props.events.length === 0) { + return nothing; + } + + const visible = props.events.slice(0, 20); + + return html` +
+ + ${icons.radio} + ${t("overview.eventLog.title")} + ${props.events.length} + +
+ ${visible.map( + (entry) => html` +
+ ${new Date(entry.ts).toLocaleTimeString()} + ${entry.event} + ${ + entry.payload + ? html`${formatEventPayload(entry.payload).slice(0, 120)}` + : nothing + } +
+ `, + )} +
+
+ `; +} diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index 9db33a2b577..fa661016464 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -1,5 +1,31 @@ import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +const AUTH_REQUIRED_CODES = new Set([ + ConnectErrorDetailCodes.AUTH_REQUIRED, + ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, + ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING, + ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED, + ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED, +]); + +const AUTH_FAILURE_CODES = new Set([ + ...AUTH_REQUIRED_CODES, + ConnectErrorDetailCodes.AUTH_UNAUTHORIZED, + ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, + ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH, + ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, + ConnectErrorDetailCodes.AUTH_RATE_LIMITED, + ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING, + ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING, + ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED, + ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, +]); + +const INSECURE_CONTEXT_CODES = new Set([ + ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, +]); + /** Whether the overview should show device-pairing guidance for this error. */ export function shouldShowPairingHint( connected: boolean, @@ -14,3 +40,44 @@ export function shouldShowPairingHint( } return lastError.toLowerCase().includes("pairing required"); } + +export function shouldShowAuthHint( + connected: boolean, + lastError: string | null, + lastErrorCode?: string | null, +): boolean { + if (connected || !lastError) { + return false; + } + if (lastErrorCode) { + return AUTH_FAILURE_CODES.has(lastErrorCode); + } + const lower = lastError.toLowerCase(); + return lower.includes("unauthorized") || lower.includes("connect failed"); +} + +export function shouldShowAuthRequiredHint( + hasToken: boolean, + hasPassword: boolean, + lastErrorCode?: string | null, +): boolean { + if (lastErrorCode) { + return AUTH_REQUIRED_CODES.has(lastErrorCode); + } + return !hasToken && !hasPassword; +} + +export function shouldShowInsecureContextHint( + connected: boolean, + lastError: string | null, + lastErrorCode?: string | null, +): boolean { + if (connected || !lastError) { + return false; + } + if (lastErrorCode) { + return INSECURE_CONTEXT_CODES.has(lastErrorCode); + } + const lower = lastError.toLowerCase(); + return lower.includes("secure context") || lower.includes("device identity required"); +} diff --git a/ui/src/ui/views/overview-log-tail.ts b/ui/src/ui/views/overview-log-tail.ts new file mode 100644 index 00000000000..8be2aa9d5c5 --- /dev/null +++ b/ui/src/ui/views/overview-log-tail.ts @@ -0,0 +1,44 @@ +import { html, nothing } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +/** Strip ANSI escape codes (SGR, OSC-8) for readable log display. */ +function stripAnsi(text: string): string { + /* eslint-disable no-control-regex -- stripping ANSI escape sequences requires matching ESC */ + return text.replace(/\x1b\]8;;.*?\x1b\\|\x1b\]8;;\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""); +} + +export type OverviewLogTailProps = { + lines: string[]; + onRefreshLogs: () => void; +}; + +export function renderOverviewLogTail(props: OverviewLogTailProps) { + if (props.lines.length === 0) { + return nothing; + } + + const displayLines = props.lines + .slice(-50) + .map((line) => stripAnsi(line)) + .join("\n"); + + return html` +
+ + ${icons.scrollText} + ${t("overview.logTail.title")} + ${props.lines.length} + { + e.preventDefault(); + e.stopPropagation(); + props.onRefreshLogs(); + }} + >${icons.loader} + +
${displayLines}
+
+ `; +} diff --git a/ui/src/ui/views/overview-quick-actions.ts b/ui/src/ui/views/overview-quick-actions.ts new file mode 100644 index 00000000000..b1358ca2e67 --- /dev/null +++ b/ui/src/ui/views/overview-quick-actions.ts @@ -0,0 +1,31 @@ +import { html } from "lit"; +import { t } from "../../i18n/index.ts"; +import { icons } from "../icons.ts"; + +export type OverviewQuickActionsProps = { + onNavigate: (tab: string) => void; + onRefresh: () => void; +}; + +export function renderOverviewQuickActions(props: OverviewQuickActionsProps) { + return html` +
+ + + + +
+ `; +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 6ebcb884ff6..ed8ef6fb740 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -1,12 +1,29 @@ -import { html } from "lit"; -import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +import { html, nothing } from "lit"; import { t, i18n, SUPPORTED_LOCALES, type Locale } 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 { formatNextRun } from "../presenter.ts"; +import { icons } from "../icons.ts"; import type { UiSettings } from "../storage.ts"; -import { shouldShowPairingHint } from "./overview-hints.ts"; +import type { + AttentionItem, + CronJob, + CronStatus, + SessionsListResult, + SessionsUsageResult, + SkillStatusReport, +} from "../types.ts"; +import { renderOverviewAttention } from "./overview-attention.ts"; +import { renderOverviewCards } from "./overview-cards.ts"; +import { renderOverviewEventLog } from "./overview-event-log.ts"; +import { + shouldShowAuthHint, + shouldShowAuthRequiredHint, + shouldShowInsecureContextHint, + shouldShowPairingHint, +} from "./overview-hints.ts"; +import { renderOverviewLogTail } from "./overview-log-tail.ts"; export type OverviewProps = { connected: boolean; @@ -20,24 +37,39 @@ export type OverviewProps = { cronEnabled: boolean | null; cronNext: number | null; lastChannelsRefresh: number | null; + // New dashboard data + 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; }; export function renderOverview(props: OverviewProps) { const snapshot = props.hello?.snapshot as | { uptimeMs?: number; - policy?: { tickIntervalMs?: number }; authMode?: "none" | "token" | "password" | "trusted-proxy"; } | undefined; const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); - const tick = snapshot?.policy?.tickIntervalMs - ? `${snapshot.policy.tickIntervalMs}ms` + 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"; @@ -74,38 +106,12 @@ export function renderOverview(props: OverviewProps) { if (props.connected || !props.lastError) { return null; } - const lower = props.lastError.toLowerCase(); - const authRequiredCodes = new Set([ - ConnectErrorDetailCodes.AUTH_REQUIRED, - ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, - ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING, - ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED, - ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED, - ]); - const authFailureCodes = new Set([ - ...authRequiredCodes, - ConnectErrorDetailCodes.AUTH_UNAUTHORIZED, - ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, - ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH, - ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH, - ConnectErrorDetailCodes.AUTH_RATE_LIMITED, - ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING, - ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING, - ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED, - ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, - ]); - const authFailed = props.lastErrorCode - ? authFailureCodes.has(props.lastErrorCode) - : lower.includes("unauthorized") || lower.includes("connect failed"); - if (!authFailed) { + if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) { return null; } const hasToken = Boolean(props.settings.token.trim()); const hasPassword = Boolean(props.password.trim()); - const isAuthRequired = props.lastErrorCode - ? authRequiredCodes.has(props.lastErrorCode) - : !hasToken && !hasPassword; - if (isAuthRequired) { + if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) { return html`
${t("overview.auth.required")} @@ -151,15 +157,7 @@ export function renderOverview(props: OverviewProps) { if (isSecureContext) { return null; } - const lower = props.lastError.toLowerCase(); - const insecureContextCode = - props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || - props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED; - if ( - !insecureContextCode && - !lower.includes("secure context") && - !lower.includes("device identity required") - ) { + if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) { return null; } return html` @@ -194,12 +192,12 @@ export function renderOverview(props: OverviewProps) { const currentLocale = i18n.getLocale(); return html` -
+
${t("overview.access.title")}
${t("overview.access.subtitle")}
-
-
@@ -321,45 +374,32 @@ export function renderOverview(props: OverviewProps) {
-
-
-
${t("overview.stats.instances")}
-
${props.presenceCount}
-
${t("overview.stats.instancesHint")}
-
-
-
${t("overview.stats.sessions")}
-
${props.sessionsCount ?? t("common.na")}
-
${t("overview.stats.sessionsHint")}
-
-
-
${t("overview.stats.cron")}
-
- ${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")} -
-
${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}
-
-
+
+ + ${renderOverviewCards({ + usageResult: props.usageResult, + sessionsResult: props.sessionsResult, + skillsReport: props.skillsReport, + cronJobs: props.cronJobs, + cronStatus: props.cronStatus, + presenceCount: props.presenceCount, + onNavigate: props.onNavigate, + })} + + ${renderOverviewAttention({ items: props.attentionItems })} + +
+ +
+ ${renderOverviewEventLog({ + events: props.eventLog, + })} + + ${renderOverviewLogTail({ + lines: props.overviewLogLines, + onRefreshLogs: props.onRefreshLogs, + })} +
-
-
${t("overview.notes.title")}
-
${t("overview.notes.subtitle")}
-
-
-
${t("overview.notes.tailscaleTitle")}
-
- ${t("overview.notes.tailscaleText")} -
-
-
-
${t("overview.notes.sessionTitle")}
-
${t("overview.notes.sessionText")}
-
-
-
${t("overview.notes.cronTitle")}
-
${t("overview.notes.cronText")}
-
-
-
`; } diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 453c216592a..1fa65450589 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -23,7 +23,18 @@ function buildProps(result: SessionsListResult): SessionsProps { includeGlobal: false, includeUnknown: false, basePath: "", + searchQuery: "", + sortColumn: "updated", + sortDir: "desc", + page: 0, + pageSize: 10, + actionsOpenKey: null, onFiltersChange: () => undefined, + onSearchChange: () => undefined, + onSortChange: () => undefined, + onPageChange: () => undefined, + onPageSizeChange: () => undefined, + onActionsOpenChange: () => undefined, onRefresh: () => undefined, onPatch: () => undefined, onDelete: () => undefined, diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 6f0332f62be..bb1bef96d38 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; +import { icons } from "../icons.ts"; import { pathForTab } from "../navigation.ts"; import { formatSessionTokens } from "../presenter.ts"; import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; @@ -13,12 +14,23 @@ export type SessionsProps = { includeGlobal: boolean; includeUnknown: boolean; basePath: string; + searchQuery: string; + sortColumn: "key" | "kind" | "updated" | "tokens"; + sortDir: "asc" | "desc"; + page: number; + pageSize: number; + actionsOpenKey: string | null; onFiltersChange: (next: { activeMinutes: string; limit: string; includeGlobal: boolean; includeUnknown: boolean; }) => void; + onSearchChange: (query: string) => void; + onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void; + onPageChange: (page: number) => void; + onPageSizeChange: (size: number) => void; + onActionsOpenChange: (key: string | null) => void; onRefresh: () => void; onPatch: ( key: string, @@ -41,6 +53,7 @@ const VERBOSE_LEVELS = [ { value: "full", label: "full" }, ] as const; const REASONING_LEVELS = ["", "off", "on", "stream"] as const; +const PAGE_SIZES = [10, 25, 50, 100] as const; function normalizeProviderId(provider?: string | null): string { if (!provider) { @@ -107,24 +120,110 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | return value; } +function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] { + const q = query.trim().toLowerCase(); + if (!q) { + return rows; + } + return rows.filter((row) => { + const key = (row.key ?? "").toLowerCase(); + const label = (row.label ?? "").toLowerCase(); + const kind = (row.kind ?? "").toLowerCase(); + const displayName = (row.displayName ?? "").toLowerCase(); + return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q); + }); +} + +function sortRows( + rows: GatewaySessionRow[], + column: "key" | "kind" | "updated" | "tokens", + dir: "asc" | "desc", +): GatewaySessionRow[] { + const cmp = dir === "asc" ? 1 : -1; + return [...rows].toSorted((a, b) => { + let diff = 0; + switch (column) { + case "key": + diff = (a.key ?? "").localeCompare(b.key ?? ""); + break; + case "kind": + diff = (a.kind ?? "").localeCompare(b.kind ?? ""); + break; + case "updated": { + const au = a.updatedAt ?? 0; + const bu = b.updatedAt ?? 0; + diff = au - bu; + break; + } + case "tokens": { + const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0; + const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0; + diff = at - bt; + break; + } + } + return diff * cmp; + }); +} + +function paginateRows(rows: T[], page: number, pageSize: number): T[] { + const start = page * pageSize; + return rows.slice(start, start + pageSize); +} + export function renderSessions(props: SessionsProps) { - const rows = props.result?.sessions ?? []; + const rawRows = props.result?.sessions ?? []; + const filtered = filterRows(rawRows, props.searchQuery); + const sorted = sortRows(filtered, props.sortColumn, props.sortDir); + const totalRows = sorted.length; + const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize)); + const page = Math.min(props.page, totalPages - 1); + const paginated = paginateRows(sorted, page, props.pageSize); + + const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => { + const isActive = props.sortColumn === col; + const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const); + return html` + props.onSortChange(col, isActive ? nextDir : "desc")} + > + ${label} + ${icons.arrowUpDown} + + `; + }; + return html` -
-
+ ${ + props.actionsOpenKey + ? html` +
props.onActionsOpenChange(null)} + aria-hidden="true" + >
+ ` + : nothing + } +
+
Sessions
-
Active session keys and per-session overrides.
+
${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}
-
-
@@ -219,6 +381,8 @@ function renderRow( basePath: string, onPatch: SessionsProps["onPatch"], onDelete: SessionsProps["onDelete"], + onActionsOpenChange: (key: string | null) => void, + actionsOpenKey: string | null, disabled: boolean, ) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a"; @@ -234,36 +398,58 @@ function renderRow( typeof row.displayName === "string" && row.displayName.trim().length > 0 ? row.displayName.trim() : null; - const label = typeof row.label === "string" ? row.label.trim() : ""; - const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label); + const showDisplayName = Boolean( + displayName && + displayName !== row.key && + displayName !== (typeof row.label === "string" ? row.label.trim() : ""), + ); const canLink = row.kind !== "global"; const chatUrl = canLink ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}` : null; + const isMenuOpen = actionsOpenKey === row.key; + const badgeClass = + row.kind === "direct" + ? "data-table-badge--direct" + : row.kind === "group" + ? "data-table-badge--group" + : row.kind === "global" + ? "data-table-badge--global" + : "data-table-badge--unknown"; return html` -
-
- ${canLink ? html`${row.key}` : row.key} - ${showDisplayName ? html`${displayName}` : nothing} -
-
+ + +
+ ${canLink ? html`${row.key}` : row.key} + ${ + showDisplayName + ? html`${displayName}` + : nothing + } +
+ + { const value = (e.target as HTMLInputElement).value.trim(); onPatch(row.key, { label: value || null }); }} /> -
-
${row.kind}
-
${updated}
-
${formatSessionTokens(row)}
-
+ + + ${row.kind} + + ${updated} + ${formatSessionTokens(row)} + -
-
+ + -
-
+ + -
-
- -
-
+ + +
+ + ${ + isMenuOpen + ? html` +
+ ${ + canLink + ? html` + onActionsOpenChange(null)} + > + Open in Chat + + ` + : nothing + } + +
+ ` + : nothing + } +
+ + `; } diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 830f97921f8..ad0f4ee63c0 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -10,6 +10,7 @@ import { } from "./skills-shared.ts"; export type SkillsProps = { + connected: boolean; loading: boolean; report: SkillStatusReport | null; error: string | null; @@ -40,16 +41,22 @@ export function renderSkills(props: SkillsProps) {
Skills
-
Bundled, managed, and workspace skills.
+
Installed skills and their status.
-
-
-