feat(sidebar B.1 #31): visible LLM status chip with live state
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:
@@ -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 -->
|
||||
|
||||
60
src/lib/components/LlmStatusChip.svelte
Normal file
60
src/lib/components/LlmStatusChip.svelte
Normal 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}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
64
src/lib/stores/llmStatus.svelte.ts
Normal file
64
src/lib/stores/llmStatus.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user