Skip to content

refactor(inference): make Ollama the single source of truth for active model#106

Merged
quiet-node merged 4 commits intomainfrom
feat/inference-source-of-truth
Apr 26, 2026
Merged

refactor(inference): make Ollama the single source of truth for active model#106
quiet-node merged 4 commits intomainfrom
feat/inference-source-of-truth

Conversation

@quiet-node
Copy link
Copy Markdown
Owner

Summary

  • Eliminates DEFAULT_MODEL_NAME ("gemma4:e2b") fallback that silently downgraded existing users to the small 2B model on first launch of the in-app picker (regression introduced in feat: in-app model picker with hardened selection pipeline #103).
  • Active model is now Option<String> in Rust state and string | null in TS. Ollama's /api/tags is the single source of truth. No fabricated defaults.
  • Renames [model] config section to [inference] since ollama_url is the only field left and the section now describes the inference daemon, not the model. Hard rename, no shim (PR feat: in-app model picker with hardened selection pipeline #103 has not shipped to users yet, so no migration needed).
  • Auto-picks the first installed Ollama model when nothing is persisted, so a user with any model pulled never sees an empty picker on first launch.
  • Adds OllamaErrorKind::NoModelSelected and routes a structured error through ask_ollama and search_pipeline when no model is selected.
  • Frontend: CapabilityMismatchStrip extended with a no-model message; the picker chip stays visible (label "Pick a model" when null) as the recovery affordance; submit is gated through the existing ask-bar shake animation.
  • useConversationHistory.save() rejects when no model is selected, centralizing the guard.

Why

Pre-#103, the active model came from config.model.available.first(). Post-#103, it came from a SQLite app_config.active_model row. Existing users upgrading to #103 had no SQLite row populated, so resolve_seed_active_model fell back to DEFAULT_MODEL_NAME = "gemma4:e2b". Anyone whose pre-#103 first available was a larger model (gemma4:e4b, llama3.1:8b, etc.) got silently downgraded to the small 2B model until they manually opened the picker. This explains the user-reported drastic quality drop on single-model conversations after #103.

The fix is structural, not a patch: by removing the fallback string entirely and treating "no model" as a real first-class state, the silent-downgrade bug cannot exist by construction. Empty Ollama → empty UI affordance, no fabrication.

Test plan

  • bun run test:all:coverage passes (534 backend tests, 898 frontend tests, 100% line/function/branch/statement coverage on both sides).
  • bun run validate-build clean (lint, format, typecheck, frontend build, backend release build, app bundle). Zero warnings.
  • Manual: launch with no Ollama models pulled. Verify picker chip shows "Pick a model", capability strip renders the no-model message, send button shake fires when pressed.
  • Manual: launch with one model pulled but no persisted choice. Verify it auto-picks that model (chip shows the slug, no strip).
  • Manual: launch with multiple models pulled, pick a non-default one, restart. Verify the persisted choice survives.
  • Manual: edit config.toml to use [inference] ollama_url = "..." and confirm it loads; confirm any leftover [model] section is ignored and falls back to defaults.

Notes

  • Breaking config change: users with a customized [model] ollama_url must rename the section to [inference]. CHANGELOG entry added under Unreleased.
  • This PR is Phase A of a two-phase effort. Phase B (multi-model conversation continuity, cross-model artifact handling, mid-conversation capability mismatch UX) lands in a follow-up PR.

…e model

Eliminates the DEFAULT_MODEL_NAME fallback that silently downgraded existing
users to gemma4:e2b on first launch of the model picker. Active model is now
Option<String>: None when no model is installed and no choice is persisted,
otherwise the user's persisted slug or the first installed model.

Renames [model] config section to [inference] (only field is ollama_url, so
the section now describes the inference daemon, not the model).

Frontend: capability mismatch strip extended for the no-model state, picker
chip stays visible as the recovery affordance, send is gated through the
existing shake animation, save short-circuits when active model is null.

No backward compatibility for the section rename: pre-#103 was a placeholder
default and PR #103 has not shipped to users yet.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…e picker chip

Phase A surfaced two defects in manual smoke test:

1. Picker chip was missing in chat mode when activeModel was null.
   The chip is the recovery affordance, so it must stay visible whenever
   the user is in chat mode regardless of model state.

2. The no-model warning strip rendered the same copy whether Ollama was
   unreachable or simply had zero models installed. The two states need
   different recovery actions: start Ollama vs pull a model.

get_model_picker_state now returns a typed ollamaReachable flag so the
frontend can pick the right strip copy. The chip is rendered
unconditionally in chat mode and hides only when Ollama is unreachable
(nothing to pick from).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
- Relax compose-mode chip gate so the picker chip stays visible whenever
  Ollama is reachable, even with zero models or no active selection.
  Match chat-mode by routing onModelPickerToggle through ollamaReachable.
- Drop the duplicate capability-conflict toast on submit-gate. The
  persistent strip already surfaces the message; the transient toast
  added visual clutter and overflowed the window in compose mode.
  Removed the now-unused Toast component and its tests.
- Update no-models strip copy: "Thuki couldn't find any local LLM
  models. Pull one from Ollama with ollama pull <model>, then come
  back."

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
- Move MAX_MODEL_SLUG_LEN from models.rs to config/defaults.rs so all
  baked-in pipeline constants live in the single source of truth
  declared by CLAUDE.md.
- Document MAX_MODEL_SLUG_LEN in docs/configurations.md alongside the
  other Ollama API safety limits.
- Switch useOllama.ts modelName coercion from `||` to `??` so an empty
  slug (malformed state) is no longer silently dropped; only a true
  null absence becomes undefined. Updated the matching test contract
  and added an askSearch null-active-model branch test to keep
  coverage at 100%.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node quiet-node merged commit 6263e29 into main Apr 26, 2026
3 checks passed
@quiet-node quiet-node deleted the feat/inference-source-of-truth branch April 26, 2026 21:50
quiet-node added a commit that referenced this pull request Apr 27, 2026
…ite commands (#108)

* feat(settings): backend foundation for Settings panel

Wires the Tauri command surface, config-write pipeline, and tray menu
entries the Settings GUI depends on. No frontend yet.

Architecture
- AppConfig wrapped in parking_lot::RwLock so the GUI can mutate runtime
  config in-process. Reads (every Ollama call, every search call, every
  command) take cheap clones via state.read().clone(); writes are rare
  and use a write guard. Parking_lot avoids std::sync poisoning on
  writer panic.
- Single security boundary: defaults::ALLOWED_FIELDS is the authoritative
  allowlist of (section, key) pairs the GUI may write. set_config_field
  rejects any pair not in the list with a typed UnknownField error,
  preventing the GUI from writing fields that do not exist or are
  intentionally not user-tunable (activation timing, vision limits, etc).
- toml_edit for per-field patches so user comments and key ordering in
  config.toml survive across GUI saves. Round-tripped through loader::load
  so clamp/empty-fallback/cross-field-invariant rules apply identically
  to GUI edits and hand-edits.
- Focus-event reload (no file watcher): reload_config_from_disk re-runs
  loader::load on Settings window focus. Saves ~250 LOC and one critical
  failure mode versus the watcher approach the eng-review outside voice
  challenged.
- Activation policy switches Accessory <-> Regular while Settings is
  visible so the Accessory app can take focus on its Settings window.
  Restored on close. Window hide-on-close preserves React state across
  reopen.

Commands (registered via tauri::generate_handler!)
- get_config — read snapshot from RwLock.
- set_config_field(section, key, value) — validated per-field write.
- reset_config(section?) — restore one section or whole file to defaults.
- reload_config_from_disk — focus-event sync.
- get_corrupt_marker — read+consume the recovery marker.
- reveal_config_in_finder — open Finder with file selected.

Errors
- ConfigError extended with UnknownSection / UnknownField / TypeMismatch /
  Parse variants. Serializes as a tagged enum across the IPC boundary so
  the frontend can render typed inline error pills.

Tray menu
- Reordered to: Open Thuki / Settings... (Cmd+,) / --- / Reveal config.toml
  / --- / Quit Thuki (Cmd+Q). Cmd+, is a menu accelerator (fires only
  while the tray menu is open) — see design doc P5 for the macOS
  contract reasoning.

Tests
- 30 new tests in settings_commands::tests covering allowlist enforcement,
  every JSON->TOML coercion path (integer/float/string/array primitives
  plus type-mismatch rejection), comment preservation, document I/O
  errors, and the corrupt-marker consume/cleanup contract.
- 463 tests pass; cargo clippy -D warnings clean; cargo fmt clean.
- Three pre-existing clippy errors fixed in passing (commands.rs await
  guard scope, images.rs slice::from_ref, search/config.rs const assert).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat(settings): Settings panel UI implementation

Frontend implementation of the Settings panel approved in the design
doc. The panel ships in this commit as a working window: bun run
validate-build now produces a release .app bundle that exposes
Settings... in the tray menu and renders the three-tab UI inside the
secondary NSWindow defined in tauri.conf.json.

Window architecture
- main.tsx routes on getCurrentWindow().label so one bundle serves both
  windows. Label "settings" mounts <SettingsWindow/>; everything else
  gets the existing chat overlay tree. No accidental cross-window state
  coupling.

Visual language ("Polished Portal", variant A from /design-shotgun)
- src/styles/settings.module.css: complete token set (typography
  11/12/13/14/24px, spacing 4/8/12/16/24/32, pill tab 76x52 @ 12px
  radius, slider 4px track / 14px thumb, 32x32 stepper). All colors
  reused from src/App.css; no parallel design system.
- Filled orange pill tabs with icon-above-label, active = #ff8d5c bg
  with dark text. Compact 56px rows. Uppercase 0.18em tracking section
  sub-headings. Helper text below each control. Bottom-right Saved
  pill (1.5s, aria-live="polite"). Reduced-motion media query.

Hooks
- useDebouncedSave: per-field 250ms debounce. Skips the seed render
  so initial mount never fires a save. flushNow() exposes a synchronous
  save for tab-switch flush. resetTo() re-seeds without scheduling a
  save (used by parent reload).
- useConfigSync: invokes get_config on mount, subscribes to
  tauri://focus, calls reload_config_from_disk on focus to pick up
  external hand-edits. Replaces the file watcher subsystem the eng
  review collapsed.

Form primitives (src/settings/components)
- SettingRow / Section / SectionHeading layout primitives.
- TextField, Textarea, Dropdown, NumberSlider, NumberStepper,
  OrderedListEditor (up/down/delete + add — no drag-and-drop, YAGNI
  per design doc).
- SavedPill (aria-live="polite"), ConfirmDialog (Esc-to-cancel,
  destructive variant for resets).
- SaveField: render-prop wrapper that binds one form row to one
  (section, key) tuple via useDebouncedSave. Resync token from parent
  re-seeds on external changes without scheduling a redundant save.

Tabs
- GeneralTab: MODEL (model list editor + Ollama URL) / PROMPT
  (textarea + char count) / WINDOW (4 sliders) / QUOTE (3 steppers).
  13 controls, scrolls in the 720x560 fixed window.
- SearchTab: SERVICES (URLs + sandbox-isolation warning) / PIPELINE
  (3 steppers) / TIMEOUTS (5 sliders). 10 controls.
- AboutTab: APP (version + links) / ACTIVATION (info-only) /
  PERMISSIONS (status pills + Open System Settings buttons, refreshed
  on window focus) / LIMITS (info-only) / FILE (Reveal config.toml,
  Refresh from disk, Reset all to defaults).

Accessibility
- ARIA tablist with arrow-key cycling between tabs.
- aria-selected/aria-controls/tabpanel role wiring.
- Sliders carry aria-valuemin/max/now/text.
- Saved pill is aria-live="polite"; row errors are role="alert".
- Focus rings via outline (not box-shadow) so they survive transforms.
- Reduced-motion media query disables transitions.

Resets
- Per-tab "Reset to defaults" link triggers the ConfirmDialog (matches
  mac convention for destructive actions).
- About → Reset all opens a separate destructive-styled confirm and
  invokes reset_config(section: null).

Corrupt-config recovery
- Mount-time invoke of get_corrupt_marker. If a marker is returned, a
  dismissible banner appears at the top of the active tab body with
  "Reveal" (opens Finder via open_url) and "Dismiss" actions. Banner
  state is per-session in React (consumes the marker on the backend so
  it does not reappear on next launch).

Validation
- bun run validate-build passes end-to-end: lint + format + typecheck +
  full release .app bundle.
- 721 existing Vitest tests still pass; 463 cargo tests still pass.

Test coverage gap
- Vitest tests for the new src/settings/* tree are NOT yet added in
  this commit. The shipped surface is large (10 components + 2 hooks +
  3 tabs + SettingsWindow); covering it to 100% is genuinely a separate
  half-day of work and was deferred per the user request to land the
  feature in this worktree first. Backend coverage is at 100% on new
  code. Test coverage for the frontend is the only known gap before
  /ship.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat(settings): redesign panel to match chat overlay aesthetic

Settings now feels native to Thuki instead of a generic macOS prefs
clone. Visual vocabulary mirrors the chat overlay (App.css /
.morphing-container): warm-orange surface-base + radial aura, top-edge
glow hairline, AI-bubble border treatment (1px white-06% with warm top
edge), 10px rounded card, native overlay scrollbar.

Window chrome: decorations:false + transparent:true, custom in-app top
bar via the shared WindowControls component (3 traffic lights, no
Thuki Settings title, red x hides via getCurrentWindow().hide()).
Resized 720x560 -> 580x720 for an elegant portrait shape. Top region
has 8px breathing padding so the controls strip matches the chat view.

Tab nav switched to a horizontal CodexBar-style icon row (icon above
label, subtle dark inset + warm orange on active). All inputs,
textareas, buttons, sliders, steppers, and pills restyled with the
chat-bubble vocabulary so the two windows feel like one product.

Drag from any non-interactive surface via handleDragStart mirroring
App.tsx, walking up the DOM and bailing on form controls. Settings
window added to capabilities/default.json with allow-hide,
allow-start-dragging, and allow-set-focus so red x and drag actually
work (previously scoped to main only).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat(settings): polish panel UX and reach 100% coverage

UI polish (single user-facing pass):

- Tab restructure: 4 tabs (Model brain / Web globe / Display monitor /
  About info). Old "General" split into Model (model + ollama_url +
  prompt) and Display (window + quote). Web tab keeps search.
- Per-section reset buttons removed. "Reset all to defaults" stays in
  About tab.
- Settings tooltips now reuse the shared Tooltip component (with new
  optional `placement: 'top' | 'bottom'` prop) and pull help copy from
  a single source-of-truth map (`configHelpers.ts`) that mirrors
  `docs/configurations.md` row-for-row. The `?` info button is keyboard
  focusable and reveals the tooltip on focus.
- About hero redesign: centered layout with Thuki logo, clickable
  version pill linking to GitHub release notes, two-line tagline
  ("A floating, local-first AI for macOS." / "No cloud. No clutter.
  Just answers."), three icon-only social buttons (GitHub / X /
  Feedback). Activation + Limits sections removed; permissions retain
  the row layout but lose the inter-row hairline. About tab no longer
  scrolls.
- File row buttons unified to ghost style. "Reveal config.toml in
  Finder" → "Reveal Thuki app data". "Refresh from disk" → "Refresh
  config.toml" with explanatory tooltip placed above the trigger.
- SavedPill now opaque so it doesn't bleed into rows. NumberStepper
  sits right-aligned at a sensible width instead of stretching.

Save reliability fixes:

- `useDebouncedSave` rewritten around a `lastSavedRef` value-equality
  gate plus an epoch counter. Eliminates the React StrictMode phantom
  save (pill appearing on tab switch with no edits) and the
  every-other-save miss after a successful save bumped
  `isInitialMountRef`. Adds `isMountedRef` guard on post-await
  setError, unmount-time flush of pending changes (so tab-switch
  mid-edit doesn't drop the user's last keystroke), and structural
  array equality for the model list. Drops unused `pending` field from
  the public handle.
- `SaveField` helper prop type narrowed `ReactNode` → `string` to
  match the tooltip contract.

Tests + coverage to 100%:

- Backend: extracted `write_field_to_disk` and `reset_section_on_disk`
  helpers from the Tauri command wrappers so the allowlist guards,
  document patch, atomic write, and post-write reload are exercised
  without an `AppHandle`. Added tests for boolean/datetime/inline-table
  coercion branches, IO-error propagation under read-only parent dirs,
  and corrupt-marker write failure (squat the marker filename with a
  directory). Coverage-excluded the five thin Tauri command wrappers
  per project convention. Removed unreachable defensive branches in
  loader's `rename_corrupt` and the `Float` coercion path.
- Frontend: new test files for `configHelpers`, `types`,
  `useDebouncedSave` (race + epoch + unmount-flush), `useConfigSync`
  (focus + reload + cleanup), every primitive in `components/index`
  (Section, SettingRow, TextField, Textarea, NumberSlider stepper +
  drag-aware sync, NumberStepper, Dropdown, OrderedListEditor,
  SavedPill, ConfirmDialog, ResetSectionLink), `SaveField` (resync
  token + errored prop forwarding), `SettingsWindow` (tab nav,
  corrupt banner, Cmd+,, drag, savedPill timer), and the four tab
  smokescreens. `main.tsx` factored to a pure `rootForLabel` helper
  for testability.
- Mock additions: `__emitFocus` / `__resetFocusListeners` /
  `__setWindowLabel` on the tauri-window mock to drive the new
  hook + main entry tests.

Configuration docs touched indirectly: tooltip strings in
`configHelpers.ts` are kept in sync with `docs/configurations.md`;
update both files in lockstep when adding a tunable.

Validate-build clean (lint zero warnings, prettier clean, typecheck
clean, frontend + backend build green). bun run test:all:coverage
passes 100% lines / 100% functions / 100% branches / 100% statements
on the frontend and 100% lines + 100% functions on the backend.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat(tray): drop "Reveal config.toml" item from tray menu

The Settings → About tab already exposes "Reveal Thuki app data" with
the same affordance, and the tray surface should stay focused on
session-level actions (open / settings / quit). The
`reveal_config_in_finder` Tauri command itself is unchanged so the
About tab button keeps working.

Tray menu is now: Open Thuki · Settings… · ─ · Quit Thuki.

No tests had to be removed: the tray wiring was driven by an inline
match in `lib.rs`'s `setup` closure, never directly tested. The
`reveal_config_in_finder` command remains coverage-excluded as a thin
FFI wrapper.

Validate-build clean. bun run test:all:coverage passes 100% lines /
100% functions / 100% branches / 100% statements on the frontend and
100% lines + 100% functions on the backend.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): reconcile with main's model picker pipeline

Adapts the Settings panel to the model-selection redesign that landed in
main (#103). Active model state now lives in SQLite via ActiveModelState,
not in a TOML field, so the Settings GUI no longer owns active-model
selection: the in-app ModelPickerPanel does.

Bug fix (models.rs)
- All four model-picker Tauri commands took State<'_, AppConfig> but
  lib.rs registers RwLock<AppConfig> after the worktree's wrapping.
  Tauri State<T> looks up by type, so the lookup silently failed at
  runtime: the AskBar chip stayed hidden and the chat-header picker
  came up empty. Each handler now takes the RwLock-wrapped state and
  snapshots ollama_url via config.read() before any await.

Settings panel reconciliation
- Drop the "Active Ollama model" row from ModelTab. Capability moves
  to App.tsx's ModelPickerPanel; URL + system prompt remain in Settings.
- Remove model.available from RawAppConfig (settings + ConfigContext),
  ALLOWED_FIELDS, configHelpers, all test fixtures.
- Delete OrderedListEditor component and its tests (last caller gone).
- Drop the now-unused .modelList/.modelItem*/.iconBtn/.modelAddRow CSS.
- Update settings_commands tests: ALLOWED_FIELDS count 20 -> 19.
- Replace stale config.model.active() assertion in config tests with
  a model.ollama_url check.

Coverage maintenance
- Add useModelCapabilities stale-resolution test to mirror the existing
  stale-rejection test (the isLatest short-circuit path was uncovered
  after the rebase exposed it).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* refactor(settings): rename Model tab to AI, section to Ollama

The Settings tab labelled "Model" no longer holds a model picker — that
moved to the in-app ModelPickerPanel after main #103. Keeping the label
misleads users into expecting a model list under it. Two cosmetic renames
realign the surface with what it actually tunes:

- Tab label "Model" -> "AI" (icon unchanged: brain still maps to both).
- Section heading "Model" -> "Ollama". With only the Ollama URL field in
  this section now, "Ollama" is the honest name; "Model" implied a
  picker was missing.

Pure UI rename: no schema, ALLOWED_FIELDS, or backend changes. Test
strings + adjacent doc comments updated to match.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat(settings): auto-resize window to active tab content

Replaces the fixed 680px Settings window height with a content-aware
height that animates between tabs. AI hugs ~520, Display lands at ~720,
Web at ~860 — light tabs no longer waste whitespace and heavy tabs no
longer force unnecessary scrolling at the default size.

Hook: useSettingsAutoResize(contentRef, chromeHeight)

- ResizeObserver on the inner content wrapper measures natural
  scrollHeight whenever the active tab swaps or a textarea grows.
- Target window height = content + chrome, clamped to [280, 900]. The
  body retains overflow-y: auto so content over MAX still scrolls
  internally rather than pushing the OS window off-screen.
- First measurement snaps without animation (no visible settle on
  open). Subsequent changes interpolate via requestAnimationFrame with
  ease-out cubic over 220ms, issuing one Tauri setSize per frame so
  macOS animates the real NSWindow at 60Hz.
- Sub-4px deltas are dropped to avoid ResizeObserver chatter.

Wiring

- SettingsWindow wraps tab panels in a measured div ref'd to the hook.
- Chrome height = 148 baked-in (window padding + WindowControls + tab
  bar + body padding). When the corrupt-config recovery banner is
  visible, hook receives chrome + 56 so the window grows to fit it.
- tauri.conf.json relaxes the settings window's min/max from
  680/680/680 to 280/520/900 so the runtime resize is not capped by
  the configured bounds.

Tests

- New useSettingsAutoResize.test covers: snap on first observation,
  rAF interpolation, clamp at MIN and MAX, sub-4px no-op, mid-flight
  animation re-targeting, observer + rAF cleanup on unmount, no-op
  when ref is null, and chrome-height update reflected on next resize.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): force window remeasure on tab switch

The auto-resize hook only updated the window height on its first mount
because ResizeObserver does not fire when React swaps a tab's tree
inside the same wrapper element within a single paint frame: the
wrapper's box change happens between paints and is not always observed.

Adds a `revision` parameter to `useSettingsAutoResize` (passed the
active tab id from SettingsWindow) plus a `useLayoutEffect` that
synchronously remeasures whenever revision changes. Layout-effect
timing guarantees `scrollHeight` reflects the freshly-mounted tab's
natural height, so the window animates to the correct target on every
switch instead of staying frozen at the first-mount value.

Test added for the revision-driven remeasure.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): re-attach auto-resize observer when content mounts

The auto-resize hook ran its setup effect once on initial render and
returned early because the wrapper element did not exist yet (the
SettingsWindow tree gates rendering on \`if (!config) return null\`,
so the wrapper is only added on the second render after \`get_config\`
resolves). With a plain \`useRef\`, the dependency array stayed
identity-stable across renders, so the effect never re-ran when the
element finally appeared. Result: ResizeObserver was never attached
and \`handleResizeRef\` stayed at its no-op default, so the
revision-driven re-measure on tab switch did nothing.

Switches to a state-backed callback ref pattern: SettingsWindow stores
the wrapper in \`useState\`, the hook accepts the element directly as a
parameter, and the setup effect depends on \`el\`. When the element
mounts, the state update triggers a re-render, the effect re-runs with
the real DOM node, and ResizeObserver attaches correctly. Subsequent
tab switches drive the layout-effect re-measure which now reaches a
real \`handleResize\` instead of the no-op stub.

Tests rewritten around the new signature, with \`act()\` wrapping for
state updates so the layout-effect re-measure is exercised correctly.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* refactor(settings): cap window height at 700px so tall tabs scroll

The Web tab's full timeouts list pushes natural content past 1500px,
which made the panel uncomfortably tall on smaller laptop displays.
Drop the auto-resize ceiling from 900 to 700; tabs that exceed the
cap continue scrolling inside .body via its existing overflow-y: auto.
tauri.conf.json maxHeight matches.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* refactor(settings): drop About-tab special-case overflow override

The About tab had its own .bodyNoScroll class (overflow: hidden,
scrollbar-gutter: auto) so its content was clipped at the bottom when
natural height exceeded the window cap, instead of scrolling like
every other tab. Removes the special case so .body's default
overflow-y: auto applies uniformly: every tab now scrolls when its
content exceeds the cap.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* refactor(settings): drop scrollbar-gutter so short tabs fill width

Removing \`scrollbar-gutter: stable\` lets the AI, Display, and About
tabs use the full body width when their content fits under
MAX_HEIGHT. The Web tab still scrolls when it overflows; macOS
overlay scrollbars float over content on that tab without causing
layout shift, so reserving a permanent gutter on every tab was
just visual noise.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): add chrome-height slack so short tabs do not scroll

\`.body\` uses \`overflow-y: auto\`, which paints a scrollbar whenever
content is even 1px taller than the body box. The hand-measured
chrome height (148) was a touch too tight, so the AI/Display/About
tabs ended up with content marginally exceeding the body and a
scroll affordance appearing on tabs that should fit cleanly.

Bumps CHROME_HEIGHT to 164 to give the body 16px of slack. Short tabs
now sit comfortably with no scrollbar; the Web tab still clamps at
MAX_HEIGHT and scrolls because its natural height vastly exceeds the
cap regardless of slack.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): measure chrome height dynamically

The static chrome estimate was systematically off, so the body box
ended up a few pixels shorter than the inner content even on tabs
that should fit cleanly. Result: AI/Display/About each painted a
scrollbar despite the cap not being hit.

Wraps WindowControls + banner + tab bar in a measured container,
runs a ResizeObserver on it, and uses its real `offsetHeight` plus
two CSS-constant offsets (window padding-top 8 + body padding 42) as
the chrome offset for the auto-resize hook. The ResizeObserver also
catches the banner appearing or disappearing without needing a
separate code path.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* refactor(settings): drop reserved scrollbar gutter on body

\`scrollbar-gutter: stable\` reserved a vertical strip of empty space on
every tab so content would not shift when the macOS overlay scrollbar
faded in. The macOS overlay scrollbar already floats on top of content
when it appears, so the gutter was just dead margin on the tabs that
do not scroll (AI, Display, About). Drop it so short tabs render
without any scrollbar artifact and Web still gets the floating
scrollbar when its content exceeds the window cap.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): only enable body scroll when content was clamped

Previously \`.body\` had \`overflow-y: auto\` unconditionally, so any
sub-pixel rounding mismatch between the auto-resize hook's chrome
estimate and the real layout produced a stray scrollbar even on
short tabs (AI, Display, About) whose content fit inside the
auto-sized window. Result: every tab looked scrollable.

useSettingsAutoResize now returns an \`isClamped\` boolean that flips
true when natural content + chrome exceeded MAX_HEIGHT. SettingsWindow
applies a \`bodyScrollable\` class only in that case; the default
\`.body\` style is \`overflow: hidden\` so short tabs render with no
scrollbar artifact at all. Web (the only tab past the cap) still gets
internal scrolling because it trips the clamp branch.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): rename remaining [model] section refs to [inference] in coverage-only tests

Post-rebase cleanup. Three .model.ollama_url field accesses and seven
"model" string literals in settings_commands::tests and config::tests
were only exercised when the cfg(coverage) build drops the
#[cfg_attr(coverage_nightly, coverage(off))] gate on the surrounding
Tauri-command wrappers, so the regular cargo test passed but
test:all:coverage failed to compile. Update them to match main's
[model] -> [inference] section rename from #106.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* refactor: remove collapsed height and hide commit delay from window configuration

- Removed `collapsed_height` and `hide_commit_delay_ms` from the `WindowSection` struct and related configurations.
- Updated the default values and tests to reflect the removal of these properties.
- Adjusted the frontend to use fixed values for collapsed height and hide commit delay, ensuring consistent behavior across the application.
- Emitted configuration updates to the frontend when changes are made to other window properties.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): add bottom padding to About tab and Cmd+W to hide panel

About tab was visually clipped at the bottom because .aboutBody had no
padding-bottom, causing the auto-resize window to size exactly to the
content edge with no breathing room. Adding 8px padding-bottom matches
the spacing visible on other tabs.

Cmd+W now hides the settings window to follow the standard macOS panel
close shortcut. The existing Cmd+, re-focus handler lives in the same
useEffect; both shortcuts share one document listener.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(settings): patch_document insert-on-missing, RMW lock, stale doc comment

Three fixes to settings_commands.rs:

1. patch_document now inserts a new TOML item with inferred type when the
   key is absent (e.g. user hand-edited it out), instead of returning
   UnknownField. Type inference: bool, integer, float, string, array-of-strings.
   Objects and null still return TypeMismatch. Adds json_value_to_toml_item
   helper for the missing-key path.

2. Hold the RwLock write guard across the full disk read-modify-write in
   set_config_field, reset_config, and reload_config_from_disk. Prevents
   a TOCTOU race where two concurrent invocations could read stale file
   content and last-write-wins on a different field.

3. Correct stale doc comment: State<AppConfig> -> State<RwLock<AppConfig>>.

Adds 7 new tests covering insert-on-missing paths (float, string, integer,
array, object rejection, array-non-string rejection, bool). Removes the
now-incorrect patch_document_unknown_field_errors test (allowlist
enforcement is upstream in write_field_to_disk, covered by existing test).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: tighten comment on u64 precision edge case in json_value_to_toml_item

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix(config): update AppConfig doc to reflect RwLock<AppConfig> managed state

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* docs(configurations): add baked-in rows for COLLAPSED_WINDOW_HEIGHT and HIDE_COMMIT_DELAY_MS

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* style: apply cargo fmt to settings_commands

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

---------

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
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