From e962a8ec7a00fde2b527816b28f6465d189cb646 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 3 Apr 2026 23:08:34 +0700 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20Phase=205=20=E2=80=94=20Zustand?= =?UTF-8?q?=20persist=20middleware=20+=20constants=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores: - use-auth-store: manual localStorage → persist() with partialize (token, userId, senderID) - use-ui-store: manual localStorage → persist() with partialize (theme, language, timezone, sidebarCollapsed) - use-team-event-store: manual persist helpers → persist() with partialize (last 20 events), onRehydrateStorage seeds counter Constants: - Split lib/constants.ts (144 lines) into: - lib/routes.ts (46 lines) — route paths - lib/timezone-utils.ts (74 lines) — IANA timezone data - lib/constants.ts (30 lines) — barrel re-export ForwardRef: skipped — all UI primitives already use React 19 ref-as-prop pattern. No behavior changes. Build passes. --- ui/web/src/lib/constants.ts | 129 ++-------------------- ui/web/src/lib/routes.ts | 46 ++++++++ ui/web/src/lib/timezone-utils.ts | 74 +++++++++++++ ui/web/src/stores/use-auth-store.ts | 127 +++++++++++---------- ui/web/src/stores/use-team-event-store.ts | 96 ++++++++-------- ui/web/src/stores/use-ui-store.ts | 74 +++++++------ 6 files changed, 282 insertions(+), 264 deletions(-) create mode 100644 ui/web/src/lib/routes.ts create mode 100644 ui/web/src/lib/timezone-utils.ts diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 8209b56d..580ce751 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -1,49 +1,11 @@ -export const ROUTES = { - LOGIN: "/login", - OVERVIEW: "/overview", - CHAT: "/chat", - CHAT_PATTERN: "/chat/:sessionKey?", - AGENTS: "/agents", - AGENT_DETAIL: "/agents/:id", - AGENT_CODEX_POOL: "/agents/:id/codex-pool", - IMPORT_EXPORT: "/import-export", - SESSIONS: "/sessions", - SESSION_DETAIL: "/sessions/:key", - SKILLS: "/skills", - SKILL_DETAIL: "/skills/:id", - CRON: "/cron", - CRON_DETAIL: "/cron/:id", - CONFIG: "/config", - TRACES: "/traces", - TRACE_DETAIL: "/traces/:id", - EVENTS: "/events", - USAGE: "/usage", - CHANNELS: "/channels", - CHANNEL_DETAIL: "/channels/:id", - CONTACTS: "/contacts", - APPROVALS: "/approvals", - NODES: "/nodes", - LOGS: "/logs", - PROVIDERS: "/providers", - PROVIDER_DETAIL: "/providers/:id", - TEAMS: "/teams", - TEAM_DETAIL: "/teams/:id", - BUILTIN_TOOLS: "/builtin-tools", - CLI_CREDENTIALS: "/cli-credentials", - MCP: "/mcp", - TTS: "/tts", - STORAGE: "/storage", - PENDING_MESSAGES: "/pending-messages", - MEMORY: "/memory", - KNOWLEDGE_GRAPH: "/knowledge-graph", - ACTIVITY: "/activity", - API_KEYS: "/api-keys", - PACKAGES: "/packages", - TENANTS: "/admin/tenants", - TENANT_DETAIL: "/admin/tenants/:id", - SETUP: "/setup", - SELECT_TENANT: "/select-tenant", -} as const; +// Barrel re-exports for backward compatibility. +// Import directly from sub-modules for new code. +export { ROUTES } from "./routes"; +export { + TIMEZONE_OPTIONS, + getAllIanaTimezones, + isValidIanaTimezone, +} from "./timezone-utils"; export const LOCAL_STORAGE_KEYS = { TOKEN: "goclaw:token", @@ -66,78 +28,3 @@ export const LANGUAGE_LABELS: Record = { vi: "Tiếng Việt", zh: "中文", }; - -/** "auto" = browser's local timezone. */ -export const TIMEZONE_OPTIONS = [ - { value: "auto", label: "Auto (Local)" }, - { value: "UTC", label: "UTC" }, - { value: "America/New_York", label: "New York (ET)" }, - { value: "America/Chicago", label: "Chicago (CT)" }, - { value: "America/Los_Angeles", label: "Los Angeles (PT)" }, - { value: "Europe/London", label: "London (GMT/BST)" }, - { value: "Europe/Paris", label: "Paris (CET)" }, - { value: "Asia/Tokyo", label: "Tokyo (JST)" }, - { value: "Asia/Shanghai", label: "Shanghai (CST)" }, - { value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh (ICT)" }, - { value: "Asia/Singapore", label: "Singapore (SGT)" }, - { value: "Australia/Sydney", label: "Sydney (AEST)" }, -] as const; - -/** Compute UTC offset string for a timezone, e.g. "UTC+7", "UTC-5:30" */ -function formatUtcOffset(tz: string): string { - const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date()); - const raw = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; - return raw.replace("GMT", "UTC").replace(/^UTC$/, "UTC+0"); -} - -function parseOffsetMinutes(offset: string): number { - const m = offset.match(/UTC([+-]?)(\d+)(?::(\d+))?/); - if (!m) return 0; - const sign = m[1] === "-" ? -1 : 1; - return sign * (parseInt(m[2]!, 10) * 60 + parseInt(m[3] ?? "0", 10)); -} - -type TzOption = { value: string; label: string }; -let _cachedTimezones: TzOption[] | undefined; - -/** Minimal fallback when Intl.supportedValuesOf is unavailable. */ -const FALLBACK_TIMEZONES: string[] = [ - "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", - "America/Halifax", "America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin", - "Europe/Moscow", "Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Ho_Chi_Minh", - "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Asia/Singapore", "Australia/Sydney", - "Pacific/Auckland", -]; - -/** All IANA timezones with dynamic UTC offsets, sorted by offset then name. */ -export function getAllIanaTimezones(): TzOption[] { - if (_cachedTimezones) return _cachedTimezones; - let tzNames: string[]; - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tzNames = (Intl as any).supportedValuesOf("timeZone") as string[]; - } catch { - tzNames = FALLBACK_TIMEZONES; - } - // Chrome may omit "UTC" from the list - if (!tzNames.includes("UTC")) tzNames = ["UTC", ...tzNames]; - const entries = tzNames.map((tz: string) => { - const offset = formatUtcOffset(tz); - return { value: tz, label: `${tz} (${offset})`, _offset: parseOffsetMinutes(offset) }; - }); - entries.sort((a: { _offset: number; value: string }, b: { _offset: number; value: string }) => - a._offset !== b._offset ? a._offset - b._offset : a.value.localeCompare(b.value), - ); - _cachedTimezones = entries.map(({ value, label }: { value: string; label: string }) => ({ value, label })); - return _cachedTimezones; -} - -let _cachedTzSet: Set | undefined; - -/** Check if a value is a valid IANA timezone from the dynamic list. */ -export function isValidIanaTimezone(tz: string): boolean { - if (!_cachedTzSet) { - _cachedTzSet = new Set(getAllIanaTimezones().map((t) => t.value)); - } - return _cachedTzSet.has(tz); -} diff --git a/ui/web/src/lib/routes.ts b/ui/web/src/lib/routes.ts new file mode 100644 index 00000000..52ce6f74 --- /dev/null +++ b/ui/web/src/lib/routes.ts @@ -0,0 +1,46 @@ +export const ROUTES = { + LOGIN: "/login", + OVERVIEW: "/overview", + CHAT: "/chat", + CHAT_PATTERN: "/chat/:sessionKey?", + AGENTS: "/agents", + AGENT_DETAIL: "/agents/:id", + AGENT_CODEX_POOL: "/agents/:id/codex-pool", + IMPORT_EXPORT: "/import-export", + SESSIONS: "/sessions", + SESSION_DETAIL: "/sessions/:key", + SKILLS: "/skills", + SKILL_DETAIL: "/skills/:id", + CRON: "/cron", + CRON_DETAIL: "/cron/:id", + CONFIG: "/config", + TRACES: "/traces", + TRACE_DETAIL: "/traces/:id", + EVENTS: "/events", + USAGE: "/usage", + CHANNELS: "/channels", + CHANNEL_DETAIL: "/channels/:id", + CONTACTS: "/contacts", + APPROVALS: "/approvals", + NODES: "/nodes", + LOGS: "/logs", + PROVIDERS: "/providers", + PROVIDER_DETAIL: "/providers/:id", + TEAMS: "/teams", + TEAM_DETAIL: "/teams/:id", + BUILTIN_TOOLS: "/builtin-tools", + CLI_CREDENTIALS: "/cli-credentials", + MCP: "/mcp", + TTS: "/tts", + STORAGE: "/storage", + PENDING_MESSAGES: "/pending-messages", + MEMORY: "/memory", + KNOWLEDGE_GRAPH: "/knowledge-graph", + ACTIVITY: "/activity", + API_KEYS: "/api-keys", + PACKAGES: "/packages", + TENANTS: "/admin/tenants", + TENANT_DETAIL: "/admin/tenants/:id", + SETUP: "/setup", + SELECT_TENANT: "/select-tenant", +} as const; diff --git a/ui/web/src/lib/timezone-utils.ts b/ui/web/src/lib/timezone-utils.ts new file mode 100644 index 00000000..0555176e --- /dev/null +++ b/ui/web/src/lib/timezone-utils.ts @@ -0,0 +1,74 @@ +/** "auto" = browser's local timezone. */ +export const TIMEZONE_OPTIONS = [ + { value: "auto", label: "Auto (Local)" }, + { value: "UTC", label: "UTC" }, + { value: "America/New_York", label: "New York (ET)" }, + { value: "America/Chicago", label: "Chicago (CT)" }, + { value: "America/Los_Angeles", label: "Los Angeles (PT)" }, + { value: "Europe/London", label: "London (GMT/BST)" }, + { value: "Europe/Paris", label: "Paris (CET)" }, + { value: "Asia/Tokyo", label: "Tokyo (JST)" }, + { value: "Asia/Shanghai", label: "Shanghai (CST)" }, + { value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh (ICT)" }, + { value: "Asia/Singapore", label: "Singapore (SGT)" }, + { value: "Australia/Sydney", label: "Sydney (AEST)" }, +] as const; + +/** Compute UTC offset string for a timezone, e.g. "UTC+7", "UTC-5:30" */ +function formatUtcOffset(tz: string): string { + const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date()); + const raw = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; + return raw.replace("GMT", "UTC").replace(/^UTC$/, "UTC+0"); +} + +function parseOffsetMinutes(offset: string): number { + const m = offset.match(/UTC([+-]?)(\d+)(?::(\d+))?/); + if (!m) return 0; + const sign = m[1] === "-" ? -1 : 1; + return sign * (parseInt(m[2]!, 10) * 60 + parseInt(m[3] ?? "0", 10)); +} + +type TzOption = { value: string; label: string }; +let _cachedTimezones: TzOption[] | undefined; + +/** Minimal fallback when Intl.supportedValuesOf is unavailable. */ +const FALLBACK_TIMEZONES: string[] = [ + "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Halifax", "America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin", + "Europe/Moscow", "Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Ho_Chi_Minh", + "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Asia/Singapore", "Australia/Sydney", + "Pacific/Auckland", +]; + +/** All IANA timezones with dynamic UTC offsets, sorted by offset then name. */ +export function getAllIanaTimezones(): TzOption[] { + if (_cachedTimezones) return _cachedTimezones; + let tzNames: string[]; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tzNames = (Intl as any).supportedValuesOf("timeZone") as string[]; + } catch { + tzNames = FALLBACK_TIMEZONES; + } + // Chrome may omit "UTC" from the list + if (!tzNames.includes("UTC")) tzNames = ["UTC", ...tzNames]; + const entries = tzNames.map((tz: string) => { + const offset = formatUtcOffset(tz); + return { value: tz, label: `${tz} (${offset})`, _offset: parseOffsetMinutes(offset) }; + }); + entries.sort((a: { _offset: number; value: string }, b: { _offset: number; value: string }) => + a._offset !== b._offset ? a._offset - b._offset : a.value.localeCompare(b.value), + ); + _cachedTimezones = entries.map(({ value, label }: { value: string; label: string }) => ({ value, label })); + return _cachedTimezones; +} + +let _cachedTzSet: Set | undefined; + +/** Check if a value is a valid IANA timezone from the dynamic list. */ +export function isValidIanaTimezone(tz: string): boolean { + if (!_cachedTzSet) { + _cachedTzSet = new Set(getAllIanaTimezones().map((t) => t.value)); + } + return _cachedTzSet.has(tz); +} diff --git a/ui/web/src/stores/use-auth-store.ts b/ui/web/src/stores/use-auth-store.ts index 4ccc146c..1519a57b 100644 --- a/ui/web/src/stores/use-auth-store.ts +++ b/ui/web/src/stores/use-auth-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { LOCAL_STORAGE_KEYS } from "@/lib/constants"; +import { persist } from "zustand/middleware"; import { clearSetupSkippedState } from "@/lib/setup-skip"; import type { TenantMembership } from "@/types/tenant"; @@ -29,63 +29,70 @@ interface AuthState { logout: () => void; } -export const useAuthStore = create((set) => ({ - token: localStorage.getItem(LOCAL_STORAGE_KEYS.TOKEN) ?? "", - userId: localStorage.getItem(LOCAL_STORAGE_KEYS.USER_ID) ?? "", - senderID: localStorage.getItem(LOCAL_STORAGE_KEYS.SENDER_ID) ?? "", - connected: false, - role: "" as UserRole, - serverInfo: null, - tenantId: "", - tenantName: "", - tenantSlug: "", - isOwner: false, - availableTenants: [], - tenantSelected: !!localStorage.getItem(LOCAL_STORAGE_KEYS.TENANT_ID), - - setCredentials: (token, userId) => { - localStorage.setItem(LOCAL_STORAGE_KEYS.TOKEN, token); - localStorage.setItem(LOCAL_STORAGE_KEYS.USER_ID, userId); - set({ token, userId }); - }, - - setPairing: (senderID, userId) => { - localStorage.setItem(LOCAL_STORAGE_KEYS.SENDER_ID, senderID); - localStorage.setItem(LOCAL_STORAGE_KEYS.USER_ID, userId); - set({ senderID, userId }); - }, - - setConnected: (connected, serverInfo) => { - set({ connected, serverInfo: serverInfo ?? null }); - }, - - setRole: (role) => { - set({ role }); - }, - - setTenant: (id, name, slug, isOwner) => { - set({ tenantId: id, tenantName: name, tenantSlug: slug, isOwner }); - }, - - setAvailableTenants: (tenants) => { - set({ availableTenants: tenants }); - }, - - setTenantSelected: (selected) => { - set({ tenantSelected: selected }); - }, - - logout: () => { - localStorage.removeItem(LOCAL_STORAGE_KEYS.TOKEN); - localStorage.removeItem(LOCAL_STORAGE_KEYS.USER_ID); - localStorage.removeItem(LOCAL_STORAGE_KEYS.SENDER_ID); - localStorage.removeItem(LOCAL_STORAGE_KEYS.TENANT_ID); - localStorage.removeItem(LOCAL_STORAGE_KEYS.TENANT_HINT); - clearSetupSkippedState(); - set({ - token: "", userId: "", senderID: "", connected: false, role: "", serverInfo: null, - tenantId: "", tenantName: "", tenantSlug: "", isOwner: false, availableTenants: [], +export const useAuthStore = create()( + persist( + (set) => ({ + token: "", + userId: "", + senderID: "", + connected: false, + role: "" as UserRole, + serverInfo: null, + tenantId: "", + tenantName: "", + tenantSlug: "", + isOwner: false, + availableTenants: [], tenantSelected: false, - }); - }, -})); + + setCredentials: (token, userId) => { + set({ token, userId }); + }, + + setPairing: (senderID, userId) => { + set({ senderID, userId }); + }, + + setConnected: (connected, serverInfo) => { + set({ connected, serverInfo: serverInfo ?? null }); + }, + + setRole: (role) => { + set({ role }); + }, + + setTenant: (id, name, slug, isOwner) => { + set({ tenantId: id, tenantName: name, tenantSlug: slug, isOwner }); + }, + + setAvailableTenants: (tenants) => { + set({ availableTenants: tenants }); + }, + + setTenantSelected: (selected) => { + set({ tenantSelected: selected }); + }, + + logout: () => { + // Remove tenant scope keys that are still managed outside persist + localStorage.removeItem("goclaw:tenant_id"); + localStorage.removeItem("goclaw:tenant_hint"); + clearSetupSkippedState(); + set({ + token: "", userId: "", senderID: "", connected: false, role: "", serverInfo: null, + tenantId: "", tenantName: "", tenantSlug: "", isOwner: false, availableTenants: [], + tenantSelected: false, + }); + }, + }), + { + name: "goclaw:auth", // localStorage key + partialize: (state) => ({ + // Only persist credentials — not transient runtime state + token: state.token, + userId: state.userId, + senderID: state.senderID, + }), + } + ) +); diff --git a/ui/web/src/stores/use-team-event-store.ts b/ui/web/src/stores/use-team-event-store.ts index 7514c76f..cd22461f 100644 --- a/ui/web/src/stores/use-team-event-store.ts +++ b/ui/web/src/stores/use-team-event-store.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; +import { persist } from "zustand/middleware"; const MAX_EVENTS = 500; -const PERSIST_KEY = "goclaw:recentEvents"; const PERSIST_MAX = 20; /** A single captured WS event entry */ @@ -52,56 +52,52 @@ function extractChatId(payload: unknown): string | null { return null; } -function loadPersistedEvents(): TeamEventEntry[] { - try { - const raw = localStorage.getItem(PERSIST_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as TeamEventEntry[]; - if (!Array.isArray(parsed)) return []; - return parsed.slice(-PERSIST_MAX); - } catch { - return []; - } -} +// Counter seeded from persisted state after hydration +let counter = 0; -function persistEvents(events: TeamEventEntry[]) { - try { - const recent = events.slice(-PERSIST_MAX); - localStorage.setItem(PERSIST_KEY, JSON.stringify(recent)); - } catch { - // storage full or unavailable — ignore - } -} +export const useTeamEventStore = create()( + persist( + (set) => ({ + events: [], + paused: false, -const initialEvents = loadPersistedEvents(); -let counter = initialEvents.length > 0 ? initialEvents[initialEvents.length - 1]!.id : 0; + addEvent: (event, payload) => { + set((s) => { + if (s.paused) return s; + const entry: TeamEventEntry = { + id: ++counter, + event, + payload, + timestamp: Date.now(), + teamId: extractTeamId(payload), + userId: extractUserId(payload), + chatId: extractChatId(payload), + }; + const next = [...s.events, entry]; + const trimmed = next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next; + return { events: trimmed }; + }); + }, -export const useTeamEventStore = create((set) => ({ - events: initialEvents, - paused: false, + clear: () => { + set({ events: [] }); + }, - addEvent: (event, payload) => { - set((s) => { - if (s.paused) return s; - const entry: TeamEventEntry = { - id: ++counter, - event, - payload, - timestamp: Date.now(), - teamId: extractTeamId(payload), - userId: extractUserId(payload), - chatId: extractChatId(payload), - }; - const next = [...s.events, entry]; - const trimmed = next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next; - persistEvents(trimmed); - return { events: trimmed }; - }); - }, - - clear: () => { - localStorage.removeItem(PERSIST_KEY); - set({ events: [] }); - }, - setPaused: (paused) => set({ paused }), -})); + setPaused: (paused) => set({ paused }), + }), + { + name: "goclaw:recentEvents", // keep existing localStorage key for backward compat + partialize: (state) => ({ + // Only persist the most recent events — not transient paused flag + events: state.events.slice(-PERSIST_MAX), + }), + onRehydrateStorage: () => (state) => { + // Seed the counter from the highest persisted event id + if (state?.events && state.events.length > 0) { + const lastEvent = state.events[state.events.length - 1]; + if (lastEvent) counter = lastEvent.id; + } + }, + } + ) +); diff --git a/ui/web/src/stores/use-ui-store.ts b/ui/web/src/stores/use-ui-store.ts index 9de916e6..31b88fdd 100644 --- a/ui/web/src/stores/use-ui-store.ts +++ b/ui/web/src/stores/use-ui-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; +import { persist } from "zustand/middleware"; import i18n from "@/i18n"; -import { LOCAL_STORAGE_KEYS, type Language } from "@/lib/constants"; +import { type Language } from "@/lib/constants"; export type Theme = "light" | "dark" | "system"; @@ -19,40 +20,47 @@ interface UiState { setMobileSidebarOpen: (open: boolean) => void; } -export const useUiStore = create((set) => ({ - theme: (localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as Theme) ?? "dark", - language: (i18n.language as Language) ?? "en", - timezone: localStorage.getItem(LOCAL_STORAGE_KEYS.TIMEZONE) ?? "auto", - sidebarCollapsed: - localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED) === "true", - mobileSidebarOpen: false, +export const useUiStore = create()( + persist( + (set, get) => ({ + theme: "dark" as Theme, + language: (i18n.language as Language) ?? "en", + timezone: "auto", + sidebarCollapsed: false, + mobileSidebarOpen: false, - setTheme: (theme) => { - localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, theme); - set({ theme }); - }, + setTheme: (theme) => { + set({ theme }); + }, - setLanguage: (language) => { - i18n.changeLanguage(language); - set({ language }); - }, + setLanguage: (language) => { + i18n.changeLanguage(language); + set({ language }); + }, - setTimezone: (tz) => { - localStorage.setItem(LOCAL_STORAGE_KEYS.TIMEZONE, tz); - set({ timezone: tz }); - }, + setTimezone: (tz) => { + set({ timezone: tz }); + }, - toggleSidebar: () => - set((state) => { - const next = !state.sidebarCollapsed; - localStorage.setItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED, String(next)); - return { sidebarCollapsed: next }; + toggleSidebar: () => { + set({ sidebarCollapsed: !get().sidebarCollapsed }); + }, + + setSidebarCollapsed: (collapsed) => { + set({ sidebarCollapsed: collapsed }); + }, + + setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }), }), - - setSidebarCollapsed: (collapsed) => { - localStorage.setItem(LOCAL_STORAGE_KEYS.SIDEBAR_COLLAPSED, String(collapsed)); - set({ sidebarCollapsed: collapsed }); - }, - - setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }), -})); + { + name: "goclaw:ui", // localStorage key + partialize: (state) => ({ + // Persist user preferences — not transient UI state + theme: state.theme, + language: state.language, + timezone: state.timezone, + sidebarCollapsed: state.sidebarCollapsed, + }), + } + ) +);