Skip to content

feat: Tauri 2 desktop GUI (M2) — kernel as stdio sidecar - #16

Merged
hutusi merged 19 commits into
mainfrom
feat/tauri-gui
Jul 13, 2026
Merged

hutusi merged 19 commits into
mainfrom
feat/tauri-gui

Conversation

@hutusi

@hutusi hutusi commented Jul 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

M2 as designed in docs/DESIGN.md: a Tauri 2 desktop app at apps/gui that spawns the existing kernel as a stdio sidecar (minerva acp --allow-unconfigured) and reuses @minerva/client end to end. Full TUI parity — streaming markdown, thoughts, tool calls with diffs, permission prompts (y/a/n hotkey parity), first-run config, session browser/resume, modes, profiles, compact, /skills — plus GUI-native extras: multi-project tabs over one kernel, a side-by-side diff viewer, and native notifications when a long turn finishes unfocused.

Architecture notes

  • Rust owns the kernel child (~200 lines): webview-held children orphan on HMR reloads, and Rust-side byte-level line framing makes 16 MiB frames and UTF-8 chunk boundaries a non-issue. No shell:* capability is granted to the webview; all restart policy lives in TS (kernel-manager.ts, one auto-respawn per death).
  • One kernel, many tabs: sessions multiplex over the single connection (session/new takes cwd); only the active tab holds a live store, so crash recovery is "drop stores, keep tab state, re-ensure lazily" with a resume→create fallback for stale ids.
  • Protocol additions kept minimal: minerva/config/state (read half of config/set_model, for frontends across a pipe) and the acp-only --allow-unconfigured flag; client now surfaces the kernel's modes it previously dropped.
  • Kernel-adjacent fix: packages/protocol/stdio.ts now uses web-standard TextDecoder instead of node:string_decoder — the module loads in the webview via the package index, where Vite externalizes node: builtins.
  • Packaging: prepare-sidecar.ts feeds the release dist/{minerva, rg} pair to Tauri as externalBin; Tauri strips the target-triple suffixes and co-locates both beside the app binary, so resolveRgPath works unchanged (verified in the bundle). Local tauri build only; release-CI bundling/signing deferred. Dev uses a config overlay so tauri dev never needs the release pair.

Verification

  • bun run verify + bun run knip green at every one of the 11 commits (528 tests at HEAD; new coverage: transport framing, kernel manager against a fake JSON-RPC bridge, tabs reducer + persistence, permission queue, diff alignment, notification matrix, keyless acp conformance over a real process boundary, minerva/config/state kernel tests).
  • Live-validated on macOS: dev app streams a real session; webview reload reattaches to the same kernel pid; kill -9 auto-respawns once and stays down on the second kill; SIGTERM of the app leaves no orphan (stdin-EOF safety net); first-run flow holds keyless at the config dialog with no session created; packaged .app launched via Finder spawns the bundled kernel and Cmd+Q kills it via the exit hook.
  • Manual GUI smoke checklist added to CONTRIBUTING (needs eyes-on-window steps like the permission dialog and split-diff toggle).

Notes for review

  • CI is intentionally untouched: the gate covers all new TS; Rust builds locally only (M2 scope).
  • App identifier is com.hutusi.minerva; placeholder icons — happy to swap for real branding.

Summary by CodeRabbit

  • New Features
    • Added a Tauri desktop GUI with chat, project tabs, session browsing, permission prompts, model/provider configuration, diff/markdown rendering, folder picking, and native notifications.
    • Introduced kernel sidecar lifecycle orchestration (restart-safe), plus a new minerva/config/state flow enabling live provider/model state and keyless acp --allow-unconfigured.
  • Documentation
    • Updated README, contributing, design, and protocol docs with finalized GUI build/run guidance and config-state semantics.
  • Tests
    • Added/expanded GUI, sidecar/transport, diff/format, tabs/sessions, permissions, notifications, and config-state test coverage.
  • Chores
    • Updated tooling/typecheck scope and ignore rules for GUI build artifacts; improved JS/CSS parsing settings and protocol stream decoding for browser/webview use.

hutusi added 11 commits July 13, 2026 06:23
M2 starts here: apps/gui is the Tauri 2 + React + Vite frontend slot that
DESIGN.md reserved. This slice is toolchain-only (no src-tauri yet) so the
workspace lands with every repo gate green before any GUI logic exists.

The webview needs the DOM lib, which would poison the Bun-typed root tsconfig
project, so apps/gui is excluded from the root project and typechecks as its
own project (tsc -p apps/gui) — the root typecheck script now runs both.
Biome needed tailwindDirectives enabled to parse Tailwind v4's CSS syntax.
shadcn is configured (components.json) but no components are vendored yet;
the cn() helper and its deps arrive with the first component so knip stays
clean at every slice boundary.
…inimal chat

Proves the full M2 pipe end to end: webview → Tauri IPC → Rust-spawned
kernel (minerva acp on stdio) → session/update stream → SessionStore render.
Verified live: tauri dev creates a real session through the kernel, a Vite
full reload reattaches to the same kernel process instead of spawning a
second one, and killing the app leaves no orphan (kernel exits on stdin EOF).

Rust owns the child process — a webview-held child would be orphaned on
every HMR reload — and does its own byte-level line framing so plugin event
chunking and UTF-8 chunk boundaries can't corrupt frames. The webview gets
one event per complete frame (minerva://line) plus minerva://exit; all
restart/reconnect policy stays in TS. No shell capability is granted to the
webview: the sidecar bridge is app-defined commands only.

The webview-side Transport adapter is DI-split (sidecar-bridge.ts is the
only module touching @tauri-apps/api) so the framing codec is unit-testable
with a fake bridge under bun test.

protocol: stdio.ts now uses web-standard TextDecoder instead of
node:string_decoder — the module loads in the GUI webview via the package
index, where Vite externalizes node: builtins and the import throws at
module eval (this was invisible until the webview went blank).
…ri build

De-risks the one open assumption in the M2 design early: Tauri does strip
the target-triple suffix from externalBin at bundle time and co-locates the
files with the app executable (Contents/MacOS/{minerva-gui, minerva, rg}),
so the kernel's process.execPath sits beside rg and resolveRgPath works
with zero kernel changes. Verified live: the bundled .app launched via
Finder (no terminal env) spawns the compiled kernel, completes the protocol
round-trip against settings-stored credentials, and Cmd+Q kills the kernel
through the RunEvent::Exit hook.

prepare-sidecar.ts reuses the repo's release build (the host-only
minerva+rg pair) and derives the triple from rustc -vV, which is
authoritative and always present. It is only needed before `tauri build`:
with externalBin configured, tauri-build hard-fails compilation when the
binaries are missing, which would have broken `tauri dev` on fresh clones —
so the dev script layers tauri.dev.conf.json over the config to drop
externalBin from the dev loop (dev spawns the kernel from source anyway).
Brings the GUI transcript to feature parity with the TUI: markdown assistant
messages (model output only, DOMPurify-sanitized — assistant HTML is
untrusted input), streaming-thought rolling tail with a collapsible full
text once done, tool items with status dot / subagent task-progress line /
output preview, unified inline diffs on the TUI's 20-line budget, the plan
checklist, the usage/context status line, and Esc-to-cancel.

The pure display helpers the TUI already had (diffLines/clipDiff line diff,
clipLines/thoughtTail/formatTokens) move from packages/cli into
@minerva/client rather than being duplicated: they are exactly the
"shared frontend core" that package exists for (design decision #8), and
both frontends must render identical previews and token counts. Their
tests move with them; the CLI now imports them from the client package.
`minerva acp --allow-unconfigured` (acp-only, parse-time enforced) skips
the API-key hard exit so a GUI host can reach the kernel before any key
exists — the first-run config dialog (next slice) drives
minerva/config/set_model over the protocol, exactly like the TUI's /config.
The kernel already tolerates a keyless provider; only the gate moved.
Conformance-tested over a real process boundary: keyless + flag answers
initialize, keyless without the flag still exits 1. The GUI spawns with
the flag in both dev and release.

The permission dialog replicates the TUI bridge seam (client constructed
before React mounts, requests resolved by whatever UI is attached) but as
a FIFO queue, because parallel tool calls can stack requests in a GUI.
Hotkey parity with the TUI — y/a/n by option KIND, Esc cancels the whole
turn (ACP cancelled outcome) — and the same field-sniffed rawInput preview
(command / edit diff / new-file content / URL) so MCP tools with matching
shapes get previews for free. Hand-rolled overlay rather than a generic
dialog component: hotkey-first modal flow is the whole interaction.
Adds minerva/config/state — the read half of config/set_model. The TUI
computes provider choices host-side (registry + where each key was found),
but a frontend on the far side of a pipe can't, so the kernel now answers
with the live model ref (the provider id is the ref truth), a per-provider
keySource (env | settings | none, blank-aware to match resolveApiKey), and
needsApiKey for the live provider. A live provider outside the registry
reports needsApiKey: false — there is nothing a config dialog could
usefully demand a key for.

The GUI boots through it: connect → getConfigState → if needsApiKey, hold
before any session exists and open the config dialog (one-screen
provider/key/model form, mirroring the TUI's /config steps); submitting
drives the existing minerva/config/set_model persist + hot-swap, then the
first session opens — no restart. The same dialog reopens from a footer
button for later model switches. Verified live: with a scrubbed env and
empty data dir the keyless kernel stays up and the app holds at the dialog
with no session created.
The GUI now covers the TUI's whole session surface: a native folder picker
(tauri-plugin-dialog) opens another project, the session browser lists the
kernel's JSONL index for the cwd (TUI-created sessions included) and
resumes via session/load replay, plus new-session, a mode picker, a
profile picker, compact, and clickable /skill autocomplete in the composer
(skill text goes to the kernel verbatim — it expands known skills, same as
the TUI).

client: newSession/loadSession now surface the kernel's already-typed
SessionModeState instead of dropping it after seeding the store — a mode
picker needs the available list, not just the current id. Additive change,
covered by a new client test.

Switching sessions cancels any running turn and detaches the old store
(loadSession refuses to overwrite a live registration by design). Skills
and profiles are fetched per-cwd and failure-tolerant: a project without
them must never block the session.
One kernel, many projects: tabs each hold a cwd + persisted sessionId
(pure reducer, localStorage-persisted), multiplexed over the single
connection — session/new takes cwd per session, so sidecar-per-tab would
buy nothing but N crash domains. Sessions materialize lazily: only the
active tab holds a live store; background tabs rebuild from JSONL replay
on activation. That same property makes crash recovery trivial — drop
every Session object, keep tab state, re-ensure on demand.

The kernel lifecycle moves into a manager (kernel-manager.ts) that owns
spawn, client construction, and recovery: on kernel death it cancels all
pending permission requests (a modal must not outlive its kernel), builds
a FRESH client over a fresh transport (a dead Connection's pending state
is unrecoverable), and auto-respawns exactly once — the allowance is only
replenished by a manual restart, so a kernel that dies after every boot
can't spin forever. ensureTabSession falls back from resume to a fresh
session, making stale persisted ids self-healing.

Verified live: kill -9 of the kernel auto-respawned a new one within
seconds; killing the respawn stayed down awaiting the banner's restart.
Manager, tab reducer, persistence serde, fallback, and permission
cancellation are unit-tested against a fake JSON-RPC-answering bridge.
Split view over the same shared unified diff (diffLines/clipDiff from
@minerva/client): alignDiffRows pairs the i-th removed line with the i-th
added line inside each changed region — the classic split-view pairing —
with the longer side finishing against blanks and gap/note lines becoming
full-width bands. The unified/split choice is a persisted preference; both
views share the transcript's 20-line clipping budget so a huge diff can't
flood the transcript in either mode.
A GUI can do what the terminal bell can't: tell the user their turn
finished while they were in another app. decideNotification is a pure
matrix — notify only when the window is unfocused, the turn ran at least
5s (shorter means the user hasn't left), it wasn't user-cancelled, and
notifications aren't muted; abnormal stops (token/request limits,
refusal) escalate the title. Delivery goes through
tauri-plugin-notification with the OS permission requested on first use
and denial treated as silence. A footer bell toggle persists the mute.
The M2 build is done, so the docs stop saying "planned": PROTOCOL.md's
transport table names the GUI sidecar as a live stdio consumer and gains
the minerva/config/state reference (the read half of config/set_model,
including why it exists — remote frontends can't compute provider choices
host-side); DESIGN.md's M2 milestone records the decisions made during the
build (Rust-owned child with Rust-side framing, one-kernel-many-tabs with
lazy per-tab stores, protocol-driven first-run config, externalBin pair
packaging with release-CI deferred); README gains the GUI quickstart; and
CONTRIBUTING adds the Rust prerequisite, the apps/gui test-layout rows with
the pure-modules-only coverage rationale, the GUI manual smoke checklist,
and fixes the stale Bun pin (CI runs 1.3.14).
@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4bbedf70-a8d3-4645-9d2f-c2f95c53290d

📥 Commits

Reviewing files that changed from the base of the PR and between 82126b0 and fe1e0cf.

📒 Files selected for processing (18)
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/src/app.tsx
  • apps/gui/src/components/ConfigDialog.tsx
  • apps/gui/src/components/chat/DiffView.tsx
  • apps/gui/src/components/chat/Transcript.tsx
  • apps/gui/src/lib/config-form.ts
  • apps/gui/src/lib/session-switches.ts
  • apps/gui/src/lib/sidecar-bridge.ts
  • apps/gui/src/main.tsx
  • apps/gui/test/config-form.test.ts
  • apps/gui/test/session-switches.test.ts
  • packages/cli/src/config-panel.tsx
  • packages/cli/src/index.tsx
  • packages/client/src/index.ts
  • packages/client/src/model-ref.ts
  • packages/client/test/model-ref.test.ts
  • packages/kernel/src/kernel.ts
  • packages/providers/src/registry.ts
💤 Files with no reviewable changes (1)
  • apps/gui/src/lib/config-form.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • packages/client/src/index.ts
  • apps/gui/src/main.tsx
  • apps/gui/src/components/chat/Transcript.tsx
  • packages/cli/src/index.tsx
  • apps/gui/src/lib/sidecar-bridge.ts
  • apps/gui/src/components/chat/DiffView.tsx
  • apps/gui/src/components/ConfigDialog.tsx
  • packages/kernel/src/kernel.ts
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/src/app.tsx

📝 Walkthrough

Walkthrough

Adds a Tauri 2 React desktop GUI with a Rust-managed kernel sidecar, shared protocol/client utilities, configuration-state handling, session and tab management, chat rendering, native integrations, CLI keyless startup, tests, and updated documentation.

Changes

GUI platform and sidecar

Layer / File(s) Summary
Tauri shell and sidecar transport
apps/gui/src-tauri/..., apps/gui/src/lib/sidecar-bridge.ts, apps/gui/src/lib/tauri-transport.ts
Adds Tauri configuration, Rust sidecar process management, JSON-RPC event forwarding, packaging, and native bridge APIs.
Kernel lifecycle and session state
apps/gui/src/lib/kernel-manager.ts, apps/gui/src/lib/permission-queue.ts, apps/gui/src/lib/tabs.ts, apps/gui/src/lib/tab-session.ts, apps/gui/src/lib/native.ts
Adds kernel restart handling, permission queuing, tab persistence, session recovery, and native folder and notification operations.
GUI application and chat UI
apps/gui/src/app.tsx, apps/gui/src/components/*, apps/gui/src/hooks/*, apps/gui/src/index.css
Adds session orchestration, configuration and permission dialogs, project tabs, transcript rendering, diffs, markdown, notifications, and styling.

Shared protocol and CLI support

Layer / File(s) Summary
Configuration-state protocol
packages/protocol/src/types.ts, packages/kernel/src/kernel.ts, packages/client/src/client.ts, docs/PROTOCOL.md
Adds minerva/config/state, provider and key metadata, kernel computation, and client access.
Shared diff and formatting utilities
packages/client/src/diff.ts, packages/client/src/format.ts, packages/client/src/index.ts, packages/cli/src/app.tsx
Adds reusable diff and text-formatting utilities and updates CLI imports to use them.
Keyless ACP startup
packages/cli/src/args.ts, packages/cli/src/index.tsx, packages/cli/test/*
Adds and validates --allow-unconfigured for ACP hosts without provider keys.

Documentation and repository configuration

Layer / File(s) Summary
GUI guidance and architecture records
README.md, CONTRIBUTING.md, docs/DESIGN.md
Documents GUI prerequisites, commands, manual smoke checks, packaging, and the shipped desktop architecture.
Build and analysis configuration
.gitignore, biome.json, knip.json, tsconfig.json, package.json
Excludes generated GUI artifacts and configures project analysis and Tailwind-aware CSS parsing.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • hutusi/minerva#1 — Extends the provider registry foundation used by the GUI configuration-state flow.
  • hutusi/minerva#7 — Provides the model/provider configuration protocol used by the GUI configuration flow.
  • hutusi/minerva#10 — Relates to the shared thought-rendering and clipping utilities moved into the client package.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: adding a Tauri 2 desktop GUI with the kernel running as a stdio sidecar.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tauri-gui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (10)
CONTRIBUTING.md (1)

16-17: 🩺 Stability & Availability | 🔵 Trivial

Consider adding a native Rust compile check to CI.

As documented, bun run verify does not validate the Tauri backend, so Rust regressions can pass CI unnoticed. A platform-neutral cargo check or dedicated macOS build job would catch these without requiring release bundling/signing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CONTRIBUTING.md` around lines 16 - 17, Add a platform-neutral Rust validation
step to the CI workflow, using cargo check or an equivalent dedicated Tauri
backend compile check, so the Rust side is validated alongside the existing bun
run verify gate without requiring release bundling or signing. Update the
CONTRIBUTING.md documentation to reflect the new CI coverage.
apps/gui/src/components/HeaderBar.tsx (1)

60-88: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Profile/mode <select> elements have no accessible name beyond title.

title alone is an unreliable accessible-name source across screen readers. Consider adding aria-label="Profile" / aria-label="Session mode" alongside the existing title for a proper accessible name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/components/HeaderBar.tsx` around lines 60 - 88, Add explicit
aria-label attributes to the profile and session mode select elements in
HeaderBar, using “Profile” and “Session mode” respectively, while preserving the
existing title attributes and selection behavior.
apps/gui/src/components/ConfigDialog.tsx (1)

60-64: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

No client-side gate for providers that require a key but have none found.

canSubmit never checks selected?.requiresApiKey && selected.keySource === "none" && !apiKey. A user can submit with a provider that needs a key and none configured, only to have the backend reject it. Adding this check would avoid a needless round-trip and give feedback before the async call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/components/ConfigDialog.tsx` around lines 60 - 64, Add a
client-side requirement to canSubmit in ConfigDialog: prevent submission when
the selected provider requires an API key, keySource is "none", and no apiKey is
entered. Preserve the existing busy, name, model, and custom-base-URL checks.
apps/gui/src/app.tsx (1)

493-505: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Verify outline-none still yields the intended focus-visible reset under Tailwind v4.

Tailwind v4 changed outline-none to a plain outline-style: none reset; the accessible "invisible-but-present" outline reset (useful for Windows High Contrast Mode) is now outline-hidden. This textarea combines outline-none with a custom focus:ring-2 focus:ring-ring, the exact pattern the v4 migration guide calls out. Visually the ring still shows on focus, but the semantic outline-removal behavior differs from v3.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/app.tsx` around lines 493 - 505, Update the textarea’s focus
reset utility in the JSX element identified by its placeholder and focus ring
classes: replace the Tailwind v3-style outline reset with the Tailwind
v4-compatible utility that preserves an invisible outline for accessibility,
while keeping the existing focus:ring-2 focus:ring-ring behavior unchanged.
apps/gui/src/components/chat/DiffView.tsx (2)

24-47: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Toggle buttons don't expose pressed state to assistive tech.

The active view is conveyed only via background color; adding aria-pressed={view === option} would let screen readers announce the current selection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/components/chat/DiffView.tsx` around lines 24 - 47, Add
aria-pressed={view === option} to each toggle button rendered in DiffView’s
view-option map, preserving the existing visual state and toggle behavior so
assistive technologies can identify the active view.

15-22: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Diff computation reruns on every render, including view-toggle clicks.

clipDiff(diffLines(...)) runs unconditionally on Line 17 even though diff.oldText/diff.newText are stable across re-renders (e.g. toggling view, or a parent re-render elsewhere in the transcript). Memoizing avoids redoing the diff/clip work when only the view mode changes.

♻️ Proposed fix
-import { clipDiff, type DiffLine, diffLines } from "`@minerva/client`";
-import { useState } from "react";
+import { clipDiff, type DiffLine, diffLines } from "`@minerva/client`";
+import { useMemo, useState } from "react";
 import { alignDiffRows, type SplitCell } from "../../lib/diff-rows";
 ...
   const [view, setView] = useState(loadPreference);
-  const lines = clipDiff(diffLines(diff.oldText, diff.newText), DIFF_LINE_CAP);
+  const lines = useMemo(
+    () => clipDiff(diffLines(diff.oldText, diff.newText), DIFF_LINE_CAP),
+    [diff.oldText, diff.newText],
+  );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/components/chat/DiffView.tsx` around lines 15 - 22, Memoize the
computed `lines` value in `DiffView` so `diffLines` and `clipDiff` rerun only
when `diff.oldText` or `diff.newText` changes. Preserve the existing line-cap
behavior and rendering while avoiding recomputation during view toggles or
unrelated re-renders.
apps/gui/src/components/chat/ToolItem.tsx (1)

32-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Diff path is dropped when rendering tool diffs.

item.diff carries a path field, but it's discarded here — DiffView only renders oldText/newText. Users can't tell which file was changed from the tool-call view.

💡 Suggested fix: surface the file path above the diff
       {item.diff ? (
         <div className="ml-5">
+          <div className="font-mono text-xs text-muted-foreground">{item.diff.path}</div>
           <DiffView diff={item.diff} />
         </div>
       ) : preview ? (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/components/chat/ToolItem.tsx` around lines 32 - 36, Update the
item.diff rendering branch in ToolItem to surface item.diff.path above or
alongside the DiffView, ensuring users can identify the changed file while
preserving the existing oldText/newText diff rendering.
packages/kernel/src/kernel.ts (1)

796-810: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

keySource precedence duplicates resolveApiKey's logic inline.

The blank-aware env→settings→none precedence here is hand-rolled and must stay manually in sync with resolveApiKey (imported and already used just below for needsApiKey). A future change to key-resolution precedence in resolveApiKey could silently diverge from what keySource reports.

Consider deriving keySource from resolveApiKey's own resolution (e.g. have it optionally report which source satisfied it, or check env/storedKeys membership using the same helper) to keep the two paths from drifting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/kernel/src/kernel.ts` around lines 796 - 810, The keySource
calculation in the providers mapping duplicates resolveApiKey’s env-to-settings
precedence and can drift. Update resolveApiKey or add a shared resolution helper
so the mapping derives both the resolved key source and needsApiKey from the
same blank-aware logic, preserving env, settings, and none precedence without
maintaining separate checks.
apps/gui/src/lib/notify.ts (1)

21-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider an exhaustive switch instead of a ternary chain.

Logic is correct today, but the final else branch silently absorbs any future StopReason addition as "The model refused the request." A switch with a satisfies never default would fail to compile instead of silently mislabeling a new stop reason.

♻️ Suggested refactor
-  const title =
-    input.stopReason === "end_turn" ? "Minerva finished a turn" : "Minerva needs attention";
-  const detail =
-    input.stopReason === "end_turn"
-      ? "The reply is ready."
-      : input.stopReason === "max_tokens"
-        ? "The turn hit the output-token limit."
-        : input.stopReason === "max_turn_requests"
-          ? "The turn hit the request limit."
-          : "The model refused the request.";
-  return { title, body: `${input.project} — ${detail}` };
+  const detail = ((): string => {
+    switch (input.stopReason) {
+      case "end_turn": return "The reply is ready.";
+      case "max_tokens": return "The turn hit the output-token limit.";
+      case "max_turn_requests": return "The turn hit the request limit.";
+      case "refusal": return "The model refused the request.";
+      case "cancelled": throw new Error("unreachable: filtered above");
+      default: {
+        const _exhaustive: never = input.stopReason;
+        return _exhaustive;
+      }
+    }
+  })();
+  const title = input.stopReason === "end_turn" ? "Minerva finished a turn" : "Minerva needs attention";
+  return { title, body: `${input.project} — ${detail}` };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/lib/notify.ts` around lines 21 - 35, Update decideNotification’s
stopReason-to-detail mapping to use an exhaustive switch rather than the nested
ternary chain. Handle each existing StopReason explicitly and add a
satisfies-never default so future StopReason additions produce a compile-time
error instead of falling through to the refusal message.
apps/gui/src/lib/tab-session.ts (1)

16-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Silent fallback loses the reason a resumed session failed to load.

The comment explains several plausible causes (deleted log, stale id, mid-life kernel restart), but the empty catch gives no way to tell which one occurred without attaching a debugger. Given session recovery is a headline feature of this PR, a lightweight diagnostic would help triage user reports of "my tab always opens fresh."

♻️ Suggested tweak
   if (tab.sessionId) {
     try {
       return { session: await ops.load(tab.sessionId, tab.cwd), resumed: true };
-    } catch {
+    } catch (cause) {
+      console.warn(`ensureTabSession: failed to resume ${tab.sessionId}`, cause);
       // Fall through to a fresh session in the tab's project.
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/lib/tab-session.ts` around lines 16 - 28, Update
ensureTabSession to capture the error from ops.load when a tab.sessionId is
present and emit a lightweight diagnostic before falling back to ops.create.
Include the tab/session context and error details in the existing logging
mechanism, while preserving the current fresh-session fallback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/gui/scripts/prepare-sidecar.ts`:
- Around line 26-37: Update the rustc result handling before parsing stdout:
validate rustc.success and ensure rustc.stdout exists before calling toString().
Preserve the existing failure message and exit behavior, while only extracting
the host triple after the spawn succeeded with readable stdout.

In `@apps/gui/src-tauri/src/sidecar.rs`:
- Around line 42-66: Update the release branch of kernel_command to resolve the
bundled externalBin sidecar using the artifact’s actual runtime name instead of
hardcoding dir.join("minerva"). Preserve the existing acp arguments and debug
behavior, and ensure packaged builds locate the sidecar next to the executable.

In `@apps/gui/src/app.tsx`:
- Around line 267-293: Preserve unsent drafts when switching tabs by lifting
Chat’s local draft state into the parent tab state. Add per-tab drafts storage
near the active tab/session state, pass the active tab’s draft and an
onDraftChange handler into Chat, and update the handler to store text by
activeTab.id; then remove Chat’s local draft useState and use the new props
throughout Chat.
- Around line 400-407: Update the stick-to-bottom useEffect around vm.items to
force scrolling to the latest message on its initial mount or first populated
transcript render, before applying the nearBottom guard. Preserve the existing
behavior of following subsequent updates only when the reader remains near the
bottom.

In `@apps/gui/src/components/ConfigDialog.tsx`:
- Around line 39-53: Preserve bare model identifiers when initializing the model
state in the ConfigDialog component. Update the model initializer around
currentProvider and providerName so a state.model without a slash uses the full
state.model value, while provider/model references continue using the segment
after the slash; ensure submitting without editing the field does not fall back
to selected.defaultModel.
- Around line 86-198: Update the ConfigDialog modal container to use dialog
semantics with role="dialog", aria-modal="true", and an accessible label or
title. Add Escape-key handling that invokes onClose when available, place
initial focus on the dialog or an appropriate first control when it opens, and
ensure keyboard focus is contained within the modal so users cannot tab into the
background overlay.

In `@apps/gui/src/components/PermissionDialog.tsx`:
- Around line 60-91: Update the PermissionDialog component’s modal container to
expose dialog semantics with role="dialog" and aria-modal="true", and implement
focus management that initially focuses an element inside the dialog and traps
Tab/Shift+Tab within its focusable controls until the dialog is answered. Keep
focus handling scoped to the dialog lifecycle and preserve the existing option
response behavior.
- Around line 39-55: Update the hotkey handling in the PermissionDialog
useEffect’s onKeyDown function so recognized options call preventDefault() and
stopPropagation() before responding. Keep the existing option lookup and
selected outcome behavior unchanged, while continuing to ignore unrecognized
keys.

In `@apps/gui/src/components/SessionBrowser.tsx`:
- Around line 39-92: The modal container in SessionBrowser should expose dialog
semantics and receive initial keyboard focus when opened. Update the inner
dialog element to use role="dialog", aria-modal="true", and an appropriate
accessible label, and add a ref with an effect that focuses it on mount while
preserving the existing Escape handling and focus cleanup behavior.

In `@apps/gui/src/lib/kernel-manager.ts`:
- Around line 56-79: Update the run function’s client.initialize/getConfigState
handshake to enforce a finite timeout, and ensure timeout handling terminates
the active bridge/sidecar process before transitioning to the existing "down"
error state. Preserve generation checks and prevent late handshake completion
from updating state after timeout or supersession.

In `@apps/gui/src/lib/native.ts`:
- Around line 14-22: Update notify() to enforce its documented best-effort
contract by wrapping the permission checks, permission request, and
sendNotification call in error handling that suppresses any thrown errors.
Preserve the existing permission flow and notification arguments, but ensure
notify() resolves without rejection when any native notification operation
fails.

In `@docs/DESIGN.md`:
- Around line 84-104: Update the introduction in DESIGN.md to reflect that the
Tauri 2 GUI shipped post-v0.3, replacing the outdated “CLI now, GUI later” and
v0.1 design-record framing. Align the introductory architecture/status summary
with the shipped GUI details described in the M2 section, while leaving the
technical decisions unchanged.

---

Nitpick comments:
In `@apps/gui/src/app.tsx`:
- Around line 493-505: Update the textarea’s focus reset utility in the JSX
element identified by its placeholder and focus ring classes: replace the
Tailwind v3-style outline reset with the Tailwind v4-compatible utility that
preserves an invisible outline for accessibility, while keeping the existing
focus:ring-2 focus:ring-ring behavior unchanged.

In `@apps/gui/src/components/chat/DiffView.tsx`:
- Around line 24-47: Add aria-pressed={view === option} to each toggle button
rendered in DiffView’s view-option map, preserving the existing visual state and
toggle behavior so assistive technologies can identify the active view.
- Around line 15-22: Memoize the computed `lines` value in `DiffView` so
`diffLines` and `clipDiff` rerun only when `diff.oldText` or `diff.newText`
changes. Preserve the existing line-cap behavior and rendering while avoiding
recomputation during view toggles or unrelated re-renders.

In `@apps/gui/src/components/chat/ToolItem.tsx`:
- Around line 32-36: Update the item.diff rendering branch in ToolItem to
surface item.diff.path above or alongside the DiffView, ensuring users can
identify the changed file while preserving the existing oldText/newText diff
rendering.

In `@apps/gui/src/components/ConfigDialog.tsx`:
- Around line 60-64: Add a client-side requirement to canSubmit in ConfigDialog:
prevent submission when the selected provider requires an API key, keySource is
"none", and no apiKey is entered. Preserve the existing busy, name, model, and
custom-base-URL checks.

In `@apps/gui/src/components/HeaderBar.tsx`:
- Around line 60-88: Add explicit aria-label attributes to the profile and
session mode select elements in HeaderBar, using “Profile” and “Session mode”
respectively, while preserving the existing title attributes and selection
behavior.

In `@apps/gui/src/lib/notify.ts`:
- Around line 21-35: Update decideNotification’s stopReason-to-detail mapping to
use an exhaustive switch rather than the nested ternary chain. Handle each
existing StopReason explicitly and add a satisfies-never default so future
StopReason additions produce a compile-time error instead of falling through to
the refusal message.

In `@apps/gui/src/lib/tab-session.ts`:
- Around line 16-28: Update ensureTabSession to capture the error from ops.load
when a tab.sessionId is present and emit a lightweight diagnostic before falling
back to ops.create. Include the tab/session context and error details in the
existing logging mechanism, while preserving the current fresh-session fallback
behavior.

In `@CONTRIBUTING.md`:
- Around line 16-17: Add a platform-neutral Rust validation step to the CI
workflow, using cargo check or an equivalent dedicated Tauri backend compile
check, so the Rust side is validated alongside the existing bun run verify gate
without requiring release bundling or signing. Update the CONTRIBUTING.md
documentation to reflect the new CI coverage.

In `@packages/kernel/src/kernel.ts`:
- Around line 796-810: The keySource calculation in the providers mapping
duplicates resolveApiKey’s env-to-settings precedence and can drift. Update
resolveApiKey or add a shared resolution helper so the mapping derives both the
resolved key source and needsApiKey from the same blank-aware logic, preserving
env, settings, and none precedence without maintaining separate checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b445a4e-ed0c-46a4-a6ca-593a34a14bfd

📥 Commits

Reviewing files that changed from the base of the PR and between b6856f5 and ec67281.

⛔ Files ignored due to path filters (8)
  • apps/gui/src-tauri/Cargo.lock is excluded by !**/*.lock
  • apps/gui/src-tauri/icons/128x128.png is excluded by !**/*.png
  • apps/gui/src-tauri/icons/128x128@2x.png is excluded by !**/*.png
  • apps/gui/src-tauri/icons/32x32.png is excluded by !**/*.png
  • apps/gui/src-tauri/icons/64x64.png is excluded by !**/*.png
  • apps/gui/src-tauri/icons/icon.ico is excluded by !**/*.ico
  • apps/gui/src-tauri/icons/icon.png is excluded by !**/*.png
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (73)
  • .gitignore
  • CONTRIBUTING.md
  • README.md
  • apps/gui/components.json
  • apps/gui/index.html
  • apps/gui/package.json
  • apps/gui/scripts/prepare-sidecar.ts
  • apps/gui/src-tauri/Cargo.toml
  • apps/gui/src-tauri/build.rs
  • apps/gui/src-tauri/capabilities/default.json
  • apps/gui/src-tauri/icons/icon.icns
  • apps/gui/src-tauri/src/lib.rs
  • apps/gui/src-tauri/src/main.rs
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/src-tauri/tauri.conf.json
  • apps/gui/src-tauri/tauri.dev.conf.json
  • apps/gui/src/app.tsx
  • apps/gui/src/components/ConfigDialog.tsx
  • apps/gui/src/components/HeaderBar.tsx
  • apps/gui/src/components/PermissionDialog.tsx
  • apps/gui/src/components/ProjectTabs.tsx
  • apps/gui/src/components/SessionBrowser.tsx
  • apps/gui/src/components/chat/DiffView.tsx
  • apps/gui/src/components/chat/Markdown.tsx
  • apps/gui/src/components/chat/PlanItem.tsx
  • apps/gui/src/components/chat/ThoughtItem.tsx
  • apps/gui/src/components/chat/ToolItem.tsx
  • apps/gui/src/components/chat/Transcript.tsx
  • apps/gui/src/components/chat/UsageFooter.tsx
  • apps/gui/src/hooks/use-session-store.ts
  • apps/gui/src/index.css
  • apps/gui/src/lib/diff-rows.ts
  • apps/gui/src/lib/kernel-manager.ts
  • apps/gui/src/lib/native.ts
  • apps/gui/src/lib/notify.ts
  • apps/gui/src/lib/permission-queue.ts
  • apps/gui/src/lib/sidecar-bridge.ts
  • apps/gui/src/lib/tab-session.ts
  • apps/gui/src/lib/tabs.ts
  • apps/gui/src/lib/tauri-transport.ts
  • apps/gui/src/main.tsx
  • apps/gui/test/diff-rows.test.ts
  • apps/gui/test/kernel-manager.test.ts
  • apps/gui/test/notify.test.ts
  • apps/gui/test/permission-queue.test.ts
  • apps/gui/test/tab-session.test.ts
  • apps/gui/test/tabs.test.ts
  • apps/gui/test/tauri-transport.test.ts
  • apps/gui/tsconfig.json
  • apps/gui/vite.config.ts
  • biome.json
  • docs/DESIGN.md
  • docs/PROTOCOL.md
  • knip.json
  • package.json
  • packages/cli/src/app.tsx
  • packages/cli/src/args.ts
  • packages/cli/src/index.tsx
  • packages/cli/test/acp.test.ts
  • packages/cli/test/app.test.tsx
  • packages/cli/test/args.test.ts
  • packages/client/src/client.ts
  • packages/client/src/diff.ts
  • packages/client/src/format.ts
  • packages/client/src/index.ts
  • packages/client/test/client.test.ts
  • packages/client/test/diff.test.ts
  • packages/client/test/format.test.ts
  • packages/kernel/src/kernel.ts
  • packages/kernel/test/config.test.ts
  • packages/protocol/src/stdio.ts
  • packages/protocol/src/types.ts
  • tsconfig.json

Comment thread apps/gui/scripts/prepare-sidecar.ts Outdated
Comment on lines +42 to +66
fn kernel_command() -> Result<Command, String> {
if cfg!(debug_assertions) {
// CARGO_MANIFEST_DIR = <repo>/apps/gui/src-tauri at compile time.
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.canonicalize()
.map_err(|e| format!("cannot resolve repo root: {e}"))?;
let entry = repo_root.join("packages/cli/src/index.tsx");
let mut cmd = Command::new("bun");
cmd.arg("run")
.arg(entry)
.arg("acp")
.arg("--allow-unconfigured");
cmd.current_dir(repo_root);
Ok(cmd)
} else {
let exe = std::env::current_exe().map_err(|e| format!("cannot resolve app path: {e}"))?;
let dir = exe
.parent()
.ok_or("app executable has no parent directory")?;
let mut cmd = Command::new(dir.join("minerva"));
cmd.arg("acp").arg("--allow-unconfigured");
Ok(cmd)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether build.rs or tauri.conf.json handle stripping/renaming the target-triple suffix.
cat apps/gui/src-tauri/build.rs 2>/dev/null
rg -n 'externalBin' apps/gui/src-tauri/tauri.conf.json
fd -HI 'minerva-*' apps/gui/src-tauri

Repository: hutusi/minerva

Length of output: 686


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- apps/gui/src-tauri/tauri.conf.json ---'
cat -n apps/gui/src-tauri/tauri.conf.json | sed -n '1,220p'

printf '\n%s\n' '--- apps/gui/src-tauri/src/sidecar.rs ---'
cat -n apps/gui/src-tauri/src/sidecar.rs | sed -n '1,240p'

printf '\n%s\n' '--- other packaging/build files mentioning binaries/minerva or externalBin ---'
rg -n 'binaries/minerva|externalBin|minerva-' apps/gui/src-tauri -g '!target/**'

printf '\n%s\n' '--- repo files named minerva* outside target ---'
fd -HI 'minerva*' . -E target

Repository: hutusi/minerva

Length of output: 9996


Resolve the release sidecar path in apps/gui/src-tauri/src/sidecar.rs:62
Command::new(dir.join("minerva")) does not match the bundled externalBin artifact, so packaged builds will fail to start the kernel unless the sidecar name is resolved at runtime or the bundle renames it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src-tauri/src/sidecar.rs` around lines 42 - 66, Update the release
branch of kernel_command to resolve the bundled externalBin sidecar using the
artifact’s actual runtime name instead of hardcoding dir.join("minerva").
Preserve the existing acp arguments and debug behavior, and ensure packaged
builds locate the sidecar next to the executable.

Comment thread apps/gui/src/app.tsx Outdated
Comment thread apps/gui/src/app.tsx
Comment thread apps/gui/src/components/ConfigDialog.tsx Outdated
Comment thread apps/gui/src/components/PermissionDialog.tsx
Comment thread apps/gui/src/components/SessionBrowser.tsx
Comment thread apps/gui/src/lib/kernel-manager.ts
Comment thread apps/gui/src/lib/native.ts
Comment thread docs/DESIGN.md
…ad fallback)

Six findings from the codex review of PR #16, all confirmed against the
code before fixing:

- App exit now shuts the kernel down through its durability path instead
  of SIGKILL: close stdin (the acp host's shutdown signal — kernel.close()
  cancels, drains up to 5s, flushes session logs), wait up to 7s, kill
  only as fallback. Cmd+Q was ironically the only violent exit path;
  SIGTERM already went through stdin EOF. Verified live: quit logs
  "kernel exited gracefully on stdin close".
- Model refs with slashes survive the config dialog: provider is
  everything before the FIRST slash (registry contract), and submission
  always qualifies with the selected provider instead of slash-sniffing —
  TUI parity; openrouter-style ids like meta-llama/llama-3 now work. The
  form logic moved to a pure, tested module (config-form.ts).
- Custom keyless providers persist requiresApiKey: apiKey !== "" (TUI
  parity); previously the registry defaulted them to key-required and
  resolution rejected them.
- A tab's persisted session only falls back to a fresh one when the id is
  actually dead ("unknown session"); load-while-prompt-active, failed
  flushes, and transient I/O now surface via the notice bar and retry on
  tab re-activation — the kernel throws those loudly by design, and the
  blanket catch was silently replacing conversations with blank sessions.
- The session browser no longer reverses the kernel's newest-first list.
- Dropped the unused clipLines import left over from the helper move.
@hutusi

hutusi commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Codex review round — disposition

All six findings verified against the code and confirmed; fixed in 5707467.

# Finding Verdict Fix
1 P1 — exit force-kills the kernel, bypassing durability shutdown Confirmed. Cmd+Q was the only violent exit path — SIGTERM already shut down via stdin EOF. shutdown_gracefully: drop stdin (acp host runs kernel.close() — cancel, drain ≤5s, flush), poll try_wait up to 7s, kill as fallback; used by both the exit hook and sidecar_kill. Verified live: quit logs "kernel exited gracefully on stdin close".
2 P2 — dialog corrupts model ids containing / Confirmed. parseModelRef splits on the FIRST slash; the dialog split on [1] and slash-sniffed on submit. New pure config-form.ts: splitModelRef keeps everything after the first slash; submission always qualifies ${provider}/${model} (TUI parity), with a pasted provider/ prefix stripped rather than double-prefixed. Unit-tested.
3 P2 — any load error silently replaces the tab's session Confirmed, and worse than it looks: the kernel deliberately throws loud errors for load-during-active-prompt and failed flushes ("pending writes failed") — the blanket catch converted exactly those data-loss signals into a blank session. ensureTabSession now takes an isStale predicate; only RpcError "unknown session: …" falls back. Everything else surfaces via the notice bar and retries on tab re-activation. Tests cover both classes.
4 P2 — custom keyless providers unusable Confirmed. TUI persists requiresApiKey: apiKey !== "" (config-panel.tsx:217); the GUI omitted it. buildSetModelParams mirrors the TUI for custom providers. Unit-tested (keyless and keyed).
5 P2 — session browser reverses MRU order Confirmed. The kernel already returns newest-first (kernel.ts:541). Dropped the reverse(), fixed the comment.
6 P3 — unused clipLines import Confirmed (leftover from the S4 helper move). Removed.

Gates: bun run verify (536 tests, +8 for the new pure modules), bun run knip, cargo clippy -D warnings, packaged .app rebuild all green. The EADDRINUSE flake you hit is the known pre-existing serve-retry test-infra issue (see packages/kernel/test/fixtures/serve-retry.ts), unrelated to this branch.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
apps/gui/src/app.tsx (1)

279-281: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Remounting Chat on tab switch still discards the unsent draft.

This was flagged in a previous review and persists: key={activeTab?.id} unmounts/remounts Chat on every tab switch, and draft remains local useState inside Chat. Switching away and back loses any typed-but-unsent text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/app.tsx` around lines 279 - 281, Remove the activeTab-dependent
key from the Chat rendering in the app component so tab switches do not unmount
and remount Chat, preserving its local draft state. Keep the existing
activeSession and client conditional rendering unchanged.
🧹 Nitpick comments (1)
apps/gui/src/app.tsx (1)

200-218: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Unhandled rejection from client.closeSession in switchWithinTab.

Line 207 calls client.closeSession(previous.id) without a catch handler. If the close fails (e.g., kernel transport error), the unhandled rejection could surface as an uncaught promise warning. The same pattern appears in closeTab (line 230), but there at least the session is being removed from the map regardless. Here, a failed close leaves the previous session in an indeterminate state while the new one is already active.

♻️ Suggested fix: add error handling
       if (previous && previous.id !== session.id && client) {
         if (previous.store.snapshot.busy) client.cancel(previous.id);
-        client.closeSession(previous.id);
+        client.closeSession(previous.id).catch(() => {
+          // best-effort close; session is already replaced
+        });
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/app.tsx` around lines 200 - 218, Handle the promise returned by
client.closeSession in switchWithinTab by attaching error handling that reports
close failures through the existing error-reporting mechanism. Preserve the
current session replacement, dispatch, and browser-closing flow regardless of
whether closing the previous session succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@apps/gui/src/app.tsx`:
- Around line 279-281: Remove the activeTab-dependent key from the Chat
rendering in the app component so tab switches do not unmount and remount Chat,
preserving its local draft state. Keep the existing activeSession and client
conditional rendering unchanged.

---

Nitpick comments:
In `@apps/gui/src/app.tsx`:
- Around line 200-218: Handle the promise returned by client.closeSession in
switchWithinTab by attaching error handling that reports close failures through
the existing error-reporting mechanism. Preserve the current session
replacement, dispatch, and browser-closing flow regardless of whether closing
the previous session succeeds.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e814b359-881b-4e80-9399-d9b9bbcb20ae

📥 Commits

Reviewing files that changed from the base of the PR and between ec67281 and 5707467.

📒 Files selected for processing (9)
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/src/app.tsx
  • apps/gui/src/components/ConfigDialog.tsx
  • apps/gui/src/components/SessionBrowser.tsx
  • apps/gui/src/lib/config-form.ts
  • apps/gui/src/lib/tab-session.ts
  • apps/gui/test/config-form.test.ts
  • apps/gui/test/tab-session.test.ts
  • packages/cli/src/app.tsx
💤 Files with no reviewable changes (1)
  • packages/cli/src/app.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/gui/src/components/SessionBrowser.tsx
  • apps/gui/src/components/ConfigDialog.tsx
  • apps/gui/src-tauri/src/sidecar.rs

…imeout)

Ten of thirteen findings confirmed and fixed; two refuted with evidence,
one already fixed in the codex round (see the PR disposition comment).

- Unsent composer drafts survive tab switches: Chat still remounts per tab
  (scroll/effect isolation), but drafts live in an App-held per-tab map the
  component initializes from and writes back to.
- A resumed or switched-to transcript now lands on the latest message: the
  first paint with content always jumps to the bottom (a fresh scroll
  container starts at the top, so near-bottom stickiness never engaged).
- Permission hotkeys no longer leak into whatever had focus: handled keys
  preventDefault + stopPropagation, editable targets are exempt (typing
  wins if focus escaped the modal), and focus moves to the first option so
  Enter confirms it. All three overlays gain role="dialog"/aria-modal and
  initial focus; the config dialog closes on Escape when dismissible.
- The kernel handshake is bounded (15s): a sidecar that spawns but never
  answers used to wedge the app in "starting" forever with start() a no-op;
  it now lands in "down" with the wedged process killed, and the manual
  restart button recovers. Regression-tested with a mute fake kernel.
- notify() honors its never-throws contract (callers fire-and-forget), the
  rustc triple probe fails with the install hint instead of a stack trace
  when rustc is missing, and DESIGN.md's intro stops saying "GUI later".
@hutusi

hutusi commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

CodeRabbit review round — disposition

13 findings across both reviews, each verified against the code before acting: 10 fixed in e40aefa, 1 already fixed, 2 refuted.

Finding Verdict Disposition
Chat remount discards unsent draft (flagged twice) Confirmed Fixed: drafts live in an App-held per-tab map; Chat initializes from it and writes back, so the per-tab remount (kept for scroll/effect isolation) no longer loses text.
Resumed sessions land at the top of history Confirmed Fixed: first paint with content always scrolls to the latest message; stickiness applies after.
Permission hotkeys leak keystrokes to focused elements Confirmed Fixed: handled keys preventDefault + stopPropagation; editable targets are exempt (typing wins if focus escapes); focus moves to the first option so Enter confirms.
Permission / Config / SessionBrowser modals lack dialog semantics + focus Confirmed (3 findings) Fixed: role="dialog", aria-modal, labels, and initial focus on all three; ConfigDialog closes on Escape when dismissible (never on first run). Focus trap deliberately left as follow-up, as the review itself suggested.
No timeout on the kernel handshake Confirmed Fixed: handshake bounded at 15s; expiry kills the wedged process and lands in "down", where the restart button recovers. Regression-tested with a mute fake kernel (handshakeTimeoutMs injectable).
notify() can reject despite its contract Confirmed Fixed: wrapped; denial/unsupported/plugin errors are silence.
rustc.stdout.toString() can throw before the success check Confirmed Fixed: the whole triple probe is guarded; a missing rustc prints the install hint.
DESIGN.md intro still says "CLI now, GUI later" Confirmed Fixed.
ConfigDialog blanks the model field for bare refs Already fixed 5707467 (codex round) replaced the parsing with splitModelRef; a bare ref now displays as the anthropic model it resolves to, un-truncated.
Release sidecar path won't match the bundled externalBin name (Major) Refuted Tauri strips the target-triple suffix at bundle time: the shipped layout is Contents/MacOS/{minerva-gui, minerva, rg}, and the packaged app has been launched repeatedly on this branch spawning Contents/MacOS/minerva acp successfully (see the PR description's verification notes).
client.closeSession unhandled rejection in switchWithinTab Refuted closeSession is synchronous (void — it deletes a client-side store entry; packages/client/src/client.ts:191). There is no promise to catch.

Gates: bun run verify (537 tests), bun run knip, cargo clippy -D warnings green; dev app boot-smoked after the changes.

…focus return)

All three findings confirmed.

- Intentional kernel shutdown no longer masquerades as a crash: each spawn
  gets a monotonic generation, and the stdout thread's EOF path only reaps
  and emits minerva://exit when ITS OWN generation still sits in state. A
  killed kernel therefore emits nothing (no spurious auto-restart after a
  handshake timeout), and a lingering EOF thread can no longer steal a
  newly spawned child out of state during the shutdown grace. sidecar_kill
  became an async command running the grace wait on a blocking thread —
  callers await it, so the manager only shows the restart button once the
  old child is fully gone, and the 7s wait never blocks the main thread.

- The stale-session predicate now matches errors the kernel actually
  throws: session/load wraps Session.load messages verbatim, which are
  "no persisted session …" (deleted log / other data dir) and "invalid
  session id: …" — not the "unknown session" family the previous predicate
  (and its fabricated test) assumed, which had silently disabled the
  self-heal. The predicate moved next to ensureTabSession and is now
  pinned by a real-kernel round-trip test so it can't drift from the wire
  again.

- Dialogs return focus on close: all three overlays capture
  document.activeElement on open and restore it in effect cleanup, so
  resolving a permission (or closing config/sessions) puts keyboard users
  back in the composer instead of on <body>. The permission dialog hands
  focus along correctly across a stacked request queue.
@hutusi

hutusi commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Codex round 2 — disposition

All three findings confirmed and fixed in 4559cbb.

# Finding Verdict Fix
1 P1 — intentional shutdown races with restart; old EOF thread can detach the new child; 7s wait on the IPC thread Confirmed (all three sub-issues; the starts: 2, kills: 2 repro matched the code: the EOF thread emitted exit unconditionally, so an intentional kill looked like a crash). Every spawn now carries a monotonic generation; the EOF thread only reaps + emits minerva://exit when its own generation still sits in state — so intentional kills emit nothing (no phantom crash recovery) and an old thread can't steal a newer child. sidecar_kill is now async with the grace wait on a blocking thread, and the manager awaits it before entering "down", so restart can't race the draining process. Re-verified live: kill -9 still auto-respawns (genuine crashes still emit), and packaged-app quit still logs "kernel exited gracefully on stdin close".
2 P2 — stale predicate doesn't match real kernel errors Confirmed — worse, the regression test fabricated the wrong error, so the suite endorsed a broken self-heal. session/load wraps Session.load messages verbatim: "no persisted session …" and "invalid session id: …", never "unknown session". Predicate (isStaleSessionError, moved next to ensureTabSession) now matches the real families, and a new real-kernel round-trip test (stale-session.test.ts: actual kernel, in-proc transport) pins it to the wire — including an end-to-end ensureTabSession self-heal of a dead tab id.
3 P2 — dialogs don't restore focus on close Confirmed. All three overlays capture document.activeElement on open and restore it in effect cleanup; the permission dialog's per-request cleanup hands focus along correctly across a stacked queue and returns it to the composer after the last resolve.

Gates: bun run verify (540 tests, +3 real-kernel), bun run knip, cargo clippy --all-targets -D warnings, packaged rebuild — green. Live re-drills: crash auto-respawn ✓, graceful-quit drain ✓.

…ll races)

Three confirmed concurrency races, all in the seams between async work and
the state that outlives it.

- Rust: start/kill are now serialized by an async lifecycle mutex held for
  the FULL operation, and shutdown joins the stdout forwarder after
  reaping. Previously the slot emptied before the ≤7s drain, so a
  webview-reload start could spawn a replacement while the old kernel was
  still draining — and the never-joined forwarder could emit the dying
  kernel's lines into the replacement's freshly subscribed listeners
  (Tauri events are app-global), risking response/id collisions on the new
  connection. The two locks have strictly separated roles (deadlock audit
  in the code): the reader thread never takes lifecycle, and shutdown
  never holds the slot lock while joining, so a crash racing a kill cannot
  deadlock. The exit hook block_ons the lifecycle lock — blocking quit
  until the drain finishes is the point.

- TS: every async session install (lazy ensure or user switch) now begins
  a per-tab token (session-slots.ts, single monotonic counter that never
  resets) and commits only while still current. A superseded result —
  newer switch, closed tab, replaced client — is closed instead of
  installed, fixing both the invisible "already open" registration leak
  and completion-order-beats-user-order overwrites. Commits read the
  previous session from a synchronously updated authoritative ref, not the
  render closure, so back-to-back installs can't skip closing the loser.
@hutusi

hutusi commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Codex round 3 — disposition

All three concurrency findings confirmed and fixed in d3c6c25.

# Finding Verdict Fix
1 P2 — sidecar state empties before shutdown completes; forwarder never joined Confirmed. The slot went None before the ≤7s drain, so a webview-reload sidecar_start could spawn a replacement mid-drain, and the dying kernel's forwarder could emit into the replacement's listeners (Tauri events are app-global). sidecar_start/sidecar_kill are serialized by an async lifecycle mutex held for the full operation (start becomes async and queues behind a drain); shutdown_gracefully joins the stdout forwarder after reaping, so no line event survives a completed shutdown. Lock roles are strictly separated with a deadlock audit in the code: the reader never takes lifecycle, and shutdown never holds slot during the join — a crash racing a kill can't deadlock. The exit hook block_ons the lifecycle lock.
2 P2 — lazy session results outlive their tab/client Confirmed. The ensure completion installed unconditionally: a mid-flight tab close left the session invisibly registered ("already open" later); a mid-flight client swap let a dead-client session pollute the new map. Every install begins a per-tab token (session-slots.ts); completion commits only while current, else the orphan session is closed, not installed. Tab close invalidates, client swap invalidateAlls. Error reports are also suppressed when superseded.
3 P2 — concurrent switches commit in completion order Confirmed. No token; previous read from the render closure. Same token mechanism: a superseded switch result is closed and discarded, so the user's latest choice always wins. Commits read the previous session from a synchronously updated authoritative ref (never the render closure), so back-to-back installs can't skip closing the loser. Token semantics unit-tested (supersede, per-tab independence, invalidate/invalidateAll, no token resurrection after clear — the counter never resets).

Gates: bun run verify (544 tests, +4), bun run knip, cargo clippy --all-targets -D warnings, packaged rebuild — green. Live re-drills: boot + tab-restore resume ✓, kill -9 → single auto-respawn (async start + lifecycle mutex don't break crash recovery) ✓, packaged Cmd+Q → "kernel exited gracefully on stdin close", no orphans ✓. The HMR-reload-during-drain interleaving itself isn't headlessly scriptable; it's covered by the lifecycle-mutex serialization argument documented in the code.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/gui/test/stale-session.test.ts (1)

18-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add teardown for the temp dirs and kernel/client handles. createKernel() returns a kernel with close(), and MinervaClient exposes close(). Clean both up after the test, and remove cwd/dataDir with rmSync to avoid leaving temp artifacts behind.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/test/stale-session.test.ts` around lines 18 - 26, Update setup and
the stale-session test teardown to retain the kernel, client, cwd, and dataDir
handles. After the test completes, call the kernel and MinervaClient close()
methods and remove both temporary directories with rmSync, ensuring cleanup runs
even when assertions fail.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/gui/test/stale-session.test.ts`:
- Around line 18-26: Update setup and the stale-session test teardown to retain
the kernel, client, cwd, and dataDir handles. After the test completes, call the
kernel and MinervaClient close() methods and remove both temporary directories
with rmSync, ensuring cleanup runs even when assertions fail.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bacc5e10-2ea0-4993-ac40-f685b9b5c696

📥 Commits

Reviewing files that changed from the base of the PR and between 5707467 and d3c6c25.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • apps/gui/package.json
  • apps/gui/scripts/prepare-sidecar.ts
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/src/app.tsx
  • apps/gui/src/components/ConfigDialog.tsx
  • apps/gui/src/components/PermissionDialog.tsx
  • apps/gui/src/components/SessionBrowser.tsx
  • apps/gui/src/lib/kernel-manager.ts
  • apps/gui/src/lib/native.ts
  • apps/gui/src/lib/session-slots.ts
  • apps/gui/src/lib/tab-session.ts
  • apps/gui/test/kernel-manager.test.ts
  • apps/gui/test/session-slots.test.ts
  • apps/gui/test/stale-session.test.ts
  • apps/gui/test/tab-session.test.ts
  • docs/DESIGN.md
🚧 Files skipped from review as they are similar to previous changes (12)
  • apps/gui/src/lib/native.ts
  • apps/gui/src/lib/tab-session.ts
  • docs/DESIGN.md
  • apps/gui/scripts/prepare-sidecar.ts
  • apps/gui/test/kernel-manager.test.ts
  • apps/gui/package.json
  • apps/gui/src/components/ConfigDialog.tsx
  • apps/gui/src/components/SessionBrowser.tsx
  • apps/gui/src/components/PermissionDialog.tsx
  • apps/gui/src/lib/kernel-manager.ts
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/src/app.tsx

…n patches)

Two codex findings — both follow-through gaps in the round-3 mechanisms —
plus a CodeRabbit test-hygiene nitpick.

- The dead-child reap branch in sidecar_start now applies the same
  discipline round 3 gave the kill path: take the dead Running out of the
  slot, release the slot lock (the reader's EOF path takes it — joining
  while holding it would deadlock), reap, JOIN the reader, then re-acquire
  and spawn. Previously the dead reader's JoinHandle was dropped and the
  successor spawned immediately, so a reader still draining buffered
  stdout could emit the dead kernel's frames into the successor's
  connection. The lifecycle lock (held for all of start) keeps the briefly
  empty slot private.

- Profile updates now go through updateSession, the single write path for
  in-place session patches: it applies only while the SAME session is
  still installed in the tab and updates sessionsRef and React state in
  lockstep. The old handler wrote React state directly from a captured
  session object — the ref/state divergence meant any later commit or
  tab-close rebuilt from the stale ref and silently REVERTED the profile,
  and a response delayed past a session switch could reinstall the
  detached old store.

- stale-session.test.ts closes its kernels and clients in afterEach
  (matching the acp/app test precedent). Temp dirs stay on the OS tmp
  cleaner like the rest of the suite.
@hutusi

hutusi commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Review round 4 — disposition (codex ×2 + CodeRabbit nitpick)

Fixed in f220cad.

Finding Verdict Disposition
codex P2 — dead-child respawn drops the reader without joining Confirmed — round 3 applied the join discipline to the kill path but missed the reap branch in sidecar_start. The reap branch now takes the dead Running out, releases the slot lock (the reader's EOF path takes it; joining under it would deadlock), reaps, joins the reader, then re-acquires and spawns. The lifecycle lock held across all of start keeps the briefly empty slot private. Same window argument as the kill path: any frame emitted before the join lands where no live transport handler exists yet, and the join guarantees silence after.
codex P2 — profile updates bypass the session lifecycle safeguards Confirmed, with the reported symptom verified in code: the handler wrote React state only, so sessionsRef diverged and any later commitSession/removeSession (e.g. closing another tab) rebuilt from the stale ref and reverted the profile; a delayed response after a switch could reinstall the detached store. New updateSession(tabId, sessionId, patch) is the single write path for in-place session patches: applies only while that exact session is still installed, and updates sessionsRef + React state in lockstep. onSetProfile captures ids at initiation; the info line and error report are suppressed once superseded.
CodeRabbit trivial — stale-session.test.ts teardown Partially applied. Kernels and clients are now closed in afterEach (matching the acp.test.ts/app.test.tsx precedent). The rmSync half is deliberately skipped: every in-proc kernel suite in this repo (client.test.ts, config.test.ts, …) leaves mkdtemp dirs to the OS tmp cleaner; diverging in one file adds noise, not hygiene.

Gates: bun run verify (544 tests), bun run knip, cargo clippy --all-targets -D warnings, packaged rebuild — green. Standing drills re-run: kill -9 → single auto-respawn ✓; packaged Cmd+Q → "kernel exited gracefully on stdin close", no orphans ✓. The reap-branch interleaving itself (death landing in the instant before a start, with the reader mid-drain) isn't deliberately reproducible; it rests on the join + lock-ordering reasoning documented in the code.

Tag sidecar exits with process generations so stale webview events cannot tear down replacements, and prevent superseded startup attempts from attaching clients.

Queue profile mutations per session in request order and cover both races with deterministic regression tests.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/kernel/src/kernel.ts (1)

455-477: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject profile mutations after session replacement.

loadSettings() yields at Line 455, allowing session/load to replace this session in #sessions. This then updates and persists the detached instance while returning success, leaving the active loaded session with the old profile.

Proposed fix
     } else {
       const settings = await loadSettings(this.#runtime, this.#dataDir, session.cwd);
       try {
         resolved = resolveProfile(settings, profile);
       } catch (error) {
         throw new RpcError(
           JSON_RPC_ERROR_CODES.INVALID_PARAMS,
           error instanceof Error ? error.message : String(error),
         );
       }
     }
+    if (this.#sessions.get(session.id) !== session) {
+      throw new RpcError(
+        JSON_RPC_ERROR_CODES.INVALID_REQUEST,
+        "session was reloaded while switching profile",
+      );
+    }
     if (session.promptActive) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/kernel/src/kernel.ts` around lines 455 - 477, After the await to
load settings in the profile-switch flow, verify that the current session is
still the active instance registered in `#sessions` before resolving or mutating
it. If session/load replaced it, reject the request with the appropriate
invalid-request RpcError; otherwise preserve the existing resolveProfile,
session.profile assignment, and session.append behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/gui/src/lib/sidecar-generation.ts`:
- Around line 29-35: Update the exit handler in the sidecar generation bridge so
every exit whose generation does not match active is stored in pending,
including when another generation is currently active. Remove the active ===
null restriction from the nonmatching-generation branch, preserving the existing
direct delivery for the active generation; activate() will discard stale
buffered exits.

---

Outside diff comments:
In `@packages/kernel/src/kernel.ts`:
- Around line 455-477: After the await to load settings in the profile-switch
flow, verify that the current session is still the active instance registered in
`#sessions` before resolving or mutating it. If session/load replaced it, reject
the request with the appropriate invalid-request RpcError; otherwise preserve
the existing resolveProfile, session.profile assignment, and session.append
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4cb713b9-ab7c-4d08-9ff9-de74abdc5d6a

📥 Commits

Reviewing files that changed from the base of the PR and between d3c6c25 and 82126b0.

📒 Files selected for processing (10)
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/src/app.tsx
  • apps/gui/src/lib/kernel-manager.ts
  • apps/gui/src/lib/sidecar-bridge.ts
  • apps/gui/src/lib/sidecar-generation.ts
  • apps/gui/test/kernel-manager.test.ts
  • apps/gui/test/sidecar-generation.test.ts
  • apps/gui/test/stale-session.test.ts
  • packages/kernel/src/kernel.ts
  • packages/kernel/test/profiles.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/gui/test/stale-session.test.ts
  • apps/gui/src/lib/sidecar-bridge.ts
  • apps/gui/src-tauri/src/sidecar.rs
  • apps/gui/test/kernel-manager.test.ts
  • apps/gui/src/lib/kernel-manager.ts
  • apps/gui/src/app.tsx

Comment on lines +29 to +35
exit(event: SidecarExitEvent) {
if (event.generation === active) {
active = null;
deliver(event.code);
} else if (active === null) {
pending.set(event.generation, event.code);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Retain replacement exits while another generation is active.

An early exit for generation 2 is discarded while generation 1 is active. If 2 is a replacement that dies before activate(2), the bridge attaches without receiving its exit. Buffer every nonmatching generation; activate() already discards stale entries.

Proposed fix
-      } else if (active === null) {
+      } else {
         pending.set(event.generation, event.code);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exit(event: SidecarExitEvent) {
if (event.generation === active) {
active = null;
deliver(event.code);
} else if (active === null) {
pending.set(event.generation, event.code);
}
exit(event: SidecarExitEvent) {
if (event.generation === active) {
active = null;
deliver(event.code);
} else {
pending.set(event.generation, event.code);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gui/src/lib/sidecar-generation.ts` around lines 29 - 35, Update the exit
handler in the sidecar generation bridge so every exit whose generation does not
match active is stored in pending, including when another generation is
currently active. Remove the active === null restriction from the
nonmatching-generation branch, preserving the existing direct delivery for the
active generation; activate() will discard stale buffered exits.

…round

A 26-agent review of the whole branch (final state, not diffs) surfaced 10
verified findings; all accepted.

Rust lock discipline — the review caught the file breaking its own rule:
- sidecar_send no longer holds the slot mutex across the pipe write (a
  kernel that stops draining stdin filled the OS buffer and wedged kill and
  app exit behind the block). stdin is now Arc-shared: sends clone it under
  a brief slot lock and write on the blocking pool, with a fair writes
  mutex preserving frame order across concurrent invokes.
- The reader's EOF path takes its Running under the slot lock but WAITS
  outside it — stdout can close while the process lives on, and the slot
  must never be hostage to that wait.
- sidecar_kill is generation-targeted: a stale kill (handshake-timeout
  catch racing crash recovery) no-ops instead of reaping the freshly
  spawned replacement; the bridge passes the generation it owns.

GUI behavior:
- A kernel crash during session load no longer leaves a permanent,
  misleading "couldn't open" notice (the rejection microtask beat the
  client-change effect that would have invalidated its token; the handler
  now defers to the crash banner when the manager's client has moved on).
- Double-clicking a session row no longer errors with "already open in
  this client": same-target switches dedup before the load even starts.
- Chat is keyed by session (drafts stay per-tab via the drafts map), so a
  within-tab switch gets a fresh initial-scroll to the latest message.
- Diff rendering is memoized and transcript items are React.memo'd — the
  store reuses untouched item objects, so streaming stops re-running the
  quadratic LCS for every historical diff on every token.
- The dark palette is now reachable: the OS color scheme toggles the .dark
  class the theme was written against.

Duplication with drift risk:
- providerKeyStatuses (providers) is now the single home of the
  key-detection policy consumed by both the kernel's config/state and the
  TUI's /config rows; splitModelRef (client) is the single frontend home
  of the ref grammar, used by the GUI dialog and the TUI panel.
@hutusi

hutusi commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Final consolidated review round

Two things happened in this round:

1. Review of the externally contributed commit 82126b0 ("preserve lifecycle and profile ordering"): approved. Both fixes are real residual races — the webview-side exit-generation gate (a legitimate crash exit from an old kernel could reach a new webview's bridge and tear down its replacement; an own-generation exit racing the start invoke could be lost) and the kernel's per-session profile-mutation queue (rapid set_profile calls could apply out of arrival order across the settings-I/O await). Both are deterministically regression-tested; gates and live drills re-verified before this round built on top.

2. A 26-agent consolidated review of the branch's FINAL STATE (not diffs — the concurrency trio has been patched across four rounds by two agents, and layered diffs hide drift). 10 findings survived adversarial verification; all accepted and fixed in 7b77504:

Finding Fix
sidecar_send held the slot mutex across a blocking pipe write — a kernel that stops draining stdin wedged kill/app-exit behind a full pipe buffer stdin is Arc-shared: sends clone under a brief slot lock and write on the blocking pool; a fair writes mutex preserves frame order
Reader's EOF path called child.wait() under the slot lock, violating the file's own documented discipline take under the lock, wait outside it
Untargeted sidecar_kill could reap a replacement kernel spawned by crash recovery kills are generation-targeted; stale kills no-op
Crash during session load left a permanent, misleading "couldn't open" notice (rejection microtask beats the client-change effect) the handler defers to the crash banner when the manager's client has moved on
Double-click on a session row → "already open in this client" same-target switch dedup before the load starts
didInitialScroll survived within-tab session switches (transcript opened at a stale scroll position) Chat keyed by session id; drafts remain per-tab
Unmemoized quadratic diff recomputation on every streaming token useMemo on the LCS + React.memo on transcript items (the store reuses untouched item objects)
The entire dark palette + dark: variants were unreachable (no .dark class ever set) OS color scheme now toggles the class (matchMedia + listener)
Key-detection policy duplicated between the kernel's config/state and the TUI's /config rows providerKeyStatuses in @minerva/providers is the single home; both consume it
Model-ref grammar encoded in three places splitModelRef in @minerva/client is the single frontend home (GUI dialog + TUI panel); registry's validating parseModelRef stays canonical server-side

Gates: 550 tests, knip, clippy --all-targets -D warnings, packaged rebuild — green. Live drills re-run on the final state: kill -9 → single auto-respawn ✓; packaged Cmd+Q → graceful drain, no orphans ✓.

With five external review rounds plus this consolidated pass all dispositioned, the remaining pre-merge gate is the human smoke walk (CONTRIBUTING's GUI checklist).

Keep sidecar children reachable until stdout failures are reaped so retries cannot leave orphaned kernels. Reuse in-flight session loads across rapid A-B-A selections while preserving the user's latest intent and isolating client generations.

Add focused Rust and TypeScript regression coverage for both concurrency sequences.
@hutusi
hutusi merged commit 94aaef3 into main Jul 13, 2026
8 checks passed
@hutusi
hutusi deleted the feat/tauri-gui branch July 22, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant