diff --git a/apps/web/src/components/chat/Chat.test.tsx b/apps/web/src/components/chat/Chat.test.tsx
new file mode 100644
index 0000000..43205ac
--- /dev/null
+++ b/apps/web/src/components/chat/Chat.test.tsx
@@ -0,0 +1,198 @@
+/**
+ * @file Chat.test.tsx
+ * @description Tests for Chat component error handling in imperative handle methods
+ */
+
+import { createRef } from "react";
+import { render } from "@testing-library/react";
+import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
+import { Chat, type ChatRef } from "./Chat";
+import * as useChatModule from "@/hooks/useChat";
+import * as useWebSocketModule from "@/hooks/useWebSocket";
+import * as authModule from "@/lib/auth/auth-context";
+
+// Mock scrollIntoView (not available in JSDOM)
+Element.prototype.scrollIntoView = vi.fn();
+
+// Mock dependencies
+vi.mock("@/lib/auth/auth-context", () => ({
+ useAuth: vi.fn(),
+}));
+
+vi.mock("@/hooks/useChat", () => ({
+ useChat: vi.fn(),
+}));
+
+vi.mock("@/hooks/useWebSocket", () => ({
+ useWebSocket: vi.fn(),
+}));
+
+vi.mock("./MessageList", () => ({
+ MessageList: (): React.ReactElement =>
,
+}));
+
+vi.mock("./ChatInput", () => ({
+ ChatInput: ({
+ onSend,
+ }: {
+ onSend: (content: string) => Promise;
+ disabled: boolean;
+ inputRef: React.RefObject;
+ }): React.ReactElement => (
+
+ ),
+}));
+
+const mockUseAuth = authModule.useAuth as MockedFunction;
+const mockUseChat = useChatModule.useChat as MockedFunction;
+const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
+ typeof useWebSocketModule.useWebSocket
+>;
+
+function createMockUseChatReturn(
+ overrides: Partial = {}
+): useChatModule.UseChatReturn {
+ return {
+ messages: [
+ {
+ id: "welcome",
+ role: "assistant",
+ content: "Hello!",
+ createdAt: new Date().toISOString(),
+ },
+ ],
+ isLoading: false,
+ error: null,
+ conversationId: null,
+ conversationTitle: null,
+ sendMessage: vi.fn().mockResolvedValue(undefined),
+ loadConversation: vi.fn().mockResolvedValue(undefined),
+ startNewConversation: vi.fn(),
+ setMessages: vi.fn(),
+ clearError: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe("Chat", () => {
+ let consoleSpy: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ mockUseAuth.mockReturnValue({
+ user: { id: "user-1", name: "Test User", email: "test@test.com" },
+ isAuthenticated: true,
+ isLoading: false,
+ login: vi.fn(),
+ logout: vi.fn(),
+ } as unknown as ReturnType);
+
+ mockUseWebSocket.mockReturnValue({
+ isConnected: true,
+ socket: null,
+ connectionError: null,
+ });
+ });
+
+ afterEach(() => {
+ consoleSpy.mockRestore();
+ });
+
+ describe("loadConversation via ref", () => {
+ it("should successfully load a conversation", async () => {
+ const mockLoadConversation = vi.fn().mockResolvedValue(undefined);
+ mockUseChat.mockReturnValue(
+ createMockUseChatReturn({ loadConversation: mockLoadConversation })
+ );
+
+ const ref = createRef();
+ render();
+
+ await ref.current?.loadConversation("conv-123");
+
+ expect(mockLoadConversation).toHaveBeenCalledWith("conv-123");
+ });
+
+ it("should log error context and re-throw on network failure", async () => {
+ const networkError = new Error("Network request failed");
+ const mockLoadConversation = vi.fn().mockRejectedValue(networkError);
+ mockUseChat.mockReturnValue(
+ createMockUseChatReturn({ loadConversation: mockLoadConversation })
+ );
+
+ const ref = createRef();
+ render();
+
+ await expect(ref.current?.loadConversation("conv-123")).rejects.toThrow(
+ "Network request failed"
+ );
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "Failed to load conversation",
+ expect.objectContaining({
+ error: networkError,
+ conversationId: "conv-123",
+ })
+ );
+ });
+
+ it("should log error context and re-throw on API error (500)", async () => {
+ const apiError = new Error("Internal Server Error");
+ const mockLoadConversation = vi.fn().mockRejectedValue(apiError);
+ mockUseChat.mockReturnValue(
+ createMockUseChatReturn({ loadConversation: mockLoadConversation })
+ );
+
+ const ref = createRef();
+ render();
+
+ await expect(ref.current?.loadConversation("conv-456")).rejects.toThrow(
+ "Internal Server Error"
+ );
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "Failed to load conversation",
+ expect.objectContaining({
+ conversationId: "conv-456",
+ })
+ );
+ });
+
+ it("should re-throw error for caller to handle", async () => {
+ const mockLoadConversation = vi.fn().mockRejectedValue(new Error("Auth failed"));
+ mockUseChat.mockReturnValue(
+ createMockUseChatReturn({ loadConversation: mockLoadConversation })
+ );
+
+ const ref = createRef();
+ render();
+
+ // Verify the error propagates to the caller
+ await expect(ref.current?.loadConversation("conv-789")).rejects.toThrow("Auth failed");
+ });
+ });
+
+ describe("sendMessage error handling", () => {
+ it("should log error when sendMessage fails", async () => {
+ const sendError = new Error("Send failed");
+ const mockSendMessage = vi.fn().mockRejectedValue(sendError);
+ mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
+
+ const ref = createRef();
+ const { getByTestId } = render();
+
+ // Click the send button (which calls handleSendMessage)
+ const sendButton = getByTestId("chat-input");
+ sendButton.click();
+
+ // Wait for async handling
+ await vi.waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith("Error sending message:", sendError);
+ });
+ });
+ });
+});
diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx
index 69f4550..c79dba7 100644
--- a/apps/web/src/components/chat/Chat.tsx
+++ b/apps/web/src/components/chat/Chat.tsx
@@ -96,8 +96,13 @@ export const Chat = forwardRef(function Chat(
// Expose methods to parent via ref
useImperativeHandle(ref, () => ({
- loadConversation: async (conversationId: string): Promise => {
- await loadConversation(conversationId);
+ loadConversation: async (cId: string): Promise => {
+ try {
+ await loadConversation(cId);
+ } catch (err) {
+ console.error("Failed to load conversation", { error: err, conversationId: cId });
+ throw err;
+ }
},
startNewConversation: (projectId?: string | null): void => {
startNewConversation(projectId);
@@ -175,7 +180,11 @@ export const Chat = forwardRef(function Chat(
const handleSendMessage = useCallback(
async (content: string) => {
- await sendMessage(content);
+ try {
+ await sendMessage(content);
+ } catch (err) {
+ console.error("Error sending message:", err);
+ }
},
[sendMessage]
);
diff --git a/apps/web/src/hooks/useChat.test.ts b/apps/web/src/hooks/useChat.test.ts
index 012a050..7c98325 100644
--- a/apps/web/src/hooks/useChat.test.ts
+++ b/apps/web/src/hooks/useChat.test.ts
@@ -420,4 +420,175 @@ describe("useChat", () => {
expect(result.current.error).toBeNull();
});
});
+
+ describe("error context logging", () => {
+ it("should log comprehensive error context when sendMessage fails", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
+
+ const { result } = renderHook(() => useChat({ model: "llama3.2" }));
+
+ await act(async () => {
+ await result.current.sendMessage("Hello world");
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "Failed to send chat message",
+ expect.objectContaining({
+ errorType: "LLM_ERROR",
+ messageLength: 11,
+ messagePreview: "Hello world",
+ model: "llama3.2",
+ timestamp: expect.any(String) as string,
+ })
+ );
+ });
+
+ it("should truncate long message previews to 50 characters", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
+
+ const longMessage = "A".repeat(100);
+ const { result } = renderHook(() => useChat());
+
+ await act(async () => {
+ await result.current.sendMessage(longMessage);
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "Failed to send chat message",
+ expect.objectContaining({
+ messagePreview: "A".repeat(50),
+ messageLength: 100,
+ })
+ );
+ });
+
+ it("should include message count in error context", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ // First successful message
+ mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
+ mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
+
+ const { result } = renderHook(() => useChat());
+
+ await act(async () => {
+ await result.current.sendMessage("First");
+ });
+
+ // Second message fails
+ mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
+
+ await act(async () => {
+ await result.current.sendMessage("Second");
+ });
+
+ // messageCount should reflect messages including the new user message
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "Failed to send chat message",
+ expect.objectContaining({
+ messageCount: expect.any(Number) as number,
+ })
+ );
+ });
+ });
+
+ describe("LLM vs persistence error separation", () => {
+ it("should show LLM error and add error message to chat when API fails", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
+
+ const { result } = renderHook(() => useChat());
+
+ await act(async () => {
+ await result.current.sendMessage("Hello");
+ });
+
+ expect(result.current.error).toBe("Model not available");
+ // Should have welcome + user + error message
+ expect(result.current.messages).toHaveLength(3);
+ expect(result.current.messages[2]?.content).toContain("Error: Model not available");
+ });
+
+ it("should keep assistant message visible when save fails", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
+ mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
+
+ const { result } = renderHook(() => useChat());
+
+ await act(async () => {
+ await result.current.sendMessage("Hello");
+ });
+
+ // Assistant message should still be visible
+ expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
+ expect(result.current.messages[2]?.content).toBe("Great answer!");
+
+ // Error should indicate persistence failure
+ expect(result.current.error).toContain("Message sent but failed to save");
+ });
+
+ it("should log with PERSISTENCE_ERROR type when save fails", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
+ mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
+
+ const { result } = renderHook(() => useChat());
+
+ await act(async () => {
+ await result.current.sendMessage("Test");
+ });
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "Failed to save conversation",
+ expect.objectContaining({
+ errorType: "PERSISTENCE_ERROR",
+ })
+ );
+
+ // Should NOT have logged as LLM_ERROR
+ const llmErrorCalls = consoleSpy.mock.calls.filter((call) => {
+ const ctx: unknown = call[1];
+ return (
+ typeof ctx === "object" &&
+ ctx !== null &&
+ "errorType" in ctx &&
+ (ctx as { errorType: string }).errorType === "LLM_ERROR"
+ );
+ });
+ expect(llmErrorCalls).toHaveLength(0);
+ });
+
+ it("should use different user-facing messages for LLM vs save errors", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ // Test LLM error message
+ mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
+ const { result: result1 } = renderHook(() => useChat());
+
+ await act(async () => {
+ await result1.current.sendMessage("Test");
+ });
+
+ const llmError = result1.current.error;
+
+ // Test save error message
+ mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("OK"));
+ mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
+ const { result: result2 } = renderHook(() => useChat());
+
+ await act(async () => {
+ await result2.current.sendMessage("Test");
+ });
+
+ const saveError = result2.current.error;
+
+ // They should be different
+ expect(llmError).toBe("Timeout");
+ expect(saveError).toContain("Message sent but failed to save");
+ expect(llmError).not.toEqual(saveError);
+ });
+ });
});
diff --git a/apps/web/src/hooks/useChat.ts b/apps/web/src/hooks/useChat.ts
index 5426d52..6a969f8 100644
--- a/apps/web/src/hooks/useChat.ts
+++ b/apps/web/src/hooks/useChat.ts
@@ -208,12 +208,33 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
? generateTitle(content)
: (conversationTitle ?? "Chat Conversation");
- // Save conversation
- await saveConversation(finalMessages, title);
+ // Save conversation (separate error handling from LLM errors)
+ try {
+ await saveConversation(finalMessages, title);
+ } catch (saveErr) {
+ const saveErrorMsg =
+ saveErr instanceof Error ? saveErr.message : "Unknown persistence error";
+ setError(`Message sent but failed to save: ${saveErrorMsg}`);
+ console.error("Failed to save conversation", {
+ error: saveErr,
+ errorType: "PERSISTENCE_ERROR",
+ conversationId,
+ });
+ }
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
setError(errorMsg);
onError?.(err instanceof Error ? err : new Error(errorMsg));
+ console.error("Failed to send chat message", {
+ error: err,
+ errorType: "LLM_ERROR",
+ conversationId,
+ messageLength: content.length,
+ messagePreview: content.substring(0, 50),
+ model,
+ messageCount: messagesRef.current.length,
+ timestamp: new Date().toISOString(),
+ });
// Add error message to chat
const errorMessage: Message = {
diff --git a/apps/web/src/hooks/useChatOverlay.test.ts b/apps/web/src/hooks/useChatOverlay.test.ts
index dbed8e6..5b2d37f 100644
--- a/apps/web/src/hooks/useChatOverlay.test.ts
+++ b/apps/web/src/hooks/useChatOverlay.test.ts
@@ -260,7 +260,7 @@ describe("useChatOverlay", () => {
expect(result.current.isOpen).toBe(false);
});
- it("should not change minimized state when toggling", () => {
+ it("should reset minimized state when toggling closed", () => {
const { result } = renderHook(() => useChatOverlay());
act(() => {
@@ -274,8 +274,24 @@ describe("useChatOverlay", () => {
result.current.toggle();
});
- // Should close but keep minimized state for next open
+ // Should close and reset minimized (invariant: cannot be minimized when closed)
expect(result.current.isOpen).toBe(false);
+ expect(result.current.isMinimized).toBe(false);
+ });
+
+ it("should preserve minimized state when toggling open", () => {
+ const { result } = renderHook(() => useChatOverlay());
+
+ // Start closed
+ expect(result.current.isOpen).toBe(false);
+
+ act(() => {
+ result.current.toggle();
+ });
+
+ // Should open and not be minimized
+ expect(result.current.isOpen).toBe(true);
+ expect(result.current.isMinimized).toBe(false);
});
});
@@ -305,4 +321,122 @@ describe("useChatOverlay", () => {
expect(result.current.isMinimized).toBe(false);
});
});
+
+ describe("state invariant enforcement", () => {
+ it("should reject invalid localStorage state: closed AND minimized", () => {
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ // This violates the invariant: cannot be minimized when closed
+ localStorageMock.setItem(
+ "chatOverlayState",
+ JSON.stringify({ isOpen: false, isMinimized: true })
+ );
+
+ const { result } = renderHook(() => useChatOverlay());
+
+ // Should fall back to defaults since invariant is violated
+ expect(result.current.isOpen).toBe(false);
+ expect(result.current.isMinimized).toBe(false);
+ });
+
+ it("should accept valid state: open and minimized", () => {
+ localStorageMock.setItem(
+ "chatOverlayState",
+ JSON.stringify({ isOpen: true, isMinimized: true })
+ );
+
+ const { result } = renderHook(() => useChatOverlay());
+
+ expect(result.current.isOpen).toBe(true);
+ expect(result.current.isMinimized).toBe(true);
+ });
+
+ it("should reset isMinimized when closing via close()", () => {
+ const { result } = renderHook(() => useChatOverlay());
+
+ act(() => {
+ result.current.open();
+ result.current.minimize();
+ });
+
+ expect(result.current.isMinimized).toBe(true);
+
+ act(() => {
+ result.current.close();
+ });
+
+ expect(result.current.isOpen).toBe(false);
+ expect(result.current.isMinimized).toBe(false);
+ });
+ });
+
+ describe("storage error handling", () => {
+ it("should call onStorageError when localStorage save fails", () => {
+ const onStorageError = vi.fn();
+ const quotaError = new DOMException("Storage full", "QuotaExceededError");
+
+ const { result } = renderHook(() => useChatOverlay({ onStorageError }));
+
+ // Make setItem throw
+ vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
+ throw quotaError;
+ });
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ act(() => {
+ result.current.open();
+ });
+
+ expect(onStorageError).toHaveBeenCalledWith(
+ "Storage is full. Chat state may not persist across page refreshes."
+ );
+ });
+
+ it("should show appropriate message for SecurityError", () => {
+ const onStorageError = vi.fn();
+ const securityError = new DOMException("Blocked", "SecurityError");
+
+ const { result } = renderHook(() => useChatOverlay({ onStorageError }));
+
+ vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
+ throw securityError;
+ });
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ act(() => {
+ result.current.open();
+ });
+
+ expect(onStorageError).toHaveBeenCalledWith(
+ "Storage is unavailable in this browser mode. Chat state will not persist."
+ );
+ });
+
+ it("should only notify once per error type", () => {
+ const onStorageError = vi.fn();
+ const quotaError = new DOMException("Storage full", "QuotaExceededError");
+
+ // Set up spy BEFORE rendering so all saves (including initial) throw
+ vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
+ throw quotaError;
+ });
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ const { result } = renderHook(() => useChatOverlay({ onStorageError }));
+
+ // Initial render triggers a save which throws → first notification
+ // Multiple state changes that trigger more saves
+ act(() => {
+ result.current.open();
+ });
+ act(() => {
+ result.current.minimize();
+ });
+ act(() => {
+ result.current.expand();
+ });
+
+ // Should only have been called once for QuotaExceededError despite multiple failures
+ expect(onStorageError).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/apps/web/src/hooks/useChatOverlay.ts b/apps/web/src/hooks/useChatOverlay.ts
index 675f2d1..fefdf95 100644
--- a/apps/web/src/hooks/useChatOverlay.ts
+++ b/apps/web/src/hooks/useChatOverlay.ts
@@ -3,7 +3,7 @@
* @description Hook for managing the global chat overlay state
*/
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect, useCallback, useRef } from "react";
import { safeJsonParse, isChatOverlayState } from "@/lib/utils/safe-json";
interface ChatOverlayState {
@@ -11,6 +11,10 @@ interface ChatOverlayState {
isMinimized: boolean;
}
+export interface UseChatOverlayOptions {
+ onStorageError?: (message: string) => void;
+}
+
interface UseChatOverlayResult extends ChatOverlayState {
open: () => void;
close: () => void;
@@ -27,6 +31,23 @@ const DEFAULT_STATE: ChatOverlayState = {
isMinimized: false,
};
+/**
+ * Get a user-friendly error message for localStorage failures
+ */
+function getStorageErrorMessage(error: unknown): string {
+ if (error instanceof DOMException) {
+ switch (error.name) {
+ case "QuotaExceededError":
+ return "Storage is full. Chat state may not persist across page refreshes.";
+ case "SecurityError":
+ return "Storage is unavailable in this browser mode. Chat state will not persist.";
+ case "InvalidStateError":
+ return "Storage is disabled. Chat state will not persist across page refreshes.";
+ }
+ }
+ return "Unable to save chat state. It may not persist across page refreshes.";
+}
+
/**
* Load state from localStorage with runtime type validation
*/
@@ -48,9 +69,13 @@ function loadState(): ChatOverlayState {
}
/**
- * Save state to localStorage
+ * Save state to localStorage, notifying on error (once per error type)
*/
-function saveState(state: ChatOverlayState): void {
+function saveState(
+ state: ChatOverlayState,
+ onStorageError: ((message: string) => void) | undefined,
+ notifiedErrors: Set
+): void {
if (typeof window === "undefined") {
return;
}
@@ -58,20 +83,31 @@ function saveState(state: ChatOverlayState): void {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
+ const errorName = error instanceof DOMException ? error.name : "UnknownError";
console.warn("Failed to save chat overlay state to localStorage:", error);
+
+ if (onStorageError && !notifiedErrors.has(errorName)) {
+ notifiedErrors.add(errorName);
+ onStorageError(getStorageErrorMessage(error));
+ }
}
}
/**
* Custom hook for managing chat overlay state
- * Persists state to localStorage for consistency across page refreshes
+ * Persists state to localStorage for consistency across page refreshes.
+ * Enforces invariant: cannot be minimized when closed.
*/
-export function useChatOverlay(): UseChatOverlayResult {
+export function useChatOverlay(options: UseChatOverlayOptions = {}): UseChatOverlayResult {
+ const { onStorageError } = options;
const [state, setState] = useState(loadState);
+ const notifiedErrorsRef = useRef>(new Set());
+ const onStorageErrorRef = useRef(onStorageError);
+ onStorageErrorRef.current = onStorageError;
// Persist state changes to localStorage
useEffect(() => {
- saveState(state);
+ saveState(state, onStorageErrorRef.current, notifiedErrorsRef.current);
}, [state]);
const open = useCallback(() => {
@@ -79,7 +115,7 @@ export function useChatOverlay(): UseChatOverlayResult {
}, []);
const close = useCallback(() => {
- setState((prev) => ({ ...prev, isOpen: false }));
+ setState({ isOpen: false, isMinimized: false });
}, []);
const minimize = useCallback(() => {
@@ -91,7 +127,10 @@ export function useChatOverlay(): UseChatOverlayResult {
}, []);
const toggle = useCallback(() => {
- setState((prev) => ({ ...prev, isOpen: !prev.isOpen }));
+ setState((prev) => {
+ const newIsOpen = !prev.isOpen;
+ return { isOpen: newIsOpen, isMinimized: newIsOpen ? prev.isMinimized : false };
+ });
}, []);
const toggleMinimize = useCallback(() => {
diff --git a/apps/web/src/lib/utils/safe-json.test.ts b/apps/web/src/lib/utils/safe-json.test.ts
index 5ae198a..6257661 100644
--- a/apps/web/src/lib/utils/safe-json.test.ts
+++ b/apps/web/src/lib/utils/safe-json.test.ts
@@ -170,7 +170,12 @@ describe("isMessageArray", () => {
describe("isChatOverlayState", () => {
it("should return true for a valid ChatOverlayState", () => {
expect(isChatOverlayState({ isOpen: true, isMinimized: false })).toBe(true);
- expect(isChatOverlayState({ isOpen: false, isMinimized: true })).toBe(true);
+ expect(isChatOverlayState({ isOpen: true, isMinimized: true })).toBe(true);
+ expect(isChatOverlayState({ isOpen: false, isMinimized: false })).toBe(true);
+ });
+
+ it("should reject invalid state: closed AND minimized (invariant violation)", () => {
+ expect(isChatOverlayState({ isOpen: false, isMinimized: true })).toBe(false);
});
it("should return false for non-object values", () => {
diff --git a/apps/web/src/lib/utils/safe-json.ts b/apps/web/src/lib/utils/safe-json.ts
index fe5816f..9f27afd 100644
--- a/apps/web/src/lib/utils/safe-json.ts
+++ b/apps/web/src/lib/utils/safe-json.ts
@@ -76,12 +76,16 @@ export function isMessageArray(value: unknown): value is {
/**
* Type guard: validates ChatOverlayState shape
* Expects { isOpen: boolean, isMinimized: boolean }
+ * Enforces invariant: cannot be minimized when closed
*/
export function isChatOverlayState(
value: unknown
): value is { isOpen: boolean; isMinimized: boolean } {
if (!isRecord(value)) return false;
- return typeof value.isOpen === "boolean" && typeof value.isMinimized === "boolean";
+ if (typeof value.isOpen !== "boolean" || typeof value.isMinimized !== "boolean") return false;
+ // Invariant: cannot be minimized when closed
+ if (!value.isOpen && value.isMinimized) return false;
+ return true;
}
/**