Skip to content

Comments

feat: persist and restore UI/audio/model settings#174

Merged
AAlp22 merged 3 commits intomasterfrom
feat/persist-ui-settings
Feb 21, 2026
Merged

feat: persist and restore UI/audio/model settings#174
AAlp22 merged 3 commits intomasterfrom
feat/persist-ui-settings

Conversation

@ysdede
Copy link
Owner

@ysdede ysdede commented Feb 21, 2026

Summary by Sourcery

Persist application settings for UI layout, audio device selection, and model/configuration options, and restore them on load using a unified settings storage utility.

New Features:

  • Add centralized settings storage utility to persist general, model, audio, and UI configuration in local storage with validation and clamping.
  • Persist and restore widget position, transcript tab and split ratio, debug panel visibility and height, and various transcription/general settings across sessions.
  • Preserve the selected audio input device and model between app launches when available.

Bug Fixes:

  • Ensure audio device refresh retains the currently selected device if it still exists and gracefully falls back or clears selection when not available.

Enhancements:

  • Refactor debug panel and transcription display components to support externally controlled layout state instead of directly reading and writing to local storage.
  • Debounce settings writes to storage to reduce unnecessary local storage operations and handle hydration after device refresh completes.

Tests:

  • Extend app store tests to cover device selection retention and fallback behavior.
  • Add tests for the new settings storage utility to validate sanitization and legacy data migration behavior.

Summary by CodeRabbit

  • New Features

    • Persistent, storage-backed UI settings across sessions (widget position, transcript tabs/ratio, debug panel height/visibility, audio/model prefs) with debounced saves and hydration/flush on lifecycle events.
  • Bug Fixes

    • Improved device refresh/selection logic with tolerant label matching and safer handling when devices are missing or enumeration errors occur.
  • Refactor

    • Centralized settings storage and controlled UI state flow allowing external control of split ratio and debug panel height.
  • Tests

    • Added comprehensive tests for device handling and settings persistence.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 21, 2026

Reviewer's Guide

This PR introduces a centralized settings persistence layer and wires it into the app to persist and restore UI layout, audio device selection, model selection, and various tuning parameters, while also hardening device refresh logic and adding tests for both settings and device handling behavior.

Sequence diagram for app startup, settings hydration, and device refresh

sequenceDiagram
    autonumber
    participant Browser as BrowserWindow
    participant App as AppComponent
    participant Settings as SettingsStorage
    participant Store as AppStore
    participant Media as NavigatorMediaDevices

    Browser->>App: mount()
    activate App
    App->>Browser: addEventListener(resize)

    App->>Settings: loadSettingsFromStorage()
    activate Settings
    Settings-->>App: PersistedSettings
    deactivate Settings

    App->>Store: setSelectedModelId(persistedModelId?)
    App->>Store: setSelectedDeviceId(persistedDeviceId?)
    App->>Store: setEnergyThreshold(...)
    App->>Store: setSileroThreshold(...)
    App->>Store: setV4InferenceIntervalMs(...)
    App->>Store: setV4SilenceFlushSec(...)
    App->>Store: setStreamingWindow(...)
    App->>Store: setShowDebugPanel(persistedVisible?)

    App->>App: setDebugPanelHeight(clampDebugPanelHeight(persistedHeight))
    App->>App: setActiveTranscriptTab(persistedTab?)
    App->>App: setMergedSplitRatio(clampMergedSplitRatio(persistedRatio))
    App->>App: setWidgetPos(clampWidgetPosition(persistedOrDefaultPosition))

    App->>Store: refreshDevices()
    activate Store
    Store->>Media: enumerateDevices()
    activate Media
    Media-->>Store: devices
    deactivate Media

    Store->>Store: filter audioinput devices
    alt no audio devices
      Store->>Store: setAvailableDevices([])
      Store->>Store: setSelectedDeviceId('')
    else some devices
      Store->>Store: setAvailableDevices(mics)
      Store->>Store: compute hasCurrentSelection
      alt has current selection
        Store->>Store: keep selectedDeviceId
      else no valid selection
        Store->>Store: setSelectedDeviceId(firstMicId)
      end
    end
    deactivate Store

    Store-->>App: refreshDevices() resolved
    App->>App: setSettingsHydrated(true)
    deactivate App
Loading

Sequence diagram for settings snapshot persistence with debounce

sequenceDiagram
    autonumber
    participant App as AppComponent
    participant Store as AppStore
    participant Settings as SettingsStorage
    participant LS as LocalStorage

    rect rgb(235, 245, 255)
      note over App,Store: User changes settings (UI, audio, model, tuning)
      App->>Store: update signals (e.g. setEnergyThreshold, setSelectedModelId)
      App->>App: update local signals (widgetPos, debugPanelHeight, mergedSplitRatio)
    end

    App->>App: reactiveEffect() triggered
    alt settingsHydrated is false
      App-->>App: return (do not persist)
    else settingsHydrated is true
      App->>Store: read general, model, audio settings
      App->>App: read ui signals (widgetPos, debugPanelHeight, mergedSplitRatio, activeTab)
      App->>App: build snapshot : PersistedSettings

      alt existing debounce timeout
        App->>App: clearTimeout(settingsSaveTimeout)
      end

      App->>App: setTimeout(SETTINGS_SAVE_DEBOUNCE_MS)
      App-->>App: store timeout id in settingsSaveTimeout

      App->>App: timeout fires
      App->>Settings: saveSettingsToStorage(snapshot)
      activate Settings
      Settings->>LS: setItem(APP_SETTINGS_STORAGE_KEY, JSON.stringify(snapshot))
      LS-->>Settings: success or error
      Settings-->>App: return
      deactivate Settings
    end
Loading

Class diagram for centralized settings persistence and consumers

classDiagram
    class SettingsStorage {
      <<utility>>
      +APP_SETTINGS_STORAGE_KEY : string
      +MIN_DEBUG_PANEL_HEIGHT : number
      +MAX_DEBUG_PANEL_HEIGHT : number
      +DEFAULT_DEBUG_PANEL_HEIGHT : number
      +MIN_MERGED_SPLIT_RATIO : number
      +MAX_MERGED_SPLIT_RATIO : number
      +DEFAULT_MERGED_SPLIT_RATIO : number
      +clampDebugPanelHeight(height number) number
      +clampMergedSplitRatio(ratio number) number
      +loadSettingsFromStorage() PersistedSettings
      +saveSettingsToStorage(settings PersistedSettings) void
    }

    class PersistedSettings {
      +general : PersistedGeneral
      +model : PersistedModel
      +audio : PersistedAudio
      +ui : PersistedUi
    }

    class PersistedGeneral {
      +energyThreshold : number
      +sileroThreshold : number
      +v4InferenceIntervalMs : number
      +v4SilenceFlushSec : number
      +streamingWindow : number
    }

    class PersistedModel {
      +selectedModelId : string
    }

    class PersistedAudio {
      +selectedDeviceId : string
      +selectedDeviceLabel : string
    }

    class PersistedUi {
      +widgetPosition : WidgetPosition
      +debugPanel : PersistedDebugPanel
      +transcript : PersistedTranscript
    }

    class WidgetPosition {
      +x : number
      +y : number
    }

    class PersistedDebugPanel {
      +visible : boolean
      +height : number
    }

    class PersistedTranscript {
      +activeTab : StoredTranscriptTab
      +mergedSplitRatio : number
    }

    class StoredTranscriptTab {
      <<type alias>>
      +live
      +merged
    }

    class AppComponent {
      +mergedSplitRatio : Signal~number~
      +debugPanelHeight : Signal~number~
      +settingsHydrated : Signal~boolean~
      +widgetPos : Signal~WidgetPosition~
      +activeTranscriptTab : Signal~StoredTranscriptTab~
      +onMount() void
      +createEffect_saveSettingsSnapshot() void
    }

    class AppStore {
      +energyThreshold() number
      +sileroThreshold() number
      +v4InferenceIntervalMs() number
      +v4SilenceFlushSec() number
      +streamingWindow() number
      +selectedModelId() string
      +selectedDeviceId() string
      +availableDevices() MediaDeviceInfo[]
      +showDebugPanel() boolean
      +setEnergyThreshold(value number) void
      +setSileroThreshold(value number) void
      +setV4InferenceIntervalMs(value number) void
      +setV4SilenceFlushSec(value number) void
      +setStreamingWindow(value number) void
      +setSelectedModelId(id string) void
      +setSelectedDeviceId(id string) void
      +setShowDebugPanel(visible boolean) void
      +refreshDevices() Promise~void~
    }

    class TranscriptionDisplayProps {
      +activeTab : StoredTranscriptTab
      +onTabChange(tab StoredTranscriptTab) void
      +mergedSplitRatio : number
      +onMergedSplitRatioChange(ratio number) void
    }

    class TranscriptionDisplayComponent {
      +internalMergedSplitRatio : Signal~number~
      +mergedSplitRatio() number
      +setMergedSplitRatio(ratio number) void
      +startSplitResize(event MouseEvent) void
    }

    class DebugPanelProps {
      +audioEngine : AudioEngine
      +melClient : MelWorkerClient
      +height : number
      +onHeightChange(height number) void
    }

    class DebugPanelComponent {
      +internalHeight : Signal~number~
      +panelHeight() number
      +setPanelHeight(height number) void
      +handleMouseDown(e MouseEvent) void
      +handleMouseMove(e MouseEvent) void
      +handleMouseUp() void
    }

    SettingsStorage ..> PersistedSettings
    PersistedSettings o--> PersistedGeneral
    PersistedSettings o--> PersistedModel
    PersistedSettings o--> PersistedAudio
    PersistedSettings o--> PersistedUi
    PersistedUi o--> WidgetPosition
    PersistedUi o--> PersistedDebugPanel
    PersistedUi o--> PersistedTranscript
    PersistedTranscript ..> StoredTranscriptTab

    AppComponent ..> SettingsStorage : uses
    AppComponent ..> AppStore : uses
    AppComponent o--> PersistedSettings : builds snapshots

    TranscriptionDisplayComponent ..> TranscriptionDisplayProps : reads
    TranscriptionDisplayComponent ..> SettingsStorage : uses clampMergedSplitRatio

    DebugPanelComponent ..> DebugPanelProps : reads
    DebugPanelComponent ..> SettingsStorage : uses clampDebugPanelHeight

    AppStore ..> navigator_mediaDevices : uses

    class navigator_mediaDevices {
      +enumerateDevices() Promise~MediaDeviceInfo[]~
    }
Loading

File-Level Changes

Change Details Files
Introduce a centralized, typed settings storage module and migrate legacy per-feature localStorage keys.
  • Add utils/settingsStorage.ts defining PersistedSettings shape, clamping helpers, sanitization, and localStorage load/save logic.
  • Support migration from legacy widget position and merged split ratio keys into the new unified settings payload.
  • Provide reusable clampDebugPanelHeight and clampMergedSplitRatio helpers plus related constants for other components to consume.
src/utils/settingsStorage.ts
src/utils/settingsStorage.test.ts
Wire settings persistence into the main App component to save and restore audio, model, general tuning, and UI layout state.
  • Replace ad‑hoc widget position persistence with centralized settings loading/saving and introduce a default widget positioning helper and viewport clamping.
  • On mount, hydrate app state from persisted settings (model ID, audio device ID, general thresholds, debug panel visibility/height, transcript tab, merged layout split ratio, widget position) with validation and clamping.
  • Add a debounced createEffect that snapshots current store/UI state and writes it to storage, guarded by a settingsHydrated flag and cleaned up on unmount.
  • Pass mergedSplitRatio and debugPanelHeight down into TranscriptionDisplay and DebugPanel as controlled props and handle resize callbacks to update signals.
  • Ensure device refresh completes before marking settings hydration complete so subsequent persistence uses up-to-date device info.
src/App.tsx
Make the debug panel fully controlled/controllable and use centralized clamping for its resizable height.
  • Refactor DebugPanel to keep an internal height signal only as a fallback, with props.height and onHeightChange enabling external control.
  • Use clampDebugPanelHeight for all height calculations and replace inline min/max logic with shared constants.
  • Ensure drag-resize operations use the controlled height and propagate updates via onHeightChange.
src/components/DebugPanel.tsx
Make the merged transcript split ratio controllable from the parent and persist it via the new settings layer.
  • Remove localStorage access and internal persistence from TranscriptionDisplay in favor of controlled mergedSplitRatio/onMergedSplitRatioChange props.
  • Use DEFAULT_MERGED_SPLIT_RATIO and clampMergedSplitRatio from settingsStorage and maintain an internal fallback signal when uncontrolled.
  • Keep the resize behavior unchanged aside from delegating persistence to the parent via the callback.
src/components/TranscriptionDisplay.tsx
Harden audio device refresh behavior and add tests to cover new selection semantics.
  • Update refreshDevices to handle missing navigator/mediaDevices gracefully, clear devices/selection when none are available, and preserve the current selection if still present.
  • Default to the first available mic only when there is no valid current selection.
  • Add tests covering preservation of a still-available selected device and fallback to the first device when the selected ID is no longer present.
src/stores/appStore.ts
src/stores/appStore.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link

coderabbitai bot commented Feb 21, 2026

📝 Walkthrough

Walkthrough

Adds a storage-backed PersistedSettings system and integrates it into app startup and lifecycle to hydrate and persist UI and device state; updates component props for controlled UI values and extends device refresh logic and tests.

Changes

Cohort / File(s) Summary
Settings Storage Infrastructure
src/utils/settingsStorage.ts, src/utils/settingsStorage.test.ts
New PersistedSettings model, clamp helpers, loadSettingsFromStorage/saveSettingsToStorage, legacy-key fallback, sanitization and clamping; comprehensive tests for parsing, clamping, conversions, legacy recovery, and persistence.
App Integration & Persistence
src/App.tsx
Hydrates persisted settings at startup, defers until device refresh, clamps widget position, initializes UI signals (widgetPosition, transcript.activeTab, mergedSplitRatio, debugPanel.height/visible, general/audio/model), debounced saves (150ms), flush-on-unload/visibility, and cleans timers on unmount.
Component Prop Updates
src/components/DebugPanel.tsx, src/components/TranscriptionDisplay.tsx
Both components now accept controlled props and callbacks: DebugPanel adds height and onHeightChange; TranscriptionDisplay adds mergedSplitRatio and onMergedSplitRatioChange. Internal clamping retained; persistence moved to parent.
Device Enumeration & Store Changes
src/stores/appStore.ts, src/stores/appStore.test.ts
refreshDevices signature now accepts { preferredDeviceId?, preferredDeviceLabel? }; guards missing navigator APIs; filters audioinput; preserves valid selection; prefers matching ID, then tolerant label matching, then first device; tests added for selection retention, fallbacks, label restoration, tolerant matching, no-audio, and error handling.
Tests & Store Updates
src/stores/appStore.test.ts, src/utils/settingsStorage.test.ts
New and expanded tests for device selection behaviors and settings storage parsing/clamping/legacy handling; environment mocks for navigator.mediaDevices.

Sequence Diagram

sequenceDiagram
    participant App as App.tsx
    participant Storage as settingsStorage
    participant LocalStorage as Browser Storage
    participant AppStore as appStore
    participant Components as UI Components

    App->>Storage: loadSettingsFromStorage()
    Storage->>LocalStorage: read APP_SETTINGS_STORAGE_KEY
    Storage-->>App: PersistedSettings (sanitized & clamped, with legacy fallbacks)

    App->>AppStore: refreshDevices(options?)
    AppStore-->>App: devices + validated selection

    App->>Components: set props (widgetPosition, activeTab, mergedSplitRatio, debugPanel.height, model/audio settings)
    Note over App,Components: hydration complete

    rect rgba(100,150,200,0.5)
        Components->>App: onHeightChange / onMergedSplitRatioChange / other UI events
        App->>App: update signals, debounce snapshot (150ms)
        App->>Storage: saveSettingsToStorage(snapshot)
        Storage->>LocalStorage: write APP_SETTINGS_STORAGE_KEY
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I nudge the split and lift the pane,

Burrows hold settings safe and plain,
Ratios clamped, the height kept right,
Devices chosen, saved at night,
Hooray — the app wakes calm and bright!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature of the changeset: adding persistence and restoration of UI, audio, and model settings through a new settingsStorage utility and component refactoring.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/persist-ui-settings

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

❤️ Share

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

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `src/App.tsx:309` </location>
<code_context>
     }
   });

+  createEffect(() => {
+    if (!settingsHydrated()) return;
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the new settings persistence and widget layout logic from `App` into dedicated helpers/hooks to keep `App` focused on wiring and view concerns.

You’ve added valuable persistence, but a lot of it is now cross‑cutting inside `App`. You can keep all behavior while shrinking `App`’s responsibilities by pushing snapshot building + hydration into a small abstraction.

### 1. Extract settings persistence into a helper/hook

Move the `createEffect` snapshot + debounced save and the `onMount` hydration logic into a dedicated module, e.g. `useSettingsPersistence.ts` (or `settingsPersistence.ts`). `App` then just wires signals into it.

**New helper (sketch):**

```ts
// utils/useSettingsPersistence.ts
import {
  clampDebugPanelHeight,
  clampMergedSplitRatio,
  DEFAULT_DEBUG_PANEL_HEIGHT,
  DEFAULT_MERGED_SPLIT_RATIO,
  loadSettingsFromStorage,
  saveSettingsToStorage,
} from './settingsStorage';

const SETTINGS_SAVE_DEBOUNCE_MS = 150;

export function useSettingsPersistence(options: {
  appStore: typeof appStore;
  widgetPos: Accessor<{ x: number; y: number } | null>;
  setWidgetPos: (pos: { x: number; y: number } | null) => void;
  activeTranscriptTab: Accessor<TranscriptViewTab>;
  setActiveTranscriptTab: (tab: TranscriptViewTab) => void;
  debugPanelHeight: Accessor<number>;
  setDebugPanelHeight: (h: number) => void;
  mergedSplitRatio: Accessor<number>;
  setMergedSplitRatio: (r: number) => void;
}) {
  const {
    appStore,
    widgetPos,
    setWidgetPos,
    activeTranscriptTab,
    setActiveTranscriptTab,
    debugPanelHeight,
    setDebugPanelHeight,
    mergedSplitRatio,
    setMergedSplitRatio,
  } = options;

  const [settingsHydrated, setSettingsHydrated] = createSignal(false);
  let saveTimeout: number | undefined;

  // Hydration
  onMount(() => {
    const persisted = loadSettingsFromStorage();
    const g = persisted.general;
    const ui = persisted.ui;

    if (persisted.model?.selectedModelId && MODELS.some(m => m.id === persisted.model!.selectedModelId)) {
      appStore.setSelectedModelId(persisted.model.selectedModelId);
    }
    if (persisted.audio?.selectedDeviceId) {
      appStore.setSelectedDeviceId(persisted.audio.selectedDeviceId);
    }

    if (g?.energyThreshold !== undefined) appStore.setEnergyThreshold(g.energyThreshold);
    if (g?.sileroThreshold !== undefined) appStore.setSileroThreshold(g.sileroThreshold);
    if (g?.v4InferenceIntervalMs !== undefined) appStore.setV4InferenceIntervalMs(g.v4InferenceIntervalMs);
    if (g?.v4SilenceFlushSec !== undefined) appStore.setV4SilenceFlushSec(g.v4SilenceFlushSec);
    if (g?.streamingWindow !== undefined) appStore.setStreamingWindow(g.streamingWindow);

    if (ui?.debugPanel?.visible !== undefined) appStore.setShowDebugPanel(ui.debugPanel.visible);
    if (ui?.debugPanel?.height !== undefined) {
      setDebugPanelHeight(clampDebugPanelHeight(ui.debugPanel.height));
    }
    if (ui?.transcript?.activeTab) {
      setActiveTranscriptTab(ui.transcript.activeTab);
    }
    if (ui?.transcript?.mergedSplitRatio !== undefined) {
      setMergedSplitRatio(clampMergedSplitRatio(ui.transcript.mergedSplitRatio));
    }

    if (ui?.widgetPosition) {
      setWidgetPos(clampWidgetPosition(ui.widgetPosition));
    } else {
      setWidgetPos(getDefaultWidgetPosition());
    }

    setSettingsHydrated(true);
  });

  // Saving
  createEffect(() => {
    if (!settingsHydrated()) return;

    const selectedDeviceId = appStore.selectedDeviceId();
    const selectedDevice = appStore.availableDevices().find(d => d.deviceId === selectedDeviceId);

    const snapshot = {
      general: {
        energyThreshold: appStore.energyThreshold(),
        sileroThreshold: appStore.sileroThreshold(),
        v4InferenceIntervalMs: appStore.v4InferenceIntervalMs(),
        v4SilenceFlushSec: appStore.v4SilenceFlushSec(),
        streamingWindow: appStore.streamingWindow(),
      },
      model: {
        selectedModelId: appStore.selectedModelId(),
      },
      audio: {
        selectedDeviceId,
        selectedDeviceLabel: selectedDevice?.label,
      },
      ui: {
        widgetPosition: widgetPos() ?? undefined,
        debugPanel: {
          visible: appStore.showDebugPanel(),
          height: debugPanelHeight(),
        },
        transcript: {
          activeTab: activeTranscriptTab(),
          mergedSplitRatio: mergedSplitRatio(),
        },
      },
    };

    if (saveTimeout) clearTimeout(saveTimeout);
    saveTimeout = window.setTimeout(
      () => saveSettingsToStorage(snapshot),
      SETTINGS_SAVE_DEBOUNCE_MS,
    );
  });

  onCleanup(() => {
    if (saveTimeout) clearTimeout(saveTimeout);
  });

  return { settingsHydrated };
}
```

**Then in `App` you can trim to:**

```ts
const App: Component = () => {
  // ...existing signals...
  const [mergedSplitRatio, setMergedSplitRatio] = createSignal(DEFAULT_MERGED_SPLIT_RATIO);
  const [debugPanelHeight, setDebugPanelHeight] = createSignal(DEFAULT_DEBUG_PANEL_HEIGHT);
  const [widgetPos, setWidgetPos] = createSignal<{ x: number; y: number } | null>(null);

  const { settingsHydrated } = useSettingsPersistence({
    appStore,
    widgetPos,
    setWidgetPos,
    activeTranscriptTab,
    setActiveTranscriptTab,
    debugPanelHeight,
    setDebugPanelHeight,
    mergedSplitRatio,
    setMergedSplitRatio,
  });

  onMount(() => {
    const onResize = () => setWindowHeight(window.innerHeight);
    window.addEventListener('resize', onResize);

    workerClient = new TranscriptionWorkerClient();
    // ...worker wiring...

    void appStore.refreshDevices();

    setWorkerReady(true);
    return () => window.removeEventListener('resize', onResize);
  });

  // onCleanup now no longer needs to clear settingsSaveTimeout
};
```

This keeps all current behavior but pulls out the big `createEffect` and the hydration `onMount` logic into a single, named abstraction, making `App` focus on wiring and view concerns.

### 2. Move widget layout helpers out of `App`

Right now `getDefaultWidgetPosition` and `clampWidgetPosition` sit in `App`. They belong with other settings/UI layout logic (e.g. `settingsStorage.ts` or a new `uiLayout.ts`).

For example:

```ts
// utils/uiLayout.ts
export const WIDGET_MAX_W = 672;
export const WIDGET_MIN_H = 80;

export const getDefaultWidgetPosition = (): { x: number; y: number } => {
  const width = typeof window !== 'undefined' ? window.innerWidth : 800;
  const height = typeof window !== 'undefined' ? window.innerHeight : 600;
  return {
    x: Math.max(0, (width - WIDGET_MAX_W) / 2),
    y: Math.max(0, height - 140),
  };
};

export const clampWidgetPosition = (position: { x: number; y: number }) => {
  if (typeof window === 'undefined') return position;
  return {
    x: Math.max(0, Math.min(window.innerWidth - WIDGET_MAX_W, position.x)),
    y: Math.max(0, Math.min(window.innerHeight - WIDGET_MIN_H, position.y)),
  };
};
```

Then import these into both `App` and the persistence helper as needed:

```ts
import { getDefaultWidgetPosition, clampWidgetPosition } from './utils/uiLayout';
```

This reduces the “layout math” noise inside `App` and keeps all settings/layout logic co‑located and easier to test.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

src/App.tsx Outdated
}
});

createEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider extracting the new settings persistence and widget layout logic from App into dedicated helpers/hooks to keep App focused on wiring and view concerns.

You’ve added valuable persistence, but a lot of it is now cross‑cutting inside App. You can keep all behavior while shrinking App’s responsibilities by pushing snapshot building + hydration into a small abstraction.

1. Extract settings persistence into a helper/hook

Move the createEffect snapshot + debounced save and the onMount hydration logic into a dedicated module, e.g. useSettingsPersistence.ts (or settingsPersistence.ts). App then just wires signals into it.

New helper (sketch):

// utils/useSettingsPersistence.ts
import {
  clampDebugPanelHeight,
  clampMergedSplitRatio,
  DEFAULT_DEBUG_PANEL_HEIGHT,
  DEFAULT_MERGED_SPLIT_RATIO,
  loadSettingsFromStorage,
  saveSettingsToStorage,
} from './settingsStorage';

const SETTINGS_SAVE_DEBOUNCE_MS = 150;

export function useSettingsPersistence(options: {
  appStore: typeof appStore;
  widgetPos: Accessor<{ x: number; y: number } | null>;
  setWidgetPos: (pos: { x: number; y: number } | null) => void;
  activeTranscriptTab: Accessor<TranscriptViewTab>;
  setActiveTranscriptTab: (tab: TranscriptViewTab) => void;
  debugPanelHeight: Accessor<number>;
  setDebugPanelHeight: (h: number) => void;
  mergedSplitRatio: Accessor<number>;
  setMergedSplitRatio: (r: number) => void;
}) {
  const {
    appStore,
    widgetPos,
    setWidgetPos,
    activeTranscriptTab,
    setActiveTranscriptTab,
    debugPanelHeight,
    setDebugPanelHeight,
    mergedSplitRatio,
    setMergedSplitRatio,
  } = options;

  const [settingsHydrated, setSettingsHydrated] = createSignal(false);
  let saveTimeout: number | undefined;

  // Hydration
  onMount(() => {
    const persisted = loadSettingsFromStorage();
    const g = persisted.general;
    const ui = persisted.ui;

    if (persisted.model?.selectedModelId && MODELS.some(m => m.id === persisted.model!.selectedModelId)) {
      appStore.setSelectedModelId(persisted.model.selectedModelId);
    }
    if (persisted.audio?.selectedDeviceId) {
      appStore.setSelectedDeviceId(persisted.audio.selectedDeviceId);
    }

    if (g?.energyThreshold !== undefined) appStore.setEnergyThreshold(g.energyThreshold);
    if (g?.sileroThreshold !== undefined) appStore.setSileroThreshold(g.sileroThreshold);
    if (g?.v4InferenceIntervalMs !== undefined) appStore.setV4InferenceIntervalMs(g.v4InferenceIntervalMs);
    if (g?.v4SilenceFlushSec !== undefined) appStore.setV4SilenceFlushSec(g.v4SilenceFlushSec);
    if (g?.streamingWindow !== undefined) appStore.setStreamingWindow(g.streamingWindow);

    if (ui?.debugPanel?.visible !== undefined) appStore.setShowDebugPanel(ui.debugPanel.visible);
    if (ui?.debugPanel?.height !== undefined) {
      setDebugPanelHeight(clampDebugPanelHeight(ui.debugPanel.height));
    }
    if (ui?.transcript?.activeTab) {
      setActiveTranscriptTab(ui.transcript.activeTab);
    }
    if (ui?.transcript?.mergedSplitRatio !== undefined) {
      setMergedSplitRatio(clampMergedSplitRatio(ui.transcript.mergedSplitRatio));
    }

    if (ui?.widgetPosition) {
      setWidgetPos(clampWidgetPosition(ui.widgetPosition));
    } else {
      setWidgetPos(getDefaultWidgetPosition());
    }

    setSettingsHydrated(true);
  });

  // Saving
  createEffect(() => {
    if (!settingsHydrated()) return;

    const selectedDeviceId = appStore.selectedDeviceId();
    const selectedDevice = appStore.availableDevices().find(d => d.deviceId === selectedDeviceId);

    const snapshot = {
      general: {
        energyThreshold: appStore.energyThreshold(),
        sileroThreshold: appStore.sileroThreshold(),
        v4InferenceIntervalMs: appStore.v4InferenceIntervalMs(),
        v4SilenceFlushSec: appStore.v4SilenceFlushSec(),
        streamingWindow: appStore.streamingWindow(),
      },
      model: {
        selectedModelId: appStore.selectedModelId(),
      },
      audio: {
        selectedDeviceId,
        selectedDeviceLabel: selectedDevice?.label,
      },
      ui: {
        widgetPosition: widgetPos() ?? undefined,
        debugPanel: {
          visible: appStore.showDebugPanel(),
          height: debugPanelHeight(),
        },
        transcript: {
          activeTab: activeTranscriptTab(),
          mergedSplitRatio: mergedSplitRatio(),
        },
      },
    };

    if (saveTimeout) clearTimeout(saveTimeout);
    saveTimeout = window.setTimeout(
      () => saveSettingsToStorage(snapshot),
      SETTINGS_SAVE_DEBOUNCE_MS,
    );
  });

  onCleanup(() => {
    if (saveTimeout) clearTimeout(saveTimeout);
  });

  return { settingsHydrated };
}

Then in App you can trim to:

const App: Component = () => {
  // ...existing signals...
  const [mergedSplitRatio, setMergedSplitRatio] = createSignal(DEFAULT_MERGED_SPLIT_RATIO);
  const [debugPanelHeight, setDebugPanelHeight] = createSignal(DEFAULT_DEBUG_PANEL_HEIGHT);
  const [widgetPos, setWidgetPos] = createSignal<{ x: number; y: number } | null>(null);

  const { settingsHydrated } = useSettingsPersistence({
    appStore,
    widgetPos,
    setWidgetPos,
    activeTranscriptTab,
    setActiveTranscriptTab,
    debugPanelHeight,
    setDebugPanelHeight,
    mergedSplitRatio,
    setMergedSplitRatio,
  });

  onMount(() => {
    const onResize = () => setWindowHeight(window.innerHeight);
    window.addEventListener('resize', onResize);

    workerClient = new TranscriptionWorkerClient();
    // ...worker wiring...

    void appStore.refreshDevices();

    setWorkerReady(true);
    return () => window.removeEventListener('resize', onResize);
  });

  // onCleanup now no longer needs to clear settingsSaveTimeout
};

This keeps all current behavior but pulls out the big createEffect and the hydration onMount logic into a single, named abstraction, making App focus on wiring and view concerns.

2. Move widget layout helpers out of App

Right now getDefaultWidgetPosition and clampWidgetPosition sit in App. They belong with other settings/UI layout logic (e.g. settingsStorage.ts or a new uiLayout.ts).

For example:

// utils/uiLayout.ts
export const WIDGET_MAX_W = 672;
export const WIDGET_MIN_H = 80;

export const getDefaultWidgetPosition = (): { x: number; y: number } => {
  const width = typeof window !== 'undefined' ? window.innerWidth : 800;
  const height = typeof window !== 'undefined' ? window.innerHeight : 600;
  return {
    x: Math.max(0, (width - WIDGET_MAX_W) / 2),
    y: Math.max(0, height - 140),
  };
};

export const clampWidgetPosition = (position: { x: number; y: number }) => {
  if (typeof window === 'undefined') return position;
  return {
    x: Math.max(0, Math.min(window.innerWidth - WIDGET_MAX_W, position.x)),
    y: Math.max(0, Math.min(window.innerHeight - WIDGET_MIN_H, position.y)),
  };
};

Then import these into both App and the persistence helper as needed:

import { getDefaultWidgetPosition, clampWidgetPosition } from './utils/uiLayout';

This reduces the “layout math” noise inside App and keeps all settings/layout logic co‑located and easier to test.

try {
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) {
setAvailableDevices([]);
setSelectedDeviceId('');
Copy link

Choose a reason for hiding this comment

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

WARNING: setSelectedDeviceId('') in the SSR/no-mediaDevices guard clobbers any device ID that was just restored from persisted settings.

In App.tsx, appStore.setSelectedDeviceId(persistedDeviceId) is called synchronously in onMount before refreshDevices(). If navigator.mediaDevices is absent (e.g. in a test environment or a future SSR path), this early-return branch immediately overwrites the restored ID with ''. The finally callback then sets settingsHydrated(true), causing the save effect to persist selectedDeviceId: '' back to storage, silently erasing the user's device preference.

Consider not touching selectedDeviceId in this guard — the device list is simply unavailable, but the previously-selected ID is still valid for when devices become available later:

Suggested change
setSelectedDeviceId('');
setAvailableDevices([]);

settings.ui.widgetPosition = legacyWidgetPosition;
}

if (legacySplitRatio !== undefined && !settings.ui?.transcript?.mergedSplitRatio) {
Copy link

Choose a reason for hiding this comment

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

WARNING: Falsy check !settings.ui?.transcript?.mergedSplitRatio will incorrectly skip migration if mergedSplitRatio is 0. While the current minimum is 0.3 (so 0 is impossible after clamping), this pattern is fragile: if the minimum is ever lowered to 0, the legacy value will be silently ignored even when the new key has no stored value.

Prefer an explicit === undefined check for consistency with the rest of the codebase:

Suggested change
if (legacySplitRatio !== undefined && !settings.ui?.transcript?.mergedSplitRatio) {
if (legacySplitRatio !== undefined && settings.ui?.transcript?.mergedSplitRatio === undefined) {

const legacyWidgetPosition = readLegacyWidgetPosition();
const legacySplitRatio = readLegacySplitRatio();

if (legacyWidgetPosition && !settings.ui?.widgetPosition) {
Copy link

Choose a reason for hiding this comment

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

SUGGESTION: For consistency, the widget-position legacy migration check also uses a falsy guard (!settings.ui?.widgetPosition). An object { x: 0, y: 0 } is truthy so this is safe today, but aligning both checks to === undefined would make the intent explicit and guard against future edge cases:

Suggested change
if (legacyWidgetPosition && !settings.ui?.widgetPosition) {
if (legacyWidgetPosition && settings.ui?.widgetPosition === undefined) {

@kiloconnect
Copy link

kiloconnect bot commented Feb 21, 2026

Code Review Summary

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
src/stores/appStore.ts 255 Bidirectional substring match preferredDeviceLabel.includes(label) is overly permissive — a short device label like "USB" would match any preferred label containing "USB", causing false-positive device selection. Drop the reverse direction.
src/stores/appStore.ts N/A setAvailableDevices([]) is called when navigator.mediaDevices is unavailable (SSR/no-media env), but selectedDeviceId is not cleared. This is intentional (preserve persisted ID), but the asymmetry may confuse callers that check availableDevices().length > 0 before using selectedDeviceId().
src/utils/settingsStorage.ts 220 Falsy check !settings.ui?.transcript?.mergedSplitRatio will incorrectly skip the legacy migration if the clamped ratio is 0 (impossible given the [0.3, 0.7] range, but fragile). Use === undefined instead.

SUGGESTION

File Line Issue
src/utils/settingsStorage.ts 163 if (activeTab) is a truthy check; activeTab !== undefined is more explicit and consistent with the mergedSplitRatio !== undefined pattern on line 164.
src/utils/settingsStorage.ts 215 For consistency, the widget-position legacy migration guard should use === undefined rather than a falsy check.
Other Observations (not in diff)

No issues found in unchanged code.

Files Reviewed (5 files)
  • src/App.tsx — settings persistence wiring, debounced save, device restore on mount
  • src/stores/appStore.tsrefreshDevices label-matching logic
  • src/utils/settingsStorage.ts — new settings storage utility (sanitize, clamp, legacy migration)
  • src/utils/settingsStorage.test.ts — new tests for storage utility
  • src/stores/appStore.test.ts — new device-selection tests
  • src/components/DebugPanel.tsx — controlled height prop lift
  • src/components/TranscriptionDisplay.tsx — controlled split-ratio prop lift

Key Findings

refreshDevices label matching (blocking suggestion): The bidirectional substring check preferredDeviceLabel.includes(label) on appStore.ts:255 can match unintended devices. For example, if the persisted label is "USB Microphone" and a device has label "USB", the reverse condition fires and selects the wrong device. Only the forward direction (label.includes(preferredDeviceLabel)) is needed.

Settings persistence architecture: The createEffect-based debounce in App.tsx:394 is correct — it only tracks settings-related signals (not hot-path signals like matureText), so it won't fire during transcription. The settingsReadyForPersist guard correctly prevents premature saves before device enumeration completes.

Legacy migration: The falsy guards in settingsStorage.ts:215 and settingsStorage.ts:220 are safe in practice (valid range excludes 0/falsy values), but should use === undefined for correctness and future-proofing.

No fragile zones touched: This PR does not modify transcription window calculations, cursor/mature-cursor logic, or sliding-window merge/finalization logic. Risk is low.

Fix these issues in Kilo Cloud

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/utils/settingsStorage.ts (1)

200-227: Consider cleaning up legacy keys after successful migration.

Legacy keys (boncukjs-control-widget-pos, keet-merged-split-ratio) are read and merged into the unified settings but are never removed from localStorage. After a migration cycle (i.e., when the unified key is saved), these orphaned keys persist indefinitely. A one-time cleanup after migration would prevent stale legacy data from lingering.

♻️ Optional: remove legacy keys after merging
 export const loadSettingsFromStorage = (): PersistedSettings => {
   if (typeof localStorage === 'undefined') return {};
 
   let parsed: unknown = null;
   try {
     const raw = localStorage.getItem(APP_SETTINGS_STORAGE_KEY);
     if (raw) parsed = JSON.parse(raw);
   } catch {
     parsed = null;
   }
 
   const settings = sanitizeSettings(parsed);
   const legacyWidgetPosition = readLegacyWidgetPosition();
   const legacySplitRatio = readLegacySplitRatio();
 
   if (legacyWidgetPosition && !settings.ui?.widgetPosition) {
     settings.ui = settings.ui ?? {};
     settings.ui.widgetPosition = legacyWidgetPosition;
+    try { localStorage.removeItem('boncukjs-control-widget-pos'); } catch { /* ignore */ }
   }
 
   if (legacySplitRatio !== undefined && !settings.ui?.transcript?.mergedSplitRatio) {
     settings.ui = settings.ui ?? {};
     settings.ui.transcript = settings.ui.transcript ?? {};
     settings.ui.transcript.mergedSplitRatio = legacySplitRatio;
+    try { localStorage.removeItem('keet-merged-split-ratio'); } catch { /* ignore */ }
   }
 
   return settings;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/settingsStorage.ts` around lines 200 - 227, The
loadSettingsFromStorage function merges legacy values from
readLegacyWidgetPosition and readLegacySplitRatio into the sanitized settings
but never removes the old localStorage keys; after detecting and applying a
legacy value (i.e., when settings.ui.widgetPosition or
settings.ui.transcript.mergedSplitRatio gets set from legacy), call
localStorage.removeItem for the legacy keys (boncukjs-control-widget-pos and
keet-merged-split-ratio) so they are cleaned up once migration is applied;
ensure this cleanup happens only after a successful merge and before returning
settings, and keep use of APP_SETTINGS_STORAGE_KEY and sanitizeSettings
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/utils/settingsStorage.ts`:
- Around line 200-227: The loadSettingsFromStorage function merges legacy values
from readLegacyWidgetPosition and readLegacySplitRatio into the sanitized
settings but never removes the old localStorage keys; after detecting and
applying a legacy value (i.e., when settings.ui.widgetPosition or
settings.ui.transcript.mergedSplitRatio gets set from legacy), call
localStorage.removeItem for the legacy keys (boncukjs-control-widget-pos and
keet-merged-split-ratio) so they are cleaned up once migration is applied;
ensure this cleanup happens only after a successful merge and before returning
settings, and keep use of APP_SETTINGS_STORAGE_KEY and sanitizeSettings
unchanged.

Delay settings persistence until audio devices are enumerated to avoid overwriting cached selections with startup defaults.

Restore preferred input using label-tolerant matching with ID fallback, and keep selection when device enumeration is temporarily empty.

Add tests for empty-device enumeration and label-based restoration behavior.
const label = device.label.trim().toLowerCase();
if (!label) return false;
if (label === preferredDeviceLabel) return true;
return label.includes(preferredDeviceLabel) || preferredDeviceLabel.includes(label);
Copy link

Choose a reason for hiding this comment

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

WARNING: Bidirectional substring match is overly permissive

The second condition preferredDeviceLabel.includes(label) can produce false-positive matches. For example, if a device has a short label like "USB" and the persisted preferred label is "USB Microphone", the condition preferredDeviceLabel.includes(label) is true, so that device would be selected even though it may not be the right one. The intent is to find a device whose label contains the preferred label (forward match), not the reverse.

Consider dropping the reverse direction:

Suggested change
return label.includes(preferredDeviceLabel) || preferredDeviceLabel.includes(label);
return label.includes(preferredDeviceLabel);

mergedSplitValue === undefined ? undefined : clampMergedSplitRatio(mergedSplitValue);
if (activeTab || mergedSplitRatio !== undefined) {
nextUi.transcript = {};
if (activeTab) nextUi.transcript.activeTab = activeTab;
Copy link

Choose a reason for hiding this comment

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

SUGGESTION: Use !== undefined for consistency with the mergedSplitRatio guard on the same line

activeTab is StoredTranscriptTab | undefined. Both 'live' and 'merged' are truthy so the falsy check works in practice, but activeTab !== undefined is more explicit and consistent with the mergedSplitRatio !== undefined pattern used on line 164.

Suggested change
if (activeTab) nextUi.transcript.activeTab = activeTab;
if (activeTab !== undefined) nextUi.transcript.activeTab = activeTab;

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/stores/appStore.test.ts (1)

104-114: Extract the navigator.mediaDevices mock setup into a shared helper.

The same Object.defineProperty(navigator, 'mediaDevices', …) / Object.defineProperty(navigator.mediaDevices, 'enumerateDevices', …) block is copy-pasted in every test within "Device Management". A small helper (or a nested beforeEach) would eliminate ~60 lines of duplication and make new test cases easier to add.

♻️ Example helper
function mockEnumerateDevices(devices: Partial<MediaDeviceInfo>[]) {
  const mock = vi.fn().mockResolvedValue(devices);
  if (!navigator.mediaDevices) {
    Object.defineProperty(navigator, 'mediaDevices', {
      value: {},
      writable: true,
    });
  }
  Object.defineProperty(navigator.mediaDevices, 'enumerateDevices', {
    value: mock,
    writable: true,
  });
  return mock;
}

Then each test becomes:

it('should keep selected device when it still exists', async () => {
  mockEnumerateDevices([
    { kind: 'audioinput', deviceId: 'mic1', label: 'Microphone 1' },
    { kind: 'audioinput', deviceId: 'mic2', label: 'Microphone 2' },
  ]);
  store.setSelectedDeviceId('mic2');
  await store.refreshDevices();
  expect(store.selectedDeviceId()).toBe('mic2');
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/appStore.test.ts` around lines 104 - 114, Extract the repeated
navigator.mediaDevices mock setup into a shared helper function (e.g.,
mockEnumerateDevices) and replace the inline Object.defineProperty blocks in the
"Device Management" tests with calls to that helper; the helper should
create/define navigator.mediaDevices when missing and set
navigator.mediaDevices.enumerateDevices to a vi.fn() that resolves the provided
devices, return the mock so tests can assert calls, and update tests that
reference enumerateDevicesMock (and use store.refreshDevices /
store.setSelectedDeviceId / store.selectedDeviceId) to call the new helper
instead.
src/stores/appStore.ts (1)

249-261: Bidirectional includes for label matching may be overly permissive.

The condition label.includes(preferredDeviceLabel) || preferredDeviceLabel.includes(label) means a short preferred label like "mic" will match any device whose label contains "mic", and conversely a short device label will match any long preferred label. This could produce false positives when multiple devices share common substrings.

Consider scoring candidates (e.g. prefer exact, then longest substring overlap) or at minimum requiring a minimum substring length, so a renamed device doesn't accidentally bind to an unrelated one.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/appStore.ts` around lines 249 - 261, The current bidirectional
includes check in the preferred-device-selection block (using
preferredDeviceLabel, mics, preferredByLabel, and setSelectedDeviceId) is too
permissive; replace it with a stricter matching routine: first try exact match
(label === preferredDeviceLabel), then collect substring-match candidates and
score them by longest common substring or longest contiguous match length
(require a minimum match length, e.g. >= 3 characters) to avoid tiny matches
like "mic"; if multiple candidates tie, prefer the one with the longest label or
the first enumerated; only setSelectedDeviceId when a candidate passes the
minimum threshold. Ensure you update the matching logic where device.label is
normalized (trim().toLowerCase()) and fall back to previous behavior only if no
candidate meets the threshold.
src/App.tsx (1)

219-225: Negative clamping edge case in clampWidgetPosition.

When window.innerWidth < WIDGET_MAX_W (e.g. on a narrow mobile viewport), window.innerWidth - WIDGET_MAX_W is negative, making Math.min(negative, position.x) always negative, then Math.max(0, negative) clamps to 0. The widget is pinned to the left edge — functional but worth documenting or explicitly handling (e.g. centering the widget instead).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/App.tsx` around lines 219 - 225, clampWidgetPosition currently pins the
widget to the left/top when the viewport is smaller than the widget; change it
to compute maxX = window.innerWidth - WIDGET_MAX_W and maxY = window.innerHeight
- WIDGET_MIN_H and then: if maxX >= 0 clamp x between 0 and maxX, otherwise set
x = Math.floor(maxX / 2) to center horizontally (similarly for y using maxY),
keeping the function name clampWidgetPosition and constants WIDGET_MAX_W /
WIDGET_MIN_H to locate and update the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/App.tsx`:
- Around line 442-456: The cleanup for the event listeners registered inside the
onMount block is currently returned from onMount (ignored); move the removal
calls for window.removeEventListener('resize', onResize),
window.removeEventListener('beforeunload', onBeforeUnload),
window.removeEventListener('pagehide', onPageHide) and
document.removeEventListener('visibilitychange', onVisibilityChange) into the
existing onCleanup callback so those handlers are properly removed; locate the
onMount registration and the onCleanup function and transfer the four
removeEventListener calls into onCleanup (keeping the same handler references:
onResize, onBeforeUnload, onPageHide, onVisibilityChange).

---

Nitpick comments:
In `@src/App.tsx`:
- Around line 219-225: clampWidgetPosition currently pins the widget to the
left/top when the viewport is smaller than the widget; change it to compute maxX
= window.innerWidth - WIDGET_MAX_W and maxY = window.innerHeight - WIDGET_MIN_H
and then: if maxX >= 0 clamp x between 0 and maxX, otherwise set x =
Math.floor(maxX / 2) to center horizontally (similarly for y using maxY),
keeping the function name clampWidgetPosition and constants WIDGET_MAX_W /
WIDGET_MIN_H to locate and update the logic.

In `@src/stores/appStore.test.ts`:
- Around line 104-114: Extract the repeated navigator.mediaDevices mock setup
into a shared helper function (e.g., mockEnumerateDevices) and replace the
inline Object.defineProperty blocks in the "Device Management" tests with calls
to that helper; the helper should create/define navigator.mediaDevices when
missing and set navigator.mediaDevices.enumerateDevices to a vi.fn() that
resolves the provided devices, return the mock so tests can assert calls, and
update tests that reference enumerateDevicesMock (and use store.refreshDevices /
store.setSelectedDeviceId / store.selectedDeviceId) to call the new helper
instead.

In `@src/stores/appStore.ts`:
- Around line 249-261: The current bidirectional includes check in the
preferred-device-selection block (using preferredDeviceLabel, mics,
preferredByLabel, and setSelectedDeviceId) is too permissive; replace it with a
stricter matching routine: first try exact match (label ===
preferredDeviceLabel), then collect substring-match candidates and score them by
longest common substring or longest contiguous match length (require a minimum
match length, e.g. >= 3 characters) to avoid tiny matches like "mic"; if
multiple candidates tie, prefer the one with the longest label or the first
enumerated; only setSelectedDeviceId when a candidate passes the minimum
threshold. Ensure you update the matching logic where device.label is normalized
(trim().toLowerCase()) and fall back to previous behavior only if no candidate
meets the threshold.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
src/stores/appStore.ts (1)

249-261: Label containment match is unidirectional — consider bidirectional check.

label.includes(preferredDeviceLabel) only matches when the stored label is a substring of the current enumerated label. The reverse scenario — where the stored label is more specific than what the device currently reports (e.g., stored "Blue Yeti X Stereo Microphone" but device now reports "Blue Yeti X") — will silently fall through to the first-device fallback.

♻️ Proposed bidirectional containment check
         const preferredByLabel = mics.find((device) => {
           const label = device.label.trim().toLowerCase();
           if (!label) return false;
           if (label === preferredDeviceLabel) return true;
-          return label.includes(preferredDeviceLabel);
+          return label.includes(preferredDeviceLabel) || preferredDeviceLabel.includes(label);
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/appStore.ts` around lines 249 - 261, The current selection logic
in the preferredDeviceLabel block only checks if device.label contains
preferredDeviceLabel, which misses cases where the stored preferredDeviceLabel
is longer/more specific than the enumerated device.label; update the matching in
the mics.find callback (used to produce preferredByLabel) to perform a
bidirectional containment check (i.e., check both
device.label.includes(preferredDeviceLabel) OR
preferredDeviceLabel.includes(device.label)) while still normalizing with
.trim().toLowerCase(), then continue to call
setSelectedDeviceId(preferredByLabel.deviceId) when a match is found.
src/App.tsx (2)

275-275: widgetPos is always non-null — the null branch at line 1135 is dead code.

initialWidgetPos is always a { x, y } object (from clampWidgetPosition or getDefaultWidgetPosition), and setWidgetPos(null) is never called anywhere. The signal's | null type and the conditional class expression at line 1135 are therefore unreachable. Consider removing the null union if null-position is no longer a supported state, or document why it's kept for future use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/App.tsx` at line 275, The widgetPos signal is typed as possibly null but
initialWidgetPos (from clampWidgetPosition/getDefaultWidgetPosition) is always
an object and setWidgetPos(null) is never used, so remove the unnecessary null
union and the dead conditional class logic: change the signal declaration to
createSignal<{ x: number; y: number }>(initialWidgetPos) (remove | null), update
any call sites or JSX that check for widgetPos being null (the conditional class
expression currently guarding at the place using widgetPos) to use widgetPos()
directly, and if you want to keep a note about future nullable support, add a
short comment by the widgetPos/setWidgetPos declaration instead of keeping the
runtime null branch.

398-408: Non-idiomatic debounce cleanup — prefer onCleanup inside the effect.

The createEffect manually clears settingsSaveTimeout on every re-run and relies on the component-level onCleanup (via flushSettingsSave) to cancel any pending timeout on disposal. This works today because settingsReadyForPersist is never reset to false after becoming true, but if the effect returns early due to that guard changing, the pending timeout is silently left in flight.

The idiomatic SolidJS pattern is to register cleanup inside the effect so cancellation is automatic regardless of how the effect re-runs or disposes:

♻️ Proposed idiomatic debounce
   createEffect(() => {
     if (!settingsReadyForPersist()) return;
     const snapshot = getSettingsSnapshot();
-    if (settingsSaveTimeout) {
-      clearTimeout(settingsSaveTimeout);
-    }
-    settingsSaveTimeout = window.setTimeout(() => {
-      saveSettingsToStorage(snapshot);
-      settingsSaveTimeout = undefined;
-    }, SETTINGS_SAVE_DEBOUNCE_MS);
+    const id = window.setTimeout(() => {
+      saveSettingsToStorage(snapshot);
+    }, SETTINGS_SAVE_DEBOUNCE_MS);
+    onCleanup(() => clearTimeout(id));
   });

This removes the need for the settingsSaveTimeout module variable entirely (it would still be needed by flushSettingsSave, so keep it there if you want the flush behaviour, or refactor flushSettingsSave to take the snapshot eagerly).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/App.tsx` around lines 398 - 408, The effect should register its own
cleanup with onCleanup instead of manually clearing the module-level
settingsSaveTimeout at the top; inside createEffect call getSettingsSnapshot and
start a timeout (store it in a local variable, and if you still need
flushSettingsSave keep assigning that id to the module-level
settingsSaveTimeout), then call onCleanup(() => { clearTimeout(localTimeout);
settingsSaveTimeout = undefined; }) so the timer is cancelled on every re-run or
dispose (and remove the manual clearTimeout(settingsSaveTimeout) at the start of
the effect); keep using settingsReadyForPersist, saveSettingsToStorage and
getSettingsSnapshot as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/App.tsx`:
- Line 275: The widgetPos signal is typed as possibly null but initialWidgetPos
(from clampWidgetPosition/getDefaultWidgetPosition) is always an object and
setWidgetPos(null) is never used, so remove the unnecessary null union and the
dead conditional class logic: change the signal declaration to createSignal<{ x:
number; y: number }>(initialWidgetPos) (remove | null), update any call sites or
JSX that check for widgetPos being null (the conditional class expression
currently guarding at the place using widgetPos) to use widgetPos() directly,
and if you want to keep a note about future nullable support, add a short
comment by the widgetPos/setWidgetPos declaration instead of keeping the runtime
null branch.
- Around line 398-408: The effect should register its own cleanup with onCleanup
instead of manually clearing the module-level settingsSaveTimeout at the top;
inside createEffect call getSettingsSnapshot and start a timeout (store it in a
local variable, and if you still need flushSettingsSave keep assigning that id
to the module-level settingsSaveTimeout), then call onCleanup(() => {
clearTimeout(localTimeout); settingsSaveTimeout = undefined; }) so the timer is
cancelled on every re-run or dispose (and remove the manual
clearTimeout(settingsSaveTimeout) at the start of the effect); keep using
settingsReadyForPersist, saveSettingsToStorage and getSettingsSnapshot as
before.

In `@src/stores/appStore.ts`:
- Around line 249-261: The current selection logic in the preferredDeviceLabel
block only checks if device.label contains preferredDeviceLabel, which misses
cases where the stored preferredDeviceLabel is longer/more specific than the
enumerated device.label; update the matching in the mics.find callback (used to
produce preferredByLabel) to perform a bidirectional containment check (i.e.,
check both device.label.includes(preferredDeviceLabel) OR
preferredDeviceLabel.includes(device.label)) while still normalizing with
.trim().toLowerCase(), then continue to call
setSelectedDeviceId(preferredByLabel.deviceId) when a match is found.

@AAlp22 AAlp22 merged commit 23d330f into master Feb 21, 2026
3 checks passed
github-actions bot pushed a commit that referenced this pull request Feb 21, 2026
feat: persist and restore UI/audio/model settings 23d330f
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.

2 participants