From f76a3c5225bb012b06a8df8aa25883442135c950 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:46:19 -0500 Subject: [PATCH] feat(ui): dashboard-v2 views refactor (slice 3/3 of dashboard-v2) (#41503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2) New self-contained chat modules extracted from dashboard-v2-structure: - chat/slash-commands.ts: slash command definitions and completions - chat/slash-command-executor.ts: execute slash commands via gateway RPC - chat/slash-command-executor.node.test.ts: test coverage - chat/speech.ts: speech-to-text (STT) support - chat/input-history.ts: per-session input history navigation - chat/pinned-messages.ts: pinned message management - chat/deleted-messages.ts: deleted message tracking - chat/export.ts: shared exportChatMarkdown helper - chat-export.ts: re-export shim for backwards compat Gateway fix: - Restore usage/cost stripping in chat.history sanitization - Add test coverage for sanitization behavior These modules are additive and tree-shaken — no existing code imports them yet. They will be wired in subsequent slices. * feat(ui): add utilities, theming, and i18n updates (slice 2 of dashboard-v2) UI utilities and theming improvements extracted from dashboard-v2-structure: Icons & formatting: - icons.ts: expanded icon set for new dashboard views - format.ts: date/number formatting helpers - tool-labels.ts: human-readable tool name mappings Theming: - theme.ts: enhanced theme resolution and system theme support - theme-transition.ts: simplified transition logic - storage.ts: theme parsing improvements for settings persistence Navigation & types: - navigation.ts: extended tab definitions for dashboard-v2 - app-view-state.ts: expanded view state management - types.ts: new type definitions (HealthSummary, ModelCatalogEntry, etc.) Components: - components/dashboard-header.ts: reusable header component i18n: - Updated en, pt-BR, zh-CN, zh-TW locales with new dashboard strings All changes are additive or backwards-compatible. Build passes. Part of #36853. * feat(ui): dashboard-v2 views refactor (slice 3 of dashboard-v2) Complete views refactor from dashboard-v2-structure, building on slice 1 (chat infra, #41497) and slice 2 (utilities/theming, #41500). Core app wiring: - app.ts: updated host component with new state properties - app-render.ts: refactored render pipeline for new dashboard layout - app-render.helpers.ts: extracted render helpers - app-settings.ts: theme listener lifecycle fix, cron runs on tab load - app-gateway.ts: refactored chat event handling - app-chat.ts: slash command integration New views: - views/command-palette.ts: command palette (Cmd+K) - views/login-gate.ts: authentication gate - views/bottom-tabs.ts: mobile tab navigation - views/overview-*.ts: modular overview dashboard (cards, attention, event log, hints, log tail, quick actions) - views/agents-panels-overview.ts: agent overview panel Refactored views: - views/chat.ts: major refactor with STT, slash commands, search, export, pinned messages, input history - views/config.ts: restructured config management - views/agents.ts: streamlined agent management - views/overview.ts: modular composition from sub-views - views/sessions.ts: enhanced session management Controllers: - controllers/health.ts: new health check controller - controllers/models.ts: new model catalog controller - controllers/agents.ts: tools catalog improvements - controllers/config.ts: config form enhancements Tests & infrastructure: - Updated test helpers, browser tests, node tests - vite.config.ts: build configuration updates - markdown.ts: rendering improvements Build passes ✅ | 44 files | +6,626/-1,499 Part of #36853. Depends on #41497 and #41500. * UI: fix chat review follow-ups * fix(ui): repair chat clear and attachment regressions * fix(ui): address remaining chat review comments * fix(ui): address review follow-ups * fix(ui): replay queued local slash commands * fix(ui): repair control-ui type drift * fix(ui): restore control UI styling * feat(ui): enhance layout and styling for config and topbar components - Updated grid layout for the config layout to allow full-width usage. - Introduced new styles for top tabs and search components to improve usability. - Added theme mode toggle styling for better visual integration. - Implemented tests for layout and theme mode components to ensure proper rendering and functionality. * feat(ui): add config file opening functionality and enhance styles - Implemented a new handler to open the configuration file using the default application based on the operating system. - Updated various CSS styles across components for improved visual consistency and usability, including adjustments to padding, margins, and font sizes. - Introduced new styles for the data table and sidebar components to enhance layout and interaction. - Added tests for the collapsed navigation rail to ensure proper functionality in different states. * refactor(ui): update CSS styles for improved layout and consistency - Simplified font-body declaration in base.css for cleaner code. - Adjusted transition properties in components.css for better readability. - Added new .workspace-link class in components.css for enhanced link styling. - Changed config layout from grid to flex in config.css for better responsiveness. - Updated related tests to reflect layout changes in config-layout.browser.test.ts. * feat(ui): enhance theme handling and loading states in chat interface - Updated CSS to support new theme mode attributes for better styling consistency across light and dark themes. - Introduced loading skeletons in the chat view to improve user experience during data fetching. - Refactored command palette to manage focus more effectively, enhancing accessibility. - Added tests for the appearance theme picker and loading states to ensure proper rendering and functionality. * refactor(ui): streamline ephemeral state management in chat and config views - Introduced interfaces for ephemeral state in chat and config views to encapsulate related variables. - Refactored state management to utilize a single object for better organization and maintainability. - Removed legacy state variables and updated related functions to reference the new state structure. - Enhanced readability and consistency across the codebase by standardizing state handling. * chore: remove test files to reduce PR scope * fix(ui): resolve type errors in debug props and chat search * refactor(ui): remove stream mode functionality across various components - Eliminated stream mode related translations and CSS styles to streamline the user interface. - Updated multiple components to remove references to stream mode, enhancing code clarity and maintainability. - Adjusted rendering logic in views to ensure consistent behavior without stream mode. - Improved overall readability by cleaning up unused variables and props. * fix(ui): add msg-meta CSS and fix rebase type errors * fix(ui): add CSS for chat footer action buttons (TTS, delete) and msg-meta * feat(ui): add delete confirmation with remember-decision checkbox * fix(ui): delete confirmation with remember, attention icon sizing * fix(ui): open delete confirm popover to the left (not clipped) * fix(ui): show all nav items in collapsed sidebar, remove gap * fix(ui): address P1/P2 review feedback — session queue clear, kill scope, palette guard, stop button * fix(ui): address Greptile re-review — kill scope, queue flush, idle handling, parallel fetch - SECURITY: /kill now enforces session tree scope (not just /kill all) - /kill reports idle sessions gracefully instead of throwing - Queue continues draining after local slash commands - /model fetches sessions.list + models.list in parallel (perf fix) * fix(ui): style update banner close button — SVG stroke + sizing * fix(ui): update layout styles for sidebar and content spacing * UI: restore colon slash command parsing * UI: restore slash command session queries * Refactor thinking resolution: Introduce resolveThinkingDefaultForModel function and update model-selection to utilize it. Add tests for new functionality in thinking.test.ts. * fix(ui): constrain welcome state logo size, add missing CSS for new session view --------- Co-authored-by: Vincent Koc --- .gitignore | 8 + src/agents/model-selection.ts | 26 +- src/auto-reply/thinking.test.ts | 35 + src/auto-reply/thinking.ts | 34 + src/config/zod-schema.providers-core.ts | 4 +- src/gateway/server-methods/config.ts | 16 + ui/src/i18n/locales/en.ts | 7 +- ui/src/i18n/locales/pt-BR.ts | 8 +- ui/src/i18n/locales/zh-CN.ts | 6 - ui/src/i18n/locales/zh-TW.ts | 6 - ui/src/styles.css | 1 + ui/src/styles/base.css | 296 ++- ui/src/styles/chat/grouped.css | 196 +- ui/src/styles/chat/layout.css | 470 +++- ui/src/styles/chat/text.css | 12 +- ui/src/styles/chat/tool-cards.css | 271 ++- ui/src/styles/components.css | 1379 ++++++++++- ui/src/styles/config.css | 985 ++++---- ui/src/styles/layout.css | 574 ++++- ui/src/styles/layout.mobile.css | 163 +- ui/src/ui/app-chat.ts | 152 +- ui/src/ui/app-gateway.ts | 20 +- ui/src/ui/app-render.helpers.ts | 461 ++-- ui/src/ui/app-render.ts | 2120 +++++++++++------ ui/src/ui/app-settings.ts | 289 ++- ui/src/ui/app.ts | 99 +- ui/src/ui/chat/attachment-support.ts | 5 + ui/src/ui/chat/deleted-messages.ts | 6 +- ui/src/ui/chat/export.ts | 72 +- ui/src/ui/chat/grouped-render.ts | 521 +++- ui/src/ui/chat/pinned-messages.ts | 6 +- ui/src/ui/chat/pinned-summary.ts | 5 + ui/src/ui/chat/search-match.ts | 10 + ui/src/ui/chat/session-cache.ts | 26 + ui/src/ui/chat/slash-command-executor.ts | 8 +- ui/src/ui/chat/slash-commands.ts | 18 +- ui/src/ui/controllers/agents.ts | 32 +- ui/src/ui/controllers/config.ts | 28 +- ui/src/ui/controllers/health.ts | 62 + ui/src/ui/controllers/models.ts | 18 + ui/src/ui/markdown.ts | 60 +- ui/src/ui/navigation.ts | 25 +- ui/src/ui/storage.ts | 59 +- ui/src/ui/theme.ts | 37 +- ui/src/ui/types.ts | 54 +- ui/src/ui/ui-types.ts | 2 + ui/src/ui/views/agents-panels-overview.ts | 195 ++ ui/src/ui/views/agents-panels-status-files.ts | 75 +- ui/src/ui/views/agents-panels-tools-skills.ts | 145 +- ui/src/ui/views/agents-utils.ts | 191 +- ui/src/ui/views/agents.ts | 502 ++-- ui/src/ui/views/bottom-tabs.ts | 33 + ui/src/ui/views/chat.ts | 1046 +++++++- ui/src/ui/views/command-palette.ts | 263 ++ ui/src/ui/views/config-form.analyze.ts | 14 +- ui/src/ui/views/config-form.node.ts | 312 ++- ui/src/ui/views/config-form.render.ts | 9 + ui/src/ui/views/config-form.shared.ts | 109 +- ui/src/ui/views/config.ts | 915 ++++--- ui/src/ui/views/cron.ts | 6 +- ui/src/ui/views/debug.ts | 2 +- ui/src/ui/views/instances.ts | 42 +- ui/src/ui/views/login-gate.ts | 132 + ui/src/ui/views/overview-attention.ts | 61 + ui/src/ui/views/overview-cards.ts | 162 ++ ui/src/ui/views/overview-event-log.ts | 42 + ui/src/ui/views/overview-hints.ts | 67 + ui/src/ui/views/overview-log-tail.ts | 44 + ui/src/ui/views/overview-quick-actions.ts | 31 + ui/src/ui/views/overview.ts | 246 +- ui/src/ui/views/sessions.ts | 343 ++- ui/src/ui/views/skills.ts | 25 +- ui/vite.config.ts | 18 + 73 files changed, 10610 insertions(+), 3112 deletions(-) create mode 100644 ui/src/ui/chat/attachment-support.ts create mode 100644 ui/src/ui/chat/pinned-summary.ts create mode 100644 ui/src/ui/chat/search-match.ts create mode 100644 ui/src/ui/chat/session-cache.ts create mode 100644 ui/src/ui/controllers/health.ts create mode 100644 ui/src/ui/controllers/models.ts create mode 100644 ui/src/ui/views/agents-panels-overview.ts create mode 100644 ui/src/ui/views/bottom-tabs.ts create mode 100644 ui/src/ui/views/command-palette.ts create mode 100644 ui/src/ui/views/login-gate.ts create mode 100644 ui/src/ui/views/overview-attention.ts create mode 100644 ui/src/ui/views/overview-cards.ts create mode 100644 ui/src/ui/views/overview-event-log.ts create mode 100644 ui/src/ui/views/overview-log-tail.ts create mode 100644 ui/src/ui/views/overview-quick-actions.ts diff --git a/.gitignore b/.gitignore index 4defa8acb..4f8abcaa9 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/src/agents/model-selection.ts b/src/agents/model-selection.ts index 3318a1159..7bbd8ed8b 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/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 359082c26..d4814a263 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 0a0f87c16..faaf5e39b 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/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d68ac6375..2b2fccee3 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/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 1d3d1c859..6e6cf9e92 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/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index cd2739658..634647bfe 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 f656793e7..39df62971 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 ef3cd77ae..804787948 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 580f8a3de..b3d4b9705 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 16b327f3a..80ddd985e 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 ffef3f69a..3d1d77435 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 c43743267..5b4606ade 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,52 @@ 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 +129,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 +175,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 +292,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 +307,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 +346,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 25fa6742b..6a16c013e 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,117 @@ 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 6598af7a0..56224fabf 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 6384db115..2115c8387 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 126972ca0..d1dc29ca0 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 f33c05f94..c05bdcbe9 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 b939c27c2..e25edce48 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 { @@ -64,7 +65,7 @@ padding-top: 0; } -.shell--chat-focus .content > * + * { +.shell--chat-focus .content>*+* { margin-top: 0; } @@ -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,37 +714,102 @@ } .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; overflow-x: hidden; } -.content > * + * { - margin-top: 24px; +.content>*+* { + margin-top: 20px; } -:root[data-theme="light"] .content { +:root[data-theme-mode="light"] .content { background: var(--bg-content); } @@ -443,7 +821,7 @@ padding-bottom: 0; } -.content--chat > * + * { +.content--chat>*+* { margin-top: 0; } @@ -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 { @@ -501,7 +879,7 @@ gap: 16px; } -.content--chat .content-header > div:first-child { +.content--chat .content-header>div:first-child { text-align: left; } @@ -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 450a83608..b871fe1d4 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 1e824fb4f..791bdd639 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 e5285bab9..ee761fe85 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 0678706cd..0a2003fac 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 1214bcc93..1b5390adc 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 556b1c982..45b39e5a7 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 891190d9a..63917b0f7 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 000000000..b8dfbebf3 --- /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.ts b/ui/src/ui/views/chat.ts index 516042c27..db0b92432 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 000000000..ec79f0228 --- /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 05c3bb5f1..82071bb4f 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 bd02be896..e7758e1c2 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 124ca50a5..07d78963d 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 366671041..b535c49e2 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.ts b/ui/src/ui/views/config.ts index 5fa88c53a..aede197a7 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 296a692d1..836b72dbb 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 3379e8813..f63e9be82 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 df5fe5fd4..9648c7a45 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 000000000..d63a12c04 --- /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 000000000..8e09ce1c1 --- /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 000000000..61e98e947 --- /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 000000000..04079f524 --- /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 9db33a2b5..fa6610164 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 000000000..8be2aa9d5 --- /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 000000000..b1358ca2e --- /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 6ebcb884f..ed8ef6fb7 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.ts b/ui/src/ui/views/sessions.ts index 6f0332f62..bb1bef96d 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 830f97921..ad0f4ee63 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.
-
-
-