diff --git a/src/lib/Sidebar.svelte b/src/lib/Sidebar.svelte
index b32ac54..6fe6fce 100644
--- a/src/lib/Sidebar.svelte
+++ b/src/lib/Sidebar.svelte
@@ -1,6 +1,7 @@
+
+{#if llmStatus.kind !== "off"}
+ {#if compact}
+
+ {:else}
+
+
+ {#if llmStatus.kind === "error"}
+
+ {:else if llmStatus.kind === "generating"}
+
+ {/if}
+ {label}
+
+ {/if}
+{/if}
diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte
index 079ec65..7c1aeb8 100644
--- a/src/lib/pages/DictationPage.svelte
+++ b/src/lib/pages/DictationPage.svelte
@@ -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);
}
}
diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte
index 1019fcf..03fbe3c 100644
--- a/src/lib/pages/SettingsPage.svelte
+++ b/src/lib/pages/SettingsPage.svelte
@@ -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";
diff --git a/src/lib/stores/llmStatus.svelte.ts b/src/lib/stores/llmStatus.svelte.ts
new file mode 100644
index 0000000..f8aaa5d
--- /dev/null
+++ b/src/lib/stores/llmStatus.svelte.ts
@@ -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({ 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 {
+ 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("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;
+ }
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 45f1c30..28f1836 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -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