From 5f6f7e39de629a3ea33f9ba89a0b9d16822acd31 Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Wed, 16 Sep 2026 22:43:54 -0500 Subject: [PATCH 1/9] feat(release): enhance release workflow to resolve version from package.json and update action to v3 --- .github/workflows/release.yml | 17 ++++++++++++++++- README.md | 2 +- apps/acp/package.json | 2 +- apps/cli/package.json | 2 +- apps/daemon/package.json | 2 +- apps/vscode/package.json | 2 +- package.json | 2 +- packages/automation/package.json | 2 +- packages/host/package.json | 2 +- packages/mcp/package.json | 2 +- packages/mcp/web/package.json | 2 +- packages/sdk/package.json | 2 +- packages/search-kit/package.json | 2 +- packages/v8/package.json | 2 +- 14 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c99ab914..dd5bb050 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,12 +61,27 @@ jobs: with: path: dist-vsix merge-multiple: true + - name: Resolve release tag from package.json + id: meta + run: | + version="$(node -p "require('./package.json').version")" + if [ -z "$version" ]; then + echo "::error::Could not read version from package.json" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v${version}" >> "$GITHUB_OUTPUT" - name: Generate release notes run: cp CHANGELOG.md release-notes.md - - uses: softprops/action-gh-release@v2 + # workflow_dispatch has no tag ref; create/update the GitHub Release from package.json. + - uses: softprops/action-gh-release@v3 with: + tag_name: ${{ steps.meta.outputs.tag }} + name: ${{ steps.meta.outputs.tag }} + target_commitish: ${{ github.sha }} body_path: release-notes.md files: 'dist-vsix/*.vsix' + fail_on_unmatched_files: true publish-marketplace: needs: package diff --git a/README.md b/README.md index 2cbd5591..9369045f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ License: AGPL v3 VS Code 1.124+ Node 20+ - Version 2.9.57 + Version 2.9.58 Documentation

diff --git a/apps/acp/package.json b/apps/acp/package.json index 6b58e605..0b8fc0eb 100644 --- a/apps/acp/package.json +++ b/apps/acp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/acp", - "version": "2.9.57", + "version": "2.9.58", "description": "Mitii ACP-lite stdio bridge (Phase 3). Decision Policy remains authority; V8 does not import ACP.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/cli/package.json b/apps/cli/package.json index 50c08350..7b63f04f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/cli", - "version": "2.9.57", + "version": "2.9.58", "description": "Mitii headless CLI over @mitii/sdk. Phase 0: --origin/--autonomy/--agent for CI automation.", "license": "AGPL-3.0-or-later", "publishConfig": { diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 5938f960..1a683e23 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/daemon", - "version": "2.9.57", + "version": "2.9.58", "description": "Mitii automation daemon process entry (Phase 1). Long-lived schedule runner.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 8ea88bf9..80fbd99a 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with repository-aware context and controlled execution", - "version": "2.9.57", + "version": "2.9.58", "publisher": "mitii", "license": "AGPL-3.0-or-later", "icon": "media/mitii-logo.png", diff --git a/package.json b/package.json index 70cd6683..b2593b94 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mitii-ai-agent", "description": "Private Mitii monorepo workspace orchestrator. Product packages: @mitii/v8, @mitii/sdk, @mitii/automation, @mitii/search-kit, @mitii/mcp, @mitii/mcp-web, @mitii/host, @mitii/cli, @mitii/daemon, @mitii/acp, apps/vscode.", - "version": "2.9.57", + "version": "2.9.58", "private": true, "license": "AGPL-3.0-or-later", "author": { diff --git a/packages/automation/package.json b/packages/automation/package.json index 1d31bbb8..cd69f075 100644 --- a/packages/automation/package.json +++ b/packages/automation/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/automation", - "version": "2.9.57", + "version": "2.9.58", "description": "Mitii automation control plane: schedules, event ingress, claim/lease runner, webhooks (Phases 1–2).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/package.json b/packages/host/package.json index 5ce1fc24..c952cbca 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/host", - "version": "2.9.57", + "version": "2.9.58", "description": "Shared host kit for Mitii apps: SQLite injection, workspace indexing, repository context, durable ports (checkpoints/memory/skills/search/network), project rules, provider presets. Web retrieval via @mitii/search-kit.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 31907166..e92a5af9 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp", - "version": "2.9.57", + "version": "2.9.58", "description": "Mitii MCP client kit: connect to MCP servers (stdio/SSE/streamable-HTTP) and register tools into V8 ToolRegistry. Does not expose Mitii as an MCP server.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/web/package.json b/packages/mcp/web/package.json index 06be26ac..1365d14f 100644 --- a/packages/mcp/web/package.json +++ b/packages/mcp/web/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp-web", - "version": "2.9.57", + "version": "2.9.58", "description": "Mitii MCP stdio server under packages/mcp/web: web_search, fetch_url, optional memory_search via search-kit (no v8).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5e9a35bf..69cbeb8d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/sdk", - "version": "2.9.57", + "version": "2.9.58", "description": "Host-neutral Mitii programmatic API over @mitii/v8 Agent Engine.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/search-kit/package.json b/packages/search-kit/package.json index 65f9d2ea..1e4a58d9 100644 --- a/packages/search-kit/package.json +++ b/packages/search-kit/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/search-kit", - "version": "2.9.57", + "version": "2.9.58", "description": "Mitii web retrieval kit: pluggable search providers, content resolvers, and URL safety. Host-neutral; no V8 dependency.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/package.json b/packages/v8/package.json index 2556cbe6..e640ee45 100644 --- a/packages/v8/package.json +++ b/packages/v8/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/v8", - "version": "2.9.57", + "version": "2.9.58", "description": "Host-neutral Mitii V8 agent runtime (modules + engine).", "license": "AGPL-3.0-or-later", "type": "module", From 86d74e4125ad2db84521c1e40b34baafa98cffda Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Thu, 17 Sep 2026 21:40:19 -0500 Subject: [PATCH 2/9] feat(vscode): settings panel overhaul, live sandbox process, and routing improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code extension: - Rework settings panel with round-trip persistence (sidebar ↔ webview) - Add live sandbox process support (liveSandboxProcess.ts + tests) - Update NumberField, SliderField, ComposerControls, and related UI components - Add vitest infrastructure for webview-ui (vitest.config.ts, vscode mock) - Add SETTINGS_COVERAGE.md and settings harness test helper - Clean up redundant activation events in package.json Agent engine / decision policy: - Refine ResolveRoute and BuildToolGrant actions - Update RulePatterns classifier - Add DecisionPolicyPipeline spec cases Skills pipeline: - Extend SkillsSelectInput contract and pipeline logic - Add SkillsPipeline spec coverage SDK / host: - Add new contract fields (packages/sdk) - Update createHostLlmPorts spec and testProviderConnection Misc: - Update CLI help text and READMEs - pnpm-lock.yaml sync --- README.md | 2 +- apps/acp/package.json | 2 +- apps/cli/README.md | 2 +- apps/cli/package.json | 2 +- apps/cli/src/help.ts | 2 +- apps/daemon/package.json | 2 +- apps/vscode/README.md | 2 +- apps/vscode/package.json | 34 +- apps/vscode/src/hostAsk.ts | 5 + apps/vscode/src/liveSandboxProcess.ts | 85 ++++ apps/vscode/src/ports.ts | 32 +- apps/vscode/src/protocol.ts | 23 +- apps/vscode/src/settingsFields.ts | 18 +- apps/vscode/src/sidebar.ts | 109 +++++- apps/vscode/tests/SETTINGS_COVERAGE.md | 29 ++ apps/vscode/tests/helpers/settingsHarness.ts | 66 ++++ apps/vscode/tests/liveSandboxProcess.test.ts | 96 +++++ apps/vscode/tests/settingsFields.test.ts | 76 +--- apps/vscode/tests/settingsRoundTrip.test.ts | 105 +++++ .../tests/sidebarSettingsPersistence.test.ts | 62 +-- apps/vscode/webview-ui/package.json | 6 +- apps/vscode/webview-ui/src/App.tsx | 88 ++++- .../src/components/ComposerControls.tsx | 2 +- .../src/components/IndexingStatusBar.tsx | 14 +- .../webview-ui/src/components/MessageList.tsx | 2 +- .../webview-ui/src/components/NumberField.tsx | 12 +- .../webview-ui/src/components/ReviewPanel.tsx | 2 +- .../src/components/SettingsPanel.tsx | 80 ++-- .../webview-ui/src/components/SliderField.tsx | 1 + .../src/components/WorkingTreeReviewBar.tsx | 23 +- apps/vscode/webview-ui/src/protocol.ts | 23 +- apps/vscode/webview-ui/src/styles.css | 24 +- .../vscode/webview-ui/tests/settings.test.tsx | 254 ++++++++++++ apps/vscode/webview-ui/tests/vscode.ts | 2 + apps/vscode/webview-ui/vitest.config.ts | 20 + package.json | 8 +- packages/automation/package.json | 2 +- packages/host/package.json | 2 +- .../src/config/createHostLlmPorts.spec.ts | 21 + .../host/src/config/testProviderConnection.ts | 5 +- packages/mcp/package.json | 2 +- packages/mcp/web/package.json | 2 +- packages/sdk/package.json | 2 +- packages/sdk/src/contracts.ts | 4 + packages/search-kit/package.json | 2 +- packages/v8/package.json | 2 +- .../contracts/input/AgentEngineInput.ts | 5 + .../agent-engine/pipeline/executeStart.ts | 2 + .../pipeline/executeStartEnrichment.ts | 1 + .../pipeline/executeToolSupport.ts | 2 + .../pipeline/modelLoopToolPhase.ts | 3 + .../agent-engine/pipeline/modelToolLoop.ts | 2 + .../pipeline/verificationFinish.ts | 2 + .../decision-policy/actions/BuildToolGrant.ts | 7 +- .../decision-policy/actions/ResolveRoute.ts | 28 +- .../modules/decision-policy/actions/index.ts | 2 +- .../tests/DecisionPolicyPipeline.spec.ts | 44 +++ .../tests/fixtures/goldenCases.ts | 8 +- .../intent/classifiers/rule/RulePatterns.ts | 5 +- .../contracts/input/SkillsSelectInput.ts | 8 + .../modules/skills/pipeline/SkillsPipeline.ts | 17 +- .../skills/tests/SkillsPipeline.spec.ts | 39 ++ pnpm-lock.yaml | 370 +++++++++++++++++- 63 files changed, 1619 insertions(+), 285 deletions(-) create mode 100644 apps/vscode/src/liveSandboxProcess.ts create mode 100644 apps/vscode/tests/SETTINGS_COVERAGE.md create mode 100644 apps/vscode/tests/helpers/settingsHarness.ts create mode 100644 apps/vscode/tests/liveSandboxProcess.test.ts create mode 100644 apps/vscode/tests/settingsRoundTrip.test.ts create mode 100644 apps/vscode/webview-ui/tests/settings.test.tsx create mode 100644 apps/vscode/webview-ui/tests/vscode.ts create mode 100644 apps/vscode/webview-ui/vitest.config.ts diff --git a/README.md b/README.md index 9369045f..e786f70d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ License: AGPL v3 VS Code 1.124+ Node 20+ - Version 2.9.58 + Version 2.9.59 Documentation

diff --git a/apps/acp/package.json b/apps/acp/package.json index 0b8fc0eb..54e48711 100644 --- a/apps/acp/package.json +++ b/apps/acp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/acp", - "version": "2.9.58", + "version": "2.9.59", "description": "Mitii ACP-lite stdio bridge (Phase 3). Decision Policy remains authority; V8 does not import ACP.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/cli/README.md b/apps/cli/README.md index de2f3fe7..e2192088 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -71,7 +71,7 @@ mitii export-session "Summarize this repo" --out session.json --echo | `run --auto ""` | Unattended CI run (agent + apply autonomy; no prompts) | | `session` | Interactive prompt loop with MITII banner | | `index` | Full workspace index + publish repository state | -| `review` | Deterministic review prep / SARIF (`--preview`, `--from`/`--to`, `--commit`, `--format`, `--output`). For LLM findings use `mitii ask … --skill code-review-and-quality` or VS Code **Review** mode | +| `review` | Deterministic review prep / SARIF (`--preview`, `--from`/`--to`, `--commit`, `--format`, `--output`). For LLM findings use `mitii ask … --skill code-review-and-quality` (recipe/skill) or the VS Code **Review** button for working-tree changes | | `status` | Show latest persisted repository state | | `export-session` | Run ask and write secret-free JSON export | | `connect` | Bridge Mitii into Telegram, Discord, or Slack | diff --git a/apps/cli/package.json b/apps/cli/package.json index 7b63f04f..c0c65387 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/cli", - "version": "2.9.58", + "version": "2.9.59", "description": "Mitii headless CLI over @mitii/sdk. Phase 0: --origin/--autonomy/--agent for CI automation.", "license": "AGPL-3.0-or-later", "publishConfig": { diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index 7a9d97ea..f4f7e55b 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -46,7 +46,7 @@ Commands: --format json|sarif Output shape (default json) --output Write result to file --effort low|medium|high Prep effort band - Full LLM review: VS Code Review mode, or: + Full LLM review: VS Code Review button (git changes), or: mitii ask "review these changes" --mode ask --skill code-review-and-quality status Show latest persisted repository state export-session Run ask and write secret-free JSON export diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 1a683e23..abe3274b 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/daemon", - "version": "2.9.58", + "version": "2.9.59", "description": "Mitii automation daemon process entry (Phase 1). Long-lived schedule runner.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 9466c9eb..992f9782 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -45,7 +45,7 @@ For cloud providers, run **Mitii: Set Provider API Key** (stored in VS Code Secr | Command | Purpose | |---|---| | **Mitii: Open Chat** | Open the sidebar | -| **Mitii: Review Working Tree Changes** | Switch to Review mode and run a structured review | +| **Mitii: Review Working Tree Changes** | Run a structured review of git changes (Review button). Optional Code Review button when enabled under Settings → Features | | **Mitii: Index Workspace** | Rebuild repository index | | **Mitii: Show Settings** | Provider, index, MCP, workspace | | **Mitii: Toggle Autocomplete** | Enable or disable FIM inline suggestions | diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 80fbd99a..8af68120 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with repository-aware context and controlled execution", - "version": "2.9.58", + "version": "2.9.59", "publisher": "mitii", "license": "AGPL-3.0-or-later", "icon": "media/mitii-logo.png", @@ -1028,6 +1028,11 @@ "default": true, "description": "Show live reasoning / thinking activity in the Mitii chat UI" }, + "mitii.ui.features.codeReviewButton": { + "type": "boolean", + "default": false, + "description": "Show the Code Review button on the working-tree review bar (in addition to Review). Off by default; enable under Settings → Features." + }, "mitii.ui.reasoningPreviewMaxChars": { "type": "number", "default": 8000, @@ -1365,6 +1370,33 @@ } } } + }, + "mitii.tokenBudget.visiblePlanMinUsableRatio": { + "type": "number", + "default": 0.35, + "minimum": 0, + "maximum": 1, + "description": "Visible-plan affordability threshold as a fraction of the context window." + }, + "mitii.tokenBudget.changeImpactMinUsableRatio": { + "type": "number", + "default": 0.35, + "minimum": 0, + "maximum": 1, + "description": "Change-impact affordability threshold as a fraction of the context window." + }, + "mitii.tokenBudget.maxPatchesPerCallCap": { + "type": "number", + "default": 96, + "minimum": 1, + "description": "Maximum patches allowed in one mutation call." + }, + "mitii.loopPolicy.maxStructuredReviewRecoveries": { + "type": "number", + "default": 2, + "minimum": 0, + "maximum": 8, + "description": "Recovery attempts when a structured review ends without findings (0 disables)." } } } diff --git a/apps/vscode/src/hostAsk.ts b/apps/vscode/src/hostAsk.ts index 2ee17dc5..50b9aa93 100644 --- a/apps/vscode/src/hostAsk.ts +++ b/apps/vscode/src/hostAsk.ts @@ -166,6 +166,8 @@ export async function runAskInOutputChannel(options: { approvalMode?: string; pinnedPaths?: string[]; requiredSkillIds?: string[]; + /** Skills that must not be auto-matched for this run. */ + excludedSkillIds?: string[]; /** Attached MCP server ids for this turn (@mcp: / host pin). */ requiredMcpServerIds?: string[]; workspaceId?: string; @@ -475,6 +477,9 @@ export async function runAskInOutputChannel(options: { ...(options.requiredSkillIds && options.requiredSkillIds.length > 0 ? { requiredSkillIds: [...options.requiredSkillIds] } : {}), + ...(options.excludedSkillIds && options.excludedSkillIds.length > 0 + ? { excludedSkillIds: [...options.excludedSkillIds] } + : {}), ...(options.requiredMcpServerIds && options.requiredMcpServerIds.length > 0 ? { requiredMcpServerIds: [...options.requiredMcpServerIds] } diff --git a/apps/vscode/src/liveSandboxProcess.ts b/apps/vscode/src/liveSandboxProcess.ts new file mode 100644 index 00000000..e0e095f4 --- /dev/null +++ b/apps/vscode/src/liveSandboxProcess.ts @@ -0,0 +1,85 @@ +import type * as vscode from 'vscode'; +import type { ProcessPort, ProcessExecRequest } from '@mitii/v8'; +import { + createSandboxedProcessPort, + resolveSandboxPolicy, + resolveSandboxSettingsFromPreset, + type SandboxBackend, + type SandboxNetworkMode, +} from '@mitii/host'; + +type ResolvedSandboxSettings = { + enabled: boolean; + network: SandboxNetworkMode; +}; + +function readSandboxSettings(vs: typeof vscode): ResolvedSandboxSettings { + const cfg = vs.workspace.getConfiguration('mitii'); + const configured = (key: string): boolean => { + const value = cfg.inspect(key); + return ( + value?.globalValue !== undefined || + value?.workspaceValue !== undefined || + value?.workspaceFolderValue !== undefined + ); + }; + return resolveSandboxSettingsFromPreset({ + approvalMode: cfg.get('safety.approvalMode') ?? 'guided', + ...(configured('safety.sandbox.enabled') + ? { enabled: cfg.get('safety.sandbox.enabled') === true } + : {}), + ...(configured('safety.sandbox.network') + ? { network: cfg.get('safety.sandbox.network') ?? 'deny' } + : {}), + }); +} + +function settingsKey(settings: ResolvedSandboxSettings): string { + return `${settings.enabled ? 1 : 0}:${settings.network}`; +} + +/** + * Process port that refreshes access policy when settings change, without + * rebuilding a wrapper on every exec. + * + * Once sandbox has been enabled for this port, it cannot be disabled through + * live settings (fail closed). Network may still tighten/loosen with approval + * mode so Guided ↔ Full access keeps working mid-session. + */ +export function createLiveSandboxedProcessPort( + vs: typeof vscode, + inner: ProcessPort, + workspaceRoot: string, + backend: SandboxBackend, +): ProcessPort { + let cachedKey: string | undefined; + let cachedPort: ProcessPort | undefined; + let sandboxLatchedOn = false; + + const resolvePort = (): ProcessPort => { + const raw = readSandboxSettings(vs); + const settings: ResolvedSandboxSettings = { + // Latch: after sandbox has run enabled, ignore live disable. + enabled: raw.enabled || sandboxLatchedOn, + network: raw.network, + }; + if (settings.enabled) sandboxLatchedOn = true; + + const key = settingsKey(settings); + if (cachedPort && cachedKey === key) return cachedPort; + + cachedKey = key; + cachedPort = createSandboxedProcessPort( + inner, + resolveSandboxPolicy({ ...settings, workspaceRoot }), + backend, + ); + return cachedPort; + }; + + return { + execFile(request: ProcessExecRequest) { + return resolvePort().execFile(request); + }, + }; +} diff --git a/apps/vscode/src/ports.ts b/apps/vscode/src/ports.ts index 75812738..d259c6b3 100644 --- a/apps/vscode/src/ports.ts +++ b/apps/vscode/src/ports.ts @@ -26,19 +26,17 @@ import { createHostNetworkPort, createHostRepositoryGraphPort, createOptionalSearchPort, - createSandboxedProcessPort, createWorkspaceCheckpointStore, createWorkspaceKnowledgeGraph, createWorkspaceVerificationStore, detectSandboxBackend, resolveMemoryEmbeddingPort, - resolveSandboxPolicy, - resolveSandboxSettingsFromPreset, resolveProviderApiKey, type SandboxBackendPrefer, } from '@mitii/host'; import type * as vscode from 'vscode'; +import { createLiveSandboxedProcessPort } from './liveSandboxProcess.js'; import { VscodeDiagnosticsPort } from './diagnosticsPort.js'; import { getSharedMcpManager } from './mcp/manager.js'; import { defaultMcpSettings, readMcpSettings } from './mcpConfig.js'; @@ -280,25 +278,6 @@ export async function createVscodeClient( env: searchEnv, }); const cfg = vs.workspace.getConfiguration('mitii'); - const sandboxInspectEnabled = cfg.inspect('safety.sandbox.enabled'); - const sandboxInspectNetwork = cfg.inspect('safety.sandbox.network'); - const sandboxEnabledUnset = - sandboxInspectEnabled?.globalValue === undefined && - sandboxInspectEnabled?.workspaceValue === undefined && - sandboxInspectEnabled?.workspaceFolderValue === undefined; - const sandboxNetworkUnset = - sandboxInspectNetwork?.globalValue === undefined && - sandboxInspectNetwork?.workspaceValue === undefined && - sandboxInspectNetwork?.workspaceFolderValue === undefined; - const sandboxResolved = resolveSandboxSettingsFromPreset({ - approvalMode: cfg.get('safety.approvalMode') ?? 'guided', - ...(sandboxEnabledUnset - ? {} - : { enabled: cfg.get('safety.sandbox.enabled') === true }), - ...(sandboxNetworkUnset - ? {} - : { network: cfg.get('safety.sandbox.network') ?? 'deny' }), - }); const sandboxBackendRaw = cfg.get('safety.sandbox.backend') ?? 'auto'; const sandboxPrefer: SandboxBackendPrefer = sandboxBackendRaw === 'docker' || @@ -314,13 +293,10 @@ export async function createVscodeClient( ? new ToolRuntimePipeline( { fileSystem, - process: createSandboxedProcessPort( + process: createLiveSandboxedProcessPort( + vs, new NodeProcessAdapter(), - resolveSandboxPolicy({ - enabled: sandboxResolved.enabled, - network: sandboxResolved.network, - workspaceRoot, - }), + workspaceRoot, detectSandboxBackend({ prefer: sandboxPrefer }), ), network, diff --git a/apps/vscode/src/protocol.ts b/apps/vscode/src/protocol.ts index 58bae2d4..2f46110c 100644 --- a/apps/vscode/src/protocol.ts +++ b/apps/vscode/src/protocol.ts @@ -16,6 +16,7 @@ export type SettingsTab = | 'modes' | 'context' | 'integrations' + | 'features' | 'debug'; export type McpTransport = 'stdio' | 'sse' | 'streamable-http'; @@ -218,6 +219,8 @@ export type SemanticIndexSource = | 'disabled'; export interface IndexStatusSnapshot { + discoveredFileCount?: number; + progressStage?: string; fileCount: number; truncated: boolean; maximumIndexFiles?: number; @@ -248,6 +251,15 @@ export interface WorkspaceSnapshotInfo { displayRoot?: string; } +/** Opt-in VS Code UI features (Settings → Features). */ +export interface UiFeaturesSnapshot { + /** + * When true, show a Code Review button beside Review on the working-tree bar. + * Default off — Review alone covers quick git-change review. + */ + codeReviewButton: boolean; +} + export interface UiSettingsSnapshot { showReasoning: boolean; reasoningPreviewMaxChars: number; @@ -273,6 +285,8 @@ export interface UiSettingsSnapshot { * key is a boolean leaf in VS Code settings.) */ modelIoLogging: boolean; + /** Opt-in IDE features (VS Code only). */ + features: UiFeaturesSnapshot; /** Window-proportional token budget tunables (Debug → developer). */ tokenBudget: TokenBudgetSettingsSnapshot; /** Agent Engine loop/stall threshold tunables (Debug → developer). */ @@ -419,12 +433,14 @@ export type UiSettingsPatch = Partial< | 'tokenBudget' | 'loopPolicy' | 'policyLab' + | 'features' > & { contextToggles?: Partial; runBudget?: Partial; modeDefaults?: Partial< Record<'ask' | 'plan' | 'agent', Partial> >; + features?: Partial; tokenBudget?: { enabled?: boolean; policy?: Record; @@ -684,6 +700,11 @@ export type WebviewToHostMessage = requiredSkillIds?: string[]; /** Attached MCP server ids for this turn (@mcp: / pin chips). */ requiredMcpServerIds?: string[]; + /** + * When mode is review: `changes` = quick diff bug scan (default); + * `code` = thorough code-review-and-quality pass. + */ + reviewKind?: 'changes' | 'code'; } | { type: 'cancel' } | { @@ -891,7 +912,7 @@ export type HostToWebviewMessage = | { type: 'paths.results'; requestId: string; suggestions: PathSuggestion[] } | { type: 'openSettings'; tab?: SettingsTab } | { type: 'setTab'; tab: UiNav } - /** Open chat in Review mode; optionally auto-start an LLM review. */ + /** Run a working-tree review via the Review button (not a chat mode). */ | { type: 'startReview'; autoRun?: boolean; prompt?: string } | { type: 'editorPin'; path: string; source?: ContextPinSource } | { type: 'editorUnpin'; path: string } diff --git a/apps/vscode/src/settingsFields.ts b/apps/vscode/src/settingsFields.ts index ac83978b..30362866 100644 --- a/apps/vscode/src/settingsFields.ts +++ b/apps/vscode/src/settingsFields.ts @@ -71,6 +71,7 @@ export const SETTINGS_NAV_ITEMS: readonly { { id: 'workspace', label: 'Workspace' }, { id: 'modes', label: 'Modes' }, { id: 'context', label: 'Context' }, + { id: 'features', label: 'Features' }, { id: 'integrations', label: 'MCP' }, { id: 'debug', label: 'Developer' }, ]; @@ -117,6 +118,7 @@ export type SettingsPage = | 'workspace' | 'modes' | 'context' + | 'features' | 'mcp' | 'developer'; @@ -246,9 +248,13 @@ export function applyUiPatch( base: UiSettingsSnapshot, patch: UiSettingsPatch, ): UiSettingsSnapshot { + const { features: _features, ...restPatch } = patch; return { ...base, - ...patch, + ...restPatch, + features: patch.features + ? { ...base.features, ...patch.features } + : base.features, contextToggles: patch.contextToggles ? { ...base.contextToggles, ...patch.contextToggles } : base.contextToggles, @@ -866,6 +872,16 @@ export const SETTINGS_FIELDS: readonly SettingsFieldSpec[] = [ reflect: 'raw', sample: false, }, + { + id: 'ui.features.codeReviewButton', + page: 'features', + tab: 'features', + setting: 'ui.features.codeReviewButton', + label: 'Show Code Review button', + kind: 'boolean', + reflect: 'raw', + sample: true, + }, { id: 'ui.reasoningPreviewMaxChars', page: 'modes', diff --git a/apps/vscode/src/sidebar.ts b/apps/vscode/src/sidebar.ts index 491f1bed..f3b3ff48 100644 --- a/apps/vscode/src/sidebar.ts +++ b/apps/vscode/src/sidebar.ts @@ -418,20 +418,33 @@ function needsFullIndexRefresh(index: IndexStatusSnapshot): boolean { return false; } -const REVIEW_HOST_PREFIX = - 'Review every selected file in the current git changes, including both staged and unstaged patches. Start by calling read_git_status with includeDiff=true. You MUST call emit_review_finding at least once before finishing — once per high-signal issue with path, content, existingCode, severity, and category. If there are no material issues, emit a single low/info finding that says so. Prose-only analysis is not a valid review. Do not digress into filename-casing rabbit holes; account for every selected file while preferring high-signal findings.'; +/** Quick git-diff scan — findings only, not multi-axis code review. */ +const REVIEW_CHANGES_HOST_PREFIX = + 'Scan the selected files in the current git changes (staged and unstaged). Start by calling read_git_status with includeDiff=true. Report only material bugs, regressions, and security issues introduced by the diff. You MUST call emit_review_finding at least once before finishing — once per high-signal issue with path, content, existingCode, severity, and category. If there are no material issues, emit a single low/info finding that says so. Prose-only analysis is not valid. Do NOT perform a multi-axis code-quality review (architecture, readability style nits, test-coverage coaching, or merge-readiness playbooks). Do NOT follow code-review-and-quality instructions even if a skill is present.'; + +/** Thorough code review of the same git changes. */ +const REVIEW_CODE_HOST_PREFIX = + 'Perform a thorough code review of every selected file in the current git changes, including both staged and unstaged patches. Start by calling read_git_status with includeDiff=true. Assess correctness, readability, architecture, tests, and operational risk. You MUST call emit_review_finding at least once before finishing — once per high-signal issue with path, content, existingCode, severity, and category. If there are no material issues, emit a single low/info finding that says so. Prose-only analysis is not a valid review. Prefer high-signal findings over nits.'; /** Avoid stacking identical review instructions from UI + host. */ -function buildReviewLlmPrompt(userPrompt: string): string { +function buildReviewLlmPrompt( + userPrompt: string, + kind: 'changes' | 'code' = 'changes', +): string { + const prefix = + kind === 'code' ? REVIEW_CODE_HOST_PREFIX : REVIEW_CHANGES_HOST_PREFIX; const trimmed = userPrompt.trim(); - if (!trimmed) return REVIEW_HOST_PREFIX; + if (!trimmed) return prefix; + // Already host-built (e.g. resume / stacked) — keep as-is. if ( - /\bemit_review_finding\b/i.test(trimmed) || - /^Review the current (?:git |working-tree )/i.test(trimmed) + trimmed.startsWith('Scan the selected files in the current git changes') || + trimmed.startsWith( + 'Perform a thorough code review of every selected file in the current git changes', + ) ) { return trimmed; } - return `${REVIEW_HOST_PREFIX}\n\n${trimmed}`; + return `${prefix}\n\n${trimmed}`; } const EMBEDDING_SOURCES = [ @@ -461,6 +474,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private runCancel?: vscode.CancellationTokenSource; + private liveApprovalMode?: string; private pendingResume?: { resolve: (value: MitiiResumeInput | 'stop') => void; }; @@ -1481,6 +1495,17 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { resolve('stop'); } + private waitForSuspensionResume(suspension: SuspensionPayload): Promise { + this.lastSuspensionRunId = suspension.runId; + this.pendingSuspension = suspension; + this.post({ type: 'run.suspended', suspension }); + return new Promise((resolve) => { + this.pendingResume = { resolve }; + // Apply access changes made while a model/tool call was still in flight. + this.autoApprovePendingToolApprovalIfAllowed(this.liveApprovalMode); + }); + } + private autoApprovePendingToolApprovalIfAllowed( approvalMode: string | undefined, ): void { @@ -1562,10 +1587,17 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { // workspace files and interleave in the same session log). this.runCancel?.cancel(); this.runCancel?.dispose(); + this.liveApprovalMode = undefined; this.runCancel = new this.vs.CancellationTokenSource(); + const reviewKind = + message.mode === 'review' + ? message.reviewKind === 'code' + ? 'code' + : 'changes' + : undefined; let llmPrompt = message.mode === 'review' - ? buildReviewLlmPrompt(prompt) + ? buildReviewLlmPrompt(prompt, reviewKind ?? 'changes') : prompt; if (message.mode === 'review') { const root = this.effectiveRoot(); @@ -1692,6 +1724,10 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { approvalMode: message.approvalMode, pinnedPaths: message.pinnedPaths, requiredSkillIds: message.requiredSkillIds, + excludedSkillIds: + message.mode === 'review' && reviewKind !== 'code' + ? ['code-review-and-quality'] + : undefined, requiredMcpServerIds: message.requiredMcpServerIds, workspaceId: this.getWorkspaceId(), workspaceState: this.host.workspaceState, @@ -1833,12 +1869,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { this.post({ type: 'setPlan', plan: planView }); } } - this.lastSuspensionRunId = suspension.runId; - this.pendingSuspension = suspension; - this.post({ type: 'run.suspended', suspension }); - return new Promise((resolve) => { - this.pendingResume = { resolve }; - }); + return this.waitForSuspensionResume(suspension); }, }, }); @@ -2190,6 +2221,8 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { ...this.lastIndex, message, readiness: 'indexing', + discoveredFileCount: undefined, + progressStage: undefined, }; this.post({ type: 'index.status', index: this.lastIndex }); } @@ -2209,6 +2242,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { secretKey: (await this.secrets.get('mitii.provider.apiKey')) ?? undefined, }); + this.discoveredModels = []; const result = await testProviderConnection({ type: message.provider.type, baseUrl: message.provider.baseUrl, @@ -2421,6 +2455,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { ? error.message : 'Settings could not be saved.', }); + return; } // Echo full state after the quick ack so Save never waits on bootstrap. await this.sendBootstrap(); @@ -2458,8 +2493,21 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { const target = this.configurationTarget(); const update = async (key: string, value: unknown): Promise => { if (configValuesEqual(cfg.get(key), value)) return; - await cfg.update(key, value, target); + try { + await cfg.update(key, value, target); + } catch (error) { + if (error instanceof Error && /not a registered configuration/i.test(error.message)) { + throw new Error(`${error.message} Reload the VS Code window to load the extension's updated settings schema, then save again.`); + } + throw error; + } }; + const approvalMode = message.approvalMode ?? message.ui?.approvalMode; + if (approvalMode !== undefined) { + await update('safety.approvalMode', approvalMode); + if (this.runCancel) this.liveApprovalMode = approvalMode; + this.autoApprovePendingToolApprovalIfAllowed(approvalMode); + } if (message.provider) { await this.writeProviderSettings(message.provider); } @@ -2473,6 +2521,12 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { if (message.ui.showReasoning !== undefined) { await update('ui.showReasoning', message.ui.showReasoning); } + if (message.ui.features?.codeReviewButton !== undefined) { + await update( + 'ui.features.codeReviewButton', + message.ui.features.codeReviewButton === true, + ); + } if (message.ui.developerEnabled !== undefined) { await update('developer.enabled', message.ui.developerEnabled); } @@ -2601,11 +2655,6 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { } } } - const approvalMode = message.approvalMode ?? message.ui?.approvalMode; - if (approvalMode !== undefined) { - await update('safety.approvalMode', approvalMode); - this.autoApprovePendingToolApprovalIfAllowed(approvalMode); - } if (message.workspaceRootOverride !== undefined) { await update('workspace.rootPathOverride', message.workspaceRootOverride); } @@ -2709,6 +2758,10 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { Extract['provider'] >, ): Promise { + const type = provider.type ?? this.vs.workspace.getConfiguration('mitii').get('provider.type'); + if (type !== 'echo' && provider.model !== undefined && !provider.model.trim()) { + throw new Error('Choose a model before saving provider settings. Test connection to discover available models.'); + } if (provider.type !== undefined) { await this.writeConfigValue('provider.type', provider.type); } @@ -3157,6 +3210,10 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { developerEnabled: cfg.get('developer.enabled') ?? false, debugLogging: cfg.get('debug') ?? false, modelIoLogging: readModelIoLoggingEnabled(cfg), + features: { + codeReviewButton: + cfg.get('ui.features.codeReviewButton') === true, + }, tokenBudget: readTokenBudgetSettings( cfg, resolveContextWindow(this.vs), @@ -3423,7 +3480,17 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { force: options.force === true, ...(options.filePaths?.length ? { filePaths: options.filePaths } : {}), ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}), - ...(options.onProgress ? { onProgress: options.onProgress } : {}), + onProgress: (progress) => { + options.onProgress?.(progress); + this.lastIndex = { + ...this.lastIndex, + progressStage: progress.stage, + discoveredFileCount: progress.fileCount ?? this.lastIndex.discoveredFileCount, + readiness: 'indexing', + message: progress.message, + }; + this.post({ type: 'index.status', index: this.lastIndex }); + }, semanticIndex: await resolveVsCodeSemanticIndexSettings( this.vs, this.secrets, diff --git a/apps/vscode/tests/SETTINGS_COVERAGE.md b/apps/vscode/tests/SETTINGS_COVERAGE.md new file mode 100644 index 00000000..759f5b2f --- /dev/null +++ b/apps/vscode/tests/SETTINGS_COVERAGE.md @@ -0,0 +1,29 @@ +# Settings regression coverage + +Run `pnpm test` for the full suite, including the rendered webview tests. Run +`pnpm test:webview` for the settings UI suite alone. + +| Suite | What it verifies | +| --- | --- | +| `settingsRoundTrip.test.ts` | Every catalog setting has a VS Code manifest registration (MCP uses the registered parent object). Every enum value, both boolean values, and representative text/numeric edits pass through the actual sidebar settings writer. Configuration and supported host snapshots reflect saved values. Hidden legacy/advanced settings are included here. | +| `sidebarSettingsPersistence.test.ts` | Workspace write target, value normalization/clamping, save/bootstrap ordering, and preservation of provider drafts during connection testing. The configuration mock rejects unregistered keys. | +| `webview-ui/tests/settings.test.tsx` | Mounts the real React App, changes rendered inputs, clicks Save, invokes the actual host writer against an in-memory VS Code configuration adapter, and reads host snapshots back. Scalar inputs are also remounted to verify persistence. Covers provider presets, custom/discovered models, missing-model validation, autocomplete, workspace inputs, all embedding sources, per-mode approvals/models, context switches, run budgets, logging, local loop controls, and both slider/number forms of Simple budgets. | +| `settingsFields.test.ts` | Parsing, clamping, defaults, token-limit calculations, profile reconciliation, mode mappings, and navigation behavior. The former tests that only wrote into a test-local object were replaced by the host/UI suites above. | +| `liveSandboxProcess.test.ts` | Subsequent commands observe access changes on the same process port; explicit sandbox network overrides remain authoritative. | +| `packages/host/src/config/createHostLlmPorts.spec.ts` | Model discovery works before model selection for OpenAI-compatible, Anthropic, and Gemini providers; unsuccessful discovery is not reported as connected. Requests are mocked. | + +Access regressions include upgrading a pending approval, upgrading while a model +call is in flight, switching back before the next approval, and preventing a stale +global Full access value from bypassing a new run's mode default. + +Index regressions distinguish the last published file count from the current +scan's discovered count, show the stage and truncation state, and preserve unsaved +embedding/file-limit changes when progress messages arrive. Discovered files are +not presented as an exact processed-files progress counter. + +These tests use jsdom and a VS Code configuration adapter, not a running VS Code +extension host. Profile and MCP disk storage are mocked in the webview suite; API +key prompts, real provider credentials/networking, native embedding availability, +and installed-extension activation require integration/manual checks. Reload VS +Code after updating the extension manifest so new settings registrations take +effect. The old installed 2.7.x manifest lacks per-mode approval registrations. diff --git a/apps/vscode/tests/helpers/settingsHarness.ts b/apps/vscode/tests/helpers/settingsHarness.ts new file mode 100644 index 00000000..46bec62f --- /dev/null +++ b/apps/vscode/tests/helpers/settingsHarness.ts @@ -0,0 +1,66 @@ +import { vi } from 'vitest'; +import manifest from '../../package.json'; +import { MitiiSidebarProvider } from '../../src/sidebar'; +const registered: Record = manifest.contributes.configuration.properties; + +export function createProviderHarness() { + const store = new Map([ + ['provider.type', 'echo'], + ['provider.preset', 'echo'], + ['provider.baseUrl', ''], + ['provider.model', 'echo'], + ]); + const updates: Array<{ key: string; value: unknown; target: unknown }> = []; + const cfg = { + inspect: (key: string) => ({ key, workspaceValue: store.get(key) }), + get: (key: string, fallback?: unknown) => + store.has(key) ? store.get(key) : fallback, + update: vi.fn(async (key: string, value: unknown, target: unknown) => { + if (!registered[`mitii.${key}`]) throw new Error(`${key} is not a registered configuration`); + updates.push({ key, value, target }); + if (value === undefined) { + store.delete(key); + } else { + store.set(key, value); + } + }), + }; + const vs = { + ConfigurationTarget: { Global: 'global', Workspace: 'workspace' }, + ExtensionMode: { Development: 1 }, + Uri: { file: (path: string) => ({ fsPath: path, scheme: 'file' }) }, + workspace: { + workspaceFolders: [{ uri: { fsPath: '/tmp/workspace' } }], + getConfiguration: () => cfg, + }, + window: { + showInformationMessage: vi.fn(), + }, + }; + const provider = new MitiiSidebarProvider( + vs as never, + { fsPath: '/tmp/ext', scheme: 'file' } as never, + async () => ({}) as never, + () => undefined, + () => 'workspace', + { appendLine: vi.fn(), show: vi.fn() } as never, + { get: vi.fn(async () => undefined) } as never, + vi.fn(), + async () => ({ fileCount: 0, truncated: false }), + { + extensionMode: 1 as never, + workspaceState: { get: vi.fn(), update: vi.fn() } as never, + inlineDiff: { setPending: vi.fn() } as never, + reviewFindings: {} as never, + onInlineDiffPending: vi.fn(), + }, + ); + (provider as unknown as { sendBootstrap: () => Promise }).sendBootstrap = + async () => undefined; + ( + provider as unknown as { + refreshDiscoveredModels: () => Promise; + } + ).refreshDiscoveredModels = async () => undefined; + return { provider, updates, store, cfg, target: vs.ConfigurationTarget.Workspace }; +} diff --git a/apps/vscode/tests/liveSandboxProcess.test.ts b/apps/vscode/tests/liveSandboxProcess.test.ts new file mode 100644 index 00000000..4ed45f68 --- /dev/null +++ b/apps/vscode/tests/liveSandboxProcess.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createLiveSandboxedProcessPort } from '../src/liveSandboxProcess'; +import type { SandboxBackend } from '@mitii/host'; + +function harness() { + const settings = new Map([['safety.approvalMode', 'guided']]); + const vs = { + workspace: { + getConfiguration: () => ({ + get: (key: string) => settings.get(key), + inspect: (key: string) => + settings.has(key) ? { workspaceValue: settings.get(key) } : {}, + }), + }, + }; + const wrap = vi.fn((request) => request); + const backend: SandboxBackend = { id: 'seatbelt', available: true, wrap }; + const inner = { + execFile: vi.fn(async () => ({ + exitCode: 0, + stdout: '', + stderr: '', + timedOut: false, + cancelled: false, + truncated: false, + })), + }; + const port = createLiveSandboxedProcessPort( + vs as never, + inner, + '/workspace', + backend, + ); + return { settings, wrap, port }; +} + +const request = { + argv: ['git', 'status'], + cwd: '/workspace', + timeoutMs: 1000, + maxOutputBytes: 1024, +}; + +describe('live command access settings', () => { + it('applies full access and later restricted access on the same process port', async () => { + const { settings, wrap, port } = harness(); + for (const [mode, network] of [ + ['guided', 'deny'], + ['pilot', 'allow'], + ['safe', 'deny'], + ] as const) { + settings.set('safety.approvalMode', mode); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ enabled: true, network }), + ); + } + }); + + it('preserves an explicit network override when access changes', async () => { + const { settings, wrap, port } = harness(); + settings.set('safety.sandbox.network', 'deny'); + settings.set('safety.approvalMode', 'pilot'); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ network: 'deny' }), + ); + }); + + it('reuses the sandboxed wrapper when policy is unchanged', async () => { + const { wrap, port } = harness(); + await port.execFile(request); + await port.execFile(request); + expect(wrap).toHaveBeenCalledTimes(2); + expect(wrap.mock.calls[0]?.[1]).toBe(wrap.mock.calls[1]?.[1]); + }); + + it('ignores a live sandbox disable after sandbox has already been enabled', async () => { + const { settings, wrap, port } = harness(); + settings.set('safety.approvalMode', 'guided'); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ enabled: true }), + ); + + settings.set('safety.sandbox.enabled', false); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ enabled: true }), + ); + }); +}); diff --git a/apps/vscode/tests/settingsFields.test.ts b/apps/vscode/tests/settingsFields.test.ts index 2e977e58..885e47fc 100644 --- a/apps/vscode/tests/settingsFields.test.ts +++ b/apps/vscode/tests/settingsFields.test.ts @@ -69,6 +69,7 @@ const BASE_UI: UiSettingsSnapshot = { developerEnabled: false, debugLogging: false, modelIoLogging: false, + features: { codeReviewButton: false }, tokenBudget: { enabled: false, policy: {}, @@ -156,46 +157,6 @@ const BASE_UI: UiSettingsSnapshot = { }, }; -function emptyStore(): Record { - return {}; -} - -function writeField( - store: Record, - setting: string, - value: unknown, -): void { - store[setting] = value; -} - -function readField(store: Record, setting: string): unknown { - return store[setting]; -} - -function editSaveReflect(fieldId: string, edited: unknown): unknown { - const field = SETTINGS_FIELDS.find((entry) => entry.id === fieldId); - if (!field) throw new Error(`Unknown field ${fieldId}`); - const store = emptyStore(); - let persisted: unknown = edited; - if (field.kind === 'int') { - persisted = normalizeTokenLimit(edited); - if (field.min !== undefined && field.min > 0) { - persisted = Math.max(field.min, Number(persisted) || field.min); - } - } else if (field.kind === 'number') { - const parsed = Number(edited); - persisted = Number.isFinite(parsed) ? parsed : field.sample; - if (field.min !== undefined) { - persisted = Math.max(field.min, Number(persisted)); - } - if (field.max !== undefined) { - persisted = Math.min(field.max, Number(persisted)); - } - } - writeField(store, field.setting, persisted); - return readField(store, field.setting); -} - describe('settings field catalog', () => { it('covers every settings page', () => { const pages = new Set(SETTINGS_FIELDS.map((field) => field.page)); @@ -203,6 +164,7 @@ describe('settings field catalog', () => { 'autocomplete', 'context', 'developer', + 'features', 'mcp', 'modes', 'provider', @@ -221,19 +183,6 @@ describe('settings field catalog', () => { expect(budgetIds).toEqual(visible); }); - it.each(SETTINGS_FIELDS.map((field) => [field.id, field] as const))( - 'edits, saves, and reflects %s', - (_id, field) => { - const reflected = editSaveReflect(field.id, field.sample); - if (field.kind === 'int') { - const expected = Math.max(field.min ?? 0, Math.floor(Number(field.sample))); - expect(reflected).toBe(expected); - } else { - expect(reflected).toEqual(field.sample); - } - expect(field.reflect).toBe('raw'); - }, - ); }); describe('context window edit / save / reflect', () => { @@ -532,6 +481,14 @@ describe('modes fields', () => { expect(reflected.reasoningPreviewMaxChars).toBe(4000); }); + it('saves the Code Review feature toggle', () => { + const next = applyUiPatch(BASE_UI, { + features: { codeReviewButton: true }, + }); + expect(next.features.codeReviewButton).toBe(true); + expect(reflectUiAfterSave(next).features.codeReviewButton).toBe(true); + }); + it('saves run budget caps and reflects them', () => { const next = applyUiPatch(BASE_UI, { runBudget: { @@ -639,18 +596,6 @@ describe('developer fields', () => { }); }); -describe('workspace override', () => { - it('saves a trimmed override and can clear it', () => { - const store = emptyStore(); - writeField(store, 'workspace.rootPathOverride', '/tmp/mitii-workspace'); - expect(readField(store, 'workspace.rootPathOverride')).toBe( - '/tmp/mitii-workspace', - ); - writeField(store, 'workspace.rootPathOverride', null); - expect(readField(store, 'workspace.rootPathOverride')).toBeNull(); - }); -}); - describe('compact settings nav tooltips', () => { it('collapses the left bar at the compact breakpoint', () => { expect(isSettingsNavCompact(300)).toBe(true); @@ -678,6 +623,7 @@ describe('compact settings nav tooltips', () => { 'workspace', 'modes', 'context', + 'features', 'integrations', 'debug', ]); diff --git a/apps/vscode/tests/settingsRoundTrip.test.ts b/apps/vscode/tests/settingsRoundTrip.test.ts new file mode 100644 index 00000000..ad242213 --- /dev/null +++ b/apps/vscode/tests/settingsRoundTrip.test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { SETTINGS_FIELDS } from '../src/settingsFields'; +import { createProviderHarness } from './helpers/settingsHarness'; + +const properties = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).contributes.configuration.properties; +function nested(path: string[], value: unknown): any { + return path.reduceRight((child, key) => ({ [key]: child }), value); +} +function patchFor(id: string, value: unknown): any { + if (id.startsWith('provider.') || id.startsWith('autocomplete.')) return nested(id.split('.'), value); + if (id === 'workspace.rootPathOverride') return { workspaceRootOverride: value }; + if (id === 'workspace.maximumIndexFiles') return { workspaceMaximumIndexFiles: value }; + if (id === 'semanticIndex.source') return { semanticIndex: { source: value } }; + if (id.startsWith('ui.')) return nested(id.split('.'), value); + if (id.startsWith('runBudget.')) return { ui: nested(id.split('.'), value) }; + for (const group of ['tokenBudget', 'loopPolicy']) { + if (id.startsWith(`${group}.`)) { + const key = id.slice(group.length + 1); + return { ui: { [group]: key === 'enabled' ? { enabled: value } : { enabled: true, [group === 'tokenBudget' ? 'policy' : 'thresholds']: { [key]: value } } } }; + } + } + const names: Record = { 'developer.enabled': 'developerEnabled', 'developer.intensityOverrides': 'intensityOverrides', 'developer.modelIo': 'modelIoLogging', debug: 'debugLogging' }; + if (names[id]) return { ui: { [names[id]]: value } }; + throw new Error(`No real save mapping for ${id}`); +} +const editable = SETTINGS_FIELDS.filter(f => !['semanticIndex.backend', 'semanticIndex.enabled', 'mcp.enabled'].includes(f.id)); +const cases = editable.flatMap(field => { + const schema = properties[`mitii.${field.setting}`]; + const values = field.kind === 'boolean' ? [false, true] : schema?.enum ?? [field.sample]; + return values.map((value: unknown) => [field.id, value] as const); +}); +describe('registered settings through the actual sidebar writer', () => { + it.each(SETTINGS_FIELDS.map(f => [f.id, f.setting]))('%s has a VS Code registration', (_id, key) => { + expect(properties[`mitii.${key === 'mcp.enabled' ? 'mcp' : key}`]).toBeDefined(); + }); + it.each(cases)('saves %s = %s and reads it back from configuration', async (id, value) => { + const { provider, store, cfg } = createProviderHarness(); + store.set('developer.intensityOverrides', true); + const host = provider as any; + await host.applySettingsSet({ type: 'settings.set', ...patchFor(id, value) }); + expect(cfg.get(id), id).toEqual(value); + const ui = host.readUi(); + if (id.startsWith('ui.')) { + expect(id.slice(3).split('.').reduce((v: any, k) => v[k], ui)).toEqual(value); + } else if (id.startsWith('autocomplete.')) { + expect(host.readAutocomplete()[id.slice(13)]).toEqual(value); + } else if (id.startsWith('provider.')) { + expect((await host.readProvider())[id.slice(9)]).toEqual(value); + } else if (id === 'semanticIndex.source') { + expect((await host.withEmbedding({ fileCount: 0, truncated: false })).embeddingSource).toEqual(value); + } + }); + + it('applies full access to a pending approval even if stale mode registration fails', async () => { + const { provider, cfg, store } = createProviderHarness(); + const host = provider as any; + const resume = vi.fn(); + host.pendingResume = { resolve: resume }; + host.pendingSuspension = { kind: 'approval_required', runId: 'run-1', approval: { approvalId: 'approval-1' } }; + const update = cfg.update; + cfg.update = vi.fn(async (key, value, target) => { + if (key.startsWith('ui.modeDefaults')) throw new Error(`${key} is not a registered configuration`); + return update(key, value, target); + }); + await expect(host.applySettingsSet({ type: 'settings.set', approvalMode: 'pilot', ui: { modeDefaults: { agent: { approvalMode: 'pilot' } } } })).rejects.toThrow(/Reload the VS Code window/); + expect(store.get('safety.approvalMode')).toBe('pilot'); + expect(resume).toHaveBeenCalledWith(expect.objectContaining({ approvalMode: 'never', approval: { approvalId: 'approval-1', decision: 'approved' } })); + }); +}); + + +describe('access changes during an active run', () => { + const suspension = { kind: 'approval_required', runId: 'run-2', approval: { approvalId: 'approval-2' } }; + it('applies full access selected while a model call was in flight to the next approval', async () => { + const { provider } = createProviderHarness(); + const host = provider as any; + host.runCancel = {}; + await host.applySettingsSet({ type: 'settings.set', approvalMode: 'pilot' }); + await expect(host.waitForSuspensionResume(suspension)).resolves.toMatchObject({ approvalMode: 'never', approval: { decision: 'approved' } }); + }); + it('does not use stale global Full access to bypass a new run mode default', async () => { + const { provider, store } = createProviderHarness(); + const host = provider as any; + store.set('safety.approvalMode', 'pilot'); + const result = host.waitForSuspensionResume(suspension); + const resolved = vi.fn(); + result.then(resolved); + await Promise.resolve(); + expect(resolved).not.toHaveBeenCalled(); + host.pendingResume.resolve('stop'); + await result; + }); + it('does not auto-approve after switching back from full access', async () => { + const { provider } = createProviderHarness(); + const host = provider as any; + host.runCancel = {}; + await host.applySettingsSet({ type: 'settings.set', approvalMode: 'pilot' }); + await host.applySettingsSet({ type: 'settings.set', approvalMode: 'guided' }); + const result = host.waitForSuspensionResume(suspension); + expect(host.pendingResume).toBeDefined(); + host.pendingResume.resolve('stop'); + await expect(result).resolves.toBe('stop'); + }); +}); diff --git a/apps/vscode/tests/sidebarSettingsPersistence.test.ts b/apps/vscode/tests/sidebarSettingsPersistence.test.ts index ae1011a9..6918598b 100644 --- a/apps/vscode/tests/sidebarSettingsPersistence.test.ts +++ b/apps/vscode/tests/sidebarSettingsPersistence.test.ts @@ -3,65 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MitiiSidebarProvider } from '../src/sidebar'; import type { UiSettingsSnapshot } from '../src/protocol'; -function createProviderHarness() { - const store = new Map([ - ['provider.type', 'echo'], - ['provider.preset', 'echo'], - ['provider.baseUrl', ''], - ['provider.model', 'echo'], - ]); - const updates: Array<{ key: string; value: unknown; target: unknown }> = []; - const cfg = { - get: (key: string, fallback?: unknown) => - store.has(key) ? store.get(key) : fallback, - update: vi.fn(async (key: string, value: unknown, target: unknown) => { - updates.push({ key, value, target }); - if (value === undefined) { - store.delete(key); - } else { - store.set(key, value); - } - }), - }; - const vs = { - ConfigurationTarget: { Global: 'global', Workspace: 'workspace' }, - ExtensionMode: { Development: 1 }, - Uri: { file: (path: string) => ({ fsPath: path, scheme: 'file' }) }, - workspace: { - workspaceFolders: [{ uri: { fsPath: '/tmp/workspace' } }], - getConfiguration: () => cfg, - }, - window: { - showInformationMessage: vi.fn(), - }, - }; - const provider = new MitiiSidebarProvider( - vs as never, - { fsPath: '/tmp/ext', scheme: 'file' } as never, - async () => ({}) as never, - () => undefined, - () => 'workspace', - { appendLine: vi.fn(), show: vi.fn() } as never, - { get: vi.fn(async () => undefined) } as never, - vi.fn(), - async () => ({ fileCount: 0, truncated: false }), - { - extensionMode: 1 as never, - workspaceState: { get: vi.fn(), update: vi.fn() } as never, - inlineDiff: {} as never, - reviewFindings: {} as never, - onInlineDiffPending: vi.fn(), - }, - ); - (provider as unknown as { sendBootstrap: () => Promise }).sendBootstrap = - async () => undefined; - ( - provider as unknown as { - refreshDiscoveredModels: () => Promise; - } - ).refreshDiscoveredModels = async () => undefined; - return { provider, updates, target: vs.ConfigurationTarget.Workspace }; -} +import { createProviderHarness } from './helpers/settingsHarness'; const UI_PATCH: Partial = { showReasoning: false, @@ -69,6 +11,7 @@ const UI_PATCH: Partial = { intensityOverrides: true, debugLogging: true, modelIoLogging: true, + features: { codeReviewButton: true }, reasoningPreviewMaxChars: 4000, depth: 'deep', effort: 'high', @@ -181,6 +124,7 @@ describe('MitiiSidebarProvider settings persistence', () => { 'autocomplete.suffixChars', 'autocomplete.temperature', 'ui.showReasoning', + 'ui.features.codeReviewButton', 'developer.enabled', 'developer.intensityOverrides', 'debug', diff --git a/apps/vscode/webview-ui/package.json b/apps/vscode/webview-ui/package.json index 81b6e62e..a6c6fa63 100644 --- a/apps/vscode/webview-ui/package.json +++ b/apps/vscode/webview-ui/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --config vitest.config.ts" }, "dependencies": { "react": "^18.3.1", @@ -15,9 +16,12 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "typescript": "^5.5.2", "vite": "^5.4.11" } diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 0d31dcdd..8bb4f653 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -129,8 +129,12 @@ const EMPTY_TOKEN_USAGE: TokenUsageSnapshot = { }; const REVIEW_SKILL_ID = 'code-review-and-quality'; +/** Quick diff scan — structured findings only, no quality skill. */ const DEFAULT_REVIEW_PROMPT = - 'Review the current working-tree changes. Prefer high-signal bugs and security issues.'; + 'Scan the working-tree diff for material bugs, regressions, and security issues only.'; +/** Thorough code review — attaches code-review-and-quality. */ +const DEFAULT_CODE_REVIEW_PROMPT = + 'Perform a thorough code review of the current working-tree changes across correctness, readability, architecture, tests, and risk.'; function mergeReviewSkillIds(ids: string[]): string[] { const without = ids.filter((id) => id !== REVIEW_SKILL_ID); @@ -245,6 +249,7 @@ const DEFAULT_UI: UiSettingsSnapshot = { developerEnabled: false, debugLogging: false, modelIoLogging: false, + features: { codeReviewButton: false }, tokenBudget: DEFAULT_TOKEN_BUDGET, loopPolicy: DEFAULT_LOOP_POLICY, policyLab: DEFAULT_POLICY_LAB, @@ -299,6 +304,10 @@ function hydrateUiSnapshot( return { ...DEFAULT_UI, ...(raw ?? {}), + features: { + ...DEFAULT_UI.features, + ...(raw?.features ?? {}), + }, modeDefaults: { ...DEFAULT_UI.modeDefaults, ...(raw?.modeDefaults ?? {}), @@ -444,11 +453,15 @@ function mergeUiPatch( tokenBudget: _tb, loopPolicy: _lp, policyLab: _pl, + features: _features, ...scalarPatch } = patch; return { ...base, ...scalarPatch, + features: patch.features + ? { ...base.features, ...patch.features } + : base.features, contextToggles: patch.contextToggles ? { ...base.contextToggles, ...patch.contextToggles } : base.contextToggles, @@ -907,6 +920,8 @@ export function App() { const [connectionMessage, setConnectionMessage] = useState( null, ); + const maximumIndexFilesDraftRef = useRef(undefined); + const embeddingSourceDraftRef = useRef(undefined); const [customModel, setCustomModel] = useState(false); const [index, setIndex] = useState({ fileCount: 0, @@ -945,9 +960,9 @@ export function App() { >([]); const [reviewBarExpandToken, setReviewBarExpandToken] = useState(0); const pendingAutoReviewRef = useRef<{ prompt?: string } | null>(null); - const runReviewRef = useRef<((promptOverride?: string) => void) | null>( - null, - ); + const runReviewRef = useRef< + ((promptOverride?: string, options?: { codeReview?: boolean }) => void) | null + >(null); const [skillItems, setSkillItems] = useState([]); const [automationSpecs, setAutomationSpecs] = useState( [], @@ -1071,6 +1086,23 @@ export function App() { [], ); + const applyIndexStatus = useCallback((incoming: IndexStatusSnapshot) => { + const next = { + ...incoming, + ...(maximumIndexFilesDraftRef.current !== undefined + ? { maximumIndexFiles: maximumIndexFilesDraftRef.current } + : {}), + ...(embeddingSourceDraftRef.current + ? { + embeddingSource: embeddingSourceDraftRef.current, + embeddingEnabled: embeddingSourceDraftRef.current !== 'disabled', + } + : {}), + }; + indexRef.current = next; + setIndex(next); + }, []); + const applyBootstrap = useCallback((msg: HostToWebviewMessage) => { if (msg.type === 'bootstrap' || msg.type === 'settings') { setWorkspace(msg.workspace); @@ -1176,7 +1208,7 @@ export function App() { settingsSavingRef.current = false; setSettingsSaving(false); if (msg.type === 'bootstrap') { - setIndex(msg.index); + applyIndexStatus(msg.index); setOnboardingRequired(msg.onboardingRequired); setHistory(msg.history); setActiveThreadId(msg.activeThreadId); @@ -1250,7 +1282,7 @@ export function App() { applyBootstrap(msg); break; case 'index.status': - setIndex(msg.index); + applyIndexStatus(msg.index); break; case 'run.started': { setRunning(true); @@ -1621,6 +1653,10 @@ export function App() { })); break; case 'settings.saved': + if (settingsSavingRef.current && msg.ok) { + embeddingSourceDraftRef.current = undefined; + maximumIndexFilesDraftRef.current = undefined; + } settingsSavingRef.current = false; setSettingsSaving(false); if (!msg.ok) { @@ -1639,7 +1675,7 @@ export function App() { postToHost({ type: 'ready' }); } return off; - }, [applyBootstrap, applyTokenUsage, markSuspensionResumed]); + }, [applyBootstrap, applyIndexStatus, applyTokenUsage, markSuspensionResumed]); useLayoutEffect(() => { const turnCountChanged = turns.length !== lastTurnCountRef.current; @@ -1693,9 +1729,13 @@ export function App() { }, [prompt, running, mode, ui, approvalMode, pinned, pinnedSkillIds, pinnedMcpServerIds]); const runReview = useCallback( - (promptOverride?: string) => { + (promptOverride?: string, options?: { codeReview?: boolean }) => { if (running) return; - const text = (promptOverride ?? prompt).trim() || DEFAULT_REVIEW_PROMPT; + const codeReview = options?.codeReview === true; + const fallback = codeReview + ? DEFAULT_CODE_REVIEW_PROMPT + : DEFAULT_REVIEW_PROMPT; + const text = (promptOverride ?? prompt).trim() || fallback; stickToBottomRef.current = true; forceScrollToBottomRef.current = true; const defaults = modeDefaultsFromUi(ui, 'ask'); @@ -1705,18 +1745,21 @@ export function App() { depth: defaults.depth, effort: ui.effort, }); - // Keep Ask/Plan/Agent selection; host maps mode:'review' → engine ask. + // Review button sends mode:'review'; keep Ask/Plan/Agent selection. if (mode === 'review') setMode('ask'); setReviewBarExpandToken((n) => n + 1); postToHost({ type: 'ask', prompt: text, mode: 'review', + reviewKind: codeReview ? 'code' : 'changes', depth: intensity.depth, effort: intensity.effort, approvalMode: defaults.approvalMode, pinnedPaths: pinned.map((p) => p.path), - requiredSkillIds: mergeReviewSkillIds(pinnedSkillIds), + requiredSkillIds: codeReview + ? mergeReviewSkillIds(pinnedSkillIds) + : pinnedSkillIds.filter((id) => id !== REVIEW_SKILL_ID), requiredMcpServerIds: pinnedMcpServerIds, }); setPrompt(''); @@ -2305,6 +2348,12 @@ export function App() { const saveAllSettings = () => { (document.activeElement as HTMLElement | null)?.blur?.(); const latestProvider = snapshotProvider(); + if (latestProvider.type !== 'echo' && !latestProvider.model.trim()) { + setError('Choose a model before saving provider settings. Test connection to discover available models.'); + setSettingsTab('model'); + return; + } + setError(null); const latestUi = clearStaleModeModelDefaultsAfterProviderModelChange({ ui: mergeUiPatch( uiRef.current, @@ -2331,6 +2380,9 @@ export function App() { ui: latestUi, workspaceRootOverride: overrideDraft.trim() || null, workspaceMaximumIndexFiles: indexRef.current.maximumIndexFiles ?? 0, + ...(embeddingSourceDraftRef.current + ? { semanticIndex: { source: embeddingSourceDraftRef.current } } + : {}), mcp, approvalMode, profile: { @@ -2659,6 +2711,12 @@ export function App() { postToHost({ type: 'openFile', path, line }) } onRunReview={() => runReview()} + onRunCodeReview={ + ui.features.codeReviewButton + ? () => runReview(undefined, { codeReview: true }) + : undefined + } + showCodeReview={ui.features.codeReviewButton === true} onUndoAll={() => { const changes = [...turns] .reverse() @@ -3060,6 +3118,7 @@ export function App() { 0, Math.min(240000, Math.floor(value)), ); + maximumIndexFilesDraftRef.current = maximumIndexFiles; indexRef.current = { ...indexRef.current, maximumIndexFiles, @@ -3070,12 +3129,17 @@ export function App() { })); }} onEmbeddingSourceChange={(source: SemanticIndexSource) => { + embeddingSourceDraftRef.current = source; + indexRef.current = { + ...indexRef.current, + embeddingSource: source, + embeddingEnabled: source !== 'disabled', + }; setIndex((current) => ({ ...current, embeddingSource: source, embeddingEnabled: source !== 'disabled', })); - postToHost({ type: 'settings.set', semanticIndex: { source } }); }} memories={memories} onAddMemory={(text) => postToHost({ type: 'addMemory', text })} diff --git a/apps/vscode/webview-ui/src/components/ComposerControls.tsx b/apps/vscode/webview-ui/src/components/ComposerControls.tsx index ccc438d4..08b7fbf8 100644 --- a/apps/vscode/webview-ui/src/components/ComposerControls.tsx +++ b/apps/vscode/webview-ui/src/components/ComposerControls.tsx @@ -114,7 +114,7 @@ export const MODE_HINT: Record = { ask: 'Explore and answer — read-only.', plan: 'Analyze and propose a structured plan.', agent: 'Implement changes with controlled execution.', - review: 'Structured working-tree review (use the Review bar above the chat).', + review: 'Git working-tree review via the Review button — not a chat mode.', }; interface ComposerControlsProps { diff --git a/apps/vscode/webview-ui/src/components/IndexingStatusBar.tsx b/apps/vscode/webview-ui/src/components/IndexingStatusBar.tsx index 592f70c8..bfdac095 100644 --- a/apps/vscode/webview-ui/src/components/IndexingStatusBar.tsx +++ b/apps/vscode/webview-ui/src/components/IndexingStatusBar.tsx @@ -103,9 +103,15 @@ function shortLabel(tone: IndexTone, index: IndexStatusSnapshot): string { function detailTooltip(index: IndexStatusSnapshot): string { const parts: string[] = []; - if (index.fileCount > 0) { + if (index.fileCount >= 0) { parts.push(`${index.fileCount.toLocaleString()} files indexed`); } + if (index.discoveredFileCount !== undefined) { + parts.push( + `${index.discoveredFileCount.toLocaleString()} files discovered in current scan`, + ); + } + if (index.progressStage) parts.push(`Stage: ${index.progressStage}`); if (index.readiness) parts.push(`Readiness: ${index.readiness}`); if (index.scanCompleteness) parts.push(`Scan: ${index.scanCompleteness}`); if (index.indexMode) { @@ -142,11 +148,11 @@ export function IndexingStatusBar({ return ( diff --git a/apps/vscode/webview-ui/src/components/MessageList.tsx b/apps/vscode/webview-ui/src/components/MessageList.tsx index 8a31730b..d8a9b3b8 100644 --- a/apps/vscode/webview-ui/src/components/MessageList.tsx +++ b/apps/vscode/webview-ui/src/components/MessageList.tsx @@ -87,7 +87,7 @@ const MODE_LABELS: Record = { ask: 'Ask mode', plan: 'Plan mode', agent: 'Agent mode', - review: 'Review mode', + review: 'Working-tree review', }; export function MessageList({ diff --git a/apps/vscode/webview-ui/src/components/NumberField.tsx b/apps/vscode/webview-ui/src/components/NumberField.tsx index 9813eb84..df2a8853 100644 --- a/apps/vscode/webview-ui/src/components/NumberField.tsx +++ b/apps/vscode/webview-ui/src/components/NumberField.tsx @@ -31,6 +31,7 @@ export function NumberField({ const [draft, setDraft] = useState(String(safeValue)); const draftRef = useRef(draft); const focusedRef = useRef(false); + const valueAtFocusRef = useRef(safeValue); draftRef.current = draft; useEffect(() => { @@ -62,8 +63,14 @@ export function NumberField({ return; } setDraft(String(bounded)); - onDraftChange?.(bounded); - if (bounded !== safeValue) onCommit(bounded); + // Compare against the value when focus began so live parent updates + // (e.g. auto-scaled max output mirroring the draft) do not swallow commits. + if (bounded !== valueAtFocusRef.current) { + onDraftChange?.(bounded); + onCommit(bounded); + return; + } + onDraftChange?.(undefined); }; return ( @@ -82,6 +89,7 @@ export function NumberField({ value={draft} onFocus={() => { focusedRef.current = true; + valueAtFocusRef.current = safeValue; }} onChange={(e) => { const nextDraft = e.target.value; diff --git a/apps/vscode/webview-ui/src/components/ReviewPanel.tsx b/apps/vscode/webview-ui/src/components/ReviewPanel.tsx index a63d7175..9ed8be34 100644 --- a/apps/vscode/webview-ui/src/components/ReviewPanel.tsx +++ b/apps/vscode/webview-ui/src/components/ReviewPanel.tsx @@ -14,7 +14,7 @@ interface ReviewPanelProps { } const DEFAULT_REVIEW_HINT = - 'Review mode is read-only. Run a review below or type a focus (bugs, security, tests). Do not use Agent for review.'; + 'Working-tree review is read-only. Use Run review on the Review bar (not a chat mode). Optional focus: bugs, security, tests.'; function reviewStatusLabel(status: string): string { const normalized = status.trim() || '?'; diff --git a/apps/vscode/webview-ui/src/components/SettingsPanel.tsx b/apps/vscode/webview-ui/src/components/SettingsPanel.tsx index 6551134c..15f4197f 100644 --- a/apps/vscode/webview-ui/src/components/SettingsPanel.tsx +++ b/apps/vscode/webview-ui/src/components/SettingsPanel.tsx @@ -47,6 +47,7 @@ import { IconModel, IconPlan, IconPlug, + IconReview, } from './Icons'; import { NumberField } from './NumberField'; import { TokenBudgetAllocation } from './TokenBudgetAllocation'; @@ -137,6 +138,7 @@ const NAV: { { id: 'workspace', label: 'Workspace', icon: }, { id: 'modes', label: 'Modes', icon: }, { id: 'context', label: 'Context', icon: }, + { id: 'features', label: 'Features', icon: }, { id: 'integrations', label: 'MCP', icon: }, { id: 'debug', label: 'Developer', icon: }, ]; @@ -162,6 +164,10 @@ const PAGE_COPY: Record = { title: 'Context', description: 'What Mitii attaches to each turn.', }, + features: { + title: 'Features', + description: 'Opt-in IDE surfaces. Off by default until you enable them.', + }, integrations: { title: 'MCP', description: 'Optional servers. Off by default.', @@ -490,15 +496,16 @@ export function SettingsPanel(props: SettingsPanelProps) { effective: provider.effectiveContextWindow, fallback: ui.tokenBudget.preview.contextWindowTokens, }); - const storedMaxOutput = normalizeMaximumOutputTokens( - draftMaxOutput ?? provider.maximumOutputTokens, + const storedMaxOutput = normalizeMaximumOutputTokens(provider.maximumOutputTokens); + const previewMaxOutput = normalizeMaximumOutputTokens( + draftMaxOutput ?? storedMaxOutput, ); - const autoMaxOutput = isAutoMaximumOutputTokens(storedMaxOutput); + const autoMaxOutput = isAutoMaximumOutputTokens(previewMaxOutput); const livePreview = useMemo(() => { try { return deriveLiveTokenBudgetPreview({ contextWindowTokens: previewContextWindow, - maximumOutputTokens: storedMaxOutput, + maximumOutputTokens: previewMaxOutput, policy: ui.tokenBudget.enabled ? ui.tokenBudget.policy : undefined, runBudget: ui.runBudget, }); @@ -507,15 +514,17 @@ export function SettingsPanel(props: SettingsPanelProps) { } }, [ previewContextWindow, - storedMaxOutput, + previewMaxOutput, ui.runBudget, ui.tokenBudget.enabled, ui.tokenBudget.policy, ui.tokenBudget.preview, ]); + // When max output is auto (0), show the window-derived reserve so it tracks + // context-window edits live. Typing a positive value pins an override. const displayMaxOutput = autoMaxOutput ? livePreview.maximumOutputTokens - : storedMaxOutput; + : previewMaxOutput; return (
({ ...prev, model: next })); }} > + {options.map((id) => ( - {customModel || !options.includes(provider.model) ? ( + {customModel || (Boolean(provider.model.trim()) && !options.includes(provider.model)) ? ( onProviderChange((prev) => ({ ...prev, @@ -741,16 +752,7 @@ export function SettingsPanel(props: SettingsPanelProps) { onDraftChange={setDraftMaxOutput} onCommit={(value) => { setDraftMaxOutput(undefined); - // Keep auto (0) when the user accepts the derived reserve for - // the current window; only store a hard override when they - // pick a different number (or explicitly type 0). - const next = - value === 0 - ? 0 - : autoMaxOutput && - value === livePreview.maximumOutputTokens - ? 0 - : normalizeMaximumOutputTokens(value); + const next = normalizeMaximumOutputTokens(value); onProviderChange((prev) => ({ ...prev, maximumOutputTokens: next, @@ -765,10 +767,12 @@ export function SettingsPanel(props: SettingsPanelProps) { ? ` (currently ${provider.effectiveContextWindow.toLocaleString()} tokens)` : '' }.` - : `Context window will save as ${provider.contextWindow.toLocaleString()} tokens.`}{' '} + : `Context window will save as ${provider.contextWindow.toLocaleString()} tokens.`} +

+

{autoMaxOutput - ? `Max output follows the window (~${livePreview.maximumOutputTokens.toLocaleString()} tokens). Set a different number to hard-override.` - : `Max output will save as ${storedMaxOutput.toLocaleString()} tokens.`} + ? `Max output auto-scales with the window (${livePreview.maximumOutputTokens.toLocaleString()} tokens). Enter 0 to keep auto, or a positive number to override.` + : `Max output will save as ${previewMaxOutput.toLocaleString()} tokens (fixed override). Set to 0 to resume auto-scaling.`}

{ui.tokenBudget.enabled ? (

@@ -1128,7 +1132,11 @@ export function SettingsPanel(props: SettingsPanelProps) { /> ) : null} + {activeTab === 'features' ? ( +

+ + +

+ When enabled, the review bar shows both{' '} + Review (quick diff findings) and{' '} + Code Review (thorough quality review with the + code-review skill). Off by default. +

+
+
+ ) : null} + {activeTab === 'integrations' ? (
void; onOpenFinding?: (path: string, line?: number) => void; onRunReview: () => void; + /** Deeper code review — only when Settings → Features enables the button. */ + onRunCodeReview?: () => void; + showCodeReview?: boolean; onUndoAll?: () => void; onKeepAll?: () => void; /** Clear sticky review findings (Problems + comments + chips). */ @@ -78,6 +81,8 @@ export function WorkingTreeReviewBar({ onOpenDiff, onOpenFinding, onRunReview, + onRunCodeReview, + showCodeReview = false, onUndoAll, onKeepAll, onDismissFindings, @@ -229,11 +234,22 @@ export function WorkingTreeReviewBar({ type="button" className="wt-review__cta" disabled={running || fileCount === 0} - title="Run a structured read-only review — findings open in the editor" + title="Quick diff scan for bugs/regressions — not a full code review" onClick={onRunReview} > {running ? 'Reviewing…' : 'Review'} + {showCodeReview && onRunCodeReview ? ( + + ) : null}
) : (
@@ -283,8 +299,9 @@ export function WorkingTreeReviewBar({ {tab === 'findings' ? ( findings.length === 0 ? (

- No findings yet. Click Review — issues appear on the code line - in the editor (and in Problems). + No findings yet. Click Review + {showCodeReview ? ' or Code Review' : ''} — issues appear on the + code line in the editor (and in Problems).

) : (
    diff --git a/apps/vscode/webview-ui/src/protocol.ts b/apps/vscode/webview-ui/src/protocol.ts index 58bae2d4..2f46110c 100644 --- a/apps/vscode/webview-ui/src/protocol.ts +++ b/apps/vscode/webview-ui/src/protocol.ts @@ -16,6 +16,7 @@ export type SettingsTab = | 'modes' | 'context' | 'integrations' + | 'features' | 'debug'; export type McpTransport = 'stdio' | 'sse' | 'streamable-http'; @@ -218,6 +219,8 @@ export type SemanticIndexSource = | 'disabled'; export interface IndexStatusSnapshot { + discoveredFileCount?: number; + progressStage?: string; fileCount: number; truncated: boolean; maximumIndexFiles?: number; @@ -248,6 +251,15 @@ export interface WorkspaceSnapshotInfo { displayRoot?: string; } +/** Opt-in VS Code UI features (Settings → Features). */ +export interface UiFeaturesSnapshot { + /** + * When true, show a Code Review button beside Review on the working-tree bar. + * Default off — Review alone covers quick git-change review. + */ + codeReviewButton: boolean; +} + export interface UiSettingsSnapshot { showReasoning: boolean; reasoningPreviewMaxChars: number; @@ -273,6 +285,8 @@ export interface UiSettingsSnapshot { * key is a boolean leaf in VS Code settings.) */ modelIoLogging: boolean; + /** Opt-in IDE features (VS Code only). */ + features: UiFeaturesSnapshot; /** Window-proportional token budget tunables (Debug → developer). */ tokenBudget: TokenBudgetSettingsSnapshot; /** Agent Engine loop/stall threshold tunables (Debug → developer). */ @@ -419,12 +433,14 @@ export type UiSettingsPatch = Partial< | 'tokenBudget' | 'loopPolicy' | 'policyLab' + | 'features' > & { contextToggles?: Partial; runBudget?: Partial; modeDefaults?: Partial< Record<'ask' | 'plan' | 'agent', Partial> >; + features?: Partial; tokenBudget?: { enabled?: boolean; policy?: Record; @@ -684,6 +700,11 @@ export type WebviewToHostMessage = requiredSkillIds?: string[]; /** Attached MCP server ids for this turn (@mcp: / pin chips). */ requiredMcpServerIds?: string[]; + /** + * When mode is review: `changes` = quick diff bug scan (default); + * `code` = thorough code-review-and-quality pass. + */ + reviewKind?: 'changes' | 'code'; } | { type: 'cancel' } | { @@ -891,7 +912,7 @@ export type HostToWebviewMessage = | { type: 'paths.results'; requestId: string; suggestions: PathSuggestion[] } | { type: 'openSettings'; tab?: SettingsTab } | { type: 'setTab'; tab: UiNav } - /** Open chat in Review mode; optionally auto-start an LLM review. */ + /** Run a working-tree review via the Review button (not a chat mode). */ | { type: 'startReview'; autoRun?: boolean; prompt?: string } | { type: 'editorPin'; path: string; source?: ContextPinSource } | { type: 'editorUnpin'; path: string } diff --git a/apps/vscode/webview-ui/src/styles.css b/apps/vscode/webview-ui/src/styles.css index ba64ddd7..e16a68d0 100644 --- a/apps/vscode/webview-ui/src/styles.css +++ b/apps/vscode/webview-ui/src/styles.css @@ -3778,9 +3778,12 @@ select.depth-select { display: inline-flex; align-items: center; justify-content: center; - width: 28px; + min-width: 28px; + gap: 5px; + white-space: nowrap; + font-size: 11px; height: 28px; - padding: 0; + padding: 0 7px; border-radius: 6px; border: 1px solid var(--mitii-border-soft); background: transparent; @@ -3791,6 +3794,13 @@ select.depth-select { background 120ms ease; } +.indexing-chip--icon { + width: 28px; + min-width: 28px; + padding: 0; + gap: 0; +} + .indexing-chip:hover { border-color: color-mix(in srgb, var(--index-tone) 40%, var(--mitii-border)); background: color-mix(in srgb, var(--index-tone) 8%, transparent); @@ -5396,6 +5406,12 @@ button.md-file-link.md-inline-code:hover { background: color-mix(in srgb, var(--mitii-ok) 18%, var(--mitii-panel)); } +.wt-review__cta--code { + border-color: color-mix(in srgb, #38bdf8 45%, var(--mitii-border-soft)); + color: var(--mitii-text); + background: color-mix(in srgb, #38bdf8 14%, var(--mitii-panel)); +} + .wt-review__cta:hover:not(:disabled) { background: color-mix(in srgb, var(--mitii-surface) 90%, var(--mitii-panel)); } @@ -5404,6 +5420,10 @@ button.md-file-link.md-inline-code:hover { background: color-mix(in srgb, var(--mitii-ok) 28%, var(--mitii-panel)); } +.wt-review__cta--code:hover:not(:disabled) { + background: color-mix(in srgb, #38bdf8 24%, var(--mitii-panel)); +} + .wt-review__cta:disabled { opacity: 0.5; cursor: default; diff --git a/apps/vscode/webview-ui/tests/settings.test.tsx b/apps/vscode/webview-ui/tests/settings.test.tsx new file mode 100644 index 00000000..377e592e --- /dev/null +++ b/apps/vscode/webview-ui/tests/settings.test.tsx @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { createProviderHarness } from '../../tests/helpers/settingsHarness'; +import { App } from '../src/App'; +import { PROVIDER_OPTIONS } from '../src/providerOptions'; +import { LOOP_POLICY_FIELDS } from '../../src/loopPolicySettings'; +import { SETTINGS_FIELDS } from '../../src/settingsFields'; + +const bridge = vi.hoisted(() => ({ post: vi.fn(), listener: undefined as any })); +vi.mock('../src/bridge', () => ({ + postToHost: bridge.post, + onHostMessage: (listener: any) => { bridge.listener = listener; return () => {}; }, +})); +// Profile/MCP file storage is tested in host suites. Keep this bridge test in memory. +vi.mock('../../src/profiles', async (original) => ({ + ...await original(), + readProfiles: (_root: unknown, provider: unknown) => ({ activeProfileId: 'default', profiles: [{ id: 'default', name: 'Default', provider }] }), + writeProfiles: vi.fn(), +})); +vi.mock('../../src/mcpConfig', async (original) => ({ + ...await original(), writeMcpSettings: vi.fn(), +})); + +let harness: ReturnType; +let host: any; +let saves: Promise[]; +let mcp = { enabled: false, servers: [] }; +const emit = (message: any) => act(() => bridge.listener(message)); +async function hydrate() { + emit({ type: 'settings', provider: await host.readProvider(), autocomplete: host.readAutocomplete(), + search: await host.readSearch(), ui: host.readUi(), workspace: { root: '/tmp/workspace', rootOverride: harness.store.get('workspace.rootPathOverride') }, + profiles: [], activeProfileId: 'default', mcp, mcpStore: [], mcpRuntimeStatus: 'disabled', + notice: {}, tokenUsage: host.tokenUsage }); + emit({ type: 'index.status', index: { fileCount: 12, truncated: false, + maximumIndexFiles: harness.store.get('workspace.maximumIndexFiles') ?? 0, + embeddingSource: harness.store.get('semanticIndex.source') ?? 'bundled' } }); +} +async function open(tab: string) { emit({ type: 'openSettings', tab }); } +async function save() { + await userEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await act(async () => { await Promise.all(saves); }); + expect(screen.queryByText(/could not be saved/i)).toBeNull(); +} +beforeEach(async () => { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} unobserve() {} }); + Element.prototype.scrollIntoView = vi.fn(); + harness = createProviderHarness(); host = harness.provider as any; + host.post = (message: any) => bridge.listener(message); + saves = []; mcp = { enabled: false, servers: [] }; + bridge.post.mockImplementation((message: any) => { + if (message.type === 'settings.set') { + if (message.mcp) mcp = message.mcp; + saves.push(host.applySettingsSet(message).then(async () => { + bridge.listener({ type: 'settings.saved', ok: true }); + await hydrate(); + })); + } + }); + render(); + await hydrate(); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); bridge.post.mockReset(); }); + +describe('settings inputs through the host save/read bridge', () => { + it('keeps every embedding choice through status updates, save, and reopen', async () => { + await open('workspace'); + for (const source of ['ollama', 'openai-compatible', 'disabled', 'bundled']) { + fireEvent.change(screen.getByLabelText('Embedding source'), { target: { value: source } }); + emit({ type: 'index.status', index: { fileCount: 25, truncated: false, embeddingSource: 'bundled' } }); + expect((screen.getByLabelText('Embedding source') as HTMLSelectElement).value).toBe(source); + await save(); + expect(harness.store.get('semanticIndex.source')).toBe(source); + await open('model'); await open('workspace'); + expect((screen.getByLabelText('Embedding source') as HTMLSelectElement).value).toBe(source); + } + }); + + it('tests without a model and requires an explicit model on save', async () => { + harness.store.set('provider.type', 'openai-compatible'); + harness.store.set('provider.preset', 'custom'); + harness.store.set('provider.model', ''); + await hydrate(); await open('model'); + expect((screen.getByLabelText('Model', { exact: true }) as HTMLSelectElement).value).toBe(''); + expect(screen.queryByPlaceholderText('custom model id')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + expect(bridge.post).toHaveBeenCalledWith(expect.objectContaining({ type: 'provider.testConnection', provider: expect.objectContaining({ model: '' }) })); + emit({ type: 'provider.connectionResult', ok: true, message: 'Connected. Choose a model.', models: ['test-model'] }); + const before = saves.length; + fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + expect(saves.length).toBe(before); + expect(screen.getByText(/Choose a model before saving/)).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Model', { exact: true }), { target: { value: 'test-model' } }); + await save(); + expect(harness.store.get('provider.model')).toBe('test-model'); + }); +}); + +// Hidden legacy/advanced fields use the host matrix; the UI exposes Simple budget sliders instead. +const inputFields = SETTINGS_FIELDS.filter(field => + !['provider.type', 'provider.preset', 'semanticIndex.source', 'semanticIndex.backend', 'semanticIndex.enabled', 'mcp.enabled', 'developer.intensityOverrides', 'ui.effort'].includes(field.id) + && (!field.id.startsWith('tokenBudget.') || field.id === 'tokenBudget.enabled') + && (!field.id.startsWith('loopPolicy.') || field.id === 'loopPolicy.enabled' || LOOP_POLICY_FIELDS.some(f => `loopPolicy.${f.key}` === field.id && f.tier !== 'advanced')) + && !/ui\.modeDefaults\..*\.(depth|thoroughness)$/.test(field.id)); +const labels: Record = { + 'autocomplete.enabled': 'Enable autocomplete', + 'runBudget.unlimited': 'Unlimited run budget', +}; +describe('each editable settings input saves and restores its value', () => { + it.each(inputFields.map(field => [field.id, field] as const))('%s', async (id, field) => { + harness.store.set('developer.enabled', true); + harness.store.set('tokenBudget.enabled', true); + harness.store.set('loopPolicy.enabled', true); + if (id === 'provider.baseUrl') { harness.store.set('provider.type', 'openai-compatible'); harness.store.set('provider.preset', 'custom'); } + await hydrate(); await open(field.tab); + if (id.startsWith('ui.modeDefaults.')) { + const mode = id.split('.')[2]; + fireEvent.click(screen.getByRole('tab', { name: mode[0].toUpperCase() + mode.slice(1), exact: true })); + } + document.querySelectorAll('details').forEach(el => { el.open = true; }); + let label = labels[id] ?? field.label; + if (id.endsWith('.approvalMode')) label = 'Approval mode'; + if (/ui\.modeDefaults\..*\.model$/.test(id)) label = 'Default model'; + const getInput = () => screen.getByLabelText(label, { exact: true }) as HTMLInputElement | HTMLSelectElement; + const input = getInput(); + let expected: string | boolean = String(field.sample); + if (input instanceof HTMLSelectElement) { + // Exercise each option, including returning to the original value. + for (const option of Array.from(input.options).filter(o => o.value !== '__custom__' && (o.value || id !== 'provider.model'))) { + fireEvent.change(getInput(), { target: { value: option.value } }); + await save(); + expect(getInput().value, `${id} option ${option.value}`).toBe(option.value); + expect(harness.store.get(id), id).toBe(option.value); + } + return; + } + if (input.type === 'checkbox') { + expected = !input.checked; + fireEvent.click(input); + } else { + if (String(expected) === input.value) { + const value = Number(expected); + const step = field.kind === 'number' ? 0.01 : 1; + expected = input.type === 'number' ? String(value + (value + step <= (field.max ?? Infinity) ? step : -step)) : `${expected}-edited`; + } + fireEvent.focus(input); + fireEvent.change(input, { target: { value: expected } }); + fireEvent.blur(input); + } + await save(); + expect(harness.store.get(id), id).toEqual(input.type === 'checkbox' ? expected : field.kind === 'int' || field.kind === 'number' ? Number(expected) : expected); + // Remount from persisted host snapshots; local React state cannot make this pass. + cleanup(); render(); await hydrate(); await open(field.tab); + if (id.startsWith('ui.modeDefaults.')) { + const mode = id.split('.')[2]; + fireEvent.click(screen.getByRole('tab', { name: mode[0].toUpperCase() + mode.slice(1), exact: true })); + } + expect(input.type === 'checkbox' ? (getInput() as HTMLInputElement).checked : getInput().value).toEqual(expected); + }); +}); + + +describe('simple budget controls and remaining settings', () => { + it.each([ + ['filesPerMutation', 7], ['outputRatio', 25], ['repositoryShare', 31], + ['conversationShare', 33], ['planShare', 9], ['skillsShare', 7], ['verificationChecks', 5], + ])('saves the %s slider and number input', async (key, edited) => { + harness.store.set('developer.enabled', true); + await hydrate(); await open('debug'); + const id = `tokenBudget.${key}`; + fireEvent.change(document.getElementById(id)!, { target: { value: String(edited) } }); + await save(); + cleanup(); render(); await hydrate(); await open('debug'); + expect((document.getElementById(id) as HTMLInputElement).value).toBe(String(edited)); + const number = document.getElementById(id)!.parentElement!.querySelector('input[type=number]')!; + fireEvent.focus(number); + fireEvent.change(number, { target: { value: String(Number(edited) + 1) } }); + fireEvent.blur(number); + await save(); + expect((document.getElementById(id) as HTMLInputElement).value).toBe(String(Number(edited) + 1)); + }); + + it('saves SearXNG URL and restores it', async () => { + await open('model'); + fireEvent.change(screen.getByLabelText('SearXNG base URL'), { target: { value: 'http://localhost:8888' } }); + await save(); + expect(harness.store.get('search.searxngBaseUrl')).toBe('http://localhost:8888'); + expect((screen.getByLabelText('SearXNG base URL') as HTMLInputElement).value).toBe('http://localhost:8888'); + }); + + it('saves both states of the MCP switch', async () => { + await open('integrations'); + for (const enabled of [true, false]) { + fireEvent.click(screen.getByLabelText('Enable MCP')); + await save(); + expect(mcp.enabled).toBe(enabled); + expect((screen.getByLabelText('Enable MCP') as HTMLInputElement).checked).toBe(enabled); + } + }); +}); + + +describe('provider choices and indexing status', () => { + it.each(PROVIDER_OPTIONS.map(p => [p.preset, p] as const))('saves and restores the %s provider', async (_id, preset) => { + await open('model'); + fireEvent.change(document.getElementById('ptype')!, { target: { value: preset.preset } }); + if (!preset.model) { + expect((screen.getByLabelText('Model', { exact: true }) as HTMLSelectElement).value).toBe(''); + emit({ type: 'provider.connectionResult', ok: true, message: 'Connected', models: ['chosen-model'] }); + fireEvent.change(screen.getByLabelText('Model', { exact: true }), { target: { value: 'chosen-model' } }); + } + await save(); + expect(harness.store.get('provider.preset')).toBe(preset.preset); + expect(harness.store.get('provider.type')).toBe(preset.type); + expect((document.getElementById('ptype') as HTMLSelectElement).value).toBe(preset.preset); + expect((screen.getByLabelText('Model', { exact: true }) as HTMLSelectElement).value).toBe(preset.model || 'chosen-model'); + }); + it('shows exact discovered and published file counts separately', async () => { + await open('workspace'); + emit({ type: 'index.status', index: { fileCount: 12, discoveredFileCount: 1234, progressStage: 'indexing', readiness: 'indexing', truncated: true, message: 'Indexing code and text' } }); + expect(screen.getByText('Indexed files')).toBeTruthy(); + expect(screen.getByText('Files discovered in current scan')).toBeTruthy(); + expect(screen.getByText('1234')).toBeTruthy(); + expect(screen.getByText('Yes — scan is incomplete')).toBeTruthy(); + // Header chip is icon-only; file counts live in Workspace settings. + expect(screen.queryByText('1,234 files')).toBeNull(); + }); + it('keeps the file limit draft through a progress update', async () => { + await open('workspace'); + const input = screen.getByLabelText('Maximum index files'); + fireEvent.change(input, { target: { value: '12345' } }); fireEvent.blur(input); + emit({ type: 'index.status', index: { fileCount: 12, truncated: false, maximumIndexFiles: 0 } }); + await save(); + expect(harness.store.get('workspace.maximumIndexFiles')).toBe(12345); + expect((screen.getByLabelText('Maximum index files') as HTMLInputElement).value).toBe('12345'); + }); +}); + + +it('saves a manually entered custom model ID', async () => { + await open('model'); + fireEvent.change(screen.getByLabelText('Model', { exact: true }), { target: { value: '__custom__' } }); + fireEvent.change(screen.getByLabelText('Custom model ID'), { target: { value: 'my-custom-model' } }); + await save(); + expect(harness.store.get('provider.model')).toBe('my-custom-model'); +}); + +it('allows Full access to be selected while running', async () => { + emit({ type: 'run.started', mode: 'agent', prompt: 'test access' }); + await userEvent.click(screen.getByRole('button', { name: 'Approval', exact: true })); + await userEvent.click(screen.getByRole('option', { name: /Full access/ })); + await act(async () => { await Promise.all(saves); }); + expect(harness.store.get('safety.approvalMode')).toBe('pilot'); +}); diff --git a/apps/vscode/webview-ui/tests/vscode.ts b/apps/vscode/webview-ui/tests/vscode.ts new file mode 100644 index 00000000..f5eab7ee --- /dev/null +++ b/apps/vscode/webview-ui/tests/vscode.ts @@ -0,0 +1,2 @@ +// VS Code is provided by the shared Vitest setup. +export {}; diff --git a/apps/vscode/webview-ui/vitest.config.ts b/apps/vscode/webview-ui/vitest.config.ts new file mode 100644 index 00000000..e90a7819 --- /dev/null +++ b/apps/vscode/webview-ui/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + esbuild: { jsx: 'automatic' }, + resolve: { alias: { + vscode: resolve(__dirname, 'tests/vscode.ts'), + '@mitii/live-token-budget': resolve(__dirname, '../src/liveTokenBudgetPreview.ts'), + '@mitii/v8': resolve(__dirname, '../../../packages/v8/src/index.ts'), + '@mitii/sdk': resolve(__dirname, '../../../packages/sdk/src/index.ts'), + '@mitii/host': resolve(__dirname, '../../../packages/host/src/index.ts'), + '@mitii/search-kit': resolve(__dirname, '../../../packages/search-kit/src/index.ts'), + } }, + test: { + environment: 'jsdom', + include: ['tests/**/*.test.tsx'], + setupFiles: ['../../../vitest.setup.ts'], + testTimeout: 30000, + }, +}); diff --git a/package.json b/package.json index b2593b94..1217cb77 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mitii-ai-agent", "description": "Private Mitii monorepo workspace orchestrator. Product packages: @mitii/v8, @mitii/sdk, @mitii/automation, @mitii/search-kit, @mitii/mcp, @mitii/mcp-web, @mitii/host, @mitii/cli, @mitii/daemon, @mitii/acp, apps/vscode.", - "version": "2.9.58", + "version": "2.9.59", "private": true, "license": "AGPL-3.0-or-later", "author": { @@ -47,7 +47,7 @@ "typecheck:daemon": "pnpm --filter @mitii/daemon typecheck", "typecheck:acp": "pnpm --filter @mitii/acp typecheck", "typecheck:vscode": "pnpm --filter ./apps/vscode typecheck", - "test": "node scripts/ensure-node-native.mjs && vitest run", + "test": "node scripts/ensure-node-native.mjs && vitest run && pnpm run test:webview", "test:watch": "node scripts/ensure-node-native.mjs && vitest", "test:v8": "pnpm --filter @mitii/v8 test", "test:sdk": "pnpm --filter @mitii/sdk test", @@ -89,11 +89,13 @@ "log-viewer": "node tools/log-viewer/server.mjs", "audit:dependencies": "node scripts/audit-dependencies.mjs", "audit:engines": "node scripts/audit-package-engines.mjs", - "check:circular-deps": "node scripts/check-circular-deps.mjs" + "check:circular-deps": "node scripts/check-circular-deps.mjs", + "test:webview": "pnpm --filter @mitii/vscode-webview test" }, "devDependencies": { "@electron/rebuild": "^4.2.0", "@types/node": "^20.14.0", + "jsdom": "^26.1.0", "knip": "^6.21.0", "madge": "^8.0.0", "typescript": "^5.5.2", diff --git a/packages/automation/package.json b/packages/automation/package.json index cd69f075..0e8b614b 100644 --- a/packages/automation/package.json +++ b/packages/automation/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/automation", - "version": "2.9.58", + "version": "2.9.59", "description": "Mitii automation control plane: schedules, event ingress, claim/lease runner, webhooks (Phases 1–2).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/package.json b/packages/host/package.json index c952cbca..08e1fb99 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/host", - "version": "2.9.58", + "version": "2.9.59", "description": "Shared host kit for Mitii apps: SQLite injection, workspace indexing, repository context, durable ports (checkpoints/memory/skills/search/network), project rules, provider presets. Web retrieval via @mitii/search-kit.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/src/config/createHostLlmPorts.spec.ts b/packages/host/src/config/createHostLlmPorts.spec.ts index cd7740e1..0843785a 100644 --- a/packages/host/src/config/createHostLlmPorts.spec.ts +++ b/packages/host/src/config/createHostLlmPorts.spec.ts @@ -99,6 +99,27 @@ describe('resolveProviderApiKey', () => { }); describe('testProviderConnection', () => { + it.each(['openai-compatible', 'anthropic', 'gemini'])('discovers %s models before selection without a generation request', async (type) => { + const requests: string[] = []; + const result = await testProviderConnection({ + type, baseUrl: 'https://provider.example', model: '', apiKey: 'test-key', + fetchImpl: (async (url: RequestInfo | URL, init?: RequestInit) => { + requests.push(String(url)); + expect(init?.method ?? 'GET').toBe('GET'); + return new Response(JSON.stringify({ data: [{ id: 'test-model' }], models: [{ name: 'models/test-model' }] })); + }) as typeof fetch, + }); + expect(result.ok).toBe(true); + expect(result.models).toEqual(['test-model']); + expect(result.message).toContain('Choose a model'); + expect(requests).toHaveLength(1); + }); + + it('does not claim success when discovery fails without a model', async () => { + const result = await testProviderConnection({ type: 'openai-compatible', baseUrl: 'https://provider.example', model: '', fetchImpl: (async () => new Response('', { status: 401 })) as typeof fetch }); + expect(result.ok).toBe(false); + }); + it('accepts echo without a network call', async () => { const result = await testProviderConnection({ type: 'echo', diff --git a/packages/host/src/config/testProviderConnection.ts b/packages/host/src/config/testProviderConnection.ts index 20fc93a1..f399558c 100644 --- a/packages/host/src/config/testProviderConnection.ts +++ b/packages/host/src/config/testProviderConnection.ts @@ -43,7 +43,10 @@ export async function testProviderConnection( } if (!model.trim()) { - return { ok: false, message: 'Model is required.' }; + const models = await listProviderModels(input); + return models.length > 0 + ? { ok: true, message: 'Connected. Choose a model, then save settings.', models } + : { ok: false, message: 'Could not discover models. Check the endpoint and credentials, or enter a custom model to test.' }; } if (type === 'anthropic') { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e92a5af9..92604922 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp", - "version": "2.9.58", + "version": "2.9.59", "description": "Mitii MCP client kit: connect to MCP servers (stdio/SSE/streamable-HTTP) and register tools into V8 ToolRegistry. Does not expose Mitii as an MCP server.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/web/package.json b/packages/mcp/web/package.json index 1365d14f..0f1216d5 100644 --- a/packages/mcp/web/package.json +++ b/packages/mcp/web/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp-web", - "version": "2.9.58", + "version": "2.9.59", "description": "Mitii MCP stdio server under packages/mcp/web: web_search, fetch_url, optional memory_search via search-kit (no v8).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 69cbeb8d..a4ef8be8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/sdk", - "version": "2.9.58", + "version": "2.9.59", "description": "Host-neutral Mitii programmatic API over @mitii/v8 Agent Engine.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/src/contracts.ts b/packages/sdk/src/contracts.ts index 1c717b1b..3393124e 100644 --- a/packages/sdk/src/contracts.ts +++ b/packages/sdk/src/contracts.ts @@ -175,6 +175,10 @@ export const mitiiStartInputSchema = z .array(z.string().min(1).max(64)) .max(MAX_REQUIRED_SKILLS) .optional(), + excludedSkillIds: z + .array(z.string().min(1).max(64)) + .max(20) + .optional(), requiredMcpServerIds: z .array(z.string().min(1).max(64)) .max(MAX_REQUIRED_MCP_SERVERS) diff --git a/packages/search-kit/package.json b/packages/search-kit/package.json index 1e4a58d9..cdaf495c 100644 --- a/packages/search-kit/package.json +++ b/packages/search-kit/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/search-kit", - "version": "2.9.58", + "version": "2.9.59", "description": "Mitii web retrieval kit: pluggable search providers, content resolvers, and URL safety. Host-neutral; no V8 dependency.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/package.json b/packages/v8/package.json index e640ee45..49af2e2d 100644 --- a/packages/v8/package.json +++ b/packages/v8/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/v8", - "version": "2.9.58", + "version": "2.9.59", "description": "Host-neutral Mitii V8 agent runtime (modules + engine).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts b/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts index a741ca04..9ab108a7 100644 --- a/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts +++ b/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts @@ -177,6 +177,11 @@ export const agentEngineStartInputSchema = z * Explicitly attached skill ids for this run (@skill:, CLI --skill, host pin). */ requiredSkillIds: z.array(z.string().min(1).max(64)).max(3).default([]), + /** + * Skill ids that must not be auto-matched for this run. + * Explicit requiredSkillIds still win when listed there. + */ + excludedSkillIds: z.array(z.string().min(1).max(64)).max(20).default([]), /** * Explicitly attached MCP server ids for this run (@mcp:, host pin). * Empty = all enabled MCP servers under the grant (default). diff --git a/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts b/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts index 23fb96fa..385be422 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts @@ -513,6 +513,7 @@ export async function executeStart( memoryFacts, establishedFacts, requiredSkillIds: input.requiredSkillIds ?? [], + excludedSkillIds: input.excludedSkillIds ?? [], selectedSkillIds: selectedSkills?.map((block) => block.id) ?? [], evidence: runEvidence, windowPolicy, @@ -559,6 +560,7 @@ export async function executeStart( projects: input.projects, memoryFacts, requiredSkillIds: input.requiredSkillIds ?? [], + excludedSkillIds: input.excludedSkillIds ?? [], selectedSkillIds: selectedSkills?.map((block) => block.id) ?? [], establishedFacts, plan: shared.runPlan, diff --git a/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts b/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts index f2834ce6..d59d5051 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts @@ -390,6 +390,7 @@ export async function runStartEnrichment( budgetTokens: windowPolicy.skills.budgetTokens, maxSkills: windowPolicy.skills.maxSkills, requiredSkillIds: input.requiredSkillIds ?? [], + excludedSkillIds: input.excludedSkillIds ?? [], forbidLargeSkills: resolveWindowBudgetBand(windowPolicy.contextWindowTokens) === "compact", evidence: { diff --git a/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts b/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts index 4c3c0600..250988a4 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts @@ -205,6 +205,7 @@ export async function refreshAuthorityAfterTools( route: ExecutionDecision["route"]; windowPolicy: WindowPolicy; requiredSkillIds?: readonly string[]; + excludedSkillIds?: readonly string[]; }): Promise { const discoveredPaths = [ ...new Set([ @@ -324,6 +325,7 @@ export async function refreshAuthorityAfterTools( budgetTokens: params.windowPolicy.skills.budgetTokens, maxSkills: params.windowPolicy.skills.maxSkills, requiredSkillIds: [...(params.requiredSkillIds ?? [])], + excludedSkillIds: [...(params.excludedSkillIds ?? [])], forbidLargeSkills: resolveWindowBudgetBand(params.windowPolicy.contextWindowTokens) === "compact", diff --git a/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts b/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts index 93e16434..84a61fb8 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts @@ -108,6 +108,7 @@ export async function runModelLoopToolPhase(params: { mode: "ask" | "plan" | "agent" | undefined; projects: readonly ProjectDescriptor[] | undefined; requiredSkillIds: string[] | undefined; + excludedSkillIds: string[] | undefined; answer: string; changeImpactGate: { required: boolean; satisfied: boolean }; thresholds: AgentEngineThresholds; @@ -145,6 +146,7 @@ export async function runModelLoopToolPhase(params: { mode, projects, requiredSkillIds, + excludedSkillIds, answer, changeImpactGate, thresholds, @@ -553,6 +555,7 @@ export async function runModelLoopToolPhase(params: { route: decision.route, windowPolicy: windowPolicy, requiredSkillIds: requiredSkillIds, + excludedSkillIds: excludedSkillIds, }); if (grantExpansionOutcome.kind === "expansion_required") { session.decision = decision; diff --git a/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts b/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts index 0d74c268..9661e33e 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts @@ -101,6 +101,7 @@ export async function runModelToolLoop( establishedFacts?: EstablishedFact[]; selectedSkillIds?: string[]; requiredSkillIds?: string[]; + excludedSkillIds?: string[]; taskListRef: TaskListRef; evidence?: RunEvidence; windowPolicy: WindowPolicy; @@ -593,6 +594,7 @@ export async function runModelToolLoop( mode: params.mode, projects: params.projects, requiredSkillIds: params.requiredSkillIds, + excludedSkillIds: params.excludedSkillIds, answer: session.answer, changeImpactGate, thresholds, diff --git a/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts b/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts index 14d432e3..00a483aa 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts @@ -120,6 +120,7 @@ export async function finishAfterLoop( memoryFacts?: readonly { id: string; content: string }[]; selectedSkillIds?: string[]; requiredSkillIds?: string[]; + excludedSkillIds?: string[]; establishedFacts: EstablishedFact[]; plan?: PlanArtifact; }; @@ -826,6 +827,7 @@ export async function finishAfterLoop( establishedFacts: params.loopContext?.establishedFacts ?? [], selectedSkillIds: params.loopContext?.selectedSkillIds, requiredSkillIds: params.loopContext?.requiredSkillIds, + excludedSkillIds: params.loopContext?.excludedSkillIds, evidence, windowPolicy, logVerbosity: input.logVerbosity, diff --git a/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts b/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts index 4a881c9f..e297c434 100644 --- a/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts +++ b/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts @@ -109,10 +109,9 @@ export function buildToolGrant(params: { ) { if (route === "diagnose") { reasonCodes.push("diagnosis_readonly"); - if ( - understanding.intent.classification.primaryTaskIntent === "review" || - looksLikeCodeReviewRequest(params.message ?? "") - ) { + // Structured findings only when the host/CLI injected review markers — + // never from free-form Ask/Plan/Agent text or a review intent label alone. + if (looksLikeCodeReviewRequest(params.message ?? "")) { reasonCodes.push("review_pipeline_required"); reasonCodes.push("review_findings_structured"); } diff --git a/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts b/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts index e9d89ecd..fea22ee4 100644 --- a/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts +++ b/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts @@ -358,12 +358,12 @@ function resolveAskRoute(params: { }): RouteResolution { const { primary, taskAnalysis, message, reasonCodes } = params; - // Host Review UI maps to ask mode; classifiers often label these as - // "question" once review prep is prepended. Keep the diagnose + structured - // findings path regardless. - if (isDiagnosisIntent(primary) || looksLikeCodeReviewRequest(message)) { + // Structured review is button/CLI-prep only (host markers). Free-form + // "code review" questions stay normal diagnose/answer — never force findings. + const structuredReview = looksLikeCodeReviewRequest(message); + if (isDiagnosisIntent(primary) || structuredReview) { reasonCodes.push("diagnosis_readonly"); - if (primary === "review" || looksLikeCodeReviewRequest(message)) { + if (structuredReview) { reasonCodes.push("review_pipeline_required"); reasonCodes.push("review_findings_structured"); } @@ -406,8 +406,8 @@ function resolveAskRoute(params: { } /** - * Explicit code-review asks (VS Code Review mode, CLI, or free-form). - * Matches host prefixes that include emit_review_finding / working-tree language. + * Structured git review is opt-in only: VS Code Review button / CLI prep. + * Matches host-injected markers — not free-form "code review" chat phrasing. */ export function looksLikeCodeReviewRequest(message: string): boolean { const text = message.replace(/\nClarification:\s*[\s\S]*$/i, "").trim(); @@ -419,20 +419,6 @@ export function looksLikeCodeReviewRequest(message: string): boolean { return true; } - if ( - /\b(?:code\s*review|review\s+(?:the\s+)?(?:current\s+)?(?:git\s+)?(?:working[- ]tree\s+)?(?:changes|diff|patch|pr|pull\s+request|commit))\b/i.test( - text, - ) - ) { - return true; - } - - if ( - /\breview\b[\s\S]{0,80}\b(?:bugs?|security|findings?|severity)\b/i.test(text) - ) { - return true; - } - if (/^#\s*Review prep\b/m.test(text) || /\bReview prep\s*\(/i.test(text)) { return true; } diff --git a/packages/v8/src/modules/decision-policy/actions/index.ts b/packages/v8/src/modules/decision-policy/actions/index.ts index 50ccea76..3d81865c 100644 --- a/packages/v8/src/modules/decision-policy/actions/index.ts +++ b/packages/v8/src/modules/decision-policy/actions/index.ts @@ -1,6 +1,6 @@ export { toolGrantsEquivalent } from "./CompareToolGrants"; -export { resolveRoute, isMutationIntent, isDiagnosisIntent, looksLikeAgentVerificationRequest } from "./ResolveRoute"; +export { resolveRoute, isMutationIntent, isDiagnosisIntent, looksLikeAgentVerificationRequest, looksLikeCodeReviewRequest } from "./ResolveRoute"; export type { RouteResolution } from "./ResolveRoute"; export { looksLikeWorkspaceBugReport } from "./LooksLikeWorkspaceBugReport"; diff --git a/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts b/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts index 0b397009..8ed89299 100644 --- a/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts +++ b/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts @@ -500,6 +500,50 @@ describe("DecisionPolicyPipeline", () => { expect(decision.reasonCodes).toContain("mode_ask_readonly"); }); + it("does not force structured review for free-form code-review questions", () => { + const decision = new DecisionPolicyPipeline().decide( + createInput({ + mode: "ask", + message: + "can you show the code on how it detects the unused files for code review?", + understanding: createUnderstanding({ + primaryTaskIntent: "review", + interactionIntent: "question", + taskAnalysis: { + scope: "repository", + clarity: "unclear", + recommendsRepositoryDiscovery: true, + }, + }), + }), + ); + + expect(decision.route).toBe("diagnose"); + expect(decision.reasonCodes).toContain("diagnosis_readonly"); + expect(decision.reasonCodes).not.toContain("review_pipeline_required"); + expect(decision.reasonCodes).not.toContain("review_findings_structured"); + }); + + it("does not force structured review for free-form review phrasing without host markers", () => { + const decision = new DecisionPolicyPipeline().decide( + createInput({ + mode: "ask", + message: "Review the current git changes for bugs", + understanding: createUnderstanding({ + primaryTaskIntent: "review", + interactionIntent: "question", + taskAnalysis: { + scope: "multi_file", + recommendsRepositoryDiscovery: true, + }, + }), + }), + ); + + expect(decision.reasonCodes).not.toContain("review_pipeline_required"); + expect(decision.reasonCodes).not.toContain("review_findings_structured"); + }); + it("routes ask-mode project questions to repository_answer with read tools", () => { const decision = new DecisionPolicyPipeline().decide( createInput({ diff --git a/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts b/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts index f3b1a4e1..dab75e0d 100644 --- a/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts +++ b/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts @@ -923,12 +923,14 @@ const GOLDEN_DECISION_CASES_CORE: GoldenDecisionCase[] = [ expected: { route: "diagnose", maximumWorkspaceEffect: "read", - reasonCodesIncludes: [ - "diagnosis_readonly", + // Free-form review intent is diagnose-only — structured findings require + // Review-button / CLI prep markers (emit_review_finding / Review prep). + reasonCodesIncludes: ["diagnosis_readonly"], + reasonCodesExcludes: [ "review_pipeline_required", "review_findings_structured", ], - allowedToolsIncludes: ["emit_review_finding", "read_file", "read_git_status"], + allowedToolsIncludes: ["read_file", "read_git_status"], allowedToolsExcludes: ["apply_patch"], }, }, diff --git a/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts b/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts index a0dd40b3..45d91a71 100644 --- a/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts +++ b/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts @@ -44,9 +44,12 @@ const INTENT_PATTERNS: IntentRule[] = [ confidence: 0.84, }, { + // Diff/PR/change review asks only — bare "code review" as a topic is not + // a perform-review intent (those stay questions / diagnose without + // structured findings unless the Review button injects host markers). intent: "review", pattern: - /\b(?:review|critique)\b.*\b(?:diff|pr|pull request|patch|commit|code|changes|working[- ]tree|findings?)\b|\b(?:code\s*review|emit_review_finding)\b|\bgenerate\b.*\bpr\s+(?:description|summary)\b/i, + /\b(?:review|critique)\b.*\b(?:diff|pr|pull request|patch|commit|changes|working[- ]tree|findings?)\b|\bemit_review_finding\b|\bgenerate\b.*\bpr\s+(?:description|summary)\b/i, confidence: 0.86, }, { diff --git a/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts b/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts index 669d2edd..b4fe3b60 100644 --- a/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts +++ b/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts @@ -68,6 +68,14 @@ export const skillsSelectInputSchema = z .array(z.string().min(1).max(64)) .max(MAX_REQUIRED_SKILLS) .default([]), + /** + * Skill ids that must not be auto-matched for this run. + * Required skills still win if explicitly attached. + */ + excludedSkillIds: z + .array(z.string().min(1).max(64)) + .max(20) + .default([]), /** * When true, omit sizeClass L skills unless alwaysApply / required. * Engine sets this for compact no_cache windows. diff --git a/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts b/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts index c25c7b87..488265e4 100644 --- a/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts +++ b/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts @@ -153,7 +153,22 @@ export class SkillsPipeline { `${matched.nonMatchedCount} of ${catalog.length} catalog skill(s) did not match this request (path/intent/route/keyword/threshold) and were not considered.`, ); } - const merged = mergeSkillCandidates(required.scored, matched.scored); + const excluded = new Set( + parsed.excludedSkillIds.map((id) => id.trim()).filter(Boolean), + ); + const matchedWithoutExcluded = + excluded.size === 0 + ? matched.scored + : matched.scored.filter((entry) => !excluded.has(entry.skill.id)); + if ( + excluded.size > 0 && + matchedWithoutExcluded.length < matched.scored.length + ) { + warnings.push( + `Excluded ${matched.scored.length - matchedWithoutExcluded.length} auto-matched skill(s) for this run.`, + ); + } + const merged = mergeSkillCandidates(required.scored, matchedWithoutExcluded); const conflicts = resolveSkillConflicts({ scored: merged }); if (conflicts.conflictsResolved) { reasonCodes.push("conflicts_resolved"); diff --git a/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts b/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts index f69b207d..cd0420ec 100644 --- a/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts +++ b/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts @@ -571,6 +571,45 @@ describe("SkillsPipeline", () => { ).toBe("required"); }); + it("excludes auto-matched skills listed in excludedSkillIds", async () => { + const pipeline = new SkillsPipeline({ + catalog: new InMemorySkillsCatalog(catalog), + }); + + const included = await pipeline.select( + baseInput({ + route: "execute", + query: "Fix the null check", + evidence: { + primaryIntent: "bugfix", + secondaryIntents: [], + }, + }), + ); + expect(included.instructions.map((block) => block.id)).toContain( + "bugfix-localize", + ); + + const excluded = await pipeline.select( + baseInput({ + route: "execute", + query: "Fix the null check", + evidence: { + primaryIntent: "bugfix", + secondaryIntents: [], + }, + excludedSkillIds: ["bugfix-localize"], + }), + ); + + expect(excluded.instructions.map((block) => block.id)).not.toContain( + "bugfix-localize", + ); + expect( + excluded.warnings.some((warning) => warning.includes("Excluded")), + ).toBe(true); + }); + it("reports not_found omissions for missing required skills", async () => { const pipeline = new SkillsPipeline({ catalog: new InMemorySkillsCatalog(catalog), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70e75db7..f82c1f2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@types/node': specifier: ^20.14.0 version: 20.19.43 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 knip: specifier: ^6.21.0 version: 6.24.0 @@ -28,7 +31,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) apps/acp: dependencies: @@ -81,7 +84,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) optionalDependencies: '@lancedb/lancedb': specifier: 0.33.0 @@ -180,6 +183,12 @@ importers: specifier: ^4.0.1 version: 4.0.1 devDependencies: + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/react': specifier: ^18.3.12 version: 18.3.31 @@ -189,6 +198,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@20.19.43)) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 typescript: specifier: ^5.5.2 version: 5.9.3 @@ -216,7 +228,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/host: dependencies: @@ -247,7 +259,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) optionalDependencies: '@lancedb/lancedb': specifier: 0.33.0 @@ -282,7 +294,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/mcp/web: dependencies: @@ -301,7 +313,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/sdk: dependencies: @@ -320,7 +332,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/search-kit: dependencies: @@ -336,7 +348,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/v8: dependencies: @@ -367,7 +379,7 @@ importers: version: 12.11.1 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) tests: {} @@ -382,6 +394,9 @@ importers: packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -503,6 +518,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -515,6 +534,34 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dependents/detective-less@5.0.3': resolution: {integrity: sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA==} engines: {node: '>=18'} @@ -1472,6 +1519,31 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@textlint/ast-node-types@15.7.1': resolution: {integrity: sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==} @@ -1506,6 +1578,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1757,6 +1832,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1773,6 +1852,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-back@3.1.0: resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} engines: {node: '>=6'} @@ -2007,9 +2089,17 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2019,6 +2109,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2126,6 +2219,9 @@ packages: digest-fetch@1.3.0: resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2460,6 +2556,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -2561,6 +2661,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regexp@1.0.0: resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} engines: {node: '>=0.10.0'} @@ -2605,6 +2708,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2717,6 +2829,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + madge@8.0.0: resolution: {integrity: sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==} engines: {node: '>=18'} @@ -3008,6 +3124,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.27: + resolution: {integrity: sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3163,6 +3282,10 @@ packages: engines: {node: '>=18'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@7.0.1: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} @@ -3185,6 +3308,10 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -3207,6 +3334,9 @@ packages: peerDependencies: react: ^18.3.1 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -3294,6 +3424,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -3316,6 +3449,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3500,6 +3637,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + table-layout@4.1.1: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} @@ -3556,6 +3696,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -3564,9 +3711,17 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-sitter-wasms@0.1.13: resolution: {integrity: sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==} @@ -3767,6 +3922,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -3792,6 +3951,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -3801,6 +3964,10 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -3826,10 +3993,26 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} @@ -3842,6 +4025,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3875,6 +4061,14 @@ packages: snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -4052,6 +4246,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4075,6 +4271,26 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@dependents/detective-less@5.0.3': dependencies: gonzales-pe: 4.3.0 @@ -4765,6 +4981,31 @@ snapshots: tslib: 2.8.1 optional: true + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@textlint/ast-node-types@15.7.1': {} '@textlint/linter-formatter@15.7.1': @@ -4814,6 +5055,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -5147,6 +5390,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anynum@1.0.1: {} @@ -5168,6 +5413,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-back@3.1.0: optional: true @@ -5406,12 +5655,24 @@ snapshots: css-what@6.2.2: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -5536,6 +5797,8 @@ snapshots: md5: 2.3.0 optional: true + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -5947,6 +6210,10 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-url-attributes@3.0.1: {} htmlparser2@10.1.0: @@ -6034,6 +6301,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regexp@1.0.0: {} is-unicode-supported@0.1.0: {} @@ -6066,6 +6335,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.27 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-bignum@0.0.3: @@ -6187,6 +6483,8 @@ snapshots: dependencies: yallist: 4.0.0 + lz-string@1.5.0: {} + madge@8.0.0(typescript@5.9.3): dependencies: chalk: 4.1.2 @@ -6694,6 +6992,8 @@ snapshots: dependencies: boolbase: 1.0.0 + nwsapi@2.2.27: {} + object-inspect@1.13.4: {} object-keys@1.1.1: @@ -6947,6 +7247,12 @@ snapshots: transitivePeerDependencies: - supports-color + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -6977,6 +7283,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -7008,6 +7316,8 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): dependencies: '@types/hast': 3.0.5 @@ -7161,6 +7471,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -7178,6 +7490,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -7398,6 +7714,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + table-layout@4.1.1: dependencies: array-back: 6.2.3 @@ -7463,15 +7781,29 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tmp@0.2.7: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: optional: true + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-sitter-wasms@0.1.13: optional: true @@ -7631,7 +7963,7 @@ snapshots: '@types/node': 20.19.43 fsevents: 2.3.3 - vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -7659,6 +7991,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 20.19.43 + jsdom: 26.1.0 transitivePeerDependencies: - less - lightningcss @@ -7670,6 +8003,10 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} walkdir@0.4.1: {} @@ -7690,12 +8027,19 @@ snapshots: webidl-conversions@3.0.1: optional: true + webidl-conversions@7.0.0: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 whatwg-mimetype@4.0.0: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -7720,10 +8064,14 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} xml2js@0.5.0: @@ -7733,6 +8081,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + yallist@3.1.1: {} yallist@4.0.0: {} From d9147e76be8e89705dda1df7fd3f6d4b1df4b60c Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Fri, 18 Sep 2026 16:49:55 -0500 Subject: [PATCH 3/9] feat(review): implement Composer review strip with dual-scope functionality - Added ComposerReviewStrip component to manage review and findings display. - Introduced WorkingTreeReviewBar for handling chat edits and code review actions. - Created ReviewBarActions, ReviewFilesPanel, and ReviewFindingsPanel for UI interactions. - Implemented reviewBarModel for managing file states and review scope. - Added selectLatestRunChanges and composerNeedsReviewStrip utility functions. - Developed reviewBarFormat for displaying status and severity tones. - Included tests for WorkingTreeReviewBar and reviewBarModel functionalities. - Updated vitest configuration to include both .ts and .tsx test files. --- .vscode/settings.json | 4 +- README.md | 2 +- apps/acp/package.json | 2 +- apps/cli/package.json | 2 +- apps/daemon/package.json | 2 +- apps/vscode/README.md | 4 +- apps/vscode/package.json | 4 +- apps/vscode/src/extension.ts | 3 +- apps/vscode/src/review/README.md | 22 + apps/vscode/src/review/reviewFindingParse.ts | 196 +++++++++ .../src/review/reviewFindingsPresenter.ts | 229 ++-------- apps/vscode/src/sidebar.ts | 43 +- apps/vscode/webview-ui/src/App.tsx | 92 ++-- .../webview-ui/src/components/ReviewPanel.tsx | 10 +- .../src/components/SettingsPanel.tsx | 9 +- .../src/components/WorkingTreeReviewBar.tsx | 396 +----------------- .../src/review/ComposerReviewStrip.tsx | 83 ++++ apps/vscode/webview-ui/src/review/README.md | 41 ++ .../src/review/ReviewBarActions.tsx | 142 +++++++ .../src/review/ReviewFilesPanel.tsx | 53 +++ .../src/review/ReviewFindingsPanel.tsx | 78 ++++ .../src/review/WorkingTreeReviewBar.tsx | 223 ++++++++++ apps/vscode/webview-ui/src/review/index.ts | 32 ++ .../webview-ui/src/review/reviewBarFormat.ts | 31 ++ .../webview-ui/src/review/reviewBarModel.ts | 103 +++++ .../src/review/reviewFindingTypes.ts | 12 + .../src/review/selectLatestRunChanges.ts | 29 ++ .../tests/WorkingTreeReviewBar.test.tsx | 115 +++++ .../webview-ui/tests/reviewBarModel.test.ts | 154 +++++++ apps/vscode/webview-ui/vitest.config.ts | 2 +- package.json | 2 +- packages/automation/package.json | 2 +- packages/host/package.json | 2 +- packages/mcp/package.json | 2 +- packages/mcp/web/package.json | 2 +- packages/sdk/package.json | 2 +- packages/search-kit/package.json | 2 +- packages/v8/package.json | 2 +- 38 files changed, 1428 insertions(+), 706 deletions(-) create mode 100644 apps/vscode/src/review/README.md create mode 100644 apps/vscode/src/review/reviewFindingParse.ts create mode 100644 apps/vscode/webview-ui/src/review/ComposerReviewStrip.tsx create mode 100644 apps/vscode/webview-ui/src/review/README.md create mode 100644 apps/vscode/webview-ui/src/review/ReviewBarActions.tsx create mode 100644 apps/vscode/webview-ui/src/review/ReviewFilesPanel.tsx create mode 100644 apps/vscode/webview-ui/src/review/ReviewFindingsPanel.tsx create mode 100644 apps/vscode/webview-ui/src/review/WorkingTreeReviewBar.tsx create mode 100644 apps/vscode/webview-ui/src/review/index.ts create mode 100644 apps/vscode/webview-ui/src/review/reviewBarFormat.ts create mode 100644 apps/vscode/webview-ui/src/review/reviewBarModel.ts create mode 100644 apps/vscode/webview-ui/src/review/reviewFindingTypes.ts create mode 100644 apps/vscode/webview-ui/src/review/selectLatestRunChanges.ts create mode 100644 apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx create mode 100644 apps/vscode/webview-ui/tests/reviewBarModel.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index edf706d3..1a100e79 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,7 @@ "wasms", "xenova" ], - "mitii.onboarding.completed": true + "mitii.onboarding.completed": true, + "mitii.provider.model": "my-qwen-64k:latest", + "mitii.provider.contextWindow": 45000 } diff --git a/README.md b/README.md index e786f70d..80685f9c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ License: AGPL v3 VS Code 1.124+ Node 20+ - Version 2.9.59 + Version 2.9.60 Documentation

    diff --git a/apps/acp/package.json b/apps/acp/package.json index 54e48711..584a9557 100644 --- a/apps/acp/package.json +++ b/apps/acp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/acp", - "version": "2.9.59", + "version": "2.9.60", "description": "Mitii ACP-lite stdio bridge (Phase 3). Decision Policy remains authority; V8 does not import ACP.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/cli/package.json b/apps/cli/package.json index c0c65387..c0141091 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/cli", - "version": "2.9.59", + "version": "2.9.60", "description": "Mitii headless CLI over @mitii/sdk. Phase 0: --origin/--autonomy/--agent for CI automation.", "license": "AGPL-3.0-or-later", "publishConfig": { diff --git a/apps/daemon/package.json b/apps/daemon/package.json index abe3274b..7a1a21fa 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/daemon", - "version": "2.9.59", + "version": "2.9.60", "description": "Mitii automation daemon process entry (Phase 1). Long-lived schedule runner.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 992f9782..ca5e2f69 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -33,7 +33,7 @@ For cloud providers, run **Mitii: Set Provider API Key** (stored in VS Code Secr - **Repository-aware context** - SQLite FTS5, symbols, optional vectors, repo map, diagnostics, Git state, and `@` attachments - **Skills** - force-attach playbooks with `/` or `@skill:id` in chat (up to 3 per message); workspace skills in `.mitii/skills/` -- **Ask / Plan / Agent** - read-only Q&A, structured plans, and controlled edits. Use the collapsible **Review** bar above chat (file count → Review) for structured working-tree findings via `emit_review_finding` — not a fourth mode. After findings appear, use **Dismiss**, **Fix**, or **Fix all** (Agent + `fix-review-findings` recipe). +- **Ask / Plan / Agent** - read-only Q&A, structured plans, and controlled edits. The composer **Review** strip lists **this chat’s** Mitii file changes (expand with **Review**). Enable **Settings → Features → Code Review** to add **Code Review (N)** over the full git working tree, then severity-tagged findings with **Fix** / **Fix all**. Free-form “code review” chat is not a structured review run. - **FIM autocomplete** - optional inline ghost text from a low-latency OpenAI-compatible `prompt` + `suffix` endpoint - **Safety** - configurable approvals, path containment, command policy, pre-write checkpoints, workspace trust - **Providers** - Echo, Anthropic (Claude), Gemini, and OpenAI-compatible endpoints (DeepSeek, OpenRouter, Azure, Ollama, custom `/v1`) @@ -45,7 +45,7 @@ For cloud providers, run **Mitii: Set Provider API Key** (stored in VS Code Secr | Command | Purpose | |---|---| | **Mitii: Open Chat** | Open the sidebar | -| **Mitii: Review Working Tree Changes** | Run a structured review of git changes (Review button). Optional Code Review button when enabled under Settings → Features | +| **Mitii: Review Working Tree Changes** | Expand the chat review strip. **Review** = this-chat Mitii edits. With Settings → Features → Code Review on, **Code Review (N)** runs LLM analysis over all `N` git changes | | **Mitii: Index Workspace** | Rebuild repository index | | **Mitii: Show Settings** | Provider, index, MCP, workspace | | **Mitii: Toggle Autocomplete** | Enable or disable FIM inline suggestions | diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 8af68120..dbe0b79a 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with repository-aware context and controlled execution", - "version": "2.9.59", + "version": "2.9.60", "publisher": "mitii", "license": "AGPL-3.0-or-later", "icon": "media/mitii-logo.png", @@ -1031,7 +1031,7 @@ "mitii.ui.features.codeReviewButton": { "type": "boolean", "default": false, - "description": "Show the Code Review button on the working-tree review bar (in addition to Review). Off by default; enable under Settings → Features." + "description": "Show the Code Review button on the working-tree bar. Review always lists git changes; Code Review runs an LLM analysis. Enable under Settings → Features." }, "mitii.ui.reasoningPreviewMaxChars": { "type": "number", diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index bb746715..2640b943 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -424,7 +424,8 @@ export function activate(context: ExtensionContext): void { const reviewChanges = async (): Promise => { await vscode.commands.executeCommand('mitii.sidebar.focus'); - sidebar?.post({ type: 'startReview', autoRun: true }); + // Show git changes only — LLM Code Review is the separate bar button. + sidebar?.post({ type: 'startReview', autoRun: false }); }; const runWritingRecipe = async ( diff --git a/apps/vscode/src/review/README.md b/apps/vscode/src/review/README.md new file mode 100644 index 00000000..be5d5af9 --- /dev/null +++ b/apps/vscode/src/review/README.md @@ -0,0 +1,22 @@ +# Review (host) + +Host-side review support for the VS Code sidebar. Webview dual-scope UI lives in +`apps/vscode/webview-ui/src/review/README.md`. + +## Contract + +| Concern | Module | Notes | +|---|---|---| +| Git working-tree snapshot | `../reviewDiff.ts` → `buildReviewDiff` | Feeds `setReviewDiff` / Code Review `(N)` | +| Per-file patches for LLM prep | `buildReviewFileDiffs` | Used when assembling review context | +| Finding parse + severity | `reviewFindingParse.ts` | Tool-output → finding DTO | +| Editor Problems + comment threads | `reviewFindingsPresenter.ts` | Severity-mapped diagnostics | +| Feature gate | `mitii.ui.features.codeReviewButton` | Sidebar persists via settings | + +## Dual scope (must stay aligned with webview) + +1. **Review** in the composer strip lists **this chat’s** `run.fileChanges` only. +2. **Code Review (N)** always analyzes the **full** git working tree from `buildReviewDiff`. +3. Findings / Fix surfaces only when the Code Review feature flag is on. + +Do not collapse these scopes in the host prompt for Code Review — the model MUST see the full dirty tree, not only the latest Mitii run. diff --git a/apps/vscode/src/review/reviewFindingParse.ts b/apps/vscode/src/review/reviewFindingParse.ts new file mode 100644 index 00000000..777780bd --- /dev/null +++ b/apps/vscode/src/review/reviewFindingParse.ts @@ -0,0 +1,196 @@ +import type * as vscode from 'vscode'; + +export type ReviewFindingSeverity = + | 'critical' + | 'high' + | 'medium' + | 'low' + | 'info'; + +export interface ReviewFindingView { + path: string; + content: string; + existingCode?: string; + suggestionCode?: string; + startLine?: number; + endLine?: number; + category?: string; + severity: ReviewFindingSeverity; + anchored?: boolean; + /** Set after a successful Fix / Fix all run. */ + status?: 'open' | 'fixed'; +} + +/** Stable id for matching findings across Fix runs. */ +export function reviewFindingKey(finding: { + path: string; + content: string; + startLine?: number; +}): string { + const path = finding.path.replace(/\\/g, '/').replace(/^\.\//, ''); + const line = finding.startLine ?? ''; + const content = finding.content.trim().slice(0, 160); + return `${path}|${line}|${content}`; +} + +export function parseFindingFromOutputPreview( + preview: string, +): ReviewFindingView | null { + const text = preview.trim(); + if (!text) return null; + + const jsonStart = text.indexOf('{'); + if (jsonStart < 0) { + return parseFindingFromSummary(text); + } + let raw = text.slice(jsonStart); + raw = raw.replace(/…$/, '').replace(/\.\.\.$/, ''); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + const fromFields = parseFindingFromLooseFields(text); + return fromFields ?? parseFindingFromSummary(text); + } + + const record = + parsed && typeof parsed === 'object' + ? ((parsed as { finding?: unknown }).finding && + typeof (parsed as { finding: unknown }).finding === 'object' + ? (parsed as { finding: Record }).finding + : (parsed as Record)) + : null; + if (!record) return parseFindingFromSummary(text); + + const path = String(record.path ?? '').trim(); + const content = String(record.content ?? '').trim(); + if (!path || !content) return parseFindingFromSummary(text); + + return { + path, + content, + existingCode: + typeof record.existingCode === 'string' ? record.existingCode : undefined, + suggestionCode: + typeof record.suggestionCode === 'string' + ? record.suggestionCode + : undefined, + startLine: + typeof record.startLine === 'number' ? record.startLine : undefined, + endLine: typeof record.endLine === 'number' ? record.endLine : undefined, + category: typeof record.category === 'string' ? record.category : undefined, + severity: normalizeSeverity( + typeof record.severity === 'string' ? record.severity : undefined, + ), + anchored: record.anchored === true, + }; +} + +/** Parse `finding path=… line=… sev=… cat=… :: message` summaries. */ +export function parseFindingFromSummary( + summary: string, +): ReviewFindingView | null { + const text = summary.trim(); + if (!text.toLowerCase().includes('finding') && !/\bpath=/.test(text)) { + return null; + } + const path = matchKv(text, 'path'); + const content = text.includes('::') + ? text.slice(text.indexOf('::') + 2).trim() + : (matchKv(text, 'msg') ?? matchStringField(text, 'content')); + if (!path || !content) return null; + const lineRaw = matchKv(text, 'line'); + const line = lineRaw && /^\d+$/.test(lineRaw) ? Number(lineRaw) : undefined; + return { + path, + content, + startLine: line, + endLine: line, + category: matchKv(text, 'cat') ?? undefined, + severity: normalizeSeverity(matchKv(text, 'sev') ?? undefined), + }; +} + +function parseFindingFromLooseFields(text: string): ReviewFindingView | null { + const path = matchStringField(text, 'path'); + const content = matchStringField(text, 'content'); + if (!path || !content) return null; + return { + path, + content, + existingCode: matchStringField(text, 'existingCode') ?? undefined, + startLine: matchNumberField(text, 'startLine') ?? undefined, + endLine: matchNumberField(text, 'endLine') ?? undefined, + category: matchStringField(text, 'category') ?? undefined, + severity: normalizeSeverity(matchStringField(text, 'severity') ?? undefined), + }; +} + +function matchKv(text: string, key: string): string | null { + const re = new RegExp(`\\b${key}=([^\\s]+)`); + const m = text.match(re); + return m?.[1] ? m[1].replace(/^"|"$/g, '') : null; +} + +export function normalizeSeverity( + value: string | undefined, +): ReviewFindingSeverity { + const raw = (value ?? 'medium').toLowerCase(); + if (raw === 'critical' || raw === 'blocker') return 'critical'; + if (raw === 'high' || raw === 'error') return 'high'; + if (raw === 'low' || raw === 'nit' || raw === 'fyi') return 'low'; + if (raw === 'info' || raw === 'hint') return 'info'; + return 'medium'; +} + +export function severityLabel(severity: ReviewFindingSeverity): string { + switch (severity) { + case 'critical': + return 'Critical'; + case 'high': + return 'High'; + case 'medium': + return 'Medium'; + case 'low': + return 'Low'; + case 'info': + return 'Info'; + } +} + +export function toDiagnosticSeverity( + vs: typeof vscode, + severity: ReviewFindingSeverity, +): vscode.DiagnosticSeverity { + switch (severity) { + case 'critical': + case 'high': + return vs.DiagnosticSeverity.Error; + case 'medium': + return vs.DiagnosticSeverity.Warning; + case 'low': + return vs.DiagnosticSeverity.Information; + case 'info': + return vs.DiagnosticSeverity.Hint; + } +} + +function matchStringField(text: string, key: string): string | null { + const re = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`); + const m = text.match(re); + if (!m?.[1]) return null; + try { + return JSON.parse(`"${m[1]}"`) as string; + } catch { + return m[1]; + } +} + +function matchNumberField(text: string, key: string): number | null { + const re = new RegExp(`"${key}"\\s*:\\s*(\\d+)`); + const m = text.match(re); + if (!m?.[1]) return null; + const n = Number(m[1]); + return Number.isFinite(n) && n > 0 ? n : null; +} diff --git a/apps/vscode/src/review/reviewFindingsPresenter.ts b/apps/vscode/src/review/reviewFindingsPresenter.ts index 00f5403e..fe7ff610 100644 --- a/apps/vscode/src/review/reviewFindingsPresenter.ts +++ b/apps/vscode/src/review/reviewFindingsPresenter.ts @@ -1,38 +1,24 @@ import { join } from 'node:path'; import type * as vscode from 'vscode'; -export type ReviewFindingSeverity = - | 'critical' - | 'high' - | 'medium' - | 'low' - | 'info'; - -export interface ReviewFindingView { - path: string; - content: string; - existingCode?: string; - suggestionCode?: string; - startLine?: number; - endLine?: number; - category?: string; - severity: ReviewFindingSeverity; - anchored?: boolean; - /** Set after a successful Fix / Fix all run. */ - status?: 'open' | 'fixed'; -} - -/** Stable id for matching findings across Fix runs. */ -export function reviewFindingKey(finding: { - path: string; - content: string; - startLine?: number; -}): string { - const path = finding.path.replace(/\\/g, '/').replace(/^\.\//, ''); - const line = finding.startLine ?? ''; - const content = finding.content.trim().slice(0, 160); - return `${path}|${line}|${content}`; -} +import { + parseFindingFromOutputPreview, + parseFindingFromSummary, + reviewFindingKey, + severityLabel, + toDiagnosticSeverity, + type ReviewFindingView, +} from './reviewFindingParse.js'; + +export type { + ReviewFindingSeverity, + ReviewFindingView, +} from './reviewFindingParse.js'; +export { + parseFindingFromOutputPreview, + parseFindingFromSummary, + reviewFindingKey, +} from './reviewFindingParse.js'; /** * Surfaces Mitii review findings in the editor like a real code review: @@ -132,7 +118,6 @@ export class ReviewFindingsPresenter implements vscode.Disposable { const root = this.workspaceRoot(); if (!root) return; - // Clear previous editor markers, then rebuild. this.diagnostics.clear(); for (const thread of this.threads) { thread.dispose(); @@ -142,7 +127,6 @@ export class ReviewFindingsPresenter implements vscode.Disposable { const byUri = new Map(); for (const finding of this.findings) { - // Fixed findings stay in the list for UI strike-through, but drop editor noise. if (finding.status === 'fixed') continue; const abs = join(root, finding.path); @@ -168,13 +152,14 @@ export class ReviewFindingsPresenter implements vscode.Disposable { list.push(diagnostic); byUri.set(uri.toString(), list); - const bodyParts = [ - `**${label}**${category}`, - '', - finding.content, - ]; + const bodyParts = [`**${label}**${category}`, '', finding.content]; if (finding.existingCode?.trim()) { - bodyParts.push('', '```', finding.existingCode.trim().slice(0, 800), '```'); + bodyParts.push( + '', + '```', + finding.existingCode.trim().slice(0, 800), + '```', + ); } if (finding.suggestionCode?.trim()) { bodyParts.push( @@ -204,7 +189,6 @@ export class ReviewFindingsPresenter implements vscode.Disposable { this.diagnostics.set(this.vs.Uri.parse(uriKey), diags); } - // Open the first open finding once per run so subsequent findings don't steal focus. const first = this.findings.find((f) => f.status !== 'fixed'); if (first && !this.revealedFirst) { this.revealedFirst = true; @@ -235,166 +219,3 @@ export class ReviewFindingsPresenter implements vscode.Disposable { for (const d of this.disposables) d.dispose(); } } - -export function parseFindingFromOutputPreview( - preview: string, -): ReviewFindingView | null { - const text = preview.trim(); - if (!text) return null; - - // Prefer JSON object in the preview (may be truncated / compacted). - const jsonStart = text.indexOf('{'); - if (jsonStart < 0) { - return parseFindingFromSummary(text); - } - let raw = text.slice(jsonStart); - // Truncation marker from tool-runtime / audit preview. - raw = raw.replace(/…$/, '').replace(/\.\.\.$/, ''); - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - const fromFields = parseFindingFromLooseFields(text); - return fromFields ?? parseFindingFromSummary(text); - } - - const record = - parsed && typeof parsed === 'object' - ? ((parsed as { finding?: unknown; accepted?: unknown }).finding && - typeof (parsed as { finding: unknown }).finding === 'object' - ? ((parsed as { finding: Record }).finding) - : (parsed as Record)) - : null; - if (!record) return parseFindingFromSummary(text); - - const path = String(record.path ?? '').trim(); - const content = String(record.content ?? '').trim(); - if (!path || !content) return parseFindingFromSummary(text); - - return { - path, - content, - existingCode: - typeof record.existingCode === 'string' ? record.existingCode : undefined, - suggestionCode: - typeof record.suggestionCode === 'string' - ? record.suggestionCode - : undefined, - startLine: - typeof record.startLine === 'number' ? record.startLine : undefined, - endLine: typeof record.endLine === 'number' ? record.endLine : undefined, - category: typeof record.category === 'string' ? record.category : undefined, - severity: normalizeSeverity( - typeof record.severity === 'string' ? record.severity : undefined, - ), - anchored: record.anchored === true, - }; -} - -/** Parse `finding path=… line=… sev=… cat=… :: message` summaries. */ -export function parseFindingFromSummary( - summary: string, -): ReviewFindingView | null { - const text = summary.trim(); - if (!text.toLowerCase().includes('finding') && !/\bpath=/.test(text)) { - return null; - } - const path = matchKv(text, 'path'); - const content = - text.includes('::') - ? text.slice(text.indexOf('::') + 2).trim() - : matchKv(text, 'msg') ?? matchStringField(text, 'content'); - if (!path || !content) return null; - const lineRaw = matchKv(text, 'line'); - const line = lineRaw && /^\d+$/.test(lineRaw) ? Number(lineRaw) : undefined; - return { - path, - content, - startLine: line, - endLine: line, - category: matchKv(text, 'cat') ?? undefined, - severity: normalizeSeverity(matchKv(text, 'sev') ?? undefined), - }; -} - -function parseFindingFromLooseFields(text: string): ReviewFindingView | null { - const path = matchStringField(text, 'path'); - const content = matchStringField(text, 'content'); - if (!path || !content) return null; - return { - path, - content, - existingCode: matchStringField(text, 'existingCode') ?? undefined, - startLine: matchNumberField(text, 'startLine') ?? undefined, - endLine: matchNumberField(text, 'endLine') ?? undefined, - category: matchStringField(text, 'category') ?? undefined, - severity: normalizeSeverity(matchStringField(text, 'severity') ?? undefined), - }; -} - -function matchKv(text: string, key: string): string | null { - const re = new RegExp(`\\b${key}=([^\\s]+)`); - const m = text.match(re); - return m?.[1] ? m[1].replace(/^"|"$/g, '') : null; -} - -function normalizeSeverity(value: string | undefined): ReviewFindingSeverity { - const raw = (value ?? 'medium').toLowerCase(); - if (raw === 'critical' || raw === 'blocker') return 'critical'; - if (raw === 'high' || raw === 'error') return 'high'; - if (raw === 'low' || raw === 'nit' || raw === 'fyi') return 'low'; - if (raw === 'info' || raw === 'hint') return 'info'; - return 'medium'; -} - -function severityLabel(severity: ReviewFindingSeverity): string { - switch (severity) { - case 'critical': - return 'Critical'; - case 'high': - return 'High'; - case 'medium': - return 'Medium'; - case 'low': - return 'Low'; - case 'info': - return 'Info'; - } -} - -function toDiagnosticSeverity( - vs: typeof vscode, - severity: ReviewFindingSeverity, -): vscode.DiagnosticSeverity { - switch (severity) { - case 'critical': - case 'high': - return vs.DiagnosticSeverity.Error; - case 'medium': - return vs.DiagnosticSeverity.Warning; - case 'low': - return vs.DiagnosticSeverity.Information; - case 'info': - return vs.DiagnosticSeverity.Hint; - } -} - -function matchStringField(text: string, key: string): string | null { - const re = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`); - const m = text.match(re); - if (!m?.[1]) return null; - try { - return JSON.parse(`"${m[1]}"`) as string; - } catch { - return m[1]; - } -} - -function matchNumberField(text: string, key: string): number | null { - const re = new RegExp(`"${key}"\\s*:\\s*(\\d+)`); - const m = text.match(re); - if (!m?.[1]) return null; - const n = Number(m[1]); - return Number.isFinite(n) && n > 0 ? n : null; -} diff --git a/apps/vscode/src/sidebar.ts b/apps/vscode/src/sidebar.ts index f3b3ff48..d852a3d9 100644 --- a/apps/vscode/src/sidebar.ts +++ b/apps/vscode/src/sidebar.ts @@ -418,33 +418,22 @@ function needsFullIndexRefresh(index: IndexStatusSnapshot): boolean { return false; } -/** Quick git-diff scan — findings only, not multi-axis code review. */ -const REVIEW_CHANGES_HOST_PREFIX = - 'Scan the selected files in the current git changes (staged and unstaged). Start by calling read_git_status with includeDiff=true. Report only material bugs, regressions, and security issues introduced by the diff. You MUST call emit_review_finding at least once before finishing — once per high-signal issue with path, content, existingCode, severity, and category. If there are no material issues, emit a single low/info finding that says so. Prose-only analysis is not valid. Do NOT perform a multi-axis code-quality review (architecture, readability style nits, test-coverage coaching, or merge-readiness playbooks). Do NOT follow code-review-and-quality instructions even if a skill is present.'; - -/** Thorough code review of the same git changes. */ +/** Thorough code review of git working-tree changes (Code Review button). */ const REVIEW_CODE_HOST_PREFIX = 'Perform a thorough code review of every selected file in the current git changes, including both staged and unstaged patches. Start by calling read_git_status with includeDiff=true. Assess correctness, readability, architecture, tests, and operational risk. You MUST call emit_review_finding at least once before finishing — once per high-signal issue with path, content, existingCode, severity, and category. If there are no material issues, emit a single low/info finding that says so. Prose-only analysis is not a valid review. Prefer high-signal findings over nits.'; /** Avoid stacking identical review instructions from UI + host. */ -function buildReviewLlmPrompt( - userPrompt: string, - kind: 'changes' | 'code' = 'changes', -): string { - const prefix = - kind === 'code' ? REVIEW_CODE_HOST_PREFIX : REVIEW_CHANGES_HOST_PREFIX; +function buildReviewLlmPrompt(userPrompt: string): string { const trimmed = userPrompt.trim(); - if (!trimmed) return prefix; - // Already host-built (e.g. resume / stacked) — keep as-is. + if (!trimmed) return REVIEW_CODE_HOST_PREFIX; if ( - trimmed.startsWith('Scan the selected files in the current git changes') || trimmed.startsWith( 'Perform a thorough code review of every selected file in the current git changes', ) ) { return trimmed; } - return `${prefix}\n\n${trimmed}`; + return `${REVIEW_CODE_HOST_PREFIX}\n\n${trimmed}`; } const EMBEDDING_SOURCES = [ @@ -1589,16 +1578,8 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { this.runCancel?.dispose(); this.liveApprovalMode = undefined; this.runCancel = new this.vs.CancellationTokenSource(); - const reviewKind = - message.mode === 'review' - ? message.reviewKind === 'code' - ? 'code' - : 'changes' - : undefined; let llmPrompt = - message.mode === 'review' - ? buildReviewLlmPrompt(prompt, reviewKind ?? 'changes') - : prompt; + message.mode === 'review' ? buildReviewLlmPrompt(prompt) : prompt; if (message.mode === 'review') { const root = this.effectiveRoot(); if (root) { @@ -1723,11 +1704,15 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { effort: message.effort, approvalMode: message.approvalMode, pinnedPaths: message.pinnedPaths, - requiredSkillIds: message.requiredSkillIds, - excludedSkillIds: - message.mode === 'review' && reviewKind !== 'code' - ? ['code-review-and-quality'] - : undefined, + requiredSkillIds: + message.mode === 'review' + ? [ + 'code-review-and-quality', + ...(message.requiredSkillIds ?? []).filter( + (id) => id !== 'code-review-and-quality', + ), + ].slice(0, 3) + : message.requiredSkillIds, requiredMcpServerIds: message.requiredMcpServerIds, workspaceId: this.getWorkspaceId(), workspaceState: this.host.workspaceState, diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 8bb4f653..32ca7cbe 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -40,7 +40,11 @@ import { approvalModeUiPatch } from './approvalPresets'; import { OnboardingPanel } from './components/OnboardingPanel'; import { PendingPlanBanner } from './components/PendingPlanBanner'; import { PlanFollowStrip } from './components/PlanPanel'; -import { WorkingTreeReviewBar } from './components/WorkingTreeReviewBar'; +import { + ComposerReviewStrip, + composerNeedsReviewStrip, + selectLatestRunChanges, +} from './review/ComposerReviewStrip'; import { SettingsErrorBoundary } from './components/SettingsErrorBoundary'; import { SettingsPanel } from './components/SettingsPanel'; import { WorkspaceBanner } from './components/WorkspaceBanner'; @@ -129,10 +133,7 @@ const EMPTY_TOKEN_USAGE: TokenUsageSnapshot = { }; const REVIEW_SKILL_ID = 'code-review-and-quality'; -/** Quick diff scan — structured findings only, no quality skill. */ -const DEFAULT_REVIEW_PROMPT = - 'Scan the working-tree diff for material bugs, regressions, and security issues only.'; -/** Thorough code review — attaches code-review-and-quality. */ +/** LLM code review of working-tree changes (Code Review button only). */ const DEFAULT_CODE_REVIEW_PROMPT = 'Perform a thorough code review of the current working-tree changes across correctness, readability, architecture, tests, and risk.'; @@ -960,9 +961,9 @@ export function App() { >([]); const [reviewBarExpandToken, setReviewBarExpandToken] = useState(0); const pendingAutoReviewRef = useRef<{ prompt?: string } | null>(null); - const runReviewRef = useRef< - ((promptOverride?: string, options?: { codeReview?: boolean }) => void) | null - >(null); + const runReviewRef = useRef<((promptOverride?: string) => void) | null>( + null, + ); const [skillItems, setSkillItems] = useState([]); const [automationSpecs, setAutomationSpecs] = useState( [], @@ -1463,11 +1464,12 @@ export function App() { break; case 'startReview': { setNav('chat'); - // Review is a composer action, not a chat mode. + // Review = show git changes only (not a chat mode / not LLM). if (modeRef.current === 'review') setMode('ask'); setReviewBarExpandToken((n) => n + 1); postToHost({ type: 'refreshReviewDiff' }); - if (msg.autoRun) { + // Optional auto-run is Code Review when the feature is enabled. + if (msg.autoRun && uiRef.current.features.codeReviewButton) { pendingAutoReviewRef.current = { prompt: msg.prompt }; } else { pendingAutoReviewRef.current = null; @@ -1728,14 +1730,11 @@ export function App() { setSuggestOpen(false); }, [prompt, running, mode, ui, approvalMode, pinned, pinnedSkillIds, pinnedMcpServerIds]); - const runReview = useCallback( - (promptOverride?: string, options?: { codeReview?: boolean }) => { + const runCodeReview = useCallback( + (promptOverride?: string) => { if (running) return; - const codeReview = options?.codeReview === true; - const fallback = codeReview - ? DEFAULT_CODE_REVIEW_PROMPT - : DEFAULT_REVIEW_PROMPT; - const text = (promptOverride ?? prompt).trim() || fallback; + const text = + (promptOverride ?? prompt).trim() || DEFAULT_CODE_REVIEW_PROMPT; stickToBottomRef.current = true; forceScrollToBottomRef.current = true; const defaults = modeDefaultsFromUi(ui, 'ask'); @@ -1745,21 +1744,18 @@ export function App() { depth: defaults.depth, effort: ui.effort, }); - // Review button sends mode:'review'; keep Ask/Plan/Agent selection. if (mode === 'review') setMode('ask'); setReviewBarExpandToken((n) => n + 1); postToHost({ type: 'ask', prompt: text, mode: 'review', - reviewKind: codeReview ? 'code' : 'changes', + reviewKind: 'code', depth: intensity.depth, effort: intensity.effort, approvalMode: defaults.approvalMode, pinnedPaths: pinned.map((p) => p.path), - requiredSkillIds: codeReview - ? mergeReviewSkillIds(pinnedSkillIds) - : pinnedSkillIds.filter((id) => id !== REVIEW_SKILL_ID), + requiredSkillIds: mergeReviewSkillIds(pinnedSkillIds), requiredMcpServerIds: pinnedMcpServerIds, }); setPrompt(''); @@ -1772,8 +1768,13 @@ export function App() { ); useEffect(() => { - runReviewRef.current = runReview; - }, [runReview]); + runReviewRef.current = runCodeReview; + }, [runCodeReview]); + + const showGitChanges = useCallback(() => { + setReviewBarExpandToken((n) => n + 1); + postToHost({ type: 'refreshReviewDiff' }); + }, []); const executePendingPlan = useCallback(() => { if (running) return; @@ -1993,7 +1994,6 @@ export function App() { path: file.path, }); } - queueMicrotask(() => runReviewRef.current?.()); }, []); const dismissFileChanges = useCallback((runId: string) => { @@ -2680,9 +2680,13 @@ export function App() { ) : null}
    0 || - turns.some((t) => t.fileChanges) || - reviewFindings.length > 0 + composerNeedsReviewStrip({ + chatFileCount: + selectLatestRunChanges(turns)?.files.length ?? 0, + gitFileCount: review?.files.length ?? 0, + findingsCount: reviewFindings.length, + codeReviewEnabled: ui.features.codeReviewButton === true, + }) ? ' composer-box--with-review' : '' }`} @@ -2692,16 +2696,13 @@ export function App() { } as CSSProperties } > - t.fileChanges)?.fileChanges ?? null - } + turns={turns} running={running} expandSignal={reviewBarExpandToken} + codeReviewEnabled={ui.features.codeReviewButton === true} onRefresh={() => postToHost({ type: 'refreshReviewDiff' })} onOpenFile={openFile} onOpenDiff={(path) => @@ -2710,25 +2711,10 @@ export function App() { onOpenFinding={(path, line) => postToHost({ type: 'openFile', path, line }) } - onRunReview={() => runReview()} - onRunCodeReview={ - ui.features.codeReviewButton - ? () => runReview(undefined, { codeReview: true }) - : undefined - } - showCodeReview={ui.features.codeReviewButton === true} - onUndoAll={() => { - const changes = [...turns] - .reverse() - .find((t) => t.fileChanges)?.fileChanges; - if (changes) undoFileChanges(changes.runId); - }} - onKeepAll={() => { - const changes = [...turns] - .reverse() - .find((t) => t.fileChanges)?.fileChanges; - if (changes) dismissFileChanges(changes.runId); - }} + onShowChanges={showGitChanges} + onRunCodeReview={() => runCodeReview()} + onUndoFileChanges={undoFileChanges} + onDismissFileChanges={dismissFileChanges} onDismissFindings={dismissReviewFindings} onFixAllFindings={() => fixReviewFindings()} onFixFinding={(index) => fixReviewFindings([index])} diff --git a/apps/vscode/webview-ui/src/components/ReviewPanel.tsx b/apps/vscode/webview-ui/src/components/ReviewPanel.tsx index 9ed8be34..0c35984a 100644 --- a/apps/vscode/webview-ui/src/components/ReviewPanel.tsx +++ b/apps/vscode/webview-ui/src/components/ReviewPanel.tsx @@ -14,7 +14,7 @@ interface ReviewPanelProps { } const DEFAULT_REVIEW_HINT = - 'Working-tree review is read-only. Use Run review on the Review bar (not a chat mode). Optional focus: bugs, security, tests.'; + 'Review shows git changes. Enable Settings → Features → Code Review to run an LLM analysis of those changes.'; function reviewStatusLabel(status: string): string { const normalized = status.trim() || '?'; @@ -82,10 +82,10 @@ export function ReviewPanel({ title={ files.length === 0 ? 'Make git changes first, then refresh' - : 'Run a structured review of these changes' + : 'Run an LLM code review of these changes' } > - {running ? 'Reviewing…' : 'Run review'} + {running ? 'Reviewing…' : 'Code Review'} ) : null}
    @@ -94,8 +94,8 @@ export function ReviewPanel({ {files.length === 0 ? (

    - No working-tree diff. Edit files (or stage changes), click Refresh, - then Run review. + No working-tree diff. Edit files (or stage changes), then click + Refresh to see changes.

    ) : ( diff --git a/apps/vscode/webview-ui/src/components/SettingsPanel.tsx b/apps/vscode/webview-ui/src/components/SettingsPanel.tsx index 15f4197f..c6e85959 100644 --- a/apps/vscode/webview-ui/src/components/SettingsPanel.tsx +++ b/apps/vscode/webview-ui/src/components/SettingsPanel.tsx @@ -1417,7 +1417,7 @@ export function SettingsPanel(props: SettingsPanelProps) {
    diff --git a/apps/vscode/webview-ui/src/components/WorkingTreeReviewBar.tsx b/apps/vscode/webview-ui/src/components/WorkingTreeReviewBar.tsx index e7cbc573..12dfac8e 100644 --- a/apps/vscode/webview-ui/src/components/WorkingTreeReviewBar.tsx +++ b/apps/vscode/webview-ui/src/components/WorkingTreeReviewBar.tsx @@ -1,393 +1,7 @@ -import { useEffect, useState } from 'react'; - -import type { ReviewDiffView, RunFileChangesView } from '../protocol'; - -export type ReviewFindingChip = { - path: string; - content: string; - startLine?: number; - endLine?: number; - severity: string; - category?: string; - existingCode?: string; - suggestionCode?: string; - status?: 'open' | 'fixed'; -}; - -interface WorkingTreeReviewBarProps { - review: ReviewDiffView | null; - findings?: readonly ReviewFindingChip[]; - /** Latest Mitii run edits — enables Undo All / Keep All. */ - runChanges?: RunFileChangesView | null; - running?: boolean; - /** Increment to force-expand (command palette / Review CTA). */ - expandSignal?: number; - onRefresh: () => void; - onOpenFile: (path: string) => void; - onOpenDiff: (path: string) => void; - onOpenFinding?: (path: string, line?: number) => void; - onRunReview: () => void; - /** Deeper code review — only when Settings → Features enables the button. */ - onRunCodeReview?: () => void; - showCodeReview?: boolean; - onUndoAll?: () => void; - onKeepAll?: () => void; - /** Clear sticky review findings (Problems + comments + chips). */ - onDismissFindings?: () => void; - /** Fix one finding by index into `findings`. */ - onFixFinding?: (index: number) => void; - /** Fix every open finding via the host recipe. */ - onFixAllFindings?: () => void; -} - -function statusLabel(status: string): string { - const normalized = status.trim() || '?'; - if (normalized.includes('A') || normalized === 'A') return 'Added'; - if (normalized.includes('D')) return 'Deleted'; - if (normalized.includes('R')) return 'Renamed'; - if (normalized.includes('?')) return 'Untracked'; - if (normalized.includes('M') || normalized === 'M') return 'Edited'; - return normalized; -} - -function statusTone(status: string): string { - const normalized = status.trim(); - if (normalized.includes('A') || normalized.includes('?') || normalized === 'A') { - return 'added'; - } - if (normalized.includes('D')) return 'deleted'; - return 'edited'; -} - -function severityTone(severity: string): string { - const s = severity.toLowerCase(); - if (s === 'critical' || s === 'high') return 'high'; - if (s === 'medium') return 'medium'; - return 'low'; -} - /** - * Flush review strip attached to the top of the composer box (Cursor-style). - * Review is an action — not a chat mode. + * Re-export for existing App imports. Implementation lives in `../review/`. */ -export function WorkingTreeReviewBar({ - review, - findings = [], - runChanges = null, - running = false, - expandSignal = 0, - onRefresh, - onOpenFile, - onOpenDiff, - onOpenFinding, - onRunReview, - onRunCodeReview, - showCodeReview = false, - onUndoAll, - onKeepAll, - onDismissFindings, - onFixFinding, - onFixAllFindings, -}: WorkingTreeReviewBarProps) { - const files = review?.files ?? []; - const fileCount = files.length > 0 ? files.length : (runChanges?.files.length ?? 0); - const [expanded, setExpanded] = useState(false); - const [tab, setTab] = useState<'files' | 'findings'>('files'); - - const openFindings = findings.filter((f) => f.status !== 'fixed'); - const fixedFindings = findings.filter((f) => f.status === 'fixed'); - const reviewComplete = - findings.length > 0 && openFindings.length === 0 && fixedFindings.length > 0; - const hasOpenFindings = openFindings.length > 0; - - useEffect(() => { - if (expandSignal > 0) setExpanded(true); - }, [expandSignal]); - - useEffect(() => { - if (findings.length > 0) { - setTab('findings'); - setExpanded(true); - } - }, [findings.length]); - - useEffect(() => { - if (reviewComplete) setExpanded(false); - }, [reviewComplete]); - - if (fileCount === 0 && findings.length === 0 && !running) { - return null; - } - - const canUndoKeep = - !reviewComplete && Boolean(runChanges && runChanges.files.length > 0); - const canFix = - hasOpenFindings && !running && Boolean(onFixAllFindings) && !reviewComplete; - const listFiles = - files.length > 0 - ? files.map((f) => ({ path: f.path, status: f.status })) - : (runChanges?.files ?? []).map((f) => ({ - path: f.path, - status: f.status, - })); - - return ( -
    -
    - - {!reviewComplete ? ( -
    - - - {findings.length > 0 && onDismissFindings ? ( - - ) : null} - {canFix ? ( - - ) : null} - - {showCodeReview && onRunCodeReview ? ( - - ) : null} -
    - ) : ( -
    - {onDismissFindings ? ( - - ) : null} -
    - )} -
    - {expanded ? ( -
    -
    -
    - - -
    - -
    - {tab === 'findings' ? ( - findings.length === 0 ? ( -

    - No findings yet. Click Review - {showCodeReview ? ' or Code Review' : ''} — issues appear on the - code line in the editor (and in Problems). -

    - ) : ( -
      - {findings.map((f, i) => { - const fixed = f.status === 'fixed'; - return ( -
    • - - {!fixed && onFixFinding ? ( - - ) : fixed ? ( - - ✓ - - ) : null} -
    • - ); - })} -
    - ) - ) : listFiles.length === 0 ? ( -

    No changed files.

    - ) : ( -
      - {listFiles.map((file) => { - const deleted = String(file.status).includes('D'); - return ( -
    • - - -
    • - ); - })} -
    - )} -
    - ) : null} -
    - ); -} +export { + WorkingTreeReviewBar, + type ReviewFindingChip, +} from '../review/WorkingTreeReviewBar'; diff --git a/apps/vscode/webview-ui/src/review/ComposerReviewStrip.tsx b/apps/vscode/webview-ui/src/review/ComposerReviewStrip.tsx new file mode 100644 index 00000000..26bd41b3 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ComposerReviewStrip.tsx @@ -0,0 +1,83 @@ +import type { ReviewDiffView, RunFileChangesView } from '../protocol'; +import { WorkingTreeReviewBar } from './WorkingTreeReviewBar'; +import type { ReviewFindingChip } from './reviewFindingTypes'; +import { + composerNeedsReviewStrip, + selectLatestRunChanges, +} from './selectLatestRunChanges'; + +type TurnLike = { fileChanges?: RunFileChangesView }; + +type Props = { + review: ReviewDiffView | null; + findings: readonly ReviewFindingChip[]; + turns: readonly TurnLike[]; + running: boolean; + expandSignal: number; + codeReviewEnabled: boolean; + onRefresh: () => void; + onOpenFile: (path: string) => void; + onOpenDiff: (path: string) => void; + onOpenFinding: (path: string, line?: number) => void; + onShowChanges: () => void; + onRunCodeReview: () => void; + onUndoFileChanges: (runId: string) => void; + onDismissFileChanges: (runId: string) => void; + onDismissFindings: () => void; + onFixAllFindings: () => void; + onFixFinding: (index: number) => void; +}; + +/** + * Composer-attached review strip with dual-scope wiring. + * Keeps App.tsx free of run-change lookup / feature-gate details. + */ +export function ComposerReviewStrip({ + review, + findings, + turns, + running, + expandSignal, + codeReviewEnabled, + onRefresh, + onOpenFile, + onOpenDiff, + onOpenFinding, + onShowChanges, + onRunCodeReview, + onUndoFileChanges, + onDismissFileChanges, + onDismissFindings, + onFixAllFindings, + onFixFinding, +}: Props) { + const runChanges = selectLatestRunChanges(turns); + + return ( + { + if (runChanges) onUndoFileChanges(runChanges.runId); + }} + onKeepAll={() => { + if (runChanges) onDismissFileChanges(runChanges.runId); + }} + onDismissFindings={onDismissFindings} + onFixAllFindings={onFixAllFindings} + onFixFinding={onFixFinding} + /> + ); +} + +export { composerNeedsReviewStrip, selectLatestRunChanges }; diff --git a/apps/vscode/webview-ui/src/review/README.md b/apps/vscode/webview-ui/src/review/README.md new file mode 100644 index 00000000..16ca8b3f --- /dev/null +++ b/apps/vscode/webview-ui/src/review/README.md @@ -0,0 +1,41 @@ +# Review bar (webview) + +Composer strip above the Mitii chat input. Host ownership stays in `apps/vscode/src/` +(`reviewDiff.ts`, `review/reviewFindingsPresenter.ts`); this folder is UI + scope rules only. + +## Dual-scope contract + +| Action / surface | Data source | LLM | +|---|---|---| +| Summary (`2 file changes`) | Latest run `RunFileChangesView` | No | +| **Review** + Files tab | Same chat/run edits | No | +| **Code Review (N)** | Full git `ReviewDiffView` (`N` = file count) | Yes | +| Findings tab, severity chips, Fix / Fix all | Present only when `ui.features.codeReviewButton` is on | After Code Review | + +```text +› 2 file changes Undo All · Keep All · Review | Code Review (21) +``` + +- Left count = what **this chat** changed. +- `Code Review (21)` = analyze **all** dirty git files (staged + unstaged + untracked as reported by `buildReviewDiff`). + +## Feature gate + +`Settings → Features → Code Review` (`mitii.ui.features.codeReviewButton`): + +- **Off:** bar shows chat file changes + Review. No Code Review CTA, no Findings tab, no Fix. +- **On:** Code Review CTA with git count; findings appear after a review run with criticality + Fix. + +## Modules + +| File | Role | +|---|---| +| `reviewBarModel.ts` | Pure scope resolution (MUST stay host-free) | +| `WorkingTreeReviewBar.tsx` | Orchestrator | +| `ReviewBarActions.tsx` | Undo / Keep / Review / Code Review / Fix | +| `ReviewFilesPanel.tsx` | Chat file list | +| `ReviewFindingsPanel.tsx` | Finding chips by severity | + +## Tests + +`webview-ui/tests/reviewBarModel.test.ts` — scope visibility, labels, and gating. diff --git a/apps/vscode/webview-ui/src/review/ReviewBarActions.tsx b/apps/vscode/webview-ui/src/review/ReviewBarActions.tsx new file mode 100644 index 00000000..93940daa --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ReviewBarActions.tsx @@ -0,0 +1,142 @@ +type Props = { + reviewComplete: boolean; + canUndoKeep: boolean; + canFix: boolean; + canExpandReview: boolean; + showCodeReview: boolean; + codeReviewButtonLabel: string; + canRunCodeReview: boolean; + running: boolean; + hasFindings: boolean; + onUndoAll?: () => void; + onKeepAll?: () => void; + onDismissFindings?: () => void; + onFixAllFindings?: () => void; + onShowChanges: () => void; + onRunCodeReview?: () => void; + onCollapse: () => void; + onSelectFilesTab: () => void; +}; + +/** Action cluster: Undo/Keep (chat) · Review (chat) · Code Review (git). */ +export function ReviewBarActions({ + reviewComplete, + canUndoKeep, + canFix, + canExpandReview, + showCodeReview, + codeReviewButtonLabel, + canRunCodeReview, + running, + hasFindings, + onUndoAll, + onKeepAll, + onDismissFindings, + onFixAllFindings, + onShowChanges, + onRunCodeReview, + onCollapse, + onSelectFilesTab, +}: Props) { + if (reviewComplete) { + return ( +
    + {onDismissFindings ? ( + + ) : null} +
    + ); + } + + return ( +
    + + + {hasFindings && onDismissFindings ? ( + + ) : null} + {canFix ? ( + + ) : null} + + {showCodeReview && onRunCodeReview ? ( + + ) : null} +
    + ); +} diff --git a/apps/vscode/webview-ui/src/review/ReviewFilesPanel.tsx b/apps/vscode/webview-ui/src/review/ReviewFilesPanel.tsx new file mode 100644 index 00000000..a33c3c6f --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ReviewFilesPanel.tsx @@ -0,0 +1,53 @@ +import type { ReviewBarFile } from './reviewBarModel'; +import { statusLabel, statusTone } from './reviewBarFormat'; + +type Props = { + files: readonly ReviewBarFile[]; + onOpenDiff: (path: string) => void; + onOpenFile: (path: string) => void; +}; + +/** Files tab — always this-chat Mitii edits, never the full git tree. */ +export function ReviewFilesPanel({ files, onOpenDiff, onOpenFile }: Props) { + if (files.length === 0) { + return ( +

    + No Mitii edits in this chat yet. Code Review still covers the full git + working tree when enabled. +

    + ); + } + + return ( +
      + {files.map((file) => { + const deleted = String(file.status).includes('D'); + return ( +
    • + + +
    • + ); + })} +
    + ); +} diff --git a/apps/vscode/webview-ui/src/review/ReviewFindingsPanel.tsx b/apps/vscode/webview-ui/src/review/ReviewFindingsPanel.tsx new file mode 100644 index 00000000..78215839 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ReviewFindingsPanel.tsx @@ -0,0 +1,78 @@ +import type { ReviewFindingChip } from './reviewFindingTypes'; +import { severityTone } from './reviewBarFormat'; + +type Props = { + findings: readonly ReviewFindingChip[]; + running?: boolean; + onOpenFinding?: (path: string, line?: number) => void; + onOpenFile: (path: string) => void; + onFixFinding?: (index: number) => void; +}; + +/** Findings tab — severity chips + per-row Fix (Code Review feature only). */ +export function ReviewFindingsPanel({ + findings, + running = false, + onOpenFinding, + onOpenFile, + onFixFinding, +}: Props) { + if (findings.length === 0) { + return ( +

    + No findings yet. Click Code Review to analyze the git working-tree + changes. +

    + ); + } + + return ( +
      + {findings.map((f, i) => { + const fixed = f.status === 'fixed'; + return ( +
    • + + {!fixed && onFixFinding ? ( + + ) : fixed ? ( + + ✓ + + ) : null} +
    • + ); + })} +
    + ); +} diff --git a/apps/vscode/webview-ui/src/review/WorkingTreeReviewBar.tsx b/apps/vscode/webview-ui/src/review/WorkingTreeReviewBar.tsx new file mode 100644 index 00000000..f3640a7e --- /dev/null +++ b/apps/vscode/webview-ui/src/review/WorkingTreeReviewBar.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { ReviewDiffView, RunFileChangesView } from '../protocol'; +import { ReviewBarActions } from './ReviewBarActions'; +import { ReviewFilesPanel } from './ReviewFilesPanel'; +import { ReviewFindingsPanel } from './ReviewFindingsPanel'; +import type { ReviewFindingChip } from './reviewFindingTypes'; +import { + chatFilesFromRunChanges, + gitFilesFromReview, + resolveReviewBarScope, +} from './reviewBarModel'; + +export type { ReviewFindingChip } from './reviewFindingTypes'; + +interface WorkingTreeReviewBarProps { + review: ReviewDiffView | null; + findings?: readonly ReviewFindingChip[]; + /** Latest Mitii run edits — Files list + Undo All / Keep All. */ + runChanges?: RunFileChangesView | null; + running?: boolean; + /** Increment to force-expand (command palette / Review CTA). */ + expandSignal?: number; + onRefresh: () => void; + onOpenFile: (path: string) => void; + onOpenDiff: (path: string) => void; + onOpenFinding?: (path: string, line?: number) => void; + /** Expand the bar and show this-chat file changes (no LLM). */ + onShowChanges: () => void; + /** LLM code review of the full git working tree. */ + onRunCodeReview?: () => void; + showCodeReview?: boolean; + onUndoAll?: () => void; + onKeepAll?: () => void; + onDismissFindings?: () => void; + onFixFinding?: (index: number) => void; + onFixAllFindings?: () => void; +} + +/** + * Flush review strip attached to the top of the composer box. + * Review = this-chat diffs. Code Review = full git + findings (feature-gated). + */ +export function WorkingTreeReviewBar({ + review, + findings = [], + runChanges = null, + running = false, + expandSignal = 0, + onRefresh, + onOpenFile, + onOpenDiff, + onOpenFinding, + onShowChanges, + onRunCodeReview, + showCodeReview = false, + onUndoAll, + onKeepAll, + onDismissFindings, + onFixFinding, + onFixAllFindings, +}: WorkingTreeReviewBarProps) { + const scope = useMemo( + () => + resolveReviewBarScope({ + gitFiles: gitFilesFromReview(review?.files), + chatFiles: chatFilesFromRunChanges(runChanges?.files), + findingsCount: findings.length, + showCodeReview, + running, + }), + [review?.files, runChanges?.files, findings.length, showCodeReview, running], + ); + + const [expanded, setExpanded] = useState(false); + const [tab, setTab] = useState<'files' | 'findings'>('files'); + + const openFindings = findings.filter((f) => f.status !== 'fixed'); + const fixedFindings = findings.filter((f) => f.status === 'fixed'); + const reviewComplete = + scope.showFindingsUi && + findings.length > 0 && + openFindings.length === 0 && + fixedFindings.length > 0; + const hasOpenFindings = scope.showFindingsUi && openFindings.length > 0; + + useEffect(() => { + if (expandSignal > 0) setExpanded(true); + }, [expandSignal]); + + useEffect(() => { + if (scope.showFindingsUi && findings.length > 0) { + setTab('findings'); + setExpanded(true); + } + }, [findings.length, scope.showFindingsUi]); + + useEffect(() => { + if (reviewComplete) setExpanded(false); + }, [reviewComplete]); + + if (!scope.visible) return null; + + const canUndoKeep = + !reviewComplete && Boolean(runChanges && runChanges.files.length > 0); + const canFix = + hasOpenFindings && !running && Boolean(onFixAllFindings) && !reviewComplete; + + return ( +
    +
    + + 0} + onUndoAll={onUndoAll} + onKeepAll={onKeepAll} + onDismissFindings={ + scope.showFindingsUi ? onDismissFindings : undefined + } + onFixAllFindings={scope.showFindingsUi ? onFixAllFindings : undefined} + onShowChanges={() => { + setExpanded(true); + onShowChanges(); + }} + onRunCodeReview={onRunCodeReview} + onCollapse={() => setExpanded(false)} + onSelectFilesTab={() => setTab('files')} + /> +
    + {expanded ? ( +
    +
    +
    + + {scope.showFindingsUi ? ( + + ) : null} +
    + +
    + {tab === 'findings' && scope.showFindingsUi ? ( + + ) : ( + + )} +
    + ) : null} +
    + ); +} diff --git a/apps/vscode/webview-ui/src/review/index.ts b/apps/vscode/webview-ui/src/review/index.ts new file mode 100644 index 00000000..30e38811 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/index.ts @@ -0,0 +1,32 @@ +/** + * Composer review strip — dual-scope contract. + * + * | Surface | Scope | LLM | + * |---|---|---| + * | Summary count + Files tab + Review | This chat's Mitii run edits | No | + * | Code Review (N) | Full git working tree (`N` files) | Yes | + * | Findings / Fix / severity | Only when Settings → Features → Code Review is on | After Code Review | + * + * Host still refreshes `ReviewDiffView` via `refreshReviewDiff` so the Code Review + * badge stays accurate. Undo All / Keep All bind only to `RunFileChangesView`. + * + * Pure model: `reviewBarModel.ts` (unit-tested). UI shells stay under 300 lines. + */ + +export { WorkingTreeReviewBar } from './WorkingTreeReviewBar'; +export { ComposerReviewStrip } from './ComposerReviewStrip'; +export type { ReviewFindingChip } from './reviewFindingTypes'; +export { + resolveReviewBarScope, + chatFilesFromRunChanges, + gitFilesFromReview, +} from './reviewBarModel'; +export type { + ReviewBarFile, + ReviewBarScope, + ReviewBarScopeInput, +} from './reviewBarModel'; +export { + selectLatestRunChanges, + composerNeedsReviewStrip, +} from './selectLatestRunChanges'; diff --git a/apps/vscode/webview-ui/src/review/reviewBarFormat.ts b/apps/vscode/webview-ui/src/review/reviewBarFormat.ts new file mode 100644 index 00000000..8284b395 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/reviewBarFormat.ts @@ -0,0 +1,31 @@ +/** Display helpers for git status and finding severity chips. */ + +export function statusLabel(status: string): string { + const normalized = status.trim() || '?'; + if (normalized.includes('A') || normalized === 'A') return 'Added'; + if (normalized.includes('D')) return 'Deleted'; + if (normalized.includes('R')) return 'Renamed'; + if (normalized.includes('?')) return 'Untracked'; + if (normalized.includes('M') || normalized === 'M') return 'Edited'; + return normalized; +} + +export function statusTone(status: string): string { + const normalized = status.trim(); + if ( + normalized.includes('A') || + normalized.includes('?') || + normalized === 'A' + ) { + return 'added'; + } + if (normalized.includes('D')) return 'deleted'; + return 'edited'; +} + +export function severityTone(severity: string): string { + const s = severity.toLowerCase(); + if (s === 'critical' || s === 'high') return 'high'; + if (s === 'medium') return 'medium'; + return 'low'; +} diff --git a/apps/vscode/webview-ui/src/review/reviewBarModel.ts b/apps/vscode/webview-ui/src/review/reviewBarModel.ts new file mode 100644 index 00000000..230b7e1b --- /dev/null +++ b/apps/vscode/webview-ui/src/review/reviewBarModel.ts @@ -0,0 +1,103 @@ +/** + * Dual-scope contract for the composer Working Tree review strip. + * + * - Review / Files list → this chat's Mitii run edits (`chatFiles`) + * - Code Review (N) → full git working tree (`gitFiles`), feature-gated + * - Findings / Fix → only when Code Review feature is enabled + */ + +export type ReviewBarFile = { + path: string; + status: string; +}; + +export type ReviewBarScopeInput = { + gitFiles: readonly ReviewBarFile[]; + chatFiles: readonly ReviewBarFile[]; + findingsCount: number; + showCodeReview: boolean; + running?: boolean; +}; + +export type ReviewBarScope = { + /** Files shown under Review / Files (this chat only). */ + chatFiles: ReviewBarFile[]; + chatFileCount: number; + /** Working-tree file count for the Code Review CTA. */ + gitFileCount: number; + summaryLabel: string; + visible: boolean; + canExpandReview: boolean; + canRunCodeReview: boolean; + showFindingsUi: boolean; + codeReviewButtonLabel: string; +}; + +function pluralFiles(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} + +/** + * Resolve what the review bar shows and which actions are enabled. + * Pure — safe to unit-test without React or the host bridge. + */ +export function resolveReviewBarScope( + input: ReviewBarScopeInput, +): ReviewBarScope { + const chatFiles = input.chatFiles.map((f) => ({ + path: f.path, + status: f.status, + })); + const chatFileCount = chatFiles.length; + const gitFileCount = input.gitFiles.length; + const showFindingsUi = input.showCodeReview === true; + const running = input.running === true; + + const visible = + chatFileCount > 0 || + (showFindingsUi && input.findingsCount > 0) || + (showFindingsUi && gitFileCount > 0) || + running; + + let summaryLabel: string; + if (chatFileCount > 0) { + summaryLabel = pluralFiles(chatFileCount, 'file change'); + } else if (showFindingsUi && gitFileCount > 0) { + summaryLabel = pluralFiles(gitFileCount, 'git change'); + } else if (showFindingsUi && input.findingsCount > 0) { + summaryLabel = 'Review findings'; + } else { + summaryLabel = pluralFiles(0, 'file change'); + } + + return { + chatFiles, + chatFileCount, + gitFileCount, + summaryLabel, + visible, + canExpandReview: chatFileCount > 0, + canRunCodeReview: showFindingsUi && gitFileCount > 0 && !running, + showFindingsUi, + codeReviewButtonLabel: + gitFileCount > 0 + ? `Code Review (${gitFileCount})` + : 'Code Review', + }; +} + +/** Map run file-change entries into the bar's file DTO. */ +export function chatFilesFromRunChanges( + files: readonly { path: string; status: string }[] | null | undefined, +): ReviewBarFile[] { + if (!files?.length) return []; + return files.map((f) => ({ path: f.path, status: String(f.status) })); +} + +/** Map git review-diff entries into the bar's file DTO. */ +export function gitFilesFromReview( + files: readonly { path: string; status: string }[] | null | undefined, +): ReviewBarFile[] { + if (!files?.length) return []; + return files.map((f) => ({ path: f.path, status: String(f.status) })); +} diff --git a/apps/vscode/webview-ui/src/review/reviewFindingTypes.ts b/apps/vscode/webview-ui/src/review/reviewFindingTypes.ts new file mode 100644 index 00000000..4b22e4c3 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/reviewFindingTypes.ts @@ -0,0 +1,12 @@ +/** Finding chip DTO shared by the review bar panels. */ +export type ReviewFindingChip = { + path: string; + content: string; + startLine?: number; + endLine?: number; + severity: string; + category?: string; + existingCode?: string; + suggestionCode?: string; + status?: 'open' | 'fixed'; +}; diff --git a/apps/vscode/webview-ui/src/review/selectLatestRunChanges.ts b/apps/vscode/webview-ui/src/review/selectLatestRunChanges.ts new file mode 100644 index 00000000..c313bdb7 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/selectLatestRunChanges.ts @@ -0,0 +1,29 @@ +import type { RunFileChangesView } from '../protocol'; + +type TurnWithFileChanges = { + fileChanges?: RunFileChangesView; +}; + +/** Latest Mitii run file-changes attached to a chat turn (newest first). */ +export function selectLatestRunChanges( + turns: readonly TurnWithFileChanges[], +): RunFileChangesView | null { + for (let i = turns.length - 1; i >= 0; i -= 1) { + const changes = turns[i]?.fileChanges; + if (changes?.files?.length) return changes; + } + return null; +} + +/** Whether the composer should reserve space for the review strip. */ +export function composerNeedsReviewStrip(input: { + chatFileCount: number; + gitFileCount: number; + findingsCount: number; + codeReviewEnabled: boolean; +}): boolean { + if (input.chatFileCount > 0) return true; + if (input.findingsCount > 0 && input.codeReviewEnabled) return true; + if (input.codeReviewEnabled && input.gitFileCount > 0) return true; + return false; +} diff --git a/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx b/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx new file mode 100644 index 00000000..b80ba6a7 --- /dev/null +++ b/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx @@ -0,0 +1,115 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { WorkingTreeReviewBar } from '../src/review/WorkingTreeReviewBar'; + +afterEach(() => cleanup()); + +describe('WorkingTreeReviewBar', () => { + const chatFiles = { + runId: 'run-1', + files: [ + { path: 'chat-a.ts', status: 'M' as const, additions: 1, deletions: 0 }, + { path: 'chat-b.ts', status: 'A' as const, additions: 2, deletions: 0 }, + ], + totalAdditions: 3, + totalDeletions: 0, + }; + + const review = { + summary: '## main', + files: [ + { path: 'chat-a.ts', status: 'M' }, + { path: 'chat-b.ts', status: 'A' }, + { path: 'extra.ts', status: 'M' }, + ], + }; + + it('lists this-chat files under Review and labels Code Review with git count', () => { + const onRunCodeReview = vi.fn(); + render( + , + ); + + expect(screen.getByText('2 file changes')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + expect(screen.getByText('chat-a.ts')).toBeTruthy(); + expect(screen.getByText('chat-b.ts')).toBeTruthy(); + expect(screen.queryByText('extra.ts')).toBeNull(); + + const codeReview = screen.getByRole('button', { + name: 'Code Review (3)', + }); + fireEvent.click(codeReview); + expect(onRunCodeReview).toHaveBeenCalledTimes(1); + }); + + it('hides Findings and Code Review when the feature is off', () => { + render( + , + ); + + expect(screen.queryByRole('button', { name: /Code Review/ })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + expect(screen.queryByRole('button', { name: /Findings/ })).toBeNull(); + expect(screen.queryByText('Bug')).toBeNull(); + }); + + it('shows findings with severity and Fix when Code Review is enabled', () => { + const onFixFinding = vi.fn(); + render( + , + ); + + expect(screen.getByText('critical')).toBeTruthy(); + expect(screen.getByText('Null deref')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Fix' })); + expect(onFixFinding).toHaveBeenCalledWith(0); + expect(screen.getByRole('button', { name: 'Fix all' })).toBeTruthy(); + }); +}); diff --git a/apps/vscode/webview-ui/tests/reviewBarModel.test.ts b/apps/vscode/webview-ui/tests/reviewBarModel.test.ts new file mode 100644 index 00000000..eb012798 --- /dev/null +++ b/apps/vscode/webview-ui/tests/reviewBarModel.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; + +import { + chatFilesFromRunChanges, + gitFilesFromReview, + resolveReviewBarScope, +} from '../src/review/reviewBarModel'; +import { + composerNeedsReviewStrip, + selectLatestRunChanges, +} from '../src/review/selectLatestRunChanges'; +import { severityTone, statusLabel, statusTone } from '../src/review/reviewBarFormat'; + +describe('resolveReviewBarScope', () => { + const chat = [{ path: 'a.ts', status: 'M' }, { path: 'b.ts', status: 'A' }]; + const git = [ + ...chat, + { path: 'c.ts', status: 'M' }, + { path: 'd.ts', status: '?' }, + ]; + + it('summarizes this-chat file changes and keeps Code Review on full git', () => { + const scope = resolveReviewBarScope({ + chatFiles: chat, + gitFiles: git, + findingsCount: 0, + showCodeReview: true, + }); + expect(scope.summaryLabel).toBe('2 file changes'); + expect(scope.chatFileCount).toBe(2); + expect(scope.gitFileCount).toBe(4); + expect(scope.codeReviewButtonLabel).toBe('Code Review (4)'); + expect(scope.canExpandReview).toBe(true); + expect(scope.canRunCodeReview).toBe(true); + expect(scope.showFindingsUi).toBe(true); + expect(scope.visible).toBe(true); + }); + + it('hides Code Review findings UI when the feature is off', () => { + const scope = resolveReviewBarScope({ + chatFiles: chat, + gitFiles: git, + findingsCount: 3, + showCodeReview: false, + }); + expect(scope.showFindingsUi).toBe(false); + expect(scope.canRunCodeReview).toBe(false); + expect(scope.visible).toBe(true); + expect(scope.summaryLabel).toBe('2 file changes'); + }); + + it('stays visible for git-only dirty trees when Code Review is enabled', () => { + const scope = resolveReviewBarScope({ + chatFiles: [], + gitFiles: git, + findingsCount: 0, + showCodeReview: true, + }); + expect(scope.visible).toBe(true); + expect(scope.summaryLabel).toBe('4 git changes'); + expect(scope.canExpandReview).toBe(false); + expect(scope.codeReviewButtonLabel).toBe('Code Review (4)'); + }); + + it('hides the bar when there are no chat edits and Code Review is off', () => { + const scope = resolveReviewBarScope({ + chatFiles: [], + gitFiles: git, + findingsCount: 0, + showCodeReview: false, + }); + expect(scope.visible).toBe(false); + }); + + it('disables Code Review while a run is in progress', () => { + const scope = resolveReviewBarScope({ + chatFiles: chat, + gitFiles: git, + findingsCount: 0, + showCodeReview: true, + running: true, + }); + expect(scope.canRunCodeReview).toBe(false); + expect(scope.visible).toBe(true); + }); +}); + +describe('file mappers', () => { + it('maps chat and git DTOs', () => { + expect(chatFilesFromRunChanges([{ path: 'x.ts', status: 'M' }])).toEqual([ + { path: 'x.ts', status: 'M' }, + ]); + expect(gitFilesFromReview([{ path: 'y.ts', status: 'A' }])).toEqual([ + { path: 'y.ts', status: 'A' }, + ]); + expect(chatFilesFromRunChanges(null)).toEqual([]); + expect(gitFilesFromReview(undefined)).toEqual([]); + }); +}); + +describe('selectLatestRunChanges', () => { + it('returns the newest turn with file changes', () => { + const latest = selectLatestRunChanges([ + { fileChanges: { runId: 'r1', files: [{ path: 'old.ts', status: 'M', additions: 1, deletions: 0 }], totalAdditions: 1, totalDeletions: 0 } }, + {}, + { fileChanges: { runId: 'r2', files: [{ path: 'new.ts', status: 'A', additions: 2, deletions: 0 }], totalAdditions: 2, totalDeletions: 0 } }, + ]); + expect(latest?.runId).toBe('r2'); + }); + + it('returns null when no turns have edits', () => { + expect(selectLatestRunChanges([{}, { fileChanges: { runId: 'r', files: [], totalAdditions: 0, totalDeletions: 0 } }])).toBeNull(); + }); +}); + +describe('composerNeedsReviewStrip', () => { + it('reserves composer space for chat edits or gated git/findings', () => { + expect( + composerNeedsReviewStrip({ + chatFileCount: 1, + gitFileCount: 0, + findingsCount: 0, + codeReviewEnabled: false, + }), + ).toBe(true); + expect( + composerNeedsReviewStrip({ + chatFileCount: 0, + gitFileCount: 5, + findingsCount: 0, + codeReviewEnabled: false, + }), + ).toBe(false); + expect( + composerNeedsReviewStrip({ + chatFileCount: 0, + gitFileCount: 5, + findingsCount: 0, + codeReviewEnabled: true, + }), + ).toBe(true); + }); +}); + +describe('reviewBarFormat', () => { + it('maps status and severity tones', () => { + expect(statusLabel('M')).toBe('Edited'); + expect(statusLabel('A')).toBe('Added'); + expect(statusTone('D')).toBe('deleted'); + expect(severityTone('critical')).toBe('high'); + expect(severityTone('medium')).toBe('medium'); + expect(severityTone('info')).toBe('low'); + }); +}); diff --git a/apps/vscode/webview-ui/vitest.config.ts b/apps/vscode/webview-ui/vitest.config.ts index e90a7819..43cbb909 100644 --- a/apps/vscode/webview-ui/vitest.config.ts +++ b/apps/vscode/webview-ui/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ } }, test: { environment: 'jsdom', - include: ['tests/**/*.test.tsx'], + include: ['tests/**/*.test.{ts,tsx}'], setupFiles: ['../../../vitest.setup.ts'], testTimeout: 30000, }, diff --git a/package.json b/package.json index 1217cb77..dcdcae37 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mitii-ai-agent", "description": "Private Mitii monorepo workspace orchestrator. Product packages: @mitii/v8, @mitii/sdk, @mitii/automation, @mitii/search-kit, @mitii/mcp, @mitii/mcp-web, @mitii/host, @mitii/cli, @mitii/daemon, @mitii/acp, apps/vscode.", - "version": "2.9.59", + "version": "2.9.60", "private": true, "license": "AGPL-3.0-or-later", "author": { diff --git a/packages/automation/package.json b/packages/automation/package.json index 0e8b614b..43284d84 100644 --- a/packages/automation/package.json +++ b/packages/automation/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/automation", - "version": "2.9.59", + "version": "2.9.60", "description": "Mitii automation control plane: schedules, event ingress, claim/lease runner, webhooks (Phases 1–2).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/package.json b/packages/host/package.json index 08e1fb99..0400af14 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/host", - "version": "2.9.59", + "version": "2.9.60", "description": "Shared host kit for Mitii apps: SQLite injection, workspace indexing, repository context, durable ports (checkpoints/memory/skills/search/network), project rules, provider presets. Web retrieval via @mitii/search-kit.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 92604922..bf4e02fa 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp", - "version": "2.9.59", + "version": "2.9.60", "description": "Mitii MCP client kit: connect to MCP servers (stdio/SSE/streamable-HTTP) and register tools into V8 ToolRegistry. Does not expose Mitii as an MCP server.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/web/package.json b/packages/mcp/web/package.json index 0f1216d5..39799ef7 100644 --- a/packages/mcp/web/package.json +++ b/packages/mcp/web/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp-web", - "version": "2.9.59", + "version": "2.9.60", "description": "Mitii MCP stdio server under packages/mcp/web: web_search, fetch_url, optional memory_search via search-kit (no v8).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a4ef8be8..e8414e93 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/sdk", - "version": "2.9.59", + "version": "2.9.60", "description": "Host-neutral Mitii programmatic API over @mitii/v8 Agent Engine.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/search-kit/package.json b/packages/search-kit/package.json index cdaf495c..19e7ee46 100644 --- a/packages/search-kit/package.json +++ b/packages/search-kit/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/search-kit", - "version": "2.9.59", + "version": "2.9.60", "description": "Mitii web retrieval kit: pluggable search providers, content resolvers, and URL safety. Host-neutral; no V8 dependency.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/package.json b/packages/v8/package.json index 49af2e2d..8eb866b1 100644 --- a/packages/v8/package.json +++ b/packages/v8/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/v8", - "version": "2.9.59", + "version": "2.9.60", "description": "Host-neutral Mitii V8 agent runtime (modules + engine).", "license": "AGPL-3.0-or-later", "type": "module", From a94ce3284b8a7a25e1e9dddd4579ed163537ebd1 Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Fri, 18 Sep 2026 18:51:48 -0500 Subject: [PATCH 4/9] feat(review): hide review bar when there are no file changes during a run --- README.md | 2 +- apps/acp/package.json | 2 +- apps/cli/package.json | 2 +- apps/daemon/package.json | 2 +- apps/vscode/package.json | 2 +- .../webview-ui/src/review/reviewBarModel.ts | 5 +++-- .../tests/WorkingTreeReviewBar.test.tsx | 17 +++++++++++++++++ .../webview-ui/tests/reviewBarModel.test.ts | 11 +++++++++++ package.json | 2 +- packages/automation/package.json | 2 +- packages/host/package.json | 2 +- packages/mcp/package.json | 2 +- packages/mcp/web/package.json | 2 +- packages/sdk/package.json | 2 +- packages/search-kit/package.json | 2 +- packages/v8/package.json | 2 +- 16 files changed, 44 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 80685f9c..6bd8a173 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ License: AGPL v3 VS Code 1.124+ Node 20+ - Version 2.9.60 + Version 2.9.61 Documentation

    diff --git a/apps/acp/package.json b/apps/acp/package.json index 584a9557..395ba6dd 100644 --- a/apps/acp/package.json +++ b/apps/acp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/acp", - "version": "2.9.60", + "version": "2.9.61", "description": "Mitii ACP-lite stdio bridge (Phase 3). Decision Policy remains authority; V8 does not import ACP.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/cli/package.json b/apps/cli/package.json index c0141091..82dbba61 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/cli", - "version": "2.9.60", + "version": "2.9.61", "description": "Mitii headless CLI over @mitii/sdk. Phase 0: --origin/--autonomy/--agent for CI automation.", "license": "AGPL-3.0-or-later", "publishConfig": { diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 7a1a21fa..476e873a 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/daemon", - "version": "2.9.60", + "version": "2.9.61", "description": "Mitii automation daemon process entry (Phase 1). Long-lived schedule runner.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/vscode/package.json b/apps/vscode/package.json index dbe0b79a..8dd19084 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with repository-aware context and controlled execution", - "version": "2.9.60", + "version": "2.9.61", "publisher": "mitii", "license": "AGPL-3.0-or-later", "icon": "media/mitii-logo.png", diff --git a/apps/vscode/webview-ui/src/review/reviewBarModel.ts b/apps/vscode/webview-ui/src/review/reviewBarModel.ts index 230b7e1b..c3ef41b4 100644 --- a/apps/vscode/webview-ui/src/review/reviewBarModel.ts +++ b/apps/vscode/webview-ui/src/review/reviewBarModel.ts @@ -53,11 +53,12 @@ export function resolveReviewBarScope( const showFindingsUi = input.showCodeReview === true; const running = input.running === true; + // Hide when there is nothing to review — do not show "0 file changes" + // just because a run is in progress. const visible = chatFileCount > 0 || (showFindingsUi && input.findingsCount > 0) || - (showFindingsUi && gitFileCount > 0) || - running; + (showFindingsUi && gitFileCount > 0); let summaryLabel: string; if (chatFileCount > 0) { diff --git a/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx b/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx index b80ba6a7..c5379eba 100644 --- a/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx +++ b/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx @@ -53,6 +53,23 @@ describe('WorkingTreeReviewBar', () => { expect(onRunCodeReview).toHaveBeenCalledTimes(1); }); + it('renders nothing when there are 0 file changes', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByLabelText('Working tree review')).toBeNull(); + }); + it('hides Findings and Code Review when the feature is off', () => { render( { expect(scope.visible).toBe(false); }); + it('hides the bar for 0 file changes even while a run is in progress', () => { + const scope = resolveReviewBarScope({ + chatFiles: [], + gitFiles: [], + findingsCount: 0, + showCodeReview: false, + running: true, + }); + expect(scope.visible).toBe(false); + }); + it('disables Code Review while a run is in progress', () => { const scope = resolveReviewBarScope({ chatFiles: chat, diff --git a/package.json b/package.json index dcdcae37..a26134fe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mitii-ai-agent", "description": "Private Mitii monorepo workspace orchestrator. Product packages: @mitii/v8, @mitii/sdk, @mitii/automation, @mitii/search-kit, @mitii/mcp, @mitii/mcp-web, @mitii/host, @mitii/cli, @mitii/daemon, @mitii/acp, apps/vscode.", - "version": "2.9.60", + "version": "2.9.61", "private": true, "license": "AGPL-3.0-or-later", "author": { diff --git a/packages/automation/package.json b/packages/automation/package.json index 43284d84..e20d2873 100644 --- a/packages/automation/package.json +++ b/packages/automation/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/automation", - "version": "2.9.60", + "version": "2.9.61", "description": "Mitii automation control plane: schedules, event ingress, claim/lease runner, webhooks (Phases 1–2).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/package.json b/packages/host/package.json index 0400af14..357d1363 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/host", - "version": "2.9.60", + "version": "2.9.61", "description": "Shared host kit for Mitii apps: SQLite injection, workspace indexing, repository context, durable ports (checkpoints/memory/skills/search/network), project rules, provider presets. Web retrieval via @mitii/search-kit.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index bf4e02fa..1aeb1a8f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp", - "version": "2.9.60", + "version": "2.9.61", "description": "Mitii MCP client kit: connect to MCP servers (stdio/SSE/streamable-HTTP) and register tools into V8 ToolRegistry. Does not expose Mitii as an MCP server.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/web/package.json b/packages/mcp/web/package.json index 39799ef7..7301d3b8 100644 --- a/packages/mcp/web/package.json +++ b/packages/mcp/web/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp-web", - "version": "2.9.60", + "version": "2.9.61", "description": "Mitii MCP stdio server under packages/mcp/web: web_search, fetch_url, optional memory_search via search-kit (no v8).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e8414e93..61e9c73b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/sdk", - "version": "2.9.60", + "version": "2.9.61", "description": "Host-neutral Mitii programmatic API over @mitii/v8 Agent Engine.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/search-kit/package.json b/packages/search-kit/package.json index 19e7ee46..91f99570 100644 --- a/packages/search-kit/package.json +++ b/packages/search-kit/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/search-kit", - "version": "2.9.60", + "version": "2.9.61", "description": "Mitii web retrieval kit: pluggable search providers, content resolvers, and URL safety. Host-neutral; no V8 dependency.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/package.json b/packages/v8/package.json index 8eb866b1..5c2acc24 100644 --- a/packages/v8/package.json +++ b/packages/v8/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/v8", - "version": "2.9.60", + "version": "2.9.61", "description": "Host-neutral Mitii V8 agent runtime (modules + engine).", "license": "AGPL-3.0-or-later", "type": "module", From bddb8d8b327bce2e2712b25b2aa51c7ce8f57a0e Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Fri, 18 Sep 2026 18:57:39 -0500 Subject: [PATCH 5/9] feat: enhance embedding synchronization and error handling in SQLite cache --- README.md | 2 +- apps/acp/package.json | 2 +- apps/cli/package.json | 2 +- apps/daemon/package.json | 2 +- apps/vscode/package.json | 2 +- apps/vscode/src/extension.ts | 6 + apps/vscode/src/hostAsk.ts | 7 +- apps/vscode/src/nativeSqlite.ts | 54 ++++++-- apps/vscode/src/sidebar.ts | 23 +++- package.json | 2 +- packages/automation/package.json | 2 +- packages/host/package.json | 2 +- .../adapters/OnnxRuntimeSessionFactory.ts | 47 ++++--- .../tests/OnnxRuntimeSessionFactory.spec.ts | 46 +++++++ .../host/src/indexing/fingerprintSnapshot.ts | 5 +- .../host/src/indexing/fullWorkspaceIndex.ts | 9 ++ packages/host/src/indexing/semanticIndex.ts | 2 + packages/host/src/workspaceKit.spec.ts | 16 +++ packages/mcp/package.json | 2 +- packages/mcp/web/package.json | 2 +- packages/sdk/package.json | 2 +- packages/search-kit/package.json | 2 +- packages/v8/package.json | 2 +- .../embedding/SqliteEmbeddingVectorCache.ts | 87 +++++++++---- .../internal/embedding/constants.ts | 4 +- .../tests/SqliteEmbeddingVectorCache.spec.ts | 27 ++++ .../WorkspaceIndexingRootFinalizer.ts | 121 +++++++++++------- packages/v8/vitest.config.ts | 1 + 28 files changed, 365 insertions(+), 116 deletions(-) create mode 100644 packages/v8/src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts diff --git a/README.md b/README.md index 6bd8a173..fe01f3d9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ License: AGPL v3 VS Code 1.124+ Node 20+ - Version 2.9.61 + Version 2.9.62 Documentation

    diff --git a/apps/acp/package.json b/apps/acp/package.json index 395ba6dd..5e5d68a8 100644 --- a/apps/acp/package.json +++ b/apps/acp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/acp", - "version": "2.9.61", + "version": "2.9.62", "description": "Mitii ACP-lite stdio bridge (Phase 3). Decision Policy remains authority; V8 does not import ACP.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/cli/package.json b/apps/cli/package.json index 82dbba61..bb17207d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/cli", - "version": "2.9.61", + "version": "2.9.62", "description": "Mitii headless CLI over @mitii/sdk. Phase 0: --origin/--autonomy/--agent for CI automation.", "license": "AGPL-3.0-or-later", "publishConfig": { diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 476e873a..5a16f32f 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/daemon", - "version": "2.9.61", + "version": "2.9.62", "description": "Mitii automation daemon process entry (Phase 1). Long-lived schedule runner.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 8dd19084..cffb9dd4 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with repository-aware context and controlled execution", - "version": "2.9.61", + "version": "2.9.62", "publisher": "mitii", "license": "AGPL-3.0-or-later", "icon": "media/mitii-logo.png", diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 2640b943..06dbdb53 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -9,6 +9,7 @@ import { unwrapRecipeAnswer, type MitiiWritingRecipeId, IndexLockedError, + resolveMaximumIndexFiles, } from '@mitii/host'; import { isSecurityConcern, WorkspaceIgnorePolicy } from '@mitii/v8'; @@ -378,6 +379,11 @@ export function activate(context: ExtensionContext): void { const snapshot = await buildWorkspaceSnapshot({ workspaceRoot: root, workspaceId, + maxFiles: resolveMaximumIndexFiles( + vscode.workspace + .getConfiguration('mitii') + .get('workspace.maximumIndexFiles'), + ), }); fileCount = snapshot.fileCount; truncated = snapshot.truncated; diff --git a/apps/vscode/src/hostAsk.ts b/apps/vscode/src/hostAsk.ts index 50b9aa93..17207211 100644 --- a/apps/vscode/src/hostAsk.ts +++ b/apps/vscode/src/hostAsk.ts @@ -10,7 +10,7 @@ import { type RunEvent, type TaskList, } from '@mitii/sdk'; -import { loadUserSafetyRules } from '@mitii/host'; +import { loadUserSafetyRules, resolveMaximumIndexFiles } from '@mitii/host'; import type * as vscode from 'vscode'; import { formatDiagnosticsPromptBlock } from './context/diagnosticsContext.js'; @@ -744,6 +744,11 @@ async function autoPublishFullOrSnapshot(options: { const snap = await buildWorkspaceSnapshot({ workspaceRoot: options.workspaceRoot, workspaceId: options.workspaceId, + maxFiles: resolveMaximumIndexFiles( + options.vs.workspace + .getConfiguration('mitii') + .get('workspace.maximumIndexFiles'), + ), }); await options.client.publishRepositoryState(snap.candidate); options.channel.appendLine( diff --git a/apps/vscode/src/nativeSqlite.ts b/apps/vscode/src/nativeSqlite.ts index 88a73569..0055b01d 100644 --- a/apps/vscode/src/nativeSqlite.ts +++ b/apps/vscode/src/nativeSqlite.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import Database from 'better-sqlite3'; @@ -15,10 +15,27 @@ export function openSqliteDatabase( options: SqliteOptions = {}, ): SqliteDatabase { const nativeBinding = resolveNativeSqliteBinding(); - return new Database(filename, { - ...options, - ...(nativeBinding ? { nativeBinding } : {}), - }); + try { + return new Database(filename, { + ...options, + ...(nativeBinding ? { nativeBinding } : {}), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + // Surface Electron ABI / missing binding as an actionable Mitii error so the + // host does not silently fall back to a fingerprint pin without guidance. + if ( + /NODE_MODULE_VERSION|was compiled against a different|Cannot find module|better_sqlite3/i.test( + detail, + ) + ) { + throw new Error( + `Mitii SQLite failed to open (${detail}). For VS Code/Cursor Extension Host run \`pnpm run rebuild:native\` (or \`MITII_EDITOR=cursor pnpm run rebuild:native\`) so ${NATIVE_BINDING_FILE} matches Electron, then reload the window.`, + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } } export function resolveNativeSqliteBinding(): string | undefined { @@ -31,10 +48,16 @@ export function resolveNativeSqliteBinding(): string | undefined { return undefined; } + const here = __dirname; const candidates = [ - join(__dirname, 'native', NATIVE_BINDING_FILE), - join(__dirname, '..', 'dist', 'native', NATIVE_BINDING_FILE), - join(__dirname, '..', 'native', NATIVE_BINDING_FILE), + join(here, 'native', NATIVE_BINDING_FILE), + join(here, '..', 'dist', 'native', NATIVE_BINDING_FILE), + join(here, '..', 'native', NATIVE_BINDING_FILE), + // Extension root when compiled to dist/*.js + join(here, '..', '..', 'dist', 'native', NATIVE_BINDING_FILE), + join(here, '..', '..', 'native', NATIVE_BINDING_FILE), + // Walk up from nested dist folders + ...ancestorNativeCandidates(here), ]; const bundled = candidates.find((candidate) => existsSync(candidate)); if (bundled) { @@ -42,6 +65,19 @@ export function resolveNativeSqliteBinding(): string | undefined { } throw new Error( - `Mitii SQLite native binding is missing. Checked: ${candidates.join(', ')}. Run \`pnpm run build:all\` (or \`pnpm run rebuild:native\`) so ${NATIVE_BINDING_FILE} is staged into dist/native for the Electron extension host.`, + `Mitii SQLite native binding is missing. Checked: ${[...new Set(candidates)].join(', ')}. Run \`pnpm run build:all\` (or \`pnpm run rebuild:native\` / \`MITII_EDITOR=cursor pnpm run rebuild:native\`) so ${NATIVE_BINDING_FILE} is staged into dist/native for the Electron extension host, then reload the window.`, ); } + +function ancestorNativeCandidates(startDir: string): string[] { + const out: string[] = []; + let dir = startDir; + for (let i = 0; i < 6; i += 1) { + out.push(join(dir, 'native', NATIVE_BINDING_FILE)); + out.push(join(dir, 'dist', 'native', NATIVE_BINDING_FILE)); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return out; +} diff --git a/apps/vscode/src/sidebar.ts b/apps/vscode/src/sidebar.ts index d852a3d9..17297235 100644 --- a/apps/vscode/src/sidebar.ts +++ b/apps/vscode/src/sidebar.ts @@ -3456,6 +3456,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { let truncated = false; let indexMode: IndexStatusSnapshot['indexMode'] = 'full'; let fallbackReason: string | undefined; + let embeddingIssue: string | undefined; let published; try { const full = await runFullWorkspaceIndex({ @@ -3518,6 +3519,11 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { } fileCount = full.fileCount; truncated = full.truncated; + if (full.vectorIndex.status !== 'ready') { + embeddingIssue = + full.vectorIndex.reason ?? + `vector index ${full.vectorIndex.status}`; + } published = await client.publishRepositoryStateFromIndexing(full.indexing, { catalogRevisionByRoot: full.catalogRevisionByRoot, graphRevisionByRoot: full.graphRevisionByRoot, @@ -3525,7 +3531,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { }); if (full.status === 'unchanged') { this.channel.appendLine( - `[index] unchanged (up to date) at ${full.databasePath}; vector=${full.vectorIndex.status}${full.vectorIndex.profileId ? ` profile=${full.vectorIndex.profileId}` : ''}`, + `[index] unchanged (up to date) at ${full.databasePath}; vector=${full.vectorIndex.status}${full.vectorIndex.profileId ? ` profile=${full.vectorIndex.profileId}` : ''}${full.vectorIndex.reason ? ` reason=${full.vectorIndex.reason}` : ''}`, ); } else { this.channel.appendLine( @@ -3570,6 +3576,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { const snapshot = await buildWorkspaceSnapshot({ workspaceRoot: root, workspaceId: this.getWorkspaceId(), + maxFiles: maximumIndexFiles, }); fileCount = snapshot.fileCount; truncated = snapshot.truncated; @@ -3590,6 +3597,12 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { )}\n`, ); const descriptorStatus = indexStatusFromDescriptor(published.descriptor); + const baseMessage = + indexMode === 'host_snapshot' + ? `Indexed ${fileCount} files (host snapshot fallback: ${fallbackReason ?? 'full index unavailable'})` + : truncated + ? `Indexed ${fileCount} files (truncated)` + : `Indexed ${fileCount} files`; this.lastIndex = { fileCount, truncated, @@ -3597,11 +3610,9 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { ...descriptorStatus, indexMode, message: - indexMode === 'host_snapshot' - ? `Indexed ${fileCount} files (host snapshot fallback: ${fallbackReason ?? 'full index unavailable'})` - : truncated - ? `Indexed ${fileCount} files (truncated)` - : `Indexed ${fileCount} files`, + embeddingIssue && indexMode !== 'host_snapshot' + ? `${baseMessage} · embeddings unavailable: ${embeddingIssue}` + : baseMessage, }; } else { this.lastIndex = { diff --git a/package.json b/package.json index a26134fe..1892bff6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mitii-ai-agent", "description": "Private Mitii monorepo workspace orchestrator. Product packages: @mitii/v8, @mitii/sdk, @mitii/automation, @mitii/search-kit, @mitii/mcp, @mitii/mcp-web, @mitii/host, @mitii/cli, @mitii/daemon, @mitii/acp, apps/vscode.", - "version": "2.9.61", + "version": "2.9.62", "private": true, "license": "AGPL-3.0-or-later", "author": { diff --git a/packages/automation/package.json b/packages/automation/package.json index e20d2873..7724b751 100644 --- a/packages/automation/package.json +++ b/packages/automation/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/automation", - "version": "2.9.61", + "version": "2.9.62", "description": "Mitii automation control plane: schedules, event ingress, claim/lease runner, webhooks (Phases 1–2).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/package.json b/packages/host/package.json index 357d1363..cc7d2d9d 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/host", - "version": "2.9.61", + "version": "2.9.62", "description": "Shared host kit for Mitii apps: SQLite injection, workspace indexing, repository context, durable ports (checkpoints/memory/skills/search/network), project rules, provider presets. Web retrieval via @mitii/search-kit.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts b/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts index c51e5977..c3eb77ad 100644 --- a/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts +++ b/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts @@ -150,33 +150,46 @@ export class HostOnnxRuntimeSessionFactory implements OnnxRuntimeSessionFactory input.abortSignal?.throwIfAborted(); const preferred = resolvePreferredOnnxKind(input.preferredKind); const tryNative = preferred !== 'wasm'; + const nativeErrors: string[] = []; if (tryNative) { const native = this.loadModule(ONNX_RUNTIME_NODE_PACKAGE); if (native?.InferenceSession && native.Tensor) { - const raw = await native.InferenceSession.create(input.modelPath, { - executionProviders: ['cpu'], - }); - return { - session: wrapSession(raw), - resolution: describeOnnxExecutionProvider({ - kind: 'native', - packageId: ONNX_RUNTIME_NODE_PACKAGE, - }), - createInt64Tensor: (values, dims) => - new native.Tensor!( - 'int64', - BigInt64Array.from(values.map((value) => BigInt(value))), - [...dims], - ), - }; + try { + const raw = await native.InferenceSession.create(input.modelPath, { + executionProviders: ['cpu'], + }); + return { + session: wrapSession(raw), + resolution: describeOnnxExecutionProvider({ + kind: 'native', + packageId: ONNX_RUNTIME_NODE_PACKAGE, + }), + createInt64Tensor: (values, dims) => + new native.Tensor!( + 'int64', + BigInt64Array.from(values.map((value) => BigInt(value))), + [...dims], + ), + }; + } catch (error) { + // ABI / native create failures are common in Extension Host — fall + // through to WASM so bundled embeddings still work. + nativeErrors.push( + error instanceof Error ? error.message : String(error), + ); + } } } const wasm = this.loadModule(ONNX_RUNTIME_WEB_PACKAGE); if (!wasm?.InferenceSession || !wasm.Tensor) { + const nativeHint = + nativeErrors.length > 0 + ? ` Native attempt failed: ${nativeErrors.join('; ')}.` + : ''; throw new Error( - 'ONNX Runtime is unavailable. Install optional native modules onnxruntime-node (preferred) or onnxruntime-web (WASM fallback) for this OS/CPU.', + `ONNX Runtime is unavailable. Install optional native modules onnxruntime-node (preferred) or onnxruntime-web (WASM fallback) for this OS/CPU.${nativeHint}`, ); } configureWasmPaths(wasm, ONNX_RUNTIME_WEB_PACKAGE); diff --git a/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts b/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts index 2348619d..700b294a 100644 --- a/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts +++ b/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts @@ -17,6 +17,52 @@ describe('HostOnnxRuntimeSessionFactory', () => { }), ).rejects.toThrow(/onnx|no such file|not exist|FILE|PROTOBUF|unavailable/i); }); + + it('falls back to WASM when native InferenceSession.create throws', async () => { + const modelPath = join(tmpdir(), 'mitii-fallback-minilm.onnx'); + let wasmCreateCalls = 0; + const factory = new HostOnnxRuntimeSessionFactory((packageId) => { + if (packageId === 'onnxruntime-node') { + return { + Tensor: class { + constructor() {} + } as never, + InferenceSession: { + create: async () => { + throw new Error('NODE_MODULE_VERSION mismatch'); + }, + }, + }; + } + if (packageId === 'onnxruntime-web') { + return { + Tensor: class { + constructor() {} + } as never, + InferenceSession: { + create: async () => { + wasmCreateCalls += 1; + return { + inputNames: ['input_ids'], + outputNames: ['last_hidden_state'], + run: async () => ({}), + }; + }, + }, + env: { wasm: {} }, + }; + } + return undefined; + }); + + // Force native-first so the create() throw path is exercised, then WASM. + const created = await factory.create({ + modelPath, + preferredKind: 'native', + }); + expect(wasmCreateCalls).toBe(1); + expect(created.resolution.kind).toBe('wasm'); + }); }); describe('resolvePreferredOnnxKind', () => { diff --git a/packages/host/src/indexing/fingerprintSnapshot.ts b/packages/host/src/indexing/fingerprintSnapshot.ts index 0c64d523..8c55e244 100644 --- a/packages/host/src/indexing/fingerprintSnapshot.ts +++ b/packages/host/src/indexing/fingerprintSnapshot.ts @@ -10,6 +10,7 @@ import { } from '@mitii/v8'; import { WORKSPACE_WALK_SKIP_DIR_NAMES, shouldSkipWorkspaceWalkFile } from '../internal/workspaceWalk.js'; +import { DEFAULT_MAXIMUM_INDEX_FILES } from './indexLimits.js'; import { readIndexRuntimeMetadata } from './semanticIndex.js'; export interface WorkspaceSnapshotOptions { @@ -55,7 +56,9 @@ export function fingerprintWorkspaceIndexSnapshot( export async function buildWorkspaceSnapshot( options: WorkspaceSnapshotOptions, ): Promise { - const maxFiles = options.maxFiles ?? 2_000; + // Match the full-index default so a temporary host_snapshot fallback is not + // stuck at a tiny 2k pin while embeddings/code indexes are unavailable. + const maxFiles = options.maxFiles ?? DEFAULT_MAXIMUM_INDEX_FILES; const entries: string[] = []; const relativePaths: string[] = []; let truncated = false; diff --git a/packages/host/src/indexing/fullWorkspaceIndex.ts b/packages/host/src/indexing/fullWorkspaceIndex.ts index 471dcf48..bd70d7fc 100644 --- a/packages/host/src/indexing/fullWorkspaceIndex.ts +++ b/packages/host/src/indexing/fullWorkspaceIndex.ts @@ -355,6 +355,15 @@ async function runFullWorkspaceIndexOnce(options: { resolvedSemantic.status === 'ready' && vectorIndex.status === 'ready' ? resolvedSemantic.provider.profile.id : 'unavailable', + ...(vectorIndex.status !== 'ready' + ? { + lastEmbeddingError: + vectorIndex.reason ?? + (resolvedSemantic.status === 'unavailable' + ? resolvedSemantic.reason + : 'Embedding synchronization did not complete.'), + } + : {}), snapshotFingerprint, fileCount: snapshot.statistics.files, truncated: snapshot.status !== 'complete', diff --git a/packages/host/src/indexing/semanticIndex.ts b/packages/host/src/indexing/semanticIndex.ts index 5b3ce0fa..096cafb6 100644 --- a/packages/host/src/indexing/semanticIndex.ts +++ b/packages/host/src/indexing/semanticIndex.ts @@ -111,6 +111,8 @@ export interface IndexRuntimeMetadata { lanceDbPath: string; embeddingProfile?: EmbeddingProfile; vectorRuntimeKey?: string; + /** Last probe/sync failure reason when vectors are unavailable or degraded. */ + lastEmbeddingError?: string; snapshotFingerprint?: string; fileCount?: number; truncated?: boolean; diff --git a/packages/host/src/workspaceKit.spec.ts b/packages/host/src/workspaceKit.spec.ts index d0bd9d78..b4b89520 100644 --- a/packages/host/src/workspaceKit.spec.ts +++ b/packages/host/src/workspaceKit.spec.ts @@ -220,4 +220,20 @@ describe('host durable workspace kit', () => { ); expect(snapshot.candidate.roots[0]?.rootId).toBe(root.split(/[\\/]/).pop()); }); + + it('honors an explicit fingerprint maxFiles override', async () => { + await writeFile(join(root, 'a.ts'), 'a', 'utf8'); + await writeFile(join(root, 'b.ts'), 'b', 'utf8'); + await writeFile(join(root, 'c.ts'), 'c', 'utf8'); + const snapshot = await buildWorkspaceSnapshot({ + workspaceRoot: root, + workspaceId: 'ws_1', + maxFiles: 2, + }); + expect(snapshot.fileCount).toBe(2); + expect(snapshot.truncated).toBe(true); + expect(snapshot.candidate.reasons?.[0]?.message).toContain( + 'truncated after 2 files', + ); + }); }); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1aeb1a8f..1f7fce22 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp", - "version": "2.9.61", + "version": "2.9.62", "description": "Mitii MCP client kit: connect to MCP servers (stdio/SSE/streamable-HTTP) and register tools into V8 ToolRegistry. Does not expose Mitii as an MCP server.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/web/package.json b/packages/mcp/web/package.json index 7301d3b8..4ff6af12 100644 --- a/packages/mcp/web/package.json +++ b/packages/mcp/web/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp-web", - "version": "2.9.61", + "version": "2.9.62", "description": "Mitii MCP stdio server under packages/mcp/web: web_search, fetch_url, optional memory_search via search-kit (no v8).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 61e9c73b..ca750d9d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/sdk", - "version": "2.9.61", + "version": "2.9.62", "description": "Host-neutral Mitii programmatic API over @mitii/v8 Agent Engine.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/search-kit/package.json b/packages/search-kit/package.json index 91f99570..dc9ab75f 100644 --- a/packages/search-kit/package.json +++ b/packages/search-kit/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/search-kit", - "version": "2.9.61", + "version": "2.9.62", "description": "Mitii web retrieval kit: pluggable search providers, content resolvers, and URL safety. Host-neutral; no V8 dependency.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/package.json b/packages/v8/package.json index 5c2acc24..24366e8c 100644 --- a/packages/v8/package.json +++ b/packages/v8/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/v8", - "version": "2.9.61", + "version": "2.9.62", "description": "Host-neutral Mitii V8 agent runtime (modules + engine).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts b/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts index 537e1f46..b5ac779c 100644 --- a/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts +++ b/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts @@ -21,10 +21,16 @@ interface CacheRow { dimensions: number; } +/** + * SQLite-backed embedding cache. Cache failures must never abort embedding + * synchronization — LanceDB is the source of truth for vectors. + */ export class SqliteEmbeddingVectorCache implements EmbeddingVectorCachePort { private ready = false; + private disabled = false; + private readonly memory = new Map(); constructor( private readonly database: SqliteDatabasePort, @@ -34,11 +40,21 @@ export class SqliteEmbeddingVectorCache profileId: string, contentHash: string, ): readonly number[] | undefined { - this.ensureSchema(); + const memoryKey = cacheKey(profileId, contentHash); + const mem = this.memory.get(memoryKey); + if (mem) return mem; - const row = this.database - .prepare( - ` + if (this.disabled) { + return undefined; + } + + try { + this.ensureSchema(); + if (this.disabled) return undefined; + + const row = this.database + .prepare( + ` SELECT vector_json AS vectorJson, dimensions AS dimensions @@ -47,14 +63,13 @@ export class SqliteEmbeddingVectorCache AND content_hash = ? LIMIT 1 `, - ) - .get(profileId, contentHash) as CacheRow | undefined; + ) + .get(profileId, contentHash) as CacheRow | undefined; - if (!row) { - return undefined; - } + if (!row) { + return undefined; + } - try { const parsed = JSON.parse(row.vectorJson) as unknown; if ( !Array.isArray(parsed) || @@ -66,6 +81,7 @@ export class SqliteEmbeddingVectorCache return parsed; } catch { + this.disabled = true; return undefined; } } @@ -75,11 +91,20 @@ export class SqliteEmbeddingVectorCache contentHash: string, vector: readonly number[], ): void { - this.ensureSchema(); + const memoryKey = cacheKey(profileId, contentHash); + this.memory.set(memoryKey, vector); + + if (this.disabled) { + return; + } + + try { + this.ensureSchema(); + if (this.disabled) return; - this.database - .prepare( - ` + this.database + .prepare( + ` INSERT INTO embedding_vector_cache ( profile_id, content_hash, @@ -94,22 +119,34 @@ export class SqliteEmbeddingVectorCache vector_json = excluded.vector_json, updated_at = excluded.updated_at `, - ) - .run( - profileId, - contentHash, - vector.length, - JSON.stringify(vector), - Date.now(), - ); + ) + .run( + profileId, + contentHash, + vector.length, + JSON.stringify(vector), + Date.now(), + ); + } catch { + // Keep process-local memory cache; continue embedding without SQLite. + this.disabled = true; + } } private ensureSchema(): void { - if (this.ready) { + if (this.ready || this.disabled) { return; } - this.database.exec(CREATE_CACHE_TABLE); - this.ready = true; + try { + this.database.exec(CREATE_CACHE_TABLE); + this.ready = true; + } catch { + this.disabled = true; + } } } + +function cacheKey(profileId: string, contentHash: string): string { + return `${profileId}\0${contentHash}`; +} diff --git a/packages/v8/src/modules/repository-state/internal/embedding/constants.ts b/packages/v8/src/modules/repository-state/internal/embedding/constants.ts index 0a06c583..1a4f8bd7 100644 --- a/packages/v8/src/modules/repository-state/internal/embedding/constants.ts +++ b/packages/v8/src/modules/repository-state/internal/embedding/constants.ts @@ -41,8 +41,10 @@ export const EMBEDDING_DEFAULTS = { MAXIMUM_ALLOWED_CHANGES_PER_BATCH: 10_000, + // High enough that a first full index of ~30k files can finish embeddings + // in one run instead of staying chronically "degraded"/partial. MAXIMUM_BATCHES_PER_RUN: - 100, + 2_000, MAXIMUM_ALLOWED_BATCHES_PER_RUN: 10_000, diff --git a/packages/v8/src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts b/packages/v8/src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts new file mode 100644 index 00000000..f95e174c --- /dev/null +++ b/packages/v8/src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { SqliteEmbeddingVectorCache } from "../SqliteEmbeddingVectorCache"; + +describe("SqliteEmbeddingVectorCache", () => { + it("falls back to memory when SQLite exec/get/set fails", () => { + const cache = new SqliteEmbeddingVectorCache({ + exec: () => { + throw new Error("SQLITE_ERROR: no such table"); + }, + prepare: () => ({ + get: () => { + throw new Error("SQLITE_ERROR"); + }, + run: () => { + throw new Error("SQLITE_ERROR"); + }, + }), + } as never); + + expect(cache.get("profile", "hash")).toBeUndefined(); + expect(() => + cache.set("profile", "hash", [0.1, 0.2, 0.3]), + ).not.toThrow(); + expect(cache.get("profile", "hash")).toEqual([0.1, 0.2, 0.3]); + }); +}); diff --git a/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts b/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts index ba651167..22c0067c 100644 --- a/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts +++ b/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts @@ -318,54 +318,89 @@ export class WorkspaceIndexingRootFinalizer { input.request .synchronizeEmbeddings ) { + // Keep synchronizing until the text-index revision is fully consumed. + // A single partial pass used to leave vectorIndex chronically degraded. + const maxEmbeddingPasses = 64; try { - const embedding = - await this.dependencies - .embedding - .synchronize({ - workspace: - input.request - .workspace, - rootId, - updatedAt: - input.request - .indexedAt, - ...(input.request - .abortSignal - ? { - abortSignal: - input - .request - .abortSignal, - } - : {}), - }); + for ( + let pass = 0; + pass < maxEmbeddingPasses; + pass += 1 + ) { + if ( + input.request.abortSignal + ?.aborted + ) { + embeddingStatus = + "cancelled"; + break; + } - embeddingStatus = - embedding.status; - embeddingProfileId = - embedding - .profile.id; - initialTextRevision = - embedding - .initialTextRevision; - finalTextRevision = - embedding - .finalTextRevision; - latestTextRevision = - embedding - .latestTextRevision; - embeddedChunks = - embedding - .statistics - .chunksEmbedded; - vectorsDeleted = - embedding - .statistics - .vectorsDeleted; + const embedding = + await this.dependencies + .embedding + .synchronize({ + workspace: + input.request + .workspace, + rootId, + updatedAt: + input.request + .indexedAt, + ...(input.request + .abortSignal + ? { + abortSignal: + input + .request + .abortSignal, + } + : {}), + }); + + embeddingStatus = + embedding.status; + embeddingProfileId = + embedding + .profile.id; + if ( + initialTextRevision === + undefined + ) { + initialTextRevision = + embedding + .initialTextRevision; + } + finalTextRevision = + embedding + .finalTextRevision; + latestTextRevision = + embedding + .latestTextRevision; + embeddedChunks += + embedding + .statistics + .chunksEmbedded; + vectorsDeleted += + embedding + .statistics + .vectorsDeleted; + + if ( + embedding.status !== + "partial" + ) { + break; + } + } } catch ( error ) { + // Mark partial so publish/UI show degraded vectors instead of + // "never configured" (undefined embeddingStatus → unavailable). + embeddingStatus = + embeddingStatus ?? + "partial"; warnings.push( this.warning( rootId, diff --git a/packages/v8/vitest.config.ts b/packages/v8/vitest.config.ts index 9dafabaf..3e277ab2 100644 --- a/packages/v8/vitest.config.ts +++ b/packages/v8/vitest.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ 'src/modules/repository-state/internal/chunking/tests/CodeCollapse.spec.ts', 'src/modules/repository-state/internal/text-index/tests/TrigramFts.spec.ts', 'src/modules/repository-state/internal/embedding/tests/EmbeddingVectorCache.spec.ts', + 'src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts', 'src/modules/repository-state/pipeline/ws-indexing-pipeline/tests/FileProcessorStatFreshness.spec.ts', 'src/modules/model-gateway/tests/OpenAiCompatibleRetry.spec.ts', 'src/modules/model-gateway/tests/AnthropicLlmPort.spec.ts', From 2ab3834b689e18e0a4d7853289cfe538dc08adc0 Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Fri, 18 Sep 2026 19:05:10 -0500 Subject: [PATCH 6/9] feat: update rationale messages for clarity and improved user guidance --- README.md | 2 +- apps/acp/package.json | 2 +- apps/cli/package.json | 2 +- apps/daemon/package.json | 2 +- apps/vscode/package.json | 2 +- .../src/components/ApprovalCards.tsx | 6 +++--- package.json | 2 +- packages/automation/package.json | 2 +- packages/host/package.json | 2 +- packages/mcp/package.json | 2 +- packages/mcp/web/package.json | 2 +- packages/sdk/package.json | 2 +- packages/search-kit/package.json | 2 +- packages/v8/package.json | 2 +- .../actions/buildStallContinueRationale.ts | 18 +++++++++--------- .../tests/buildStallContinueRationale.spec.ts | 12 ++++++------ .../tests/tryOfferBudgetWallContinue.spec.ts | 2 +- .../tests/AgentEngineStall.spec.ts | 4 ++-- 18 files changed, 34 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index fe01f3d9..b0f0db4a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ License: AGPL v3 VS Code 1.124+ Node 20+ - Version 2.9.62 + Version 2.9.63 Documentation

    diff --git a/apps/acp/package.json b/apps/acp/package.json index 5e5d68a8..64cd2302 100644 --- a/apps/acp/package.json +++ b/apps/acp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/acp", - "version": "2.9.62", + "version": "2.9.63", "description": "Mitii ACP-lite stdio bridge (Phase 3). Decision Policy remains authority; V8 does not import ACP.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/cli/package.json b/apps/cli/package.json index bb17207d..b5d06665 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/cli", - "version": "2.9.62", + "version": "2.9.63", "description": "Mitii headless CLI over @mitii/sdk. Phase 0: --origin/--autonomy/--agent for CI automation.", "license": "AGPL-3.0-or-later", "publishConfig": { diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 5a16f32f..d01724a4 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/daemon", - "version": "2.9.62", + "version": "2.9.63", "description": "Mitii automation daemon process entry (Phase 1). Long-lived schedule runner.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/vscode/package.json b/apps/vscode/package.json index cffb9dd4..f7f2556c 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with repository-aware context and controlled execution", - "version": "2.9.62", + "version": "2.9.63", "publisher": "mitii", "license": "AGPL-3.0-or-later", "icon": "media/mitii-logo.png", diff --git a/apps/vscode/webview-ui/src/components/ApprovalCards.tsx b/apps/vscode/webview-ui/src/components/ApprovalCards.tsx index 2123ecc6..7e10b0c8 100644 --- a/apps/vscode/webview-ui/src/components/ApprovalCards.tsx +++ b/apps/vscode/webview-ui/src/components/ApprovalCards.tsx @@ -144,7 +144,7 @@ export function ApprovalCards({ (isPlan ? 'Review the plan, then approve to continue or reject to stop.' : isContinue - ? 'This run hit a safety limit. Continue with a fresh approach, or stop with current progress.' + ? 'We need a bit more research before we can start implementing. Mind if we keep looking?' : isGrantExpansion ? 'Expand workspace access for additional paths, or keep the current grant.' : 'I need a bit more detail before continuing.'); @@ -154,7 +154,7 @@ export function ApprovalCards({ : isPlan ? 'Plan approval required' : isContinue - ? 'Continue required' + ? 'A bit more research needed' : isGrantExpansion ? 'Workspace access expansion' : 'Approval required'; @@ -333,7 +333,7 @@ export function ApprovalCards({ rows={2} value={clarifyText} onChange={(e) => onClarifyChange(e.target.value)} - placeholder="Optional: narrow the task or point to files…" + placeholder="Optional: any tips on where to look?" />