feat(web): Integrate M4-LLM error handling improvements
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:
@@ -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<string>
|
||||
): 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<ChatOverlayState>(loadState);
|
||||
const notifiedErrorsRef = useRef<Set<string>>(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(() => {
|
||||
|
||||
Reference in New Issue
Block a user