From 7afd04dc8a413fabf9211be2010a00e14d14a209 Mon Sep 17 00:00:00 2001 From: zhangjianan Date: Sat, 9 May 2026 01:09:40 +0800 Subject: [PATCH 1/3] fix(session) + feat(model+routing+market+observability): session-switch race + branch snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Session-switch fix (this session's focused work) 修复"会话切换混乱 + conversation `` not found": - **后端 `New` 立即落盘** (crates/harness-server/src/routes.rs):消除新会话未到 `Done` 就被遗弃时的 404 链路。 - **后端 Resume 加载错误归一为 not-found 信号** (routes.rs):损坏 JSON / IO hiccup 走与 `Ok(None)` 一致的清理路径。 - **前端 `setActiveId` 前移** (services/conversations.ts):消除 `sendFrame` 与 `setActiveId` 之间的微秒级竞态窗口;导出 `clearSessionRoute`。 - **前端 `handleScopedFrame` 不再翻 `activeId`** (services/frames.ts):流式后台帧每秒十几条不再让全局订阅者看到 activeId 抖动;后台帧不再广播给 legacyDispatchFrame。 - **前端识别 not-found 错误并清理陈旧行** (services/frames/lifecycleFrames.ts):从 `convoRows` 移除幽灵 id、清掉 surface 缓存,必要时重置 activeId。 - **回归测试** (services/frames.test.ts):4 个新增用例锁住 not-found cleanup + 后台帧不翻 activeId 的不变量。 设计文档:详见 plan 文件(不入库)。 ## 同分支 snapshot(先前其他工作的批量提交) 把 feat/model 分支上累积的其他在产工作一并入库: - model registry / routing policy / capability validating provider profile (harness-llm + harness-server) - fallback events + UI banner + slice - market + tools + customize page + routing settings section - subagent runs rail + REST routes + batch - harness-tools/doc.rs 与 harness-skill 默认 doc skill 扩展 - observability (OTel) infra + docs + observability_routes 大改 - HarnessObservabilityPanel / HealthCenter / KpiStrip / WorkOverview UI 重做 - jarvis-cli 增加 telemetry + web 子命令 - 服务端 chat_runs / mcp_routes / state / market_routes / route_policy / subagent_runs* 等扩展 ## 验证 - cargo test -p harness-server: 268/268 ✅ - vitest: 310/310 ✅(含 4 个新 not-found / scoped-frame 回归用例) - cargo clippy -p harness-server -- -D warnings: 通过 - 触动文件 ESLint: 0 errors - 实测:WS `new` 立即关闭后 REST GET 200(pre-fix 是 404);resume 不存在 id 后端 `error: conversation \`\` not found` Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 2 +- Cargo.lock | 276 ++- Cargo.toml | 6 + DEPLOYMENT.md | 2 +- README.md | 2 +- README.zh-CN.md | 1 + apps/jarvis-cli/Cargo.toml | 4 + apps/jarvis-cli/src/main.rs | 12 +- apps/jarvis-cli/src/runner.rs | 15 +- apps/jarvis-cli/src/telemetry.rs | 146 ++ apps/jarvis-cli/src/web.rs | 12 +- apps/jarvis-web/src/App.tsx | 72 +- .../jarvis-web/src/components/AppChatPane.tsx | 2 + apps/jarvis-web/src/components/AppSidebar.tsx | 60 +- .../Chat/toolRenderers/index.test.tsx | 56 + .../components/Chat/toolRenderers/index.tsx | 171 ++ .../components/Chat/toolStepSummary.test.ts | 24 + .../src/components/Chat/toolStepSummary.ts | 84 +- .../src/components/Chat/toolSummaries.test.ts | 34 +- .../src/components/Chat/toolSummaries.ts | 66 + .../ConversationsArchivePage.tsx | 39 +- .../components/Customize/CustomizePage.tsx | 552 +++++ .../src/components/Customize/MarketPanels.tsx | 295 +++ .../Diagnostics/ExceptionsPanel.tsx | 1 - .../src/components/Docs/DocsPage.tsx | 72 +- .../components/Docs/Editor/BlockEditor.tsx | 237 +- .../src/components/Docs/Editor/editor.css | 69 + .../src/components/FallbackBanner.tsx | 60 + .../src/components/ModelMenu/ModelMenu.tsx | 41 +- .../src/components/Projects/ProjectBoard.tsx | 37 +- .../src/components/Projects/ProjectsPage.tsx | 1 - .../components/Projects/RequirementDetail.tsx | 8 + .../WorkOverview/HarnessEvolutionPanel.tsx | 267 --- .../HarnessObservabilityPanel.tsx | 312 ++- .../Projects/WorkOverview/HealthCenter.tsx | 291 ++- .../Projects/WorkOverview/KpiStrip.tsx | 34 +- .../WorkOverview/OperationalPanel.tsx | 3 - .../WorkOverview/SubAgentRunsRail.tsx | 175 ++ .../WorkOverview/WorkOverviewPage.tsx | 113 +- .../src/components/Settings/SettingsPage.tsx | 16 + .../Settings/sections/McpSection.tsx | 83 +- .../Settings/sections/PluginsSection.tsx | 45 +- .../Settings/sections/ProvidersSection.tsx | 169 +- .../Settings/sections/RoutingSection.tsx | 315 +++ .../Settings/sections/SkillsSection.tsx | 10 +- .../Settings/sections/ToolsSection.tsx | 281 +++ apps/jarvis-web/src/services/chatRuns.ts | 4 + .../src/services/conversationSockets.ts | 4 + apps/jarvis-web/src/services/conversations.ts | 67 +- apps/jarvis-web/src/services/frames.test.ts | 80 +- apps/jarvis-web/src/services/frames.ts | 12 +- .../src/services/frames/fallbackFrames.ts | 35 + apps/jarvis-web/src/services/frames/index.ts | 2 + .../src/services/frames/lifecycleFrames.ts | 26 +- .../src/services/issueAggregator.ts | 2 +- apps/jarvis-web/src/services/market.ts | 121 ++ apps/jarvis-web/src/services/mcp.ts | 14 + apps/jarvis-web/src/services/providerAdmin.ts | 28 + apps/jarvis-web/src/services/routing.ts | 103 + apps/jarvis-web/src/services/subagentRuns.ts | 81 + apps/jarvis-web/src/services/tools.ts | 122 ++ apps/jarvis-web/src/services/workOverview.ts | 104 + apps/jarvis-web/src/store/appStore.test.ts | 28 +- apps/jarvis-web/src/store/appStore.ts | 3 + apps/jarvis-web/src/store/slices/chatSlice.ts | 13 +- .../src/store/slices/fallbackSlice.ts | 67 + apps/jarvis-web/src/store/slices/toolSlice.ts | 31 +- apps/jarvis-web/src/store/types.ts | 31 +- apps/jarvis-web/src/styles.css | 1935 +++++++++++++++-- apps/jarvis-web/src/utils/i18n.ts | 745 +++++-- apps/jarvis/Cargo.toml | 4 + apps/jarvis/src/config.rs | 45 + apps/jarvis/src/init.rs | 1 + apps/jarvis/src/main.rs | 10 +- apps/jarvis/src/serve.rs | 1032 ++++++--- apps/jarvis/src/subagents.rs | 120 +- apps/jarvis/src/telemetry.rs | 201 ++ crates/harness-cloud/src/envelope.rs | 31 +- crates/harness-cloud/src/loopback.rs | 6 +- crates/harness-cloud/src/node_registry.rs | 39 +- crates/harness-cloud/src/protocol.rs | 23 +- crates/harness-cloud/src/websocket.rs | 17 +- crates/harness-core/src/agent.rs | 1293 +++++++++-- crates/harness-core/src/fallback_event.rs | 184 ++ crates/harness-core/src/lib.rs | 18 +- crates/harness-core/src/llm.rs | 14 + crates/harness-core/src/observability.rs | 82 + crates/harness-core/src/subagent.rs | 8 + crates/harness-core/src/tool.rs | 164 +- crates/harness-core/src/tool_metadata.rs | 399 ++++ crates/harness-llm/src/anthropic.rs | 34 +- .../harness-llm/src/capability_validating.rs | 275 +++ crates/harness-llm/src/fallback.rs | 433 ++++ crates/harness-llm/src/google.rs | 24 +- crates/harness-llm/src/lib.rs | 11 + crates/harness-llm/src/openai.rs | 135 +- crates/harness-llm/src/profile.rs | 1136 ++++++++++ crates/harness-llm/src/responses.rs | 40 +- crates/harness-mcp/src/lib.rs | 2 +- crates/harness-mcp/src/manager.rs | 167 +- crates/harness-memory/src/lib.rs | 2 +- crates/harness-memory/src/summarizing.rs | 50 +- crates/harness-server/Cargo.toml | 4 + crates/harness-server/src/auto_mode.rs | 443 +++- crates/harness-server/src/chat_runs.rs | 43 + crates/harness-server/src/lib.rs | 8 +- crates/harness-server/src/market_routes.rs | 484 +++++ crates/harness-server/src/mcp_routes.rs | 29 + .../src/observability_routes.rs | 1101 +++++++++- .../harness-server/src/provider_registry.rs | 96 +- crates/harness-server/src/route_policy.rs | 255 +++ crates/harness-server/src/routes.rs | 1434 +++++++++++- crates/harness-server/src/state.rs | 81 + crates/harness-server/src/subagent_runs.rs | 571 +++++ .../src/subagent_runs_routes.rs | 248 +++ .../harness-server/tests/spec_to_done_e2e.rs | 2 +- .../assets/defaults/doc/SKILL.md | 23 +- crates/harness-skill/src/selector.rs | 20 + crates/harness-store/src/json_file.rs | 28 + crates/harness-store/tests/connect.rs | 1 + crates/harness-subagents/src/batch.rs | 623 ++++++ crates/harness-subagents/src/internal.rs | 81 + crates/harness-subagents/src/lib.rs | 2 + crates/harness-subagents/src/tool_adapter.rs | 28 +- crates/harness-tools/src/doc.rs | 508 ++++- crates/harness-tools/src/harness_health.rs | 1096 ++++++++++ crates/harness-tools/src/lib.rs | 8 +- docs/observability/local-stack.md | 64 + docs/proposals/README.md | 1 + .../model-tool-compatibility.zh-CN.md | 726 +++++++ .../otel-native-eval-harness.zh-CN.md | 40 + docs/user-guide.md | 12 +- infra/otel/collector.yaml | 29 + infra/otel/docker-compose.yml | 21 + 134 files changed, 20755 insertions(+), 1586 deletions(-) create mode 100644 apps/jarvis-cli/src/telemetry.rs create mode 100644 apps/jarvis-web/src/components/Chat/toolRenderers/index.test.tsx create mode 100644 apps/jarvis-web/src/components/Customize/CustomizePage.tsx create mode 100644 apps/jarvis-web/src/components/Customize/MarketPanels.tsx create mode 100644 apps/jarvis-web/src/components/FallbackBanner.tsx delete mode 100644 apps/jarvis-web/src/components/Projects/WorkOverview/HarnessEvolutionPanel.tsx create mode 100644 apps/jarvis-web/src/components/Projects/WorkOverview/SubAgentRunsRail.tsx create mode 100644 apps/jarvis-web/src/components/Settings/sections/RoutingSection.tsx create mode 100644 apps/jarvis-web/src/components/Settings/sections/ToolsSection.tsx create mode 100644 apps/jarvis-web/src/services/frames/fallbackFrames.ts create mode 100644 apps/jarvis-web/src/services/market.ts create mode 100644 apps/jarvis-web/src/services/routing.ts create mode 100644 apps/jarvis-web/src/services/subagentRuns.ts create mode 100644 apps/jarvis-web/src/services/tools.ts create mode 100644 apps/jarvis-web/src/store/slices/fallbackSlice.ts create mode 100644 apps/jarvis/src/telemetry.rs create mode 100644 crates/harness-core/src/fallback_event.rs create mode 100644 crates/harness-core/src/tool_metadata.rs create mode 100644 crates/harness-llm/src/capability_validating.rs create mode 100644 crates/harness-llm/src/fallback.rs create mode 100644 crates/harness-llm/src/profile.rs create mode 100644 crates/harness-server/src/market_routes.rs create mode 100644 crates/harness-server/src/route_policy.rs create mode 100644 crates/harness-server/src/subagent_runs.rs create mode 100644 crates/harness-server/src/subagent_runs_routes.rs create mode 100644 crates/harness-subagents/src/batch.rs create mode 100644 crates/harness-tools/src/harness_health.rs create mode 100644 docs/observability/local-stack.md create mode 100644 docs/proposals/model-tool-compatibility.zh-CN.md create mode 100644 infra/otel/collector.yaml create mode 100644 infra/otel/docker-compose.yml diff --git a/.env.example b/.env.example index f16bff4..27f3414 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ JARVIS_PERMISSION_MODE=ask # JARVIS_WORK_MODE=auto # JARVIS_WORK_TICK_SECONDS=30 # JARVIS_WORK_MAX_UNITS_PER_TICK=1 -# JARVIS_WORK_RUN_TIMEOUT_MS=300000 +# JARVIS_WORK_RUN_TIMEOUT_MS=600000 # --- Logging --------------------------------------------------------------- # Lift to `debug` when diagnosing tick decisions, approvals, etc. diff --git a/Cargo.lock b/Cargo.lock index d7a3250..3db2faf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,7 +387,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite 0.24.0", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -1943,6 +1943,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "harness-cloud" version = "0.1.0" @@ -2072,6 +2091,7 @@ dependencies = [ "futures", "harness-cloud", "harness-core", + "harness-llm", "harness-mcp", "harness-plugin", "harness-requirement", @@ -2079,6 +2099,7 @@ dependencies = [ "harness-store", "include_dir", "portable-pty", + "reqwest 0.12.28", "serde", "serde_json", "serde_yaml", @@ -2086,7 +2107,9 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-stream", - "tower", + "tokio-util", + "tower 0.5.3", + "tower-http", "tracing", "uuid", ] @@ -2304,6 +2327,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2331,6 +2355,19 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2348,7 +2385,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2611,6 +2648,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2638,6 +2684,9 @@ dependencies = [ "harness-subagents", "harness-tools", "open", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -2647,6 +2696,7 @@ dependencies = [ "tokio", "toml 0.8.23", "tracing", + "tracing-opentelemetry", "tracing-subscriber", ] @@ -2662,9 +2712,13 @@ dependencies = [ "harness-memory", "harness-store", "harness-tools", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "serde_json", "tokio", "tracing", + "tracing-opentelemetry", "tracing-subscriber", ] @@ -3384,6 +3438,87 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "opentelemetry" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab70038c28ed37b97d8ed414b6429d343a8bbf44c9f79ec854f3a643029ba6d7" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest 0.12.28", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" +dependencies = [ + "async-trait", + "futures-core", + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest 0.12.28", + "thiserror 1.0.69", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e05acbfada5ec79023c85368af14abd0b307c015e9064d249b2a950ef459a6" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231e9d6ceef9b0b2546ddf52335785ce41252bc7474ee8ba05bfad277be13ab8" +dependencies = [ + "async-trait", + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "opentelemetry", + "percent-encoding", + "rand 0.8.6", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3576,6 +3711,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3821,6 +3976,29 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quick-xml" version = "0.39.3" @@ -3843,7 +4021,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -3880,7 +4058,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -4057,6 +4235,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -4079,7 +4258,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -4114,7 +4293,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -4718,6 +4897,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -5526,7 +5715,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -5754,6 +5943,56 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.6", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -5783,9 +6022,10 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5844,6 +6084,24 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a971f6058498b5c0f1affa23e7ea202057a7301dbff68e968b2d578bcbd053" +dependencies = [ + "js-sys", + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" diff --git a/Cargo.toml b/Cargo.toml index ac08221..3cad2b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ harness-cloud = { path = "crates/harness-cloud" } harness-tools = { path = "crates/harness-tools" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "fs", "process"] } +tokio-util = "0.7" async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -47,8 +48,13 @@ anyhow = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } axum = { version = "0.7", features = ["ws"] } tower = "0.5" +tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +opentelemetry = "0.27" +opentelemetry_sdk = { version = "0.27", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.27", features = ["grpc-tonic", "http-proto", "reqwest-client"] } +tracing-opentelemetry = "0.28" futures = "0.3" async-stream = "0.3" tokio-stream = { version = "0.1", features = ["sync"] } diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 838f14c..41f02e3 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -176,7 +176,7 @@ The full list is in [CLAUDE.md](CLAUDE.md). What matters for ops: | `JARVIS_WORK_TICK_SECONDS` | `30` | Scheduler tick interval | | `JARVIS_WORK_MAX_UNITS_PER_TICK` | `1` | Concurrency cap per tick | | `JARVIS_WORK_MAX_RETRIES` | `1` | Retry ceiling per requirement | -| `JARVIS_WORK_RUN_TIMEOUT_MS` | `300000` | Wall-clock budget per agent run; the watchdog reaps stuck Pending after this and stuck Running after `× 3` | +| `JARVIS_WORK_RUN_TIMEOUT_MS` | `600000` | Wall-clock budget per agent run; the watchdog reaps stuck Pending after this and stuck Running after `× 3` | | `JARVIS_WORKTREE_MODE` | `off` (auto upgrades to `per_run`) | `off` / `per_run` | | `JARVIS_WORKTREE_ROOT` | `.jarvis/worktrees` | Where per-run git worktrees live | diff --git a/README.md b/README.md index 916b763..870b3bb 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ Jarvis ships with a namespaced toolset: - `requirement.{list,create,update,delete,start,complete,block}` — kanban row CRUD; agent-created rows default to `triage_state=proposed_by_agent` and wait for human approval - `triage.scan_candidates` — surface follow-up Requirement candidates from `TODO|FIXME|XXX|HACK` markers (more sources planned) - `roadmap.import` — bootstrap a project + Requirements from `docs/proposals/`, `docs/roadmap/`, or `ROADMAP.md` -- `doc.{list,get,create,update,delete,draft.{get,save}}` — long-form document CRUD +- `doc.{list,search,get,upsert,create,update,delete,draft.{get,save}}` — long-form document CRUD; `search` + `upsert` are tuned for natural-language doc workflows - `codex.run`, `claude_code.run` — opt-in sub-agent runners Mutation tools are opt-in and approval-aware. The binary composition root decides which tools are registered; `harness-core` only sees the `ToolRegistry`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 7c05fe8..1e81b23 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -191,6 +191,7 @@ Jarvis 自带命名空间化工具集: - `shell.exec` - `git.status`、`git.diff`、`git.log`、`git.show` - `workspace.context` +- `doc.{list,search,get,upsert,create,update,delete,draft.{get,save}}`:长文档 CRUD;`search` + `upsert` 面向自然语言查找和创建/更新 - 计划、审批和用户输入辅助工具 修改类工具默认需要显式启用,并会进入审批体系。binary composition root 决定注册哪些工具;`harness-core` 只知道 `ToolRegistry`。 diff --git a/apps/jarvis-cli/Cargo.toml b/apps/jarvis-cli/Cargo.toml index 8fc5437..2da853b 100644 --- a/apps/jarvis-cli/Cargo.toml +++ b/apps/jarvis-cli/Cargo.toml @@ -21,6 +21,10 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "io futures.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +opentelemetry.workspace = true +opentelemetry_sdk.workspace = true +opentelemetry-otlp.workspace = true +tracing-opentelemetry.workspace = true anyhow.workspace = true clap.workspace = true serde_json.workspace = true diff --git a/apps/jarvis-cli/src/main.rs b/apps/jarvis-cli/src/main.rs index 0fb625b..282d668 100644 --- a/apps/jarvis-cli/src/main.rs +++ b/apps/jarvis-cli/src/main.rs @@ -25,12 +25,12 @@ mod provider; mod render; mod runner; mod web; +mod telemetry; use std::path::PathBuf; use anyhow::Result; use clap::Parser; -use tracing_subscriber::EnvFilter; #[derive(Parser, Debug)] #[command( @@ -151,12 +151,10 @@ pub struct Args { #[tokio::main] async fn main() -> Result<()> { // tracing → stderr so streamed assistant text on stdout stays - // pipe-clean. RUST_LOG=info or higher keeps the terminal quiet - // by default; set RUST_LOG=debug for tool-level logs. - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into())) - .with_writer(std::io::stderr) - .init(); + // pipe-clean. Default baseline is `warn`; bumped to `info` + // automatically when OTel export is enabled so the instrumented + // spans actually fire. Set RUST_LOG explicitly to override. + let _telemetry_guard = telemetry::init(); let args = Args::parse(); diff --git a/apps/jarvis-cli/src/runner.rs b/apps/jarvis-cli/src/runner.rs index 802250c..e8baa0a 100644 --- a/apps/jarvis-cli/src/runner.rs +++ b/apps/jarvis-cli/src/runner.rs @@ -393,7 +393,10 @@ pub async fn run_repl(mut args: Args, workspace: PathBuf) -> Result<()> { continue; } args.model = Some(model.to_string()); - println!("{}", dim(&format!("(model set to `{model}` for next turn)"))); + println!( + "{}", + dim(&format!("(model set to `{model}` for next turn)")) + ); continue; } @@ -602,6 +605,16 @@ async fn run_one_turn( if delta_open { println!(); } return TurnOutcome::Done(conversation); } + AgentEvent::ProviderFallback { event } => { + if delta_open { println!(); delta_open = false; } + eprintln!( + "{} provider fallback: {} → {} ({})", + yellow("↻"), + event.from, + event.to, + event.reason, + ); + } AgentEvent::Error { message } => { if delta_open { println!(); } return TurnOutcome::Error(message); diff --git a/apps/jarvis-cli/src/telemetry.rs b/apps/jarvis-cli/src/telemetry.rs new file mode 100644 index 0000000..e7b9fd4 --- /dev/null +++ b/apps/jarvis-cli/src/telemetry.rs @@ -0,0 +1,146 @@ +//! Tracing subscriber + optional OTLP exporter init for `jarvis-cli`. +//! +//! Mirrors `apps/jarvis/src/telemetry.rs` so both binaries pick up the +//! same `JARVIS_OTEL_*` env surface. Kept as a copy (not a shared +//! crate) per the Phase 1 plan — ~140 lines is cheaper than a new +//! crate boundary. +//! +//! The CLI defaults its `EnvFilter` baseline to `warn` (vs the +//! server's `info`) so streamed assistant text on stdout stays +//! pipe-clean. The baseline is bumped to `info` automatically when +//! OTel is enabled — otherwise the exporter would have nothing to +//! send. + +use opentelemetry::trace::TracerProvider as _; +use opentelemetry::KeyValue; +use opentelemetry_otlp::{Protocol, WithExportConfig}; +use opentelemetry_sdk::trace::{Sampler, TracerProvider}; +use opentelemetry_sdk::Resource; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; + +pub struct TelemetryGuard { + provider: Option, +} + +impl Drop for TelemetryGuard { + fn drop(&mut self) { + if let Some(provider) = self.provider.take() { + let _ = provider.shutdown(); + } + } +} + +fn env_flag(key: &str) -> bool { + match std::env::var(key) { + Ok(v) => !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"), + Err(_) => false, + } +} + +pub fn init() -> TelemetryGuard { + let enabled = env_flag("JARVIS_OTEL_ENABLED"); + let baseline = if std::env::var("RUST_LOG").is_err() && enabled { + "info" + } else { + "warn" + }; + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(baseline)); + let fmt_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr); + + if !enabled { + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .init(); + return TelemetryGuard { provider: None }; + } + + let endpoint = std::env::var("JARVIS_OTEL_ENDPOINT") + .unwrap_or_else(|_| "http://127.0.0.1:4317".to_string()); + let protocol = std::env::var("JARVIS_OTEL_PROTOCOL").unwrap_or_else(|_| "grpc".to_string()); + let service_name = + std::env::var("JARVIS_OTEL_SERVICE_NAME").unwrap_or_else(|_| "jarvis-cli".to_string()); + let service_env = + std::env::var("JARVIS_OTEL_ENV").unwrap_or_else(|_| "local".to_string()); + let sample_ratio: f64 = std::env::var("JARVIS_OTEL_SAMPLE_RATIO") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1.0); + + let resource = Resource::new(vec![ + KeyValue::new("service.name", service_name.clone()), + KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), + KeyValue::new("deployment.environment.name", service_env.clone()), + ]); + + let provider_result = if protocol.eq_ignore_ascii_case("http") + || protocol.eq_ignore_ascii_case("http/protobuf") + { + opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_endpoint(&endpoint) + .with_protocol(Protocol::HttpBinary) + .build() + .map(|exporter| { + TracerProvider::builder() + .with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio) + .with_resource(resource.clone()) + .with_sampler(Sampler::TraceIdRatioBased(sample_ratio.clamp(0.0, 1.0))) + .build() + }) + } else { + opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(&endpoint) + .build() + .map(|exporter| { + TracerProvider::builder() + .with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio) + .with_resource(resource.clone()) + .with_sampler(Sampler::TraceIdRatioBased(sample_ratio.clamp(0.0, 1.0))) + .build() + }) + }; + + let provider = match provider_result { + Ok(p) => p, + Err(err) => { + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .init(); + tracing::warn!( + error = %err, + endpoint = %endpoint, + "OTLP exporter build failed; continuing with fmt logger only" + ); + return TelemetryGuard { provider: None }; + } + }; + + let tracer = provider.tracer("jarvis-cli"); + let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); + + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .with(otel_layer) + .init(); + + opentelemetry::global::set_tracer_provider(provider.clone()); + + tracing::info!( + endpoint = %endpoint, + protocol = %protocol, + service_name = %service_name, + sample_ratio, + "OTLP tracing exporter installed" + ); + + TelemetryGuard { + provider: Some(provider), + } +} diff --git a/apps/jarvis-cli/src/web.rs b/apps/jarvis-cli/src/web.rs index be6eb20..d357432 100644 --- a/apps/jarvis-cli/src/web.rs +++ b/apps/jarvis-cli/src/web.rs @@ -49,7 +49,11 @@ fn locate_jarvis_binary() -> PathBuf { // and this picks the matching one. if let Ok(self_exe) = std::env::current_exe() { if let Some(parent) = self_exe.parent() { - let bin_name = if cfg!(windows) { "jarvis.exe" } else { "jarvis" }; + let bin_name = if cfg!(windows) { + "jarvis.exe" + } else { + "jarvis" + }; let candidate = parent.join(bin_name); if candidate.is_file() { return candidate; @@ -57,7 +61,11 @@ fn locate_jarvis_binary() -> PathBuf { } } // 3. Bare name — let the OS search PATH. - PathBuf::from(if cfg!(windows) { "jarvis.exe" } else { "jarvis" }) + PathBuf::from(if cfg!(windows) { + "jarvis.exe" + } else { + "jarvis" + }) } /// Spawn `jarvis serve --workspace ` as a background child diff --git a/apps/jarvis-web/src/App.tsx b/apps/jarvis-web/src/App.tsx index defe0d1..e7349e9 100644 --- a/apps/jarvis-web/src/App.tsx +++ b/apps/jarvis-web/src/App.tsx @@ -5,20 +5,28 @@ // // Routing: react-router-dom (`BrowserRouter`) wraps the tree so the // app can host multiple pages at the server root (`/`, `/settings`, -// future `/conversations/:id`) without the URL gaining a `/ui/` +// `/sessions/:id`) without the URL gaining a `/ui/` // prefix. The Rust side serves `index.html` for any extension-less // path (see `crates/harness-server/src/ui.rs::spa_fallback`), so // reloading on `/settings` works like a real route, not just a // hash-based shim. import { useEffect } from "react"; -import { BrowserRouter, HashRouter, Route, Routes, Navigate } from "react-router-dom"; +import { + BrowserRouter, + HashRouter, + Navigate, + Route, + Routes, + useParams, +} from "react-router-dom"; import { AppSidebar } from "./components/AppSidebar"; import { AppChatPane } from "./components/AppChatPane"; import { AppWorkspaceRail } from "./components/AppWorkspaceRail"; import { AppApprovalsRail } from "./components/AppApprovalsRail"; import { QuickSwitcher } from "./components/QuickSwitcher/QuickSwitcher"; import { SettingsPage } from "./components/Settings/SettingsPage"; +import { CustomizePage } from "./components/Customize/CustomizePage"; import { ProjectsPage } from "./components/Projects/ProjectsPage"; import { DocsPage } from "./components/Docs/DocsPage"; import { WorkOverviewPage } from "./components/Projects/WorkOverview/WorkOverviewPage"; @@ -35,6 +43,7 @@ import { boot, applyI18n } from "./services/boot"; import { isDesktopRuntime } from "./services/desktop"; import { useShortcuts } from "./hooks/useShortcuts"; import { showHelpOverlay } from "./services/slash_commands"; +import { newConversation, resumeConversation } from "./services/conversations"; import { loadProviders } from "./services/providers"; import { apiUrl } from "./services/api"; import "./styles.css"; @@ -76,7 +85,8 @@ export function App() { - } /> + } /> + } /> } /> } /> } /> @@ -104,13 +114,15 @@ export function App() { /> } /> {/* `/conversations/:id` resumes the persisted conversation - and redirects to chat. Useful for bookmarks / shared URLs - that should land back in the right thread. */} + and redirects to the stable session URL. Useful for old + bookmarks / shared URLs that should land back in the + right thread. */} } /> } /> + } /> {/* SubAgent UI preview — static prototype with mocked frame data. Reachable directly only; not linked from nav. Will be replaced by the real components consuming WS events @@ -129,7 +141,17 @@ export function App() { /// handles, and the Cmd+P quick switcher. Lives at `/`. Extracted /// from `App` so other routes (Settings, future Conversations /// archive) don't carry the chat-specific chrome. -function ChatLayout() { +function ChatLayout({ newDraftOnMount = false }: { newDraftOnMount?: boolean }) { + useEffect(() => { + if (!newDraftOnMount) return; + const store = appStore.getState(); + newConversation({ + projectId: store.activeProjectFilter ?? store.draftProjectId ?? null, + workspacePath: store.draftWorkspacePath ?? null, + }); + window.setTimeout(() => document.getElementById("input")?.focus(), 0); + }, [newDraftOnMount]); + return ( <> Skip to main content @@ -148,6 +170,15 @@ function ChatLayout() { ); } +function ChatSessionLayout() { + const { id } = useParams<{ id: string }>(); + useEffect(() => { + if (!id) return; + void resumeConversation(id); + }, [id]); + return ; +} + function ProjectsLayout() { return ( <> @@ -208,6 +239,35 @@ function ConversationsArchiveLayout() { ); } +// Customize — unified entry point for Skills / MCP / Plugins. +// Reachable from the chat sidebar under "全部会话". Shares the same +// shell as ProjectsLayout so the global sidebar still anchors the +// page. +function CustomizeLayout() { + return ( + <> + + Skip to main content + +
+ + + + + + ); +} + // Worktree management — sibling to the Auto-mode dashboard under // Work mode. Lists orphan git worktrees + cleanup. function WorktreesLayout() { diff --git a/apps/jarvis-web/src/components/AppChatPane.tsx b/apps/jarvis-web/src/components/AppChatPane.tsx index bfd67f1..f201130 100644 --- a/apps/jarvis-web/src/components/AppChatPane.tsx +++ b/apps/jarvis-web/src/components/AppChatPane.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { Banner } from "./Banner"; import { ChatHeader } from "./ChatHeader"; +import { FallbackBanner } from "./FallbackBanner"; import { Composer } from "./Composer/Composer"; import { MessageList } from "./Chat/MessageList"; import { AskTextCard } from "./Chat/AskTextCard"; @@ -54,6 +55,7 @@ export function AppChatPane() { + diff --git a/apps/jarvis-web/src/components/AppSidebar.tsx b/apps/jarvis-web/src/components/AppSidebar.tsx index 16a0d00..4b42594 100644 --- a/apps/jarvis-web/src/components/AppSidebar.tsx +++ b/apps/jarvis-web/src/components/AppSidebar.tsx @@ -20,6 +20,14 @@ import { } from "react-router-dom"; import { t } from "../utils/i18n"; import { chipColor } from "../utils/chipColor"; + +/// Translate the i18n key when present, fall back to the supplied +/// literal otherwise. Used for sidebar entries whose i18n keys +/// haven't been seeded into every locale yet. +function translateOrFallback(key: string, fallback: string): string { + const v = t(key); + return v === key ? fallback : v; +} import { setDocScope, useDocScope, @@ -163,6 +171,35 @@ function ChatSidebarBody() { {t("sidebarNavConversationsArchive")} + + "nav-item" + (isActive ? " active" : "") + } + > + + {translateOrFallback("sidebarNavCustomize", "能力市场")} + @@ -226,29 +263,6 @@ function WorkSidebarBody() { {t("sidebarNavWorkOverview")} - "nav-item" + (isActive ? " active" : "")} - > - - {t("sidebarNavAutoMode")} - - "nav-item" + (isActive ? " active" : "")} - > - - {t("sidebarNavWorktrees")} - "nav-item" + (isActive ? " active" : "")} diff --git a/apps/jarvis-web/src/components/Chat/toolRenderers/index.test.tsx b/apps/jarvis-web/src/components/Chat/toolRenderers/index.test.tsx new file mode 100644 index 0000000..a97965d --- /dev/null +++ b/apps/jarvis-web/src/components/Chat/toolRenderers/index.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderOutputBody } from "."; + +describe("doc tool renderers", () => { + it("links doc search result titles to the docs page", () => { + render( + <> + {renderOutputBody( + "doc.search", + JSON.stringify({ + count: 1, + items: [ + { + project: { + id: "doc-123", + title: "Launch plan", + kind: "prd", + tags: ["release"], + }, + }, + ], + }), + )} + , + ); + + expect(screen.getByRole("link", { name: "Launch plan" })).toHaveAttribute( + "href", + "/docs/doc-123", + ); + }); + + it("links doc upsert results to the created doc", () => { + render( + <> + {renderOutputBody( + "doc.upsert", + JSON.stringify({ + created: true, + project: { + id: "doc-created", + title: "Research notes", + kind: "note", + }, + }), + )} + , + ); + + expect(screen.getByRole("link", { name: "Research notes" })).toHaveAttribute( + "href", + "/docs/doc-created", + ); + }); +}); diff --git a/apps/jarvis-web/src/components/Chat/toolRenderers/index.tsx b/apps/jarvis-web/src/components/Chat/toolRenderers/index.tsx index c30b9f5..bf33508 100644 --- a/apps/jarvis-web/src/components/Chat/toolRenderers/index.tsx +++ b/apps/jarvis-web/src/components/Chat/toolRenderers/index.tsx @@ -11,6 +11,7 @@ import { FsWriteCard } from "../FsWriteCard"; import { ProjectChecksCard } from "../ProjectChecksCard"; import { UnifiedDiffViewer } from "../UnifiedDiffViewer"; import { WorkspaceContextCard } from "../WorkspaceContextCard"; +import { isDesktopRuntime } from "../../../services/desktop"; import { t } from "../../../utils/i18n"; import { ToolOutput } from "./ToolOutput"; import { safeStringify } from "./util"; @@ -64,5 +65,175 @@ export function renderOutputBody(name: string, output: string): ReactNode { if (name === "project.checks") { return ; } + if (name.startsWith("doc.")) { + return ; + } + return ; +} + +function DocToolCard({ name, output }: { name: string; output: string }) { + const parsed = parseJson(output); + if (parsed === null) { + return ( +
+
{t("docToolNoDraft")}
+
+ ); + } + if (!isRecord(parsed)) { + return ; + } + + if (name === "doc.list" || name === "doc.search") { + const items = Array.isArray(parsed.items) ? parsed.items : []; + const count = typeof parsed.count === "number" ? parsed.count : items.length; + const query = typeof parsed.query === "string" && parsed.query.trim() ? parsed.query.trim() : null; + return ( +
+
+ {name === "doc.search" ? t("docToolSearchResults") : t("docToolDocs")} · {count} +
+ {query ?
“{query}”
: null} +
+ {items.slice(0, 8).map((item, idx) => { + const project = projectFromSearchItem(item); + if (!project) return null; + const key = typeof project.id === "string" ? project.id : idx; + return ; + })} +
+
+ ); + } + + if (name === "doc.get") { + const project = isRecord(parsed.project) ? parsed.project : null; + const draft = isRecord(parsed.draft) ? parsed.draft : null; + return ( +
+ {project ? : null} + {draft ? : null} +
+ ); + } + + if (name === "doc.upsert") { + const project = isRecord(parsed.project) ? parsed.project : null; + const draft = isRecord(parsed.draft) ? parsed.draft : null; + return ( +
+
+ {parsed.created === true ? t("docToolCreatedDoc") : t("docToolUpdatedDoc")} +
+ {project ? : null} + {draft ? : null} +
+ ); + } + + if (name === "doc.create" || name === "doc.update") { + return ( +
+ +
+ ); + } + + if (name === "doc.delete") { + return ( +
+
+ {parsed.deleted === true ? t("docToolDeletedDoc") : t("docToolNotFound")} +
+ {typeof parsed.id === "string" ? ( +
{parsed.id}
+ ) : null} +
+ ); + } + + if (name === "doc.draft.get" || name === "doc.draft.save") { + return ( +
+ +
+ ); + } + return ; } + +function parseJson(output: string): unknown { + const trimmed = output.trim(); + if (trimmed === "null") return null; + try { + return JSON.parse(trimmed); + } catch { + return output; + } +} + +function isRecord(v: unknown): v is Record { + return v != null && typeof v === "object" && !Array.isArray(v); +} + +function projectFromSearchItem(item: unknown): Record | null { + if (!isRecord(item)) return null; + if (isRecord(item.project)) return item.project; + return item; +} + +function DocProjectRow({ project }: { project: Record }) { + const title = typeof project.title === "string" ? project.title : "Untitled"; + const kind = typeof project.kind === "string" ? project.kind : "note"; + const id = typeof project.id === "string" ? project.id : null; + const tags = Array.isArray(project.tags) + ? project.tags.filter((t): t is string => typeof t === "string") + : []; + return ( +
+
+ {kind} + {id ? ( + + {title} + + ) : ( + {title} + )} +
+ {id ?
{id}
: null} + {tags.length > 0 ? ( +
+ {tags.slice(0, 5).map((tag) => ( + {tag} + ))} +
+ ) : null} +
+ ); +} + +function docHref(id: string): string { + const path = `/docs/${encodeURIComponent(id)}`; + return isDesktopRuntime() ? `#${path}` : path; +} + +function DocDraftPreview({ draft }: { draft: Record }) { + const content = typeof draft.content === "string" ? draft.content : ""; + const updatedAt = typeof draft.updated_at === "string" ? draft.updated_at : null; + const preview = content.trim() ? content.trim().slice(0, 520) : "(empty)"; + return ( +
+
+ {t("docToolLatestDraft")} + {updatedAt ? {updatedAt} : null} +
+
{preview}
+
+ ); +} diff --git a/apps/jarvis-web/src/components/Chat/toolStepSummary.test.ts b/apps/jarvis-web/src/components/Chat/toolStepSummary.test.ts index c8172d2..1a6e7d8 100644 --- a/apps/jarvis-web/src/components/Chat/toolStepSummary.test.ts +++ b/apps/jarvis-web/src/components/Chat/toolStepSummary.test.ts @@ -88,6 +88,30 @@ describe("describeStep", () => { ).toBe("Searched `TODO` (2 matches)"); }); + it("describes doc search with query and result count", () => { + expect( + describeStep([ + block({ + name: "doc.search", + args: { query: "roadmap" }, + output: JSON.stringify({ count: 2, items: [] }), + }), + ]), + ).toBe("Searched `roadmap` (2 docs)"); + }); + + it("describes doc upsert as created or updated", () => { + expect( + describeStep([ + block({ + name: "doc.upsert", + args: { title: "Launch plan" }, + output: JSON.stringify({ created: true, project: { title: "Launch plan" } }), + }), + ]), + ).toBe("Updated Launch plan (created)"); + }); + it("joins multiple verb groups with commas (Claude Code pattern)", () => { // First phrase keeps its capitalised verb; second-onwards // get lowercased so the whole row reads as one sentence. diff --git a/apps/jarvis-web/src/components/Chat/toolStepSummary.ts b/apps/jarvis-web/src/components/Chat/toolStepSummary.ts index 6e84370..badab69 100644 --- a/apps/jarvis-web/src/components/Chat/toolStepSummary.ts +++ b/apps/jarvis-web/src/components/Chat/toolStepSummary.ts @@ -41,6 +41,15 @@ const VERB_TABLE: Record = { "project.checks": { verb: "Suggested", noun: "check", nounPlural: "checks" }, "plan.update": { verb: "Updated", noun: "plan", nounPlural: "plan" }, "exit_plan": { verb: "Proposed", noun: "plan", nounPlural: "plan" }, + "doc.list": { verb: "Listed", noun: "doc", nounPlural: "docs" }, + "doc.search": { verb: "Searched", noun: "doc", nounPlural: "docs" }, + "doc.get": { verb: "Read", noun: "doc", nounPlural: "docs" }, + "doc.draft.get": { verb: "Read", noun: "draft", nounPlural: "drafts" }, + "doc.upsert": { verb: "Updated", noun: "doc", nounPlural: "docs" }, + "doc.create": { verb: "Created", noun: "doc", nounPlural: "docs" }, + "doc.update": { verb: "Updated", noun: "doc", nounPlural: "docs" }, + "doc.delete": { verb: "Deleted", noun: "doc", nounPlural: "docs" }, + "doc.draft.save": { verb: "Saved", noun: "draft", nounPlural: "drafts" }, "http.fetch": { verb: "Fetched", noun: "URL", nounPlural: "URLs" }, "time.now": { verb: "Checked", noun: "time", nounPlural: "time" }, "echo": { verb: "Echoed", noun: "value", nounPlural: "values" }, @@ -57,8 +66,12 @@ function specFor(name: string): VerbSpec { /// Pull a short, render-friendly target string out of a tool's args /// for the single-call inline form. `null` when the tool has no /// natural single-noun target (workspace.context, plan.update, etc.). -function singleInlineTarget(name: string, args: any): string | null { - if (!args || typeof args !== "object") return null; +function isRecord(v: unknown): v is Record { + return v != null && typeof v === "object" && !Array.isArray(v); +} + +function singleInlineTarget(name: string, args: unknown): string | null { + if (!isRecord(args)) return null; switch (name) { case "fs.read": case "fs.list": @@ -84,11 +97,40 @@ function singleInlineTarget(name: string, args: any): string | null { return typeof args.pattern === "string" ? `\`${args.pattern}\`` : null; case "git.show": return typeof args.revision === "string" ? args.revision : null; + case "doc.search": + return typeof args.query === "string" && args.query.trim() + ? `\`${args.query.trim()}\`` + : null; + case "doc.get": + return typeof args.id === "string" ? args.id : null; + case "doc.upsert": + case "doc.create": + case "doc.update": + return typeof args.title === "string" + ? args.title + : typeof args.id === "string" + ? args.id + : null; + case "doc.delete": + return typeof args.id === "string" ? args.id : null; + case "doc.draft.get": + case "doc.draft.save": + return typeof args.project_id === "string" ? args.project_id : null; default: return null; } } +function parseJsonObject(output: string | null): Record | null { + if (output == null || output.trim() === "" || output.trim() === "null") return null; + try { + const parsed = JSON.parse(output); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + /// Pull a tiny stat suffix from the tool's *output* for the single- /// call form. Mirrors what `toolSummaries.ts` produces for the /// header chip but kept independent because the summarisable rule @@ -98,7 +140,7 @@ function singleInlineTarget(name: string, args: any): string | null { /// surfaces). function singleInlineStat( name: string, - args: any, + _args: unknown, output: string | null, ): string | null { if (output == null) return null; @@ -147,6 +189,42 @@ function singleInlineStat( if (untracked > 0) parts.push(`?:${untracked}`); return parts.length > 0 ? parts.join(" ") : "clean"; } + case "doc.list": + case "doc.search": { + const parsed = parseJsonObject(output); + const count = typeof parsed?.count === "number" ? parsed.count : null; + return count == null ? null : `${count} doc${count === 1 ? "" : "s"}`; + } + case "doc.get": { + const parsed = parseJsonObject(output); + const project = isRecord(parsed?.project) ? parsed.project : null; + const title = project?.title; + return typeof title === "string" && title ? title : null; + } + case "doc.draft.get": + case "doc.draft.save": { + const parsed = parseJsonObject(output); + const content = parsed?.content; + if (typeof content !== "string") return output?.trim() === "null" ? "empty" : null; + const words = content.trim() ? content.trim().split(/\s+/).length : 0; + return `${words} word${words === 1 ? "" : "s"}`; + } + case "doc.upsert": { + const parsed = parseJsonObject(output); + if (!parsed) return null; + return parsed.created === true ? "created" : "updated"; + } + case "doc.create": + case "doc.update": { + const parsed = parseJsonObject(output); + const title = parsed?.title; + return typeof title === "string" && title ? title : null; + } + case "doc.delete": { + const parsed = parseJsonObject(output); + if (!parsed) return null; + return parsed.deleted === true ? "deleted" : "not found"; + } default: return null; } diff --git a/apps/jarvis-web/src/components/Chat/toolSummaries.test.ts b/apps/jarvis-web/src/components/Chat/toolSummaries.test.ts index 3343b22..f03dc16 100644 --- a/apps/jarvis-web/src/components/Chat/toolSummaries.test.ts +++ b/apps/jarvis-web/src/components/Chat/toolSummaries.test.ts @@ -14,6 +14,10 @@ import { SUMMARISABLE_TOOLS, summarise, summariseChecks, + summariseDocDraftGet, + summariseDocGet, + summariseDocList, + summariseDocSearch, summariseFsList, summariseFsRead, summariseGitDiff, @@ -299,6 +303,29 @@ describe("summarisePlan", () => { }); }); +describe("doc summaries", () => { + it("summarises doc list count", () => { + expect(summariseDocList(JSON.stringify({ count: 2, items: [] }))).toBe("2 docs"); + }); + + it("summarises doc search count and query", () => { + expect(summariseDocSearch(JSON.stringify({ count: 1, query: "roadmap", items: [] }))).toBe( + '1 doc for "roadmap"', + ); + }); + + it("summarises doc.get title and draft presence", () => { + expect( + summariseDocGet(JSON.stringify({ project: { title: "Launch plan" }, draft: { id: "d" } })), + ).toBe("Launch plan · latest draft"); + }); + + it("summarises empty and populated drafts", () => { + expect(summariseDocDraftGet("null")).toBe("empty draft"); + expect(summariseDocDraftGet(JSON.stringify({ content: "one two three" }))).toBe("3 words"); + }); +}); + describe("summarise top-level dispatch", () => { it("returns null for an unknown tool name", () => { expect(summarise("custom.mcp.tool", {}, "anything")).toBeNull(); @@ -337,8 +364,13 @@ describe("invariants", () => { expect(intersection).toEqual([]); }); - it("approval-gated set covers exactly the four mutating built-ins", () => { + it("approval-gated set covers mutating built-ins rendered open", () => { expect([...APPROVAL_GATED_TOOLS].sort()).toEqual([ + "doc.create", + "doc.delete", + "doc.draft.save", + "doc.update", + "doc.upsert", "fs.edit", "fs.patch", "fs.write", diff --git a/apps/jarvis-web/src/components/Chat/toolSummaries.ts b/apps/jarvis-web/src/components/Chat/toolSummaries.ts index 1f28854..9f4c9ca 100644 --- a/apps/jarvis-web/src/components/Chat/toolSummaries.ts +++ b/apps/jarvis-web/src/components/Chat/toolSummaries.ts @@ -38,6 +38,10 @@ export const SUMMARISABLE_TOOLS: ReadonlySet = new Set([ "code.grep", "project.checks", "plan.update", + "doc.list", + "doc.search", + "doc.get", + "doc.draft.get", ]); /// Tools that mutate state (approval-gated). Default to OPEN after @@ -50,6 +54,11 @@ export const APPROVAL_GATED_TOOLS: ReadonlySet = new Set([ "fs.write", "fs.patch", "shell.exec", + "doc.upsert", + "doc.create", + "doc.update", + "doc.delete", + "doc.draft.save", ]); /// Anything larger than this we refuse to scan. Massive grep dumps @@ -109,6 +118,14 @@ function dispatch(name: string, args: unknown, output: string | null): string | return summariseChecks(output); case "plan.update": return summarisePlan(args, output); + case "doc.list": + return summariseDocList(output); + case "doc.search": + return summariseDocSearch(output); + case "doc.get": + return summariseDocGet(output); + case "doc.draft.get": + return summariseDocDraftGet(output); default: return null; } @@ -128,6 +145,10 @@ function tryParseJson(s: string): unknown { } } +function isRecord(v: unknown): v is Record { + return v != null && typeof v === "object" && !Array.isArray(v); +} + // ---------------- per-tool parsers ---------------- /// `workspace.context` returns a JSON blob with vcs / branch / dirty @@ -305,3 +326,48 @@ export function summarisePlan(args: unknown, _output: string | null): string | n } return t("toolSummaryPlan", items.length, done); } + +export function summariseDocList(output: string | null): string | null { + if (isBlank(output)) return null; + const parsed = tryParseJson(output!); + if (!isRecord(parsed)) return null; + const count = parsed.count; + if (typeof count === "number") return t("toolSummaryDocList", count); + const items = parsed.items; + if (Array.isArray(items)) return t("toolSummaryDocList", items.length); + return null; +} + +export function summariseDocSearch(output: string | null): string | null { + if (isBlank(output)) return null; + const parsed = tryParseJson(output!); + if (!isRecord(parsed)) return null; + const count = parsed.count; + const query = parsed.query; + if (typeof count !== "number") return null; + return typeof query === "string" && query.trim() + ? t("toolSummaryDocSearch", count, query.trim()) + : t("toolSummaryDocList", count); +} + +export function summariseDocGet(output: string | null): string | null { + if (isBlank(output)) return null; + const parsed = tryParseJson(output!); + if (!isRecord(parsed)) return null; + const project = isRecord(parsed.project) ? parsed.project : null; + const title = project && typeof project.title === "string" ? project.title : null; + const hasDraft = parsed.draft != null; + if (!title) return null; + return hasDraft ? t("toolSummaryDocGetWithDraft", title) : title; +} + +export function summariseDocDraftGet(output: string | null): string | null { + if (isBlank(output)) return null; + if (output!.trim() === "null") return t("toolSummaryDocDraftEmpty"); + const parsed = tryParseJson(output!); + if (!isRecord(parsed)) return null; + const content = parsed.content; + if (typeof content !== "string") return null; + const words = content.trim() ? content.trim().split(/\s+/).length : 0; + return t("toolSummaryDocDraft", words); +} diff --git a/apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx b/apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx index 8416fef..867afee 100644 --- a/apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx +++ b/apps/jarvis-web/src/components/Conversations/ConversationsArchivePage.tsx @@ -4,31 +4,28 @@ // project filter, and date grouping. Click a row → resume the // conversation and land on the chat layout. // -// Deep-link: `/conversations/:id` directly resumes that id and -// redirects to `/`. Useful for chat URLs that survive bookmarks / -// browser back even when the row scrolled out of the sidebar. +// Deep-link: `/conversations/:id` is the legacy URL. It redirects +// to `/sessions/:id`, where the chat layout resumes and keeps the +// URL pinned to the current session. import { useEffect, useMemo, useState } from "react"; -import { Navigate, useNavigate, useParams } from "react-router-dom"; +import { Navigate, useParams } from "react-router-dom"; -import { resumeConversation, refreshConvoList } from "../../services/conversations"; +import { + resumeConversation, + refreshConvoList, + sessionRoute, +} from "../../services/conversations"; import { useAppStore } from "../../store/appStore"; import type { ConvoListRow } from "../../types/frames"; import { t } from "../../utils/i18n"; import { convoGroupLabel, relTime } from "../../utils/time"; -/// `/conversations/:id` — resumes the conversation server-side then -/// redirects to chat. Renders nothing visible. +/// `/conversations/:id` — old conversation deep-link route. export function ConversationDeepLinkRedirect() { const { id } = useParams<{ id: string }>(); - const [done, setDone] = useState(false); - useEffect(() => { - if (!id) return; - void resumeConversation(id).finally(() => setDone(true)); - }, [id]); if (!id) return ; - if (!done) return null; - return ; + return ; } /// `/conversations` — full-page archive browse. @@ -44,7 +41,6 @@ export function ConversationsArchivePage() { // Plain `string` is fine: we use the sentinel `"all"` only in a // single equality check below. const [projectFilter, setProjectFilter] = useState("all"); - const navigate = useNavigate(); useEffect(() => { void refreshConvoList(); @@ -147,16 +143,9 @@ export function ConversationsArchivePage() { type="button" style={rowMainBtnStyle} onClick={() => { - // Fire-and-forget: navigate as soon as the - // resume frame is in flight; the chat pane - // reconciles on the server's reply. Floating - // promise is intentional here — eslint's - // no-floating-promises wants either await or - // explicit `void`, and the latter is what we - // want at an event-handler boundary. - void resumeConversation(row.id).then(() => - navigate("/"), - ); + // Fire-and-forget: resumeConversation also + // pins the browser to /sessions/:id. + void resumeConversation(row.id); }} title={row.id} > diff --git a/apps/jarvis-web/src/components/Customize/CustomizePage.tsx b/apps/jarvis-web/src/components/Customize/CustomizePage.tsx new file mode 100644 index 0000000..f28cb4f --- /dev/null +++ b/apps/jarvis-web/src/components/Customize/CustomizePage.tsx @@ -0,0 +1,552 @@ +// Customize — split into a marketplace home and a management view. +// The default `/customize` surface is now the plugin marketplace; +// `#manage` / `#manage-*` opens the installed-capabilities controls. + +import { useEffect, useMemo, useState } from "react"; +import { McpSection } from "../Settings/sections/McpSection"; +import { SkillsSection } from "../Settings/sections/SkillsSection"; +import { PluginsSection } from "../Settings/sections/PluginsSection"; +import { McpMarketPanel, SkillMarketPanel } from "./MarketPanels"; +import { t } from "../../utils/i18n"; +import { useAppStore, appStore } from "../../store/appStore"; +import { listSkills } from "../../services/skills"; +import { listMcpServers } from "../../services/mcp"; +import { + fetchMarketplace, + installPlugin, + listPlugins, + type MarketplaceEntry, + type PluginInstallReport, +} from "../../services/plugins"; +import { sendFrame } from "../../services/socket"; + +type ManageTab = "plugins" | "mcp" | "skills"; +type MarketTab = "plugins" | "skills" | "mcp"; +type CustomizeMode = "market" | "manage"; +type CustomizeView = { mode: CustomizeMode; manageTab: ManageTab; marketTab: MarketTab }; + +const MANAGE_TABS: ManageTab[] = ["plugins", "mcp", "skills"]; +const MARKET_TABS: MarketTab[] = ["plugins", "skills", "mcp"]; + +type CustomizeStats = Record; + +const EMPTY_STATS: CustomizeStats = { + plugins: { total: 0, active: 0, hint: "0 installed" }, + mcp: { total: 0, active: 0, hint: "0 running" }, + skills: { total: 0, active: 0, hint: "0 active" }, +}; + +const MANAGE_META: Record = { + plugins: { + label: "Plugins", + desc: "Manage installed plugin bundles and local plugin paths.", + }, + mcp: { + label: "MCP", + desc: "Connect runtime tool servers and monitor their status.", + }, + skills: { + label: "Skills", + desc: "Activate prompt capabilities for this chat session.", + }, +}; + +const MARKET_META: Record = { + plugins: { label: "Plugins", placeholder: "Search plugins" }, + skills: { label: "Skills", placeholder: "Search skills" }, + mcp: { label: "MCP", placeholder: "Search MCP servers" }, +}; + +function tx(key: string, fallback: string): string { + const v = t(key); + return v === key ? fallback : v; +} + +function parseHash(): CustomizeView { + if (typeof window === "undefined") { + return { mode: "market", manageTab: "plugins", marketTab: "plugins" }; + } + const raw = window.location.hash.replace(/^#/, ""); + if (raw === "manage") return { mode: "manage", manageTab: "plugins", marketTab: "plugins" }; + if (raw.startsWith("manage-")) { + const tab = raw.slice("manage-".length); + if (MANAGE_TABS.includes(tab as ManageTab)) { + return { mode: "manage", manageTab: tab as ManageTab, marketTab: "plugins" }; + } + } + if (raw.startsWith("market-")) { + const tab = raw.slice("market-".length); + if (MARKET_TABS.includes(tab as MarketTab)) { + return { mode: "market", manageTab: "plugins", marketTab: tab as MarketTab }; + } + } + if (MANAGE_TABS.includes(raw as ManageTab)) { + return { mode: "manage", manageTab: raw as ManageTab, marketTab: "plugins" }; + } + return { mode: "market", manageTab: "plugins", marketTab: "plugins" }; +} + +export function CustomizePage() { + const [view, setView] = useState(() => parseHash()); + const activeSkills = useAppStore((s) => s.activeSkills); + const [stats, setStats] = useState(EMPTY_STATS); + const [statsLoading, setStatsLoading] = useState(true); + const [skillsRefreshToken, setSkillsRefreshToken] = useState(0); + const [mcpRefreshToken, setMcpRefreshToken] = useState(0); + + const refreshStats = () => { + setStatsLoading(true); + Promise.all([listPlugins(), listMcpServers(), listSkills()]) + .then(([plugins, mcpServers, skills]) => { + const runningMcp = mcpServers.filter((s) => s.status === "running").length; + setStats({ + plugins: { + total: plugins.length, + active: plugins.length, + hint: t("customizePluginsStat", plugins.length), + }, + mcp: { + total: mcpServers.length, + active: runningMcp, + hint: t("customizeMcpStat", runningMcp, mcpServers.length), + }, + skills: { + total: skills.length, + active: activeSkills.length, + hint: t("customizeSkillsStat", activeSkills.length, skills.length), + }, + }); + }) + .catch(() => { + setStats((current) => ({ + ...current, + skills: { + ...current.skills, + active: activeSkills.length, + hint: t("customizeSkillsStat", activeSkills.length, current.skills.total), + }, + })); + }) + .finally(() => setStatsLoading(false)); + }; + + useEffect(() => { + refreshStats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + setStats((current) => ({ + ...current, + skills: { + ...current.skills, + active: activeSkills.length, + hint: t("customizeSkillsStat", activeSkills.length, current.skills.total), + }, + })); + }, [activeSkills]); + + useEffect(() => { + const onHash = () => setView(parseHash()); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + + const navigate = (next: CustomizeView) => { + setView(next); + if (typeof window === "undefined") return; + const hash = next.mode === "manage" + ? `#manage-${next.manageTab}` + : next.marketTab === "plugins" + ? "" + : `#market-${next.marketTab}`; + const nextUrl = `${window.location.pathname}${hash}`; + if (`${window.location.pathname}${window.location.hash}` !== nextUrl) { + window.history.replaceState(null, "", nextUrl); + } + }; + + const handleSkillInstalled = () => { + setSkillsRefreshToken((v) => v + 1); + refreshStats(); + }; + + const handleMcpInstalled = () => { + setMcpRefreshToken((v) => v + 1); + refreshStats(); + }; + + const manage = () => navigate({ mode: "manage", manageTab: "plugins", marketTab: "plugins" }); + const market = () => navigate({ mode: "market", manageTab: "plugins", marketTab: "plugins" }); + + return ( +
+ {view.mode === "market" ? ( + navigate({ mode: "market", manageTab: "plugins", marketTab: tab })} + onManage={manage} + onInstalled={refreshStats} + onSkillInstalled={handleSkillInstalled} + onMcpInstalled={handleMcpInstalled} + /> + ) : ( + navigate({ mode: "manage", manageTab: tab, marketTab: "plugins" })} + onMarket={market} + onInstalled={refreshStats} + /> + )} +
+ ); +} + +function MarketView({ + tab, + onTab, + onManage, + onInstalled, + onSkillInstalled, + onMcpInstalled, +}: { + tab: MarketTab; + onTab: (tab: MarketTab) => void; + onManage: () => void; + onInstalled: () => void; + onSkillInstalled: () => void; + onMcpInstalled: () => void; +}) { + return ( + <> +
+ +
+ +
+
+
+
+

+ {tx("customizeHeroTitle", "Let Jarvis work your way")} +

+
+ {tab === "plugins" ? : null} + {tab === "skills" ? ( +
+ +
+ ) : null} + {tab === "mcp" ? ( +
+ +
+ ) : null} +
+ + ); +} + +function ManageView({ + tab, + stats, + statsLoading, + skillsRefreshToken, + mcpRefreshToken, + onTab, + onMarket, + onInstalled, +}: { + tab: ManageTab; + stats: CustomizeStats; + statsLoading: boolean; + skillsRefreshToken: number; + mcpRefreshToken: number; + onTab: (tab: ManageTab) => void; + onMarket: () => void; + onInstalled: (report: PluginInstallReport) => void; +}) { + const currentMeta = MANAGE_META[tab]; + return ( + <> +
+ + + {tx("customizeManage", "Manage")} +
+
+
+
+ + +
+
+ {tx(`customizeNav${capitalize(tab)}`, currentMeta.label)} + {stats[tab].hint} +
+
+ {tab === "plugins" ? ( + + ) : null} + {tab === "mcp" ? : null} + {tab === "skills" ? : null} +
+
+
+ + ); +} + +type PluginMarketState = + | { kind: "loading" } + | { kind: "ready"; entries: MarketplaceEntry[]; installed: Set } + | { kind: "error"; message: string }; + +function PluginMarketHome({ onInstalled }: { onInstalled: () => void }) { + const [query, setQuery] = useState(""); + const [state, setState] = useState({ kind: "loading" }); + const [installing, setInstalling] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const refresh = () => { + setState({ kind: "loading" }); + Promise.all([fetchMarketplace(), listPlugins()]) + .then(([entries, installed]) => { + setState({ + kind: "ready", + entries, + installed: new Set(installed.map((p) => p.source_value)), + }); + }) + .catch((e: unknown) => setState({ kind: "error", message: String(e) })); + }; + + useEffect(() => { + refresh(); + }, []); + + const filtered = useMemo(() => { + if (state.kind !== "ready") return []; + const q = query.trim().toLowerCase(); + if (!q) return state.entries; + return state.entries.filter((entry) => { + const haystack = [ + entry.name, + entry.description, + entry.source, + entry.value, + ...(entry.tags ?? []), + ].join(" ").toLowerCase(); + return haystack.includes(q); + }); + }, [query, state]); + + const install = async (entry: MarketplaceEntry) => { + setInstalling(entry.value); + setMessage(null); + setError(null); + try { + const report = await installPlugin("path", entry.value); + activateInstalledSkills(report.added_skills); + onInstalled(); + refresh(); + setMessage(t("pluginsInstallSucceeded", report.added_skills.length, report.added_mcp.length)); + } catch (e: unknown) { + setError(t("pluginsInstallFailed", String(e))); + } finally { + setInstalling(null); + } + }; + + return ( +
+
e.preventDefault()}> + + setQuery(e.target.value)} + placeholder={tx("customizeSearchPlugins", "Search plugins")} + /> + +
+
+ J + {tx("customizeFeaturePrompt", "Install a workflow pack and try it in chat")} +
+ +
+
+ {tx("customizeFeatured", "Featured")} +
+ {state.kind === "loading" ?
: null} + {state.kind === "error" ? ( +
{t("marketLoadFailed", state.message)}
+ ) : null} + {state.kind === "ready" ? ( +
    + {filtered.map((entry) => { + const installed = state.installed.has(entry.value); + return ( +
  • +
    {pluginInitial(entry.name)}
    +
    +
    {entry.name}
    +
    {entry.description}
    +
    + +
  • + ); + })} +
+ ) : null} + {message &&
{message}
} + {error &&
{error}
} +
+ ); +} + +function activateInstalledSkills(skillNames: string[]) { + if (skillNames.length === 0) return; + const sent = skillNames.filter((name) => sendFrame({ type: "activate_skill", name })); + if (sent.length === 0) return; + const current = appStore.getState().activeSkills; + const next = Array.from(new Set([...current, ...sent])); + appStore.getState().setActiveSkills?.(next); +} + +function pluginInitial(name: string): string { + return name.trim().charAt(0).toUpperCase() || "P"; +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function NavIcon({ kind }: { kind: string }) { + const common = { + width: 18, + height: 18, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor" as const, + strokeWidth: 1.8, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, + }; + if (kind === "back") { + return ( + + + + ); + } + if (kind === "chevron") { + return ( + + + + ); + } + return ( + + + + + + + ); +} + +function GearIcon() { + return ( + + ); +} + +function SearchIcon() { + return ( + + ); +} + +function PlusIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +function ChatBubbleIcon() { + return ( + + ); +} diff --git a/apps/jarvis-web/src/components/Customize/MarketPanels.tsx b/apps/jarvis-web/src/components/Customize/MarketPanels.tsx new file mode 100644 index 0000000..fb59dbf --- /dev/null +++ b/apps/jarvis-web/src/components/Customize/MarketPanels.tsx @@ -0,0 +1,295 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { addMcpServer } from "../../services/mcp"; +import { + installSkillFromMarket, + mcpConfigFromMarketEntry, + searchMcpMarket, + searchSkillMarket, + type MarketMcpEntry, + type MarketSkillEntry, +} from "../../services/market"; +import { sendFrame } from "../../services/socket"; +import { appStore } from "../../store/appStore"; +import { t } from "../../utils/i18n"; + +function tx(key: string, fallback: string): string { + const v = t(key); + return v === key ? fallback : v; +} + +type MarketState = + | { kind: "loading" } + | { kind: "ready"; entries: T[] } + | { kind: "error"; message: string }; + +export function SkillMarketPanel({ onInstalled }: { onInstalled?: () => void }) { + const [query, setQuery] = useState(""); + const [state, setState] = useState>({ kind: "loading" }); + const [installing, setInstalling] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const refresh = (q = query) => { + setState({ kind: "loading" }); + searchSkillMarket(q) + .then((entries) => setState({ kind: "ready", entries })) + .catch((e: unknown) => setState({ kind: "error", message: String(e) })); + }; + + useEffect(() => { + refresh(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submit = (e: FormEvent) => { + e.preventDefault(); + refresh(query); + }; + + const install = async (entry: MarketSkillEntry) => { + const key = `${entry.source}/${entry.skillId}`; + setInstalling(key); + setMessage(null); + setError(null); + try { + const skill = await installSkillFromMarket(entry.source, entry.skillId); + activateSkill(skill.name); + onInstalled?.(); + setMessage(t("marketSkillInstalled", skill.name)); + } catch (e: unknown) { + setError(t("marketSkillInstallFailed", String(e))); + } finally { + setInstalling(null); + } + }; + + return ( +
+ + {renderSkillMarket(state, installing, (entry) => { void install(entry); })} + {message &&
{message}
} + {error &&
{error}
} +
+ ); +} + +export function McpMarketPanel({ onInstalled }: { onInstalled?: () => void }) { + const [query, setQuery] = useState(""); + const [state, setState] = useState>({ kind: "loading" }); + const [installing, setInstalling] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const refresh = (q = query) => { + setState({ kind: "loading" }); + searchMcpMarket(q) + .then((entries) => setState({ kind: "ready", entries })) + .catch((e: unknown) => setState({ kind: "error", message: String(e) })); + }; + + useEffect(() => { + refresh(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submit = (e: FormEvent) => { + e.preventDefault(); + refresh(query); + }; + + const install = async (entry: MarketMcpEntry) => { + const cfg = mcpConfigFromMarketEntry(entry); + if (!cfg) return; + setInstalling(entry.name); + setMessage(null); + setError(null); + try { + await addMcpServer(cfg); + onInstalled?.(); + setMessage(t("marketMcpInstalled", cfg.prefix)); + } catch (e: unknown) { + setError(t("marketMcpInstallFailed", String(e))); + } finally { + setInstalling(null); + } + }; + + return ( +
+ + {renderMcpMarket(state, installing, (entry) => { void install(entry); })} + {message &&
{message}
} + {error &&
{error}
} +
+ ); +} + +function MarketHeader({ + title, + hint, + query, + setQuery, + onSubmit, + placeholder, +}: { + title: string; + hint: string; + query: string; + setQuery: (value: string) => void; + onSubmit: (e: FormEvent) => void; + placeholder: string; +}) { + return ( +
+
+
{title}
+
{hint}
+
+
+ setQuery(e.target.value)} + placeholder={placeholder} + /> + +
+
+ ); +} + +function renderSkillMarket( + state: MarketState, + installing: string | null, + onInstall: (entry: MarketSkillEntry) => void, +) { + if (state.kind === "loading") return
; + if (state.kind === "error") { + return
{t("marketLoadFailed", state.message)}
; + } + if (state.entries.length === 0) { + return
{tx("marketEmpty", "No matching entries.")}
; + } + return ( +
    + {state.entries.map((entry) => { + const key = `${entry.source}/${entry.skillId}`; + return ( +
  • +
    +
    + {entry.name} + {entry.isOfficial && {tx("marketOfficial", "Official")}} +
    +
    {entry.installHint}
    +
    + {entry.source} + {entry.installs != null && {t("marketInstalls", entry.installs)}} +
    +
    + +
  • + ); + })} +
+ ); +} + +function renderMcpMarket( + state: MarketState, + installing: string | null, + onInstall: (entry: MarketMcpEntry) => void, +) { + if (state.kind === "loading") return
; + if (state.kind === "error") { + return
{t("marketLoadFailed", state.message)}
; + } + if (state.entries.length === 0) { + return
{tx("marketEmpty", "No matching entries.")}
; + } + return ( +
    + {state.entries.map((entry) => ( + + ))} +
+ ); +} + +function McpMarketItem({ + entry, + installing, + onInstall, +}: { + entry: MarketMcpEntry; + installing: string | null; + onInstall: (entry: MarketMcpEntry) => void; +}) { + const cfg = useMemo(() => mcpConfigFromMarketEntry(entry), [entry]); + const hasRemoteOnly = !cfg && entry.remotes.length > 0; + const disabledReason = hasRemoteOnly + ? tx("marketMcpRemoteOnly", "HTTP remote, not supported by this runtime yet") + : tx("marketMcpNeedsEnv", "Needs manual command or environment variables"); + return ( +
  • +
    +
    + {entry.title || entry.name} + {entry.isLatest && {tx("marketLatest", "Latest")}} +
    +
    {entry.description || entry.name}
    +
    + {entry.packages.slice(0, 3).map((pkg) => ( + + {pkg.registryType} · {pkg.identifier} + + ))} + {entry.remotes.slice(0, 2).map((remote) => ( + {remote.transportType} + ))} + {entry.repositoryUrl && {entry.repositoryUrl.replace(/^https?:\/\//, "")}} +
    +
    + +
  • + ); +} + +function activateSkill(name: string) { + if (!sendFrame({ type: "activate_skill", name })) return; + const current = appStore.getState().activeSkills; + const next = Array.from(new Set([...current, name])); + appStore.getState().setActiveSkills?.(next); +} diff --git a/apps/jarvis-web/src/components/Diagnostics/ExceptionsPanel.tsx b/apps/jarvis-web/src/components/Diagnostics/ExceptionsPanel.tsx index 85c976a..cdcab4e 100644 --- a/apps/jarvis-web/src/components/Diagnostics/ExceptionsPanel.tsx +++ b/apps/jarvis-web/src/components/Diagnostics/ExceptionsPanel.tsx @@ -132,7 +132,6 @@ export function ExceptionsPanel({ overview }: Props) { ) => { if (convoId) { void resumeConversation(convoId); - void navigate("/"); return; } // Fallback: jump to the requirement's parent project (we don't diff --git a/apps/jarvis-web/src/components/Docs/DocsPage.tsx b/apps/jarvis-web/src/components/Docs/DocsPage.tsx index 8e1b4c8..eca8904 100644 --- a/apps/jarvis-web/src/components/Docs/DocsPage.tsx +++ b/apps/jarvis-web/src/components/Docs/DocsPage.tsx @@ -17,6 +17,7 @@ import { MarkdownView } from "../Chat/MarkdownView"; import { OpenSidebarButton } from "../Workspace/WorkspaceToggles"; import { EmptyState } from "../shared/EmptyState"; import { t } from "../../utils/i18n"; +import { sendFrame } from "../../services/socket"; import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { DocOutline } from "./DocOutline"; import { BlockEditor, createMockUploadAdapter } from "./Editor"; @@ -241,6 +242,19 @@ export function DocsPage() { await updateDocProject(selected.id, { archived: !selected.archived }); }; + const onAskAgent = () => { + if (!selected) return; + sendFrame({ type: "activate_skill", name: "doc" }); + const excerpt = draftBuffer.trim().slice(0, 800); + useAppStore + .getState() + .setComposerValue( + t("docsAskAgentPrompt", selected.id, selected.title, excerpt), + ); + void navigate("/"); + requestAnimationFrame(() => document.getElementById("input")?.focus()); + }; + const onDelete = async () => { if (!confirmingDelete) return; const id = confirmingDelete.id; @@ -286,7 +300,14 @@ export function DocsPage() { { + setDraftBuffer(next); + setSaveState((prev) => + prev.kind === "saving" || prev.kind === "offline" + ? prev + : { kind: "idle" }, + ); + }} draftReady={draftReady} previewing={previewing} setPreviewing={setPreviewing} @@ -299,6 +320,7 @@ export function DocsPage() { onChangeTags={onChangeTags} onTogglePinned={onTogglePinned} onToggleArchived={onToggleArchived} + onAskAgent={onAskAgent} onDelete={() => setConfirmingDelete(selected)} tagSuggestions={tagSuggestions} /> @@ -391,6 +413,7 @@ interface EditorColumnProps { onChangeTags: (next: string[]) => void; onTogglePinned: () => void; onToggleArchived: () => void; + onAskAgent: () => void; onDelete: () => void; tagSuggestions: string[]; } @@ -553,6 +576,30 @@ function DocsEditorMeta(props: EditorColumnProps) { > ★ + + + ); } diff --git a/apps/jarvis-web/src/components/Docs/Editor/editor.css b/apps/jarvis-web/src/components/Docs/Editor/editor.css index 530f957..a2f42e7 100644 --- a/apps/jarvis-web/src/components/Docs/Editor/editor.css +++ b/apps/jarvis-web/src/components/Docs/Editor/editor.css @@ -7,6 +7,11 @@ height: 100%; } +.block-editor-shell { + display: block; + height: 100%; +} + .block-editor-host .ProseMirror, .block-editor-surface { outline: none; @@ -555,3 +560,67 @@ font-size: 12px; line-height: 1.3; } + +/* ---------- right-click context menu -------------------------- */ + +.block-editor-context-menu { + position: fixed; + z-index: 1200; + width: 224px; + padding: 5px; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + box-shadow: + 0 10px 28px rgba(15, 23, 42, 0.14), + 0 2px 8px rgba(15, 23, 42, 0.08); + font-size: 13px; +} + +.block-editor-context-menu-item { + width: 100%; + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 9px; + border: 0; + border-radius: 5px; + background: transparent; + color: #1f2328; + cursor: pointer; + font: inherit; + text-align: left; +} + +.block-editor-context-menu-item:hover, +.block-editor-context-menu-item:focus-visible { + outline: none; + background: rgba(35, 131, 226, 0.1); +} + +.block-editor-context-menu-item.is-active { + color: #0969da; + background: rgba(35, 131, 226, 0.08); +} + +.block-editor-context-menu-item:disabled { + color: #8c959f; + cursor: default; + background: transparent; +} + +.block-editor-context-menu-shortcut { + color: #8c959f; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + font-size: 11px; +} + +.block-editor-context-menu-separator { + height: 1px; + margin: 5px 4px; + background: #d8dee4; +} diff --git a/apps/jarvis-web/src/components/FallbackBanner.tsx b/apps/jarvis-web/src/components/FallbackBanner.tsx new file mode 100644 index 0000000..c127018 --- /dev/null +++ b/apps/jarvis-web/src/components/FallbackBanner.tsx @@ -0,0 +1,60 @@ +// Inline banner that surfaces provider-fallback hops. +// +// Shows when the agent loop emits one or more `provider_fallback` +// frames during the current chat turn. The banner is auto-dismissing: +// it fades after a few seconds so a transient rate-limit recovery +// doesn't permanently clutter the chat header. Operators who want +// the full history can check the server's `tracing` log or the +// observability dashboard. +// +// Phase 4.6 of the model-tool-compatibility rollout. + +import { useEffect, useState } from "react"; +import { useAppStore } from "../store/appStore"; + +const VISIBLE_MS = 8_000; + +export function FallbackBanner() { + const history = useAppStore((s) => s.fallbackHistory); + const latest = history[0]; + const [now, setNow] = useState(() => Date.now()); + + // Refresh the wall-clock once a second while the banner is + // potentially visible so the auto-dismiss kicks in without + // requiring another store update. + useEffect(() => { + if (!latest) return undefined; + const age = now - latest.receivedAt; + if (age >= VISIBLE_MS) return undefined; + const remaining = VISIBLE_MS - age; + const timer = window.setTimeout(() => setNow(Date.now()), remaining + 50); + return () => window.clearTimeout(timer); + }, [latest, now]); + + if (!latest) return null; + if (now - latest.receivedAt > VISIBLE_MS) return null; + + return ( +
    + + + Provider fallback + · + {latest.from} + + {latest.to} + {latest.model ? ( + <> + · + {latest.model} + + ) : null} + {latest.reason ? ( + ({latest.reason}) + ) : null} + +
    + ); +} diff --git a/apps/jarvis-web/src/components/ModelMenu/ModelMenu.tsx b/apps/jarvis-web/src/components/ModelMenu/ModelMenu.tsx index dab274a..0bb943a 100644 --- a/apps/jarvis-web/src/components/ModelMenu/ModelMenu.tsx +++ b/apps/jarvis-web/src/components/ModelMenu/ModelMenu.tsx @@ -49,6 +49,20 @@ interface RoutingOption { model: string; label: string; isDefault: boolean; + badges: string[]; +} + +function badgesForCapability(p: ProviderInfo, model: string): string[] { + const cap = (p.capabilities ?? []).find((c) => c.model === model); + if (!cap) return []; + const out: string[] = []; + if (cap.supportsToolCalls === true) out.push("tools"); + if (cap.supportsReasoning === true) out.push("reasoning"); + if (cap.supportsImages === true) out.push("vision"); + if ((cap.contextWindow ?? 0) >= 64000) out.push("64k+"); + if (cap.privacyHint === "local") out.push("local"); + if (cap.privacyHint === "third-party-router") out.push("router"); + return out; } function flattenRoutingOptions(providers: ProviderInfo[]): RoutingOption[] { @@ -65,6 +79,7 @@ function flattenRoutingOptions(providers: ProviderInfo[]): RoutingOption[] { model: m, label: `${p.name} · ${formatModelLabel(m)}`, isDefault: p.is_default && m === p.default_model, + badges: badgesForCapability(p, m), }); } } @@ -117,7 +132,14 @@ export function ModelMenu() { const opts = flattenRoutingOptions(providers); // Index 0 is reserved for "server default" (empty value). const allRows: RoutingOption[] = [ - { value: "", provider: "", model: "", label: t("serverDefault"), isDefault: false }, + { + value: "", + provider: "", + model: "", + label: t("serverDefault"), + isDefault: false, + badges: [], + }, ...opts, ]; @@ -160,7 +182,22 @@ export function ModelMenu() { }} > {active ? "✓" : ""} - {row.label} + + {row.label} + {row.badges.length > 0 ? ( + + ) : null} + {i ? String(i) : ""} ); diff --git a/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx b/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx index d9c9009..733a24a 100644 --- a/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx +++ b/apps/jarvis-web/src/components/Projects/ProjectBoard.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; +import { Link } from "react-router-dom"; import type { Project, Requirement, RequirementRun, RequirementStatus } from "../../types/frames"; import { t } from "../../utils/i18n"; import { relTime } from "../../utils/time"; @@ -8,6 +9,7 @@ import { rejectRequirement, updateRequirement, } from "../../services/requirements"; +import { sessionRoute } from "../../services/conversations"; import { fetchProjectRuns, type ProjectRunHistoryItem, @@ -265,6 +267,31 @@ export function ProjectBoard({ {t("projectLessonsTitle")} + + + {t("projectWorktreesButton")} + + + {lines.map((line) => ( + {line} + ))} + + + ); +} + +function LabelWithHint({ label, lines }: { label: string; lines?: string[] }) { + if (!lines || lines.length === 0) return {label}; + return ( + + {label} + + + ); +} + function evalPassRate(cases: EvalCaseResult[] | null): number | null { if (!cases || cases.length === 0) return null; const passed = cases.filter((c) => c.outcome === "success").length; @@ -64,6 +127,12 @@ function latestEvalSuite(cases: EvalCaseResult[] | null): string { return cases?.[0]?.suite ?? "-"; } +function countKeys(row: Record | null | undefined): string { + if (!row) return "-"; + const total = Object.values(row).reduce((sum, n) => sum + n, 0); + return total > 0 ? String(total) : "-"; +} + function recTitle(rec: { key: string; title: string }): string { const key = `harnessObsRec_${rec.key}_title`; const translated = t(key); @@ -117,13 +186,18 @@ function SummaryList({ title, empty, rows, secondary }: SummaryListProps) { ); } -export function HarnessObservabilityPanel({ windowDays }: Props) { +export function HarnessObservabilityPanel({ + windowDays, +}: Props) { const [state, setState] = useState({ dashboard: null, tools: null, subagents: null, cases: null, + evalSummary: null, direction: null, + capability: null, + exporter: null, loading: true, error: null, }); @@ -136,16 +210,22 @@ export function HarnessObservabilityPanel({ windowDays }: Props) { fetchToolSummary(), fetchSubagentSummary(), fetchEvalCases(), + fetchEvalSummary(), fetchHarnessDirection(), + fetchHarnessCapabilityScore(), + fetchTelemetryExporter(), ]) - .then(([dashboard, tools, subagents, cases, direction]) => { + .then(([dashboard, tools, subagents, cases, evalSummary, direction, capability, exporter]) => { if (cancelled) return; setState({ dashboard, tools, subagents, cases, + evalSummary, direction, + capability, + exporter, loading: false, error: null, }); @@ -169,7 +249,9 @@ export function HarnessObservabilityPanel({ windowDays }: Props) { !!state.tools?.length || !!state.subagents?.length || !!state.cases?.length || - !!state.direction; + !!state.evalSummary || + !!state.direction || + !!state.capability; const unavailable = !state.loading && !state.error && !state.dashboard && !state.tools && !state.subagents; @@ -189,6 +271,10 @@ export function HarnessObservabilityPanel({ windowDays }: Props) { : state.direction.score >= 60 ? "warn" : "danger", + hint: [ + t("harnessObsMetricDirectionHintFormula"), + t("harnessObsMetricDirectionHintFocus"), + ], }, { label: t("harnessObsMetricSuccess"), @@ -199,6 +285,7 @@ export function HarnessObservabilityPanel({ windowDays }: Props) { state.dashboard?.failed_runs ?? 0, ), tone: toneFromRate(state.dashboard?.run_success_rate ?? null), + hint: [t("harnessObsMetricSuccessHint")], }, { label: t("harnessObsMetricLatency"), @@ -212,15 +299,29 @@ export function HarnessObservabilityPanel({ windowDays }: Props) { : state.dashboard.p95_latency_ms < 45_000 ? "warn" : "danger", + hint: [t("harnessObsMetricLatencyHint")], }, { label: t("harnessObsMetricEval"), value: pct(evalRate), detail: t("harnessObsMetricEvalDetail", latestEvalSuite(state.cases)), tone: toneFromRate(evalRate), + hint: [t("harnessObsMetricEvalHint")], }, ]; + const overallFormulaLines = state.capability + ? [ + t("harnessCapabilityOverallFormula"), + t("harnessCapabilityDeliveryCapFormula"), + t( + "harnessCapabilityConfidenceFormula", + state.capability.sample_count, + pct(state.capability.confidence), + ), + ] + : []; + return (
    @@ -228,9 +329,27 @@ export function HarnessObservabilityPanel({ windowDays }: Props) {

    {t("harnessObsTitle")}

    {t("harnessObsSubtitle")}

    - - {t("harnessObsWindow", windowDays)} - +
    + {state.exporter && ( + + + )} + + {t("harnessObsWindow", windowDays)} + +
    {state.error && ( @@ -248,47 +367,142 @@ export function HarnessObservabilityPanel({ windowDays }: Props) {
    {metrics.map((metric) => (
    - {metric.label} + {state.loading ? "..." : metric.value}

    {metric.detail}

    ))}
    - {state.direction && ( -
    -
    - {state.direction.components.map((component) => ( -
    -
    - {t(`harnessObsComponent_${component.key}`)} - {component.score} -
    - + {state.capability && ( +
    +
    +
    +
    + {t("harnessCapabilityTitle")}
    - ))} +

    {t("harnessCapabilitySubtitle")}

    +
    +
    + + {t("harnessCapabilityOverall")} + + + {state.capability.overall_score} + {confidenceLabel(state.capability.confidence)} +
    -
    -
    - {t("harnessObsRecommendations")} -
    -
      - {state.direction.recommendations.map((rec) => ( -
    1. - - {rec.priority} - -
      - {recTitle(rec)} -

      {recDetail(rec)}

      - {rec.metric && {rec.metric}} +
      + {state.capability.dimensions.map((dimension) => { + const primaryDriver = [...dimension.drivers].sort( + (a, b) => b.weight - a.weight, + )[0]; + const evidence = dimension.evidence[0] ?? null; + const formulaLines = [ + t( + "harnessCapabilityDimensionFormula", + dimension.drivers + .map( + (driver) => + `${driverLabel(driver.key, driver.label)} ${driver.score}×${Math.round(driver.weight * 100)}%`, + ) + .join(" + "), + ), + t("harnessCapabilityMissingFormula"), + t("harnessCapabilityDimensionConfidenceFormula"), + ]; + return ( +
      +
      + + {capabilityLabel(dimension.key, dimension.label)} + + + {dimension.score}
      -
    2. - ))} -
    +

    {t(`harnessCapabilitySummary_${dimension.key}`)}

    +
    + {t("harnessCapabilityPrimaryDriver")} + + {primaryDriver + ? `${driverLabel(primaryDriver.key, primaryDriver.label)}: ${primaryDriver.score}` + : "-"} + +
    +
    + {t("harnessCapabilityConfidence", confidenceLabel(dimension.confidence))} +
    + {evidence ? ( +
    + {t("harnessCapabilityEvidence")} + {evidence.title} +

    {evidence.detail}

    + {evidence.metric && {evidence.metric}} +
    + ) : ( +
    + {t("harnessCapabilityNoEvidence")} +
    + )} + + ); + })} +
    +
    + )} + + {state.evalSummary && ( +
    +
    + {t("harnessObsEvalMaturity")} +
    +
    +
    + {t("harnessObsEvalCapability")} + + {pct(state.evalSummary.capability_pass_rate)} + +
    +
    + {t("harnessObsEvalRegression")} + + {pct(state.evalSummary.regression_pass_rate)} + +
    +
    + {t("harnessObsEvalPassAtK")} + + {pct(state.evalSummary.trial_reliability.pass_at_k)} + +
    +
    + {t("harnessObsEvalPassAll")} + + {pct(state.evalSummary.trial_reliability.pass_all)} + +
    +
    + {t("harnessObsEvalGraders")} + + {countKeys(state.evalSummary.by_grader_kind)} + +
    +
    + {t("harnessObsEvalTranscripts")} + + {state.evalSummary.transcript_cases}/{state.evalSummary.total_cases} + +
    )} @@ -311,6 +525,30 @@ export function HarnessObservabilityPanel({ windowDays }: Props) { />
    )} + + {state.direction && ( +
    +
    +
    + {t("harnessObsRecommendations")} +
    +
      + {state.direction.recommendations.map((rec) => ( +
    1. + + {rec.priority} + +
      + {recTitle(rec)} +

      {recDetail(rec)}

      + {rec.metric && {rec.metric}} +
      +
    2. + ))} +
    +
    +
    + )} ); } diff --git a/apps/jarvis-web/src/components/Projects/WorkOverview/HealthCenter.tsx b/apps/jarvis-web/src/components/Projects/WorkOverview/HealthCenter.tsx index dd59dbb..b9a3891 100644 --- a/apps/jarvis-web/src/components/Projects/WorkOverview/HealthCenter.tsx +++ b/apps/jarvis-web/src/components/Projects/WorkOverview/HealthCenter.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useAppStore } from "../../../store/appStore"; import { t } from "../../../utils/i18n"; import { resumeConversation } from "../../../services/conversations"; import { @@ -14,7 +15,13 @@ import { type AutoModeStatus, } from "../../../services/autoMode"; import { aggregateIssues, type Issue } from "../../../services/issueAggregator"; -import type { WorkOverview, WorkQuality } from "../../../services/workOverview"; +import { + fetchHarnessDirection, + type HarnessDirectionSnapshot, + type WorkOverview, + type WorkQuality, +} from "../../../services/workOverview"; +import { KpiStrip } from "./KpiStrip"; interface Props { overview: WorkOverview | null; @@ -31,6 +38,7 @@ type Tone = "ok" | "warn" | "danger" | "neutral"; interface QualitySignal { currentRate: number | null; delta: number; + samples: number; topCommand: string | null; topCommandFails: number; } @@ -46,19 +54,20 @@ interface HealthSignal { }; } +interface OptimizationMetric { + label: string; + value: string; + detail: string; + tone: Tone; + hint: string[]; +} + function formatPercent(v: number | null): string { return v === null ? "—" : `${Math.round(v * 100)}%`; } -function formatRelative(iso: string | null | undefined): string { - if (!iso) return ""; - const then = Date.parse(iso); - if (Number.isNaN(then)) return iso; - const diff = Math.max(0, Math.floor((Date.now() - then) / 1000)); - if (diff < 60) return t("relSecondsAgo", diff); - if (diff < 3600) return t("relMinutesAgo", Math.floor(diff / 60)); - if (diff < 86400) return t("relHoursAgo", Math.floor(diff / 3600)); - return t("relDaysAgo", Math.floor(diff / 86400)); +function clampScore(n: number): number { + return Math.max(0, Math.min(100, Math.round(n))); } function qualitySignal(quality: WorkQuality | null): QualitySignal { @@ -79,11 +88,42 @@ function qualitySignal(quality: WorkQuality | null): QualitySignal { return { currentRate: latest, delta: first !== null && latest !== null ? latest - first : 0, + samples: buckets.reduce((sum, b) => sum + b.total, 0), topCommand: top?.command_normalized ?? null, topCommandFails: top?.fail_count ?? 0, }; } +function FormulaHint({ label, lines }: { label: string; lines: string[] }) { + return ( + + + + {lines.map((line) => ( + {line} + ))} + + + ); +} + +function LabelWithHint({ label, lines }: { label: string; lines?: string[] }) { + if (!lines || lines.length === 0) return {label}; + return ( + + {label} + + + ); +} + function healthTone( overview: WorkOverview | null, quality: QualitySignal, @@ -163,9 +203,11 @@ export function HealthCenter({ const [orphans, setOrphans] = useState([]); const [stuck, setStuck] = useState([]); const [autoMode, setAutoMode] = useState(null); + const [direction, setDirection] = useState(null); const [autoPending, setAutoPending] = useState(false); const [autoError, setAutoError] = useState(null); const navigate = useNavigate(); + const projects = useAppStore((s) => s.projects); const refreshDiagnostics = useCallback(async () => { try { @@ -197,6 +239,20 @@ export function HealthCenter({ }; }, []); + useEffect(() => { + let cancelled = false; + fetchHarnessDirection() + .then((snapshot) => { + if (!cancelled) setDirection(snapshot); + }) + .catch(() => { + if (!cancelled) setDirection(null); + }); + return () => { + cancelled = true; + }; + }, []); + const issues = useMemo( () => aggregateIssues({ @@ -210,10 +266,113 @@ export function HealthCenter({ const unavailable = overviewUnavailable || qualityUnavailable; const tone = healthTone(overview, qualityStats, issues, unavailable); const issue = topIssue(issues); + const activeProjects = projects.filter((p) => !p.archived); + const autoEnabled = activeProjects.filter( + (p) => p.automation?.auto_mode_enabled ?? true, + ).length; + const automationCoverage = + activeProjects.length > 0 ? autoEnabled / activeProjects.length : null; + const failuresCount = overview?.run_status_counts.failed ?? 0; + const completedCount = overview?.run_status_counts.completed ?? 0; + const runningCount = overview?.running_now.length ?? 0; + const blockedCount = overview?.blocked_requirements?.length ?? 0; + const missingStores = overview?.missing_stores.length ?? 0; + const throughput = + overview?.throughput_by_day.reduce((sum, d) => sum + d.runs_started, 0) ?? 0; + const optimizationScore = useMemo(() => { + let score = 100; + score -= Math.min(35, failuresCount * 8); + score -= Math.min(18, blockedCount * 6); + if (qualityStats.currentRate !== null) { + score -= Math.max(0, 0.9 - qualityStats.currentRate) * 38; + } + if (qualityStats.delta < 0) { + score -= Math.min(15, Math.abs(qualityStats.delta) * 60); + } + if (automationCoverage !== null) { + score -= Math.max(0, 0.85 - automationCoverage) * 18; + } + score -= Math.min(12, missingStores * 6); + if (overviewUnavailable || qualityUnavailable) score -= 18; + return clampScore(score); + }, [ + automationCoverage, + blockedCount, + failuresCount, + missingStores, + overviewUnavailable, + qualityStats.currentRate, + qualityStats.delta, + qualityUnavailable, + ]); + const optimizationMetrics: OptimizationMetric[] = [ + { + label: t("harnessMetricScore"), + value: String(optimizationScore), + detail: t("harnessMetricScoreDetail"), + tone: optimizationScore >= 80 ? "ok" : optimizationScore >= 60 ? "warn" : "danger", + hint: [t("harnessMetricScoreHintFormula"), t("harnessMetricScoreHintPenalty")], + }, + { + label: t("harnessMetricReliabilityDebt"), + value: String(failuresCount + blockedCount), + detail: t("harnessMetricReliabilityDebtDetail", failuresCount, blockedCount), + tone: failuresCount > 0 ? "danger" : blockedCount > 0 ? "warn" : "ok", + hint: [t("harnessMetricReliabilityDebtHint")], + }, + { + label: t("harnessMetricVerification"), + value: formatPercent(qualityStats.currentRate), + detail: t( + "harnessMetricVerificationDetail", + qualityStats.samples, + `${qualityStats.delta > 0 ? "+" : ""}${Math.round(qualityStats.delta * 100)}%`, + ), + tone: + qualityStats.currentRate === null + ? "neutral" + : qualityStats.currentRate >= 0.85 + ? "ok" + : qualityStats.currentRate >= 0.65 + ? "warn" + : "danger", + hint: [t("harnessMetricVerificationHint")], + }, + { + label: t("harnessMetricAutomationCoverage"), + value: formatPercent(automationCoverage), + detail: t("harnessMetricAutomationCoverageDetail", autoEnabled, activeProjects.length), + tone: + automationCoverage === null + ? "neutral" + : automationCoverage >= 0.85 + ? "ok" + : automationCoverage >= 0.55 + ? "warn" + : "danger", + hint: [t("harnessMetricAutomationCoverageHint")], + }, + { + label: t("harnessMetricObservability"), + value: missingStores === 0 ? t("harnessMetricReady") : String(missingStores), + detail: + missingStores === 0 + ? t("harnessMetricObservabilityReady") + : t("harnessMetricObservabilityMissing", missingStores), + tone: missingStores === 0 ? "ok" : "warn", + hint: [t("harnessMetricObservabilityHint")], + }, + { + label: t("harnessMetricThroughput"), + value: String(throughput), + detail: t("harnessMetricThroughputDetail", completedCount, runningCount), + tone: throughput > 0 ? "ok" : "neutral", + hint: [t("harnessMetricThroughputHint")], + }, + ]; const openConversation = (id: string) => { void resumeConversation(id); - void navigate("/"); }; const toggleAutoMode = async () => { @@ -338,8 +497,16 @@ export function HealthCenter({ void toggleAutoMode()} /> + ); } - -function Metric({ - label, - value, - tone, -}: { - label: string; - value: string; - tone: Tone; -}) { - return ( -
    - {label} - {value} -
    - ); -} diff --git a/apps/jarvis-web/src/components/Projects/WorkOverview/KpiStrip.tsx b/apps/jarvis-web/src/components/Projects/WorkOverview/KpiStrip.tsx index 96f512b..2eec167 100644 --- a/apps/jarvis-web/src/components/Projects/WorkOverview/KpiStrip.tsx +++ b/apps/jarvis-web/src/components/Projects/WorkOverview/KpiStrip.tsx @@ -14,12 +14,6 @@ interface KpiCardProps { tone?: "neutral" | "danger" | "ok"; icon: ReactNode; loading?: boolean; - /// Anchor id to scroll to when the card is clicked (e.g. the - /// operational panel's #section). When set, the card becomes a - /// ` - ); - } - return
    {body}
    ; } // Shared icon constants — small (14px) so they sit alongside the @@ -133,7 +108,6 @@ export function KpiStrip({ overview, loading }: Props) { tone={runningNow && runningNow > 0 ? "ok" : "neutral"} icon={} loading={loading && runningNow === null} - scrollTo="work-overview-operational" /> 0 ? "danger" : "neutral"} icon={} loading={loading && failed === null} - scrollTo="work-overview-operational" /> 0 ? "ok" : "neutral"} icon={} loading={loading && completed === null} - scrollTo="work-overview-throughput" /> { void resumeConversation(id); - void navigate("/"); }; const running = overview?.running_now ?? []; diff --git a/apps/jarvis-web/src/components/Projects/WorkOverview/SubAgentRunsRail.tsx b/apps/jarvis-web/src/components/Projects/WorkOverview/SubAgentRunsRail.tsx new file mode 100644 index 0000000..e745c7d --- /dev/null +++ b/apps/jarvis-web/src/components/Projects/WorkOverview/SubAgentRunsRail.tsx @@ -0,0 +1,175 @@ +// WorkOverview rail listing recent + active SubAgent runs. +// +// Polls `/v1/subagents/runs` every 5s. The poll is fine for v1 — +// sub-agent runs are minute-scale, not millisecond-scale, and the +// 5s interval keeps the API surface simple (no WS subscription +// needed). A future iteration can subscribe to the WS frame +// `subagent_run_*` if/when the server emits run-lifecycle events +// out of band. +// +// Cards show: subagent name + model, task, elapsed, tool call +// count, status dot, cancel button when running. Click a card to +// view its detail (stub for now; the detail drawer is a follow-up). + +import { useEffect, useState } from "react"; +import { + cancelSubAgentRun, + fetchSubAgentRuns, + type SubAgentRun, + type SubAgentRunStatus, +} from "../../../services/subagentRuns"; + +const POLL_MS = 5000; + +function statusColor(s: SubAgentRunStatus): string { + switch (s) { + case "running": + return "var(--color-warning, #f59e0b)"; + case "completed": + return "var(--color-success, #10b981)"; + case "failed": + return "var(--color-error, #ef4444)"; + case "cancelled": + return "var(--color-muted, #6b7280)"; + } +} + +function formatElapsed(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const sec = ms / 1000; + if (sec < 60) return `${sec.toFixed(1)}s`; + const min = sec / 60; + return `${min.toFixed(1)}m`; +} + +export function SubAgentRunsRail() { + const [runs, setRuns] = useState([]); + const [error, setError] = useState(null); + const [unavailable, setUnavailable] = useState(false); + + useEffect(() => { + let cancel = false; + let timer: ReturnType | null = null; + async function tick() { + try { + const items = await fetchSubAgentRuns(); + if (!cancel) { + setRuns(items); + setError(null); + setUnavailable(false); + } + } catch (e) { + if (cancel) return; + const msg = String(e); + if (msg.includes("not configured")) { + setUnavailable(true); + } else { + setError(msg); + } + } + } + void tick(); + timer = setInterval(tick, POLL_MS); + return () => { + cancel = true; + if (timer) clearInterval(timer); + }; + }, []); + + async function onCancel(id: string) { + try { + await cancelSubAgentRun(id); + // Optimistic refresh — the next poll will reflect the + // server's authoritative view, but flipping the local + // status immediately keeps the UI from showing a stale + // "running" until the next tick. + setRuns((prev) => + prev.map((r) => (r.id === id ? { ...r, status: "cancelled" } : r)), + ); + } catch (e) { + setError(String(e)); + } + } + + if (unavailable) { + // Don't render an empty rail when the feature is off — the + // card slot would just be visual noise. + return null; + } + + return ( +
    +
    +

    Parallel runs

    +

    + Active and recent SubAgent invocations. Polls every 5 seconds. +

    +
    + {error && ( +
    + {error} +
    + )} + {runs.length === 0 ? ( +
    No SubAgent runs recorded yet.
    + ) : ( +
      + {runs.map((r) => { + const elapsed = formatElapsed(r.duration_ms || 0); + return ( +
    1. +
      + + {r.name} + {r.model && · {r.model}} +
      + {r.task && ( +
      + {r.task.length > 80 + ? `${r.task.slice(0, 77)}…` + : r.task} +
      + )} +
      + + {r.tool_call_count} tool calls · {elapsed} + + {r.status === "running" && ( + + )} +
      + {r.status === "failed" && r.error && ( +
      + {r.error.slice(0, 120)} +
      + )} +
    2. + ); + })} +
    + )} +
    + ); +} diff --git a/apps/jarvis-web/src/components/Projects/WorkOverview/WorkOverviewPage.tsx b/apps/jarvis-web/src/components/Projects/WorkOverview/WorkOverviewPage.tsx index a01007a..6b93807 100644 --- a/apps/jarvis-web/src/components/Projects/WorkOverview/WorkOverviewPage.tsx +++ b/apps/jarvis-web/src/components/Projects/WorkOverview/WorkOverviewPage.tsx @@ -1,25 +1,100 @@ import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; +import { useAppStore } from "../../../store/appStore"; import { t } from "../../../utils/i18n"; -import type { WindowDays } from "../../../services/workOverview"; +import { newConversation } from "../../../services/conversations"; +import type { WorkOverview, WindowDays } from "../../../services/workOverview"; import { useWorkOverview } from "./useWorkOverview"; -import { KpiStrip } from "./KpiStrip"; import { HealthCenter } from "./HealthCenter"; import { ThroughputChart } from "./ThroughputChart"; import { ProjectLeaderboard } from "./ProjectLeaderboard"; import { UsagePanel } from "./UsagePanel"; -import { HarnessEvolutionPanel } from "./HarnessEvolutionPanel"; import { ModelComparisonPanel } from "./ModelComparisonPanel"; import { HarnessObservabilityPanel } from "./HarnessObservabilityPanel"; +import { SubAgentRunsRail } from "./SubAgentRunsRail"; const WINDOW_OPTIONS: WindowDays[] = [7, 30, 90]; +function pct(value: number | null | undefined): string { + return value === null || value === undefined + ? t("workOverviewDiagnoseNoData") + : `${Math.round(value * 100)}%`; +} + +function concise(value: string | null | undefined, fallback: string): string { + const v = value?.trim(); + if (!v) return fallback; + return v.length > 140 ? `${v.slice(0, 140)}...` : v; +} + +function buildDiagnosisPrompt(overview: WorkOverview | null, windowDays: WindowDays): string { + const failures = overview?.recent_failures ?? []; + const blocked = overview?.blocked_requirements ?? []; + const failureLines = failures.slice(0, 5).map((row, idx) => + `${idx + 1}. ${row.project_name ?? t("workOverviewDiagnoseUnknownProject")} / ${row.requirement_title ?? row.id}: ${concise(row.error, t("workOverviewDiagnoseNoError"))}`, + ); + const blockedLines = blocked.slice(0, 5).map((row, idx) => + `${idx + 1}. ${row.project_name ?? t("workOverviewDiagnoseUnknownProject")} / ${row.title}: ${concise(row.reason, t("workOverviewDiagnoseNoBlockedReason"))}`, + ); + return t( + "workOverviewDiagnosePrompt", + windowDays, + overview?.run_status_counts.failed ?? 0, + blocked.length, + overview?.running_now.length ?? 0, + pct(overview?.verification_pass_rate), + overview?.missing_stores.length ? overview.missing_stores.join(", ") : t("workOverviewDiagnoseNoMissingStores"), + failureLines.length ? failureLines.join("\n") : t("workOverviewDiagnoseNoFailures"), + blockedLines.length ? blockedLines.join("\n") : t("workOverviewDiagnoseNoBlocked"), + ); +} + +function projectIdsWithRunIssues(overview: WorkOverview | null): string[] { + if (!overview) return []; + const ids = new Set(); + for (const row of overview.recent_failures) { + if (row.project_id) ids.add(row.project_id); + } + for (const row of overview.blocked_requirements ?? []) { + if (row.project_id) ids.add(row.project_id); + } + for (const row of overview.running_now) { + if (row.project_id) ids.add(row.project_id); + } + return [...ids]; +} + // Top-level dashboard shown on `/projects/overview`. Owns the // time-window state + the data hook; child panels just render slices // of the response. export function WorkOverviewPage() { const [windowDays, setWindowDays] = useState(7); const state = useWorkOverview(windowDays); + const navigate = useNavigate(); + const projectsById = useAppStore((s) => s.projectsById); + const setComposerValue = useAppStore((s) => s.setComposerValue); + + const startDiagnosis = () => { + const issueProjectIds = projectIdsWithRunIssues(state.overview); + const projectId = issueProjectIds.length === 1 ? issueProjectIds[0] : null; + const workspacePath = projectId + ? projectsById[projectId]?.workspaces?.[0]?.path ?? null + : null; + void navigate("/"); + newConversation({ projectId, workspacePath }); + setComposerValue(buildDiagnosisPrompt(state.overview, windowDays)); + const submitWhenReady = (attempt = 0) => { + const form = document.getElementById("input-form") as HTMLFormElement | null; + if (form?.requestSubmit) { + form.requestSubmit(); + } else if (attempt < 8) { + window.setTimeout(() => submitWhenReady(attempt + 1), 50); + } else { + document.getElementById("input")?.focus(); + } + }; + window.setTimeout(() => submitWhenReady(), 50); + }; // Keyboard shortcut: bare `R` triggers manual refresh (matches the // banner's button affordance). Skipped while focus is in any @@ -56,9 +131,14 @@ export function WorkOverviewPage() { {t("workOverviewProjectsLink")} - - {t("workOverviewAutoModeLink")} - +
    )} - +
    + +
    -
    - -
    @@ -132,14 +208,9 @@ export function WorkOverviewPage() {
    - + - + {/* Footer kept for absolute timestamp (the banner already shows relative time, but exact wall-clock is useful for ops diff --git a/apps/jarvis-web/src/components/Settings/SettingsPage.tsx b/apps/jarvis-web/src/components/Settings/SettingsPage.tsx index 2837b7a..a7adf47 100644 --- a/apps/jarvis-web/src/components/Settings/SettingsPage.tsx +++ b/apps/jarvis-web/src/components/Settings/SettingsPage.tsx @@ -34,6 +34,8 @@ import { type AppearanceLayoutTab, } from "./sections/AppearanceLayoutSection"; import { ModelsSection } from "./sections/ModelsSection"; +import { RoutingSection } from "./sections/RoutingSection"; +import { ToolsSection } from "./sections/ToolsSection"; import { AgentsSection } from "./sections/AgentsSection"; import { ExtensionsSection, @@ -99,6 +101,16 @@ const NAV_GROUPS: NavGroup[] = [ labelKey: "settingsNavModels", fallback: "Models", }, + { + id: "routing", + labelKey: "settingsNavRouting", + fallback: "Routing", + }, + { + id: "tools", + labelKey: "settingsNavTools", + fallback: "Tools", + }, { id: "subagents", labelKey: "settingsNavSubagents", @@ -512,6 +524,10 @@ function renderSection(parsed: ParsedHash, setTab: (tab: string) => void) { return ; case "models": return ; + case "routing": + return ; + case "tools": + return ; case "subagents": return ; case "extensions": diff --git a/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx b/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx index 7741bf6..f10dcda 100644 --- a/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx +++ b/apps/jarvis-web/src/components/Settings/sections/McpSection.tsx @@ -14,9 +14,11 @@ import { checkMcpHealth, configFromCommandLine, listMcpServers, + reloadMcpServer, removeMcpServer, type McpHealth, type McpServerInfo, + type McpServerStatus, } from "../../../services/mcp"; function tx(key: string, fallback: string): string { @@ -29,7 +31,13 @@ type LoadState = | { kind: "ready"; servers: McpServerInfo[] } | { kind: "error"; message: string }; -export function McpSection({ embedded }: { embedded?: boolean } = {}) { +export function McpSection({ + embedded, + refreshToken = 0, +}: { + embedded?: boolean; + refreshToken?: number; +} = {}) { const [state, setState] = useState({ kind: "loading" }); const [adding, setAdding] = useState(false); const [healthByPrefix, setHealthByPrefix] = useState>({}); @@ -47,7 +55,7 @@ export function McpSection({ embedded }: { embedded?: boolean } = {}) { useEffect(() => { refresh(); - }, []); + }, [refreshToken]); const handleAdd = async (e: React.FormEvent) => { e.preventDefault(); @@ -92,6 +100,34 @@ export function McpSection({ embedded }: { embedded?: boolean } = {}) { } }; + const handleReload = async (prefix: string) => { + setErrorByPrefix((s) => ({ ...s, [prefix]: "" })); + setHealthByPrefix((s) => ({ ...s, [prefix]: "checking" })); + try { + const result = await reloadMcpServer(prefix); + setHealthByPrefix((s) => ({ + ...s, + [prefix]: { + ok: true, + latency_ms: result.latency_ms, + tools: result.tools.length, + }, + })); + refresh(); + } catch (e: unknown) { + setErrorByPrefix((s) => ({ + ...s, + [prefix]: t("mcpReloadFailed", String(e)), + })); + setHealthByPrefix((s) => { + const next = { ...s }; + delete next[prefix]; + return next; + }); + refresh(); + } + }; + return (
    - {renderList(state, handleRemove, handleHealth, healthByPrefix, errorByPrefix)} + {renderList( + state, + handleRemove, + handleHealth, + handleReload, + healthByPrefix, + errorByPrefix, + )}
    @@ -109,7 +152,7 @@ export function McpSection({ embedded }: { embedded?: boolean } = {}) {
    {tx("mcpCommandLineHelp", "e.g. uvx mcp-server-filesystem /tmp")}
    -
    + { void handleAdd(e); }}>