feat(web): Integrate M4-LLM error handling improvements
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline failed

Port high-value features from work/m4-llm branch into develop's
security-hardened codebase:

- Separate LLM vs persistence error handling in useChat (shows
  assistant response even when save fails)
- Add structured error context logging with errorType, messagePreview,
  messageCount fields for debugging
- Enforce state invariant in useChatOverlay: cannot be minimized when
  closed
- Add onStorageError callback with user-friendly messages and
  per-error-type deduplication
- Add error logging to Chat imperative handle methods
- Create Chat.test.tsx with loadConversation failure mode tests

Skipped from work/m4-llm (superseded by develop):
- AbortSignal timeout (develop has centralized client timeout)
- Custom toast system (duplicates @mosaic/ui)
- ErrorBoundary (develop has its own)
- WebSocket typed events (develop's ref-based pattern is superior)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-06 20:04:53 -06:00
parent ac796072d8
commit 893a139087
8 changed files with 598 additions and 17 deletions

View File

@@ -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);
});
});
});