From 279456f45c40b069ae9b6bcc418d07bbae18fc62 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:55:37 -0700 Subject: [PATCH 01/29] feat(whispering): redesign first-run onboarding as a two-column layout The setup screen wore the narrow max-w-lg centered shape meant for the capture column, so a tall setup card sat stranded beside ~560px of dead horizontal space and, with sm:py-0, hugged the top and bottom window edges. Split the first-run branch into a two-column grid: a left value-prop column (hero plus Private/On-device/Open-source trust items) beside the existing setup card on the right, stacking to a single column below lg. The hero is now a shared snippet so the capture view keeps its centered single-column layout. Columns are top-aligned so the headings share a line, and the container always keeps vertical padding so content never touches the window edges. --- apps/whispering/src/routes/(app)/+page.svelte | 492 ++++++++++-------- 1 file changed, 278 insertions(+), 214 deletions(-) diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index 45d18a73cb..50b0161f28 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -2,10 +2,14 @@ import { Button } from '@epicenter/ui/button'; import { confirmationDialog } from '@epicenter/ui/confirmation-dialog'; import { FileDropZone } from '@epicenter/ui/file-drop-zone'; + import * as Item from '@epicenter/ui/item'; import * as Kbd from '@epicenter/ui/kbd'; import { Link } from '@epicenter/ui/link'; import * as SectionHeader from '@epicenter/ui/section-header'; import * as ToggleGroup from '@epicenter/ui/toggle-group'; + import Cpu from '@lucide/svelte/icons/cpu'; + import Heart from '@lucide/svelte/icons/heart'; + import ShieldCheck from '@lucide/svelte/icons/shield-check'; import XIcon from '@lucide/svelte/icons/x'; import { createQuery } from '@tanstack/svelte-query'; import type { UnlistenFn } from '@tauri-apps/api/event'; @@ -179,10 +183,15 @@ Whispering -
- +{#snippet hero(layout: 'center' | 'split')} +
- + Press shortcut → speak → get text. Free and open source ❤️
+{/snippet} - - +
{#if !transcriptionReadiness.isReady} -
-
-

Set up transcription

-

- {transcriptionReadiness.primaryIssue ?? - 'Choose how Whispering turns your speech into text.'} -

-
- -
- {:else} - captureSurface.current, - (surface) => { - if (!surface) return; - void selectCaptureSurface(surface as CaptureSurface); - }} - class="w-full" - > - {#each CAPTURE_SURFACE_OPTIONS as option} - {@const SurfaceIcon = CAPTURE_SURFACE_META[option.value].Icon} - + +
+
- - - - {/each} - + {@render hero('split')} + + + + + + + Private and offline + + Audio is transcribed on your device and never uploaded. + + + + + + + + + Runs on this device + + No servers, no API keys, no monthly bill. + + + + + + + + + Free and open source + + Yours to keep, audit, and extend. + + + + +
- - {#if captureSurface.current === 'manual'} -
- - {#snippet pipeline()} - - - - - - - {/snippet} - - {#if manualRecorder.state === 'RECORDING'} - - {/if} -
- {:else if captureSurface.current === 'vad'} -
- - {#snippet pipeline()} - - - - - - - {/snippet} - -
- {:else if captureSurface.current === 'import'} -
- { - if (files.length > 0) { - await importFiles({ files }); - } - }} - onFileRejected={({ file, reason }) => { - report.error({ - cause: PageError.FileRejected({ - fileName: file.name, - reason, - }).error, - title: 'File rejected', - }); - }} - class="h-32 sm:h-36 w-full" - /> - - - +
+

Set up transcription

+

+ {transcriptionReadiness.primaryIssue ?? + 'Choose how Whispering turns your speech into text.'} +

+
+ -
+
- {/if} +
+ {:else} +
+ {@render hero('center')} - {#if latestRecording} -
- { - confirmationDialog.open({ - title: 'Delete recording', - description: 'Are you sure you want to delete this recording?', - confirm: { text: 'Delete', variant: 'destructive' }, - onConfirm: () => { - services.blobs.audio.revokeUrl(latestRecording.id); - recordings.delete(latestRecording.id); - report.success({ - title: 'Deleted recording!', - description: 'Your recording has been deleted.', - }); - }, - }); + + captureSurface.current, + (surface) => { + if (!surface) return; + void selectCaptureSurface(surface as CaptureSurface); }} - /> - - {#if audioPlaybackUrlQuery.data} - - {/if} -
- {/if} + class="w-full" + > + {#each CAPTURE_SURFACE_OPTIONS as option} + {@const SurfaceIcon = CAPTURE_SURFACE_META[option.value].Icon} + + + + + {/each} + -
+ {#if captureSurface.current === 'manual'} -

- {#if manualShortcutLabel} - Click the microphone to record{tauri ? ' here' : ''}, or press - + + {#snippet pipeline()} + + + + + + + {/snippet} + + {#if manualRecorder.state === 'RECORDING'} + {/if} -

+
{:else if captureSurface.current === 'vad'} -

- {#if vadShortcutLabel} - Click the microphone to listen{tauri ? ' here' : ''}, or press +

+ + {#snippet pipeline()} + + + + + + + {/snippet} + +
+ {:else if captureSurface.current === 'import'} +
+ { + if (files.length > 0) { + await importFiles({ files }); + } + }} + onFileRejected={({ file, reason }) => { + report.error({ + cause: PageError.FileRejected({ + fileName: file.name, + reason, + }).error, + title: 'File rejected', + }); + }} + class="h-32 sm:h-36 w-full" + /> + + + + +
+ {/if} + + {#if latestRecording} +
+ { + confirmationDialog.open({ + title: 'Delete recording', + description: 'Are you sure you want to delete this recording?', + confirm: { text: 'Delete', variant: 'destructive' }, + onConfirm: () => { + services.blobs.audio.revokeUrl(latestRecording.id); + recordings.delete(latestRecording.id); + report.success({ + title: 'Deleted recording!', + description: 'Your recording has been deleted.', + }); + }, + }); + }} + /> + + {#if audioPlaybackUrlQuery.data} + + {/if} +
+ {/if} + +
+ {#if captureSurface.current === 'manual'} +

+ {#if manualShortcutLabel} + Click the microphone to record{tauri ? ' here' : ''}, or press + + {manualShortcutLabel} + + {tauri ? 'to record from anywhere.' : 'to record.'} + {:else if tauri} + Click the microphone to record, or + set a global shortcut + to record from anywhere. + {:else} + Click the microphone to start recording. + {/if} +

+ {:else if captureSurface.current === 'vad'} +

+ {#if vadShortcutLabel} + Click the microphone to listen{tauri ? ' here' : ''}, or press + + {vadShortcutLabel} + + {tauri ? 'to listen from anywhere.' : 'to listen.'} + {:else if tauri} + Click the microphone to start a voice activated session, or + set a global shortcut + to listen from anywhere. + {:else} + Click the microphone to start a voice activated session. + {/if} +

+ {/if} +

+ {#if !tauri} + Tired of switching tabs? - {vadShortcutLabel} + Get the native desktop app - {tauri ? 'to listen from anywhere.' : 'to listen.'} - {:else if tauri} - Click the microphone to start a voice activated session, or - set a global shortcut - to listen from anywhere. - {:else} - Click the microphone to start a voice activated session. {/if}

- {/if} -

- {#if !tauri} - Tired of switching tabs? - - Get the native desktop app - - {/if} -

+
{/if}
From ddb64ac124c87cb0ab6cea010fe43dbb3fb89034 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:55:46 -0700 Subject: [PATCH 02/29] refactor(whispering): drop dead padding class from local model empty state The empty state passed class="py-8", but .cn-empty is scoped as .style-vega .cn-empty (specificity 0,2,0, unlayered) and already applies p-12, so the utility never took effect. Remove the no-op class. --- .../src/lib/components/settings/LocalModelSelector.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte index 1e9b5aec9c..d50e2d351a 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte @@ -266,7 +266,7 @@ {:else if !value && customEntries.length === 0} - + From ba89e06ce990ea1505f953e55d596580bb8ac5c4 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:07:17 -0700 Subject: [PATCH 03/29] style(whispering): checkpoint onboarding container and empty-state tweaks --- .../src/lib/components/settings/LocalModelSelector.svelte | 2 +- apps/whispering/src/routes/(app)/+page.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte index d50e2d351a..46e46a157e 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte @@ -266,7 +266,7 @@ {:else if !value && customEntries.length === 0} - + diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index 50b0161f28..c07e745c62 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -208,7 +208,7 @@ {/snippet}
{#if !transcriptionReadiness.isReady}
From 6a2555239813827d68a9fff3def1061a96160e7d Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:16:00 -0700 Subject: [PATCH 04/29] refactor(whispering): make first-run onboarding a single-column flow Replace the two-column hero/setup split with a single centered column: hero, then the setup card as the primary action, then the three trust guarantees as a reassurance strip below it. The columns could never balance (the setup card is intrinsically tall, the trust cards short), which left an L-shaped void and forced a diagonal eye path from title to download. One column reads top to bottom: what this is, what to do, why to trust it. The trust cards keep their copy and styling; they lay out as a 3-up grid on sm+ (icon on top) and collapse to stacked icon-left rows below sm. Setup form and trust strip share one max-w-2xl width so their edges line up. Simplify the hero snippet now that both branches center it. --- apps/whispering/src/routes/(app)/+page.svelte | 130 ++++++++---------- 1 file changed, 61 insertions(+), 69 deletions(-) diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index c07e745c62..a71f252fe0 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -183,15 +183,8 @@ Whispering -{#snippet hero(layout: 'center' | 'split')} - +{#snippet hero()} +
{#if !transcriptionReadiness.isReady} -
- -
-
- {@render hero('split')} - - - - - - - Private and offline - - Audio is transcribed on your device and never uploaded. - - - - - - - - - Runs on this device - - No servers, no API keys, no monthly bill. - - - - - - - - - Free and open source - - Yours to keep, audit, and extend. - - - - -
+
+ {@render hero()} -
-
-

Set up transcription

-

- {transcriptionReadiness.primaryIssue ?? - 'Choose how Whispering turns your speech into text.'} -

-
- +
+ +
+

Set up transcription

+

+ {transcriptionReadiness.primaryIssue ?? + 'Choose how Whispering turns your speech into text.'} +

+ +
+ + +
+ + + + + + Private and offline + + Audio is transcribed on your device and never uploaded. + + + + + + + + + Runs on this device + + No servers, no API keys, no monthly bill. + + + + + + + + + Free and open source + + Yours to keep, audit, and extend. + + +
{:else}
- {@render hero('center')} + {@render hero()} Date: Sat, 20 Jun 2026 23:29:56 -0700 Subject: [PATCH 05/29] polish(whispering): stack onboarding trust strip as single-column rows The three-guarantee trust strip sat in the narrower column of the two-column onboarding layout, where the 3-up grid wrapped each description to three lines. Drop the grid and the sm:flex-col override so each guarantee is a plain full-width Item row (icon, title, description): compact under the setup CTA, uncramped, and the default Item shape. --- apps/whispering/src/routes/(app)/+page.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index a71f252fe0..38ed34f0db 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -225,12 +225,14 @@ -
- +
+ @@ -241,7 +243,7 @@ - + @@ -252,7 +254,7 @@ - + From 5d2cfc6ac82d34c6cb1e4ddbd0cadd0ede557f19 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:44:36 -0700 Subject: [PATCH 06/29] feat(whispering): make first-run setup decisive instead of a service menu A normal user opening the not-ready home screen saw a "Service" dropdown of ~10 unfamiliar provider names and a model card buried under it, with no sign of which one thing to do. It described options instead of guiding. This leads with the recommended setup and hides the choice: - Hide the service picker on the primary path (new `hideServiceSelect` on TranscriptionRuntimeConfig). The screen now opens straight into the recommended service's setup: the local model download on desktop, the API-key field on web. - Tuck the full service picker behind a "Use a different service" disclosure for anyone who wants a cloud provider or a different model. - Drop the jargon from the recommended download button: "Download recommended model (670 MB)" instead of "Download Parakeet TDT 0.6B v3 (INT8) (~670 MB)". The card title already names the engine. Keeps the single-column layout and the vertical trust list. Setup is genuinely one decision, so this stays one screen rather than re-adding the /setup wizard that PR #1976 shipped and PR #2067 deliberately removed. --- .../settings/LocalModelSelector.svelte | 2 +- .../TranscriptionRuntimeConfig.svelte | 25 ++++++++----- apps/whispering/src/routes/(app)/+page.svelte | 35 +++++++++++++++++-- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte index 46e46a157e..607d04abcc 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte @@ -299,7 +299,7 @@ {:else} {/if} diff --git a/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte b/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte index d8f6f415fb..ab1e3aec65 100644 --- a/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte +++ b/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte @@ -36,6 +36,7 @@ label = 'Transcription Service', description, showAdvanced = true, + hideServiceSelect = false, class: className, }: { id?: string; @@ -43,6 +44,12 @@ description?: string | Snippet; /** When false, hide the advanced fields (unload policy, language, prompt). */ showAdvanced?: boolean; + /** + * When true, hide the service picker and render only the selected + * service's setup. Lets a host (the first-run screen) lead with the + * recommended setup and tuck the picker behind a disclosure of its own. + */ + hideServiceSelect?: boolean; class?: string; } = $props(); @@ -86,14 +93,16 @@ - settings.get('transcription.service'), - (selected) => - settings.set('transcription.service', selected)} - /> + {#if !hideServiceSelect} + settings.get('transcription.service'), + (selected) => + settings.set('transcription.service', selected)} + /> + {/if} {#if isSelectedServiceUnavailable && selectedTranscriptionProvider} diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index 38ed34f0db..3c15a5abe7 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -1,5 +1,6 @@ + +
+ + + + + Use a different service + + + + settings.get('transcription.service'), + (selected) => settings.set('transcription.service', selected)} + /> + + +
diff --git a/apps/whispering/src/lib/components/settings/index.ts b/apps/whispering/src/lib/components/settings/index.ts index 9d52597e4f..75abf931aa 100644 --- a/apps/whispering/src/lib/components/settings/index.ts +++ b/apps/whispering/src/lib/components/settings/index.ts @@ -14,3 +14,4 @@ export { default as TranscriptionSelector } from './selectors/TranscriptionSelec export { default as TransformationSelector } from './selectors/TransformationSelector.svelte'; export { default as VadDeviceSelector } from './selectors/VadDeviceSelector.svelte'; export { default as TranscriptionRuntimeConfig } from './TranscriptionRuntimeConfig.svelte'; +export { default as TranscriptionSetup } from './TranscriptionSetup.svelte'; diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index 3c15a5abe7..717cb09e52 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -1,6 +1,5 @@ + +{#snippet panelHead(Icon: Component, title: string, desc: string)} +
+ +
+
+

{title}

+

+ {desc} +

+
+{/snippet} + +
+
+ +
+ {#each steps as step, i} + {@const status = statusOf(i)} +
+
+ {#if status === 'done'} + + {:else} + {i + 1} + {/if} +
+ {step.label} +
+ {#if i < steps.length - 1} +
+
+
+ {/if} + {/each} +
+ + +
+ {#key transitionKey} +
+ {#if done} +
+
+ +
+
+

+ {dictationCapability.isActive + ? "You're ready to dictate anywhere" + : "You're ready to record in Whispering"} +

+

+ {dictationCapability.isActive + ? 'Press your shortcut in any app, speak, and your words appear.' + : 'Click the microphone on the home screen and speak. You can turn on dictate-anywhere later.'} +

+
+ {#if dictationCapability.isActive && shortcutLabel} +
+ Try it now: + {shortcutLabel} +
+ {/if} + +
+ {:else if current?.key === 'engine'} +
+
+

+ Set up your voice engine +

+

+ {transcriptionReadiness.primaryIssue ?? + 'Choose what turns your speech into text. You can change it later.'} +

+
+ +
+ {:else if current?.key === 'try'} + + + +
+

+ Try your first dictation +

+

+ Click record and say anything. Whispering turns it into text. +

+
+ + {#if isRecording} +
+
+ + +
+ + + Listening… click to stop + +
+ {:else if isStopping} + + Transcribing… + + {:else if practiceTranscript} +
+
+
+ It works +
+

"{practiceTranscript}"

+
+ +
+ {:else} + + {/if} +
+
+ {:else} + + + {@render panelHead( + ShieldCheck, + 'Want to dictate in every app?', + 'To type into any other app (your editor, browser, chat) with a global shortcut, macOS needs Accessibility access. It is optional, and you can turn it on anytime.', + )} + + {#if dictationCapability.isActive} + + Accessibility is on + + {:else} + +

+ Opens a short guide. We detect it automatically once it's on. +

+ {/if} +
+
+ {/if} +
+ {/key} +
+ + + {#if !done} +
+ + {#if current?.key === 'try' && !practiceTranscript} + + {:else if current?.key === 'access' && !dictationCapability.isActive} + + {:else} + + {/if} +
+ {/if} +
+
+ + From bcbb8259d477004d592a5d5320cbf9ad2881b1bf Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:20:30 -0700 Subject: [PATCH 09/29] refactor(whispering): collapse first-run setup indirection Three simplifications surfaced reviewing the first-run flow: - FirstRunSetup: derive the practice transcript instead of latching it in an $effect. stopManualRecording awaits the whole pipeline, so the row is saved by the time isSuccess flips; computing it synchronously also drops a one-frame flash of the Record button before the post-effect write. - Inline TranscriptionSetup into the wizard's engine step and delete it. It was extracted to be shared by the wizard and a minimal home not-ready screen, but the next commit replaced that screen with FirstRunSetup, so it had a single consumer, a dead class prop, and a stale shared-core comment. - home: seed setupActive with a direct getTranscriptionReadiness() call instead of a standing $derived read once under untrack. --- .../settings/TranscriptionSetup.svelte | 45 ---------------- .../src/lib/components/settings/index.ts | 1 - apps/whispering/src/routes/(app)/+page.svelte | 7 ++- .../(app)/_components/FirstRunSetup.svelte | 53 +++++++++++++++---- 4 files changed, 47 insertions(+), 59 deletions(-) delete mode 100644 apps/whispering/src/lib/components/settings/TranscriptionSetup.svelte diff --git a/apps/whispering/src/lib/components/settings/TranscriptionSetup.svelte b/apps/whispering/src/lib/components/settings/TranscriptionSetup.svelte deleted file mode 100644 index b2c5cf5124..0000000000 --- a/apps/whispering/src/lib/components/settings/TranscriptionSetup.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - -
- - - - - Use a different service - - - - settings.get('transcription.service'), - (selected) => settings.set('transcription.service', selected)} - /> - - -
diff --git a/apps/whispering/src/lib/components/settings/index.ts b/apps/whispering/src/lib/components/settings/index.ts index 75abf931aa..9d52597e4f 100644 --- a/apps/whispering/src/lib/components/settings/index.ts +++ b/apps/whispering/src/lib/components/settings/index.ts @@ -14,4 +14,3 @@ export { default as TranscriptionSelector } from './selectors/TranscriptionSelec export { default as TransformationSelector } from './selectors/TransformationSelector.svelte'; export { default as VadDeviceSelector } from './selectors/VadDeviceSelector.svelte'; export { default as TranscriptionRuntimeConfig } from './TranscriptionRuntimeConfig.svelte'; -export { default as TranscriptionSetup } from './TranscriptionSetup.svelte'; diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index a000273c7c..b6c93118f7 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -9,7 +9,7 @@ import XIcon from '@lucide/svelte/icons/x'; import { createQuery } from '@tanstack/svelte-query'; import type { UnlistenFn } from '@tauri-apps/api/event'; - import { onDestroy, onMount, untrack } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { defineErrors, extractErrorMessage } from 'wellcrafted/error'; import { tryAsync } from 'wellcrafted/result'; import { commandCallbacks } from '$lib/commands'; @@ -55,17 +55,16 @@ import VadRecordingAction from './_components/VadRecordingAction.svelte'; const latestRecording = $derived(recordings.sorted[0]); - const transcriptionReadiness = $derived(getTranscriptionReadiness()); // First-run setup is "active" from mount whenever transcription isn't ready; // the only in-mount transition is the user finishing it (onComplete sets it - // false). The initial value is read synchronously from readiness, so there is + // false). The initial value is computed once here, synchronously, so there is // no first-paint flash and no $effect latch, and no persisted "seen // onboarding" flag (which would drift from readiness). It never needs to flip // back to true within a mount because nothing on the recorder screen can make // you un-ready; a regression (model deleted in settings) re-activates it for // free, because SvelteKit remounts this page on navigation back to home. So // first run and a later regression show the same flow. - let setupActive = $state(untrack(() => !transcriptionReadiness.isReady)); + let setupActive = $state(!getTranscriptionReadiness().isReady); // The selected transformation's pipeline glyph morphs into that // transformation's row on the /transformations list, so name it with the same // id the row carries. Only the home pipeline opts in; the config topbar leaves diff --git a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte index 2edcc270a7..3be670809d 100644 --- a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte +++ b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte @@ -14,6 +14,7 @@ - {#if !hideServiceSelect} - settings.get('transcription.service'), - (selected) => - settings.set('transcription.service', selected)} - /> - {/if} - {#if isSelectedServiceUnavailable && selectedTranscriptionProvider} Desktop-only service selected diff --git a/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte b/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte index f66d8e60f4..5d6ff9dab8 100644 --- a/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte +++ b/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte @@ -1,6 +1,8 @@ Transcription Settings - Whispering @@ -12,5 +14,9 @@ hints work. + settings.get('transcription.service'), + (selected) => settings.set('transcription.service', selected)} + /> diff --git a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte index 6b40427b0a..dab1ba4a40 100644 --- a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte +++ b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte @@ -250,7 +250,7 @@ default; the picker is a wall of unfamiliar provider names that reads as "this is a developer tool". --> - + Date: Mon, 22 Jun 2026 00:46:15 -0700 Subject: [PATCH 12/29] feat(whispering): add a first-run welcome step with the trust strip On a true first run (no recordings yet) the wizard now opens with a welcome: the Whispering mark, the tagline, and the three guarantees (private by default, runs on your device, free and open source) as a single column of muted Item rows, then a Get started CTA into the engine setup. It restores the trust strip the decisive-setup pass had dropped, but as a pre-step a returning user (who already has recordings) skips. Gated on a mount-time snapshot of the recording count, not a persisted seen- onboarding flag, so it stays flag-free; the snapshot is a $state read (not a $derived) so the welcome can't flash away as the Yjs table hydrates. --- .../(app)/_components/FirstRunSetup.svelte | 103 ++++++++++++++++-- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte index dab1ba4a40..649d40ff1f 100644 --- a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte +++ b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte @@ -5,25 +5,30 @@ helpful thing. No persisted "have they seen it" flag; home holds the flow open with ephemeral state and releases on completion. - Value-first order: engine -> first dictation (the aha; the mic prompt happens - in-context here) -> dictate-anywhere upsell (macOS only; reuses the existing - Accessibility guide and reads the live capability) -> done. Steps are derived - from live state: the Accessibility step is absent where it has no meaning - (web, Linux Wayland) and adapts its content to the capability. + Value-first order: welcome (true first run only) -> engine -> first dictation + (the aha; the mic prompt happens in-context here) -> dictate-anywhere upsell + (macOS only; reuses the existing Accessibility guide and reads the live + capability) -> done. The welcome is gated on a zero-recording first run; the + Accessibility step is absent where it has no meaning (web, Linux Wayland) and + adapts its content to the capability. --> - + {#if isSelectedServiceUnavailable && selectedTranscriptionProvider} Desktop-only service selected From 18bfb6683cb6e2579311e770539bea9f216d4557 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:14:29 -0700 Subject: [PATCH 14/29] refactor(whispering): give RecordingActionCard one controller, not eight props ManualRecordingAction and VadRecordingAction each derived the same eight RecordingActionCard props (active/pending/icon/label/description/tooltip/ shortcutLabel/onclick) from their recorder state, then spelled the whole mapping out at the call site. Extract that into createManualRecordingController and createVadRecordingController, both satisfying a RecordingActionController contract, and have the card take a single controller prop. The card also owns the hide-footer-while-active rule, so callers stop repeating active ? undefined : pipeline. Same behavior; the mapping lives in one place. --- .../_components/ManualRecordingAction.svelte | 63 +------------ .../_components/RecordingActionCard.svelte | 70 +++++++-------- .../_components/VadRecordingAction.svelte | 38 +------- .../manual-recording-controller.svelte.ts | 88 +++++++++++++++++++ .../recording-action-controller.ts | 25 ++++++ .../vad-recording-controller.svelte.ts | 64 ++++++++++++++ 6 files changed, 218 insertions(+), 130 deletions(-) create mode 100644 apps/whispering/src/routes/(app)/_components/manual-recording-controller.svelte.ts create mode 100644 apps/whispering/src/routes/(app)/_components/recording-action-controller.ts create mode 100644 apps/whispering/src/routes/(app)/_components/vad-recording-controller.svelte.ts diff --git a/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte b/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte index bbd60008cb..7109506e18 100644 --- a/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte +++ b/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte @@ -1,14 +1,7 @@ diff --git a/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte b/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte index 9a0a38490c..2ccec076d2 100644 --- a/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte +++ b/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte @@ -3,28 +3,20 @@ import * as Kbd from '@epicenter/ui/kbd'; import { Spinner } from '@epicenter/ui/spinner'; import { cn } from '@epicenter/ui/utils'; - import type { Component, Snippet } from 'svelte'; + import type { Snippet } from 'svelte'; import { dictationCapability } from '$lib/state/dictation-capability.svelte'; + import type { RecordingActionController } from './recording-action-controller'; - // The caller owns its own state machine, so it picks which icon to show - // and hands us one `icon`. We only decide presentation: a spinner while - // pending, and the destructive "filled" treatment while `active`. + // The controller owns the state machine and every derived label/icon. The card + // only decides presentation: a spinner while pending, the destructive "filled" + // treatment while active, and a footer shown only at rest. let { - active = false, - description, + controller, footer, - icon: Icon, iconViewTransitionName, - label, - onclick, - pending = false, - shortcutLabel, - tooltip, }: { - active?: boolean; - description: string; + controller: RecordingActionController; footer?: Snippet; - icon: Component<{ class?: string }>; /** * When set, names the action glyph for a cross-page view transition while * the card is at rest. Suppressed automatically while `active`, because the @@ -33,67 +25,71 @@ * the card owns the at-rest gate. */ iconViewTransitionName?: string; - label: string; - onclick: () => void; - pending?: boolean; - shortcutLabel?: string; - tooltip: string; } = $props(); const accessibleLabel = $derived( - shortcutLabel ? `${label} (${shortcutLabel})` : label, + controller.shortcutLabel + ? `${controller.label} (${controller.shortcutLabel})` + : controller.label, );
+ It works
- {:else} - - {/if} - - +

"{practiceTranscript}"

+
+ {/if} +
{:else} {#if !done}
- {#if current?.key === 'try' && !practiceTranscript} @@ -485,20 +441,6 @@
From 4d486491d7e2e9bde17bfb77eaaa5e9994f3af06 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:26:55 -0700 Subject: [PATCH 16/29] refactor(whispering): make CapturePipeline a surface-driven single source of truth +page.svelte hand-assembled the capture pipeline three times inline (manual / vad / import), differing only in the device selector and whether capture behavior shows, then drilled each down as a pipeline snippet. The pipeline is fully determined by the surface, so give CapturePipeline a `surface` prop and move the selector composition plus the view-transition names into it. The action components render their own footer; +page drops the inline snippets, the prop-drilling, the transformation view-transition derivation, and five now-unused imports. --- apps/whispering/src/routes/(app)/+page.svelte | 71 +------------------ .../(app)/_components/CapturePipeline.svelte | 52 +++++++++++--- .../_components/ManualRecordingAction.svelte | 15 ++-- .../_components/VadRecordingAction.svelte | 15 ++-- 4 files changed, 56 insertions(+), 97 deletions(-) diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index b6c93118f7..291048be19 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -15,12 +15,6 @@ import { commandCallbacks } from '$lib/commands'; import DictationCapabilityNotice from '$lib/components/DictationCapabilityNotice.svelte'; import TranscriptDialog from '$lib/components/copyable/TranscriptDialog.svelte'; - import { - TranscriptionSelector, - TransformationSelector, - } from '$lib/components/settings'; - import ManualDeviceSelector from '$lib/components/settings/selectors/ManualDeviceSelector.svelte'; - import VadDeviceSelector from '$lib/components/settings/selectors/VadDeviceSelector.svelte'; import { CAPTURE_SURFACE_META, CAPTURE_SURFACE_OPTIONS, @@ -43,12 +37,10 @@ import { dictationCapability } from '$lib/state/dictation-capability.svelte'; import { manualRecorder } from '$lib/state/manual-recorder.svelte'; import { recordings } from '$lib/state/recordings.svelte'; - import { settings } from '$lib/state/settings.svelte'; import { getRecordingShortcutLabel } from '$lib/utils/recording-shortcut'; import { viewTransition } from '$lib/utils/viewTransitions'; import studioMicrophone from '$lib/assets/studio-microphone.png'; import { tauri } from '#platform/tauri'; - import CaptureBehaviorPopover from './_components/CaptureBehaviorPopover.svelte'; import CapturePipeline from './_components/CapturePipeline.svelte'; import FirstRunSetup from './_components/FirstRunSetup.svelte'; import ManualRecordingAction from './_components/ManualRecordingAction.svelte'; @@ -65,15 +57,6 @@ // free, because SvelteKit remounts this page on navigation back to home. So // first run and a later regression show the same flow. let setupActive = $state(!getTranscriptionReadiness().isReady); - // The selected transformation's pipeline glyph morphs into that - // transformation's row on the /transformations list, so name it with the same - // id the row carries. Only the home pipeline opts in; the config topbar leaves - // its selector unnamed so it never collides with the rows on /transformations. - const transformationViewTransitionName = $derived( - viewTransition.transformation( - settings.get('transformation.selectedId') ?? null, - ), - ); // The recording shortcut that actually fires on this platform, via the // `#platform/shortcuts` label seam: desktop binds push-to-talk (Fn) globally // and ships the toggle unbound, so prefer it; the browser shows the local @@ -242,33 +225,9 @@ {/each}
- {#if captureSurface.current === 'manual'}
- - {#snippet pipeline()} - - - - - - - {/snippet} - + {#if manualRecorder.state === 'RECORDING'}
{/if} diff --git a/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte b/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte index 7b7375bb10..38537bbcf0 100644 --- a/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte +++ b/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte @@ -1,53 +1,17 @@ - -
- {#if surface === 'manual'} - - {:else if surface === 'vad'} - - {/if} - - - {#if surface !== 'import'} + + {#snippet trailing()} - {/if} -
+ {/snippet} + diff --git a/apps/whispering/src/routes/(app)/_components/CapturePipelineCore.svelte b/apps/whispering/src/routes/(app)/_components/CapturePipelineCore.svelte new file mode 100644 index 0000000000..6407eddf7d --- /dev/null +++ b/apps/whispering/src/routes/(app)/_components/CapturePipelineCore.svelte @@ -0,0 +1,43 @@ + + + +
+ {@render leading?.()} + + + {@render trailing?.()} +
diff --git a/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte b/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte index f038698ebd..967e95cf85 100644 --- a/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte +++ b/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte @@ -1,4 +1,5 @@ - - {#snippet trailing()} +
+ {#if children} + {@render children()} + {/if} + + + {#if children} - {/snippet} - + {/if} +
diff --git a/apps/whispering/src/routes/(app)/_components/CapturePipelineCore.svelte b/apps/whispering/src/routes/(app)/_components/CapturePipelineCore.svelte deleted file mode 100644 index 6407eddf7d..0000000000 --- a/apps/whispering/src/routes/(app)/_components/CapturePipelineCore.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - - -
- {@render leading?.()} - - - {@render trailing?.()} -
From 5dd4655707ffe3f18b9094ef1717e41822177770 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:20:10 -0700 Subject: [PATCH 19/29] refactor(whispering): render the first-run model step as a download hero The first-run wizard tucked the whole model library into its engine step: an "All models" disclosure (showing one Parakeet model behind it), a bring-your-own box, and a second download button for the model the hero already offers. That surface belongs in settings, not first run. Give LocalModelSelector a `compact` mode that renders the hero only (download / active summary / missing warning) and drops the All-models list, the bring-your-own box, the footer, and the summary's "Change" button. TranscriptionRuntimeConfig derives it from its existing first-run signal, so the settings page keeps the full library and the wizard gets one action. --- .../settings/LocalModelSelector.svelte | 36 ++++++++++++------- .../TranscriptionRuntimeConfig.svelte | 9 +++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte index 607d04abcc..d4a0eaea78 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte @@ -43,6 +43,11 @@ * list (catalog download cards, custom folder entries, the folder help * box) collapses behind "All models". The list is backed by the engine's * models folder; the bindable value is the active entry's name. + * + * `compact` drops everything past the hero (the "All models" list, the + * bring-your-own box, the footer, and the summary's "Change" button): the + * first-run wizard wants one action, not the whole library, which stays on + * the settings page where `compact` is false. */ type LocalModelSelectorProps = { /** @@ -63,6 +68,9 @@ /** Optional footer content (download sources, naming notes) */ footer?: Snippet; + + /** Render the hero only, for the first-run wizard. See the type doc. */ + compact?: boolean; }; let { @@ -71,6 +79,7 @@ description, value = $bindable(), footer, + compact = false, }: LocalModelSelectorProps = $props(); const engine = $derived(models[0].engine); @@ -256,24 +265,25 @@ Active - + {#if !compact} + + {/if} - {:else if !value && customEntries.length === 0} + {:else if !value && (compact || customEntries.length === 0)} No local model installed - Runs on this device — private, offline, and free. Download the - recommended model to start transcribing. + Download the recommended model to start transcribing on this device. {#if recommendedState.type === 'downloading'} @@ -312,12 +322,13 @@ Selected model is missing

- "{value}" is no longer in the models folder. Pick another model under - All models, or add yours back and activate it. + "{value}" is no longer in the models folder. Download it again, or add + your own and activate it.

{/if} + {#if !compact} + {/if} diff --git a/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte b/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte index 709b1ef0e7..028dcacfed 100644 --- a/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte +++ b/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte @@ -43,6 +43,12 @@ showAdvanced?: boolean; } = $props(); + // The settings page wants the full surface; the first-run wizard wants the + // minimal one. The two move together: the only caller that hides the advanced + // fields is the wizard, and it also wants the local model selector collapsed + // to its download hero. One signal, both behaviors. + const compact = $derived(!showAdvanced); + const currentServiceCapabilities = $derived( PROVIDERS[settings.get('transcription.service')].capabilities, ); @@ -296,6 +302,7 @@ {:else if settings.get('transcription.service') === 'whispercpp'}
Date: Mon, 22 Jun 2026 02:21:04 -0700 Subject: [PATCH 20/29] fix(whispering): widen the first-run welcome CTA to the trust cards The "Get started" button collapsed to `w-auto` past the `sm` breakpoint, so on desktop it hugged its label under three full-width trust cards and read as orphaned. Make it full-width so the call to action anchors the column. --- .../src/routes/(app)/_components/FirstRunSetup.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte index 13503cab74..325eac2e0d 100644 --- a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte +++ b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte @@ -228,7 +228,7 @@
diff --git a/apps/whispering/src/routes/(app)/_components/RecordingResult.svelte b/apps/whispering/src/routes/(app)/_components/RecordingResult.svelte new file mode 100644 index 0000000000..ec57032032 --- /dev/null +++ b/apps/whispering/src/routes/(app)/_components/RecordingResult.svelte @@ -0,0 +1,57 @@ + + + +
+ + {#if audioQuery.data} + + {/if} +
From f471a0a68db3590937068b7b607e3652e82ca5e9 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:09:36 -0700 Subject: [PATCH 28/29] refactor(whispering): collapse the model folder to one source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model selector had two truth sources for "is a model on disk," refreshed on different schedules and able to drift: a component-local folder scan (mount / focus / own-mutation) and per-model download handles holding a separate stat. That split was the whole bug class behind false "missing," slow-to-active, and needs-a-refresh; the previous fix only taught the missing-check to read the less-stale of the two. Collapse both into one global per-engine `modelFolder` store. It unifies the two genuinely different kinds of truth a selector needs, and nothing more: disk state at rest (the scan plus each catalog model's completeness, refreshed in one cycle) and in-flight transfers in motion (progress, cancel; a SvelteMap whose re-set repaints the bar). Because it is a global singleton, a download started anywhere updates the one store and every mounted view reacts; there is no private scan to go stale. Selection stays in deviceConfig, a separate concern the views join. The selector and the catalog row become pure views over the store, and the missing-check collapses back to the obvious one-liner — the catalog special-case deletes itself, because the staleness it worked around no longer exists. Deletes local-model-downloads.svelte.ts. --- .../settings/LocalModelDownloadCard.svelte | 37 +-- .../settings/LocalModelSelector.svelte | 94 +++----- .../components/settings/local-model-toasts.ts | 2 +- .../lib/state/local-model-downloads.svelte.ts | 216 ----------------- .../src/lib/state/model-folder.svelte.ts | 226 ++++++++++++++++++ 5 files changed, 278 insertions(+), 297 deletions(-) delete mode 100644 apps/whispering/src/lib/state/local-model-downloads.svelte.ts create mode 100644 apps/whispering/src/lib/state/model-folder.svelte.ts diff --git a/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte b/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte index b4df3dfab1..dfce575f86 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte @@ -11,47 +11,50 @@ type LocalModelConfig, modelEntryName, } from '$lib/constants/local-models'; - import { localModelDownloads } from '$lib/state/local-model-downloads.svelte'; + import { deleteModelEntry } from '$lib/services/transcription/local-model-folder'; + import type { ModelFolder } from '$lib/state/model-folder.svelte'; import { announceModelDelete, announceModelDownload, } from './local-model-toasts'; let { + folder, model, value = $bindable(), recommended = false, - onDiskChange, }: { + /** The shared folder store, owned by the selector and passed to every row. */ + folder: ModelFolder; model: LocalModelConfig; /** Bindable selected folder entry name for this engine. */ value: string; /** Show the Recommended badge; the selector decides when it guides a choice. */ recommended?: boolean; - /** Re-scan the parent selector after this card changes the models folder. */ - onDiskChange: () => void | Promise; } = $props(); - // Shared per-model handle: the selector hero reads the same one, so a - // download started in either place shows its progress in both. - const download = $derived(localModelDownloads.get(model)); - - // Aliased so the template narrows the union per branch. - const modelState = $derived(download.state); + // Aliased so the template narrows the union per branch. The state comes from + // the shared store, so a download started anywhere shows its progress here. + const modelState = $derived(folder.stateOf(model)); const entryName = $derived(modelEntryName(model)); const isActive = $derived(value === entryName && modelState.type === 'ready'); async function downloadModel() { - const entryName = announceModelDownload(await download.download()); - if (!entryName) return; - value = entryName; - await onDiskChange(); + // The store re-scans itself on completion, so the shared selector reacts. + const downloaded = announceModelDownload(await folder.download(model)); + if (!downloaded) return; + value = downloaded; } async function deleteModel() { - if (!announceModelDelete(await download.delete())) return; + if ( + !announceModelDelete( + await deleteModelEntry({ engine: model.engine, name: entryName }), + ) + ) + return; if (value === entryName) value = ''; - await onDiskChange(); + await folder.refresh(); } async function activateModel() { @@ -60,7 +63,7 @@ } async function cancelDownload() { - await download.cancel(); + await folder.cancel(model); } diff --git a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte index aba478721c..3594be6116 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte @@ -22,13 +22,11 @@ import { deleteModelEntry, linkModelEntry, - listModelEntries, type ModelEntry, revealModelsFolder, } from '$lib/services/transcription/local-model-folder'; import { PROVIDERS } from '$lib/services/transcription/providers'; - import { localModelDownloads } from '$lib/state/local-model-downloads.svelte'; - import { tauri } from '#platform/tauri'; + import { modelFolder } from '$lib/state/model-folder.svelte'; import { announceModelDelete, announceModelDownload, @@ -90,14 +88,20 @@ const engine = $derived(models[0].engine); const modelKind = $derived(PROVIDERS[engine].modelKind); - /** Folder entry names the catalog cards already represent. */ - const catalogNames = $derived(new Set(models.map(modelEntryName))); + // The one shared folder store for this engine: the single source of disk state + // (the scan) and in-flight downloads. Every view (this selector, its hero, each + // catalog row) reads it, so a download started anywhere updates them all + // reactively; nothing here keeps a private scan that could go stale. + const folder = $derived(modelFolder(models)); - let entries = $state(null); + // Re-scan on mount and when the engine changes. The store persists across + // mounts, so an explicit refresh here catches a folder that changed while it + // was unmounted; window focus catches changes made while mounted. + $effect(() => { + folder.refresh(); + }); - const customEntries = $derived( - (entries ?? []).filter((entry) => !catalogNames.has(entry.name)), - ); + const customEntries = $derived(folder.customEntries()); /** The catalog model behind the active entry, when it is a catalog one. */ const activeCatalogModel = $derived( @@ -108,59 +112,30 @@ customEntries.find((entry) => entry.name === value) ?? null, ); - // Acquire the active catalog model's download handle in its own derived so the - // missing-check below can track its `.state` (a derived does not depend on - // state it created itself, so acquisition and the state read must be separate). - const activeCatalogDownload = $derived( - activeCatalogModel ? localModelDownloads.get(activeCatalogModel) : null, + // "Missing" means nothing in the folder backs the active selection. One truth: + // the store's scan, which is global and reactive, so a model downloaded after + // the user navigated away no longer reads as missing and needs no special-case. + const isSelectionMissing = $derived( + !!value && folder.loaded && !folder.present(value), ); - // "Missing" means nothing on disk backs the active selection. For a catalog - // model, read the global download handle, not the event-driven folder scan: - // the handle survives this component and flips the moment a download promotes - // its files, so a model downloaded after the user navigated away (e.g. switched - // to Cloud mid-download, unmounting this selector) stops reading as missing - // without waiting for a window-focus rescan. Custom (bring-your-own) entries - // have no handle and fall back to the scan. - const isSelectionMissing = $derived.by(() => { - if (!value) return false; - if (activeCatalogDownload) { - return activeCatalogDownload.state.type === 'not-downloaded'; - } - return entries !== null && !entries.some((e) => e.name === value); - }); - /** The engine's default download; the hero builds its action around it. */ const recommended = $derived(RECOMMENDED_MODELS[engine]); - const recommendedDownload = $derived(localModelDownloads.get(recommended)); - // Aliased so the template narrows the union per branch. Shared with the - // catalog row for the same model, so a download started here shows its - // progress there too. - const recommendedState = $derived(recommendedDownload.state); - - async function refreshEntries() { - if (!tauri) return; - entries = await listModelEntries(engine); - // The folder is user-editable truth, so the catalog handles re-check - // disk on the same signal that rescans the folder. Await the disk-stat - // so `isInstalled` (and the "Downloaded" badge it drives) is settled - // before the listing renders, instead of racing the next render. - await Promise.all(models.map((model) => localModelDownloads.get(model).refresh())); - } + // Aliased so the template narrows the union per branch. Shared with the catalog + // row for the same model, so a download started here shows its progress there. + const recommendedState = $derived(folder.stateOf(recommended)); async function downloadRecommendedModel() { - const entryName = announceModelDownload(await recommendedDownload.download()); - if (!entryName) return; - // Rescan before selecting so the new entry is already in the list when - // `value` flips, instead of flashing "Selected model is missing" for the - // duration of the rescan. - await refreshEntries(); - value = entryName; + // The store re-scans itself on completion, so `value` lands on a present + // entry instead of flashing "Selected model is missing". + const downloaded = announceModelDownload(await folder.download(recommended)); + if (!downloaded) return; + value = downloaded; } async function cancelRecommendedDownload() { - await recommendedDownload.cancel(); + await folder.cancel(recommended); } /** Point the engine's selection at an on-disk entry by name. */ @@ -169,13 +144,6 @@ toast.success('Model activated'); } - // Rescan on mount and when the engine changes. Selection changes do not - // change disk; download/delete handlers refresh after they change the folder. - $effect(() => { - void engine; - refreshEntries(); - }); - async function openModelsFolder() { const { error } = await revealModelsFolder(engine); if (error) { @@ -231,7 +199,7 @@ } // Rescan before selecting so the new link is already in the list when // `value` flips (no transient "Selected model is missing" flash). - await refreshEntries(); + await folder.refresh(); value = entryName; toast.success('Model linked', { description: `${entryName} now points to your file. Deleting it later removes only the link.`, @@ -242,11 +210,11 @@ if (!announceModelDelete(await deleteModelEntry({ engine, name: entry.name }))) return; if (value === entry.name) value = ''; - await refreshEntries(); + await folder.refresh(); } - + {#snippet body()} {#if isSelectionMissing} @@ -326,10 +294,10 @@ {#each models as model (model.id)} 1 && model.id === recommended.id} - onDiskChange={refreshEntries} /> {/each} diff --git a/apps/whispering/src/lib/components/settings/local-model-toasts.ts b/apps/whispering/src/lib/components/settings/local-model-toasts.ts index 3021ca9f88..91542b6a0f 100644 --- a/apps/whispering/src/lib/components/settings/local-model-toasts.ts +++ b/apps/whispering/src/lib/components/settings/local-model-toasts.ts @@ -1,7 +1,7 @@ import { toast } from '@epicenter/ui/sonner'; import type { Result } from 'wellcrafted/result'; import type { ModelFolderError } from '$lib/services/transcription/local-model-folder'; -import type { ModelDownloadResult } from '$lib/state/local-model-downloads.svelte'; +import type { ModelDownloadResult } from '$lib/state/model-folder.svelte'; /** * Toast the outcome of a catalog download and return the folder entry name to diff --git a/apps/whispering/src/lib/state/local-model-downloads.svelte.ts b/apps/whispering/src/lib/state/local-model-downloads.svelte.ts deleted file mode 100644 index a01abb8946..0000000000 --- a/apps/whispering/src/lib/state/local-model-downloads.svelte.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Shared download state for pre-built local transcription models, keyed by - * engine and model id. Every surface that renders a model (recommended-model - * hero, catalog row) reads the same handle, so a download started in one - * place shows its progress everywhere. - * - * The state machine is computed, not stored: `downloading` while a download - * owns the handle, otherwise disk truth (`isInstalled`) decides between - * not-downloaded and ready. The selected entry name is parent-owned component - * state, so catalog models and custom folder entries activate through the - * same `bind:value` path. - * - * The models folder is user-editable truth (entries can be dropped in or - * deleted outside the app), so `refresh()` re-checks disk; the selector calls - * it from the same window-focus rescan that refreshes folder entries. - * - * Shape mirrors `recordings.svelte.ts`: factory function with `$state` - * closure variables and a return object exposing a reactive getter plus - * operations. - */ -import { Err, Ok, type Result } from 'wellcrafted/result'; -import { - type LocalModelConfig, - modelEntryName, -} from '$lib/constants/local-models'; -import { - createModelStorage, - deleteModelEntry, - type ModelFolderError, -} from '$lib/services/transcription/local-model-folder'; - -type ModelDownloadState = - | { type: 'not-downloaded' } - | { type: 'downloading'; progress: number; cancelling: boolean } - | { type: 'ready' }; - -function createModelDownload(model: LocalModelConfig) { - const storage = createModelStorage(model); - - /** Disk truth: whether a valid install exists in the models folder. */ - let isInstalled = $state(false); - - /** - * The in-flight download attempt, or `null` when idle. This is the re-entry - * gate: `download()` sets it when a run starts and clears it only when that - * same run settles. `cancel()` flips `cancelling` but never clears it, so the - * gate stays closed until the abort actually surfaces — a cancel can never - * reopen the door for a second, overlapping `download_model` call on the same - * partial path. - * - * `id` is unique per attempt, so the Rust registry maps it to exactly one - * transfer for its whole lifetime. `cancelling` gates late progress callbacks - * (so a flushed Channel update cannot repaint after a cancel); the cancel - * itself, including the gap between a multi-file engine's files, is now - * enforced in Rust (the aborted task stops the whole staged download). - */ - let active = $state<{ - id: string; - progress: number; - cancelling: boolean; - } | null>(null); - - /** Per-handle counter; pairs with the model key for a globally unique id. */ - let attempts = 0; - - async function refresh() { - isInstalled = await storage.isInstalled(); - } - - void refresh(); - - return { - /** - * Where this model stands on this device. A getter, not a `$derived`: - * handles are created lazily from whichever component touches them - * first, and a derived created inside a component's effect context - * goes inert when that component is destroyed (`derived_inert`). The - * computation is one state read plus one null check; consumers that - * need caching or narrowing alias it with a component-local `$derived`. - */ - get state(): ModelDownloadState { - if (active) - return { - type: 'downloading', - progress: active.progress, - cancelling: active.cancelling, - }; - return isInstalled ? { type: 'ready' } : { type: 'not-downloaded' }; - }, - - /** - * Re-check disk truth. The models folder can change outside the app - * (entries deleted, a partial download cleaned up), so callers invoke - * this on the same signal they use to rescan the folder, typically - * window focus. - */ - refresh, - - /** - * Download the model, skipping the download when a valid install - * already exists. Selection is owned by the caller's bound value. - */ - async download(): Promise | null> { - if (active) return null; - const id = `${modelDownloadKey(model)}#${++attempts}`; - active = { id, progress: 0, cancelling: false }; - - if (await storage.isInstalled()) { - isInstalled = true; - active = null; - return Ok({ - entryName: modelEntryName(model), - outcome: 'already-installed', - }); - } - - // A cancel that arrived during the install check (before any transfer - // started, so there is no Rust task to abort yet) stops here. Report it - // as a no-op, like an already-in-flight call. - if (active.cancelling) { - active = null; - return null; - } - - const { error } = await storage.download({ - downloadId: id, - // Write through `active` (the $state proxy) so the bar repaints; the - // gate keeps `active` pointing at this attempt for the whole run. - onProgress: (value) => { - if (active && !active.cancelling) active.progress = value; - }, - }); - if (error) { - const wasCancelled = active?.cancelling ?? false; - active = null; - // If we asked to cancel, the abort is what produced this error: a - // clean stop, not a failure. Report it as a no-op (like an - // already-in-flight call) so callers raise no error toast. - return wasCancelled ? null : Err(error); - } - - // Refresh disk truth before releasing the gate so the computed machine - // lands directly on ready. - await refresh(); - active = null; - return Ok({ entryName: modelEntryName(model), outcome: 'downloaded' }); - }, - - /** - * Request cancellation of an in-flight download. Marks the attempt as - * cancelling (the UI shows "Cancelling…") and aborts its transfer in Rust; - * the still-running `download()` drops back to `not-downloaded` and resolves - * to a no-op once the abort surfaces. A no-op when nothing is downloading. - */ - async cancel(): Promise { - if (!active) return; - // Leave `active` set: the owning `download()` clears it when the abort - // surfaces. Until then the gate stays closed, so a re-download cannot - // start a second transfer over this one. - active.cancelling = true; - await storage.cancel(active.id); - }, - - /** Remove the catalog model from disk. Selection is cleared by callers. */ - async delete(): Promise> { - const { error } = await deleteModelEntry({ - engine: model.engine, - name: modelEntryName(model), - }); - if (error) return Err(error); - isInstalled = false; - return Ok(undefined); - }, - }; -} - -/** - * The result of a catalog `download()`: the outcome plus the folder entry - * name to select on success, `Err` on failure, or `null` when the call was a - * no-op (a download was already in flight). - */ -export type ModelDownloadResult = Awaited< - ReturnType['download']> ->; - -function modelDownloadKey(model: LocalModelConfig) { - return `${model.engine}:${model.id}`; -} - -function createLocalModelDownloads() { - const handles = new Map>(); - - return { - /** - * The shared download handle for a catalog model, created on first - * use. Acquire the handle and read its `state` in separate deriveds - * (`$derived(localModelDownloads.get(model))`, then - * `$derived(handle.state)`): a derived does not depend on state it - * created itself, so a single derived that creates the handle and - * reads its state in one expression would never update. - */ - get(model: LocalModelConfig) { - const key = modelDownloadKey(model); - const existing = handles.get(key); - if (existing) return existing; - const handle = createModelDownload(model); - handles.set(key, handle); - return handle; - }, - }; -} - -export const localModelDownloads = createLocalModelDownloads(); diff --git a/apps/whispering/src/lib/state/model-folder.svelte.ts b/apps/whispering/src/lib/state/model-folder.svelte.ts new file mode 100644 index 0000000000..17e9bc838e --- /dev/null +++ b/apps/whispering/src/lib/state/model-folder.svelte.ts @@ -0,0 +1,226 @@ +/** + * The single source of truth for an engine's models folder, shared by every + * surface (the first-run hero, the settings list, the catalog rows). + * + * It unifies the two kinds of truth a model selector needs, which are genuinely + * different and both required: + * + * - DISK STATE, at rest: which entries are in the folder, and whether each + * catalog model is complete. Rust owns it; this store projects it via + * `refresh()` (on first use, window focus, and after every mutation it + * performs). The scan and the completeness checks land in ONE cycle, so they + * can never disagree the way a component-local scan and a per-model stat did. + * - IN-FLIGHT TRANSFERS, in motion: downloads underway, with progress and a + * cancel flag. Transient, fed by the download Channel; never on disk yet. You + * cannot fold this into the scan: progress is a running transfer, not a file. + * + * Because the store is a global singleton per engine, a download started + * anywhere updates the one store and every mounted view re-reads reactively: no + * component-local scan to go stale, no per-model handle to drift from the folder. + * Components are pure views. + * + * Selection (the active model name) is deliberately NOT here: it lives in + * deviceConfig and is read by the transcribe dispatcher. Presence (this store) + * and selection (deviceConfig) are different concerns; a selector joins them + * ("is the selected name present?"). + * + * Shape mirrors `recordings.svelte.ts`: a factory with `$state`/`SvelteMap` + * closure variables and a return object exposing reactive getters plus + * operations. + */ +import { SvelteMap } from 'svelte/reactivity'; +import { Err, Ok, type Result } from 'wellcrafted/result'; +import { + type LocalModelConfig, + modelEntryName, +} from '$lib/constants/local-models'; +import { + createModelStorage, + listModelEntries, + type ModelEntry, + type ModelFolderError, +} from '$lib/services/transcription/local-model-folder'; +import { tauri } from '#platform/tauri'; + +type Engine = LocalModelConfig['engine']; + +export type ModelDownloadState = + | { type: 'not-downloaded' } + | { type: 'downloading'; progress: number; cancelling: boolean } + | { type: 'ready' }; + +/** + * The result of a catalog `download()`: the outcome plus the folder entry name + * to select on success, `Err` on failure, or `null` when the call was a no-op + * (a download was already in flight). + */ +export type ModelDownloadResult = Result< + { outcome: 'downloaded' | 'already-installed'; entryName: string }, + ModelFolderError +> | null; + +function createModelFolder(catalog: readonly [LocalModelConfig, ...LocalModelConfig[]]) { + const engine = catalog[0].engine; + + // DISK STATE. `entries` is the folder scan, `null` until the first load so the + // UI can tell "loading" from "empty". `completeness` is each catalog model's + // installed verdict, refreshed in the SAME cycle as the scan. + let entries = $state(null); + const completeness = new SvelteMap(); + + // IN-FLIGHT TRANSFERS, keyed by model id. Progress re-`set`s the entry + // (SvelteMap tracks `set`, not a nested mutation), so the bar repaints. The + // map IS the re-entry gate: a key present means a transfer owns that model, + // cleared only when that same run settles, so a cancel can never reopen the + // door for a second overlapping `download_model` over the same partial path. + // `id` is unique per attempt, so the Rust registry maps it to exactly one + // transfer; `cancelling` gates late progress callbacks. + const transfers = new SvelteMap< + string, + { id: string; progress: number; cancelling: boolean } + >(); + let attempts = 0; + + async function refresh() { + if (!tauri) return; + // Scan the folder and re-check each catalog model's completeness on the one + // signal, so the listing and the "ready" verdicts settle together. + const [list] = await Promise.all([ + listModelEntries(engine), + Promise.all( + catalog.map(async (model) => + completeness.set( + modelEntryName(model), + await createModelStorage(model).isInstalled(), + ), + ), + ), + ]); + entries = list; + } + + void refresh(); + + function present(name: string): boolean { + return (entries ?? []).some((entry) => entry.name === name); + } + + return { + /** The folder scan: the single disk-state source every view reads. */ + get entries() { + return entries ?? []; + }, + /** Whether the first scan has landed (so "empty" differs from "loading"). */ + get loaded() { + return entries !== null; + }, + /** Whether an entry by this exact name is in the folder right now. */ + present, + /** Folder entries that are not catalog models (bring-your-own / linked). */ + customEntries(): ModelEntry[] { + const names = new Set(catalog.map(modelEntryName)); + return (entries ?? []).filter((entry) => !names.has(entry.name)); + }, + /** Where a catalog model stands: a live transfer, else disk truth. */ + stateOf(model: LocalModelConfig): ModelDownloadState { + const transfer = transfers.get(model.id); + if (transfer) + return { + type: 'downloading', + progress: transfer.progress, + cancelling: transfer.cancelling, + }; + const name = modelEntryName(model); + return present(name) && completeness.get(name) + ? { type: 'ready' } + : { type: 'not-downloaded' }; + }, + + /** + * Re-check disk truth. The folder can change outside the app (entries + * dropped in or deleted), so views call this on window focus. + */ + refresh, + + /** + * Download a catalog model, skipping when a valid install already exists. + * Re-scans the folder before releasing the gate so the computed state lands + * directly on `ready`. + */ + async download(model: LocalModelConfig): Promise { + if (transfers.has(model.id)) return null; + const id = `${engine}:${model.id}#${++attempts}`; + transfers.set(model.id, { id, progress: 0, cancelling: false }); + const storage = createModelStorage(model); + const name = modelEntryName(model); + + if (await storage.isInstalled()) { + completeness.set(name, true); + transfers.delete(model.id); + return Ok({ entryName: name, outcome: 'already-installed' }); + } + + // A cancel that arrived during the install check (before any transfer + // started) stops here, reported as a no-op like an in-flight call. + if (transfers.get(model.id)?.cancelling) { + transfers.delete(model.id); + return null; + } + + const { error } = await storage.download({ + downloadId: id, + onProgress: (progress) => { + const transfer = transfers.get(model.id); + if (transfer && !transfer.cancelling) + transfers.set(model.id, { ...transfer, progress }); + }, + }); + if (error) { + const wasCancelled = transfers.get(model.id)?.cancelling ?? false; + transfers.delete(model.id); + // A requested cancel is the cause of this error: a clean stop, not a + // failure. Report it as a no-op so callers raise no error toast. + return wasCancelled ? null : Err(error); + } + + await refresh(); + transfers.delete(model.id); + return Ok({ entryName: name, outcome: 'downloaded' }); + }, + + /** + * Request cancellation of an in-flight download. Marks it cancelling (the UI + * shows "Cancelling…") and aborts its transfer in Rust; the still-running + * `download()` drops back to `not-downloaded` once the abort surfaces. + * Leaves the gate closed until then, so a re-download cannot start a second + * transfer over this one. A no-op when nothing is downloading. + */ + async cancel(model: LocalModelConfig): Promise { + const transfer = transfers.get(model.id); + if (!transfer) return; + transfers.set(model.id, { ...transfer, cancelling: true }); + await createModelStorage(model).cancel(transfer.id); + }, + }; +} + +const folders = new Map>(); + +/** + * The shared `modelFolder` for an engine, created on first use from its catalog. + * Pass the engine's catalog models (e.g. `PARAKEET_MODELS`); the same engine + * always passes the same constant, so this is a singleton per engine. + */ +export function modelFolder( + catalog: readonly [LocalModelConfig, ...LocalModelConfig[]], +) { + const engine = catalog[0].engine; + const existing = folders.get(engine); + if (existing) return existing; + const folder = createModelFolder(catalog); + folders.set(engine, folder); + return folder; +} + +/** The shared folder store handle, for components that receive it as a prop. */ +export type ModelFolder = ReturnType; From b7fbb95355a1233377d8e581c9d83fcdce298652 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:41:00 -0700 Subject: [PATCH 29/29] refactor(whispering): fold model completeness into the folder scan The store built its disk state from two Rust calls per refresh: enumerate the folder, then stat each catalog model's completeness. Fold the completeness into the enumeration: `list_model_entries` now takes the catalog and returns each entry already judged `complete` (against the 90% floor, the same rule the download integrity check uses). So the store's `refresh()` is one call and one projection, and `stateOf` reads `entry.complete` straight off the scan. `resolve_model_files` stays: the transcribe pre-flight needs a single model's actual byte size to message a truncated download ("got 200MB, expected 488MB"), which the list's per-entry boolean cannot carry. Both read-path verdicts share `is_size_complete`, so the threshold still has one owner. The store's `createModelStorage.isInstalled` and its completeness map are gone. --- .../src/transcription/model_folder.rs | 190 ++++++++++++------ .../transcription/local-model-folder.ts | 47 ++--- .../src/lib/state/model-folder.svelte.ts | 31 +-- apps/whispering/src/lib/tauri/bindings.gen.ts | 51 +++-- apps/whispering/src/lib/tauri/commands.ts | 1 - 5 files changed, 194 insertions(+), 126 deletions(-) diff --git a/apps/whispering/src-tauri/src/transcription/model_folder.rs b/apps/whispering/src-tauri/src/transcription/model_folder.rs index 517490bb0d..3b6688d28e 100644 --- a/apps/whispering/src-tauri/src/transcription/model_folder.rs +++ b/apps/whispering/src-tauri/src/transcription/model_folder.rs @@ -67,6 +67,11 @@ pub struct ModelEntry { /// Whether the entry is a symlink (a "bring your own model" link). Display /// only ("Your model (linked)"); it does not change how the entry loads. pub linked: bool, + /// Whether this is a complete install. A catalog entry (its name matches a + /// model in the passed catalog) is checked against that model's expected + /// files at the 90% floor; a custom (non-catalog) entry has no expectation + /// and reads as complete. + pub complete: bool, } /// One file to download for a model. Mirrors the catalog shape: a Whisper model @@ -82,11 +87,24 @@ pub struct ModelFileDownload { pub size_bytes: f64, } +/// One catalog model's expectation, passed by the webview (which owns the +/// catalog) so the scan can judge each entry's completeness in the same pass. +/// `filenames` empty means the entry is itself the file (Whisper), checked +/// against `expected_sizes[0]`; otherwise one filename per file inside the entry +/// directory, each checked against the aligned `expected_sizes`. +#[derive(Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct CatalogModel { + /// The model's folder entry name, matched against scanned entry names. + pub entry_name: String, + pub filenames: Vec, + pub expected_sizes: Vec, +} + /// One file's presence and completeness in a model entry, resolved through any /// symlink. The webview supplies expected catalog sizes and reads back both the -/// stat'd `size` (for messaging) and the `complete` verdict (for installed / -/// truncated decisions). The completeness rule itself lives in Rust; see -/// `is_size_complete`. +/// stat'd `size` (for messaging) and the `complete` verdict. The completeness +/// rule itself lives in Rust; see `is_size_complete`. #[derive(Clone, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ModelFileStatus { @@ -101,11 +119,15 @@ pub struct ModelFileStatus { /// List every selectable entry in the engine's models folder: model files /// (.bin/.gguf/.ggml) for Whisper, directories for Parakeet and Moonshine, plus /// symlinks to either. Hidden entries and in-flight `.partial` staging are -/// skipped. Returns an empty list when the folder does not exist yet. +/// skipped. Returns an empty list when the folder does not exist yet. Each +/// entry's `complete` verdict is judged against the matching catalog model in +/// one pass (the webview owns the catalog and passes it in); a custom entry, +/// which matches no catalog model, reads as complete. #[tauri::command] #[specta::specta] pub fn list_model_entries( engine: Engine, + catalog: Vec, app_handle: AppHandle, ) -> Result, ModelFolderError> { let models_dir = engine_models_path(&app_handle, engine) @@ -134,68 +156,67 @@ pub fn list_model_entries( Engine::Parakeet | Engine::Moonshine => file_type.is_dir() || linked, }; if keep { - entries.push(ModelEntry { name, linked }); + // Judge completeness against the matching catalog model, resolving + // through any symlink; a custom (non-catalog) entry has no + // expectation and reads as complete. + let complete = catalog + .iter() + .find(|model| model.entry_name == name) + .map(|model| { + entry_complete( + &models_dir.join(&name), + &model.filenames, + &model.expected_sizes, + ) + }) + .unwrap_or(true); + entries.push(ModelEntry { + name, + linked, + complete, + }); } } entries.sort_by(|a, b| a.name.cmp(&b.name)); Ok(entries) } -fn has_whisper_extension(name: &str) -> bool { - std::path::Path::new(name) - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| WHISPER_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) - .unwrap_or(false) -} - -/// Remove one entry from the engine's models folder. A symlinked entry removes -/// only the link, never its target; a real entry is removed outright. The name -/// must be a single folder entry, so this can never reach outside the folder. -/// Succeeds when the entry is already gone. -#[tauri::command] -#[specta::specta] -pub fn delete_model_entry( - engine: Engine, - name: String, - app_handle: AppHandle, -) -> Result<(), ModelFolderError> { - if !is_contained_entry_name(&name) { - return Err(ModelFolderError::InvalidEntryName { - message: format!("Model entry name must be a single models-folder entry, got: {name}"), - }); - } - let path = engine_models_path(&app_handle, engine) - .map_err(|message| ModelFolderError::DeleteFailed { message })? - .join(&name); - - let Ok(meta) = path.symlink_metadata() else { - // Already gone (or never existed): nothing to delete. - return Ok(()); - }; - - let outcome = if meta.file_type().is_symlink() { - unlink_symlink(&path) - } else if meta.is_dir() { - std::fs::remove_dir_all(&path) +/// Whether an entry is a complete install: every expected file present and at +/// least the completeness floor of its catalog size, resolved through any +/// symlink. Empty `filenames` means the entry is itself the file (Whisper), +/// checked against `expected_sizes[0]`; otherwise one per file inside the +/// directory. Shares the `is_size_complete` floor with the download integrity +/// check, so the read verdict and the write verdict can never drift. +fn entry_complete(entry: &Path, filenames: &[String], expected_sizes: &[f64]) -> bool { + let sizes: Vec> = if filenames.is_empty() { + vec![file_size(entry)] } else { - std::fs::remove_file(&path) + filenames + .iter() + .map(|filename| { + if is_contained_entry_name(filename) { + file_size(&entry.join(filename)) + } else { + None + } + }) + .collect() }; - outcome.map_err(|e| ModelFolderError::DeleteFailed { - message: format!("Could not delete \"{name}\": {e}"), - }) + if sizes.len() != expected_sizes.len() { + return false; + } + sizes + .iter() + .zip(expected_sizes) + .all(|(size, &expected)| matches!(size, Some(actual) if is_size_complete(*actual, expected))) } -/// Resolve an entry **through any symlink** and report each expected file's size -/// and completeness verdict. The webview passes the expected catalog sizes (it -/// owns the catalog); the 90% completeness rule lives here next to the stat, so -/// "what counts as a complete file on disk" has one owner shared with the -/// download integrity check (`is_size_complete`). An empty `filenames` means the -/// entry is itself the file (Whisper) and returns one element checked against -/// `expected_sizes[0]`; otherwise one element per filename (directory engines), -/// each checked against the aligned `expected_sizes`. A dead link reports -/// `size: None, complete: false`, so a linked-but-broken model reads as not -/// installed. +/// Resolve one entry **through any symlink** and report each expected file's +/// stat'd size and completeness verdict. The list scan (`list_model_entries`) +/// folds completeness into one boolean per entry for the selector; this returns +/// the per-file sizes the transcribe pre-flight needs to message a truncated +/// download ("got 200MB, expected 488MB"). Both share the `is_size_complete` +/// floor. An empty `filenames` means the entry is itself the file (Whisper). #[tauri::command] #[specta::specta] pub fn resolve_model_files( @@ -214,8 +235,6 @@ pub fn resolve_model_files( .map_err(|message| ModelFolderError::ReadFailed { message })? .join(&name); - // Empty `filenames` => the entry itself is the file (Whisper); otherwise one - // file per name inside the entry directory. let sizes: Vec> = if filenames.is_empty() { vec![file_size(&entry)] } else { @@ -231,8 +250,6 @@ pub fn resolve_model_files( .collect() }; - // Pair each stat with its aligned expected size; a missing file (or a missing - // expectation) is never complete. Ok(sizes .into_iter() .enumerate() @@ -246,6 +263,51 @@ pub fn resolve_model_files( .collect()) } +fn has_whisper_extension(name: &str) -> bool { + std::path::Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| WHISPER_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) + .unwrap_or(false) +} + +/// Remove one entry from the engine's models folder. A symlinked entry removes +/// only the link, never its target; a real entry is removed outright. The name +/// must be a single folder entry, so this can never reach outside the folder. +/// Succeeds when the entry is already gone. +#[tauri::command] +#[specta::specta] +pub fn delete_model_entry( + engine: Engine, + name: String, + app_handle: AppHandle, +) -> Result<(), ModelFolderError> { + if !is_contained_entry_name(&name) { + return Err(ModelFolderError::InvalidEntryName { + message: format!("Model entry name must be a single models-folder entry, got: {name}"), + }); + } + let path = engine_models_path(&app_handle, engine) + .map_err(|message| ModelFolderError::DeleteFailed { message })? + .join(&name); + + let Ok(meta) = path.symlink_metadata() else { + // Already gone (or never existed): nothing to delete. + return Ok(()); + }; + + let outcome = if meta.file_type().is_symlink() { + unlink_symlink(&path) + } else if meta.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + outcome.map_err(|e| ModelFolderError::DeleteFailed { + message: format!("Could not delete \"{name}\": {e}"), + }) +} + /// Clear whatever currently occupies a path before promoting onto it. Reads the /// path's own type with `symlink_metadata` (never following a link), so a /// colliding linked model is unlinked without touching its target, a stale real @@ -441,7 +503,8 @@ async fn run_staged_download( const COMPLETENESS_FLOOR: f64 = 0.9; /// The single completeness rule, shared by the download integrity check -/// (`ensure_complete`) and the read-path verdict (`resolve_model_files`), so the +/// (`ensure_complete`) and the read-path verdicts (`entry_complete`, +/// `resolve_model_files`), so the /// threshold has one owner instead of a copy on each side of the IPC boundary. fn is_size_complete(received: f64, expected: f64) -> bool { received >= expected * COMPLETENESS_FLOOR @@ -529,8 +592,9 @@ mod tests { #[test] fn is_size_complete_is_the_single_floor_shared_by_both_paths() { - // The same rule `ensure_complete` (download) and `resolve_model_files` - // (read) both call, so the threshold can never drift between them. + // The same rule `ensure_complete` (download) and the read-path verdicts + // (`entry_complete`, `resolve_model_files`) all call, so the threshold can + // never drift between them. assert!(is_size_complete(900.0, 1000.0)); assert!(!is_size_complete(899.0, 1000.0)); assert!(is_size_complete(1200.0, 1000.0)); diff --git a/apps/whispering/src/lib/services/transcription/local-model-folder.ts b/apps/whispering/src/lib/services/transcription/local-model-folder.ts index 5db999a562..9691021454 100644 --- a/apps/whispering/src/lib/services/transcription/local-model-folder.ts +++ b/apps/whispering/src/lib/services/transcription/local-model-folder.ts @@ -36,13 +36,28 @@ export type { ModelEntry, ModelFolderError } from '$lib/tauri/commands'; type Engine = LocalModelConfig['engine']; /** - * List every selectable entry in the engine's models folder. Rust applies the - * per-engine shape filter (Whisper model files; directories for the others), - * resolves links, and sorts. Returns an empty list on any error (never - * rejects), so the selector always has something to render. + * List every selectable entry in the engine's models folder, each judged + * `complete` against the catalog in the same pass. Rust applies the per-engine + * shape filter (Whisper model files; directories for the others), resolves + * links, sorts, and stats completeness; the webview owns the catalog (file names + * and expected sizes), so it passes the engine's models in. Returns an empty + * list on any error (never rejects), so the selector always has something to + * render. */ -export async function listModelEntries(engine: Engine): Promise { - const { data } = await commands.listModelEntries(engine); +export async function listModelEntries( + catalog: readonly [LocalModelConfig, ...LocalModelConfig[]], +): Promise { + const { data } = await commands.listModelEntries( + catalog[0].engine, + catalog.map((model) => { + const { filenames, expected } = modelSizeChecks(model); + return { + entryName: modelEntryName(model), + filenames, + expectedSizes: expected, + }; + }), + ); return data ?? []; } @@ -136,26 +151,6 @@ function modelSizeChecks(model: LocalModelConfig): { */ export function createModelStorage(model: LocalModelConfig) { return { - /** - * Whether a valid install exists in the folder. JS passes the catalog's - * expected sizes; Rust resolves the entry through any link, stats each file, - * and returns the completeness verdict (the 90% rule lives in Rust next to - * the stat). One path serves downloaded, linked, and hand-dropped installs, - * so a linked-but-broken model reads as not installed. Never rejects; any - * error reads as not installed. - */ - async isInstalled(): Promise { - const { filenames, expected } = modelSizeChecks(model); - const { data: statuses } = await commands.resolveModelFiles( - model.engine, - modelEntryName(model), - filenames, - expected, - ); - if (!statuses || statuses.length !== expected.length) return false; - return statuses.every((status) => status.complete); - }, - /** * Download the model to its canonical path. Rust stages, integrity-checks * each file, and promotes with one rename; a cancel or error cleans up the diff --git a/apps/whispering/src/lib/state/model-folder.svelte.ts b/apps/whispering/src/lib/state/model-folder.svelte.ts index 17e9bc838e..0cc86d5b27 100644 --- a/apps/whispering/src/lib/state/model-folder.svelte.ts +++ b/apps/whispering/src/lib/state/model-folder.svelte.ts @@ -63,10 +63,10 @@ function createModelFolder(catalog: readonly [LocalModelConfig, ...LocalModelCon const engine = catalog[0].engine; // DISK STATE. `entries` is the folder scan, `null` until the first load so the - // UI can tell "loading" from "empty". `completeness` is each catalog model's - // installed verdict, refreshed in the SAME cycle as the scan. + // UI can tell "loading" from "empty". Each entry carries its own `complete` + // verdict, judged against the catalog by the one Rust scan, so "ready" is a + // pure read of the scan with no second source to drift from. let entries = $state(null); - const completeness = new SvelteMap(); // IN-FLIGHT TRANSFERS, keyed by model id. Progress re-`set`s the entry // (SvelteMap tracks `set`, not a nested mutation), so the bar repaints. The @@ -83,20 +83,9 @@ function createModelFolder(catalog: readonly [LocalModelConfig, ...LocalModelCon async function refresh() { if (!tauri) return; - // Scan the folder and re-check each catalog model's completeness on the one - // signal, so the listing and the "ready" verdicts settle together. - const [list] = await Promise.all([ - listModelEntries(engine), - Promise.all( - catalog.map(async (model) => - completeness.set( - modelEntryName(model), - await createModelStorage(model).isInstalled(), - ), - ), - ), - ]); - entries = list; + // One scan returns each entry already judged complete against the catalog, + // so the listing and the "ready" verdicts are the same data. + entries = await listModelEntries(catalog); } void refresh(); @@ -131,7 +120,8 @@ function createModelFolder(catalog: readonly [LocalModelConfig, ...LocalModelCon cancelling: transfer.cancelling, }; const name = modelEntryName(model); - return present(name) && completeness.get(name) + const entry = (entries ?? []).find((e) => e.name === name); + return entry?.complete ? { type: 'ready' } : { type: 'not-downloaded' }; }, @@ -154,8 +144,9 @@ function createModelFolder(catalog: readonly [LocalModelConfig, ...LocalModelCon const storage = createModelStorage(model); const name = modelEntryName(model); - if (await storage.isInstalled()) { - completeness.set(name, true); + // Already installed? A fresh scan is the one truth; skip the transfer. + await refresh(); + if ((entries ?? []).find((entry) => entry.name === name)?.complete) { transfers.delete(model.id); return Ok({ entryName: name, outcome: 'already-installed' }); } diff --git a/apps/whispering/src/lib/tauri/bindings.gen.ts b/apps/whispering/src/lib/tauri/bindings.gen.ts index 9bbeffe5ed..7a0112201a 100644 --- a/apps/whispering/src/lib/tauri/bindings.gen.ts +++ b/apps/whispering/src/lib/tauri/bindings.gen.ts @@ -222,11 +222,14 @@ export const commands = { * List every selectable entry in the engine's models folder: model files * (.bin/.gguf/.ggml) for Whisper, directories for Parakeet and Moonshine, plus * symlinks to either. Hidden entries and in-flight `.partial` staging are - * skipped. Returns an empty list when the folder does not exist yet. + * skipped. Returns an empty list when the folder does not exist yet. Each + * entry's `complete` verdict is judged against the matching catalog model in + * one pass (the webview owns the catalog and passes it in); a custom entry, + * which matches no catalog model, reads as complete. */ - listModelEntries: (engine: Engine) => + listModelEntries: (engine: Engine, catalog: CatalogModel[]) => typedError( - __TAURI_INVOKE('list_model_entries', { engine }), + __TAURI_INVOKE('list_model_entries', { engine, catalog }), ), /** * Remove one entry from the engine's models folder. A symlinked entry removes @@ -239,16 +242,12 @@ export const commands = { __TAURI_INVOKE('delete_model_entry', { engine, name }), ), /** - * Resolve an entry **through any symlink** and report each expected file's size - * and completeness verdict. The webview passes the expected catalog sizes (it - * owns the catalog); the 90% completeness rule lives here next to the stat, so - * "what counts as a complete file on disk" has one owner shared with the - * download integrity check (`is_size_complete`). An empty `filenames` means the - * entry is itself the file (Whisper) and returns one element checked against - * `expected_sizes[0]`; otherwise one element per filename (directory engines), - * each checked against the aligned `expected_sizes`. A dead link reports - * `size: None, complete: false`, so a linked-but-broken model reads as not - * installed. + * Resolve one entry **through any symlink** and report each expected file's + * stat'd size and completeness verdict. The list scan (`list_model_entries`) + * folds completeness into one boolean per entry for the selector; this returns + * the per-file sizes the transcribe pre-flight needs to message a truncated + * download ("got 200MB, expected 488MB"). Both share the `is_size_complete` + * floor. An empty `filenames` means the entry is itself the file (Whisper). */ resolveModelFiles: ( engine: Engine, @@ -372,6 +371,20 @@ export const events = { }; /* Types */ +/** + * One catalog model's expectation, passed by the webview (which owns the + * catalog) so the scan can judge each entry's completeness in the same pass. + * `filenames` empty means the entry is itself the file (Whisper), checked + * against `expected_sizes[0]`; otherwise one filename per file inside the entry + * directory, each checked against the aligned `expected_sizes`. + */ +export type CatalogModel = { + /** The model's folder entry name, matched against scanned entry names. */ + entryName: string; + filenames: string[]; + expectedSizes: (number | null)[]; +}; + /** * One command's binding, as sent from the FE registrar. `command_id` is the * id the trigger event is emitted under; the FE filters by that command's `on` @@ -610,6 +623,13 @@ export type ModelEntry = { * only ("Your model (linked)"); it does not change how the entry loads. */ linked: boolean; + /** + * Whether this is a complete install. A catalog entry (its name matches a + * model in the passed catalog) is checked against that model's expected + * files at the 90% floor; a custom (non-catalog) entry has no expectation + * and reads as complete. + */ + complete: boolean; }; /** @@ -630,9 +650,8 @@ export type ModelFileDownload = { /** * One file's presence and completeness in a model entry, resolved through any * symlink. The webview supplies expected catalog sizes and reads back both the - * stat'd `size` (for messaging) and the `complete` verdict (for installed / - * truncated decisions). The completeness rule itself lives in Rust; see - * `is_size_complete`. + * stat'd `size` (for messaging) and the `complete` verdict. The completeness + * rule itself lives in Rust; see `is_size_complete`. */ export type ModelFileStatus = { /** diff --git a/apps/whispering/src/lib/tauri/commands.ts b/apps/whispering/src/lib/tauri/commands.ts index 98e22b7b99..2bb2e7e3a5 100644 --- a/apps/whispering/src/lib/tauri/commands.ts +++ b/apps/whispering/src/lib/tauri/commands.ts @@ -114,7 +114,6 @@ export type { LocalModelState, ModelEntry, ModelFileDownload, - ModelFileStatus, ModelFolderError, ModelImportError, ModelStateEvent,