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:
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user