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

@@ -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)"
>