Files
stack/apps/web/src/hooks/useChatOverlay.ts
Jason Woltje f64ca3871d
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline was successful
fix(web): Address review findings for M4-LLM integration
- Sanitize user-facing error messages (no raw API/DB errors)
- Remove dead try/catch from Chat.tsx handleSendMessage
- Add onError callback for persistence errors in useChat
- Add console.error logging to loadConversation
- Guard minimize/toggleMinimize against closed overlay state
- Improve error dedup bucketing for non-DOMException errors
- Add tests: non-Error throws, updateConversation failure,
  minimize/toggleMinimize guards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 20:25:03 -06:00

155 lines
4.1 KiB
TypeScript

/**
* @file useChatOverlay.ts
* @description Hook for managing the global chat overlay state
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { safeJsonParse, isChatOverlayState } from "@/lib/utils/safe-json";
interface ChatOverlayState {
isOpen: boolean;
isMinimized: boolean;
}
export interface UseChatOverlayOptions {
onStorageError?: (message: string) => void;
}
interface UseChatOverlayResult extends ChatOverlayState {
open: () => void;
close: () => void;
minimize: () => void;
expand: () => void;
toggle: () => void;
toggleMinimize: () => void;
}
const STORAGE_KEY = "chatOverlayState";
const DEFAULT_STATE: ChatOverlayState = {
isOpen: false,
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
*/
function loadState(): ChatOverlayState {
if (typeof window === "undefined") {
return DEFAULT_STATE;
}
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored) {
return safeJsonParse(stored, isChatOverlayState, DEFAULT_STATE);
}
} catch (error) {
console.warn("Failed to load chat overlay state from localStorage:", error);
}
return DEFAULT_STATE;
}
/**
* Save state to localStorage, notifying on error (once per error type)
*/
function saveState(
state: ChatOverlayState,
onStorageError: ((message: string) => void) | undefined,
notifiedErrors: Set<string>
): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
const errorName =
error instanceof DOMException
? error.name
: error instanceof Error
? error.constructor.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.
* Enforces invariant: cannot be minimized when closed.
*/
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, onStorageErrorRef.current, notifiedErrorsRef.current);
}, [state]);
const open = useCallback(() => {
setState({ isOpen: true, isMinimized: false });
}, []);
const close = useCallback(() => {
setState({ isOpen: false, isMinimized: false });
}, []);
const minimize = useCallback(() => {
setState((prev) => (prev.isOpen ? { ...prev, isMinimized: true } : prev));
}, []);
const expand = useCallback(() => {
setState((prev) => ({ ...prev, isMinimized: false }));
}, []);
const toggle = useCallback(() => {
setState((prev) => {
const newIsOpen = !prev.isOpen;
return { isOpen: newIsOpen, isMinimized: newIsOpen ? prev.isMinimized : false };
});
}, []);
const toggleMinimize = useCallback(() => {
setState((prev) => (prev.isOpen ? { ...prev, isMinimized: !prev.isMinimized } : prev));
}, []);
return {
...state,
open,
close,
minimize,
expand,
toggle,
toggleMinimize,
};
}