From 5883b97e275ba60ba05f21bc27be95c9422e17f2 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 7 Jul 2026 22:20:16 +0100 Subject: [PATCH 1/7] feat(notifications): inspectable error toasts with details dialog Error-level notifications no longer dump raw JSON into the toast. The toast shows a one-line summary with a "Details" action that opens a quill dialog: pretty-printed error payload, a downloadable error-plus-logs bundle (fed by a new renderer log ring buffer teed off the shell logger), and a dev-only shortcut that prefills a new task with the error and recent logs as context. Generated-By: PostHog Code Task-Id: 8fd812ea-6ddb-4811-bb8a-cf00dbad7ea1 --- .../notifications/ErrorDetailsDialog.tsx | 102 ++++++++++++++++++ .../notifications/errorDetails.test.ts | 55 ++++++++++ .../features/notifications/errorDetails.ts | 84 +++++++++++++++ .../notifications/notifications.test.ts | 49 +++++++++ .../features/notifications/notifications.ts | 40 ++++++- packages/ui/src/shell/App.tsx | 2 + packages/ui/src/shell/logCapture.ts | 80 ++++++++++++++ packages/ui/src/shell/logger.ts | 42 ++++---- 8 files changed, 432 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/features/notifications/ErrorDetailsDialog.tsx create mode 100644 packages/ui/src/features/notifications/errorDetails.test.ts create mode 100644 packages/ui/src/features/notifications/errorDetails.ts create mode 100644 packages/ui/src/shell/logCapture.ts diff --git a/packages/ui/src/features/notifications/ErrorDetailsDialog.tsx b/packages/ui/src/features/notifications/ErrorDetailsDialog.tsx new file mode 100644 index 0000000000..3ebecfcaad --- /dev/null +++ b/packages/ui/src/features/notifications/ErrorDetailsDialog.tsx @@ -0,0 +1,102 @@ +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@posthog/quill"; +import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { formatCapturedLogs } from "@posthog/ui/shell/logCapture"; +import { serializeError, useErrorDetailsStore } from "./errorDetails"; + +// Keep the create-task prompt readable: the full 500-entry buffer belongs in +// the downloaded bundle, not in a composer draft. +const TASK_PROMPT_LOG_ENTRIES = 100; + +function buildBundle( + title: string, + occurredAt: number, + prettyError: string, +): string { + return [ + `# ${title}`, + `Occurred at: ${new Date(occurredAt).toISOString()}`, + "", + "## Error", + prettyError, + "", + "## Recent logs", + formatCapturedLogs(), + "", + ].join("\n"); +} + +// Global inspector behind every error toast's "Details" action: the full +// pretty-printed payload the toast had no room for, a downloadable +// error-plus-logs bundle, and (dev builds only) a one-click task prefilled +// with the same context. +export function ErrorDetailsDialog() { + const detail = useErrorDetailsStore((s) => s.detail); + const close = useErrorDetailsStore((s) => s.close); + if (!detail) return null; + + const prettyError = serializeError(detail.error); + + const handleDownload = () => { + const bundle = buildBundle(detail.title, detail.occurredAt, prettyError); + const blob = new Blob([bundle], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `posthog-code-error-${detail.occurredAt}.md`; + anchor.click(); + URL.revokeObjectURL(url); + }; + + const handleCreateTask = () => { + close(); + openTaskInput({ + initialPrompt: [ + `Investigate this error from the PostHog Code app: ${detail.title}`, + "", + "## Error", + "```json", + prettyError, + "```", + "", + "## Recent logs", + "```", + formatCapturedLogs({ maxEntries: TASK_PROMPT_LOG_ENTRIES }), + "```", + ].join("\n"), + }); + }; + + return ( + !open && close()}> + + + {detail.title} + +
+ {prettyError} +
+ + {import.meta.env.DEV && ( + + )} + + + +
+
+ ); +} diff --git a/packages/ui/src/features/notifications/errorDetails.test.ts b/packages/ui/src/features/notifications/errorDetails.test.ts new file mode 100644 index 0000000000..3d7acbe35a --- /dev/null +++ b/packages/ui/src/features/notifications/errorDetails.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { serializeError, summarizeError } from "./errorDetails"; + +describe("serializeError", () => { + it("pretty-prints plain objects", () => { + expect(serializeError({ code: 500, message: "boom" })).toBe( + JSON.stringify({ code: 500, message: "boom" }, null, 2), + ); + }); + + it("expands Error instances with message, stack, and enumerable extras", () => { + const err = Object.assign(new Error("kaput"), { code: "ECONNRESET" }); + const parsed = JSON.parse(serializeError(err)); + expect(parsed.name).toBe("Error"); + expect(parsed.message).toBe("kaput"); + expect(parsed.code).toBe("ECONNRESET"); + expect(typeof parsed.stack).toBe("string"); + }); + + it("elides circular references instead of throwing", () => { + const obj: Record = { a: 1 }; + obj.self = obj; + const parsed = JSON.parse(serializeError(obj)); + expect(parsed.self).toBe("[circular]"); + }); + + it("falls back to String() for values JSON cannot represent", () => { + expect(serializeError(undefined)).toBe("undefined"); + }); +}); + +describe("summarizeError", () => { + it.each([ + ["a string error", "it broke", "it broke"], + ["an Error's message", new Error("nope"), "nope"], + ["a message-bearing object", { message: "denied", code: 403 }, "denied"], + ])("uses %s", (_label, input, expected) => { + expect(summarizeError(input)).toBe(expected); + }); + + it("flattens whitespace and truncates long messages with an ellipsis", () => { + const summary = summarizeError(`x y\n${"z".repeat(300)}`); + expect(summary.startsWith("x y z")).toBe(true); + expect(summary.length).toBe(141); + expect(summary.endsWith("…")).toBe(true); + }); + + it("stringifies messageless payloads", () => { + expect(summarizeError({ status: 502 })).toBe('{ "status": 502 }'); + }); + + it("never returns an empty summary", () => { + expect(summarizeError(" ")).toBe("Unknown error"); + }); +}); diff --git a/packages/ui/src/features/notifications/errorDetails.ts b/packages/ui/src/features/notifications/errorDetails.ts new file mode 100644 index 0000000000..0d206269f9 --- /dev/null +++ b/packages/ui/src/features/notifications/errorDetails.ts @@ -0,0 +1,84 @@ +import { create } from "zustand"; + +// The error behind an error-level toast, captured so the details dialog can +// show the full payload the toast had no room for. +export interface ErrorDetail { + title: string; + error: unknown; + occurredAt: number; +} + +// Pretty-printed JSON of an arbitrary error payload that never throws: +// Error instances become plain objects (keeping message, stack, and any +// enumerable extras like `code`), circular references are elided, and +// non-JSON values fall back to String(). +export function serializeError(error: unknown): string { + const seen = new WeakSet(); + try { + const json = JSON.stringify( + error, + (_key, value: unknown) => { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + ...Object.fromEntries(Object.entries(value)), + }; + } + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[circular]"; + seen.add(value); + } + if (typeof value === "bigint" || typeof value === "function") { + return String(value); + } + return value; + }, + 2, + ); + return json ?? String(error); + } catch { + return String(error); + } +} + +const SUMMARY_LIMIT = 140; + +// One-line summary of an error payload, sized for a toast description. The +// full payload stays behind the toast's "Details" action. +export function summarizeError(error: unknown): string { + let message: string; + if (typeof error === "string") { + message = error; + } else if (error instanceof Error) { + message = error.message; + } else if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message: unknown }).message === "string" + ) { + message = (error as { message: string }).message; + } else { + message = serializeError(error); + } + const flat = message.replace(/\s+/g, " ").trim(); + if (flat.length === 0) return "Unknown error"; + return flat.length <= SUMMARY_LIMIT + ? flat + : `${flat.slice(0, SUMMARY_LIMIT)}…`; +} + +interface ErrorDetailsState { + detail: ErrorDetail | null; + show: (detail: ErrorDetail) => void; + close: () => void; +} + +// View state for the global error details dialog (rendered once in App). +export const useErrorDetailsStore = create((set) => ({ + detail: null, + show: (detail) => set({ detail }), + close: () => set({ detail: null }), +})); diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index 4e99b64eb2..603b0bcff3 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -17,6 +17,7 @@ const toastMock = vi.hoisted(() => ({ vi.mock("@posthog/ui/primitives/toast", () => ({ toast: toastMock })); import { playCompletionSound } from "@posthog/ui/utils/sounds"; +import { useErrorDetailsStore } from "./errorDetails"; import type { IActiveView, INotificationSettings, @@ -175,6 +176,54 @@ describe("native tier settings gating (app unfocused)", () => { }); }); +describe("notifyError", () => { + const payload = { status: 500, message: "upstream exploded", body: { x: 1 } }; + + it("toasts a one-line summary, never the raw payload", () => { + const { bus } = makeBus({ hasFocus: true }); + bus.notifyError("Sync failed", payload); + expect(toastMock.error).toHaveBeenCalledWith( + "Sync failed", + expect.objectContaining({ description: "upstream exploded" }), + ); + }); + + it("attaches a Details action that opens the error details dialog", () => { + useErrorDetailsStore.getState().close(); + const { bus } = makeBus({ hasFocus: true }); + bus.notifyError("Sync failed", payload); + const options = toastMock.error.mock.calls[0]?.[1] as { + action?: { label: string; onClick: () => void }; + }; + expect(options.action?.label).toBe("Details"); + options.action?.onClick(); + const detail = useErrorDetailsStore.getState().detail; + expect(detail?.title).toBe("Sync failed"); + expect(detail?.error).toBe(payload); + useErrorDetailsStore.getState().close(); + }); + + it("the Details action wins over target navigation on error toasts", () => { + const { bus } = makeBus({ hasFocus: true }); + bus.notifyError("Sync failed", payload, taskTarget(TASK_ID)); + const options = toastMock.error.mock.calls[0]?.[1] as { + action?: { label: string }; + }; + expect(options.action?.label).toBe("Details"); + }); + + it("app unfocused → native notification with the summary as body", () => { + const { bus, notify } = makeBus({ hasFocus: false }); + bus.notifyError("Sync failed", payload); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Sync failed", + body: "upstream exploded", + }), + ); + }); +}); + describe("sound", () => { it("plays on the toast tier too (not just native)", () => { const { bus, play } = makeBus({ diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index 03393830f9..1ba3fd2367 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -11,6 +11,7 @@ import { resolveSoundUrl, } from "@posthog/ui/utils/sounds"; import { inject, injectable } from "inversify"; +import { summarizeError, useErrorDetailsStore } from "./errorDetails"; import { ACTIVE_VIEW_PROVIDER, type IActiveView, @@ -41,6 +42,11 @@ export interface NotificationDescriptor { // How long the task took, in ms. When the user enables sound scaling, this // drives the completion sound's playback rate (fast task -> faster/higher). soundDurationMs?: number; + // Raw error payload behind an error-level notification. Never rendered into + // the toast itself (it doesn't fit); the toast instead gets a "Details" + // action that opens the error details dialog — pretty-printed payload, + // downloadable error+logs bundle, and a dev-only create-task shortcut. + error?: unknown; } // The single channel every app notification flows through. Reads focus + the @@ -132,18 +138,48 @@ export class NotificationBus { }); } + // Error entry point: the toast carries a one-line summary; the raw payload + // rides along on `error` and stays inspectable behind the Details action. + notifyError( + title: string, + error: unknown, + target?: NotificationTarget, + ): void { + this.notify({ + title, + body: summarizeError(error), + target, + toast: { level: "error", description: summarizeError(error) }, + error, + }); + } + private showToast(descriptor: NotificationDescriptor): void { const level = descriptor.toast?.level ?? "success"; toast[level](descriptor.title ?? descriptor.body, { description: descriptor.toast?.description, duration: descriptor.toast?.duration, - action: this.deriveAction(descriptor.target), + action: this.deriveAction(descriptor), }); } private deriveAction( - target: NotificationTarget | undefined, + descriptor: NotificationDescriptor, ): { label: string; onClick: () => void } | undefined { + // Inspecting the payload beats navigation on error toasts: the error is + // the thing the user needs, and it never fits in the toast. + if (descriptor.error !== undefined) { + const detail = { + title: descriptor.title ?? descriptor.body, + error: descriptor.error, + occurredAt: Date.now(), + }; + return { + label: "Details", + onClick: () => useErrorDetailsStore.getState().show(detail), + }; + } + const target = descriptor.target; if (!target) return undefined; // Route through the shared open-target handler so the toast click lands on // the same place a native notification click would — channel-aware for diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index da3e9c62d8..0bca213d76 100644 --- a/packages/ui/src/shell/App.tsx +++ b/packages/ui/src/shell/App.tsx @@ -14,6 +14,7 @@ import { useAuthSession } from "@posthog/ui/features/auth/useAuthSession"; import { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; import { CanvasGenerationToaster } from "@posthog/ui/features/canvas/freeform/useCanvasGenerationToasts"; import { AddDirectoryDialog } from "@posthog/ui/features/folder-picker/AddDirectoryDialog"; +import { ErrorDetailsDialog } from "@posthog/ui/features/notifications/ErrorDetailsDialog"; import { OnboardingFlow } from "@posthog/ui/features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; import { SettingsDialog } from "@posthog/ui/features/settings/SettingsDialog"; @@ -222,6 +223,7 @@ function App({ devToolbar }: AppProps) { /> + {devToolbar} diff --git a/packages/ui/src/shell/logCapture.ts b/packages/ui/src/shell/logCapture.ts new file mode 100644 index 0000000000..63db32f4d7 --- /dev/null +++ b/packages/ui/src/shell/logCapture.ts @@ -0,0 +1,80 @@ +// In-memory ring buffer of recent renderer log lines, teed off the shell +// logger. Exists so error surfaces (the error details dialog) can bundle the +// logs that led up to a failure without any host round trip — the host's own +// transports (file, console) are unaffected. + +export interface CapturedLogEntry { + at: number; + level: "debug" | "info" | "warn" | "error"; + scope: string | null; + message: string; +} + +const MAX_ENTRIES = 500; + +const entries: CapturedLogEntry[] = []; + +// Single-line, never-throwing rendering of one log argument. Errors keep +// their stack (the whole point of capturing), objects are JSON with circular +// references elided. +function formatArg(arg: unknown): string { + if (typeof arg === "string") return arg; + if (arg instanceof Error) { + return arg.stack ?? `${arg.name}: ${arg.message}`; + } + if (typeof arg === "object" && arg !== null) { + const seen = new WeakSet(); + try { + return ( + JSON.stringify(arg, (_key, value: unknown) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[circular]"; + seen.add(value); + } + if (typeof value === "bigint" || typeof value === "function") { + return String(value); + } + return value; + }) ?? String(arg) + ); + } catch { + return String(arg); + } + } + return String(arg); +} + +export function recordLog( + level: CapturedLogEntry["level"], + scope: string | null, + args: unknown[], +): void { + entries.push({ + at: Date.now(), + level, + scope, + message: args.map(formatArg).join(" "), + }); + if (entries.length > MAX_ENTRIES) { + entries.splice(0, entries.length - MAX_ENTRIES); + } +} + +// Plain-text dump of the most recent captured lines, newest last. +export function formatCapturedLogs(options?: { maxEntries?: number }): string { + const max = options?.maxEntries ?? entries.length; + const slice = entries.slice(-max); + if (slice.length === 0) return "(no logs captured this session)"; + return slice + .map( + (entry) => + `${new Date(entry.at).toISOString()} [${entry.level}]${ + entry.scope ? ` [${entry.scope}]` : "" + } ${entry.message}`, + ) + .join("\n"); +} + +export function clearCapturedLogs(): void { + entries.length = 0; +} diff --git a/packages/ui/src/shell/logger.ts b/packages/ui/src/shell/logger.ts index faed6c6410..b7bf5b7801 100644 --- a/packages/ui/src/shell/logger.ts +++ b/packages/ui/src/shell/logger.ts @@ -1,4 +1,5 @@ import { resolveService } from "@posthog/di/container"; +import { recordLog } from "./logCapture"; export interface ScopedLogger { info(...args: unknown[]): void; @@ -21,31 +22,32 @@ function impl(): HostLogger | null { } } +// Every log line is also teed into the in-memory capture buffer +// (shell/logCapture) so error surfaces can bundle recent logs — even on hosts +// that bind no HOST_LOGGER at all. +type Level = "debug" | "info" | "warn" | "error"; + +function emit(level: Level, scope: string | null, args: unknown[]): void { + recordLog(level, scope, args); + const target = impl(); + if (!target) return; + const sink = scope ? target.scope(scope) : target; + sink[level](...args); +} + function deferredScope(name: string): ScopedLogger { return { - info: (...args) => - impl() - ?.scope(name) - .info(...args), - warn: (...args) => - impl() - ?.scope(name) - .warn(...args), - error: (...args) => - impl() - ?.scope(name) - .error(...args), - debug: (...args) => - impl() - ?.scope(name) - .debug(...args), + info: (...args) => emit("info", name, args), + warn: (...args) => emit("warn", name, args), + error: (...args) => emit("error", name, args), + debug: (...args) => emit("debug", name, args), }; } export const logger: HostLogger = { scope: (name) => deferredScope(name), - info: (...args) => impl()?.info(...args), - warn: (...args) => impl()?.warn(...args), - error: (...args) => impl()?.error(...args), - debug: (...args) => impl()?.debug(...args), + info: (...args) => emit("info", null, args), + warn: (...args) => emit("warn", null, args), + error: (...args) => emit("error", null, args), + debug: (...args) => emit("debug", null, args), }; From 924e893d04eb076b3256a9bbc29137d0a80b4208 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 7 Jul 2026 22:51:50 +0100 Subject: [PATCH 2/7] fix(notifications): route raw-error toasts through inspectable toastError The click-to-inspect Details action only reached errors sent through NotificationBus, but the toasts that actually dump raw JSON are direct toast.error calls in hooks. Add a lightweight toastError helper (summary in the toast, full pretty-printed payload behind a Details action) and migrate every raw-error dump to it. serializeError now reflows the JSON embedded in API error strings so the dialog is readable. Generated-By: PostHog Code Task-Id: 8fd812ea-6ddb-4811-bb8a-cf00dbad7ea1 --- .../canvas/hooks/useGenerateContext.ts | 6 +- .../canvas/hooks/useGenerateFreeformCanvas.ts | 5 +- .../external-apps/useExternalAppAction.ts | 9 +-- .../focus/focus-events.contribution.ts | 6 +- .../home/hooks/useRunWorkstreamAction.ts | 7 +-- .../components/ConfigureAgentsSection.tsx | 9 +-- .../inbox/hooks/useInboxCloudTaskRunner.ts | 7 +-- .../notifications/errorDetails.test.ts | 56 +++++++++++++++++- .../features/notifications/errorDetails.ts | 58 +++++++++++++++++++ .../features/notifications/notifications.ts | 11 ++-- .../components/ContinueCliSessions.tsx | 6 +- .../components/WorkspaceSetupPrompt.tsx | 6 +- .../task-detail/hooks/useTaskCreation.ts | 14 ++--- .../features/workspace/useFocusWorkspace.tsx | 12 ++-- 14 files changed, 151 insertions(+), 61 deletions(-) diff --git a/packages/ui/src/features/canvas/hooks/useGenerateContext.ts b/packages/ui/src/features/canvas/hooks/useGenerateContext.ts index 99d3af4562..b868629eb3 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateContext.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateContext.ts @@ -8,8 +8,8 @@ import type { WorkspaceMode } from "@posthog/shared"; import { buildContextGenerationPrompt } from "@posthog/ui/features/canvas/contextPrompt"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderGenerationTaskMutation } from "@posthog/ui/features/canvas/hooks/useFolderGenerationTask"; +import { toastError } from "@posthog/ui/features/notifications/errorDetails"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; -import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; @@ -47,9 +47,7 @@ export function useGenerateContext(channelId: string, channelName: string) { ); if (!result.success) { - toast.error("Couldn't start CONTEXT.md generation", { - description: result.error, - }); + toastError("Couldn't start CONTEXT.md generation", result.error); return null; } diff --git a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts index 5638e6846f..76d84eaedf 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts @@ -20,6 +20,7 @@ import { } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; +import { toastError } from "@posthog/ui/features/notifications/errorDetails"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; @@ -123,9 +124,7 @@ export function useGenerateFreeformCanvas(args: { ); if (!result.success) { - toast.error("Couldn't start canvas generation", { - description: result.error, - }); + toastError("Couldn't start canvas generation", result.error); return null; } diff --git a/packages/ui/src/features/external-apps/useExternalAppAction.ts b/packages/ui/src/features/external-apps/useExternalAppAction.ts index 6cb6280ca0..b7f6663eda 100644 --- a/packages/ui/src/features/external-apps/useExternalAppAction.ts +++ b/packages/ui/src/features/external-apps/useExternalAppAction.ts @@ -8,6 +8,7 @@ import { useService } from "@posthog/di/react"; import { useCallback } from "react"; import { toast } from "../../primitives/toast"; import { showFocusSuccessToast } from "../focus/focusToast"; +import { toastError } from "../notifications/errorDetails"; export function useExternalAppAction() { const service = useService(EXTERNAL_APPS_SERVICE); @@ -39,14 +40,10 @@ export function useExternalAppAction() { }); return; case "open-failed": - toast.error("Failed to open in external app", { - description: outcome.error, - }); + toastError("Failed to open in external app", outcome.error); return; case "focus-failed": - toast.error("Could not edit workspace", { - description: outcome.error, - }); + toastError("Could not edit workspace", outcome.error); return; case "copied": toast.success("Path copied to clipboard", { diff --git a/packages/ui/src/features/focus/focus-events.contribution.ts b/packages/ui/src/features/focus/focus-events.contribution.ts index 18e571042e..26888c480e 100644 --- a/packages/ui/src/features/focus/focus-events.contribution.ts +++ b/packages/ui/src/features/focus/focus-events.contribution.ts @@ -4,12 +4,12 @@ import { type HostTrpcClient, } from "@posthog/host-router/client"; import { inject, injectable } from "inversify"; -import { toast } from "../../primitives/toast"; import { logger } from "../../shell/logger"; import { IMPERATIVE_QUERY_CLIENT, type ImperativeQueryClient, } from "../../shell/queryClient"; +import { toastError } from "../notifications/errorDetails"; import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers"; import { useFocusStore } from "./focusStore"; @@ -47,9 +47,7 @@ export class FocusEventsContribution implements Contribution { ); const result = await useFocusStore.getState().disableFocus(); if (!result.success && result.error) { - toast.error("Could not unfocus workspace", { - description: result.error, - }); + toastError("Could not unfocus workspace", result.error); } }, }); diff --git a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts index 5cd825bbc0..2a2e71465a 100644 --- a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts +++ b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts @@ -17,6 +17,7 @@ import { homeKeys } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; import { useQuickActionStore } from "@posthog/ui/features/home/stores/quickActionStore"; import { insertOptimisticTask } from "@posthog/ui/features/home/utils/optimisticTask"; import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { toastError } from "@posthog/ui/features/notifications/errorDetails"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; @@ -170,16 +171,14 @@ export function useRunWorkstreamAction(): RunWorkstreamAction { }); return; } - toast.error("Failed to start task", { description: result.error }); + toastError("Failed to start task", result.error); log.error("Quick action task creation failed", { failedStep: result.failedStep, error: result.error, }); fallbackToTaskInput(); } catch (error) { - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Failed to start task", { description }); + toastError("Failed to start task", error); log.error("Quick action task creation threw", { error }); fallbackToTaskInput(); } finally { diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 402502ef97..e63809c47c 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -32,6 +32,7 @@ import { useRepositoryIntegration, useUserRepositoryIntegration, } from "@posthog/ui/features/integrations/useIntegrations"; +import { toastError } from "@posthog/ui/features/notifications/errorDetails"; import { ScoutsFleetSection } from "@posthog/ui/features/scouts/components/ScoutsFleetSection"; import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; import { SlackInboxNotificationsSettings } from "@posthog/ui/features/settings/sections/SlackInboxNotificationsSettings"; @@ -365,9 +366,7 @@ function SetupTaskSection() { adapter, }); } else { - toast.error("Failed to start Self-driving setup", { - description: result.error, - }); + toastError("Failed to start Self-driving setup", result.error); log.error("Self-driving setup task creation failed", { failedStep: result.failedStep, error: result.error, @@ -380,9 +379,7 @@ function SetupTaskSection() { action_type: "run_setup_agent", success: false, }); - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Failed to start Self-driving setup", { description }); + toastError("Failed to start Self-driving setup", error); log.error("Unexpected error during Self-driving setup task creation", { error, repository: setupRepository, diff --git a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts index 77b06dbb67..5578055962 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts @@ -19,6 +19,7 @@ import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast"; import { resolveDefaultModel } from "@posthog/ui/features/inbox/hooks/resolveDefaultModel"; import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { toastError } from "@posthog/ui/features/notifications/errorDetails"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; @@ -262,7 +263,7 @@ export function useInboxCloudTaskRunner({ toast.dismiss(toastId); // Usage-limit blocks already show the upgrade modal; don't double-toast. if (!isUsageLimitResult(result)) { - toast.error(copy.errorTitle, { description: result.error }); + toastError(copy.errorTitle, result.error); log.error("Cloud-task creation failed", { failedStep: result.failedStep, error: result.error, @@ -273,9 +274,7 @@ export function useInboxCloudTaskRunner({ } } catch (error) { toast.dismiss(toastId); - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error(copy.errorTitle, { description }); + toastError(copy.errorTitle, error); log.error("Unexpected error during cloud-task creation", { error, reportId, diff --git a/packages/ui/src/features/notifications/errorDetails.test.ts b/packages/ui/src/features/notifications/errorDetails.test.ts index 3d7acbe35a..5685bef755 100644 --- a/packages/ui/src/features/notifications/errorDetails.test.ts +++ b/packages/ui/src/features/notifications/errorDetails.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from "vitest"; -import { serializeError, summarizeError } from "./errorDetails"; +import { describe, expect, it, vi } from "vitest"; + +const toastMock = vi.hoisted(() => ({ error: vi.fn() })); +vi.mock("@posthog/ui/primitives/toast", () => ({ toast: toastMock })); + +import { + serializeError, + summarizeError, + toastError, + useErrorDetailsStore, +} from "./errorDetails"; describe("serializeError", () => { it("pretty-prints plain objects", () => { @@ -8,6 +17,22 @@ describe("serializeError", () => { ); }); + it("reflows the JSON payload embedded in an API error string", () => { + const message = + 'Failed request: [400] {"type":"validation_error","attr":"model"}'; + expect(serializeError(message)).toBe( + `Failed request: [400]\n${JSON.stringify( + { type: "validation_error", attr: "model" }, + null, + 2, + )}`, + ); + }); + + it("returns a plain string unchanged when there's no JSON to reflow", () => { + expect(serializeError("Not authenticated")).toBe("Not authenticated"); + }); + it("expands Error instances with message, stack, and enumerable extras", () => { const err = Object.assign(new Error("kaput"), { code: "ECONNRESET" }); const parsed = JSON.parse(serializeError(err)); @@ -53,3 +78,30 @@ describe("summarizeError", () => { expect(summarizeError(" ")).toBe("Unknown error"); }); }); + +describe("toastError", () => { + const rawError = + 'Failed request: [400] {"detail":"This field is required.","attr":"model"}'; + + it("shows a summary in the toast, not the raw payload, with a Details action", () => { + toastMock.error.mockClear(); + useErrorDetailsStore.getState().close(); + + toastError("Couldn't start generation", rawError); + + const [title, options] = toastMock.error.mock.calls[0] as [ + string, + { description: string; action: { label: string; onClick: () => void } }, + ]; + expect(title).toBe("Couldn't start generation"); + expect(options.description).toBe(summarizeError(rawError)); + expect(options.description.length).toBeLessThanOrEqual(141); + + expect(options.action.label).toBe("Details"); + options.action.onClick(); + const detail = useErrorDetailsStore.getState().detail; + expect(detail?.title).toBe("Couldn't start generation"); + expect(detail?.error).toBe(rawError); + useErrorDetailsStore.getState().close(); + }); +}); diff --git a/packages/ui/src/features/notifications/errorDetails.ts b/packages/ui/src/features/notifications/errorDetails.ts index 0d206269f9..c966aba671 100644 --- a/packages/ui/src/features/notifications/errorDetails.ts +++ b/packages/ui/src/features/notifications/errorDetails.ts @@ -1,3 +1,4 @@ +import { toast } from "@posthog/ui/primitives/toast"; import { create } from "zustand"; // The error behind an error-level toast, captured so the details dialog can @@ -8,11 +9,34 @@ export interface ErrorDetail { occurredAt: number; } +// API error messages routinely arrive as a string with a JSON payload embedded +// in them (e.g. `Failed request: [400] {"detail":"..."}`). Pull that payload +// out and pretty-print it so the details dialog is readable rather than one +// unwrapped line. Returns the string untouched when there's no parseable JSON. +function prettifyErrorString(message: string): string { + const start = message.indexOf("{"); + const end = message.lastIndexOf("}"); + if (start !== -1 && end > start) { + const candidate = message.slice(start, end + 1); + try { + const parsed: unknown = JSON.parse(candidate); + const prefix = message.slice(0, start).trim(); + const pretty = JSON.stringify(parsed, null, 2); + return prefix ? `${prefix}\n${pretty}` : pretty; + } catch { + // Not JSON after all — fall through to the raw string. + } + } + return message; +} + // Pretty-printed JSON of an arbitrary error payload that never throws: // Error instances become plain objects (keeping message, stack, and any // enumerable extras like `code`), circular references are elided, and // non-JSON values fall back to String(). export function serializeError(error: unknown): string { + // Strings are already human-readable; only reflow an embedded JSON payload. + if (typeof error === "string") return prettifyErrorString(error); const seen = new WeakSet(); try { const json = JSON.stringify( @@ -82,3 +106,37 @@ export const useErrorDetailsStore = create((set) => ({ show: (detail) => set({ detail }), close: () => set({ detail: null }), })); + +// Open the error details dialog for a given error. Shared by the notification +// bus's error toasts and the standalone `toastError` helper so both land on the +// same inspectable dialog. +export function showErrorDetails(title: string, error: unknown): void { + useErrorDetailsStore.getState().show({ + title, + error, + occurredAt: Date.now(), + }); +} + +// Fire an error toast whose payload stays inspectable: a one-line summary in +// the toast body plus a "Details" action that opens the full pretty-printed +// error (and its logs) in the dialog. Use this instead of a bare +// `toast.error(title, { description: someRawError })` — that overflows the +// toast and can't be opened. This is the lightweight, synchronous path for +// errors raised by a user action; task-lifecycle notifications that need +// focus-aware routing and sound still go through `NotificationBus.notifyError`. +export function toastError( + title: string, + error: unknown, + options?: { id?: string; duration?: number }, +): void { + toast.error(title, { + id: options?.id, + duration: options?.duration, + description: summarizeError(error), + action: { + label: "Details", + onClick: () => showErrorDetails(title, error), + }, + }); +} diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index 1ba3fd2367..34b6ced792 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -11,7 +11,7 @@ import { resolveSoundUrl, } from "@posthog/ui/utils/sounds"; import { inject, injectable } from "inversify"; -import { summarizeError, useErrorDetailsStore } from "./errorDetails"; +import { showErrorDetails, summarizeError } from "./errorDetails"; import { ACTIVE_VIEW_PROVIDER, type IActiveView, @@ -169,14 +169,11 @@ export class NotificationBus { // Inspecting the payload beats navigation on error toasts: the error is // the thing the user needs, and it never fits in the toast. if (descriptor.error !== undefined) { - const detail = { - title: descriptor.title ?? descriptor.body, - error: descriptor.error, - occurredAt: Date.now(), - }; + const title = descriptor.title ?? descriptor.body; + const error = descriptor.error; return { label: "Details", - onClick: () => useErrorDetailsStore.getState().show(detail), + onClick: () => showErrorDetails(title, error), }; } const target = descriptor.target; diff --git a/packages/ui/src/features/task-detail/components/ContinueCliSessions.tsx b/packages/ui/src/features/task-detail/components/ContinueCliSessions.tsx index 7cedaf2281..c4f4bfc07e 100644 --- a/packages/ui/src/features/task-detail/components/ContinueCliSessions.tsx +++ b/packages/ui/src/features/task-detail/components/ContinueCliSessions.tsx @@ -22,7 +22,7 @@ import { } from "@radix-ui/themes"; import { useEffect, useState } from "react"; import claudeMark from "../../../assets/services/claude.svg"; -import { toast } from "../../../primitives/toast"; +import { toastError } from "../../notifications/errorDetails"; import { useCreateTask } from "../../tasks/useTaskCrudMutations"; import { useClaudeCliSessions } from "../hooks/useClaudeCliSessions"; import { SuggestedTasksPanel } from "./SuggestedTasksPanel"; @@ -349,9 +349,7 @@ export function NewTaskSuggestions({ session_status: session.status, failed_step: result.failedStep, }); - toast.error("Couldn't continue Claude Code session", { - description: result.error, - }); + toastError("Couldn't continue Claude Code session", result.error); } } finally { setRunningId(null); diff --git a/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx index 38052b08fc..de75dd5844 100644 --- a/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx +++ b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx @@ -10,9 +10,9 @@ import { getTaskRepository } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { Box, Button, Code, Flex, Spinner, Text } from "@radix-ui/themes"; import { useCallback, useMemo, useState } from "react"; -import { toast } from "../../../primitives/toast"; import { FolderPicker } from "../../folder-picker/FolderPicker"; import { useFolders } from "../../folders/useFolders"; +import { toastError } from "../../notifications/errorDetails"; import { useEnsureWorkspace } from "../../workspace/useWorkspaceMutations"; interface WorkspaceSetupPromptProps { @@ -50,9 +50,7 @@ export function WorkspaceSetupPrompt({ const result = await setupSaga.setupWorkspace(executor, taskId, path); if (!result.success) { - toast.error("Failed to set up workspace", { - description: result.error, - }); + toastError("Failed to set up workspace", result.error); } setSelectedPath(""); diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 367f5aa8f9..0943ab26ad 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -40,6 +40,7 @@ import { import { useDraftStore } from "../../message-editor/draftStore"; import { useTaskInputHistoryStore } from "../../message-editor/taskInputHistoryStore"; import type { EditorHandle } from "../../message-editor/types"; +import { toastError } from "../../notifications/errorDetails"; import { useProvisioningStore } from "../../provisioning/store"; import { useSettingsStore } from "../../settings/settingsStore"; import { useCreateTask } from "../../tasks/useTaskCrudMutations"; @@ -388,9 +389,10 @@ export function useTaskCreation({ useProvisioningStore .getState() .setFailed(result.data.task.id, result.data.provisioningError); - toast.error(getErrorTitle("workspace_creation"), { - description: result.data.provisioningError, - }); + toastError( + getErrorTitle("workspace_creation"), + result.data.provisioningError, + ); } if (result.success) { @@ -430,7 +432,7 @@ export function useTaskCreation({ log.warn("Cloud task creation blocked by usage limit"); } else { const title = getErrorTitle(result.failedStep); - toast.error(title, { description: result.error }); + toastError(title, result.error); log.error("Task creation failed", { failedStep: result.failedStep, error: result.error, @@ -446,9 +448,7 @@ export function useTaskCreation({ } return result.success; } catch (error) { - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Failed to create task", { description }); + toastError("Failed to create task", error); log.error("Unexpected error during task creation", { error }); if (pendingTaskKey) { pendingTaskPromptStoreApi.clear(pendingTaskKey); diff --git a/packages/ui/src/features/workspace/useFocusWorkspace.tsx b/packages/ui/src/features/workspace/useFocusWorkspace.tsx index a67cd93209..dd0c45dc3c 100644 --- a/packages/ui/src/features/workspace/useFocusWorkspace.tsx +++ b/packages/ui/src/features/workspace/useFocusWorkspace.tsx @@ -11,6 +11,7 @@ import { useFocusStore, } from "../focus/focusStore"; import { showFocusSuccessToast } from "../focus/focusToast"; +import { toastError } from "../notifications/errorDetails"; import { useTerminalStore } from "../terminal/terminalStore"; import { useWorkspace } from "./useWorkspace"; @@ -54,9 +55,10 @@ export function useFocusWorkspace(taskId: string) { (hadStash ? "Your stashed changes were restored." : undefined), }); } else { - toast.error(`Could not return to ${focusSession.originalBranch}`, { - description: result.error, - }); + toastError( + `Could not return to ${focusSession.originalBranch}`, + result.error, + ); } }, [focusSession, disableFocus, getFocusTerminalKey]); @@ -76,9 +78,7 @@ export function useFocusWorkspace(taskId: string) { if (result.success) { showFocusSuccessToast(params.branch, result); } else { - toast.error("Could not edit workspace", { - description: result.error, - }); + toastError("Could not edit workspace", result.error); } }, [workspace, enableFocus]); From 0e80e308d505e2a73acc8b593eb3889dac955236 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 7 Jul 2026 23:52:33 +0100 Subject: [PATCH 3/7] fix(notifications): clamp overflowing toast description, tidy error dialog footer Long unbroken JSON error strings overflowed the fixed 360px toast card (Quill's description class sets no overflow-wrap). Break anywhere and clamp to 3 lines. Move the dev-only create-task button to the left of the dialog footer and tag it with a "Dev" badge. Generated-By: PostHog Code Task-Id: 025dc6c9-832e-43f0-b7dd-ea06a7ee43c9 --- .../notifications/ErrorDetailsDialog.tsx | 9 ++++++++- packages/ui/src/styles/globals.css | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/notifications/ErrorDetailsDialog.tsx b/packages/ui/src/features/notifications/ErrorDetailsDialog.tsx index 3ebecfcaad..b5d10ae22c 100644 --- a/packages/ui/src/features/notifications/ErrorDetailsDialog.tsx +++ b/packages/ui/src/features/notifications/ErrorDetailsDialog.tsx @@ -1,4 +1,5 @@ import { + Badge, Button, Dialog, DialogContent, @@ -85,8 +86,14 @@ export function ErrorDetailsDialog() { {import.meta.env.DEV && ( - )}