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 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:35:08 -05:00
parent a8b602fbde
commit 4027dd65be
2 changed files with 186 additions and 9 deletions

View File

@@ -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<MessageCategory> {
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<MessageCategory>;
toggleCategory: (cat: MessageCategory) => void;
@@ -20,13 +53,7 @@ interface FilterState {
export function useFilters(): FilterState {
const [enabledCategories, setEnabledCategories] = useState<
Set<MessageCategory>
>(() => {
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<Set<string>>(new Set());
@@ -34,7 +61,28 @@ export function useFilters(): FilterState {
Set<string>
>(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) => {

View File

@@ -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<MessageCategory> {
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<string, string> = {};
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);
});
});
});