feat(sidebar B.1 #31): visible LLM status chip with live state
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

The LLM runtime has been quiet since it shipped in Phase 3 — users
had no surface-level signal that cleanup was loaded, warming, or
actively generating. Settings has verbose status text internally,
but a dictation-flow user never opens Settings during a run.

New: a shared $state store drives a small chip in the sidebar that
reflects the true LLM state in ≤500 ms of any transition (brief
item #31 acceptance). Five states:

  off        → hidden (user has aiTier === "off")
  warming    → model download or first load in flight; amber pulse
  ready      → loaded + idle; green dot
  generating → cleanup_transcript_text_cmd or
               extract_tasks_from_transcript_cmd in flight; accent
               pulse with Sparkles icon
  error      → last operation failed; red dot with AlertTriangle

The store exposes three calls: refreshLlmStatus(aiTier) (polls the
backend), markGenerating(detail) / markGenerationDone(success).
DictationPage wraps its cleanup + extract calls in mark-generating
pairs. SettingsPage's LLM load / unload / delete / download paths
also refresh the global store so Settings-initiated transitions
surface in the sidebar immediately. The chip collapses to a
dot-only compact form when the sidebar is collapsed.

No backend changes — everything wires onto the existing
`get_llm_status` Tauri command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 16:40:02 +01:00
parent ae4c1e3c6d
commit ad311d278f
6 changed files with 164 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { page, settings, tasks, saveSettings } from "$lib/stores/page.svelte.js";
import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
import LlmStatusChip from "$lib/components/LlmStatusChip.svelte";
let taskCount = $derived(tasks.filter((t) => !t.done).length);
@@ -53,6 +54,17 @@
{#if !collapsed}
<p class="text-[10px] text-text-tertiary px-5 mt-1.5 tracking-[0.12em] uppercase">Think out loud</p>
<!-- LLM status chip — hidden when aiTier === off, otherwise reflects
disconnected / warming / ready / generating / error state live. -->
<div class="px-5 mt-2">
<LlmStatusChip />
</div>
{:else}
<!-- Collapsed: compact dot-only chip so the sidebar still surfaces
the state without eating horizontal space. -->
<div class="mt-2 flex justify-center">
<LlmStatusChip compact />
</div>
{/if}
<!-- Separator -->

View File

@@ -0,0 +1,60 @@
<script lang="ts">
// @ts-nocheck
import { Sparkles, AlertTriangle } from "lucide-svelte";
import { llmStatus } from "$lib/stores/llmStatus.svelte.js";
let { compact = false } = $props<{ compact?: boolean }>();
let label = $derived.by(() => {
switch (llmStatus.kind) {
case "warming":
return llmStatus.detail ?? "AI warming";
case "ready":
return "AI ready";
case "generating":
return llmStatus.detail ?? "Cleaning…";
case "error":
return "AI error";
default:
return "";
}
});
let dotClass = $derived.by(() => {
switch (llmStatus.kind) {
case "warming":
return "bg-warning animate-pulse";
case "ready":
return "bg-success";
case "generating":
return "bg-accent animate-pulse";
case "error":
return "bg-danger";
default:
return "bg-text-tertiary";
}
});
</script>
{#if llmStatus.kind !== "off"}
{#if compact}
<span
class="inline-flex items-center justify-center w-[10px] h-[10px] rounded-full {dotClass}"
title={label}
aria-label={label}
></span>
{:else}
<span
class="inline-flex items-center gap-1.5 px-2 py-[3px] rounded-full bg-bg-elevated border border-border-subtle text-[10px] text-text-secondary whitespace-nowrap"
title={llmStatus.detail ?? label}
>
<span class="inline-block w-[6px] h-[6px] rounded-full {dotClass}"></span>
{#if llmStatus.kind === "error"}
<AlertTriangle size={10} class="text-danger" />
{:else if llmStatus.kind === "generating"}
<Sparkles size={10} class="text-accent" />
{/if}
<span>{label}</span>
</span>
{/if}
{/if}

View File

@@ -5,6 +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 { profilesStore } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js";
import Card from "$lib/components/Card.svelte";
@@ -444,14 +445,20 @@
const llmLoaded = await invoke("get_llm_status").catch(() => false);
if (!llmLoaded) return text;
// Flip the sidebar LLM status chip to "generating" so the user can
// see the cleanup pass in flight (brief item #31). Always paired
// with markGenerationDone below — success or failure.
markGenerating("Cleaning up");
try {
const cleaned = await invoke("cleanup_transcript_text_cmd", {
transcript: text,
profileId: profilesStore.activeProfileId,
});
markGenerationDone(true);
return cleaned?.trim() ? cleaned.trim() : text;
} catch (err) {
console.warn("LLM cleanup failed, keeping existing transcript", err);
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
return text;
}
}
@@ -459,11 +466,14 @@
async function extractTasksForTranscript(text) {
const llmLoaded = await invoke("get_llm_status").catch(() => false);
if (settings.aiTier === "tasks" && llmLoaded) {
markGenerating("Extracting tasks");
try {
const items = await invoke("extract_tasks_from_transcript_cmd", { transcript: text });
markGenerationDone(true);
return items.map((taskText) => ({ text: taskText }));
} catch (err) {
console.warn("LLM extract_tasks failed, falling back to regex", err);
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
}
}

View File

@@ -18,6 +18,10 @@
import { Check, ChevronRight } from "lucide-svelte";
import { _ } from "svelte-i18n";
import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n";
// 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";
const prefs = getPreferences();
@@ -515,6 +519,7 @@
await invoke("download_llm_model", { modelId });
llmDownloadingModel = "";
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
llmStatus = "Download complete";
} catch (err) {
llmDownloadingModel = "";
@@ -528,6 +533,7 @@
try {
await invoke("load_llm_model", { modelId });
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
} catch (err) {
llmStatus = typeof err === "string" ? err : "LLM load failed";
}
@@ -537,6 +543,7 @@
try {
await invoke("unload_llm_model");
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
llmStatus = "Model unloaded";
} catch (err) {
llmStatus = typeof err === "string" ? err : "LLM unload failed";
@@ -548,6 +555,7 @@
try {
await invoke("delete_llm_model", { modelId });
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
llmStatus = "Downloaded model removed";
} catch (err) {
llmStatus = typeof err === "string" ? err : "Delete failed";
@@ -574,6 +582,7 @@
await unloadLlmModel();
} else {
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
}
llmStatus = llmModelDownloaded(modelId)
? "Selected model changed. Load it to enable AI features."
@@ -585,6 +594,7 @@
if (openSection === 'ai') {
await ensureRecommendedLlmTier();
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
}
}
@@ -594,6 +604,7 @@
systemInfo = await invoke("probe_system").catch(() => null);
await ensureRecommendedLlmTier();
await refreshLlmStatus();
await refreshGlobalLlmStatus(settings.aiTier);
const loaded = await invoke("check_engine");
engineOk = loaded;
engineStatus = loaded ? "Model loaded" : "No model loaded";

View File

@@ -0,0 +1,64 @@
import { invoke } from "@tauri-apps/api/core";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
/// Visible LLM state for the status chip (brief item #31).
/// off — user has settings.aiTier === "off"; chip hidden.
/// warming — model download or load in progress.
/// ready — model loaded, idle.
/// generating — an active cleanup_transcript_text_cmd call is in flight.
/// error — last health check or generation failed.
export type LlmStatusKind = "off" | "warming" | "ready" | "generating" | "error";
export interface LlmStatusState {
kind: LlmStatusKind;
/// Optional short phrase for the chip to surface (e.g. "Loading Qwen3 4B").
detail: string | null;
}
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.
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;
} catch (err) {
llmStatus.kind = "error";
llmStatus.detail = typeof err === "string" ? err : (err as Error)?.message ?? "Unknown error";
}
}
export function markGenerating(detail: string | null = null): void {
llmStatus.kind = "generating";
llmStatus.detail = detail;
}
export function markGenerationDone(success: boolean, detail: string | null = null): void {
if (success) {
llmStatus.kind = "ready";
llmStatus.detail = null;
} else {
llmStatus.kind = "error";
llmStatus.detail = detail;
}
}

View File

@@ -22,6 +22,7 @@
import { listen } from "@tauri-apps/api/event";
import { toasts } from "$lib/stores/toasts.svelte.js";
import { initI18n } from "$lib/i18n";
import { refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
import { page as sveltePage } from "$app/stores";
@@ -301,6 +302,12 @@
})
.catch(() => { /* update check failure must not affect the app */ });
// Seed the LLM status chip (sidebar) with whichever state the
// 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(() => {});
// Meeting auto-capture: poll the process list and toast when a match
// appears (edge-triggered — no repeat toasts until the app goes away
// and comes back). We never start recording from this signal; the