fix(ai): start the sample loop on a fresh launch, and let the report say when AI didn't run - #147
Conversation
…say when AI didn't run (I79) Issue #92 screenshotted a Windows session report with "Focused-time —", "No focus score was recorded", no ai_* timeline rows — and "No distractions detected. Nice work." right beside all of it. Root cause: useModelStore is never hydrated outside Settings → AI. hydrate() had exactly one caller, ModelPickerContainer's mount effect, and that component lives only in the Settings → AI pane. useSettingsStore is hydrated at boot by ThemeProvider, so aiFeaturesEnabled was correctly true while activeModelId sat at its null initial value — and activeModelId gates everything: SessionView's loop effect returns early on !activeModelId (so startSampleLoop never runs and its no_active_model toast can never fire), and Home's topic-submit skips the V2-P9 gesture-context screen pre-acquire on the same condition, which on WebView2 is separately fatal. Any launch without a Settings → AI visit ran a whole session with AI silently dead. Cross-platform, present at HEAD; also explains #94. Fixes, in the order a session hits them: - hydrate the model store in Home's boot effect - pre-acquire the screen stream from every real gesture that (re)boots the loop: topic submit, Rejoin, camera/mic "Try again"; a store mid-hydration counts as "maybe active" - toast once per session when AI is on, the store is ready, and no model is active — the gap onStartFail could never cover - onStalled: one notice per loop lifetime after 3 consecutive unproductive ticks, with a distinct reason and actionable copy per cause. Every path it covers was console.warn-only, and release builds have no devtools. Paused states are not stalls. - derive the per-tick timeout from the model's benchmarked p95 (3x, floored at 90s, capped at benchmark.ts's 300s). A model could benchmark successfully — the only thing that sets activeModelId — and then abort every live tick. - bound the screen acquire at 120s so an unanswered picker is a retryable error, not a permanent boot() wedge that also strands the sidecar - map InvalidStateError/InvalidAccessError to screen_capture_denied, whose overlay retry is itself the missing user gesture - migration 004 records sessions.ai_enabled, and aiCoverage() gives the report four honest states instead of one shrug: ran (keeps "Nice work"), noChecks (names the malfunction), off, unknown (pre-004). Shared with the text export. - Rust: resolve_runtime_dir falls back to the binary's own directory rather than None (None meant no CWD and no PATH prepend — the state I75 fixed), and the crash-loop give-up path carries the VC++ redist hint, which a child dying in the Windows loader never reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…starts (I79) The V2-P9 gesture pre-acquire stashes a live screen MediaStream for boot() to consume, and the only release path was SessionView's unmount. Two paths reach a stash with no SessionView behind it, and this PR added one of them: - handleRejoin: joinSession() throws (bad stored credentials), the catch toasts, and status never flips to 'active'. - handleTopicSubmit + host: runHostInvite returns silently on its identity/display_name guard before hostSession() begins the session. Either way the OS screen-recording indicator stays lit with nothing behind it until the app quits. Discard on the rejoin failure, and don't pre-acquire at all when the host precondition already fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only on migration 004, whose hash is re-pinned in migrations.rs and re-manifested. Editing a .sql already in MANIFEST.sha256 is exactly what the forward-only guard blocks, and the escape hatch it documents applies here: 004 is new in this PR and has never shipped, so its manifest line was removed by hand before regenerating. The semantic worth stating: the column is the FINAL state of the AI toggle, not its state at session start. That is deliberate. The question it answers is "should the report explain an absence?", and aiCoverage() consults the sample counters first — so a session that recorded checks still reads as measured even if the user switched AI off before leaving, and only a session with nothing to show falls through to this column for its wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 65-agent verification sweep over the first draft confirmed the root cause (two independent verifiers, cross-platform, still-broken at HEAD) and found six more defects on the same path: - hydrate()'s 'error' status was terminal. ModelPickerContainer retried only on 'loading', so a single failed models.json read (AV lock, partial write) left activeModelId null for the whole process — reopening #92 through a narrower door that fix A itself widened. Gate is now status !== 'ready', and the session notice distinguishes "no model picked" from "model list unreadable". - The footer chip read "AI off" while AI was ON with no model. That was the only on-screen signal during #92 and it pointed at the wrong setting. New 'unconfigured' status behind a pure deriveAiChipStatus(), with 'loading' deliberately reading 'off' so no launch flashes a warning during hydration. - aiCoverage returned 'ran' for confident_samples 0 with skipped_samples k, on the argument that the #47 D5 data-quality line caveats it. It does not below SKIPPED_SAMPLES_MIN (3): for k of 1-2 the page rendered "Focused-time —", no caveat, and "No distractions detected. Nice work." — this issue's own defect surviving its own fix. Fifth state 'noConfident'; the two tests that asserted the old behaviour are edited, not appended to. - append_windows_dll_hint probed only vcruntime140.dll, so it stayed silent on a machine with the C runtime but not the C++ one. Requires both now. - next_attempts resets the restart streak whenever a child clears MIN_HEALTHY_UPTIME, so a sidecar dying every ~2.5 minutes crash-looped forever and never set errored: the stall notice fired once and the session then ran an hour on a dying engine. TOTAL_RESTART_BUDGET (12 per generation) closes it, sized so an 8-hour session dying hourly never trips while a 121s cycle trips at ~24 minutes. - Settings → Sessions marks an unmeasured row "not measured" when ai_enabled is 1, never inferring it for 0 or NULL. One finding rejected: "onSidecarErrored re-arms every tick, so a flapping sidecar re-toasts forever". errored is cleared only by sidecar_start/stop and the watcher returns after setting it, so errored→running requires deliberate user action — re-notifying then is correct, as the existing test documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedReview was skipped as selected files did not have any reviewable changes. 💤 Files selected but had no reviewable changes (1)
⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR adds nullable AI enablement tracking to sessions, introduces migration 4, improves sidecar and sampling-loop recovery, distinguishes unconfigured AI status, and makes reports and session metadata aware of AI coverage states. ChangesAI session and runtime behavior
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Home
participant ModelStore
participant SampleLoop
participant Sidecar
participant SessionView
Home->>ModelStore: hydrate model configuration
SessionView->>SampleLoop: start sampling with stall callback
SampleLoop->>Sidecar: check health and request inference
Sidecar-->>SampleLoop: result, error, or timeout
SampleLoop-->>SessionView: report stall reason
SessionView-->>SessionView: show toast and update AI status
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches✨ Simplify code
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/features/ai/sampleLoop.ts (1)
1008-1032: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winTeardown-triggered aborts/capture failures get misclassified as live stalls, firing callbacks after the loop already stopped.
teardownInternal()(invoked bystop(), called wheneverstatus/aiFeaturesEnabled/activeModelId/localStream/captureDeniedchange in SessionView, i.e. on leave, AI-toggle, model change, or camera loss) callsactiveAbort.abort()on any in-flight fetch. That abort surfaces insidetick()'s catch block (lines 1024-1029) as aDOMException/AbortError, which is now new code that unconditionally callsnoteUnproductiveTick('inference_timeout')— this can pushunproductiveTickstoSTALL_TICKSand fireopts.onStalledeven though the loop was told to stop moments earlier. Sincetoast.errorin SessionView'sonStalledhandler is a global singleton, the misleading "AI stalled" toast (andsetAiRuntimeStatus('error')) can still surface after the user has already left the session, toggled AI off, or switched models — precisely the kind of false signal issue#92was trying to eliminate.The same gap exists in
boot()'s catch around the newacquireScreenStreamBounded()(line 1210): ifstop()races the (now up to 2-minute) acquire wait,onCaptureError/onCaptureDeniedstill fire unconditionally in the catch (1211-1226) with nostate.stoppedcheck, even thoughteardownInternal()isn't called from this path — meaning if the loop was already torn down elsewhere for another reason mid-acquire, this can duplicate/contradict the teardown reason.Guard both catch blocks with an early
state.stoppedcheck so a teardown-in-flight abort/failure is silently discarded rather than reported as a new stall/capture error.🐛 Proposed fix
} catch (err) { + // I79 — teardownInternal() (via stop()) aborts any in-flight request. + // That abort is not a live stall — the loop is already being torn + // down — so it must not count toward the stall streak or fire + // onStalled/onCaptureError after teardown has started. + if (state.stopped) return if (err instanceof CaptureError) { if (err.code === 'screen_capture_denied') {Also applies to: 1208-1231
🤖 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 `@src/features/ai/sampleLoop.ts` around lines 1008 - 1032, Guard both the tick() catch block and the boot() catch around acquireScreenStreamBounded() with an immediate state.stopped check that returns before recording unproductive ticks or invoking capture/error callbacks. Preserve the existing handling for errors received while the loop remains active, including permission-denied behavior and inference-timeout reporting.
🤖 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 `@src/features/ai/ModelPickerContainer.tsx`:
- Around line 83-95: Update the hydration effect in ModelPickerContainer so
hydrate() runs once per picker mount instead of re-triggering when its own
status changes to loading or error. Remove status from the effect’s
retry-triggering logic while retaining the existing hydrate dependency and
non-ready initialization behavior, or introduce an explicit user/visit retry
trigger that cannot be caused by hydrate’s state transitions.
In `@src/features/session/aiChip.ts`:
- Around line 44-50: Update the status logic in the AI chip state function
around activeModelId, modelStatus, and hasLocalStream so enabled-but-not-ready
states never return “off”: return distinct statuses for model loading, model
errors, and missing local streams, while reserving “unconfigured” for genuinely
disabled or absent setup. Add tests covering each loading, error, and
missing-stream case.
---
Outside diff comments:
In `@src/features/ai/sampleLoop.ts`:
- Around line 1008-1032: Guard both the tick() catch block and the boot() catch
around acquireScreenStreamBounded() with an immediate state.stopped check that
returns before recording unproductive ticks or invoking capture/error callbacks.
Preserve the existing handling for errors received while the loop remains
active, including permission-denied behavior and inference-timeout reporting.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3e45511f-e034-41a6-b378-65d63aa36e3d
📒 Files selected for processing (36)
ISSUES.mdsrc-tauri/src/commands/sessions.rssrc-tauri/src/commands/sidecar.rssrc-tauri/src/db/migrations.rssrc-tauri/src/db/migrations/004_ai_enabled.sqlsrc-tauri/src/db/migrations/MANIFEST.sha256src-tauri/src/db/sessions.rssrc/components/AiStatusChip.tsxsrc/features/ai/ModelPickerContainer.tsxsrc/features/ai/captureScreen.tssrc/features/ai/focusStore.tssrc/features/ai/index.tssrc/features/ai/sampleLoop.tssrc/features/session/Report.tsxsrc/features/session/SessionView.tsxsrc/features/session/aiChip.tssrc/features/session/lifecycle.tssrc/features/session/reportData.tssrc/features/session/reportSerialize.tssrc/features/settings/categories/SessionsCategory.tsxsrc/lib/db/sessions.tssrc/routes/Home.tsxsrc/stories/AiStatusChip.stories.tsxsrc/stories/Dashboard.stories.tsxsrc/stories/FocusInsights.stories.tsxsrc/stories/Report.stories.tsxsrc/strings.tstests/unit/ai-chip-status.test.tstests/unit/ai-focus-store.test.tstests/unit/ai-models.test.tstests/unit/ai-sample-loop.test.tstests/unit/file-export.test.tstests/unit/report-data.test.tstests/unit/report-serialize.test.tstests/unit/stats-data.test.tstests/unit/stats-insights.test.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (7)
**/*
📄 CodeRabbit inference engine (CLAUDE.md)
**/*: Do not add telemetry or break compatibility with released local data, peer wire formats, or identity derivation.
Avoid unrelated refactoring, speculative abstractions, and comments unless the reason is non-obvious.
Use one focused change per commit and conventional-commit subjects such asfeat:,fix:,chore:,docs:, orci:.
Files:
ISSUES.mdsrc-tauri/src/db/migrations/004_ai_enabled.sqlsrc-tauri/src/db/migrations/MANIFEST.sha256src/stories/Dashboard.stories.tsxsrc/features/settings/categories/SessionsCategory.tsxsrc/features/ai/ModelPickerContainer.tsxtests/unit/file-export.test.tssrc/features/session/aiChip.tstests/unit/ai-chip-status.test.tstests/unit/stats-insights.test.tstests/unit/stats-data.test.tssrc/stories/AiStatusChip.stories.tsxsrc/features/ai/index.tstests/unit/ai-focus-store.test.tssrc/features/ai/captureScreen.tssrc/stories/FocusInsights.stories.tsxsrc/features/session/reportData.tssrc/features/session/lifecycle.tssrc-tauri/src/commands/sessions.rssrc/components/AiStatusChip.tsxsrc/routes/Home.tsxsrc/lib/db/sessions.tstests/unit/ai-models.test.tstests/unit/report-data.test.tssrc-tauri/src/db/migrations.rssrc/features/ai/focusStore.tssrc/features/session/reportSerialize.tstests/unit/report-serialize.test.tssrc-tauri/src/db/sessions.rssrc/strings.tssrc/features/session/Report.tsxtests/unit/ai-sample-loop.test.tssrc/features/session/SessionView.tsxsrc/stories/Report.stories.tsxsrc-tauri/src/commands/sidecar.rssrc/features/ai/sampleLoop.ts
src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
src/**/*.{ts,tsx}: Put toast and notification copy insrc/strings.ts; prefer centralized strings over inline user-facing copy.
Maintain WCAG AA contrast in both themes, do not convey information by color alone, and honor the global reduced-motion kill switch; new motion must be gated by default.
The application is local-only: never add telemetry, and never instruct users to share model files or BIP39 mnemonics with an AI service.
Files:
src/stories/Dashboard.stories.tsxsrc/features/settings/categories/SessionsCategory.tsxsrc/features/ai/ModelPickerContainer.tsxsrc/features/session/aiChip.tssrc/stories/AiStatusChip.stories.tsxsrc/features/ai/index.tssrc/features/ai/captureScreen.tssrc/stories/FocusInsights.stories.tsxsrc/features/session/reportData.tssrc/features/session/lifecycle.tssrc/components/AiStatusChip.tsxsrc/routes/Home.tsxsrc/lib/db/sessions.tssrc/features/ai/focusStore.tssrc/features/session/reportSerialize.tssrc/strings.tssrc/features/session/Report.tsxsrc/features/session/SessionView.tsxsrc/stories/Report.stories.tsxsrc/features/ai/sampleLoop.ts
**/*.tsx
📄 CodeRabbit inference engine (CLAUDE.md)
Provide a Storybook story for every component.
Files:
src/stories/Dashboard.stories.tsxsrc/features/settings/categories/SessionsCategory.tsxsrc/features/ai/ModelPickerContainer.tsxsrc/stories/AiStatusChip.stories.tsxsrc/stories/FocusInsights.stories.tsxsrc/components/AiStatusChip.tsxsrc/routes/Home.tsxsrc/features/session/Report.tsxsrc/features/session/SessionView.tsxsrc/stories/Report.stories.tsx
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use TypeScript strict mode and ensure the code passes the TypeScript build and type checks.
Files:
src/stories/Dashboard.stories.tsxsrc/features/settings/categories/SessionsCategory.tsxsrc/features/ai/ModelPickerContainer.tsxtests/unit/file-export.test.tssrc/features/session/aiChip.tstests/unit/ai-chip-status.test.tstests/unit/stats-insights.test.tstests/unit/stats-data.test.tssrc/stories/AiStatusChip.stories.tsxsrc/features/ai/index.tstests/unit/ai-focus-store.test.tssrc/features/ai/captureScreen.tssrc/stories/FocusInsights.stories.tsxsrc/features/session/reportData.tssrc/features/session/lifecycle.tssrc/components/AiStatusChip.tsxsrc/routes/Home.tsxsrc/lib/db/sessions.tstests/unit/ai-models.test.tstests/unit/report-data.test.tssrc/features/ai/focusStore.tssrc/features/session/reportSerialize.tstests/unit/report-serialize.test.tssrc/strings.tssrc/features/session/Report.tsxtests/unit/ai-sample-loop.test.tssrc/features/session/SessionView.tsxsrc/stories/Report.stories.tsxsrc/features/ai/sampleLoop.ts
src-tauri/**/*.{rs,toml}
📄 CodeRabbit inference engine (CLAUDE.md)
Treat peer wire formats and identity derivation as cross-version contracts; coordinate changes so older peers and existing stored data remain compatible.
Files:
src-tauri/src/commands/sessions.rssrc-tauri/src/db/migrations.rssrc-tauri/src/db/sessions.rssrc-tauri/src/commands/sidecar.rs
src-tauri/**/*.rs
📄 CodeRabbit inference engine (CLAUDE.md)
Rust changes must pass
cargo test,cargo fmt --check, andcargo clippy.
Files:
src-tauri/src/commands/sessions.rssrc-tauri/src/db/migrations.rssrc-tauri/src/db/sessions.rssrc-tauri/src/commands/sidecar.rs
src/components/**/*.tsx
📄 CodeRabbit inference engine (CLAUDE.md)
Components under
src/components/must compose fromui/,design/, and shared utilities, and must not reverse-import application components into the primitive layer.
Files:
src/components/AiStatusChip.tsx
🔇 Additional comments (41)
ISSUES.md (1)
13-93: LGTM!src/stories/Dashboard.stories.tsx (1)
48-48: LGTM!tests/unit/ai-chip-status.test.ts (1)
1-83: LGTM!src/stories/FocusInsights.stories.tsx (1)
45-45: LGTM!src-tauri/src/db/migrations/004_ai_enabled.sql (1)
1-24: LGTM!tests/unit/file-export.test.ts (1)
79-94: LGTM!tests/unit/stats-data.test.ts (1)
36-52: LGTM!src/features/ai/captureScreen.ts (1)
204-216: LGTM!src/features/ai/index.ts (1)
283-312: LGTM!tests/unit/ai-models.test.ts (1)
329-376: LGTM!tests/unit/ai-sample-loop.test.ts (1)
18-22: LGTM!Also applies to: 891-1046, 1600-1626
src/stories/AiStatusChip.stories.tsx (1)
16-18: LGTM!Also applies to: 23-31
src/features/settings/categories/SessionsCategory.tsx (1)
200-209: LGTM!tests/unit/report-data.test.ts (1)
9-9: LGTM!Also applies to: 350-432
src-tauri/src/db/migrations.rs (1)
16-22: LGTM!Also applies to: 115-115, 240-285, 350-353
src-tauri/src/db/migrations/MANIFEST.sha256 (1)
10-10: LGTM!src-tauri/src/db/sessions.rs (1)
36-64: LGTM!Also applies to: 74-91, 114-140, 232-232, 288-288, 376-376, 391-391, 419-419
src-tauri/src/commands/sessions.rs (1)
32-50: LGTM!src/lib/db/sessions.ts (1)
29-33: LGTM!Also applies to: 51-51, 67-67
src/features/ai/focusStore.ts (1)
176-194: LGTM!src/features/session/lifecycle.ts (1)
312-312: LGTM!tests/unit/ai-focus-store.test.ts (1)
18-18: LGTM!Also applies to: 287-323
tests/unit/stats-insights.test.ts (1)
36-36: LGTM!src-tauri/src/commands/sidecar.rs (1)
81-94: LGTM!Also applies to: 495-509, 537-551, 658-665, 678-679, 726-755, 867-898
src/features/ai/sampleLoop.ts (4)
82-104: LGTM!Also applies to: 140-151
360-383: LGTM!Also applies to: 432-444, 498-533, 796-807
878-884: LGTM!Also applies to: 914-918, 938-938, 965-965
1078-1124: LGTM! The bounded acquire wrapper correctly races against the deadline, clears its timer, and stops a late-arriving stream to avoid a leaked OS recording indicator.Also applies to: 1210-1210, 1276-1276
src/components/AiStatusChip.tsx (1)
7-37: LGTM!src/features/session/SessionView.tsx (3)
77-77: LGTM!Also applies to: 188-190, 887-915
1021-1038: LGTM!
1399-1412: LGTM!Also applies to: 1475-1485
src/routes/Home.tsx (2)
28-32: LGTM!Also applies to: 126-141
282-302: LGTM!Also applies to: 344-360, 370-370
src/features/session/reportData.ts (1)
38-80: LGTM! The five-state classification and its precedence (score first, for pre-003 rows) is correctly implemented and matches the referenced test cases.src/features/session/reportSerialize.ts (1)
17-24: LGTM!Also applies to: 74-106, 126-126, 140-142, 177-177
src/features/session/Report.tsx (1)
58-73: LGTM!Also applies to: 284-288, 472-472, 526-526, 603-607, 622-622
src/strings.ts (1)
604-606: LGTM! New copy is correctly centralized here and keys line up exactly with theSampleLoopStallReasonandAiCoverageunions used at the call sites.Also applies to: 646-665, 776-785, 805-816, 1065-1069
Source: Coding guidelines
src/stories/Report.stories.tsx (1)
53-53: LGTM! Each new story's field combination correctly exercises the intendedAiCoveragebranch.Also applies to: 202-206, 228-255, 257-279, 281-305
tests/unit/report-serialize.test.ts (1)
50-50: LGTM! Good coverage of all the coverage-state permutations, including the pre-003 (ai_enabled: null) cause-neutral case.Also applies to: 128-203
src/features/session/aiChip.ts (1)
14-43: LGTM!
| // Hydrate persistent records once on mount. | ||
| // | ||
| // I79 — retry on `'error'` too, not only `'loading'`. A LazyStore read of | ||
| // models.json can fail transiently (an AV scanner holding the file, a | ||
| // partial write from a previous run), and `status: 'error'` used to be | ||
| // terminal for the whole process: `activeModelId` stayed null, so no AI ran | ||
| // for any session until the app was restarted, and this — the one surface | ||
| // that could re-read it — refused to try. `hydrate()` early-returns only on | ||
| // `'ready'`, and this effect's deps re-run it on a status CHANGE rather than | ||
| // in a loop, so a persistent failure costs one attempt per visit here. | ||
| useEffect(() => { | ||
| if (status === 'loading') void hydrate() | ||
| if (status !== 'ready') void hydrate() | ||
| }, [status, hydrate]) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Avoid retrying hydration from its own loading transition.
When hydrate() changes error to loading, Line 94 invokes it again because loading !== 'ready'. Since hydrate() only short-circuits at ready, a persistent failure can create overlapping reads and an error/loading retry loop, rather than one retry per picker visit. Run hydration once per mount, or use an explicit retry trigger.
Proposed fix
useEffect(() => {
- if (status !== 'ready') void hydrate()
-}, [status, hydrate])
+ void hydrate()
+}, [hydrate])#!/usr/bin/env bash
set -euo pipefail
# Locate and inspect the model-store hydration state transitions.
rg -n -C 6 --glob '*.{ts,tsx}' \
'hydrate\s*[:=]|status:\s*["'\'']loading["'\'']|status:\s*["'\'']error["'\'']' src
# Map candidate exports before inspecting the hydrate implementation.
ast-grep outline src --items all --type function --match hydrate🤖 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 `@src/features/ai/ModelPickerContainer.tsx` around lines 83 - 95, Update the
hydration effect in ModelPickerContainer so hydrate() runs once per picker mount
instead of re-triggering when its own status changes to loading or error. Remove
status from the effect’s retry-triggering logic while retaining the existing
hydrate dependency and non-ready initialization behavior, or introduce an
explicit user/visit retry trigger that cannot be caused by hydrate’s state
transitions.
| if (!activeModelId) { | ||
| return modelStatus === 'loading' ? 'off' : 'unconfigured' | ||
| } | ||
| // Camera still spinning up (or off). Reads as 'off' rather than | ||
| // 'unconfigured' — nothing is missing from the user's setup, and the camera | ||
| // tile plus MediaErrorBanner already own that story. | ||
| if (!hasLocalStream) return 'off' |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Do not map enabled-but-not-ready states to off.
When AI is enabled, Lines 45 and 50 still return off during model hydration and capture startup/recovery, so the chip reports “AI off” even though the user enabled AI. Additionally, modelStatus === 'error' is misclassified as unconfigured. Use distinct non-off statuses for loading, model errors, and missing streams, and cover each case with tests.
🤖 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 `@src/features/session/aiChip.ts` around lines 44 - 50, Update the status logic
in the AI chip state function around activeModelId, modelStatus, and
hasLocalStream so enabled-but-not-ready states never return “off”: return
distinct statuses for model loading, model errors, and missing local streams,
while reserving “unconfigured” for genuinely disabled or absent setup. Add tests
covering each loading, error, and missing-stream case.
main moved 53 files while this branch was in flight, and took I79 for the macOS display-capture fix — which also touches sampleLoop.ts. Only ISSUES.md conflicted textually (main reformatted the whole table); every code file auto-merged. Resolution: take main's table wholesale and re-insert this branch's row as I83, after main's I81 and clear of PR #146's claimed I82. All 74 in-code I79 tags across 27 files renumbered with it, so the comment tags still point at a real ledger row. Migration 004's bytes changed with its comment tag, so its hash is re-pinned and re-manifested — again via the documented never-shipped escape hatch, since 004 is new in this PR. 942 vitest + 77 cargo tests pass; build, lint, tokens, strings, migrations, stories, contrast, and cargo fmt all green against the merged tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge from main brought prettier 3.9.5 into package-lock.json, but this working tree still had 3.8.3 installed from the pre-merge lockfile. Every `npm run format` I ran after the merge therefore rewrote files — including nine that belong to main and that this PR never meant to touch — in 3.8.3 style, and CI's Frontend job failed its Format check against 3.9.5. `npm ci` + `npm run format` restores all nine to byte-identical with main and formats the two files this PR does own. The diff against main is now exactly the 36 intended files. Worth remembering: `format:check` compares against the LOCKFILE's prettier, so a stale node_modules turns a formatting pass into a formatting regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the two sampleLoop.ts conflicts between #98's structured logger and I83's stall tracking: keep main's log.warn call sites, keep this branch's noteUnproductiveTick() calls. The inference.aborted line now reports tickTimeoutMs (the p95-derived bound I83 introduced) — main's requestTimeoutMs binding no longer exists on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
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)
src/features/ai/captureScreen.ts (1)
204-216: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winHandle non-transient-activation
InvalidStateErrorseparately.
getDisplayMedia()can also reject withInvalidStateErrorfor an inactive/unfocused document or a reusedCaptureController. These failures should not route toscreen_capture_denied’s permission-recovery overlay, which presents permission/Open-Settings guidance plus a retry for the missing gesture instead of the actual state recovery.🤖 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 `@src/features/ai/captureScreen.ts` around lines 204 - 216, The InvalidStateError branch in the capture error handling must distinguish transient-activation failures from inactive/unfocused documents or reused CaptureController errors. Route non-transient-activation InvalidStateError cases through the existing unavailable/state-recovery path, while preserving the denied recovery overlay only for genuine missing-gesture failures; keep InvalidAccessError behavior unchanged unless needed for this distinction.
🤖 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 `@ISSUES.md`:
- Line 96: Update the issue entry’s coverage description to state that
aiCoverage() has five states, including noConfident, and change the test
description from “pre-003 scored row” to “pre-004 scored row” to match migration
004’s boundary.
---
Outside diff comments:
In `@src/features/ai/captureScreen.ts`:
- Around line 204-216: The InvalidStateError branch in the capture error
handling must distinguish transient-activation failures from inactive/unfocused
documents or reused CaptureController errors. Route non-transient-activation
InvalidStateError cases through the existing unavailable/state-recovery path,
while preserving the denied recovery overlay only for genuine missing-gesture
failures; keep InvalidAccessError behavior unchanged unless needed for this
distinction.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 28f8dbc0-3db5-4613-9016-9c8a23a022ce
📒 Files selected for processing (31)
ISSUES.mdsrc-tauri/src/commands/sessions.rssrc-tauri/src/commands/sidecar.rssrc-tauri/src/db/migrations.rssrc-tauri/src/db/migrations/004_ai_enabled.sqlsrc-tauri/src/db/migrations/MANIFEST.sha256src-tauri/src/db/sessions.rssrc/components/AiStatusChip.tsxsrc/features/ai/ModelPickerContainer.tsxsrc/features/ai/captureScreen.tssrc/features/ai/focusStore.tssrc/features/ai/index.tssrc/features/ai/sampleLoop.tssrc/features/session/Report.tsxsrc/features/session/SessionView.tsxsrc/features/session/aiChip.tssrc/features/session/lifecycle.tssrc/features/session/reportData.tssrc/features/session/reportSerialize.tssrc/features/settings/categories/SessionsCategory.tsxsrc/lib/db/sessions.tssrc/routes/Home.tsxsrc/stories/AiStatusChip.stories.tsxsrc/stories/Report.stories.tsxsrc/strings.tstests/unit/ai-chip-status.test.tstests/unit/ai-focus-store.test.tstests/unit/ai-models.test.tstests/unit/ai-sample-loop.test.tstests/unit/report-data.test.tstests/unit/report-serialize.test.ts
🚧 Files skipped from review as they are similar to previous changes (29)
- src-tauri/src/db/migrations/MANIFEST.sha256
- src-tauri/src/db/migrations/004_ai_enabled.sql
- tests/unit/ai-chip-status.test.ts
- src/features/ai/index.ts
- src/components/AiStatusChip.tsx
- src/features/ai/ModelPickerContainer.tsx
- src/stories/AiStatusChip.stories.tsx
- src-tauri/src/commands/sessions.rs
- src/features/session/lifecycle.ts
- tests/unit/ai-focus-store.test.ts
- src/features/settings/categories/SessionsCategory.tsx
- src/features/session/aiChip.ts
- tests/unit/ai-models.test.ts
- tests/unit/report-data.test.ts
- src/routes/Home.tsx
- tests/unit/report-serialize.test.ts
- src/strings.ts
- src-tauri/src/db/migrations.rs
- src/lib/db/sessions.ts
- src-tauri/src/db/sessions.rs
- src/features/session/reportData.ts
- src/features/ai/focusStore.ts
- src/features/session/Report.tsx
- src/stories/Report.stories.tsx
- src/features/session/reportSerialize.ts
- src/features/session/SessionView.tsx
- tests/unit/ai-sample-loop.test.ts
- src-tauri/src/commands/sidecar.rs
- src/features/ai/sampleLoop.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (6)
- GitHub Check: Installer (Windows)
- GitHub Check: Installer (macOS)
- GitHub Check: Analyze (rust)
- GitHub Check: Rust (Windows)
- GitHub Check: Frontend
- GitHub Check: Rust (macOS)
🧰 Additional context used
📓 Path-based instructions (3)
**/*
📄 CodeRabbit inference engine (CLAUDE.md)
**/*: Do not add telemetry or break compatibility with released local data, peer wire formats, or identity derivation.
Avoid unrelated refactoring, speculative abstractions, and comments unless the reason is non-obvious.
Use one focused change per commit and conventional-commit subjects such asfeat:,fix:,chore:,docs:, orci:.
Files:
ISSUES.mdsrc/features/ai/captureScreen.ts
src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
src/**/*.{ts,tsx}: Put toast and notification copy insrc/strings.ts; prefer centralized strings over inline user-facing copy.
Maintain WCAG AA contrast in both themes, do not convey information by color alone, and honor the global reduced-motion kill switch; new motion must be gated by default.
The application is local-only: never add telemetry, and never instruct users to share model files or BIP39 mnemonics with an AI service.
Files:
src/features/ai/captureScreen.ts
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use TypeScript strict mode and ensure the code passes the TypeScript build and type checks.
Files:
src/features/ai/captureScreen.ts
🔇 Additional comments (1)
ISSUES.md (1)
93-95: LGTM!
| | I69 | Sev3 | `src-tauri/src/lib.rs` | The corrupt-DB recovery dialog asserted re-pairing was required and never mentioned the friends-backup import — wrong at the exact moment a friend loses their list. | **fixed (copy)** — the dialog now names Settings → Identity → Import friends as the restore path if a backup exists, otherwise re-pair. | | ||
| | I70 | Sev4 | `.github/workflows/release.yml` | A half-built draft (one platform's artifact missing from `latest.json`) could be published, stranding every friend on the missing platform with no update path and a false "you're on the latest". | **fixed** — a job asserts both platforms are present in the draft's `latest.json` and, on failure, stamps the draft title "INCOMPLETE, DO NOT PUBLISH" (needs `contents: write` to read a draft). Not runnable on this box; validated by YAML parse + reading. | | ||
| | I71 | Sev2 | `src/features/updater/updaterStore.ts` + `src-tauri/src/commands/system.rs` | Issue #77: an app opened straight from the mounted `.dmg` runs under macOS App Translocation (read-only bundle), where `update.install()`'s rename-into-place can never succeed — every launch re-downloaded the installer, offered "Restart now", and failed with the generic install toast. The one documented install step (drag to Applications) is exactly the one this path skipped, and the updater had no idea. | **fixed** — new `system_install_context` command (translocation via exe-path component, read-only volume via `statfs`; fail-open) consulted after a check finds an update: an unswappable bundle sets a new process-permanent `blocked` status _before_ any bytes move, and the banner + Settings → About replace the doomed Restart with move-to-Applications guidance. Verified live: dev binary on a read-only DMG against the real v1.7.0 release showed the blocked row. Windows/NSIS unaffected (always updatable). | | ||
| | I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the final-state coverage description.
This entry first says aiCoverage() has four states, but later records the addition of a fifth (noConfident). It also says “pre-003 scored row” even though migration 004 is the boundary described here. Update both references to match the final implementation.
Suggested wording changes
- gives the report four honest states
+ gives the report five honest states
- the pre-003 scored row
+ the pre-004 scored row📝 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.
| | I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. | |
| | I83 | Sev1 | `src/features/ai/modelStore.ts` + `src/routes/Home.tsx` + `src/features/session/SessionView.tsx` + `sampleLoop.ts` + `Report.tsx` | Issue `#92`: a real 10-minute two-person session on Windows rendered a report with `Focused-time —`, "No focus score was recorded for this session.", zero `ai_*` timeline rows — and, directly beside all that, "No distractions detected. Nice work." **Root cause: `useModelStore` is never hydrated outside Settings → AI.** `hydrate()` had exactly one caller, `ModelPickerContainer`'s mount effect (`ModelPickerContainer.tsx:85`), and that component mounts only inside the Settings → AI pane. `useSettingsStore` is hydrated at boot by `ThemeProvider` (`src/design/theme.tsx:52`), so `aiFeaturesEnabled` was correctly `true` while `activeModelId` sat at its `null` initial value — and `activeModelId` gates everything: `SessionView.tsx`'s sample-loop effect returns early on `if (!activeModelId)`, so `startSampleLoop` is never called and its `onStartFail('no_active_model')` toast — the one surface that names this — can never fire; `Home.tsx`'s `handleTopicSubmit` skips the V2-P9 gesture-context `preacquireScreenStream()` on the same condition, which on WebView2 is separately fatal. So any launch where the user didn't happen to open Settings → AI ran a whole session with AI silently dead: no loop, no toast, no audit row, no log line, and an unscored `sessions` row. Cross-platform and present at HEAD — it also explains `#94` ("Ai does not work on macos when its enabled"). The report then made the silence permanent: `score`/`focused_pct`/`confident_samples`/`skipped_samples` all read NULL for an AI-off session, an AI-on-but-dead session, AND a pre-004 row, so no surface could tell a deliberate choice from a malfunction, and the distractions empty state asserted a clean measurement that never happened. Five further silent-death paths found alongside it: a sidecar that spawns but never reports healthy, an HTTP error from the sidecar, a per-tick abort, and any other tick throw were each `console.warn`-only (no devtools in release builds); the live 90 s per-tick timeout was 3.3× tighter than benchmark.ts's 300 s bound, so a model could benchmark successfully — the only thing that sets `activeModelId` — and then abort every live inference forever; an unanswered screen-share picker wedged `boot()` with no timeout, and `stop()` awaits `bootPromise`, so the sidecar was never killed; the Rejoin path and the camera/mic "Try again" path both re-`boot()` with no gesture pre-acquire; `mapDisplayMediaError` had no `InvalidStateError`/`InvalidAccessError` case, so a missing-transient-activation refusal was filed as `unavailable` (a dead-end toast) instead of reaching the recovery overlay whose retry button IS a gesture; `resolve_runtime_dir`'s `_ => Ok(None)` still degraded to a spawn with no CWD and no PATH prepend — the exact lethal-on-Windows state I75 fixed; and a child that dies in the Windows loader spawns Ok, so it crash-loops to the restart budget without ever reaching the VC++-redist hint. | **fixed** — (1) hydrate `useModelStore` in `Home.tsx`'s boot effect, so the persisted model is the truth from launch rather than from a Settings visit; (2) `handleTopicSubmit` + `handleRejoin` + `handleMediaRetry` all pre-acquire the screen stream inside their real user gesture, and a store still mid-hydration counts as "maybe active" (an unconsumed stream is discarded on unmount; a missed pre-acquire is fatal on WebView2); (3) a once-per-session toast when AI is on, the model store is `ready`, and no model is active — the gap where `onStartFail` could never fire; (4) `onStalled` fires once per loop lifetime after `STALL_TICKS` (3) consecutive unproductive ticks, with a distinct reason per cause (`engine_unavailable` / `engine_error` / `inference_timeout` / `unknown`) and actionable copy; paused states (break, camera off, pomodoro rest, battery) are deliberately not stalls; (5) the per-tick timeout is derived from the model's benchmarked p95 (`effectiveRequestTimeoutMs`: 3× p95, floored at 90 s, capped at benchmark.ts's 300 s); (6) `SCREEN_ACQUIRE_TIMEOUT_MS` (120 s) bounds the acquire so an unanswered picker becomes a visible retryable error instead of a permanent wedge, and a late-arriving stream is stopped rather than leaked; (7) `InvalidStateError` / `InvalidAccessError` → `screen_capture_denied`, routing to the overlay whose retry is itself the missing gesture; (8) migration **004** adds `sessions.ai_enabled` (1/0, NULL = pre-004), written from live settings at teardown, and the new `aiCoverage()` derivation gives the report five honest states — `ran` keeps the earned "Nice work", `noChecks` names the malfunction and points at Settings → AI, `off` says AI was off, `unknown` stays cause-neutral for pre-004 rows — shared by the rendered report and the text export so a pasted copy can never disagree; (9) Rust: `resolve_runtime_dir` falls back to the binary's own directory (one of the two places ggml globs anyway) instead of `None`, and the crash-loop give-up path now carries `append_windows_dll_hint`. Tests: `aiCoverage` (6 cases incl. the pre-004 scored row and the NULL-is-not-0 rule), serializer honesty (4), `snapshotFocusForReport.aiEnabled` (3), stall notice (4 incl. streak-reset and camera-off-is-not-a-stall), `effectiveRequestTimeoutMs` boundaries (4), and a Rust 003→004 upgrade test asserting old rows read NULL. Stories: `AiOnButNoChecks`, `AiOffForSession`. **Round 2** (a 65-agent adversarial sweep over the first draft found six more): (10) `hydrate()`'s `status: 'error'` was terminal — `ModelPickerContainer` only retried on `'loading'`, so one failed models.json read (AV lock, partial write) killed AI for the whole process and reopened this very issue through a narrower door; the gate is now `status !== 'ready'` and the session notice distinguishes it (`modelListUnreadable`). (11) the footer chip read **"AI off" while AI was ON** with no model — the single on-screen signal during `#92`, pointing at exactly the wrong setting; new `'unconfigured'` status via a pure, unit-tested `deriveAiChipStatus()`, with `'loading'` deliberately reading `'off'` so no launch flashes it. (12) `aiCoverage`'s first cut returned `'ran'` for `confident_samples: 0, skipped_samples: k`, defended on the grounds that the `#47` D5 line caveats it — it does not below `SKIPPED_SAMPLES_MIN` (3), so k of 1–2 rendered a fabricated all-clear with no caveat at all; fifth state `'noConfident'` added and the two tests asserting the old behavior **edited**, not appended. (13) `append_windows_dll_hint` probed only `vcruntime140.dll`, staying silent on a box with the C runtime but not the C++ one; now requires both. (14) `next_attempts` resets the streak on any child clearing `MIN_HEALTHY_UPTIME` (120 s), so a sidecar dying every ~2.5 min crash-looped **forever** without ever setting `errored` — the stall notice fired once and the session then ran for an hour on a dying engine; `TOTAL_RESTART_BUDGET` (12 per generation) closes it, sized so an 8-hour session dying hourly never trips while a 121 s cycle trips at ~24 min. (15) Settings → Sessions now marks an unmeasured row `not measured` when `ai_enabled === 1`. One round-2 finding was **rejected**: "`onSidecarErrored` re-arms every tick, so a flapping sidecar re-toasts forever" — `errored` is cleared only by `sidecar_start`/`sidecar_stop` (`sidecar.rs:233`/`:270`) and the watcher `return`s after setting it, so errored→running requires deliberate user action and re-notifying then is correct, as the existing test documents. | |
🤖 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 `@ISSUES.md` at line 96, Update the issue entry’s coverage description to state
that aiCoverage() has five states, including noConfident, and change the test
description from “pre-003 scored row” to “pre-004 scored row” to match migration
004’s boundary.
Round 2 added a fifth coverage state (noConfident) and grew the aiCoverage/serializer suites, but the ledger entry still described the first draft's four states and 6/4 case counts. Keeps 'pre-003 scored row' as-is: that case is about migration 003's sample counters, not 004's ai_enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#147 (I83) and this branch (I82) independently detect "AI stopped producing readings" by different mechanisms, and they cannot both ship: they share the `stallReported` latch, so a naive resolution silently suppressed I82's audit row and re-armed I83's once-per-lifetime toast. I82's wall-clock watchdog survives; I83's consecutive-tick counter is deleted, for three reasons verified against the code: 1. Cold-start false positive, live on main. boot() schedules the first tick immediately and its own comment says the first few "gracefully skip" while llama-server loads the model — sidecar.rs starts with healthy:false and only an async /health poll flips it. I83 counted those skips, so at the 5 s fallback cadence a perfectly healthy CPU-only Windows session toasted "The AI engine isn't responding" and pinned the chip to 'AI error' ~15-30 s in, with no resume path. 2. Worst-case latency. I83 itself raised the per-tick bound to clamp(3 x p95, 90 s, 300 s), so three consecutive timeouts is 270-900 s before the user hears anything, against I82's <=135 s. 3. Coverage. I83 never reported camera_missing, screen_lost or capture_failing at all. Kept from I83 where it was better: the structured log call sites from #98 (console.warn is now an ESLint error), the p95-derived tick timeout, the "vision files may be incomplete" copy (now aiNoReading.inference_failed), and the four report stories. Dropped strings.session.errors.aiStalled, which auto-merged into a silent duplicate of aiNoReading. Two fixes on top of a straight resolution: - The chip now reads 'error' for engine-side blockers and 'paused' only for the input-absent ones, matching onStartFail/onSidecarErrored. A flat 'paused' made the chip *less* alarming as an outage got longer. - console.error in the ai_stalled append path went through the logger; it was outside the conflict and would have failed no-console. Tests: 1011 pass. I83's 4 stall tests are deleted (3 redundant with I82's 12); its intermittent-failure property is ported onto the watchdog, where it is strictly harder to satisfy. The full-timeout test now pins requestTimeoutMs explicitly instead of relying on effectiveRequestTimeoutMs(0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #92. Very likely fixes #94 too (same root cause, different platform) — worth a re-test before closing that one.
The report in #92 was telling the truth. Nothing had measured anything.
useModelStoreis never hydrated outside Settings → AI.hydrate()had exactly one caller —ModelPickerContainer's mount effect (ModelPickerContainer.tsx:85) — and that component mounts only inside the Settings → AI pane.useSettingsStoreis hydrated at boot, byThemeProvider(src/design/theme.tsx:52). So on a fresh launchaiFeaturesEnabledread correctly astruewhileactiveModelIdsat at itsnullinitial value. AndactiveModelIdgates the entire pipeline:SessionView.tsx's sample-loop effect returns early onif (!activeModelId)— sostartSampleLoopis never called, and itsonStartFail('no_active_model')toast, the one surface that names this problem, can never fire.Home.tsx'shandleTopicSubmitskipped the V2-P9 gesture-contextpreacquireScreenStream()on the same condition — separately fatal on WebView2.So any launch where the user didn't happen to open Settings → AI ran a full session with AI silently dead: no loop, no toast, no audit row, no log line, and an unscored
sessionsrow. Cross-platform, present at HEAD. The user in #92 had a configured, benchmarked model; the app just never looked at it.Then the report made the silence permanent.
score,focused_pct,confident_samplesandskipped_samplesall read NULL for an AI-off session, an AI-on-but-dead session and a row predating the 003 counters — so no surface could tell a deliberate choice from a malfunction, and the distractions section asserted a clean measurement that never happened:What changed
The loop actually starts
useModelStoreinHome's boot effect.ready, and no model is active. That is the gaponStartFailcould never cover, because the loop it belongs to was never constructed.Failures stop being silent — every path below was
console.warn-only, and release builds have no devtoolsonStalledfires once per loop lifetime after 3 consecutive unproductive ticks, with a distinct reason and actionable copy per cause (engine_unavailable/engine_error/inference_timeout/unknown), and flips the AI chip toerrorso the state outlives the toast. Paused states — break, camera off, pomodoro rest, battery — are deliberately not stalls.effectiveRequestTimeoutMs: 3× p95, floored at the old 90 s, capped at benchmark.ts's own 300 s). The flat 90 s was 3.3× tighter than the benchmark's bound, so a model could benchmark successfully — the only thing that setsactiveModelId— and then abort every live inference, forever.SCREEN_ACQUIRE_TIMEOUT_MS(120 s) bounds the acquire.getDisplayMedianever times out on its own, so an unanswered picker wedgedboot()permanently — and sincestop()awaitsbootPromise, the sidecar it had already started was never killed either. A late-arriving stream is stopped rather than leaked.InvalidStateError/InvalidAccessError(missing transient activation — Chromium and WebKit respectively) now map toscreen_capture_denied, which mounts the recovery overlay. Its "Try again" button is itself the user gesture the call was missing, so the retry genuinely works; the old classification produced a dead-end toast carrying a raw DOMException string.The report can say what happened — migration 004 adds
sessions.ai_enabledaiCoverage()gives five honest states:rankeeps the earned "Nice work",noConfidentcovers checks that ran but couldn't be read (see round 2),noChecksnames the malfunction and points at Settings → AI,offsays AI was off,unknownstays cause-neutral for old rows.Rust sidecar diagnostics
resolve_runtime_dirfalls back to the binary's own directory instead ofNone.Nonemeant spawning with no working directory and no PATH prepend — the exact lethal-on-Windows state I75 fixed, still reachable on any resource-path miss. The exe's own directory is one of the two placesggml_backend_load_bestglobs anyway, so the fallback is never worse.append_windows_dll_hint. A child that dies inside the Windows loader spawns successfully (CreateProcess returns before the DLL resolution that kills it), so it never reached the spawn-failure path where that hint lived — it crash-looped to the restart budget and surfaced an exit code the user could do nothing with.Round 2: what an adversarial sweep found in the first draft
The first three commits were then verified by a 65-agent sweep (two independent checkers per finding). It confirmed the root cause above — both checkers,
produces_screenshot: yes,still-brokenat HEAD — and found six more defects on the same path, all fixed in the fourth commit:hydrate()'s'error'status was terminal.ModelPickerContainerretried only on'loading', so one failedmodels.jsonread (AV lock, partial write) leftactiveModelIdnull for the whole process — reopening this very issue through a narrower door that the hydration fix itself widened. The gate is nowstatus !== 'ready', and the session notice distinguishes "no model picked" from "model list unreadable".'unconfigured'status ("AI needs a model") behind a pure, unit-testedderiveAiChipStatus().'loading'deliberately still reads'off', or every launch would flash a warning during the hydration window.aiCoveragereturned'ran'for a session where no check was readable. I had defended this in a comment and a test, arguing the Project analysis (post-v1.3.1): verified improvement backlog — 4 real bugs, 22 improvements #47 D5 data-quality line caveats it. It does not: that line needsSKIPPED_SAMPLES_MIN(3) skips before it renders anything, so for 1–2 unreadable checks the page showed "Focused-time —", no caveat at all, and "No distractions detected. Nice work." — this issue's own defect surviving its own fix. Added a fifth state'noConfident', and edited the two tests that asserted the old behaviour rather than appending around them.append_windows_dll_hintprobed onlyvcruntime140.dll.llama-server.exealso links the C++ standard library, and a machine can carry one without the other, so the actionable hint stayed silent on some of the boxes that needed it. Requires both now.next_attemptsresets the streak whenever a child clearsMIN_HEALTHY_UPTIME(120 s), so a sidecar dying every ~2.5 minutes crash-looped forever without ever settingerrored: the stall notice fired once and the session then ran for an hour on a dying engine.TOTAL_RESTART_BUDGET(12 per generation) closes it, sized so an 8-hour session dying hourly never trips while a 121-second cycle trips at ~24 minutes.not measuredwhenai_enabled === 1— the second place a user looks for a missing number. Never inferred for0orNULL.One finding I rejected: "
onSidecarErroredre-arms every tick, so a flapping sidecar re-toasts forever."erroredis cleared only bysidecar_start/sidecar_stop(sidecar.rs:233/:270) and the watcherreturns after setting it, soerrored → runningrequires deliberate user action. Re-notifying after the user restarts and it fails again is correct, and the existing test documents that as intended.Testing
npm run build/lint/test(903 pass),check-tokens,check-strings,check-migrations,check-stories,check-contrast, andcargo test/fmt --check/clippyall green. The one clippy warning (watch()has 8 args) pre-exists onmain.New tests (914 total, up from 903):
aiCoverage— 8 cases, including the pre-003 row that recorded a score (must still readran), the zero-is-not-non-null rule, theskipped: 2window paired with an assertion thatsampleQualitySummaryreturnsnullthere (that pairing is the evidence for the reversal), and the NULL-is-not-0 rule.noConfidentcopy never claims "No AI checks ran".deriveAiChipStatus— 6 cases, pinning the guard order, not just the states.snapshotFocusForReport.aiEnabled— 3 cases.'error'that invents no model, and a secondhydrate()after an error reaching'ready'(pins the retry gate).effectiveRequestTimeoutMs— 4 boundary cases.Stories:
AiOnButNoChecks(the #92 state),AiOffForSession,AiRanNoReadableChecks, and anUnconfiguredchip variant;NoAiBaselinere-documented as the pre-004 unknown case.Manual testing: none
Every fix here is verified by unit tests and code reading only. I did not run the app — no
npm run tauri dev, no macOS walk, and no Windows walk. Windows-MCP is Windows-only and this session ran on macOS, so nothing was machine-walked either.That matters most for the parts that are specifically about Windows runtime behaviour and cannot be proven from source: the WebView2 gesture requirement, the picker-wedge timeout, the
InvalidStateErrormapping, and the VC++ DLL hint. The primary fix is the exception — model-store hydration is cross-platform and directly observable: with AI on and a benchmarked model, start a session without visiting Settings → AI, and the report should now carry a score where it previously carried none.Deliberately not fixed
Each of these was confirmed by two independent verifiers during the investigation, and each is out of scope for #92:
001_initial.sqlwas amended in place pre-release, so anyapp.dbcreated before 2026-05-11 permanently lacksfocused_pct/generated_atand can never persist a sessions row. Still unfixed at HEAD, overlaps Session history and stats broken #99, and needs its own migration rather than a rider on this one.MediaErrorBannerrather than anything AI-specific. Correct behaviour (no camera, nothing to analyse) but the connection to AI going quiet isn't spelled out. Left alone.ISSUES.md.Ledgered as I83.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes