diff --git a/ui/src/components/agents/AgentsSidebar.tsx b/ui/src/components/agents/AgentsSidebar.tsx index 43bda382..1ba8a3dc 100644 --- a/ui/src/components/agents/AgentsSidebar.tsx +++ b/ui/src/components/agents/AgentsSidebar.tsx @@ -27,6 +27,7 @@ import type { SessionUser } from "@/lib/api" import type { AgentSource, AgentThread } from "@/lib/agents/types" import type { SidebarLayout } from "@/components/sidebar-layout" import { SidebarUserMenu } from "@/components/SidebarUserMenu" +import { SidebarFilterMenu } from "@/components/agents/SidebarFilterMenu" import { Button } from "@/components/ui/button" import { SidebarCollapseButton, @@ -34,7 +35,13 @@ import { SidebarLayoutProvider, useSidebarLayout, } from "@/components/sidebar-layout" -import { groupThreads } from "@/lib/agents/api" +import { + availableFacets, + filterThreads, + groupThreadsByMode, + hasActiveFilters, +} from "@/lib/agents/sidebarFilter" +import { useSidebarPrefs } from "@/lib/agents/sidebarPrefs" import { useDeleteAgentThread, useResolveAgentThread, @@ -101,6 +108,8 @@ export function AgentsSidebar({ activeThreadId, layout, }: AgentsSidebarProps) { + const { prefs, setGroup, setCompact, setFilters, resetFilters } = + useSidebarPrefs() const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT) const activeThreads = sidebar.data?.active.items ?? [] const resolvedThreads = sidebar.data?.resolved.items ?? [] @@ -108,7 +117,16 @@ export function AgentsSidebar({ const visibleThreads = [...activeThreads, ...resolvedThreads] useSeedAgentThreadDetails(visibleThreads, activeThreadId) useRunCompletionNotifier(visibleThreads, activeThreadId) - const groups = groupThreads(activeThreads) + + const facets = availableFacets(visibleThreads) + const filteredActive = filterThreads(activeThreads, prefs.filters) + const filteredResolved = filterThreads(resolvedThreads, prefs.filters) + const sections = groupThreadsByMode(filteredActive, prefs.group) + const showResolved = prefs.filters.includeResolved + const isEmpty = + sections.length === 0 && + (!showResolved || filteredResolved.length === 0) && + hasActiveFilters(prefs.filters) return (
- - - - - + {prefs.group === "none" + ? sections[0]?.threads.map((thread) => ( + + )) + : sections.map((section) => ( + + ))} + {showResolved && ( + + )} + {isEmpty && ( +

+ No threads match these filters. +

+ )}
-
- +
+
+ +
+
) @@ -206,12 +237,14 @@ function ThreadGroup({ activeThreadId, onNavigate, defaultCollapsed = false, + compact = false, }: { label: string threads: Array activeThreadId?: string onNavigate?: () => void defaultCollapsed?: boolean + compact?: boolean }) { const [collapsed, setCollapsed] = useState(defaultCollapsed) if (threads.length === 0) return null @@ -219,7 +252,7 @@ function ThreadGroup({ const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon return ( -
+
@@ -248,11 +282,13 @@ function ResolvedThreadGroup({ hasMore, activeThreadId, onNavigate, + compact = false, }: { threads: Array hasMore: boolean activeThreadId?: string onNavigate?: () => void + compact?: boolean }) { const [collapsed, setCollapsed] = useState(true) if (threads.length === 0) return null @@ -283,6 +319,7 @@ function ResolvedThreadGroup({ thread={thread} isActive={thread.id === activeThreadId} onNavigate={onNavigate} + compact={compact} /> ))} {hasMore && ( @@ -305,10 +342,12 @@ function ThreadRow({ thread, isActive, onNavigate, + compact = false, }: { thread: AgentThread isActive: boolean onNavigate?: () => void + compact?: boolean }) { const deleteThread = useDeleteAgentThread() const resolveThread = useResolveAgentThread() @@ -367,7 +406,8 @@ function ThreadRow({ params={{ threadId: thread.id }} onClick={onNavigate} className={cn( - "group mb-0.5 flex h-8 items-center gap-2 rounded-lg px-2.5 transition-colors", + "group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 transition-colors", + compact ? "h-7 gap-1.5" : "h-8", isActive ? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]" : "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]", @@ -405,7 +445,7 @@ function ThreadRow({ {thread.title} - {prMeta && PrIcon && ( + {!compact && prMeta && PrIcon && ( {prMeta.label} )} - {badge && ( + {!compact && badge && ( {badge} diff --git a/ui/src/components/agents/SidebarFilterMenu.tsx b/ui/src/components/agents/SidebarFilterMenu.tsx new file mode 100644 index 00000000..fe5ce88b --- /dev/null +++ b/ui/src/components/agents/SidebarFilterMenu.tsx @@ -0,0 +1,317 @@ +import { Menu } from "@base-ui/react/menu" +import { CaretRightIcon, CheckIcon, FunnelIcon } from "@phosphor-icons/react" + +import type { AgentSource, AgentStatus } from "@/lib/agents/types" +import type { + PrFilter, + SidebarFacets, + SidebarFilters, + SidebarGroupMode, +} from "@/lib/agents/sidebarFilter" +import type { SidebarPrefs } from "@/lib/agents/sidebarPrefs" +import { + GROUP_MODE_OPTIONS, + OWNERSHIP_OPTIONS, + PR_FILTER_OPTIONS, + SOURCE_FILTER_OPTIONS, + STATUS_FILTER_OPTIONS, + hasActiveFilters, + toggleArrayValue, +} from "@/lib/agents/sidebarFilter" +import { cn } from "@/lib/utils" + +const POPUP_CLASS = + "z-50 min-w-[12rem] origin-(--transform-origin) overflow-hidden rounded-md border border-[var(--ui-border)] bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95" + +const ITEM_CLASS = + "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50" + +const LABEL_CLASS = + "px-2 py-1 text-[10px] font-medium tracking-wide text-[var(--ui-text-dim)] uppercase" + +const SEPARATOR_CLASS = "my-1 h-px bg-[var(--ui-border)]" + +function Indicator() { + return +} + +function CountBadge({ count }: { count: number }) { + if (count <= 0) return null + return ( + + {count} + + ) +} + +function CheckboxSubmenu({ + label, + options, + selected, + onToggle, +}: { + label: string + options: Array<{ value: string; label: string }> + selected: Array + onToggle: (value: string) => void +}) { + const disabled = options.length === 0 + return ( + + + {label} + {selected.length > 0 ? ( + + ) : ( + + )} + + + + + {options.map((option) => ( + onToggle(option.value)} + closeOnClick={false} + className={ITEM_CLASS} + > + {option.label} + + + + + ))} + + + + + ) +} + +export interface SidebarFilterMenuProps { + prefs: SidebarPrefs + facets: SidebarFacets + onGroupChange: (mode: SidebarGroupMode) => void + onFiltersChange: (filters: SidebarFilters) => void + onCompactChange: (compact: boolean) => void + onResetFilters: () => void +} + +export function SidebarFilterMenu({ + prefs, + facets, + onGroupChange, + onFiltersChange, + onCompactChange, + onResetFilters, +}: SidebarFilterMenuProps) { + const { filters } = prefs + const active = hasActiveFilters(filters) + + const patch = (next: Partial) => + onFiltersChange({ ...filters, ...next }) + + return ( + + + + {active && ( + + )} + + } + /> + + + +
Group
+ + onGroupChange(value as SidebarGroupMode) + } + > + {GROUP_MODE_OPTIONS.map((option) => ( + + {option.label} + + + + + ))} + + + + + + + Filter + + + + + + + patch({ + ownership: value as SidebarFilters["ownership"], + }) + } + > + {OWNERSHIP_OPTIONS.map((option) => ( + + {option.label} + + + + + ))} + + + + + + patch({ + statuses: toggleArrayValue( + filters.statuses, + value as AgentStatus + ), + }) + } + /> + + patch({ + sources: toggleArrayValue( + filters.sources, + value as AgentSource + ), + }) + } + /> + + patch({ + pr: toggleArrayValue(filters.pr, value as PrFilter), + }) + } + /> + ({ + value: m, + label: m, + }))} + selected={filters.models} + onToggle={(value) => + patch({ + models: toggleArrayValue(filters.models, value), + }) + } + /> + ({ + value: r, + label: r, + }))} + selected={filters.repos} + onToggle={(value) => + patch({ repos: toggleArrayValue(filters.repos, value) }) + } + /> + + + + + patch({ includeResolved: checked }) + } + closeOnClick={false} + className={ITEM_CLASS} + > + Include resolved + + + + + + + + + Reset filters + + + + + + + + + onCompactChange(checked)} + closeOnClick={false} + className={ITEM_CLASS} + > + Compact + + + + +
+
+
+
+ ) +} diff --git a/ui/src/lib/agents/sidebarFilter.test.ts b/ui/src/lib/agents/sidebarFilter.test.ts new file mode 100644 index 00000000..bb6e3e05 --- /dev/null +++ b/ui/src/lib/agents/sidebarFilter.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest" + +import { + DEFAULT_SIDEBAR_FILTERS, + availableFacets, + filterThreads, + groupThreadsByMode, + hasActiveFilters, + toggleArrayValue, +} from "./sidebarFilter" +import type { SidebarFilters } from "./sidebarFilter" +import type { AgentThread } from "./types" + +const DAY = 24 * 60 * 60 * 1000 + +function makeThread(overrides: Partial = {}): AgentThread { + return { + id: Math.random().toString(36).slice(2), + title: "Thread", + repo: "repo", + repoFullName: "acme/repo", + branch: "main", + model: "gpt-5", + source: "dashboard", + status: "idle", + viewed: true, + isOwner: true, + createdAt: Date.now(), + updatedAt: Date.now(), + messages: [], + ...overrides, + } +} + +function filters(overrides: Partial = {}): SidebarFilters { + return { ...DEFAULT_SIDEBAR_FILTERS, ...overrides } +} + +describe("filterThreads", () => { + it("returns all threads with default filters", () => { + const threads = [makeThread(), makeThread()] + expect(filterThreads(threads, DEFAULT_SIDEBAR_FILTERS)).toHaveLength(2) + }) + + it("filters by ownership", () => { + const mine = makeThread({ isOwner: true }) + const shared = makeThread({ isOwner: false }) + const unknown = makeThread({ isOwner: undefined }) + const all = [mine, shared, unknown] + expect(filterThreads(all, filters({ ownership: "mine" }))).toEqual([ + mine, + unknown, + ]) + expect(filterThreads(all, filters({ ownership: "shared" }))).toEqual([ + shared, + ]) + }) + + it("filters by status (multi-select)", () => { + const running = makeThread({ status: "running" }) + const finished = makeThread({ status: "finished" }) + const idle = makeThread({ status: "idle" }) + const result = filterThreads( + [running, finished, idle], + filters({ statuses: ["running", "finished"] }) + ) + expect(result).toEqual([running, finished]) + }) + + it("filters by source, defaulting missing source to dashboard", () => { + const gh = makeThread({ source: "github" }) + const noSource = makeThread({ source: undefined }) + expect( + filterThreads([gh, noSource], filters({ sources: ["dashboard"] })) + ).toEqual([noSource]) + expect( + filterThreads([gh, noSource], filters({ sources: ["github"] })) + ).toEqual([gh]) + }) + + it("filters by pull-request state including 'none'", () => { + const open = makeThread({ + pr: { + number: 1, + title: "x", + state: "open", + headRef: "h", + baseRef: "main", + url: "u", + }, + }) + const noPr = makeThread({ pr: undefined }) + expect(filterThreads([open, noPr], filters({ pr: ["open"] }))).toEqual([ + open, + ]) + expect(filterThreads([open, noPr], filters({ pr: ["none"] }))).toEqual([ + noPr, + ]) + }) + + it("filters by model and repo", () => { + const a = makeThread({ model: "gpt-5", repoFullName: "acme/a" }) + const b = makeThread({ model: "claude", repoFullName: "acme/b" }) + expect(filterThreads([a, b], filters({ models: ["claude"] }))).toEqual([b]) + expect(filterThreads([a, b], filters({ repos: ["acme/a"] }))).toEqual([a]) + }) +}) + +describe("availableFacets", () => { + it("returns distinct sorted models and repos, skipping empties", () => { + const threads = [ + makeThread({ model: "gpt-5", repoFullName: "acme/b" }), + makeThread({ model: "claude", repoFullName: "acme/a" }), + makeThread({ model: "gpt-5", repoFullName: "" }), + ] + const facets = availableFacets(threads) + expect(facets.models).toEqual(["claude", "gpt-5"]) + expect(facets.repos).toEqual(["acme/a", "acme/b"]) + }) +}) + +describe("groupThreadsByMode", () => { + it("returns an empty array for no threads", () => { + expect(groupThreadsByMode([], "date")).toEqual([]) + }) + + it("groups everything into one section for 'none'", () => { + const sections = groupThreadsByMode([makeThread(), makeThread()], "none") + expect(sections).toHaveLength(1) + expect(sections[0]?.key).toBe("all") + expect(sections[0]?.threads).toHaveLength(2) + }) + + it("buckets by date and drops empty buckets", () => { + const now = Date.now() + const sections = groupThreadsByMode( + [ + makeThread({ updatedAt: now }), + makeThread({ updatedAt: now - 3 * DAY }), + makeThread({ updatedAt: now - 40 * DAY }), + ], + "date" + ) + expect(sections.map((s) => s.key)).toEqual(["today", "last7", "older"]) + expect(sections.find((s) => s.key === "last7")?.defaultCollapsed).toBe(true) + expect(sections.find((s) => s.key === "today")?.defaultCollapsed).toBe( + false + ) + }) + + it("groups by status in a fixed order", () => { + const sections = groupThreadsByMode( + [ + makeThread({ status: "idle" }), + makeThread({ status: "running" }), + makeThread({ status: "error" }), + ], + "status" + ) + expect(sections.map((s) => s.key)).toEqual(["running", "error", "idle"]) + }) + + it("groups by repo alphabetically with a fallback label", () => { + const sections = groupThreadsByMode( + [ + makeThread({ repoFullName: "acme/z" }), + makeThread({ repoFullName: "acme/a" }), + makeThread({ repoFullName: "" }), + ], + "repo" + ) + expect(sections.map((s) => s.label)).toEqual([ + "acme/a", + "acme/z", + "No repository", + ]) + }) + + it("sorts threads within a section by recency", () => { + const older = makeThread({ status: "idle", updatedAt: 1 }) + const newer = makeThread({ status: "idle", updatedAt: 2 }) + const [section] = groupThreadsByMode([older, newer], "status") + expect(section?.threads).toEqual([newer, older]) + }) +}) + +describe("hasActiveFilters", () => { + it("is false for defaults", () => { + expect(hasActiveFilters(DEFAULT_SIDEBAR_FILTERS)).toBe(false) + }) + + it("is true when any dimension changes", () => { + expect(hasActiveFilters(filters({ ownership: "mine" }))).toBe(true) + expect(hasActiveFilters(filters({ statuses: ["running"] }))).toBe(true) + expect(hasActiveFilters(filters({ includeResolved: false }))).toBe(true) + }) +}) + +describe("toggleArrayValue", () => { + it("adds a missing value and removes a present one", () => { + expect(toggleArrayValue(["a"], "b")).toEqual(["a", "b"]) + expect(toggleArrayValue(["a", "b"], "a")).toEqual(["b"]) + }) +}) diff --git a/ui/src/lib/agents/sidebarFilter.ts b/ui/src/lib/agents/sidebarFilter.ts new file mode 100644 index 00000000..ecab348b --- /dev/null +++ b/ui/src/lib/agents/sidebarFilter.ts @@ -0,0 +1,278 @@ +import { groupThreads } from "./api" +import type { AgentSource, AgentStatus, AgentThread } from "./types" + +export type SidebarGroupMode = "none" | "date" | "status" | "repo" + +export type SidebarOwnership = "all" | "mine" | "shared" + +export type PrFilter = "none" | "draft" | "open" | "merged" | "closed" + +export interface SidebarFilters { + ownership: SidebarOwnership + statuses: Array + sources: Array + pr: Array + models: Array + repos: Array + includeResolved: boolean +} + +export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = { + ownership: "all", + statuses: [], + sources: [], + pr: [], + models: [], + repos: [], + includeResolved: true, +} + +export const GROUP_MODE_OPTIONS: Array<{ + value: SidebarGroupMode + label: string +}> = [ + { value: "repo", label: "Project" }, + { value: "date", label: "Date" }, + { value: "status", label: "Status" }, + { value: "none", label: "None" }, +] + +export const OWNERSHIP_OPTIONS: Array<{ + value: SidebarOwnership + label: string +}> = [ + { value: "all", label: "All agents" }, + { value: "mine", label: "My agents" }, + { value: "shared", label: "Shared with me" }, +] + +export const STATUS_FILTER_OPTIONS: Array<{ + value: AgentStatus + label: string +}> = [ + { value: "running", label: "Running" }, + { value: "finished", label: "Finished" }, + { value: "interrupted", label: "Interrupted" }, + { value: "error", label: "Error" }, + { value: "idle", label: "Idle" }, +] + +export const SOURCE_FILTER_OPTIONS: Array<{ + value: AgentSource + label: string +}> = [ + { value: "dashboard", label: "Dashboard" }, + { value: "github", label: "GitHub" }, + { value: "slack", label: "Slack" }, + { value: "linear", label: "Linear" }, + { value: "schedule", label: "Schedule" }, +] + +export const PR_FILTER_OPTIONS: Array<{ value: PrFilter; label: string }> = [ + { value: "none", label: "No pull request" }, + { value: "draft", label: "Draft" }, + { value: "open", label: "Open" }, + { value: "merged", label: "Merged" }, + { value: "closed", label: "Closed" }, +] + +function threadSource(thread: AgentThread): AgentSource { + return thread.source ?? "dashboard" +} + +function threadPr(thread: AgentThread): PrFilter { + return thread.pr ? thread.pr.state : "none" +} + +/** Apply the active filter dimensions to a list of threads. */ +export function filterThreads( + threads: Array, + filters: SidebarFilters +): Array { + return threads.filter((thread) => { + if (filters.ownership === "mine" && thread.isOwner === false) return false + if (filters.ownership === "shared" && thread.isOwner !== false) return false + if ( + filters.statuses.length > 0 && + !filters.statuses.includes(thread.status) + ) { + return false + } + if ( + filters.sources.length > 0 && + !filters.sources.includes(threadSource(thread)) + ) { + return false + } + if (filters.pr.length > 0 && !filters.pr.includes(threadPr(thread))) { + return false + } + if (filters.models.length > 0 && !filters.models.includes(thread.model)) { + return false + } + if ( + filters.repos.length > 0 && + !filters.repos.includes(thread.repoFullName) + ) { + return false + } + return true + }) +} + +export interface SidebarFacets { + models: Array + repos: Array +} + +/** Distinct model + repo values present in the given threads (for the filter submenus). */ +export function availableFacets(threads: Array): SidebarFacets { + const models = new Set() + const repos = new Set() + for (const thread of threads) { + if (thread.model) models.add(thread.model) + if (thread.repoFullName) repos.add(thread.repoFullName) + } + return { + models: [...models].sort((a, b) => a.localeCompare(b)), + repos: [...repos].sort((a, b) => a.localeCompare(b)), + } +} + +export interface ThreadGroupSection { + key: string + label: string + threads: Array + defaultCollapsed: boolean +} + +const STATUS_GROUP_ORDER: Array = [ + "running", + "finished", + "interrupted", + "error", + "idle", +] + +const STATUS_GROUP_LABEL: Record = { + running: "Running", + finished: "Finished", + interrupted: "Interrupted", + error: "Error", + idle: "Idle", +} + +function sortedByRecency(threads: Array): Array { + return [...threads].sort((a, b) => b.updatedAt - a.updatedAt) +} + +/** Split threads into ordered, labelled sections according to the group mode. */ +export function groupThreadsByMode( + threads: Array, + mode: SidebarGroupMode +): Array { + if (threads.length === 0) return [] + + if (mode === "none") { + return [ + { + key: "all", + label: "All", + threads: sortedByRecency(threads), + defaultCollapsed: false, + }, + ] + } + + if (mode === "date") { + const groups = groupThreads(threads) + return ( + [ + { + key: "today", + label: "Today", + threads: groups.today, + collapsed: false, + }, + { + key: "last7", + label: "Last 7 days", + threads: groups.last7, + collapsed: true, + }, + { + key: "last30", + label: "Last 30 days", + threads: groups.last30, + collapsed: true, + }, + { + key: "older", + label: "Older", + threads: groups.older, + collapsed: false, + }, + ] as const + ) + .filter((section) => section.threads.length > 0) + .map((section) => ({ + key: section.key, + label: section.label, + threads: section.threads, + defaultCollapsed: section.collapsed, + })) + } + + if (mode === "status") { + const byStatus = new Map>() + for (const thread of threads) { + const list = byStatus.get(thread.status) ?? [] + list.push(thread) + byStatus.set(thread.status, list) + } + return STATUS_GROUP_ORDER.filter((status) => byStatus.has(status)).map( + (status) => ({ + key: status, + label: STATUS_GROUP_LABEL[status], + threads: sortedByRecency(byStatus.get(status) ?? []), + defaultCollapsed: false, + }) + ) + } + + const byRepo = new Map>() + for (const thread of threads) { + const key = thread.repoFullName || "No repository" + const list = byRepo.get(key) ?? [] + list.push(thread) + byRepo.set(key, list) + } + return [...byRepo.keys()] + .sort((a, b) => a.localeCompare(b)) + .map((repo) => ({ + key: repo, + label: repo, + threads: sortedByRecency(byRepo.get(repo) ?? []), + defaultCollapsed: false, + })) +} + +/** True when any filter dimension differs from the defaults. */ +export function hasActiveFilters(filters: SidebarFilters): boolean { + return ( + filters.ownership !== DEFAULT_SIDEBAR_FILTERS.ownership || + filters.statuses.length > 0 || + filters.sources.length > 0 || + filters.pr.length > 0 || + filters.models.length > 0 || + filters.repos.length > 0 || + filters.includeResolved !== DEFAULT_SIDEBAR_FILTERS.includeResolved + ) +} + +/** Toggle membership of a value within a filter array (immutable). */ +export function toggleArrayValue(values: Array, value: T): Array { + return values.includes(value) + ? values.filter((v) => v !== value) + : [...values, value] +} diff --git a/ui/src/lib/agents/sidebarPrefs.ts b/ui/src/lib/agents/sidebarPrefs.ts new file mode 100644 index 00000000..d254a826 --- /dev/null +++ b/ui/src/lib/agents/sidebarPrefs.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useState } from "react" + +import { + DEFAULT_SIDEBAR_FILTERS, + type SidebarFilters, + type SidebarGroupMode, +} from "./sidebarFilter" + +const STORAGE_KEY = "open-swe.agents.sidebar-prefs" + +const GROUP_MODES: ReadonlyArray = [ + "none", + "date", + "status", + "repo", +] + +export interface SidebarPrefs { + group: SidebarGroupMode + compact: boolean + filters: SidebarFilters +} + +export const DEFAULT_SIDEBAR_PREFS: SidebarPrefs = { + group: "date", + compact: false, + filters: DEFAULT_SIDEBAR_FILTERS, +} + +function asStringArray(value: unknown): Array { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === "string") + : [] +} + +function sanitizeFilters(value: unknown): SidebarFilters { + const raw = + value && typeof value === "object" ? (value as Record) : {} + const ownership = raw.ownership + return { + ownership: + ownership === "mine" || ownership === "shared" || ownership === "all" + ? ownership + : DEFAULT_SIDEBAR_FILTERS.ownership, + statuses: asStringArray(raw.statuses) as SidebarFilters["statuses"], + sources: asStringArray(raw.sources) as SidebarFilters["sources"], + pr: asStringArray(raw.pr) as SidebarFilters["pr"], + models: asStringArray(raw.models), + repos: asStringArray(raw.repos), + includeResolved: + typeof raw.includeResolved === "boolean" + ? raw.includeResolved + : DEFAULT_SIDEBAR_FILTERS.includeResolved, + } +} + +function sanitizePrefs(value: unknown): SidebarPrefs { + const raw = + value && typeof value === "object" ? (value as Record) : {} + const group = raw.group + return { + group: GROUP_MODES.includes(group as SidebarGroupMode) + ? (group as SidebarGroupMode) + : DEFAULT_SIDEBAR_PREFS.group, + compact: + typeof raw.compact === "boolean" + ? raw.compact + : DEFAULT_SIDEBAR_PREFS.compact, + filters: sanitizeFilters(raw.filters), + } +} + +function loadPrefs(): SidebarPrefs { + if (typeof window === "undefined") return DEFAULT_SIDEBAR_PREFS + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULT_SIDEBAR_PREFS + return sanitizePrefs(JSON.parse(raw)) + } catch { + return DEFAULT_SIDEBAR_PREFS + } +} + +export function useSidebarPrefs() { + const [prefs, setPrefs] = useState(loadPrefs) + + useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) + } catch { + /* ignore persistence failures (private mode, quota, SSR) */ + } + }, [prefs]) + + const setGroup = useCallback( + (group: SidebarGroupMode) => setPrefs((prev) => ({ ...prev, group })), + [] + ) + const setCompact = useCallback( + (compact: boolean) => setPrefs((prev) => ({ ...prev, compact })), + [] + ) + const setFilters = useCallback( + (filters: SidebarFilters) => setPrefs((prev) => ({ ...prev, filters })), + [] + ) + const resetFilters = useCallback( + () => + setPrefs((prev) => ({ + ...prev, + filters: { ...DEFAULT_SIDEBAR_FILTERS }, + })), + [] + ) + + return { prefs, setGroup, setCompact, setFilters, resetFilters } +} + +export type UseSidebarPrefs = ReturnType