refactor(ui): Phase 5 — Zustand persist middleware + constants split

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.
This commit is contained in:
viettranx committed 2026-04-03 23:08:34 +07:00
1 parent 2ed810b854
commit e962a8ec7a
6 files changed
+282 -264

No files matched your search

+8 -121
View File
@@ -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<Language, string> = {
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<string> | 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);
}
+46
View File
@@ -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;
+74
View File
@@ -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<string> | 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);
}
+67 -60
View File
@@ -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<AuthState>((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<AuthState>()(
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,
}),
}
)
);
+46 -50
View File
@@ -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<TeamEventState>()(
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<TeamEventState>((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;
}
},
}
)
);
+41 -33
View File
@@ -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<UiState>((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<UiState>()(
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,
}),
}
)
);