diff --git a/builds/typescript/client_web/src/api/gateway-adapter.test.ts b/builds/typescript/client_web/src/api/gateway-adapter.test.ts index 667c182..06602d6 100644 --- a/builds/typescript/client_web/src/api/gateway-adapter.test.ts +++ b/builds/typescript/client_web/src/api/gateway-adapter.test.ts @@ -14,14 +14,15 @@ import { type ChatEvent, } from "./gateway-adapter"; -function sseResponse(frames: string): Response { - return new Response(frames, { - status: 200, - headers: { - "content-type": "text/event-stream", - }, - }); -} +function sseResponse(frames: string, headers?: Record): Response { + return new Response(frames, { + status: 200, + headers: { + "content-type": "text/event-stream", + ...(headers ?? {}), + }, + }); +} async function collectEvents(stream: AsyncIterable): Promise { const events: ChatEvent[] = []; @@ -67,7 +68,7 @@ describe("gateway-adapter SSE parsing", () => { ]); }); - it("maps legacy text-delta content field to delta", async () => { + it("maps legacy text-delta content field to delta", async () => { vi.stubGlobal( "fetch", vi.fn(async () => @@ -85,12 +86,54 @@ describe("gateway-adapter SSE parsing", () => { ); const events = await collectEvents(sendMessage(null, "hi")); - expect(events[0]).toMatchObject({ - type: "text-delta", - delta: "Legacy format", - }); - }); -}); + expect(events[0]).toMatchObject({ + type: "text-delta", + delta: "Legacy format", + }); + }); + + it("exposes context-window warnings from response headers", async () => { + const onContextWarning = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + sseResponse( + [ + "event: done", + 'data: {"finish_reason":"stop","conversation_id":"conv_3"}', + "", + ].join("\n"), + { + "x-context-window-warning": "1", + "x-context-window-estimated-tokens": "90000", + "x-context-window-budget-tokens": "100000", + "x-context-window-ratio": "0.9", + "x-context-window-threshold": "0.8", + "x-context-window-managed": "1", + "x-context-window-message": "This session is getting long.", + } + ) + ) + ); + + const events = await collectEvents(sendMessage(null, "hi", { onContextWarning })); + expect(events).toEqual([ + { + type: "done", + finish_reason: "stop", + conversation_id: "conv_3", + }, + ]); + expect(onContextWarning).toHaveBeenCalledWith({ + estimated_tokens: 90000, + budget_tokens: 100000, + ratio: 0.9, + threshold: 0.8, + managed: true, + message: "This session is getting long.", + }); + }); +}); describe("gateway-adapter settings models", () => { beforeEach(() => { diff --git a/builds/typescript/client_web/src/api/gateway-adapter.ts b/builds/typescript/client_web/src/api/gateway-adapter.ts index 450f39e..a188438 100644 --- a/builds/typescript/client_web/src/api/gateway-adapter.ts +++ b/builds/typescript/client_web/src/api/gateway-adapter.ts @@ -5,10 +5,11 @@ import { parseSSE } from "./sse-parser"; import { GatewayError, GatewayNotFoundError, - type ApprovalDecision, - type ChatEvent, - type Conversation, - type ConversationDetail, + type ApprovalDecision, + type ChatEvent, + type Conversation, + type ConversationDetail, + type ContextWindowWarning, type GatewayCredentialUpdateRequest, type GatewayCredentialUpdateResponse, type GatewayMemoryBackupRestoreRequest, @@ -36,10 +37,11 @@ type ConversationListResponse = { offset: number; }; -type SendMessageOptions = { - signal?: AbortSignal; - metadata?: Record; -}; +type SendMessageOptions = { + signal?: AbortSignal; + metadata?: Record; + onContextWarning?: (warning: ContextWindowWarning) => void; +}; type ErrorPayload = { code?: string; @@ -188,7 +190,7 @@ function toChatEvent(eventName: string, data: string): ChatEvent { return normalized; } -function normalizeChatEventPayload(eventName: string, parsed: unknown): unknown { +function normalizeChatEventPayload(eventName: string, parsed: unknown): unknown { if (!isRecord(parsed)) { return parsed; } @@ -216,8 +218,34 @@ function normalizeChatEventPayload(eventName: string, parsed: unknown): unknown }; } - return withType; -} + return withType; +} + +function parseContextWindowWarning(headers: Headers): ContextWindowWarning | null { + if (headers.get("x-context-window-warning") !== "1") { + return null; + } + + const estimatedTokens = Number.parseInt(headers.get("x-context-window-estimated-tokens") ?? "", 10); + const budgetTokens = Number.parseInt(headers.get("x-context-window-budget-tokens") ?? "", 10); + const ratio = Number.parseFloat(headers.get("x-context-window-ratio") ?? ""); + const threshold = Number.parseFloat(headers.get("x-context-window-threshold") ?? ""); + const managed = headers.get("x-context-window-managed") === "1"; + const message = headers.get("x-context-window-message") ?? "This session is getting long."; + + if (!Number.isFinite(estimatedTokens) || !Number.isFinite(budgetTokens) || !Number.isFinite(ratio)) { + return null; + } + + return { + estimated_tokens: estimatedTokens, + budget_tokens: budgetTokens, + ratio, + threshold: Number.isFinite(threshold) ? threshold : 0.8, + managed, + message, + }; +} export async function* sendMessage( conversationId: string | null, @@ -241,14 +269,19 @@ export async function* sendMessage( signal: options.signal }); - if (!response.ok) { - throw await toGatewayError(response); - } - - for await (const event of parseSSE(response)) { - yield toChatEvent(event.event, event.data); - } -} + if (!response.ok) { + throw await toGatewayError(response); + } + + const contextWarning = parseContextWindowWarning(response.headers); + if (contextWarning) { + options.onContextWarning?.(contextWarning); + } + + for await (const event of parseSSE(response)) { + yield toChatEvent(event.event, event.data); + } +} export async function listConversations(): Promise { const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/conversations`, { diff --git a/builds/typescript/client_web/src/api/types.ts b/builds/typescript/client_web/src/api/types.ts index 0928aad..47eae1c 100644 --- a/builds/typescript/client_web/src/api/types.ts +++ b/builds/typescript/client_web/src/api/types.ts @@ -94,6 +94,15 @@ export type ChatEvent = | ChatErrorEvent | DoneEvent; +export type ContextWindowWarning = { + estimated_tokens: number; + budget_tokens: number; + ratio: number; + threshold: number; + managed: boolean; + message: string; +}; + export type ApprovalDecision = "approved" | "denied"; export type ApprovalMode = "ask-on-write" | "auto-approve"; diff --git a/builds/typescript/client_web/src/api/useGatewayChat.test.tsx b/builds/typescript/client_web/src/api/useGatewayChat.test.tsx index 4a3ba0e..a97adfe 100644 --- a/builds/typescript/client_web/src/api/useGatewayChat.test.tsx +++ b/builds/typescript/client_web/src/api/useGatewayChat.test.tsx @@ -7,7 +7,18 @@ const sendMessageMock = vi.fn< ( conversationId: string | null, content: string, - options?: { signal?: AbortSignal; metadata?: Record } + options?: { + signal?: AbortSignal; + metadata?: Record; + onContextWarning?: (warning: { + estimated_tokens: number; + budget_tokens: number; + ratio: number; + threshold: number; + managed: boolean; + message: string; + }) => void; + } ) => AsyncIterable >(); @@ -41,7 +52,18 @@ vi.mock("./gateway-adapter", () => ({ sendMessage: ( conversationId: string | null, content: string, - options?: { signal?: AbortSignal; metadata?: Record } + options?: { + signal?: AbortSignal; + metadata?: Record; + onContextWarning?: (warning: { + estimated_tokens: number; + budget_tokens: number; + ratio: number; + threshold: number; + managed: boolean; + message: string; + }) => void; + } ) => sendMessageMock(conversationId, content, options), submitApprovalDecision: (requestId: string, decision: "approved" | "denied") => submitApprovalDecisionMock(requestId, decision), @@ -160,11 +182,75 @@ describe("useGatewayChat", () => { }); expect(result.current.error?.message).toBe("Provider unavailable"); + expect(result.current.errorCode).toBe("provider_error"); expect(result.current.messages).toEqual([ { id: "message-1", role: "user", content: "Hello" } ]); }); + it("stores context overflow error code for overflow-specific UI actions", async () => { + sendMessageMock.mockImplementation(() => + streamEvents([ + { + type: "error", + code: "context_overflow", + message: "This session has gotten long. Start a new conversation to continue - all your work is saved.", + }, + ]) + ); + + const { result } = renderHook(() => useGatewayChat()); + + act(() => { + result.current.append("Hello"); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.errorCode).toBe("context_overflow"); + }); + + it("stores context warning metadata passed from the gateway adapter", async () => { + sendMessageMock.mockImplementation((_conversationId, _content, options) => + (async function* contextWarningStream() { + options?.onContextWarning?.({ + estimated_tokens: 90_000, + budget_tokens: 100_000, + ratio: 0.9, + threshold: 0.8, + managed: true, + message: "This session is getting long. Earlier turns were compacted so you can keep chatting.", + }); + yield { + type: "done", + finish_reason: "stop", + conversation_id: "conv-warning", + } as ChatEvent; + })() + ); + + const { result } = renderHook(() => useGatewayChat()); + + act(() => { + result.current.append("Hello"); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.contextWindowWarning).toEqual({ + estimated_tokens: 90_000, + budget_tokens: 100_000, + ratio: 0.9, + threshold: 0.8, + managed: true, + message: "This session is getting long. Earlier turns were compacted so you can keep chatting.", + }); + }); + it("auto-approves approval requests during streaming", async () => { sendMessageMock.mockImplementation(() => streamEvents([ diff --git a/builds/typescript/client_web/src/api/useGatewayChat.ts b/builds/typescript/client_web/src/api/useGatewayChat.ts index 465873c..e262bc1 100644 --- a/builds/typescript/client_web/src/api/useGatewayChat.ts +++ b/builds/typescript/client_web/src/api/useGatewayChat.ts @@ -11,7 +11,7 @@ import { updateConversationSkills, updateProjectSkills, } from "./gateway-adapter"; -import type { ActivityEvent, ApprovalDecision, PendingApproval } from "./types"; +import type { ActivityEvent, ApprovalDecision, ContextWindowWarning, PendingApproval } from "./types"; const EMPTY_MESSAGES: Message[] = []; const EMPTY_ACTIVITY: ActivityEvent[] = []; @@ -35,9 +35,11 @@ type ConversationState = { messages: Message[]; isLoading: boolean; error: Error | null; + errorCode: string | null; toolStatus: string | null; pendingApprovals: PendingApproval[]; activity: ActivityEvent[]; + contextWindowWarning: ContextWindowWarning | null; conversationId: string | null; abortController: AbortController | null; requestToken: number; @@ -75,13 +77,16 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { messages: Message[]; isLoading: boolean; error: Error | null; + errorCode: string | null; conversationId: string | null; toolStatus: string | null; pendingApprovals: PendingApproval[]; activity: ActivityEvent[]; + contextWindowWarning: ContextWindowWarning | null; append: (content: string, options?: { metadata?: Record }) => void; resolveApproval: (requestId: string, decision: ApprovalDecision) => Promise; stop: () => void; + startNewConversation: () => void; } { const externalConversationId = options.conversationId ?? null; const externalProjectId = options.projectId ?? null; @@ -97,6 +102,7 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { const [messages, setMessages] = useState(cached?.messages ?? externalMessages); const [isLoading, setIsLoading] = useState(cached?.isLoading ?? false); const [error, setError] = useState(cached?.error ?? null); + const [errorCode, setErrorCode] = useState(cached?.errorCode ?? null); const [conversationId, setConversationId] = useState( cached?.conversationId ?? externalConversationId ); @@ -105,6 +111,9 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { cached?.pendingApprovals ?? EMPTY_APPROVALS ); const [activity, setActivity] = useState(cached?.activity ?? EMPTY_ACTIVITY); + const [contextWindowWarning, setContextWindowWarning] = useState( + cached?.contextWindowWarning ?? null + ); const abortControllerRef = useRef(cached?.abortController ?? null); const requestTokenRef = useRef(cached?.requestToken ?? 0); @@ -130,9 +139,11 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { setIsLoading(false); setError(null); + setErrorCode(null); setToolStatus(null); setPendingApprovals([]); setActivity([]); + setContextWindowWarning(null); } window.addEventListener(GATEWAY_CHAT_RUNTIME_RESET_EVENT, handleRuntimeReset); @@ -151,9 +162,11 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { messages: messages, isLoading, error, + errorCode, toolStatus, pendingApprovals, activity, + contextWindowWarning, conversationId, abortController: abortControllerRef.current, requestToken: requestTokenRef.current, @@ -170,9 +183,11 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { setMessages(restored.messages); setIsLoading(restored.isLoading); setError(restored.error); + setErrorCode(restored.errorCode); setToolStatus(restored.toolStatus); setPendingApprovals(restored.pendingApprovals); setActivity(restored.activity); + setContextWindowWarning(restored.contextWindowWarning); setConversationId(restored.conversationId); abortControllerRef.current = restored.abortController; requestTokenRef.current = restored.requestToken; @@ -184,12 +199,14 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { // Start empty when switching conversations; externalMessages can be stale // from the previous project's history that has not cleared yet. A later // effect repopulates the correct history once async fetch completes. - setMessages(EMPTY_MESSAGES); + setMessages(EMPTY_MESSAGES); setIsLoading(false); setError(null); + setErrorCode(null); setToolStatus(null); setPendingApprovals([]); setActivity([]); + setContextWindowWarning(null); setConversationId(externalConversationId); abortControllerRef.current = null; requestTokenRef.current = 0; @@ -236,6 +253,28 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { setIsLoading(false); } + function startNewConversation() { + requestTokenRef.current += 1; + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + backgroundStreams.delete(cacheKeyRef.current); + backgroundStates.delete(cacheKeyRef.current); + + setMessages(EMPTY_MESSAGES); + setIsLoading(false); + setError(null); + setErrorCode(null); + setToolStatus(null); + setPendingApprovals([]); + setActivity([]); + setContextWindowWarning(null); + setConversationId(null); + + conversationIdRef.current = null; + messageCounterRef.current = 0; + activityCounterRef.current = 0; + } + async function resolveApproval(requestId: string, decision: ApprovalDecision): Promise { // Capture the tool name before removing the approval so we can show // a user-friendly status ("Writing to your library...") instead of "Approval approved" @@ -269,6 +308,7 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { }; setError(null); + setErrorCode(null); setIsLoading(true); setToolStatus("Running slash command..."); setMessages((current) => [...current, userMessage]); @@ -289,6 +329,7 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { setToolStatus(null); } catch (error) { setError(toError(error)); + setErrorCode(null); setToolStatus(null); } finally { setIsLoading(false); @@ -315,7 +356,9 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { const assistantMessageId = nextMessageId(); setError(null); + setErrorCode(null); setIsLoading(true); + setContextWindowWarning(null); setMessages((current) => [...current, userMessage]); // Track this as a background stream so state updates route correctly @@ -392,7 +435,17 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { try { for await (const event of sendMessage(conversationIdRef.current, trimmed, { signal: controller.signal, - metadata: options?.metadata + metadata: options?.metadata, + onContextWarning: (warning) => { + if (isActive()) { + setContextWindowWarning(warning); + return; + } + + updateBackground(() => ({ + contextWindowWarning: warning, + })); + }, })) { if (requestToken !== requestTokenRef.current && isActive()) { return; @@ -461,11 +514,13 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { case "done": if (isActive()) { setToolStatus(null); + setErrorCode(null); updateConversationId(event.conversation_id); } else { updateBackground(() => ({ toolStatus: null, isLoading: false, + errorCode: null, conversationId: event.conversation_id ?? null, })); } @@ -475,11 +530,13 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { if (isActive()) { setToolStatus(null); setError(new Error(event.message)); + setErrorCode(event.code); } else { updateBackground(() => ({ toolStatus: null, isLoading: false, error: new Error(event.message), + errorCode: event.code, })); } backgroundStreams.delete(activeCacheKey); @@ -526,11 +583,13 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { if (isActive()) { setToolStatus(null); setError(toError(approvalError)); + setErrorCode(null); } else { updateBackground(() => ({ toolStatus: null, isLoading: false, error: toError(approvalError), + errorCode: null, })); } backgroundStreams.delete(activeCacheKey); @@ -557,6 +616,7 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { updateBackground(() => ({ isLoading: false, error: toError(caughtError), + errorCode: null, })); backgroundStreams.delete(activeCacheKey); return; @@ -566,6 +626,7 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { } setError(toError(caughtError)); + setErrorCode(null); } finally { backgroundStreams.delete(activeCacheKey); if (isActive() && requestToken === requestTokenRef.current) { @@ -583,13 +644,16 @@ export function useGatewayChat(options: UseGatewayChatOptions = {}): { messages, isLoading, error, + errorCode, conversationId, toolStatus, pendingApprovals, activity, + contextWindowWarning, append, resolveApproval, - stop + stop, + startNewConversation }; } diff --git a/builds/typescript/client_web/src/components/chat/ChatPanel.test.tsx b/builds/typescript/client_web/src/components/chat/ChatPanel.test.tsx index 69f9ace..df38cd6 100644 --- a/builds/typescript/client_web/src/components/chat/ChatPanel.test.tsx +++ b/builds/typescript/client_web/src/components/chat/ChatPanel.test.tsx @@ -13,19 +13,32 @@ vi.mock("@/api/useGatewayChat", () => ({ function makeHookState(overrides: Partial<{ messages: Message[]; isLoading: boolean; + error: Error | null; + errorCode: string | null; toolStatus: string | null; + contextWindowWarning: { + estimated_tokens: number; + budget_tokens: number; + ratio: number; + threshold: number; + managed: boolean; + message: string; + } | null; }> = {}) { return { messages: overrides.messages ?? [], isLoading: overrides.isLoading ?? false, - error: null, + error: overrides.error ?? null, + errorCode: overrides.errorCode ?? null, conversationId: null, toolStatus: overrides.toolStatus ?? null, pendingApprovals: [], activity: [], + contextWindowWarning: overrides.contextWindowWarning ?? null, append: vi.fn(), resolveApproval: vi.fn(async () => undefined), stop: vi.fn(), + startNewConversation: vi.fn(), }; } @@ -62,4 +75,41 @@ describe("ChatPanel typing indicator behavior", () => { expect(screen.queryByText("Thinking...")).not.toBeInTheDocument(); }); + + it("shows context warning banner when near limit", () => { + useGatewayChatMock.mockReturnValue( + makeHookState({ + contextWindowWarning: { + estimated_tokens: 80_000, + budget_tokens: 100_000, + ratio: 0.8, + threshold: 0.8, + managed: false, + message: "This session is getting long.", + }, + }) + ); + + render(); + + expect(screen.getByText("This session is getting long.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start New Conversation" })).toBeInTheDocument(); + }); + + it("shows overflow-specific recovery actions", () => { + useGatewayChatMock.mockReturnValue( + makeHookState({ + messages: [{ id: "u-1", role: "user", content: "Continue from this prompt" }], + error: new Error("This session has gotten long."), + errorCode: "context_overflow", + }) + ); + + render(); + + expect(screen.getByRole("button", { name: "Start New Conversation" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue in New Conversation" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open Settings" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Try Again" })).not.toBeInTheDocument(); + }); }); diff --git a/builds/typescript/client_web/src/components/chat/ChatPanel.tsx b/builds/typescript/client_web/src/components/chat/ChatPanel.tsx index a864897..fc03bf4 100644 --- a/builds/typescript/client_web/src/components/chat/ChatPanel.tsx +++ b/builds/typescript/client_web/src/components/chat/ChatPanel.tsx @@ -89,11 +89,14 @@ export default function ChatPanel({ messages, isLoading, error, + errorCode, conversationId, toolStatus, pendingApprovals, + contextWindowWarning, append, - stop + stop, + startNewConversation, } = useGatewayChat({ conversationId: activeConversationId, projectId: activeProjectId ?? null, @@ -176,15 +179,38 @@ export default function ChatPanel({ const chatError = historyError ?? error?.message ?? null; const visibleChatError = chatError && chatError !== dismissedError ? chatError : null; + const isContextOverflowError = errorCode === "context_overflow"; const isProviderError = visibleChatError != null && ( visibleChatError.includes("credentials") || visibleChatError.includes("could not be reached") || visibleChatError.includes("provider") || visibleChatError.includes("model") - ); + ) && !isContextOverflowError; + const lastUserMessage = [...messages].reverse().find((message) => message.role === "user") ?? null; const shouldShowEmptyState = isEmpty && messages.length === 0 && !isLoading; const shouldShowConversation = contentOverride === undefined; + function resetErrorPresentation() { + setHistoryError(null); + if (visibleChatError) { + setDismissedError(visibleChatError); + } + setConnectionStatus("connected"); + } + + function handleStartNewConversation() { + resetErrorPresentation(); + startNewConversation(); + } + + function handleContinueInNewConversation() { + const replayContent = lastUserMessage?.content?.trim(); + handleStartNewConversation(); + if (replayContent && replayContent.length > 0) { + append(replayContent, { metadata: messageMetadata }); + } + } + function handleDragOver(e: DragEvent) { e.preventDefault(); setIsDragOver(true); @@ -323,15 +349,38 @@ export default function ChatPanel({ isTyping={showTypingFeedback} typingStatus={typingStatus} > + {contextWindowWarning && !visibleChatError && ( +
+
+

+ {contextWindowWarning.message}{" "} + + ({Math.round(contextWindowWarning.ratio * 100)}% of current prompt budget) + +

+ +
+
+ )} {visibleChatError && ( { - setHistoryError(null); - setDismissedError(visibleChatError); - setConnectionStatus("connected"); - }} + onRetry={isContextOverflowError ? undefined : () => resetErrorPresentation()} + primaryActionLabel={isContextOverflowError ? "Start New Conversation" : undefined} + onPrimaryAction={isContextOverflowError ? handleStartNewConversation : undefined} + secondaryActionLabel={ + isContextOverflowError && lastUserMessage ? "Continue in New Conversation" : undefined + } + onSecondaryAction={ + isContextOverflowError && lastUserMessage ? handleContinueInNewConversation : undefined + } onDismiss={() => { setHistoryError(null); setDismissedError(visibleChatError); diff --git a/builds/typescript/client_web/src/components/chat/ErrorMessage.tsx b/builds/typescript/client_web/src/components/chat/ErrorMessage.tsx index 828939f..7552fab 100644 --- a/builds/typescript/client_web/src/components/chat/ErrorMessage.tsx +++ b/builds/typescript/client_web/src/components/chat/ErrorMessage.tsx @@ -5,13 +5,21 @@ type ErrorMessageProps = { onRetry?: () => void; onDismiss?: () => void; onOpenSettings?: () => void; + primaryActionLabel?: string; + onPrimaryAction?: () => void; + secondaryActionLabel?: string; + onSecondaryAction?: () => void; }; export default function ErrorMessage({ message, onRetry, onDismiss, - onOpenSettings + onOpenSettings, + primaryActionLabel, + onPrimaryAction, + secondaryActionLabel, + onSecondaryAction, }: ErrorMessageProps) { return (
@@ -24,6 +32,24 @@ export default function ErrorMessage({

{message}

+ {onPrimaryAction && primaryActionLabel && ( + + )} + {onSecondaryAction && secondaryActionLabel && ( + + )} {onOpenSettings && (