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
+6
-4
@@ -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",
|
||||
|
||||
Generated
+35
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user