feat(#42): Implement persistent Jarvis chat overlay
Add a persistent chat overlay accessible from any authenticated view. The overlay wraps the existing Chat component and adds state management, keyboard shortcuts, and responsive design. Features: - Three states: Closed (floating button), Open (full panel), Minimized (header) - Keyboard shortcuts: - Cmd/Ctrl + K: Open chat (when closed) - Escape: Minimize chat (when open) - Cmd/Ctrl + Shift + J: Toggle chat panel - State persistence via localStorage - Responsive design (full-width mobile, sidebar desktop) - PDA-friendly design with calm colors - 32 comprehensive tests (14 hook tests + 18 component tests) Files added: - apps/web/src/hooks/useChatOverlay.ts - apps/web/src/hooks/useChatOverlay.test.ts - apps/web/src/components/chat/ChatOverlay.tsx - apps/web/src/components/chat/ChatOverlay.test.tsx Files modified: - apps/web/src/components/chat/index.ts (added export) - apps/web/src/app/(authenticated)/layout.tsx (integrated overlay) All tests passing (490 tests, 50 test files) All lint checks passing Build succeeds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
276
apps/web/src/hooks/useChatOverlay.test.ts
Normal file
276
apps/web/src/hooks/useChatOverlay.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file useChatOverlay.test.ts
|
||||
* @description Tests for the useChatOverlay hook that manages chat overlay state
|
||||
*/
|
||||
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useChatOverlay } from "./useChatOverlay";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = ((): Storage => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: (key: string): string | null => store[key] ?? null,
|
||||
setItem: (key: string, value: string): void => {
|
||||
store[key] = value;
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete store[key];
|
||||
},
|
||||
clear: (): void => {
|
||||
store = {};
|
||||
},
|
||||
get length(): number {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: (index: number): string | null => {
|
||||
const keys = Object.keys(store);
|
||||
return keys[index] ?? null;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("useChatOverlay", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should initialize with closed and not minimized state", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
|
||||
it("should restore state from localStorage if available", () => {
|
||||
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 handle invalid localStorage data gracefully", () => {
|
||||
localStorageMock.setItem("chatOverlayState", "invalid json");
|
||||
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("open", () => {
|
||||
it("should open the chat overlay", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
|
||||
it("should persist state to localStorage when opening", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||
isOpen: boolean;
|
||||
isMinimized: boolean;
|
||||
};
|
||||
expect(stored.isOpen).toBe(true);
|
||||
expect(stored.isMinimized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
it("should close the chat overlay", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.close();
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("should persist state to localStorage when closing", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.close();
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||
isOpen: boolean;
|
||||
isMinimized: boolean;
|
||||
};
|
||||
expect(stored.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minimize", () => {
|
||||
it("should minimize the chat overlay", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.minimize();
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
expect(result.current.isMinimized).toBe(true);
|
||||
});
|
||||
|
||||
it("should persist minimized state to localStorage", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.minimize();
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||
isOpen: boolean;
|
||||
isMinimized: boolean;
|
||||
};
|
||||
expect(stored.isMinimized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expand", () => {
|
||||
it("should expand the minimized chat overlay", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
result.current.minimize();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.expand();
|
||||
});
|
||||
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
|
||||
it("should persist expanded state to localStorage", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
result.current.minimize();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.expand();
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as {
|
||||
isOpen: boolean;
|
||||
isMinimized: boolean;
|
||||
};
|
||||
expect(stored.isMinimized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("should toggle the chat overlay open state", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
// Initially closed
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
|
||||
// Toggle to open
|
||||
act(() => {
|
||||
result.current.toggle();
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
|
||||
// Toggle to close
|
||||
act(() => {
|
||||
result.current.toggle();
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("should not change minimized state when toggling", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
result.current.minimize();
|
||||
});
|
||||
|
||||
expect(result.current.isMinimized).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggle();
|
||||
});
|
||||
|
||||
// Should close but keep minimized state for next open
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleMinimize", () => {
|
||||
it("should toggle the minimize state", () => {
|
||||
const { result } = renderHook(() => useChatOverlay());
|
||||
|
||||
act(() => {
|
||||
result.current.open();
|
||||
});
|
||||
|
||||
// Initially not minimized
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
|
||||
// Toggle to minimized
|
||||
act(() => {
|
||||
result.current.toggleMinimize();
|
||||
});
|
||||
|
||||
expect(result.current.isMinimized).toBe(true);
|
||||
|
||||
// Toggle back to expanded
|
||||
act(() => {
|
||||
result.current.toggleMinimize();
|
||||
});
|
||||
|
||||
expect(result.current.isMinimized).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user