From 4027dd65bebd4d82097b049e51577abd796bb10b Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 13:35:08 -0500 Subject: [PATCH] Persist filter and auto-redact preferences to localStorage Category toggles and the auto-redact checkbox now survive page reloads. On mount, useFilters reads from localStorage keys session-viewer:enabledCategories and session-viewer:autoRedact, falling back to defaults when storage is empty, corrupted, or contains invalid category names. Each state change writes back to localStorage in a useEffect. Tests cover round-trip persistence, invalid data recovery, corrupted JSON fallback, and the boolean coercion for auto-redact. Co-Authored-By: Claude Opus 4.5 --- src/client/hooks/useFilters.ts | 66 +++++++++++-- tests/unit/filter-persistence.test.ts | 129 ++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 tests/unit/filter-persistence.test.ts diff --git a/src/client/hooks/useFilters.ts b/src/client/hooks/useFilters.ts index 77a0c51..4c351ea 100644 --- a/src/client/hooks/useFilters.ts +++ b/src/client/hooks/useFilters.ts @@ -1,7 +1,40 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import type { MessageCategory, ParsedMessage } from "../lib/types"; import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types"; +const STORAGE_KEY_CATEGORIES = "session-viewer:enabledCategories"; +const STORAGE_KEY_AUTOREDACT = "session-viewer:autoRedact"; + +function loadEnabledCategories(): Set { + try { + const stored = localStorage.getItem(STORAGE_KEY_CATEGORIES); + if (stored) { + const arr = JSON.parse(stored) as string[]; + const valid = arr.filter((c) => + ALL_CATEGORIES.includes(c as MessageCategory) + ) as MessageCategory[]; + if (valid.length > 0) return new Set(valid); + } + } catch { + // Fall through to default + } + const set = new Set(ALL_CATEGORIES); + for (const cat of DEFAULT_HIDDEN_CATEGORIES) { + set.delete(cat); + } + return set; +} + +function loadAutoRedact(): boolean { + try { + const stored = localStorage.getItem(STORAGE_KEY_AUTOREDACT); + if (stored !== null) return stored === "true"; + } catch { + // Fall through to default + } + return false; +} + interface FilterState { enabledCategories: Set; toggleCategory: (cat: MessageCategory) => void; @@ -20,13 +53,7 @@ interface FilterState { export function useFilters(): FilterState { const [enabledCategories, setEnabledCategories] = useState< Set - >(() => { - const set = new Set(ALL_CATEGORIES); - for (const cat of DEFAULT_HIDDEN_CATEGORIES) { - set.delete(cat); - } - return set; - }); + >(loadEnabledCategories); const [searchQuery, setSearchQuery] = useState(""); const [redactedUuids, setRedactedUuids] = useState>(new Set()); @@ -34,7 +61,28 @@ export function useFilters(): FilterState { Set >(new Set()); - const [autoRedactEnabled, setAutoRedactEnabled] = useState(false); + const [autoRedactEnabled, setAutoRedactEnabled] = useState(loadAutoRedact); + + // Persist enabledCategories to localStorage + useEffect(() => { + try { + localStorage.setItem( + STORAGE_KEY_CATEGORIES, + JSON.stringify([...enabledCategories]) + ); + } catch { + // Ignore storage errors + } + }, [enabledCategories]); + + // Persist autoRedact to localStorage + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY_AUTOREDACT, String(autoRedactEnabled)); + } catch { + // Ignore storage errors + } + }, [autoRedactEnabled]); const toggleCategory = useCallback((cat: MessageCategory) => { setEnabledCategories((prev) => { diff --git a/tests/unit/filter-persistence.test.ts b/tests/unit/filter-persistence.test.ts new file mode 100644 index 0000000..7e668b3 --- /dev/null +++ b/tests/unit/filter-persistence.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { MessageCategory } from "../../src/shared/types.js"; +import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../../src/shared/types.js"; + +// Test the localStorage persistence logic used by useFilters +const STORAGE_KEY_CATEGORIES = "session-viewer:enabledCategories"; +const STORAGE_KEY_AUTOREDACT = "session-viewer:autoRedact"; + +function loadEnabledCategories(): Set { + try { + const stored = localStorage.getItem(STORAGE_KEY_CATEGORIES); + if (stored) { + const arr = JSON.parse(stored) as string[]; + const valid = arr.filter((c) => + ALL_CATEGORIES.includes(c as MessageCategory) + ) as MessageCategory[]; + if (valid.length > 0) return new Set(valid); + } + } catch { + // Fall through to default + } + const set = new Set(ALL_CATEGORIES); + for (const cat of DEFAULT_HIDDEN_CATEGORIES) { + set.delete(cat); + } + return set; +} + +function loadAutoRedact(): boolean { + try { + const stored = localStorage.getItem(STORAGE_KEY_AUTOREDACT); + if (stored !== null) return stored === "true"; + } catch { + // Fall through to default + } + return false; +} + +// Mock localStorage +const store: Record = {}; +const localStorageMock = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { for (const key in store) delete store[key]; }), + get length() { return Object.keys(store).length; }, + key: vi.fn((i: number) => Object.keys(store)[i] ?? null), +}; + +Object.defineProperty(globalThis, "localStorage", { value: localStorageMock }); + +describe("filter persistence", () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + describe("loadEnabledCategories", () => { + it("returns default categories when localStorage is empty", () => { + const result = loadEnabledCategories(); + const expected = new Set(ALL_CATEGORIES); + for (const cat of DEFAULT_HIDDEN_CATEGORIES) { + expected.delete(cat); + } + expect(result).toEqual(expected); + }); + + it("restores categories from localStorage", () => { + const saved: MessageCategory[] = ["user_message", "assistant_text"]; + store[STORAGE_KEY_CATEGORIES] = JSON.stringify(saved); + + const result = loadEnabledCategories(); + expect(result).toEqual(new Set(saved)); + }); + + it("filters out invalid category values from localStorage", () => { + const saved = ["user_message", "invalid_category", "thinking"]; + store[STORAGE_KEY_CATEGORIES] = JSON.stringify(saved); + + const result = loadEnabledCategories(); + expect(result.has("user_message")).toBe(true); + expect(result.has("thinking")).toBe(true); + expect(result.size).toBe(2); + }); + + it("falls back to defaults on corrupted localStorage data", () => { + store[STORAGE_KEY_CATEGORIES] = "not valid json"; + + const result = loadEnabledCategories(); + const expected = new Set(ALL_CATEGORIES); + for (const cat of DEFAULT_HIDDEN_CATEGORIES) { + expected.delete(cat); + } + expect(result).toEqual(expected); + }); + + it("falls back to defaults when stored array is all invalid", () => { + store[STORAGE_KEY_CATEGORIES] = JSON.stringify(["fake1", "fake2"]); + + const result = loadEnabledCategories(); + const expected = new Set(ALL_CATEGORIES); + for (const cat of DEFAULT_HIDDEN_CATEGORIES) { + expected.delete(cat); + } + expect(result).toEqual(expected); + }); + }); + + describe("loadAutoRedact", () => { + it("returns false when localStorage is empty", () => { + expect(loadAutoRedact()).toBe(false); + }); + + it("returns true when stored as 'true'", () => { + store[STORAGE_KEY_AUTOREDACT] = "true"; + expect(loadAutoRedact()).toBe(true); + }); + + it("returns false when stored as 'false'", () => { + store[STORAGE_KEY_AUTOREDACT] = "false"; + expect(loadAutoRedact()).toBe(false); + }); + + it("returns false for any non-'true' string", () => { + store[STORAGE_KEY_AUTOREDACT] = "yes"; + expect(loadAutoRedact()).toBe(false); + }); + }); +});