Skip to content

Commit 84fd280

Browse files
committed
Add toast notifications and Ollama status awareness
A lightweight toast system (no new dependency) gives feedback on actions that were previously silent — API key saves, custom provider add/remove, exports, copy-as-markdown — and surfaces Ollama startup failures with an actionable message. The chat view now shows an inline "Ollama isn't running" banner with a Start button whenever an Ollama model is selected while the server is offline, instead of letting the first send fail with a raw network error.
1 parent bf4bb6e commit 84fd280

5 files changed

Lines changed: 166 additions & 13 deletions

File tree

frontend/src/App.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Settings from "./pages/Settings";
55
import Compare from "./pages/Compare";
66
import UsageDashboard from "./pages/UsageDashboard";
77
import { ThemeProvider } from "@/components/theme-provider";
8+
import { ToastProvider } from "@/components/toast";
89
import { SessionsProvider } from "@/lib/sessions-context";
910
import { I18nProvider } from "@/lib/i18n";
1011

@@ -26,9 +27,11 @@ function App() {
2627
return (
2728
<ThemeProvider defaultTheme="system" storageKey="app-ui-theme">
2829
<I18nProvider>
29-
<SessionsProvider>
30-
<RouterProvider router={router} />
31-
</SessionsProvider>
30+
<ToastProvider>
31+
<SessionsProvider>
32+
<RouterProvider router={router} />
33+
</SessionsProvider>
34+
</ToastProvider>
3235
</I18nProvider>
3336
</ThemeProvider>
3437
);

frontend/src/components/toast.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/* eslint-disable react-refresh/only-export-components -- the provider and its
2+
hook are one inseparable unit; splitting them into separate files would be
3+
pure ceremony for a component this small. */
4+
import { createContext, useCallback, useContext, useRef, useState } from "react";
5+
import { CheckCircle2, AlertCircle, Info, X } from "lucide-react";
6+
import { cn } from "@/lib/utils";
7+
8+
type ToastKind = "success" | "error" | "info";
9+
10+
interface Toast {
11+
id: number;
12+
kind: ToastKind;
13+
message: string;
14+
}
15+
16+
const TOAST_DURATION_MS = 4000;
17+
18+
const ToastContext = createContext<((kind: ToastKind, message: string) => void) | null>(null);
19+
20+
export function ToastProvider({ children }: { children: React.ReactNode }) {
21+
const [toasts, setToasts] = useState<Toast[]>([]);
22+
const nextId = useRef(1);
23+
24+
const push = useCallback((kind: ToastKind, message: string) => {
25+
const id = nextId.current++;
26+
setToasts((prev) => [...prev, { id, kind, message }]);
27+
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), TOAST_DURATION_MS);
28+
}, []);
29+
30+
return (
31+
<ToastContext.Provider value={push}>
32+
{children}
33+
<div className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-80 flex-col gap-2">
34+
{toasts.map((toast) => (
35+
<div
36+
key={toast.id}
37+
role="status"
38+
className={cn(
39+
"pointer-events-auto flex items-start gap-2 rounded-lg border p-3 text-sm shadow-lg",
40+
"animate-in slide-in-from-bottom-2 fade-in bg-background",
41+
toast.kind === "error" ? "border-destructive/50" : "border-border"
42+
)}
43+
>
44+
{toast.kind === "success" && <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-primary" />}
45+
{toast.kind === "error" && <AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />}
46+
{toast.kind === "info" && <Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
47+
<span className="flex-1">{toast.message}</span>
48+
<button
49+
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
50+
className="shrink-0 text-muted-foreground hover:text-foreground"
51+
aria-label="Dismiss notification"
52+
>
53+
<X className="size-3.5" />
54+
</button>
55+
</div>
56+
))}
57+
</div>
58+
</ToastContext.Provider>
59+
);
60+
}
61+
62+
export function useToast() {
63+
const push = useContext(ToastContext);
64+
return {
65+
success: (message: string) => push?.("success", message),
66+
error: (message: string) => push?.("error", message),
67+
info: (message: string) => push?.("info", message),
68+
};
69+
}

frontend/src/lib/translations.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,15 @@ export interface Dictionary {
268268
recordShortcut: string;
269269
reset: string;
270270
keybindingConflict: string;
271+
toastApiKeySaved: string;
272+
toastApiKeyCleared: string;
273+
toastProviderAdded: string;
274+
toastProviderRemoved: string;
275+
toastExportDone: string;
276+
toastOllamaNotInstalled: string;
277+
toastOllamaStartFailed: string;
278+
copiedAsMarkdown: string;
279+
ollamaOfflineBanner: string;
271280
copied: string;
272281
openLogsFolder: string;
273282
checkForUpdates: string;
@@ -557,6 +566,15 @@ export const en: Dictionary = {
557566
recordShortcut: "Record shortcut",
558567
reset: "Reset",
559568
keybindingConflict: "{key} is already used by another shortcut.",
569+
toastApiKeySaved: "API key saved",
570+
toastApiKeyCleared: "API key removed",
571+
toastProviderAdded: "provider added",
572+
toastProviderRemoved: "Provider removed",
573+
toastExportDone: "Export complete",
574+
toastOllamaNotInstalled: "Ollama isn't installed — download it from ollama.com to run local models.",
575+
toastOllamaStartFailed: "Couldn't start Ollama",
576+
copiedAsMarkdown: "Conversation copied as Markdown",
577+
ollamaOfflineBanner: "Ollama isn't running — this model won't respond until it's started.",
560578
copyDiagnosticInfo: "Copy diagnostic info",
561579
copied: "Copied",
562580
openLogsFolder: "Open logs folder",
@@ -856,6 +874,15 @@ export const tr: Dictionary = {
856874
recordShortcut: "Kısayolu kaydet",
857875
reset: "Sıfırla",
858876
keybindingConflict: "{key} zaten başka bir kısayol tarafından kullanılıyor.",
877+
toastApiKeySaved: "API anahtarı kaydedildi",
878+
toastApiKeyCleared: "API anahtarı kaldırıldı",
879+
toastProviderAdded: "sağlayıcı eklendi",
880+
toastProviderRemoved: "Sağlayıcı kaldırıldı",
881+
toastExportDone: "Dışa aktarma tamamlandı",
882+
toastOllamaNotInstalled: "Ollama kurulu değil — yerel modeller çalıştırmak için ollama.com adresinden indirin.",
883+
toastOllamaStartFailed: "Ollama başlatılamadı",
884+
copiedAsMarkdown: "Sohbet Markdown olarak kopyalandı",
885+
ollamaOfflineBanner: "Ollama çalışmıyor — başlatılana kadar bu model yanıt vermeyecek.",
859886
copied: "Kopyalandı",
860887
openLogsFolder: "Günlük klasörünü aç",
861888
checkForUpdates: "Güncellemeleri denetle",

frontend/src/pages/Chat.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
Circle,
3636
ListChecks,
3737
ShieldQuestion,
38+
AlertTriangle,
3839
} from "lucide-react";
3940
import { Button } from "@/components/ui/button";
4041
import { Textarea } from "@/components/ui/textarea";
@@ -67,6 +68,7 @@ import { PromptVariableDialog } from "@/components/prompt-variable-dialog";
6768
import { ScreenshotPickerDialog } from "@/components/screenshot-picker-dialog";
6869
import { speakText, stopSpeaking } from "@/lib/tts";
6970
import { computeLineDiff } from "@/lib/diff";
71+
import { useToast } from "@/components/toast";
7072
import type {
7173
ChatMessage,
7274
OllamaModel,
@@ -369,8 +371,10 @@ export default function Chat() {
369371
const navigate = useNavigate();
370372
const { sessions, projects, loading, hasApi, createSession, refresh } = useSessions();
371373
const { t } = useI18n();
374+
const toast = useToast();
372375

373376
const [models, setModels] = useState<OllamaModel[]>([]);
377+
const [ollamaRunning, setOllamaRunning] = useState<boolean | null>(null);
374378
const [llamaCppModels, setLlamaCppModels] = useState<LocalGgufModel[]>([]);
375379
const [model, setModel] = useState<string>("");
376380
const [pendingCustomProvider, setPendingCustomProvider] = useState<ProviderId | null>(null);
@@ -468,6 +472,14 @@ export default function Chat() {
468472
})();
469473
}, [agentWorkspace, pendingToolCalls, writeDiffPreviews]);
470474

475+
// Only checked while an Ollama model is selected — the banner's render
476+
// condition also gates on the provider, so a stale value from a previous
477+
// Ollama selection can never show for a cloud model.
478+
useEffect(() => {
479+
if (!hasApi || parseModelRef(model)?.provider !== "ollama") return;
480+
window.api.ollama.status().then(setOllamaRunning);
481+
}, [hasApi, model]);
482+
471483
useEffect(() => {
472484
if (!hasApi || !sessionId) return;
473485
window.api.sessions.get(sessionId).then((session) => {
@@ -801,7 +813,20 @@ export default function Chat() {
801813
async function handleCopyChatMarkdown() {
802814
if (!sessionId) return;
803815
const markdown = await window.api.data.getSessionMarkdown(sessionId);
804-
if (markdown) await navigator.clipboard.writeText(markdown);
816+
if (markdown) {
817+
await navigator.clipboard.writeText(markdown);
818+
toast.success(t.copiedAsMarkdown);
819+
}
820+
}
821+
822+
async function startOllamaFromBanner() {
823+
const result = await window.api.ollama.start();
824+
setOllamaRunning(!result.error);
825+
if (result.error === "not-installed") {
826+
toast.error(t.toastOllamaNotInstalled);
827+
} else if (result.error) {
828+
toast.error(`${t.toastOllamaStartFailed}: ${result.error}`);
829+
}
805830
}
806831

807832
async function runCompletion(
@@ -1699,6 +1724,16 @@ export default function Chat() {
16991724
</Popover>
17001725
</div>
17011726

1727+
{parsedModel?.provider === "ollama" && ollamaRunning === false && (
1728+
<div className="flex items-center gap-2 border-b border-border bg-destructive/5 px-4 py-2 text-xs">
1729+
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
1730+
<span className="flex-1">{t.ollamaOfflineBanner}</span>
1731+
<Button size="sm" variant="outline" onClick={startOllamaFromBanner}>
1732+
{t.start}
1733+
</Button>
1734+
</div>
1735+
)}
1736+
17021737
{planSteps.length > 0 && (
17031738
<div className="border-b border-border bg-muted/30 px-4 py-2">
17041739
<div className="mx-auto flex max-w-3xl flex-col gap-1 2xl:max-w-4xl">

frontend/src/pages/Settings.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import type {
6464
} from "@/types/electron";
6565
import { EXTRA_MODELS } from "@/lib/model-catalog";
6666
import { recommendGpuBackend, gpuBackendNote } from "@/lib/gpu";
67+
import { useToast } from "@/components/toast";
6768
import {
6869
DEFAULT_KEYBINDINGS,
6970
KEYBINDING_ACTIONS,
@@ -162,6 +163,7 @@ export default function Settings() {
162163
const [editDraftName, setEditDraftName] = useState("");
163164
const [editDraftPrompt, setEditDraftPrompt] = useState("");
164165
const { refresh: refreshSessions } = useSessions();
166+
const toast = useToast();
165167
const activePullCount = useRef(0);
166168

167169
const [mcpStatuses, setMcpStatuses] = useState<Record<string, McpServerStatus>>({});
@@ -336,7 +338,8 @@ export default function Settings() {
336338
}
337339

338340
async function handleExportAll() {
339-
await window.api.data.exportAll();
341+
const result = await window.api.data.exportAll();
342+
if (result.success) toast.success(t.toastExportDone);
340343
}
341344

342345
async function handleImport() {
@@ -432,27 +435,35 @@ export default function Settings() {
432435
}
433436

434437
async function saveOpenaiKey() {
435-
await window.api.secrets.set("openai_api_key", openaiKeyInput.trim());
436-
setOpenaiKeySet(!!openaiKeyInput.trim());
438+
const value = openaiKeyInput.trim();
439+
await window.api.secrets.set("openai_api_key", value);
440+
setOpenaiKeySet(!!value);
437441
setOpenaiKeyInput("");
442+
toast.success(value ? t.toastApiKeySaved : t.toastApiKeyCleared);
438443
}
439444

440445
async function saveAnthropicKey() {
441-
await window.api.secrets.set("anthropic_api_key", anthropicKeyInput.trim());
442-
setAnthropicKeySet(!!anthropicKeyInput.trim());
446+
const value = anthropicKeyInput.trim();
447+
await window.api.secrets.set("anthropic_api_key", value);
448+
setAnthropicKeySet(!!value);
443449
setAnthropicKeyInput("");
450+
toast.success(value ? t.toastApiKeySaved : t.toastApiKeyCleared);
444451
}
445452

446453
async function saveFigmaToken() {
447-
await window.api.secrets.set("figma_token", figmaTokenInput.trim());
448-
setFigmaTokenSet(!!figmaTokenInput.trim());
454+
const value = figmaTokenInput.trim();
455+
await window.api.secrets.set("figma_token", value);
456+
setFigmaTokenSet(!!value);
449457
setFigmaTokenInput("");
458+
toast.success(value ? t.toastApiKeySaved : t.toastApiKeyCleared);
450459
}
451460

452461
async function saveGeminiKey() {
453-
await window.api.secrets.set("gemini_api_key", geminiKeyInput.trim());
454-
setGeminiKeySet(!!geminiKeyInput.trim());
462+
const value = geminiKeyInput.trim();
463+
await window.api.secrets.set("gemini_api_key", value);
464+
setGeminiKeySet(!!value);
455465
setGeminiKeyInput("");
466+
toast.success(value ? t.toastApiKeySaved : t.toastApiKeyCleared);
456467
}
457468

458469
function prefillCustomProviderPreset(preset: { name: string; baseUrl: string; modelIds: string[] }) {
@@ -480,6 +491,7 @@ export default function Settings() {
480491
setCustomDraftBaseUrl("");
481492
setCustomDraftModelIds("");
482493
setShowAddCustomProvider(false);
494+
toast.success(`${name}${t.toastProviderAdded}`);
483495
}
484496

485497
async function removeCustomProvider(id: string) {
@@ -488,13 +500,15 @@ export default function Settings() {
488500
customProviders: (settings.customProviders ?? []).filter((p) => p.id !== id),
489501
});
490502
setSettings(updated);
503+
toast.success(t.toastProviderRemoved);
491504
}
492505

493506
async function saveCustomProviderKey(id: string) {
494507
const value = (customKeyInputs[id] ?? "").trim();
495508
await window.api.secrets.set(`custom_${id}_api_key`, value);
496509
setCustomKeySet((prev) => ({ ...prev, [id]: !!value }));
497510
setCustomKeyInputs((prev) => ({ ...prev, [id]: "" }));
511+
toast.success(value ? t.toastApiKeySaved : t.toastApiKeyCleared);
498512
}
499513

500514
async function saveOllamaHost() {
@@ -546,6 +560,11 @@ export default function Settings() {
546560
} else {
547561
const result = await window.api.ollama.start();
548562
setRunning(!result.error);
563+
if (result.error === "not-installed") {
564+
toast.error(t.toastOllamaNotInstalled);
565+
} else if (result.error) {
566+
toast.error(`${t.toastOllamaStartFailed}: ${result.error}`);
567+
}
549568
}
550569
}
551570

0 commit comments

Comments
 (0)