feat(webui): report user-visible errors to the per-user error log

Add errorReporter (errorToast = toast.error + fire-and-forget POST to
/api/error-logs) and wire it into the operational error toasts across
chat, threads, tasks, agents, models, files and clipboard. Pure form
validation hints stay on toast.error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-31 10:46:07 +08:00
parent a4e47e1b5d
commit dd4b63a0ee
13 changed files with 197 additions and 35 deletions
+7 -2
View File
@@ -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."
+11 -5
View File
@@ -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<ChatInterfaceProps>(
} 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<ChatInterfaceProps>(
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<ChatInterfaceProps>(
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<ChatInterfaceProps>(
: `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<ChatInterfaceProps>(
: `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."
+2 -2
View File
@@ -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<ChatMessageProps>(
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<
+2 -2
View File
@@ -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<CodeBlockProps>(({ 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]);
+3 -3
View File
@@ -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}`),
}
);
+39 -1
View File
@@ -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<TestOutcome | null>(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 (
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
@@ -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 {
+16 -8
View File
@@ -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) => ({
+7 -3
View File
@@ -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 {
+6 -5
View File
@@ -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) => {
+5 -1
View File
@@ -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);
}
+4 -3
View File
@@ -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."
);
}
+49
View File
@@ -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());
});
});
+46
View File
@@ -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);
}