feat(ux): dogfood pass — onboarding, tasks, LLM chip, float popout

Bundles a session of dogfood UX feedback plus the two Cursor Bugbot
findings on the auto-titles branch.

Onboarding (FirstRunPage):
- Welcome leads with "Set up automatically"; system breakdown and the
  full model list move behind a "Choose manually" disclosure
- Morning / evening / autostart modal copy trimmed to one short
  sentence each; CTAs shortened
- "Corbie" autostart string reverted to "Kon"
- Already-downloaded models are clickable in the picker so the
  Settings → About → Replay onboarding flow doesn't re-download
- Autostart "No thanks" now does isEnabled() → disable() to actually
  remove the OS login item when replaying after a previous "Yes"

Tasks page:
- Bucket nav (All / Inbox / Today / Soon / Later) now a horizontal
  pill row; was stacking because nav was block-level
- List sidebar sized to content via self-start max-h-full instead of
  stretching to viewport when sparse
- Energy chip surfaces at opacity-60 when unset (was opacity-0,
  hidden until hover) so the affordance is discoverable
- "Brain-Dead" energy label → "Zero" everywhere user-facing; enum
  stays brain_dead to avoid a destructive DB migration

LLM status chip (llmStatus.svelte.ts + Dictation/Settings):
- Chip no longer auto-warms when the engine isn't loaded; it's hidden
  unless ready / generating / loading / error
- refreshLlmStatus takes { force: true } so post-load reconcile clears
  stale "warming"; ambient refreshes still preserve in-flight state
- markError exported; failed loads surface "AI error" with detail
  rather than silently going to off
- check_llm_model is the source of truth (replaces the bool-only
  get_llm_status path in the store)

Float popout window:
- Native decorations off — was stacking two titlebars + two close X's
  on KWin, one of which silently failed
- ResizeHandles mounted outside the animate-float-enter wrapper so
  fixed-position handles anchor to the viewport, not the transformed
  root; secondary-windows capability gains
  core:window:allow-start-resize-dragging for tasks-float
- GTK Utility WindowTypeHint applied pre-map (mirroring the preview
  window) so KWin Wayland honours always-on-top reliably
- visible_on_all_workspaces(true) so the pinned tasks list follows
  workspace switches
- togglePin does hide()+show()+focus() on re-pin to nudge the
  compositor into re-evaluating window state
- Pop-out / Edit / Open viewer buttons hidden on Android via
  isAndroid() — the multi-window Tauri commands stub out there

Build / Bugbot:
- src-tauri Cargo.toml: whisper feature now chains whisper-vulkan, so
  the dev runner's --no-default-features --features whisper
  invocation actually pulls Vulkan acceleration instead of silently
  falling back to CPU-only
- jsconfig.json's inherited "types": ["node"] fixed by adding
  @types/node; corresponding @ts-expect-error in vite.config.js
  removed now that process is a known global

Verification: svelte-check + cargo check pass clean. Manual
device-side validation still pending for float resize and replay
autostart "No thanks" — those are the only remaining confidence items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 23:43:00 +01:00
parent ce849a15ab
commit a15167c44e
18 changed files with 403 additions and 174 deletions

18
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^25.6.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
@@ -1662,6 +1663,16 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -3181,6 +3192,13 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",

View File

@@ -30,6 +30,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^25.6.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",

View File

@@ -10,13 +10,22 @@ name = "kon_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[features] [features]
# Default build includes the Whisper backend. Disabling this feature # Default build includes the Whisper backend with Vulkan GPU acceleration.
# also drops it from kon-transcription (see Cargo.toml in that crate) #
# so a --no-default-features workspace build does not pull whisper-rs-sys. # Vulkan is chained into `whisper` (rather than being a separate top-level
# load_model_from_disk returns a runtime error for Engine::Whisper when # default) because Tauri's dev runner invokes
# this feature is off; Parakeet continues to work. # `cargo run --no-default-features --features whisper`, which would
# otherwise drop the vulkan feature and silently fall back to CPU-only
# inference. The Bugbot finding is satisfied: desktop builds get GPU
# acceleration by default, while the workspace-level kon-transcription
# crate still keeps `whisper` and `whisper-vulkan` separable for
# CPU-only-capable targets that build the crate directly.
#
# `whisper-vulkan` is kept as an aliased explicit-opt-in for callers who
# want to spell out the dependency.
default = ["whisper"] default = ["whisper"]
whisper = ["kon-transcription/whisper"] whisper = ["kon-transcription/whisper", "whisper-vulkan"]
whisper-vulkan = ["kon-transcription/whisper-vulkan"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
@@ -41,7 +50,7 @@ kon-llm = { path = "../crates/llm" }
# autostart plugins are desktop-only — gated below under # autostart plugins are desktop-only — gated below under
# cfg(not(target_os = "android")). The dialog, opener, and notification # cfg(not(target_os = "android")). The dialog, opener, and notification
# plugins all support Android natively so live in the unconditional list. # plugins all support Android natively so live in the unconditional list.
tauri = { version = "2" } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge # Phase 6 nudges: OS-native notifications via the frontend-owned nudge

View File

@@ -6,6 +6,7 @@
"permissions": [ "permissions": [
"core:event:default", "core:event:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-hide", "core:window:allow-hide",
"core:window:allow-show", "core:window:allow-show",

View File

@@ -1 +1 @@
{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}} {"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-start-resize-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}

View File

@@ -58,14 +58,33 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
// custom frameless chrome drawn by the Titlebar component. // custom frameless chrome drawn by the Titlebar component.
let use_native_decorations = cfg!(target_os = "linux"); let use_native_decorations = cfg!(target_os = "linux");
// Built hidden so the GTK utility type hint can be applied pre-map on
// Linux — see the preview window for the same pattern. KWin/Mutter on
// Wayland reliably keep utility-class windows above normal windows
// (and respect runtime keep-above toggles for them); for normal
// windows the same hint requests are flaky post-map.
//
// Decorations are off for this window: the float route renders its own
// titlebar with the pin / close controls, and stacking native KDE
// decorations on top of that produced two titlebars and two close X's
// (the native one didn't even close the window because the in-page
// chrome captured the click first). Custom drag is wired via
// handleDragStart (startDragging) and ResizeHandles in the route.
let _ = use_native_decorations; // keep the OS detection for future use
let mut builder = let mut builder =
WebviewWindowBuilder::new(&app, "tasks-float", WebviewUrl::App("/float".into())) WebviewWindowBuilder::new(&app, "tasks-float", WebviewUrl::App("/float".into()))
.title("Kon Tasks") .title("Kon Tasks")
.inner_size(480.0, 520.0) .inner_size(480.0, 520.0)
.min_inner_size(360.0, 480.0) .min_inner_size(360.0, 480.0)
.always_on_top(true) .always_on_top(true)
.decorations(use_native_decorations) // Pin across virtual desktops so users who flip workspaces
.resizable(true); // mid-task don't lose the floating tasks list. Combined with
// always_on_top + utility hint, this matches what users
// expect from a "pin" button on KDE Plasma and GNOME.
.visible_on_all_workspaces(true)
.decorations(false)
.resizable(true)
.visible(false);
// Inject preferences before Svelte mounts // Inject preferences before Svelte mounts
if let Some(script) = app.try_state::<PreferencesScript>() { if let Some(script) = app.try_state::<PreferencesScript>() {
@@ -74,8 +93,23 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
} }
} }
builder.build().map_err(|e| e.to_string())?; let window = builder.build().map_err(|e| e.to_string())?;
// Apply the GTK Utility type hint before the window maps. On X11 and
// XWayland the hint is honoured immediately; on native Wayland-only
// compositors GTK uses the closest semantic equivalent. Must happen
// before show() per GTK3 docs.
#[cfg(target_os = "linux")]
{
use gdk::WindowTypeHint;
use gtk::prelude::GtkWindowExt;
if let Ok(gtk_window) = window.gtk_window() {
gtk_window.set_type_hint(WindowTypeHint::Utility);
}
}
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }

View File

@@ -1,18 +1,21 @@
<script lang="ts"> <script lang="ts">
// Phase 3 — Energy tag chip. Cycles a task's energy level through // Phase 3 — Energy tag chip. Cycles a task's energy level through
// the spec's four states: unset → High → Medium → Brain-Dead → unset. // the spec's four states: unset → High → Medium → Zero → unset.
// //
// Visual discipline: when energy is unset, the chip renders at // Visual discipline: when energy is unset, the chip renders at a
// `group-hover` opacity only so untagged rows stay calm. Once set, // muted but still legible opacity so users can discover the affordance
// the chip is always visible because the colour IS the signal for // without first hovering the row. The previous "0% until hover"
// the match-my-energy sort. // behaviour hid the control entirely, which read as broken on first
// contact. Once set, the chip is always fully visible because the
// colour IS the signal for the match-my-energy sort.
// //
// Colour choices borrow the existing design tokens: // Colour choices borrow the existing design tokens:
// High → accent (warm, on-brand, attention-ready) // High → accent (warm, on-brand, attention-ready)
// Medium → warning (amber, unforced) // Medium → warning (amber, unforced)
// Brain-Dead → text-tertiary (low-energy grey, not danger red — // Zero → text-tertiary (low-energy grey, not danger red — the
// the brief is explicit that this state must not feel // brief is explicit that this state must not feel
// pathologised) // pathologised; the internal enum value remains
// `brain_dead` to avoid a DB migration churn)
// //
// Callers pass the task's current energy and a setter. This component // Callers pass the task's current energy and a setter. This component
// owns no state — the task store is the source of truth. // owns no state — the task store is the source of truth.
@@ -34,7 +37,7 @@
// Cycle order lives here so the chip is the single authority on what // Cycle order lives here so the chip is the single authority on what
// "next" means. Tap once to tag, tap again to move up, tap past // "next" means. Tap once to tag, tap again to move up, tap past
// Brain-Dead to clear. Keyboard-equivalent via the <button> element. // Zero to clear. Keyboard-equivalent via the <button> element.
const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"]; const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"];
function next(): EnergyLevel | null { function next(): EnergyLevel | null {
@@ -46,7 +49,7 @@
switch (level) { switch (level) {
case "high": return "High"; case "high": return "High";
case "medium": return "Medium"; case "medium": return "Medium";
case "brain_dead": return "Brain-Dead"; case "brain_dead": return "Zero";
default: return "No energy set"; default: return "No energy set";
} }
} }
@@ -67,7 +70,7 @@
type="button" type="button"
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
{energy === null {energy === null
? 'opacity-0 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary' ? 'opacity-60 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary'
: ''} : ''}
{energy === 'high' {energy === 'high'
? 'text-accent border-accent bg-accent/10' ? 'text-accent border-accent bg-accent/10'

View File

@@ -5,8 +5,10 @@
import { errorMessage } from "$lib/utils/errors.js"; import { errorMessage } from "$lib/utils/errors.js";
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
type WhisperVariant = "tiny" | "base" | "small" | "distil-s" | "medium" | "distil-l";
let { modelSize = "base", onComplete = () => {} } = $props<{ let { modelSize = "base", onComplete = () => {} } = $props<{
modelSize?: "tiny" | "base" | "small" | "medium"; modelSize?: WhisperVariant;
onComplete?: () => void; onComplete?: () => void;
}>(); }>();
@@ -18,14 +20,17 @@
let unlisten: null | (() => void) = null; let unlisten: null | (() => void) = null;
const modelInfo = { const modelInfo = {
tiny: { size: "~75 MB", accuracy: "Basic" }, tiny: { display: "Tiny", size: "~75 MB", accuracy: "Basic accuracy" },
base: { size: "~142 MB", accuracy: "Good" }, base: { display: "Base", size: "~142 MB", accuracy: "Good accuracy" },
small: { size: "~466 MB", accuracy: "Better" }, small: { display: "Small", size: "~466 MB", accuracy: "Better accuracy" },
medium: { size: "~1.5 GB", accuracy: "Best" }, "distil-s": { display: "Distil-S", size: "~336 MB", accuracy: "Small-class accuracy at ~6× the speed" },
} satisfies Record<string, { size: string; accuracy: string }>; medium: { display: "Medium", size: "~1.5 GB", accuracy: "Best accuracy, slower" },
"distil-l": { display: "Distil-L", size: "~1.55 GB", accuracy: "Near-best accuracy at ~6× the speed" },
} satisfies Record<WhisperVariant, { display: string; size: string; accuracy: string }>;
let currentModelInfo = $derived( let currentModelInfo = $derived(
modelInfo[String(modelSize) as keyof typeof modelInfo] modelInfo[String(modelSize) as WhisperVariant]
); );
let displayName = $derived(currentModelInfo?.display ?? String(modelSize));
onMount(async () => { onMount(async () => {
unlisten = await listen("model-download-progress", (event) => { unlisten = await listen("model-download-progress", (event) => {
@@ -72,11 +77,15 @@
<h3 class="text-[18px] font-semibold text-text mb-2">Download Whisper Model</h3> <h3 class="text-[18px] font-semibold text-text mb-2">Download Whisper Model</h3>
<p class="text-[13px] text-text-secondary mb-1"> <p class="text-[13px] text-text-secondary mb-1">
Kon needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech. Kon needs the <span class="font-medium text-text">{displayName}</span> model to transcribe speech.
</p> </p>
{#if currentModelInfo}
<p class="text-[11px] text-text-tertiary mb-6"> <p class="text-[11px] text-text-tertiary mb-6">
{currentModelInfo?.size ?? "?"} · {currentModelInfo?.accuracy ?? "?"} accuracy · 100% offline {currentModelInfo.size} · {currentModelInfo.accuracy} · 100% offline
</p> </p>
{:else}
<p class="text-[11px] text-text-tertiary mb-6">100% offline</p>
{/if}
{#if downloading} {#if downloading}
<div class="mb-4"> <div class="mb-4">

View File

@@ -5,7 +5,7 @@
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js"; import { markError, markGenerating, markGenerationDone, markLoading, refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js"; import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
import { profilesStore } from "$lib/stores/profiles.svelte.ts"; import { profilesStore } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js"; import { toasts } from "$lib/stores/toasts.svelte.js";
@@ -22,7 +22,8 @@
import { bionicReading } from '$lib/actions/bionicReading.js'; import { bionicReading } from '$lib/actions/bionicReading.js';
import { measurePreWrap } from '$lib/utils/textMeasure.js'; import { measurePreWrap } from '$lib/utils/textMeasure.js';
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js'; import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
import { hasTauriRuntime } from '$lib/utils/runtime.js'; import { hasTauriRuntime, isAndroid } from '$lib/utils/runtime.js';
import { errorMessage } from '$lib/utils/errors.js';
const prefs = getPreferences(); const prefs = getPreferences();
const tauriRuntimeAvailable = hasTauriRuntime(); const tauriRuntimeAvailable = hasTauriRuntime();
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window."; const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window.";
@@ -240,6 +241,8 @@
try { try {
const status = await invoke("check_llm_model", { modelId: settings.llmModelId }); const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
if (status?.downloaded && !status.loaded) { if (status?.downloaded && !status.loaded) {
markLoading("Loading AI model");
try {
await invoke("load_llm_model", { await invoke("load_llm_model", {
modelId: settings.llmModelId, modelId: settings.llmModelId,
// Sequential-GPU guard (brief item A.1 #28): frees whisper // Sequential-GPU guard (brief item A.1 #28): frees whisper
@@ -247,9 +250,13 @@
// keeps both resident (default, safe on multi-GB cards). // keeps both resident (default, safe on multi-GB cards).
concurrent: settings.aiGpuConcurrency === "parallel", concurrent: settings.aiGpuConcurrency === "parallel",
}); });
} finally {
await refreshLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
}
} }
} catch (err) { } catch (err) {
console.warn("ensureLlmModelLoaded failed", err); console.warn("ensureLlmModelLoaded failed", err);
markError(errorMessage(err));
} }
} }
@@ -377,7 +384,11 @@
// Preview overlay: if the user opted in, reset any leftover state // Preview overlay: if the user opted in, reset any leftover state
// from a prior run and open the window when the main window is not // from a prior run and open the window when the main window is not
// focused (i.e. the user is dictating into some other app). // focused (i.e. the user is dictating into some other app).
if (settings.transcriptionPreview && tauriRuntimeAvailable) { if (settings.transcriptionPreview && tauriRuntimeAvailable && !isAndroid()) {
// open_preview_window is a desktop-only multi-window command; the
// Android Tauri stub returns an error. Skip it so we don't surface
// a noisy console error every time the user starts recording on
// a mobile build.
emit("preview-listening").catch(() => {}); emit("preview-listening").catch(() => {});
try { try {
const focused = await getCurrentWindow().isFocused(); const focused = await getCurrentWindow().isFocused();

View File

@@ -52,11 +52,19 @@
}); });
try { try {
// If the model is already on disk, skip the download step so the user
// can replay onboarding without re-downloading. download_model is a
// no-op for present files in current builds, but we belt-and-brace by
// checking is_downloaded first.
const alreadyDownloaded = (models ?? []).find((m) => m.id === modelId)?.is_downloaded ?? false;
if (modelId.startsWith("whisper-")) { if (modelId.startsWith("whisper-")) {
// backend's whisper_model_id accepts the full model id via its // backend's whisper_model_id accepts the full model id via its
// `other => ModelId::new(other)` fallback, so pass the id through // `other => ModelId::new(other)` fallback, so pass the id through
// unchanged rather than maintaining a fragile lowercased alias map. // unchanged rather than maintaining a fragile lowercased alias map.
if (!alreadyDownloaded) {
await invoke("download_model", { size: modelId }); await invoke("download_model", { size: modelId });
}
await invoke("load_model", { size: modelId }); await invoke("load_model", { size: modelId });
const idToLabel = { const idToLabel = {
@@ -70,7 +78,9 @@
settings.engine = "whisper"; settings.engine = "whisper";
settings.modelSize = idToLabel[modelId] ?? "Base"; settings.modelSize = idToLabel[modelId] ?? "Base";
} else if (modelId.startsWith("parakeet-")) { } else if (modelId.startsWith("parakeet-")) {
if (!alreadyDownloaded) {
await invoke("download_parakeet_model", { name: "ctc-int8" }); await invoke("download_parakeet_model", { name: "ctc-int8" });
}
await invoke("load_parakeet_model", { name: "ctc-int8" }); await invoke("load_parakeet_model", { name: "ctc-int8" });
settings.engine = "parakeet"; settings.engine = "parakeet";
} }
@@ -95,6 +105,12 @@
} }
} }
// Auto-detect is the default surface; the system breakdown and the
// manual model list live behind a "Choose manually" disclosure so the
// first-run screen is not a wall of jargon for users who just want to
// hit one button and start dictating.
let showManual = $state(false);
// Phase 5: forced-choice rituals + autostart prompts. Research on // Phase 5: forced-choice rituals + autostart prompts. Research on
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults // libertarian-paternalism nudges (Thaler/Sunstein) says defaults
// drive uptake, but the ADHD target audience is sensitive to // drive uptake, but the ADHD target audience is sensitive to
@@ -104,6 +120,11 @@
let ritualsStep = $state<RitualsStep>("idle"); let ritualsStep = $state<RitualsStep>("idle");
let autostartApplying = $state(false); let autostartApplying = $state(false);
function setupAutomatically() {
if (!models?.length) return;
downloadAndGo(models[0].id);
}
async function answerMorning(yes: boolean) { async function answerMorning(yes: boolean) {
settings.ritualsMorning = yes; settings.ritualsMorning = yes;
saveSettings(); saveSettings();
@@ -124,13 +145,20 @@
await plugin.enable(); await plugin.enable();
settings.launchAtLogin = true; settings.launchAtLogin = true;
} else { } else {
// Don't call disable() on a fresh install — there's nothing to // On a true first run this is already off, but replaying
// disable, and some platforms treat "disable when unset" as an // onboarding after previously choosing "Yes" must remove the
// error. Just record the choice. // OS-level login item too.
if (await plugin.isEnabled()) {
await plugin.disable();
}
settings.launchAtLogin = false; settings.launchAtLogin = false;
} }
} catch (err) { } catch (err) {
toasts.warn("Could not update autostart", String(err)); toasts.warn("Could not update autostart", String(err));
try {
const plugin = await import("@tauri-apps/plugin-autostart");
settings.launchAtLogin = await plugin.isEnabled();
} catch {}
} finally { } finally {
autostartApplying = false; autostartApplying = false;
settings.ritualsPromptSeen = true; settings.ritualsPromptSeen = true;
@@ -179,9 +207,9 @@
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" /> <Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Morning triage?</h2> <h2 class="text-xl font-medium text-text">Morning triage?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed"> <p class="text-sm text-text-secondary mt-3 leading-relaxed">
On the first launch of the day, a gentle modal shows yesterday's open items and asks you to pick up to three for today. The rest can wait. Each morning, pick three things to focus on. Everything else can wait.
</p> </p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. You can change your mind any time in Settings.</p> <p class="text-[11px] text-text-tertiary mt-3">Off by default. Change anytime in Settings.</p>
<div class="flex items-center justify-center gap-3 mt-6"> <div class="flex items-center justify-center gap-3 mt-6">
<button <button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover" class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -190,12 +218,12 @@
<button <button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover" class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerMorning(true)} onclick={() => answerMorning(true)}
>Yes, turn it on</button> >Turn on</button>
</div> </div>
<button <button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline" class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals} onclick={skipRituals}
>Skip all these questions</button> >Skip these</button>
</div> </div>
{:else if ritualsStep === "evening"} {:else if ritualsStep === "evening"}
@@ -203,9 +231,9 @@
<Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" /> <Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Evening wind-down?</h2> <h2 class="text-xl font-medium text-text">Evening wind-down?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed"> <p class="text-sm text-text-secondary mt-3 leading-relaxed">
A reflective page you can open when you want to close the day. Shows what you finished, names the open loops, then gets out of the way. Never scheduled, never nagging. A page to reflect on what you finished and what's still open — only when you choose to open it.
</p> </p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Always opt-in.</p> <p class="text-[11px] text-text-tertiary mt-3">Off by default. Never scheduled.</p>
<div class="flex items-center justify-center gap-3 mt-6"> <div class="flex items-center justify-center gap-3 mt-6">
<button <button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover" class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -214,22 +242,22 @@
<button <button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover" class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerEvening(true)} onclick={() => answerEvening(true)}
>Yes, turn it on</button> >Turn on</button>
</div> </div>
<button <button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline" class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals} onclick={skipRituals}
>Skip the rest</button> >Skip these</button>
</div> </div>
{:else if ritualsStep === "autostart"} {:else if ritualsStep === "autostart"}
<div class="w-full max-w-md mx-auto text-center"> <div class="w-full max-w-md mx-auto text-center">
<Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" /> <Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Launch Corbie at login?</h2> <h2 class="text-xl font-medium text-text">Launch Kon at login?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed"> <p class="text-sm text-text-secondary mt-3 leading-relaxed">
So Corbie is already there when you need it — especially useful if you said yes to morning triage. Uses your OS's standard autostart. No background tricks, no telemetry. Kon will be ready as soon as you sign in. Uses your OS's standard autostart.
</p> </p>
<p class="text-[11px] text-text-tertiary mt-3">You can change this any time in Settings.</p> <p class="text-[11px] text-text-tertiary mt-3">Change anytime in Settings.</p>
<div class="flex items-center justify-center gap-3 mt-6"> <div class="flex items-center justify-center gap-3 mt-6">
<button <button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover" class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -240,7 +268,7 @@
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60" class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60"
onclick={() => answerAutostart(true)} onclick={() => answerAutostart(true)}
disabled={autostartApplying} disabled={autostartApplying}
>{autostartApplying ? 'Saving…' : 'Yes, launch at login'}</button> >{autostartApplying ? 'Saving…' : 'Yes'}</button>
</div> </div>
</div> </div>
@@ -277,8 +305,30 @@
<div class="mt-4 p-3 rounded-lg bg-danger/10 text-danger text-sm">{error}</div> <div class="mt-4 p-3 rounded-lg bg-danger/10 text-danger text-sm">{error}</div>
{/if} {/if}
{#if models.length > 0}
<div class="mt-8 flex flex-col items-center">
<button
class="px-6 py-3 rounded-xl text-base font-medium bg-accent text-white hover:bg-accent-hover
shadow-[0_4px_16px_rgba(232,168,124,0.3)] active:scale-[0.97] transition-all duration-150"
onclick={setupAutomatically}
>
Set up automatically
</button>
<p class="mt-2 text-xs text-text-tertiary">
Picks the best model for your machine — about {models[0]?.disk_size_mb ?? "?"} MB.
</p>
</div>
<button
class="mt-6 mx-auto block text-xs text-text-secondary hover:text-text underline"
onclick={() => (showManual = !showManual)}
>
{showManual ? 'Hide manual setup' : 'Choose manually'}
</button>
{#if showManual}
{#if systemInfo} {#if systemInfo}
<div class="mt-8 p-4 rounded-lg bg-bg-input border border-border"> <div class="mt-4 p-4 rounded-lg bg-bg-input border border-border">
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Your system</h3> <h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Your system</h3>
<div class="grid grid-cols-2 gap-2 text-sm"> <div class="grid grid-cols-2 gap-2 text-sm">
<span class="text-text-secondary">RAM</span> <span class="text-text-secondary">RAM</span>
@@ -293,10 +343,8 @@
</div> </div>
{/if} {/if}
{#if models.length > 0}
<div class="mt-6"> <div class="mt-6">
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Pick a model</h3> <h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Pick a model</h3>
<p class="text-xs text-text-tertiary mb-3">One tap — we handle the rest.</p>
<div class="space-y-2"> <div class="space-y-2">
{#each models as model, i} {#each models as model, i}
<button <button
@@ -304,7 +352,6 @@
{i === 0 ? 'border-accent bg-accent/5 hover:bg-accent/10' : 'border-border bg-bg-input hover:bg-hover'}" {i === 0 ? 'border-accent bg-accent/5 hover:bg-accent/10' : 'border-border bg-bg-input hover:bg-hover'}"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
onclick={() => downloadAndGo(model.id)} onclick={() => downloadAndGo(model.id)}
disabled={model.is_downloaded}
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -324,9 +371,10 @@
</div> </div>
</div> </div>
{/if} {/if}
{/if}
<button <button
class="mt-6 text-xs text-text-tertiary hover:text-text-secondary underline" class="mt-6 mx-auto block text-xs text-text-tertiary hover:text-text-secondary underline"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
onclick={skipSetup} onclick={skipSetup}
> >

View File

@@ -2,6 +2,7 @@
// @ts-nocheck // @ts-nocheck
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { isAndroid } from "$lib/utils/runtime.js";
import { import {
history, history,
saveTranscriptMeta, saveTranscriptMeta,
@@ -916,6 +917,9 @@
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); copyItem(item); }} onclick={(e) => { e.stopPropagation(); copyItem(item); }}
>Copy</button> >Copy</button>
{#if !isAndroid()}
<!-- open_viewer_window is desktop-only; the
Android Tauri stub returns an error. -->
<button <button
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text" class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
@@ -925,6 +929,7 @@
Edit Edit
<ExternalLink size={11} aria-hidden="true" /> <ExternalLink size={11} aria-hidden="true" />
</button> </button>
{/if}
<button <button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text" class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
@@ -953,7 +958,7 @@
<Sparkles size={11} aria-hidden="true" /> <Sparkles size={11} aria-hidden="true" />
{titling.has(item.id) ? "Titling…" : "Title"} {titling.has(item.id) ? "Titling…" : "Title"}
</button> </button>
{#if item.audioPath && item.segments && item.segments.length > 0} {#if item.audioPath && item.segments && item.segments.length > 0 && !isAndroid()}
<button <button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover" class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"

View File

@@ -14,6 +14,7 @@
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts"; import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js"; import { toasts } from "$lib/stores/toasts.svelte.js";
import { errorMessage } from "$lib/utils/errors.js";
import { clampTextLines } from "$lib/utils/textMeasure.js"; import { clampTextLines } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { Check, ChevronRight } from "lucide-svelte"; import { Check, ChevronRight } from "lucide-svelte";
@@ -22,7 +23,7 @@
// Aliased because SettingsPage has its own local refreshLlmStatus // Aliased because SettingsPage has its own local refreshLlmStatus
// that just mutates the page-local llmLoaded bool. The store // that just mutates the page-local llmLoaded bool. The store
// version drives the sidebar chip (brief item #31). // version drives the sidebar chip (brief item #31).
import { refreshLlmStatus as refreshGlobalLlmStatus } from "$lib/stores/llmStatus.svelte.js"; import { refreshLlmStatus as refreshGlobalLlmStatus, markError as markGlobalLlmError, markLoading as markGlobalLlmLoading } from "$lib/stores/llmStatus.svelte.js";
const prefs = getPreferences(); const prefs = getPreferences();
@@ -520,7 +521,7 @@
await invoke("download_llm_model", { modelId }); await invoke("download_llm_model", { modelId });
llmDownloadingModel = ""; llmDownloadingModel = "";
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
llmStatus = "Download complete"; llmStatus = "Download complete";
} catch (err) { } catch (err) {
llmDownloadingModel = ""; llmDownloadingModel = "";
@@ -531,6 +532,7 @@
async function loadSelectedLlmModel() { async function loadSelectedLlmModel() {
const modelId = selectedLlmModelId(); const modelId = selectedLlmModelId();
llmStatus = "Loading..."; llmStatus = "Loading...";
markGlobalLlmLoading("Loading AI model");
try { try {
await invoke("load_llm_model", { await invoke("load_llm_model", {
modelId, modelId,
@@ -538,9 +540,12 @@
concurrent: settings.aiGpuConcurrency === "parallel", concurrent: settings.aiGpuConcurrency === "parallel",
}); });
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
} catch (err) { } catch (err) {
llmStatus = typeof err === "string" ? err : "LLM load failed"; const message = errorMessage(err);
llmStatus = message || "LLM load failed";
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
markGlobalLlmError(message);
} }
} }
@@ -548,7 +553,7 @@
try { try {
await invoke("unload_llm_model"); await invoke("unload_llm_model");
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
llmStatus = "Model unloaded"; llmStatus = "Model unloaded";
} catch (err) { } catch (err) {
llmStatus = typeof err === "string" ? err : "LLM unload failed"; llmStatus = typeof err === "string" ? err : "LLM unload failed";
@@ -560,7 +565,7 @@
try { try {
await invoke("delete_llm_model", { modelId }); await invoke("delete_llm_model", { modelId });
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
llmStatus = "Downloaded model removed"; llmStatus = "Downloaded model removed";
} catch (err) { } catch (err) {
llmStatus = typeof err === "string" ? err : "Delete failed"; llmStatus = typeof err === "string" ? err : "Delete failed";
@@ -589,7 +594,7 @@
// already loaded) — refresh both Settings-local and global // already loaded) — refresh both Settings-local and global
// status so the sidebar chip and download/load buttons react. // status so the sidebar chip and download/load buttons react.
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
} catch (err) { } catch (err) {
llmStatus = typeof err === "string" ? err : "Test failed"; llmStatus = typeof err === "string" ? err : "Test failed";
llmTestHint = ""; llmTestHint = "";
@@ -618,7 +623,7 @@
await unloadLlmModel(); await unloadLlmModel();
} else { } else {
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
} }
llmStatus = llmModelDownloaded(modelId) llmStatus = llmModelDownloaded(modelId)
? "Selected model changed. Load it to enable AI features." ? "Selected model changed. Load it to enable AI features."
@@ -630,7 +635,7 @@
if (openSection === 'ai') { if (openSection === 'ai') {
await ensureRecommendedLlmTier(); await ensureRecommendedLlmTier();
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
} }
} }
@@ -719,7 +724,7 @@
systemInfo = await invoke("probe_system").catch(() => null); systemInfo = await invoke("probe_system").catch(() => null);
await ensureRecommendedLlmTier(); await ensureRecommendedLlmTier();
await refreshLlmStatus(); await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier); await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
const loaded = await invoke("check_engine"); const loaded = await invoke("check_engine");
engineOk = loaded; engineOk = loaded;
engineStatus = loaded ? "Model loaded" : "No model loaded"; engineStatus = loaded ? "Model loaded" : "No model loaded";
@@ -2322,6 +2327,26 @@
</details> </details>
{/if} {/if}
</div> </div>
<!-- Replay onboarding — useful for testing first-run flow without
wiping data. Resets the rituals-prompt-seen flag and routes
the main shell back to the FirstRunPage. Already-downloaded
models stay clickable there, so no re-download is needed. -->
<div class="mt-6 pt-5 border-t border-border-subtle">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Onboarding</p>
<p class="text-[11px] text-text-tertiary mb-3">
Replay the first-run welcome and the morning / evening / autostart prompts. Your downloaded models, transcripts, and tasks are kept.
</p>
<button
type="button"
onclick={() => {
settings.ritualsPromptSeen = false;
saveSettings();
page.current = "first-run";
}}
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
>Replay onboarding</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -2,6 +2,7 @@
import { tick } from "svelte"; import { tick } from "svelte";
import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app"; import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { isAndroid } from "$lib/utils/runtime.js";
import { import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask, tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
setTaskEnergy, setTaskEnergy,
@@ -107,7 +108,7 @@
switch (level) { switch (level) {
case "high": return "High"; case "high": return "High";
case "medium": return "Medium"; case "medium": return "Medium";
case "brain_dead": return "Brain-Dead"; case "brain_dead": return "Zero";
default: return "Not set"; default: return "Not set";
} }
} }
@@ -123,7 +124,7 @@
{ value: null, label: "—" }, { value: null, label: "—" },
{ value: "high", label: "High" }, { value: "high", label: "High" },
{ value: "medium", label: "Med" }, { value: "medium", label: "Med" },
{ value: "brain_dead", label: "Low" }, { value: "brain_dead", label: "Zero" },
]; ];
let energyRadioGroupEl = $state<HTMLDivElement | null>(null); let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
@@ -359,6 +360,10 @@
</button> </button>
</div> </div>
{#if !isAndroid()}
<!-- Multi-window pop-out is desktop-only — the Tauri Android stub
for open_task_window returns an error, so the button would just
surface a toast on a mobile build. Hide it instead. -->
<button <button
class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text" class="flex items-center gap-1.5 btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
@@ -368,6 +373,7 @@
<ExternalLink size={14} aria-hidden="true" /> <ExternalLink size={14} aria-hidden="true" />
Pop out Pop out
</button> </button>
{/if}
</div> </div>
<!-- Search --> <!-- Search -->
@@ -410,7 +416,12 @@
<!-- Bucket tabs + sort --> <!-- Bucket tabs + sort -->
<div class="flex items-center gap-1 px-7 pb-3"> <div class="flex items-center gap-1 px-7 pb-3">
<nav aria-label="Task filters"> <!-- Bucket tabs render as a horizontal pill row. The nav element is
block-level by default, which previously made its button children
stack vertically — the outer flex container only governs siblings,
not children of the nav. Adding flex/gap here puts the tabs
side-by-side as intended. -->
<nav aria-label="Task filters" class="flex items-center gap-1 flex-wrap">
{#each buckets as bucket} {#each buckets as bucket}
<button <button
class="flex items-center gap-1.5 btn-md rounded-lg class="flex items-center gap-1.5 btn-md rounded-lg
@@ -457,10 +468,15 @@
</div> </div>
<!-- Main content: sidebar + tasks --> <!-- Main content: sidebar + tasks -->
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3"> <div class="flex flex-1 min-h-0 px-7 pb-4 gap-3 items-start">
<!-- List sidebar --> <!-- List sidebar — `self-start` + `max-h-full` so the panel sizes to
its content rather than stretching to the full task-area height.
A nearly-empty list with three items used to draw a column that
ran the full height of the window even though it had nothing in
it; this keeps the surface honest. The inner items list keeps
its scroll affordance for users with many lists. -->
<div <div
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden self-start max-h-full
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}" {sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
style="transition: width var(--duration-ui)" style="transition: width var(--duration-ui)"
> >

View File

@@ -17,37 +17,73 @@ export interface LlmStatusState {
export const llmStatus = $state<LlmStatusState>({ kind: "off", detail: null }); export const llmStatus = $state<LlmStatusState>({ kind: "off", detail: null });
/// Poll `get_llm_status` once. Cheap enough to call on layout mount, on interface RefreshLlmStatusOptions {
/// recording start, and on Settings-panel open. Keeps the chip in sync force?: boolean;
/// with loads / unloads that happen outside the frontend's observation }
/// path (first-run, background reload). The `aiTier` input short-circuits
/// to "off" so we don't show a chip when the user has opted out. /// Poll the configured model's status once. Cheap enough to call on layout
export async function refreshLlmStatus(aiTier: string): Promise<void> { /// mount, on recording start, and on Settings-panel open. Keeps the chip in
if (aiTier === "off") { /// sync with loads / unloads that happen outside the frontend's observation
llmStatus.kind = "off"; /// path (first-run, background reload).
llmStatus.detail = null; ///
return; /// Visibility rule: the chip appears only when there is something
} /// meaningful for the user to know — the model is ready, generating, or
if (!hasTauriRuntime()) { /// errored, or an explicit load is in flight (markLoading). It is hidden
// Running in a pure-browser preview (vite dev without tauri) — leave /// when AI is off, no model is configured, or the configured model isn't
// the chip off rather than asserting a loaded state we can't verify. /// downloaded yet — those states surface in Settings, not as a stuck
/// "warming" pill that misleads the user into thinking the engine is busy.
export async function refreshLlmStatus(
aiTier: string,
llmModelId: string | null,
options: RefreshLlmStatusOptions = {},
): Promise<void> {
const force = options.force === true;
// Generation is a foreground action with its own success/failure
// transition. Ambient health checks should never hide it.
if (llmStatus.kind === "generating") return;
// Warming means a caller explicitly started a load. Ambient refreshes
// should not clobber that in-flight signal; post-load callers pass
// force=true to reconcile the final state.
if (llmStatus.kind === "warming" && !force) return;
if (aiTier === "off" || !hasTauriRuntime() || !llmModelId) {
llmStatus.kind = "off"; llmStatus.kind = "off";
llmStatus.detail = null; llmStatus.detail = null;
return; return;
} }
try { try {
const loaded = await invoke<boolean>("get_llm_status"); const status = await invoke<{ downloaded: boolean; loaded: boolean }>(
// Don't clobber a "generating" state with "ready" — a parallel "check_llm_model",
// cleanup call in flight is a truer signal than the load status. { modelId: llmModelId },
if (llmStatus.kind === "generating") return; );
llmStatus.kind = loaded ? "ready" : "warming"; if (status.loaded) {
llmStatus.kind = "ready";
llmStatus.detail = null; llmStatus.detail = null;
} else {
// Not loaded and no load in flight — keep the chip hidden. A genuine
// load (triggered from DictationPage / SettingsPage) will call
// markLoading() to flip the chip.
llmStatus.kind = "off";
llmStatus.detail = null;
}
} catch (err) { } catch (err) {
llmStatus.kind = "error"; llmStatus.kind = "error";
llmStatus.detail = typeof err === "string" ? err : (err as Error)?.message ?? "Unknown error"; llmStatus.detail = typeof err === "string" ? err : (err as Error)?.message ?? "Unknown error";
} }
} }
export function markLoading(detail: string | null = "Loading model"): void {
llmStatus.kind = "warming";
llmStatus.detail = detail;
}
export function markError(detail: string | null = null): void {
llmStatus.kind = "error";
llmStatus.detail = detail;
}
export function markGenerating(detail: string | null = null): void { export function markGenerating(detail: string | null = null): void {
llmStatus.kind = "generating"; llmStatus.kind = "generating";
llmStatus.detail = detail; llmStatus.detail = detail;

View File

@@ -334,7 +334,7 @@
// backend is in right now. The chip also reacts to the $effect // backend is in right now. The chip also reacts to the $effect
// on settings.aiTier below and to explicit mark-generating // on settings.aiTier below and to explicit mark-generating
// calls from DictationPage around cleanup_transcript_text_cmd. // calls from DictationPage around cleanup_transcript_text_cmd.
refreshLlmStatus(settings.aiTier).catch(() => {}); refreshLlmStatus(settings.aiTier, settings.llmModelId).catch(() => {});
if (settings.prewarmModelOnStartup) { if (settings.prewarmModelOnStartup) {
invoke("prewarm_default_model_cmd").catch(() => {}); invoke("prewarm_default_model_cmd").catch(() => {});

View File

@@ -11,15 +11,13 @@
applyExternalPreferences, applyExternalPreferences,
PREFERENCES_CHANGED_EVENT, PREFERENCES_CHANGED_EVENT,
} from "$lib/stores/preferences.svelte.js"; } from "$lib/stores/preferences.svelte.js";
import Titlebar from "$lib/components/Titlebar.svelte";
import FocusTimer from "$lib/components/FocusTimer.svelte"; import FocusTimer from "$lib/components/FocusTimer.svelte";
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js"; import ResizeHandles from "$lib/components/ResizeHandles.svelte";
let { children } = $props(); let { children } = $props();
let glowing = $state(false); let glowing = $state(false);
let unlistenFocus = null; let unlistenFocus = null;
let unlistenPrefs = null; let unlistenPrefs = null;
let useCustomChrome = $state(false);
const prefs = getPreferences(); const prefs = getPreferences();
@@ -44,9 +42,10 @@
} }
onMount(async () => { onMount(async () => {
loadOsInfo() // The float route renders its own pin / close header inside +page.svelte,
.then(() => { useCustomChrome = !isLinux(); }) // so the layout no longer needs to swap in a separate Titlebar component
.catch(() => {}); // based on platform. Keeping the loadOsInfo call out entirely simplifies
// the chrome — one titlebar source of truth, one place for the pin.
try { try {
unlistenFocus = await listen("task-window-focus", () => { unlistenFocus = await listen("task-window-focus", () => {
glowing = true; glowing = true;
@@ -84,14 +83,15 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col {glowing ? 'animate-glow-pulse' : ''}"> <div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col {glowing ? 'animate-glow-pulse' : ''}">
{#if useCustomChrome}
<Titlebar compact />
{/if}
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
{@render children()} {@render children()}
</div> </div>
</div> </div>
<!-- Mounted outside the animated/transformed root so fixed-position
resize hit zones anchor to the webview viewport. -->
<ResizeHandles />
<!-- Focus timer also visible in the always-on-top float window so a <!-- Focus timer also visible in the always-on-top float window so a
running countdown stays with the Now list. The component is a running countdown stays with the Now list. The component is a
global overlay (position: fixed) so it pins to the top-right of global overlay (position: fixed) so it pins to the top-right of

View File

@@ -70,7 +70,21 @@
async function togglePin() { async function togglePin() {
pinned = !pinned; pinned = !pinned;
await getCurrentWindow().setAlwaysOnTop(pinned); const w = getCurrentWindow();
await w.setAlwaysOnTop(pinned);
if (pinned) {
// KWin/Mutter on Wayland routinely drop _NET_WM_STATE_ABOVE when it
// arrives post-map for a normal window. Combined with the GTK Utility
// hint applied at creation (open_task_window), an unmap/map cycle is
// the most reliable way to force the compositor to re-apply state
// changes. Hidden for one event-loop tick — visually a flicker, but
// the pin actually sticks afterwards.
try {
await w.hide();
await w.show();
await w.setFocus();
} catch {}
}
} }
function closeWindow() { function closeWindow() {

View File

@@ -2,7 +2,6 @@ import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/