diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index db2b28e4f..7c9a48737 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -10,6 +10,13 @@ describe("AppSettingsSchema", () => { expect(settings.codeViewerAutosave).toBe(false); }); + it("defaults notification detail toggles to false", () => { + const settings = Schema.decodeUnknownSync(AppSettingsSchema)({}); + + expect(settings.showNotificationDetails).toBe(false); + expect(settings.includeDiagnosticsTipsInCopy).toBe(false); + }); + it("preserves an explicit codeViewerAutosave setting", () => { const settings = Schema.decodeUnknownSync(AppSettingsSchema)({ codeViewerAutosave: true, @@ -18,6 +25,16 @@ describe("AppSettingsSchema", () => { expect(settings.codeViewerAutosave).toBe(true); }); + it("preserves explicit notification detail settings", () => { + const settings = Schema.decodeUnknownSync(AppSettingsSchema)({ + showNotificationDetails: true, + includeDiagnosticsTipsInCopy: true, + }); + + expect(settings.showNotificationDetails).toBe(true); + expect(settings.includeDiagnosticsTipsInCopy).toBe(true); + }); + it("defaults the PR request changes button tone to warning", () => { const settings = Schema.decodeUnknownSync(AppSettingsSchema)({}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index ae79c5022..f7d05f974 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -80,6 +80,8 @@ export const AppSettingsSchema = Schema.Struct({ rebaseBeforeCommit: Schema.Boolean.pipe(withDefaults(() => false)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), showAuthFailuresAsErrors: Schema.Boolean.pipe(withDefaults(() => true)), + showNotificationDetails: Schema.Boolean.pipe(withDefaults(() => false)), + includeDiagnosticsTipsInCopy: Schema.Boolean.pipe(withDefaults(() => false)), locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)), openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c36f82a6d..6710c5f2b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4859,6 +4859,8 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { setThreadError(activeThread.id, null)} providerStatus={activeProviderStatus} transportState={transportState} diff --git a/apps/web/src/components/chat/ErrorNotificationBar.test.tsx b/apps/web/src/components/chat/ErrorNotificationBar.test.tsx new file mode 100644 index 000000000..b23bc1835 --- /dev/null +++ b/apps/web/src/components/chat/ErrorNotificationBar.test.tsx @@ -0,0 +1,88 @@ +import type { ServerProviderStatus } from "@okcode/contracts"; +import type { ComponentProps, ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ErrorNotificationBar } from "./ErrorNotificationBar"; + +function makeProviderStatus(overrides: Partial = {}): ServerProviderStatus { + return { + provider: "codex", + status: "warning", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-10T12:00:00.000Z", + message: "Provider is checking state.", + ...overrides, + }; +} + +const THREAD_ERROR = + "Git command failed in GitCore.createWorktree: OPENAI_API_KEY=sk-proj-secret (/repo) - Base branch 'main' does not resolve to a commit yet."; + +function renderBar( + overrides: Partial> = {}, +): ReactElement { + const { onDismissThreadError, transportState, ...restOverrides } = overrides; + return ( + + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ErrorNotificationBar", () => { + it("keeps raw error text out of the collapsed bar and shows the aggregate count", () => { + const markup = renderToStaticMarkup(renderBar()); + + expect(markup).toContain("Show 2 notifications"); + expect(markup).not.toContain("OPENAI_API_KEY=sk-proj-secret"); + expect(markup).not.toContain("Base branch 'main' does not resolve to a commit yet."); + }); + + it("expands to show redacted error text and diagnostics copy", async () => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = create(renderBar()); + }); + + const root = renderer!.root; + const toggle = root.findByProps({ "aria-label": "Show 2 notifications" }); + + await act(async () => { + toggle.props.onClick(); + }); + + expect(root.findByProps({ "aria-label": "Hide 2 notifications" })).toBeTruthy(); + expect(root.findByProps({ "aria-label": "Copy diagnostics" })).toBeTruthy(); + expect(JSON.stringify(renderer!.toJSON())).toContain("Worktree thread could not start"); + expect(JSON.stringify(renderer!.toJSON())).toContain( + "Base branch 'main' does not resolve to a commit yet.", + ); + }); + + it("starts expanded when notification details are enabled", () => { + const markup = renderToStaticMarkup( + renderBar({ + showNotificationDetails: true, + }), + ); + + expect(markup).toContain("Hide 2 notifications"); + expect(markup).toContain("Worktree thread could not start"); + expect(markup).toContain("Base branch 'main' does not resolve to a commit yet."); + }); +}); diff --git a/apps/web/src/components/chat/ErrorNotificationBar.tsx b/apps/web/src/components/chat/ErrorNotificationBar.tsx index 6577f7ff8..081963a0c 100644 --- a/apps/web/src/components/chat/ErrorNotificationBar.tsx +++ b/apps/web/src/components/chat/ErrorNotificationBar.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { memo, useState, useCallback, useMemo, useEffect, useRef, useId } from "react"; import { CircleAlertIcon, ChevronDownIcon, @@ -9,18 +9,28 @@ import { } from "lucide-react"; import { type ServerProviderStatus } from "@okcode/contracts"; import type { TransportState } from "../../wsTransport"; -import { humanizeThreadError, isAuthenticationThreadError } from "./threadError"; +import { + buildThreadErrorDiagnosticsCopy, + humanizeThreadError, + isAuthenticationThreadError, +} from "./threadError"; import { getProviderStatusHeading, getProviderStatusDescription, } from "./providerStatusPresentation"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { MessageCopyButton } from "./MessageCopyButton"; interface ErrorNotificationBarProps { /** Thread error string (from activeThread.error) */ threadError: string | null; /** Whether to show auth failures as errors */ showAuthFailuresAsErrors?: boolean; + /** Whether notification details should start expanded */ + showNotificationDetails?: boolean; + /** Whether copied diagnostics should include troubleshooting tips */ + includeDiagnosticsTipsInCopy?: boolean; /** Dismiss the thread error */ onDismissThreadError?: () => void; /** Provider health status */ @@ -33,10 +43,12 @@ interface ErrorNotificationBarProps { interface NotificationItem { id: string; + kind: "connection" | "provider" | "thread-error"; icon: React.ElementType; title: string; description: string; - technicalDetails?: string | null; + detailsText?: string | null; + diagnosticsCopyText?: string | null; severity: "error" | "warning" | "info"; dismissible: boolean; onDismiss?: () => void; @@ -45,13 +57,16 @@ interface NotificationItem { export const ErrorNotificationBar = memo(function ErrorNotificationBar({ threadError, showAuthFailuresAsErrors = true, + showNotificationDetails = false, + includeDiagnosticsTipsInCopy = false, onDismissThreadError, providerStatus, transportState, isMobileCompanion, }: ErrorNotificationBarProps) { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(showNotificationDetails); const [dismissedIds, setDismissedIds] = useState>(new Set()); + const detailsPanelId = useId(); // Track which notification IDs are currently active so we can // re-show a notification if the error clears and returns. @@ -66,6 +81,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ transportState === "reconnecting" ? { id: "connection", + kind: "connection", icon: WifiOffIcon, title: "Reconnecting to OK Code", description: "Trying to restore the remote session.", @@ -75,6 +91,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ : transportState === "closed" ? { id: "connection", + kind: "connection", icon: WifiOffIcon, title: "Disconnected from OK Code", description: "The remote server is unavailable.", @@ -83,6 +100,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ } : { id: "connection", + kind: "connection", icon: WifiIcon, title: "Connecting to OK Code", description: "Establishing the remote session connection.", @@ -98,6 +116,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ const description = getProviderStatusDescription(providerStatus); items.push({ id: "provider", + kind: "provider", icon: CircleAlertIcon, title, description, @@ -112,10 +131,14 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ const presentation = humanizeThreadError(threadError); items.push({ id: "thread-error", + kind: "thread-error", icon: CircleAlertIcon, title: presentation.title ?? "Error", description: presentation.description, - technicalDetails: presentation.technicalDetails, + detailsText: presentation.technicalDetails ?? presentation.description, + diagnosticsCopyText: buildThreadErrorDiagnosticsCopy(threadError, { + includeTips: includeDiagnosticsTipsInCopy, + }), severity: "error", dismissible: !!onDismissThreadError, ...(onDismissThreadError ? { onDismiss: onDismissThreadError } : {}), @@ -127,6 +150,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ }, [ threadError, showAuthFailuresAsErrors, + includeDiagnosticsTipsInCopy, onDismissThreadError, providerStatus, transportState, @@ -165,6 +189,12 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ prevActiveIdsRef.current = currentIds; }, [notifications, isExpanded]); + useEffect(() => { + if (showNotificationDetails && notifications.length > 0) { + setIsExpanded(true); + } + }, [notifications.length, showNotificationDetails]); + const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); const handleDismiss = useCallback((notif: NotificationItem) => { @@ -189,7 +219,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ const primary = visibleNotifications[0]!; const PrimaryIcon = primary.icon; const count = visibleNotifications.length; - const hasMultiple = count > 1; + const countLabel = count === 1 ? "1 notification" : `${count} notifications`; const severityColor = { error: "text-destructive", @@ -222,49 +252,31 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({
- - {primary.title !== "Error" ? ( - <> - {primary.title} - — {primary.description} - - ) : ( - {primary.description} - )} + + {primary.title}
- {/* Count badge */} - {hasMultiple && ( - - {count} - - )} - - {/* Expand/collapse toggle */} - {(hasMultiple || primary.technicalDetails) && ( - - )} + {/* Dismiss button */} + ) : null}
- {notif.dismissible && ( - - )}
); })} diff --git a/apps/web/src/components/chat/threadError.test.ts b/apps/web/src/components/chat/threadError.test.ts index 17c8903e7..97a0cdf1e 100644 --- a/apps/web/src/components/chat/threadError.test.ts +++ b/apps/web/src/components/chat/threadError.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { humanizeThreadError, isAuthenticationThreadError } from "./threadError"; +import { + buildThreadErrorDiagnosticsCopy, + humanizeThreadError, + isAuthenticationThreadError, +} from "./threadError"; describe("humanizeThreadError", () => { it("summarizes worktree creation failures into a user-facing message", () => { @@ -52,4 +56,34 @@ describe("humanizeThreadError", () => { it("does not classify unrelated failures as authentication errors", () => { expect(isAuthenticationThreadError("Provider crashed while starting.")).toBe(false); }); + + it("builds redacted diagnostics copy without optional tips by default", () => { + expect( + buildThreadErrorDiagnosticsCopy( + "Git command failed in GitCore.createWorktree: OPENAI_API_KEY=sk-proj-secret (/repo) - token=abc123", + ), + ).toBe( + [ + "Message: Worktree thread could not start: token=[REDACTED]", + "", + "Technical details:", + "Git command failed in GitCore.createWorktree: OPENAI_API_KEY=[REDACTED] (/repo) - token=[REDACTED]", + ].join("\n"), + ); + }); + + it("adds troubleshooting tips when requested", () => { + expect( + buildThreadErrorDiagnosticsCopy( + "Codex CLI is not authenticated. Run `codex login` and try again.", + { includeTips: true }, + ), + ).toContain("Troubleshooting:"); + expect( + buildThreadErrorDiagnosticsCopy( + "Codex CLI is not authenticated. Run `codex login` and try again.", + { includeTips: true }, + ), + ).toContain("Run `codex login` and retry the turn."); + }); }); diff --git a/apps/web/src/components/chat/threadError.ts b/apps/web/src/components/chat/threadError.ts index f001b731b..58fdde358 100644 --- a/apps/web/src/components/chat/threadError.ts +++ b/apps/web/src/components/chat/threadError.ts @@ -6,6 +6,10 @@ export interface ThreadErrorPresentation { technicalDetails: string | null; } +export interface ThreadErrorDiagnosticsCopyOptions { + includeTips?: boolean; +} + const WORKTREE_COMMAND_PREFIX = "Git command failed in GitCore.createWorktree:"; const AUTH_FAILURE_PATTERNS = [ "run `codex login`", @@ -27,6 +31,38 @@ function extractWorktreeDetail(error: string): string | null { return detail.length > 0 ? detail : null; } +function getProviderLoginCommand(error: string): string | null { + const lower = error.toLowerCase(); + if (lower.includes("claude")) { + return "`claude auth login`"; + } + if (lower.includes("codex")) { + return "`codex login`"; + } + return null; +} + +function buildTroubleshootingTips(error: string, presentation: ThreadErrorPresentation): string[] { + const tips: string[] = []; + + if (isAuthenticationThreadError(error)) { + const loginCommand = getProviderLoginCommand(error); + tips.push( + loginCommand + ? `Run ${loginCommand} and retry the turn.` + : "Run the provider login command for this CLI, then retry the turn.", + ); + } + + if (presentation.title === "Worktree thread could not start") { + tips.push( + "Create the first commit or switch to a base branch that resolves to a commit before starting a worktree thread.", + ); + } + + return tips; +} + export function isAuthenticationThreadError(error: string | null | undefined): boolean { const trimmed = error?.trim(); if (!trimmed) { @@ -54,3 +90,38 @@ export function humanizeThreadError(error: string): ThreadErrorPresentation { technicalDetails: null, }; } + +export function buildThreadErrorDiagnosticsCopy( + error: string, + options: ThreadErrorDiagnosticsCopyOptions = {}, +): string { + const presentation = humanizeThreadError(error); + const lines: string[] = []; + const message = presentation.title + ? `${presentation.title}: ${presentation.description}` + : presentation.description; + + lines.push(`Message: ${message}`); + + if ( + presentation.technicalDetails && + presentation.technicalDetails.trim() !== presentation.description.trim() + ) { + lines.push(""); + lines.push("Technical details:"); + lines.push(presentation.technicalDetails); + } + + if (options.includeTips) { + const tips = buildTroubleshootingTips(error, presentation); + if (tips.length > 0) { + lines.push(""); + lines.push("Troubleshooting:"); + for (const tip of tips) { + lines.push(`- ${tip}`); + } + } + } + + return lines.join("\n"); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index aaa934270..82730defd 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -697,6 +697,12 @@ function SettingsRouteView() { ...(settings.showAuthFailuresAsErrors !== defaults.showAuthFailuresAsErrors ? ["Auth failure errors"] : []), + ...(settings.showNotificationDetails !== defaults.showNotificationDetails + ? ["Notification details"] + : []), + ...(settings.includeDiagnosticsTipsInCopy !== defaults.includeDiagnosticsTipsInCopy + ? ["Diagnostics copy tips"] + : []), ...(settings.openLinksExternally !== defaults.openLinksExternally ? ["Open links externally"] : []), @@ -1646,6 +1652,63 @@ function SettingsRouteView() { } /> + + updateSettings({ + showNotificationDetails: defaults.showNotificationDetails, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + showNotificationDetails: Boolean(checked), + }) + } + aria-label="Show notification details by default" + /> + } + /> + + + updateSettings({ + includeDiagnosticsTipsInCopy: defaults.includeDiagnosticsTipsInCopy, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + includeDiagnosticsTipsInCopy: Boolean(checked), + }) + } + aria-label="Include diagnostics tips in copied text" + /> + } + /> +