From 5e9d36c058e8117ea7c59c974eb34cebd3d53f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 01:30:37 -0400 Subject: [PATCH] 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) --- package.json | 10 +- pnpm-lock.yaml | 35 ++++ src/renderer/lib/ipc-batcher.ts | 30 +++ src/renderer/state/hooks.ts | 47 +++++ src/renderer/state/slices/communication.ts | 17 ++ src/renderer/state/slices/company.ts | 88 +++++++++ src/renderer/state/slices/navigation.ts | 86 ++++++++ src/renderer/state/slices/selection.ts | 89 +++++++++ src/renderer/state/slices/social.ts | 28 +++ src/renderer/state/slices/ui.ts | 57 ++++++ src/renderer/state/store.ts | 42 ++++ tests/unit/ipc-batcher.test.ts | 73 +++++++ tests/unit/store.test.ts | 220 +++++++++++++++++++++ 13 files changed, 818 insertions(+), 4 deletions(-) create mode 100644 src/renderer/lib/ipc-batcher.ts create mode 100644 src/renderer/state/hooks.ts create mode 100644 src/renderer/state/slices/communication.ts create mode 100644 src/renderer/state/slices/company.ts create mode 100644 src/renderer/state/slices/navigation.ts create mode 100644 src/renderer/state/slices/selection.ts create mode 100644 src/renderer/state/slices/social.ts create mode 100644 src/renderer/state/slices/ui.ts create mode 100644 src/renderer/state/store.ts create mode 100644 tests/unit/ipc-batcher.test.ts create mode 100644 tests/unit/store.test.ts diff --git a/package.json b/package.json index 0cf78b0c..b488118a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.52", + "version": "0.4.53", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", @@ -26,29 +26,31 @@ "postinstall": "node scripts/postinstall.mjs" }, "dependencies": { - "@fontsource-variable/jetbrains-mono": "5.2.8", "@agentcompany/adapter-claude-local": "workspace:*", "@agentcompany/adapter-codex-local": "workspace:*", "@agentcompany/adapter-gemini-local": "workspace:*", "@agentcompany/adapter-utils": "workspace:*", + "@fontsource-variable/jetbrains-mono": "5.2.8", "@phosphor-icons/react": "2.1.10", "clsx": "2.1.1", "electron-log": "5.4.3", "electron-updater": "6.8.3", "framer-motion": "12.36.0", + "immer": "^11.1.4", "react": "19.2.4", "react-dom": "19.2.4", "tailwind-merge": "3.5.0", - "zod": "4.3.6" + "zod": "4.3.6", + "zustand": "^5.0.12" }, "devDependencies": { + "@eslint/js": "9.38.0", "@playwright/test": "1.58.2", "@tailwindcss/vite": "4.2.1", "@types/node": "^25.2.3", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.0", - "@eslint/js": "9.38.0", "electron": "39.8.0", "electron-builder": "26.8.1", "electron-vite": "5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd2b002a..1aa6adc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: framer-motion: specifier: 12.36.0 version: 12.36.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + immer: + specifier: ^11.1.4 + version: 11.1.4 react: specifier: 19.2.4 version: 19.2.4 @@ -50,6 +53,9 @@ importers: zod: specifier: 4.3.6 version: 4.3.6 + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4) devDependencies: '@eslint/js': specifier: 9.38.0 @@ -1928,6 +1934,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2916,6 +2925,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: 7zip-bin@5.2.0: {} @@ -4783,6 +4810,8 @@ snapshots: ignore@7.0.5: {} + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5676,3 +5705,9 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4): + optionalDependencies: + '@types/react': 19.2.14 + immer: 11.1.4 + react: 19.2.4 diff --git a/src/renderer/lib/ipc-batcher.ts b/src/renderer/lib/ipc-batcher.ts new file mode 100644 index 00000000..b7055411 --- /dev/null +++ b/src/renderer/lib/ipc-batcher.ts @@ -0,0 +1,30 @@ +type BatchCallback = () => void; + +export function createEventBatcher(callback: BatchCallback, windowMs = 50) { + let timer: ReturnType | 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 }; +} diff --git a/src/renderer/state/hooks.ts b/src/renderer/state/hooks.ts new file mode 100644 index 00000000..571a9192 --- /dev/null +++ b/src/renderer/state/hooks.ts @@ -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); diff --git a/src/renderer/state/slices/communication.ts b/src/renderer/state/slices/communication.ts new file mode 100644 index 00000000..1dead8dc --- /dev/null +++ b/src/renderer/state/slices/communication.ts @@ -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 +}); diff --git a/src/renderer/state/slices/company.ts b/src/renderer/state/slices/company.ts new file mode 100644 index 00000000..13be6f1f --- /dev/null +++ b/src/renderer/state/slices/company.ts @@ -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; +} + +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); + }); + } + }, +}); diff --git a/src/renderer/state/slices/navigation.ts b/src/renderer/state/slices/navigation.ts new file mode 100644 index 00000000..343e431a --- /dev/null +++ b/src/renderer/state/slices/navigation.ts @@ -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; + } + }), +}); diff --git a/src/renderer/state/slices/selection.ts b/src/renderer/state/slices/selection.ts new file mode 100644 index 00000000..5b78d614 --- /dev/null +++ b/src/renderer/state/slices/selection.ts @@ -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 + }), +}); diff --git a/src/renderer/state/slices/social.ts b/src/renderer/state/slices/social.ts new file mode 100644 index 00000000..8b6613de --- /dev/null +++ b/src/renderer/state/slices/social.ts @@ -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; + }), +}); diff --git a/src/renderer/state/slices/ui.ts b/src/renderer/state/slices/ui.ts new file mode 100644 index 00000000..cc467a2b --- /dev/null +++ b/src/renderer/state/slices/ui.ts @@ -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; + }), +}); diff --git a/src/renderer/state/store.ts b/src/renderer/state/store.ts new file mode 100644 index 00000000..36124e8a --- /dev/null +++ b/src/renderer/state/store.ts @@ -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()( + devtools( + immer((...a) => ({ + ...createNavigationSlice(...a), + ...createCompanySlice(...a), + ...createSelectionSlice(...a), + ...createUiSlice(...a), + ...createCommunicationSlice(...a), + ...createSocialSlice(...a), + })), + { + name: "AgentCompany", + enabled: process.env.NODE_ENV === "development", + } + ) +); diff --git a/tests/unit/ipc-batcher.test.ts b/tests/unit/ipc-batcher.test.ts new file mode 100644 index 00000000..100e33c4 --- /dev/null +++ b/tests/unit/ipc-batcher.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createEventBatcher } from "@renderer/lib/ipc-batcher"; + +describe("createEventBatcher", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("calls callback after windowMs timeout (50ms)", () => { + const callback = vi.fn(); + const batcher = createEventBatcher(callback, 50); + + batcher.schedule(); + expect(callback).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("coalesces multiple schedule() calls within window into single callback", () => { + const callback = vi.fn(); + const batcher = createEventBatcher(callback, 50); + + batcher.schedule(); + batcher.schedule(); + batcher.schedule(); + + vi.advanceTimersByTime(50); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("flush() immediately invokes callback and clears pending timer", () => { + const callback = vi.fn(); + const batcher = createEventBatcher(callback, 50); + + batcher.schedule(); + batcher.flush(); + + expect(callback).toHaveBeenCalledTimes(1); + + // Advancing time should not trigger another callback + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("dispose() cancels pending timer without invoking callback", () => { + const callback = vi.fn(); + const batcher = createEventBatcher(callback, 50); + + batcher.schedule(); + batcher.dispose(); + + vi.advanceTimersByTime(100); + expect(callback).not.toHaveBeenCalled(); + }); + + it("schedule() after flush() starts new timer", () => { + const callback = vi.fn(); + const batcher = createEventBatcher(callback, 50); + + batcher.schedule(); + batcher.flush(); + expect(callback).toHaveBeenCalledTimes(1); + + batcher.schedule(); + vi.advanceTimersByTime(50); + expect(callback).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts new file mode 100644 index 00000000..eeb21168 --- /dev/null +++ b/tests/unit/store.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useAppStore } from "@renderer/state/store"; +import type { ProfileSnapshot } from "@shared/types"; + +// Mock window.agentCompany for refreshSnapshot test +const mockLoadSnapshot = vi.fn(); +vi.stubGlobal("window", { + agentCompany: { + loadSnapshot: mockLoadSnapshot, + }, +}); + +describe("useAppStore", () => { + beforeEach(() => { + // Reset store to initial state before each test + useAppStore.setState(useAppStore.getInitialState()); + vi.clearAllMocks(); + }); + + it("is created without errors and has all 6 slice properties", () => { + const state = useAppStore.getState(); + + // NavigationSlice + expect(state).toHaveProperty("section"); + expect(state).toHaveProperty("setSection"); + expect(state).toHaveProperty("commandPaletteOpen"); + expect(state).toHaveProperty("toggleCommandPalette"); + + // CompanySlice + expect(state).toHaveProperty("snapshot"); + expect(state).toHaveProperty("setSnapshot"); + expect(state).toHaveProperty("refreshSnapshot"); + + // SelectionSlice + expect(state).toHaveProperty("selectedTaskId"); + expect(state).toHaveProperty("clearCompanyScopedSelections"); + + // UiSlice + expect(state).toHaveProperty("locale"); + expect(state).toHaveProperty("setLocale"); + + // CommunicationSlice - exists (even if minimal) + expect(state).toBeDefined(); + + // SocialSlice + expect(state).toHaveProperty("socialAccounts"); + expect(state).toHaveProperty("setSocialAccounts"); + }); + + it("NavigationSlice.setSection('tasks') updates section to 'tasks'", () => { + expect(useAppStore.getState().section).toBe("overview"); + + useAppStore.getState().setSection("tasks"); + + expect(useAppStore.getState().section).toBe("tasks"); + }); + + it("NavigationSlice.toggleCommandPalette() toggles commandPaletteOpen and clears search on close", () => { + expect(useAppStore.getState().commandPaletteOpen).toBe(false); + + // Open + useAppStore.getState().toggleCommandPalette(); + expect(useAppStore.getState().commandPaletteOpen).toBe(true); + + // Set some search state + useAppStore.getState().setCommandPaletteSearch("test"); + useAppStore.getState().setSelectedPaletteIndex(3); + expect(useAppStore.getState().commandPaletteSearch).toBe("test"); + expect(useAppStore.getState().selectedPaletteIndex).toBe(3); + + // Close -- should clear search and index + useAppStore.getState().toggleCommandPalette(); + expect(useAppStore.getState().commandPaletteOpen).toBe(false); + expect(useAppStore.getState().commandPaletteSearch).toBe(""); + expect(useAppStore.getState().selectedPaletteIndex).toBe(0); + }); + + it("NavigationSlice.navigateTo('agents', 'agent-123') sets section to 'agents' and selectedAgentId to 'agent-123'", () => { + useAppStore.getState().navigateTo("agents", "agent-123"); + + expect(useAppStore.getState().section).toBe("agents"); + expect(useAppStore.getState().selectedAgentId).toBe("agent-123"); + }); + + it("SelectionSlice.clearCompanyScopedSelections() nulls all selection IDs", () => { + // Set some selections + useAppStore.getState().setSelectedTaskId("task-1"); + useAppStore.getState().setSelectedAgentId("agent-1"); + useAppStore.getState().setSelectedApprovalId("approval-1"); + useAppStore.getState().setSelectedRunId("run-1"); + useAppStore.getState().setSelectedGoalId("goal-1"); + useAppStore.getState().setSelectedProjectId("project-1"); + useAppStore.getState().setSelectedWorkspaceId("workspace-1"); + useAppStore.getState().setSelectedCommunicationEntityId("comm-1"); + useAppStore.getState().setSelectedConnectorId("conn-1"); + + useAppStore.getState().clearCompanyScopedSelections(); + + const state = useAppStore.getState(); + expect(state.selectedTaskId).toBeNull(); + expect(state.selectedAgentId).toBeNull(); + expect(state.selectedApprovalId).toBeNull(); + expect(state.selectedRunId).toBeNull(); + expect(state.selectedGoalId).toBeNull(); + expect(state.selectedProjectId).toBeNull(); + expect(state.selectedWorkspaceId).toBeNull(); + expect(state.selectedCommunicationEntityId).toBeNull(); + // Connector is NOT cleared per spec + expect(state.selectedConnectorId).toBe("conn-1"); + }); + + it("CompanySlice.setSnapshot(mockSnapshot) stores snapshot correctly", () => { + const mockSnapshot: ProfileSnapshot = { + currentCompanyId: "company-1", + profilePath: "/test/path", + updatesEnabled: true, + theme: "system", + locale: "en", + backend: { secretBackend: "safe_storage" }, + companies: [], + connectors: [], + workspaces: [], + agents: [], + goals: [], + projects: [], + tasks: [], + approvals: [], + runs: [], + costs: [], + activity: [], + secrets: [], + comments: [], + socialAccounts: [], + meetings: [], + documents: [], + knowledgeBase: [], + sprints: [], + automationRules: [], + agentMessages: [], + workflows: [], + }; + + expect(useAppStore.getState().snapshot).toBeNull(); + + useAppStore.getState().setSnapshot(mockSnapshot); + + expect(useAppStore.getState().snapshot).toEqual(mockSnapshot); + }); + + it("CompanySlice.refreshSnapshot() calls window.agentCompany.loadSnapshot()", async () => { + const mockSnapshot: ProfileSnapshot = { + currentCompanyId: "company-1", + profilePath: "/test/path", + updatesEnabled: true, + theme: "system", + locale: "en", + backend: { secretBackend: "safe_storage" }, + companies: [], + connectors: [], + workspaces: [], + agents: [], + goals: [], + projects: [], + tasks: [], + approvals: [], + runs: [], + costs: [], + activity: [], + secrets: [], + comments: [], + socialAccounts: [], + meetings: [], + documents: [], + knowledgeBase: [], + sprints: [], + automationRules: [], + agentMessages: [], + workflows: [], + }; + + mockLoadSnapshot.mockResolvedValue({ ok: true, data: mockSnapshot }); + + await useAppStore.getState().refreshSnapshot(); + + expect(mockLoadSnapshot).toHaveBeenCalledTimes(1); + expect(useAppStore.getState().snapshot).toEqual(mockSnapshot); + }); + + it("UiSlice.setLocale('zh') updates locale to 'zh'", () => { + expect(useAppStore.getState().locale).toBe("en"); + + useAppStore.getState().setLocale("zh"); + + expect(useAppStore.getState().locale).toBe("zh"); + }); + + it("SocialSlice.setSocialAccounts([account]) stores accounts", () => { + const account = { + id: "acc-1", + companyId: "company-1", + platform: "twitter" as const, + accountName: "@test", + displayName: "Test", + profileUrl: "https://twitter.com/test", + credentialSecretId: null, + status: "active" as const, + requireApproval: false, + metadataJson: "{}", + lastUsedAt: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }; + + expect(useAppStore.getState().socialAccounts).toEqual([]); + + useAppStore.getState().setSocialAccounts([account]); + + expect(useAppStore.getState().socialAccounts).toEqual([account]); + }); +});