feat(01-01): create Zustand store with 6 slices, IPC batcher, and unit tests
- Install zustand 5.x and immer 11.x dependencies - Create 6 Zustand slices: navigation, company, selection, ui, communication, social - Create combined store with devtools + immer middleware - Create IPC event batcher with 50ms debounce window (schedule/flush/dispose) - Create typed selector hooks for narrow state subscriptions - Add 14 unit tests covering store initialization, slice behaviors, and batcher timing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a4de64b876
commit
5e9d36c058
13 files changed
+818
-4
No files matched your search
@@ -0,0 +1,30 @@
|
||||
type BatchCallback = () => void;
|
||||
|
||||
export function createEventBatcher(callback: BatchCallback, windowMs = 50) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function schedule() {
|
||||
if (timer !== null) return; // already scheduled -- coalesce
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
callback();
|
||||
}, windowMs);
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { schedule, flush, dispose };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useAppStore } from "./store";
|
||||
|
||||
// Navigation slice selectors
|
||||
export const useSection = () => useAppStore((s) => s.section);
|
||||
export const useSetSection = () => useAppStore((s) => s.setSection);
|
||||
export const useNavigateTo = () => useAppStore((s) => s.navigateTo);
|
||||
export const useCommandPaletteOpen = () =>
|
||||
useAppStore((s) => s.commandPaletteOpen);
|
||||
export const useConsoleOpen = () => useAppStore((s) => s.consoleOpen);
|
||||
export const useSidebarCollapsed = () =>
|
||||
useAppStore((s) => s.sidebarCollapsed);
|
||||
|
||||
// Company slice selectors
|
||||
export const useSnapshot = () => useAppStore((s) => s.snapshot);
|
||||
export const useCurrentCompanyId = () =>
|
||||
useAppStore((s) => s.currentCompanyId);
|
||||
export const useError = () => useAppStore((s) => s.error);
|
||||
export const useInboxItems = () => useAppStore((s) => s.inboxItems);
|
||||
export const useCompanyMetrics = () => useAppStore((s) => s.companyMetrics);
|
||||
export const useStandupReport = () => useAppStore((s) => s.standupReport);
|
||||
export const useBadges = () => useAppStore((s) => s.badges);
|
||||
|
||||
// Selection slice selectors
|
||||
export const useSelectedTaskId = () => useAppStore((s) => s.selectedTaskId);
|
||||
export const useSelectedAgentId = () =>
|
||||
useAppStore((s) => s.selectedAgentId);
|
||||
export const useSelectedApprovalId = () =>
|
||||
useAppStore((s) => s.selectedApprovalId);
|
||||
export const useSelectedRunId = () => useAppStore((s) => s.selectedRunId);
|
||||
export const useSelectedGoalId = () => useAppStore((s) => s.selectedGoalId);
|
||||
export const useSelectedProjectId = () =>
|
||||
useAppStore((s) => s.selectedProjectId);
|
||||
export const useSelectedWorkspaceId = () =>
|
||||
useAppStore((s) => s.selectedWorkspaceId);
|
||||
export const useSelectedConnectorId = () =>
|
||||
useAppStore((s) => s.selectedConnectorId);
|
||||
export const useSelectedCommunicationEntityId = () =>
|
||||
useAppStore((s) => s.selectedCommunicationEntityId);
|
||||
|
||||
// UI slice selectors
|
||||
export const useLocale = () => useAppStore((s) => s.locale);
|
||||
export const useShowOnboarding = () => useAppStore((s) => s.showOnboarding);
|
||||
export const useTaskViewMode = () => useAppStore((s) => s.taskViewMode);
|
||||
|
||||
// Social slice selectors
|
||||
export const useSocialAccounts = () => useAppStore((s) => s.socialAccounts);
|
||||
export const useBrowserActions = () => useAppStore((s) => s.browserActions);
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
|
||||
export interface CommunicationSlice {
|
||||
// Placeholder for future message state -- this slice will be populated
|
||||
// during component decomposition. The communication component currently
|
||||
// manages its own internal state.
|
||||
}
|
||||
|
||||
export const createCommunicationSlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
CommunicationSlice
|
||||
> = () => ({
|
||||
// Empty state for now
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
import type {
|
||||
CompanyMetrics,
|
||||
InboxItem,
|
||||
ProfileSnapshot,
|
||||
StandupReport,
|
||||
} from "@shared/types";
|
||||
import type { SidebarBadges } from "@shared/contracts";
|
||||
|
||||
export interface CompanySlice {
|
||||
snapshot: ProfileSnapshot | null;
|
||||
currentCompanyId: string | null;
|
||||
companyMetrics: CompanyMetrics | null;
|
||||
standupReport: StandupReport | null;
|
||||
inboxItems: InboxItem[];
|
||||
badges: SidebarBadges | null;
|
||||
error: string | null;
|
||||
setSnapshot: (snapshot: ProfileSnapshot | null) => void;
|
||||
setCurrentCompanyId: (id: string | null) => void;
|
||||
setCompanyMetrics: (metrics: CompanyMetrics | null) => void;
|
||||
setStandupReport: (report: StandupReport | null) => void;
|
||||
setInboxItems: (items: InboxItem[]) => void;
|
||||
setBadges: (badges: SidebarBadges | null) => void;
|
||||
setError: (error: string | null) => void;
|
||||
refreshSnapshot: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const createCompanySlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
CompanySlice
|
||||
> = (set) => ({
|
||||
snapshot: null,
|
||||
currentCompanyId: null,
|
||||
companyMetrics: null,
|
||||
standupReport: null,
|
||||
inboxItems: [],
|
||||
badges: null,
|
||||
error: null,
|
||||
setSnapshot: (snapshot) =>
|
||||
set((state) => {
|
||||
state.snapshot = snapshot;
|
||||
}),
|
||||
setCurrentCompanyId: (id) =>
|
||||
set((state) => {
|
||||
state.currentCompanyId = id;
|
||||
}),
|
||||
setCompanyMetrics: (metrics) =>
|
||||
set((state) => {
|
||||
state.companyMetrics = metrics;
|
||||
}),
|
||||
setStandupReport: (report) =>
|
||||
set((state) => {
|
||||
state.standupReport = report;
|
||||
}),
|
||||
setInboxItems: (items) =>
|
||||
set((state) => {
|
||||
state.inboxItems = items;
|
||||
}),
|
||||
setBadges: (badges) =>
|
||||
set((state) => {
|
||||
state.badges = badges;
|
||||
}),
|
||||
setError: (error) =>
|
||||
set((state) => {
|
||||
state.error = error;
|
||||
}),
|
||||
refreshSnapshot: async () => {
|
||||
try {
|
||||
const result = await window.agentCompany.loadSnapshot();
|
||||
if (result.ok) {
|
||||
set((state) => {
|
||||
state.snapshot = result.data;
|
||||
});
|
||||
} else {
|
||||
set((state) => {
|
||||
state.error = result.error.message;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
set((state) => {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
import type { SectionId } from "@shared/types";
|
||||
|
||||
export interface NavigationSlice {
|
||||
section: SectionId;
|
||||
commandPaletteOpen: boolean;
|
||||
commandPaletteSearch: string;
|
||||
selectedPaletteIndex: number;
|
||||
consoleOpen: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
companySwitcherOpen: boolean;
|
||||
setSection: (section: SectionId) => void;
|
||||
toggleCommandPalette: () => void;
|
||||
setCommandPaletteSearch: (search: string) => void;
|
||||
setSelectedPaletteIndex: (index: number) => void;
|
||||
toggleConsole: () => void;
|
||||
toggleSidebar: () => void;
|
||||
toggleCompanySwitcher: () => void;
|
||||
navigateTo: (section: SectionId, entityId?: string) => void;
|
||||
}
|
||||
|
||||
export const createNavigationSlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
NavigationSlice
|
||||
> = (set) => ({
|
||||
section: "overview",
|
||||
commandPaletteOpen: false,
|
||||
commandPaletteSearch: "",
|
||||
selectedPaletteIndex: 0,
|
||||
consoleOpen: false,
|
||||
sidebarCollapsed: false,
|
||||
companySwitcherOpen: false,
|
||||
setSection: (section) =>
|
||||
set((state) => {
|
||||
state.section = section;
|
||||
}),
|
||||
toggleCommandPalette: () =>
|
||||
set((state) => {
|
||||
state.commandPaletteOpen = !state.commandPaletteOpen;
|
||||
if (!state.commandPaletteOpen) {
|
||||
state.commandPaletteSearch = "";
|
||||
state.selectedPaletteIndex = 0;
|
||||
}
|
||||
}),
|
||||
setCommandPaletteSearch: (search) =>
|
||||
set((state) => {
|
||||
state.commandPaletteSearch = search;
|
||||
state.selectedPaletteIndex = 0;
|
||||
}),
|
||||
setSelectedPaletteIndex: (index) =>
|
||||
set((state) => {
|
||||
state.selectedPaletteIndex = index;
|
||||
}),
|
||||
toggleConsole: () =>
|
||||
set((state) => {
|
||||
state.consoleOpen = !state.consoleOpen;
|
||||
}),
|
||||
toggleSidebar: () =>
|
||||
set((state) => {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
}),
|
||||
toggleCompanySwitcher: () =>
|
||||
set((state) => {
|
||||
state.companySwitcherOpen = !state.companySwitcherOpen;
|
||||
}),
|
||||
navigateTo: (section, entityId) =>
|
||||
set((state) => {
|
||||
state.section = section;
|
||||
if (entityId) {
|
||||
if (section === "agents") state.selectedAgentId = entityId;
|
||||
else if (section === "tasks") state.selectedTaskId = entityId;
|
||||
else if (section === "approvals") state.selectedApprovalId = entityId;
|
||||
else if (section === "runs") {
|
||||
state.selectedRunId = entityId;
|
||||
state.consoleOpen = true;
|
||||
} else if (section === "goals") state.selectedGoalId = entityId;
|
||||
else if (section === "projects") state.selectedProjectId = entityId;
|
||||
else if (section === "connectors") state.selectedConnectorId = entityId;
|
||||
else if (section === "communication")
|
||||
state.selectedCommunicationEntityId = entityId;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
|
||||
export interface SelectionSlice {
|
||||
selectedTaskId: string | null;
|
||||
selectedConnectorId: string | null;
|
||||
selectedApprovalId: string | null;
|
||||
selectedRunId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
selectedGoalId: string | null;
|
||||
selectedProjectId: string | null;
|
||||
selectedWorkspaceId: string | null;
|
||||
selectedCommunicationEntityId: string | null;
|
||||
setSelectedTaskId: (id: string | null) => void;
|
||||
setSelectedConnectorId: (id: string | null) => void;
|
||||
setSelectedApprovalId: (id: string | null) => void;
|
||||
setSelectedRunId: (id: string | null) => void;
|
||||
setSelectedAgentId: (id: string | null) => void;
|
||||
setSelectedGoalId: (id: string | null) => void;
|
||||
setSelectedProjectId: (id: string | null) => void;
|
||||
setSelectedWorkspaceId: (id: string | null) => void;
|
||||
setSelectedCommunicationEntityId: (id: string | null) => void;
|
||||
clearCompanyScopedSelections: () => void;
|
||||
}
|
||||
|
||||
export const createSelectionSlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
SelectionSlice
|
||||
> = (set) => ({
|
||||
selectedTaskId: null,
|
||||
selectedConnectorId: null,
|
||||
selectedApprovalId: null,
|
||||
selectedRunId: null,
|
||||
selectedAgentId: null,
|
||||
selectedGoalId: null,
|
||||
selectedProjectId: null,
|
||||
selectedWorkspaceId: null,
|
||||
selectedCommunicationEntityId: null,
|
||||
setSelectedTaskId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedTaskId = id;
|
||||
}),
|
||||
setSelectedConnectorId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedConnectorId = id;
|
||||
}),
|
||||
setSelectedApprovalId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedApprovalId = id;
|
||||
}),
|
||||
setSelectedRunId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedRunId = id;
|
||||
}),
|
||||
setSelectedAgentId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedAgentId = id;
|
||||
}),
|
||||
setSelectedGoalId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedGoalId = id;
|
||||
}),
|
||||
setSelectedProjectId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedProjectId = id;
|
||||
}),
|
||||
setSelectedWorkspaceId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedWorkspaceId = id;
|
||||
}),
|
||||
setSelectedCommunicationEntityId: (id) =>
|
||||
set((state) => {
|
||||
state.selectedCommunicationEntityId = id;
|
||||
}),
|
||||
clearCompanyScopedSelections: () =>
|
||||
set((state) => {
|
||||
state.selectedTaskId = null;
|
||||
state.selectedApprovalId = null;
|
||||
state.selectedRunId = null;
|
||||
state.selectedAgentId = null;
|
||||
state.selectedGoalId = null;
|
||||
state.selectedProjectId = null;
|
||||
state.selectedWorkspaceId = null;
|
||||
state.selectedCommunicationEntityId = null;
|
||||
// selectedConnectorId is NOT cleared -- connectors are global
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
import type { SocialAccountRecord, BrowserActionRecord } from "@shared/types";
|
||||
|
||||
export interface SocialSlice {
|
||||
socialAccounts: SocialAccountRecord[];
|
||||
browserActions: BrowserActionRecord[];
|
||||
setSocialAccounts: (accounts: SocialAccountRecord[]) => void;
|
||||
setBrowserActions: (actions: BrowserActionRecord[]) => void;
|
||||
}
|
||||
|
||||
export const createSocialSlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
SocialSlice
|
||||
> = (set) => ({
|
||||
socialAccounts: [],
|
||||
browserActions: [],
|
||||
setSocialAccounts: (accounts) =>
|
||||
set((state) => {
|
||||
state.socialAccounts = accounts;
|
||||
}),
|
||||
setBrowserActions: (actions) =>
|
||||
set((state) => {
|
||||
state.browserActions = actions;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { DEFAULT_LOCALE } from "@shared/locale";
|
||||
|
||||
export interface UiSlice {
|
||||
locale: AppLocale;
|
||||
showOnboarding: boolean;
|
||||
initialOnboardingLocked: boolean;
|
||||
confirmDeleteCompanyId: string | null;
|
||||
taskViewMode: "list" | "kanban";
|
||||
logRevision: number;
|
||||
setLocale: (locale: AppLocale) => void;
|
||||
setShowOnboarding: (show: boolean) => void;
|
||||
setInitialOnboardingLocked: (locked: boolean) => void;
|
||||
setConfirmDeleteCompanyId: (id: string | null) => void;
|
||||
setTaskViewMode: (mode: "list" | "kanban") => void;
|
||||
incrementLogRevision: () => void;
|
||||
}
|
||||
|
||||
export const createUiSlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
UiSlice
|
||||
> = (set) => ({
|
||||
locale: DEFAULT_LOCALE,
|
||||
showOnboarding: false,
|
||||
initialOnboardingLocked: false,
|
||||
confirmDeleteCompanyId: null,
|
||||
taskViewMode: "list",
|
||||
logRevision: 0,
|
||||
setLocale: (locale) =>
|
||||
set((state) => {
|
||||
state.locale = locale;
|
||||
}),
|
||||
setShowOnboarding: (show) =>
|
||||
set((state) => {
|
||||
state.showOnboarding = show;
|
||||
}),
|
||||
setInitialOnboardingLocked: (locked) =>
|
||||
set((state) => {
|
||||
state.initialOnboardingLocked = locked;
|
||||
}),
|
||||
setConfirmDeleteCompanyId: (id) =>
|
||||
set((state) => {
|
||||
state.confirmDeleteCompanyId = id;
|
||||
}),
|
||||
setTaskViewMode: (mode) =>
|
||||
set((state) => {
|
||||
state.taskViewMode = mode;
|
||||
}),
|
||||
incrementLogRevision: () =>
|
||||
set((state) => {
|
||||
state.logRevision += 1;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { devtools } from "zustand/middleware";
|
||||
import {
|
||||
createNavigationSlice,
|
||||
type NavigationSlice,
|
||||
} from "./slices/navigation";
|
||||
import { createCompanySlice, type CompanySlice } from "./slices/company";
|
||||
import {
|
||||
createSelectionSlice,
|
||||
type SelectionSlice,
|
||||
} from "./slices/selection";
|
||||
import { createUiSlice, type UiSlice } from "./slices/ui";
|
||||
import {
|
||||
createCommunicationSlice,
|
||||
type CommunicationSlice,
|
||||
} from "./slices/communication";
|
||||
import { createSocialSlice, type SocialSlice } from "./slices/social";
|
||||
|
||||
export type AppState = NavigationSlice &
|
||||
CompanySlice &
|
||||
SelectionSlice &
|
||||
UiSlice &
|
||||
CommunicationSlice &
|
||||
SocialSlice;
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
immer((...a) => ({
|
||||
...createNavigationSlice(...a),
|
||||
...createCompanySlice(...a),
|
||||
...createSelectionSlice(...a),
|
||||
...createUiSlice(...a),
|
||||
...createCommunicationSlice(...a),
|
||||
...createSocialSlice(...a),
|
||||
})),
|
||||
{
|
||||
name: "AgentCompany",
|
||||
enabled: process.env.NODE_ENV === "development",
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in new issue
Block a user