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

View File

@@ -1,18 +1,21 @@
<script lang="ts">
// 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
// `group-hover` opacity only so untagged rows stay calm. Once set,
// the chip is always visible because the colour IS the signal for
// the match-my-energy sort.
// Visual discipline: when energy is unset, the chip renders at a
// muted but still legible opacity so users can discover the affordance
// without first hovering the row. The previous "0% until hover"
// 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:
// High → accent (warm, on-brand, attention-ready)
// Medium → warning (amber, unforced)
// Brain-Dead → text-tertiary (low-energy grey, not danger red —
// the brief is explicit that this state must not feel
// pathologised)
// High → accent (warm, on-brand, attention-ready)
// Medium → warning (amber, unforced)
// Zero → text-tertiary (low-energy grey, not danger red — the
// brief is explicit that this state must not feel
// 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
// 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
// "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"];
function next(): EnergyLevel | null {
@@ -46,7 +49,7 @@
switch (level) {
case "high": return "High";
case "medium": return "Medium";
case "brain_dead": return "Brain-Dead";
case "brain_dead": return "Zero";
default: return "No energy set";
}
}
@@ -67,7 +70,7 @@
type="button"
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
{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'
? 'text-accent border-accent bg-accent/10'

View File

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

View File

@@ -5,7 +5,7 @@
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
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 { profilesStore } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js";
@@ -22,7 +22,8 @@
import { bionicReading } from '$lib/actions/bionicReading.js';
import { measurePreWrap } from '$lib/utils/textMeasure.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 tauriRuntimeAvailable = hasTauriRuntime();
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window.";
@@ -240,16 +241,22 @@
try {
const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
if (status?.downloaded && !status.loaded) {
await invoke("load_llm_model", {
modelId: settings.llmModelId,
// Sequential-GPU guard (brief item A.1 #28): frees whisper
// before loading the LLM on tight-VRAM setups. "parallel"
// keeps both resident (default, safe on multi-GB cards).
concurrent: settings.aiGpuConcurrency === "parallel",
});
markLoading("Loading AI model");
try {
await invoke("load_llm_model", {
modelId: settings.llmModelId,
// Sequential-GPU guard (brief item A.1 #28): frees whisper
// before loading the LLM on tight-VRAM setups. "parallel"
// keeps both resident (default, safe on multi-GB cards).
concurrent: settings.aiGpuConcurrency === "parallel",
});
} finally {
await refreshLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
}
}
} catch (err) {
console.warn("ensureLlmModelLoaded failed", err);
markError(errorMessage(err));
}
}
@@ -377,7 +384,11 @@
// 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
// 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(() => {});
try {
const focused = await getCurrentWindow().isFocused();

View File

@@ -52,11 +52,19 @@
});
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-")) {
// backend's whisper_model_id accepts the full model id via its
// `other => ModelId::new(other)` fallback, so pass the id through
// unchanged rather than maintaining a fragile lowercased alias map.
await invoke("download_model", { size: modelId });
if (!alreadyDownloaded) {
await invoke("download_model", { size: modelId });
}
await invoke("load_model", { size: modelId });
const idToLabel = {
@@ -70,7 +78,9 @@
settings.engine = "whisper";
settings.modelSize = idToLabel[modelId] ?? "Base";
} else if (modelId.startsWith("parakeet-")) {
await invoke("download_parakeet_model", { name: "ctc-int8" });
if (!alreadyDownloaded) {
await invoke("download_parakeet_model", { name: "ctc-int8" });
}
await invoke("load_parakeet_model", { name: "ctc-int8" });
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
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults
// drive uptake, but the ADHD target audience is sensitive to
@@ -104,6 +120,11 @@
let ritualsStep = $state<RitualsStep>("idle");
let autostartApplying = $state(false);
function setupAutomatically() {
if (!models?.length) return;
downloadAndGo(models[0].id);
}
async function answerMorning(yes: boolean) {
settings.ritualsMorning = yes;
saveSettings();
@@ -124,13 +145,20 @@
await plugin.enable();
settings.launchAtLogin = true;
} else {
// Don't call disable() on a fresh install — there's nothing to
// disable, and some platforms treat "disable when unset" as an
// error. Just record the choice.
// On a true first run this is already off, but replaying
// onboarding after previously choosing "Yes" must remove the
// OS-level login item too.
if (await plugin.isEnabled()) {
await plugin.disable();
}
settings.launchAtLogin = false;
}
} catch (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 {
autostartApplying = false;
settings.ritualsPromptSeen = true;
@@ -179,9 +207,9 @@
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Morning triage?</h2>
<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 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">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -190,12 +218,12 @@
<button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerMorning(true)}
>Yes, turn it on</button>
>Turn on</button>
</div>
<button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals}
>Skip all these questions</button>
>Skip these</button>
</div>
{:else if ritualsStep === "evening"}
@@ -203,9 +231,9 @@
<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>
<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 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">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -214,22 +242,22 @@
<button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerEvening(true)}
>Yes, turn it on</button>
>Turn on</button>
</div>
<button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals}
>Skip the rest</button>
>Skip these</button>
</div>
{:else if ritualsStep === "autostart"}
<div class="w-full max-w-md mx-auto text-center">
<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">
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 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">
<button
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"
onclick={() => answerAutostart(true)}
disabled={autostartApplying}
>{autostartApplying ? 'Saving…' : 'Yes, launch at login'}</button>
>{autostartApplying ? 'Saving…' : 'Yes'}</button>
</div>
</div>
@@ -277,56 +305,76 @@
<div class="mt-4 p-3 rounded-lg bg-danger/10 text-danger text-sm">{error}</div>
{/if}
{#if systemInfo}
<div class="mt-8 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>
<div class="grid grid-cols-2 gap-2 text-sm">
<span class="text-text-secondary">RAM</span>
<span class="text-text">{Math.round(systemInfo.ram_mb / 1024)} GB</span>
<span class="text-text-secondary">CPU</span>
<span class="text-text truncate" title={systemInfo.cpu_brand}>{systemInfo.cpu_brand}</span>
<span class="text-text-secondary">Cores</span>
<span class="text-text">{systemInfo.cpu_cores}</span>
<span class="text-text-secondary">OS</span>
<span class="text-text">{systemInfo.os}</span>
</div>
</div>
{/if}
{#if models.length > 0}
<div class="mt-6">
<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">
{#each models as model, i}
<button
class="w-full text-left p-3 rounded-lg border
{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)"
onclick={() => downloadAndGo(model.id)}
disabled={model.is_downloaded}
>
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium text-text">{model.display_name}</span>
{#if i === 0}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">Recommended</span>
{/if}
{#if model.is_downloaded}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-success/15 text-success font-medium">Downloaded</span>
{/if}
</div>
<span class="text-xs text-text-tertiary">{model.disk_size_mb} MB</span>
</div>
<p class="text-xs text-text-secondary mt-1">{model.description}</p>
</button>
{/each}
</div>
<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}
<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>
<div class="grid grid-cols-2 gap-2 text-sm">
<span class="text-text-secondary">RAM</span>
<span class="text-text">{Math.round(systemInfo.ram_mb / 1024)} GB</span>
<span class="text-text-secondary">CPU</span>
<span class="text-text truncate" title={systemInfo.cpu_brand}>{systemInfo.cpu_brand}</span>
<span class="text-text-secondary">Cores</span>
<span class="text-text">{systemInfo.cpu_cores}</span>
<span class="text-text-secondary">OS</span>
<span class="text-text">{systemInfo.os}</span>
</div>
</div>
{/if}
<div class="mt-6">
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Pick a model</h3>
<div class="space-y-2">
{#each models as model, i}
<button
class="w-full text-left p-3 rounded-lg border
{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)"
onclick={() => downloadAndGo(model.id)}
>
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium text-text">{model.display_name}</span>
{#if i === 0}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">Recommended</span>
{/if}
{#if model.is_downloaded}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-success/15 text-success font-medium">Downloaded</span>
{/if}
</div>
<span class="text-xs text-text-tertiary">{model.disk_size_mb} MB</span>
</div>
<p class="text-xs text-text-secondary mt-1">{model.description}</p>
</button>
{/each}
</div>
</div>
{/if}
{/if}
<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)"
onclick={skipSetup}
>

View File

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

View File

@@ -14,6 +14,7 @@
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js";
import { errorMessage } from "$lib/utils/errors.js";
import { clampTextLines } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { Check, ChevronRight } from "lucide-svelte";
@@ -22,7 +23,7 @@
// Aliased because SettingsPage has its own local refreshLlmStatus
// that just mutates the page-local llmLoaded bool. The store
// 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();
@@ -520,7 +521,7 @@
await invoke("download_llm_model", { modelId });
llmDownloadingModel = "";
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
llmStatus = "Download complete";
} catch (err) {
llmDownloadingModel = "";
@@ -531,6 +532,7 @@
async function loadSelectedLlmModel() {
const modelId = selectedLlmModelId();
llmStatus = "Loading...";
markGlobalLlmLoading("Loading AI model");
try {
await invoke("load_llm_model", {
modelId,
@@ -538,9 +540,12 @@
concurrent: settings.aiGpuConcurrency === "parallel",
});
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId, { force: true });
} 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 {
await invoke("unload_llm_model");
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
llmStatus = "Model unloaded";
} catch (err) {
llmStatus = typeof err === "string" ? err : "LLM unload failed";
@@ -560,7 +565,7 @@
try {
await invoke("delete_llm_model", { modelId });
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
llmStatus = "Downloaded model removed";
} catch (err) {
llmStatus = typeof err === "string" ? err : "Delete failed";
@@ -589,7 +594,7 @@
// already loaded) — refresh both Settings-local and global
// status so the sidebar chip and download/load buttons react.
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
} catch (err) {
llmStatus = typeof err === "string" ? err : "Test failed";
llmTestHint = "";
@@ -618,7 +623,7 @@
await unloadLlmModel();
} else {
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
}
llmStatus = llmModelDownloaded(modelId)
? "Selected model changed. Load it to enable AI features."
@@ -630,7 +635,7 @@
if (openSection === 'ai') {
await ensureRecommendedLlmTier();
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
}
}
@@ -719,7 +724,7 @@
systemInfo = await invoke("probe_system").catch(() => null);
await ensureRecommendedLlmTier();
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
await refreshGlobalLlmStatus(settings.aiTier, settings.llmModelId);
const loaded = await invoke("check_engine");
engineOk = loaded;
engineStatus = loaded ? "Model loaded" : "No model loaded";
@@ -2322,6 +2327,26 @@
</details>
{/if}
</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>
{/if}
</div>

View File

@@ -2,6 +2,7 @@
import { tick } from "svelte";
import type { EnergyLevel, TaskBucket, TaskEntry, TaskList } from "$lib/types/app";
import { invoke } from "@tauri-apps/api/core";
import { isAndroid } from "$lib/utils/runtime.js";
import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
setTaskEnergy,
@@ -107,7 +108,7 @@
switch (level) {
case "high": return "High";
case "medium": return "Medium";
case "brain_dead": return "Brain-Dead";
case "brain_dead": return "Zero";
default: return "Not set";
}
}
@@ -123,7 +124,7 @@
{ value: null, label: "—" },
{ value: "high", label: "High" },
{ value: "medium", label: "Med" },
{ value: "brain_dead", label: "Low" },
{ value: "brain_dead", label: "Zero" },
];
let energyRadioGroupEl = $state<HTMLDivElement | null>(null);
@@ -359,15 +360,20 @@
</button>
</div>
<button
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)"
onclick={popOutTasks}
aria-label="Pop out task window"
>
<ExternalLink size={14} aria-hidden="true" />
Pop out
</button>
{#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
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)"
onclick={popOutTasks}
aria-label="Pop out task window"
>
<ExternalLink size={14} aria-hidden="true" />
Pop out
</button>
{/if}
</div>
<!-- Search -->
@@ -410,7 +416,12 @@
<!-- Bucket tabs + sort -->
<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}
<button
class="flex items-center gap-1.5 btn-md rounded-lg
@@ -457,10 +468,15 @@
</div>
<!-- Main content: sidebar + tasks -->
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3">
<!-- List sidebar -->
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3 items-start">
<!-- 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
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]'}"
style="transition: width var(--duration-ui)"
>

View File

@@ -17,37 +17,73 @@ export interface LlmStatusState {
export const llmStatus = $state<LlmStatusState>({ kind: "off", detail: null });
/// Poll `get_llm_status` once. Cheap enough to call on layout mount, on
/// recording start, and on Settings-panel open. Keeps the chip in sync
/// 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.
export async function refreshLlmStatus(aiTier: string): Promise<void> {
if (aiTier === "off") {
llmStatus.kind = "off";
llmStatus.detail = null;
return;
}
if (!hasTauriRuntime()) {
// Running in a pure-browser preview (vite dev without tauri) — leave
// the chip off rather than asserting a loaded state we can't verify.
interface RefreshLlmStatusOptions {
force?: boolean;
}
/// Poll the configured model's status once. Cheap enough to call on layout
/// mount, on recording start, and on Settings-panel open. Keeps the chip in
/// sync with loads / unloads that happen outside the frontend's observation
/// path (first-run, background reload).
///
/// Visibility rule: the chip appears only when there is something
/// meaningful for the user to know — the model is ready, generating, or
/// errored, or an explicit load is in flight (markLoading). It is hidden
/// when AI is off, no model is configured, or the configured model isn't
/// 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.detail = null;
return;
}
try {
const loaded = await invoke<boolean>("get_llm_status");
// Don't clobber a "generating" state with "ready" — a parallel
// cleanup call in flight is a truer signal than the load status.
if (llmStatus.kind === "generating") return;
llmStatus.kind = loaded ? "ready" : "warming";
llmStatus.detail = null;
const status = await invoke<{ downloaded: boolean; loaded: boolean }>(
"check_llm_model",
{ modelId: llmModelId },
);
if (status.loaded) {
llmStatus.kind = "ready";
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) {
llmStatus.kind = "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 {
llmStatus.kind = "generating";
llmStatus.detail = detail;

View File

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

View File

@@ -11,15 +11,13 @@
applyExternalPreferences,
PREFERENCES_CHANGED_EVENT,
} from "$lib/stores/preferences.svelte.js";
import Titlebar from "$lib/components/Titlebar.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 glowing = $state(false);
let unlistenFocus = null;
let unlistenPrefs = null;
let useCustomChrome = $state(false);
const prefs = getPreferences();
@@ -44,9 +42,10 @@
}
onMount(async () => {
loadOsInfo()
.then(() => { useCustomChrome = !isLinux(); })
.catch(() => {});
// The float route renders its own pin / close header inside +page.svelte,
// so the layout no longer needs to swap in a separate Titlebar component
// based on platform. Keeping the loadOsInfo call out entirely simplifies
// the chrome — one titlebar source of truth, one place for the pin.
try {
unlistenFocus = await listen("task-window-focus", () => {
glowing = true;
@@ -84,14 +83,15 @@
<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' : ''}">
{#if useCustomChrome}
<Titlebar compact />
{/if}
<div class="flex-1 min-h-0 overflow-hidden">
{@render children()}
</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
running countdown stays with the Now list. The component is a
global overlay (position: fixed) so it pins to the top-right of

View File

@@ -70,7 +70,21 @@
async function togglePin() {
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() {