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:
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user