diff --git a/src/app/components/AgentsPanel.tsx b/src/app/components/AgentsPanel.tsx index 317f075..a313884 100644 --- a/src/app/components/AgentsPanel.tsx +++ b/src/app/components/AgentsPanel.tsx @@ -29,6 +29,7 @@ import { import { useAsyncAgents } from "@/app/hooks/useAsyncAgents"; import { useAutoNotify } from "@/app/hooks/useAutoNotify"; import { initializeThreadAutoNotifyReports } from "@/lib/autoNotify"; +import { errorToast } from "@/lib/errorReporter"; import { messagesToSubAgentSteps, type SubAgentStep, @@ -204,7 +205,10 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { // check_async_task(task_id) to fetch the real result — zero backend change. const reportToMain = (task: EnrichedAsyncTask) => { if (!onReportToMainChat || !threadId) { - toast.error("Open the conversation to notify the main agent."); + errorToast( + "agents.notify", + "Open the conversation to notify the main agent." + ); return; } const result = onReportToMainChat(task, threadId); @@ -218,7 +222,8 @@ export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { toast.info("This result is already in the main chat."); return; } - toast.error( + errorToast( + "agents.notify", result === "wrong-thread" ? "The active conversation changed — reopen this task and try again." : "Main chat is busy — try again when it's idle." diff --git a/src/app/components/ChatInterface.tsx b/src/app/components/ChatInterface.tsx index 258c818..f42f551 100644 --- a/src/app/components/ChatInterface.tsx +++ b/src/app/components/ChatInterface.tsx @@ -101,6 +101,7 @@ import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog"; import { MemoryFileDialog } from "@/app/components/MemoryFileDialog"; import { FILE_LINK_EVENT, type FileLinkEventDetail } from "@/lib/fileLink"; import { notifyWorkspaceChanged } from "@/lib/workspaceEvents"; +import { errorToast } from "@/lib/errorReporter"; import type { ModelRef, ReasoningEffort, @@ -1027,7 +1028,8 @@ export const ChatInterface = React.memo( } uploaded to the workspace.` ); } catch (error) { - toast.error( + errorToast( + "chat.attachment", error instanceof Error ? error.message : "Failed to upload files." ); } finally { @@ -1663,7 +1665,8 @@ export const ChatInterface = React.memo( toast.success(`Model set to ${modelKey}.`); setModelPickerOpen(false); } catch (err) { - toast.error( + errorToast( + "chat.message", err instanceof Error ? `Couldn't update model: ${err.message}` : "Couldn't update model — try again." @@ -1695,7 +1698,8 @@ export const ChatInterface = React.memo( toast.success("Model reset to the registry default."); setModelPickerOpen(false); } catch (err) { - toast.error( + errorToast( + "chat.message", err instanceof Error ? `Couldn't reset model: ${err.message}` : "Couldn't reset model — try again." @@ -2269,7 +2273,8 @@ export const ChatInterface = React.memo( : `Reasoning effort set to ${effort}.` ); } catch (err) { - toast.error( + errorToast( + "chat.model", err instanceof Error ? `Couldn't update reasoning effort: ${err.message}` : "Couldn't update reasoning effort — try again." @@ -2305,7 +2310,8 @@ export const ChatInterface = React.memo( : `Top P set to ${value.value.toFixed(2)} (temperature omitted).` ); } catch (err) { - toast.error( + errorToast( + "chat.model", err instanceof Error ? `Couldn't update sampling: ${err.message}` : "Couldn't update sampling — try again." diff --git a/src/app/components/ChatMessage.tsx b/src/app/components/ChatMessage.tsx index 348532d..e550e20 100644 --- a/src/app/components/ChatMessage.tsx +++ b/src/app/components/ChatMessage.tsx @@ -31,7 +31,7 @@ import { copyText } from "@/lib/clipboard"; import { mapActionRequestsToToolCalls } from "@/lib/actionGrouping"; import { mergeApprovalDecision } from "@/lib/approvalDecision"; import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode"; -import { toast } from "sonner"; +import { errorToast } from "@/lib/errorReporter"; interface ChatMessageProps { message: Message; @@ -182,7 +182,7 @@ export const ChatMessage = React.memo( if (copyResetTimer.current) clearTimeout(copyResetTimer.current); copyResetTimer.current = setTimeout(() => setCopied(false), 2000); } else { - toast.error("Couldn't copy to clipboard."); + errorToast("clipboard", "Couldn't copy to clipboard."); } }, [messageContent]); const [expandedSubAgents, setExpandedSubAgents] = useState< diff --git a/src/app/components/CodeBlock.tsx b/src/app/components/CodeBlock.tsx index 89978b6..4afd0cb 100644 --- a/src/app/components/CodeBlock.tsx +++ b/src/app/components/CodeBlock.tsx @@ -4,8 +4,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { Check, Copy } from "lucide-react"; -import { toast } from "sonner"; import { copyText } from "@/lib/clipboard"; +import { errorToast } from "@/lib/errorReporter"; interface CodeBlockProps { language: string; @@ -34,7 +34,7 @@ export const CodeBlock = React.memo(({ language, value }) => { if (copyResetTimer.current) clearTimeout(copyResetTimer.current); copyResetTimer.current = setTimeout(() => setCopied(false), 2000); } else { - toast.error("Couldn't copy to clipboard."); + errorToast("clipboard", "Couldn't copy to clipboard."); } }, [value]); diff --git a/src/app/components/FileViewDialog.tsx b/src/app/components/FileViewDialog.tsx index a75da70..7af44f8 100644 --- a/src/app/components/FileViewDialog.tsx +++ b/src/app/components/FileViewDialog.tsx @@ -9,9 +9,9 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; -import { toast } from "sonner"; -import { MarkdownContent } from "@/app/components/MarkdownContent"; import { copyText } from "@/lib/clipboard"; +import { errorToast } from "@/lib/errorReporter"; +import { MarkdownContent } from "@/app/components/MarkdownContent"; import type { FileItem } from "@/app/types/types"; import useSWRMutation from "swr/mutation"; @@ -69,7 +69,7 @@ export const FileViewDialog = React.memo<{ }, { onSuccess: () => setIsEditingMode(false), - onError: (error) => toast.error(`Failed to save file: ${error}`), + onError: (error) => errorToast("files.save", `Failed to save file: ${error}`), } ); diff --git a/src/app/components/ImageModelsEditor.tsx b/src/app/components/ImageModelsEditor.tsx index 2f59090..e4556fb 100644 --- a/src/app/components/ImageModelsEditor.tsx +++ b/src/app/components/ImageModelsEditor.tsx @@ -3,11 +3,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Eye, EyeOff } from "lucide-react"; import { toast } from "sonner"; +import { errorToast } from "@/lib/errorReporter"; import { Button } from "@/components/ui/button"; import { getImageGeneration, newImageModelDraft, putImageGeneration, + testImageModel, type ImageGenerationSettings, type ImageModel, type ImageModelStatus, @@ -120,9 +122,44 @@ function ImageModelCard({ onRemove: () => void; }) { const [showKey, setShowKey] = useState(false); + const [testing, setTesting] = useState(false); + const [outcome, setOutcome] = useState(null); const paramsResult = parseParams(model.paramsText); const paramsError = paramsResult.ok ? null : paramsResult.message; + const runTest = async () => { + if (!paramsResult.ok) return; + setTesting(true); + setOutcome(null); + try { + const result = await testImageModel({ + id: model.id, + name: model.name, + provider: model.provider, + api_key: model.api_key, + base_url: model.base_url, + enabled: model.enabled, + default_size: model.default_size, + default_quality: model.default_quality, + params: paramsResult.value, + }); + setOutcome({ ok: true, latencyMs: result.latency_ms }); + toast.success( + `${model.id || "Image model"} passed the test in ${(result.latency_ms / 1000).toFixed(1)} s.` + ); + } catch (reason) { + const message = + reason instanceof Error ? reason.message : "Test failed."; + setOutcome({ ok: false, message }); + errorToast( + "models.image-test", + `${model.id || "Image model"} failed the test: ${message}` + ); + } finally { + setTesting(false); + } + }; + return (
@@ -336,7 +373,8 @@ export function ImageModelsEditor() { setDraft(toDraftState(saved)); toast.success("Image models saved."); } catch (reason) { - toast.error( + errorToast( + "models.image-save", reason instanceof Error ? reason.message : "Failed to save image models." ); } finally { diff --git a/src/app/components/RegistryEditor.tsx b/src/app/components/RegistryEditor.tsx index 2a3b641..7b727bb 100644 --- a/src/app/components/RegistryEditor.tsx +++ b/src/app/components/RegistryEditor.tsx @@ -1,8 +1,9 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react"; import { toast } from "sonner"; +import { errorToast } from "@/lib/errorReporter"; import { Button } from "@/components/ui/button"; import { invalidateAvailableModels } from "@/app/hooks/useAvailableModels"; import type { @@ -962,7 +963,7 @@ export function RegistryEditor() { return; } setSaveIssues(result.error); - toast.error(result.error.message); + errorToast("models.registry", result.error.message); return; } setData(result.body); @@ -972,7 +973,8 @@ export function RegistryEditor() { invalidateAvailableModels(); toast.success(`Registry saved (revision ${result.body.revision}).`); } catch (reason) { - toast.error( + errorToast( + "models.registry", reason instanceof Error ? reason.message : "Failed to save the registry." ); } finally { @@ -1004,7 +1006,8 @@ export function RegistryEditor() { if (isAffected(next.defaults.primary)) { const replacement = enabledModelRefs(next)[0]?.ref ?? null; if (!replacement) { - toast.error( + errorToast( + "models.registry", "Editing the default primary model requires a second enabled model to take over." ); return; @@ -1019,14 +1022,15 @@ export function RegistryEditor() { }); if (!result.ok) { if (result.status === 409) { - toast.error( + errorToast( + "models.registry", "Registry changed elsewhere — reloaded the latest state." ); await load(); return; } setSaveIssues(result.error); - toast.error(result.error.message); + errorToast("models.registry", result.error.message); return; } setData(result.body); @@ -1037,7 +1041,8 @@ export function RegistryEditor() { `Registry saved (revision ${result.body.revision}); changed models are disabled — test and re-enable them when ready.` ); } catch (reason) { - toast.error( + errorToast( + "models.registry", reason instanceof Error ? reason.message : "Failed to save the registry." ); } finally { @@ -1106,7 +1111,10 @@ export function RegistryEditor() { code: null, }, })); - toast.error(`${key} failed the provider test.`); + errorToast( + "models.provider-test", + `${key} failed the provider test.` + ); } } catch (reason) { setTestOutcomes((current) => ({ diff --git a/src/app/components/ScheduledTasksPanel.tsx b/src/app/components/ScheduledTasksPanel.tsx index 307c520..e05be99 100644 --- a/src/app/components/ScheduledTasksPanel.tsx +++ b/src/app/components/ScheduledTasksPanel.tsx @@ -22,6 +22,7 @@ import { type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; +import { errorToast } from "@/lib/errorReporter"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -430,7 +431,8 @@ function TaskForm({ onSaved(); } } catch (err) { - toast.error( + errorToast( + "tasks.save", err instanceof Error ? err.message : isEditing @@ -557,7 +559,8 @@ function TaskDetail({ task, onBack, onEdit, onDeleted }: TaskDetailProps) { await runScheduledTaskNow(task.prompt); toast.success(`"${task.name}" started.`); } catch (err) { - toast.error( + errorToast( + "tasks.run", err instanceof Error ? err.message : "Failed to start the task." ); } finally { @@ -573,7 +576,8 @@ function TaskDetail({ task, onBack, onEdit, onDeleted }: TaskDetailProps) { setDeleteOpen(false); onDeleted(); } catch (err) { - toast.error( + errorToast( + "tasks.delete", err instanceof Error ? err.message : "Failed to delete the task." ); } finally { diff --git a/src/app/components/ThreadList.tsx b/src/app/components/ThreadList.tsx index 6915183..f8df2b2 100644 --- a/src/app/components/ThreadList.tsx +++ b/src/app/components/ThreadList.tsx @@ -53,7 +53,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { toast } from "sonner"; +import { errorToast } from "@/lib/errorReporter"; type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error"; @@ -351,7 +351,7 @@ export function ThreadList({ setRenameTarget(null); mutateFn(); } catch { - toast.error("Couldn't rename — try again."); + errorToast("threads.rename", "Couldn't rename — try again."); } finally { actionBusyRef.current = false; setActionBusy(false); @@ -379,7 +379,7 @@ export function ThreadList({ setDeleteTarget(null); mutateFn(); } catch { - toast.error("Couldn't delete — try again."); + errorToast("threads.delete", "Couldn't delete — try again."); } finally { actionBusyRef.current = false; setActionBusy(false); @@ -398,7 +398,8 @@ export function ThreadList({ await pinThread(thread.id, !thread.pinned); mutateFn(); } catch { - toast.error( + errorToast( + "threads.pin", thread.pinned ? "Couldn't unpin — try again." : "Couldn't pin — try again." @@ -424,7 +425,7 @@ export function ThreadList({ try { await exportThread(thread.id, thread.title); } catch { - toast.error("Couldn't export — try again."); + errorToast("threads.export", "Couldn't export — try again."); } finally { exportBusyIdsRef.current.delete(thread.id); setExportBusyIds((current) => { diff --git a/src/app/components/UserManagementSection.tsx b/src/app/components/UserManagementSection.tsx index bfcfd5e..d00a79c 100644 --- a/src/app/components/UserManagementSection.tsx +++ b/src/app/components/UserManagementSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; +import { errorToast } from "@/lib/errorReporter"; interface ManagedUser { username: string; @@ -60,7 +61,10 @@ export function UserManagementSection({ self }: { self: string }) { toast.success(success); await refresh(); } catch (reason) { - toast.error(reason instanceof Error ? reason.message : "Request failed."); + errorToast( + "users.manage", + reason instanceof Error ? reason.message : "Request failed." + ); } finally { setBusy(false); } diff --git a/src/app/hooks/useChat.ts b/src/app/hooks/useChat.ts index afd071d..a7f4e04 100644 --- a/src/app/hooks/useChat.ts +++ b/src/app/hooks/useChat.ts @@ -7,7 +7,7 @@ import type { TodoItem } from "@/app/types/types"; import { useQueryState } from "nuqs"; import { parseSummarizationEvent } from "@/lib/summarization"; import { findActiveTurnId } from "@/lib/usageTurn"; -import { toast } from "sonner"; +import { errorToast } from "@/lib/errorReporter"; import type { ModelRef, ThreadModelSelection } from "@/lib/modelRegistry"; import { setThreadModelSelection, setThreadRunning } from "@/app/hooks/useThreads"; import { @@ -301,7 +301,7 @@ export function useChat({ now - lastErrorToastRef.current.at > 10_000 ) { lastErrorToastRef.current = { key: `${key}:${message}`, at: now }; - toast.error(message); + errorToast("chat", message); } }, []); @@ -427,7 +427,8 @@ export function useChat({ activeRunRef.current = null; setRunConnection(null); void setThreadId(null); - toast.error( + errorToast( + "chat.conversation", "Conversation is no longer available. Started a new chat." ); } diff --git a/src/lib/errorReporter.test.ts b/src/lib/errorReporter.test.ts new file mode 100644 index 0000000..63a49ef --- /dev/null +++ b/src/lib/errorReporter.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const toastMock = vi.hoisted(() => ({ error: vi.fn() })); +vi.mock("sonner", () => ({ toast: toastMock })); + +import { errorToast, reportError, subscribeErrorLogged } from "./errorReporter"; + +describe("errorReporter", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("posts the entry and notifies listeners on success", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + const listener = vi.fn(); + const unsubscribe = subscribeErrorLogged(listener); + + reportError("chat.send", "boom", "stack"); + await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(1)); + + expect(fetchMock).toHaveBeenCalledWith("/api/error-logs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: "chat.send", message: "boom", details: "stack" }), + }); + unsubscribe(); + }); + + it("swallows network failures without notifying", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + const listener = vi.fn(); + const unsubscribe = subscribeErrorLogged(listener); + + reportError("chat.send", "boom"); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it("errorToast shows the toast and reports", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + errorToast("threads.rename", "Couldn't rename — try again."); + expect(toastMock.error).toHaveBeenCalledWith("Couldn't rename — try again."); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + }); +}); diff --git a/src/lib/errorReporter.ts b/src/lib/errorReporter.ts new file mode 100644 index 0000000..c1ac186 --- /dev/null +++ b/src/lib/errorReporter.ts @@ -0,0 +1,46 @@ +import { toast } from "sonner"; + +/** Fire-and-forget report of a user-visible error to the per-user error log. + * Listeners (the notification bell) are only notified once the entry is + * durably stored, so the badge never counts entries the user cannot see. */ + +const listeners = new Set<() => void>(); + +export function subscribeErrorLogged(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function reportError( + source: string, + message: string, + details?: string +): void { + void fetch("/api/error-logs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source, + message, + ...(details ? { details } : {}), + }), + }) + .then((response) => { + if (!response.ok) return; + for (const listener of listeners) listener(); + }) + .catch(() => {}); +} + +/** toast.error + error-log report. Use for operational failures; pure form + * validation hints should keep using toast.error directly. */ +export function errorToast( + source: string, + message: string, + details?: string +): void { + toast.error(message); + reportError(source, message, details); +}