update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
@@ -0,0 +1,389 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef } from "react";
|
||||
import type {
|
||||
CompanyRecord,
|
||||
OnboardingBootstrapResult,
|
||||
SectionId,
|
||||
} from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { createTranslator } from "../i18n";
|
||||
import { useAppStore } from "../state/store";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { useFocusTrap, useReducedMotion } from "../components/ui";
|
||||
import {
|
||||
BookOpen,
|
||||
Gear,
|
||||
Tray,
|
||||
SquaresFour,
|
||||
UsersThree,
|
||||
ListChecks,
|
||||
ChatCircle,
|
||||
PuzzlePiece,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Megaphone,
|
||||
FolderOpen,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useDesktopEvents } from "./useDesktopEvents";
|
||||
import { useDataLoader } from "./useDataLoader";
|
||||
import { useCompanyData } from "./useCompanyData";
|
||||
import { useCommandPaletteHotkeys } from "./useCommandPaletteHotkeys";
|
||||
|
||||
type NavItemDef = { id: SectionId; labelKey: string; icon: typeof SquaresFour };
|
||||
export type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour };
|
||||
export type PaletteAction = { key: string; section: SectionId; entityId?: string };
|
||||
|
||||
const sidebarNavDefs: NavItemDef[] = [
|
||||
{ id: "overview", labelKey: "nav.overview", icon: SquaresFour },
|
||||
{ id: "inbox", labelKey: "nav.inbox", icon: Tray },
|
||||
{ id: "communication", labelKey: "nav.communication", icon: ChatCircle },
|
||||
{ id: "tasks", labelKey: "nav.work", icon: ListChecks },
|
||||
{ id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck },
|
||||
{ id: "agents", labelKey: "nav.agents", icon: UsersThree },
|
||||
{ id: "workspaces", labelKey: "nav.workspaces", icon: FolderOpen },
|
||||
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
|
||||
{ id: "documents", labelKey: "nav.documents", icon: FileText },
|
||||
];
|
||||
|
||||
const paletteOnlyNavDefs: NavItemDef[] = [
|
||||
{ id: "skills", labelKey: "nav.skills", icon: PuzzlePiece },
|
||||
{ id: "social", labelKey: "nav.social", icon: Megaphone },
|
||||
];
|
||||
|
||||
export function useAppController() {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const noMotion = { duration: 0 };
|
||||
const pageTransition = reducedMotion ? noMotion : { duration: 0.32, ease: [0.16, 1, 0.3, 1] as const };
|
||||
const panelTransition = reducedMotion ? noMotion : { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const };
|
||||
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const setLocale = useAppStore((s) => s.setLocale);
|
||||
const t = useMemo(() => createTranslator(locale), [locale]);
|
||||
|
||||
const sidebarNav: NavItem[] = useMemo(() => sidebarNavDefs.map((item) => ({ id: item.id, label: t(item.labelKey), icon: item.icon })), [t]);
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
sidebarNav
|
||||
.concat(
|
||||
paletteOnlyNavDefs.map((item) => ({
|
||||
id: item.id,
|
||||
label: t(item.labelKey),
|
||||
icon: item.icon,
|
||||
})),
|
||||
)
|
||||
.concat({ id: "settings", label: t("nav.settings"), icon: Gear }),
|
||||
[sidebarNav, t],
|
||||
);
|
||||
|
||||
const snapshot = useAppStore((s) => s.snapshot);
|
||||
const setSnapshot = useAppStore((s) => s.setSnapshot);
|
||||
const section = useAppStore((s) => s.section);
|
||||
const setSection = useAppStore((s) => s.setSection);
|
||||
const error = useAppStore((s) => s.error);
|
||||
const setError = useAppStore((s) => s.setError);
|
||||
const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen);
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
const consoleOpen = useAppStore((s) => s.consoleOpen);
|
||||
const setConsoleOpen = useAppStore((s) => s.setConsoleOpen);
|
||||
const sidebarCollapsed = useAppStore((s) => s.sidebarCollapsed);
|
||||
const setSidebarCollapsed = useAppStore((s) => s.setSidebarCollapsed);
|
||||
const companySwitcherOpen = useAppStore((s) => s.companySwitcherOpen);
|
||||
const setCompanySwitcherOpen = useAppStore((s) => s.setCompanySwitcherOpen);
|
||||
const confirmDeleteCompanyId = useAppStore((s) => s.confirmDeleteCompanyId);
|
||||
const setConfirmDeleteCompanyId = useAppStore((s) => s.setConfirmDeleteCompanyId);
|
||||
const selectedTaskId = useAppStore((s) => s.selectedTaskId);
|
||||
const setSelectedTaskId = useAppStore((s) => s.setSelectedTaskId);
|
||||
const selectedConnectorId = useAppStore((s) => s.selectedConnectorId);
|
||||
const setSelectedConnectorId = useAppStore((s) => s.setSelectedConnectorId);
|
||||
const selectedApprovalId = useAppStore((s) => s.selectedApprovalId);
|
||||
const setSelectedApprovalId = useAppStore((s) => s.setSelectedApprovalId);
|
||||
const selectedRunId = useAppStore((s) => s.selectedRunId);
|
||||
const setSelectedRunId = useAppStore((s) => s.setSelectedRunId);
|
||||
const selectedAgentId = useAppStore((s) => s.selectedAgentId);
|
||||
const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId);
|
||||
const selectedGoalId = useAppStore((s) => s.selectedGoalId);
|
||||
const setSelectedGoalId = useAppStore((s) => s.setSelectedGoalId);
|
||||
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
|
||||
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId);
|
||||
const setSelectedWorkspaceId = useAppStore((s) => s.setSelectedWorkspaceId);
|
||||
const selectedCommunicationEntityId = useAppStore((s) => s.selectedCommunicationEntityId);
|
||||
const setSelectedCommunicationEntityId = useAppStore((s) => s.setSelectedCommunicationEntityId);
|
||||
const taskViewMode = useAppStore((s) => s.taskViewMode);
|
||||
const setTaskViewMode = useAppStore((s) => s.setTaskViewMode);
|
||||
const logBufferRef = useRef<Record<string, string>>({});
|
||||
const incrementLogRevision = useAppStore((s) => s.incrementLogRevision);
|
||||
const commandPaletteSearch = useAppStore((s) => s.commandPaletteSearch);
|
||||
const setCommandPaletteSearch = useAppStore((s) => s.setCommandPaletteSearch);
|
||||
const selectedPaletteIndex = useAppStore((s) => s.selectedPaletteIndex);
|
||||
const setSelectedPaletteIndex = useAppStore((s) => s.setSelectedPaletteIndex);
|
||||
const showOnboarding = useAppStore((s) => s.showOnboarding);
|
||||
const setShowOnboarding = useAppStore((s) => s.setShowOnboarding);
|
||||
const initialOnboardingLocked = useAppStore((s) => s.initialOnboardingLocked);
|
||||
const setInitialOnboardingLocked = useAppStore((s) => s.setInitialOnboardingLocked);
|
||||
const inboxItems = useAppStore((s) => s.inboxItems);
|
||||
const setInboxItems = useAppStore((s) => s.setInboxItems);
|
||||
const companyMetrics = useAppStore((s) => s.companyMetrics);
|
||||
const setCompanyMetrics = useAppStore((s) => s.setCompanyMetrics);
|
||||
const standupReport = useAppStore((s) => s.standupReport);
|
||||
const setStandupReport = useAppStore((s) => s.setStandupReport);
|
||||
const socialAccounts = useAppStore((s) => s.socialAccounts);
|
||||
const setSocialAccounts = useAppStore((s) => s.setSocialAccounts);
|
||||
const browserActions = useAppStore((s) => s.browserActions);
|
||||
const setBrowserActions = useAppStore((s) => s.setBrowserActions);
|
||||
const setBadges = useAppStore((s) => s.setBadges);
|
||||
|
||||
const currentCompany = useMemo<CompanyRecord | null>(() => {
|
||||
if (!snapshot) return null;
|
||||
return snapshot.companies.find((company) => company.id === snapshot.currentCompanyId) ?? snapshot.companies[0] ?? null;
|
||||
}, [snapshot]);
|
||||
|
||||
const companyData = useCompanyData(snapshot, currentCompany);
|
||||
const { tasks, runs, approvals, pendingApprovals, companyAgents, companyGoals, companyProjects,
|
||||
companyWorkspaces, companyCosts, filteredActivity, companySecrets, companyDocuments,
|
||||
companyKnowledge, companyMeetings, companySprints, selectedTask, selectedApproval,
|
||||
selectedRun, selectedAgent, selectedGoal, selectedProject, selectedWorkspace, navBadges } = companyData;
|
||||
|
||||
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
|
||||
|
||||
const clearCompanyScopedUiState = useCallback(() => {
|
||||
const store = useAppStore.getState();
|
||||
store.clearCompanyScopedSelections();
|
||||
store.setInboxItems([]);
|
||||
store.setCompanyMetrics(null);
|
||||
store.setStandupReport(null);
|
||||
store.setSocialAccounts([]);
|
||||
store.setBrowserActions([]);
|
||||
store.setBadges(null);
|
||||
store.setSkills([]);
|
||||
store.setSelectedSkillId(null);
|
||||
store.setSelectedSkillFilePath("SKILL.md");
|
||||
store.setSkillImportModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const { refresh } = useDataLoader(
|
||||
snapshot, section, selectedConnectorId, selectedRunId,
|
||||
setSnapshot, setError, setInitialOnboardingLocked,
|
||||
setSelectedConnectorId, setSelectedRunId,
|
||||
setBadges, setInboxItems, setSocialAccounts, setBrowserActions,
|
||||
setCompanyMetrics, setStandupReport,
|
||||
);
|
||||
|
||||
// Stable ref so the subscribe callback always calls the latest refresh
|
||||
// without tearing down the subscription when refresh identity changes.
|
||||
const refreshRef = useRef(refresh);
|
||||
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
|
||||
|
||||
useDesktopEvents(refreshRef, logBufferRef, incrementLogRevision);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
const root = document.documentElement;
|
||||
const applyTheme = () => {
|
||||
const isDark = snapshot.theme === "dark" ||
|
||||
(snapshot.theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
root.classList.toggle("dark", isDark);
|
||||
root.style.colorScheme = isDark ? "dark" : "light";
|
||||
};
|
||||
applyTheme();
|
||||
if (snapshot.locale && snapshot.locale !== locale) setLocale(snapshot.locale);
|
||||
if (snapshot.theme === "system") {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mq.addEventListener("change", applyTheme);
|
||||
return () => mq.removeEventListener("change", applyTheme);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setLocale is a stable Zustand setter
|
||||
}, [snapshot, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||
setSelectedRunId(runs[0]?.id ?? null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectedRunId is a stable Zustand setter
|
||||
}, [runs, selectedRunId]);
|
||||
|
||||
const activeRunId = selectedRun?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
void (async () => {
|
||||
const activeRun = runs.find((run) => run.id === activeRunId);
|
||||
if (!activeRun) return;
|
||||
const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId });
|
||||
if (result.ok) {
|
||||
logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data };
|
||||
incrementLogRevision();
|
||||
return;
|
||||
}
|
||||
setError(result.error.message);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- incrementLogRevision and setError are stable Zustand setters
|
||||
}, [activeRunId, runs]);
|
||||
|
||||
|
||||
const handleLocaleChange = useCallback(async (newLocale: AppLocale) => {
|
||||
setLocale(newLocale);
|
||||
try { await window.agentCompany.updateSettings({ locale: newLocale }); } catch { /* local state already updated */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setLocale is a stable Zustand setter
|
||||
}, []);
|
||||
|
||||
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
|
||||
|
||||
const paletteQuery = useDeferredValue(commandPaletteSearch.trim().toLowerCase());
|
||||
const paletteResults = useMemo(() => {
|
||||
const filteredNavItems = paletteQuery
|
||||
? navItems.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery))
|
||||
: navItems;
|
||||
const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedRuns = paletteQuery ? runs.filter((run) => {
|
||||
const matchingTask = tasks.find((task) => task.id === run.taskId);
|
||||
return [run.id, run.summary ?? "", run.errorMessage ?? "", matchingTask?.title ?? "", run.connectorId]
|
||||
.some((value) => value.toLowerCase().includes(paletteQuery));
|
||||
}).slice(0, 5) : [];
|
||||
const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
|
||||
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
|
||||
return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults };
|
||||
}, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks, navItems]);
|
||||
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
|
||||
|
||||
const paletteActions = useMemo<PaletteAction[]>(() => ([
|
||||
...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })),
|
||||
...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })),
|
||||
...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })),
|
||||
...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })),
|
||||
...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })),
|
||||
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
|
||||
]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]);
|
||||
const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]);
|
||||
|
||||
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
|
||||
setSection(targetSection);
|
||||
if (entityId) {
|
||||
if (targetSection === "agents") setSelectedAgentId(entityId);
|
||||
else if (targetSection === "connectors") setSelectedConnectorId(entityId);
|
||||
else if (targetSection === "runs") { setSelectedRunId(entityId); setConsoleOpen(true); }
|
||||
else if (targetSection === "tasks") {
|
||||
const matchingTask = tasks.find((task) => task.id === entityId) ?? null;
|
||||
if (matchingTask) { setSelectedTaskId(matchingTask.id); }
|
||||
else {
|
||||
const matchingRun = runs.find((run) => run.id === entityId) ?? null;
|
||||
if (matchingRun) { setSelectedRunId(matchingRun.id); setSelectedTaskId(matchingRun.taskId); setConsoleOpen(true); }
|
||||
}
|
||||
}
|
||||
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
|
||||
else if (targetSection === "goals") setSelectedGoalId(entityId);
|
||||
else if (targetSection === "projects") setSelectedProjectId(entityId);
|
||||
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
|
||||
}, [runs, tasks]);
|
||||
|
||||
useCommandPaletteHotkeys({
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setCommandPaletteSearch,
|
||||
setSelectedPaletteIndex,
|
||||
paletteActions,
|
||||
selectedPaletteIndex,
|
||||
navigateTo,
|
||||
commandPaletteSearch,
|
||||
});
|
||||
|
||||
const setCompany = useCallback(async (companyId: string) => {
|
||||
if (companyId === "__new__") { setShowOnboarding(true); return; }
|
||||
try {
|
||||
unwrap(await window.agentCompany.setCurrentCompany(companyId));
|
||||
clearCompanyScopedUiState();
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError and setShowOnboarding are stable Zustand setters
|
||||
}, [clearCompanyScopedUiState, refresh]);
|
||||
|
||||
const deleteCompany = useCallback(async (companyId: string) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.deleteCompany(companyId));
|
||||
setConfirmDeleteCompanyId(null);
|
||||
setCompanySwitcherOpen(false);
|
||||
await refresh();
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
|
||||
}, [refresh]);
|
||||
|
||||
const startRun = useCallback(async (taskId: string) => {
|
||||
if (!currentCompany) { setError("Select a company before starting a run."); return; }
|
||||
const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId });
|
||||
if (!result.ok) { setError(result.error.message); return; }
|
||||
setSelectedRunId(result.data);
|
||||
setConsoleOpen(true);
|
||||
await refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
|
||||
}, [currentCompany, refresh]);
|
||||
|
||||
const cancelRun = useCallback(async (runId: string) => {
|
||||
try {
|
||||
const run = runs.find((entry) => entry.id === runId);
|
||||
if (!run) throw new Error("Selected run is no longer available.");
|
||||
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
|
||||
}, [refresh, runs]);
|
||||
|
||||
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
|
||||
setShowOnboarding(false);
|
||||
setInitialOnboardingLocked(false);
|
||||
await refresh();
|
||||
setError(null);
|
||||
setSection("overview");
|
||||
if (summary.runId) setSelectedRunId(summary.runId);
|
||||
if (summary.leadAgentId) setSelectedAgentId(summary.leadAgentId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
|
||||
}, [refresh]);
|
||||
|
||||
const openPathWithFeedback = useCallback(async (path: string) => {
|
||||
try { unwrap(await window.agentCompany.openPath(path)); setError(null); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pageTransition, panelTransition,
|
||||
locale, t, handleLocaleChange,
|
||||
sidebarNav, navItems, navBadges,
|
||||
snapshot, currentCompany,
|
||||
showOnboarding, initialOnboardingLocked, handleOnboardingDone, setShowOnboarding, setInitialOnboardingLocked, setError,
|
||||
sidebarCollapsed, setSidebarCollapsed,
|
||||
companySwitcherOpen, setCompanySwitcherOpen,
|
||||
confirmDeleteCompanyId, setConfirmDeleteCompanyId,
|
||||
setCompany, deleteCompany,
|
||||
section, setSection, error, consoleOpen, setConsoleOpen,
|
||||
tasks, runs, approvals, pendingApprovals,
|
||||
companyAgents, companyGoals, companyProjects, companyWorkspaces,
|
||||
companyCosts, filteredActivity, companySecrets, companyDocuments,
|
||||
companyKnowledge, companyMeetings, companySprints,
|
||||
socialAccounts, browserActions, inboxItems, companyMetrics, standupReport,
|
||||
selectedTask, selectedTaskId, setSelectedTaskId,
|
||||
selectedConnector, setSelectedConnectorId,
|
||||
selectedApproval, selectedApprovalId, setSelectedApprovalId,
|
||||
selectedAgent, selectedAgentId, setSelectedAgentId,
|
||||
selectedGoal, selectedGoalId, setSelectedGoalId,
|
||||
selectedProject, selectedProjectId, setSelectedProjectId,
|
||||
selectedWorkspace, setSelectedWorkspaceId,
|
||||
selectedCommunicationEntityId, setSelectedCommunicationEntityId,
|
||||
selectedRun, selectedRunId, setSelectedRunId,
|
||||
taskViewMode, setTaskViewMode,
|
||||
logBufferRef,
|
||||
navigateTo, refresh, startRun, cancelRun, openPathWithFeedback,
|
||||
commandPaletteOpen, setCommandPaletteOpen,
|
||||
commandPaletteSearch, setCommandPaletteSearch,
|
||||
selectedPaletteIndex, setSelectedPaletteIndex,
|
||||
paletteTrapRef,
|
||||
filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults,
|
||||
paletteActions, paletteActionIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppStore } from "../state/store";
|
||||
import type { PaletteAction } from "./useAppController";
|
||||
|
||||
interface CommandPaletteHotkeysOptions {
|
||||
commandPaletteOpen: boolean;
|
||||
setCommandPaletteOpen: (open: boolean) => void;
|
||||
setCommandPaletteSearch: (value: string) => void;
|
||||
setSelectedPaletteIndex: (index: number) => void;
|
||||
paletteActions: PaletteAction[];
|
||||
selectedPaletteIndex: number;
|
||||
navigateTo: (section: PaletteAction["section"], entityId?: string) => void;
|
||||
commandPaletteSearch: string;
|
||||
}
|
||||
|
||||
export function useCommandPaletteHotkeys({
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setCommandPaletteSearch,
|
||||
setSelectedPaletteIndex,
|
||||
paletteActions,
|
||||
selectedPaletteIndex,
|
||||
navigateTo,
|
||||
commandPaletteSearch,
|
||||
}: CommandPaletteHotkeysOptions) {
|
||||
useEffect(() => {
|
||||
function onGlobalKey(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setCommandPaletteOpen(!commandPaletteOpen);
|
||||
if (!commandPaletteOpen) {
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onGlobalKey);
|
||||
return () => document.removeEventListener("keydown", onGlobalKey);
|
||||
}, [commandPaletteOpen, setCommandPaletteOpen, setCommandPaletteSearch, setSelectedPaletteIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") { setCommandPaletteOpen(false); setCommandPaletteSearch(""); setSelectedPaletteIndex(0); return; }
|
||||
if (paletteActions.length === 0) return;
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); setSelectedPaletteIndex((useAppStore.getState().selectedPaletteIndex + 1) % paletteActions.length); return; }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); setSelectedPaletteIndex((useAppStore.getState().selectedPaletteIndex - 1 + paletteActions.length) % paletteActions.length); return; }
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
const selectedAction = paletteActions[Math.min(selectedPaletteIndex, paletteActions.length - 1)];
|
||||
if (!selectedAction) return;
|
||||
navigateTo(selectedAction.section, selectedAction.entityId);
|
||||
setCommandPaletteOpen(false);
|
||||
setCommandPaletteSearch("");
|
||||
setSelectedPaletteIndex(0);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [commandPaletteOpen, navigateTo, paletteActions, selectedPaletteIndex, setCommandPaletteOpen, setCommandPaletteSearch, setSelectedPaletteIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
setSelectedPaletteIndex(0);
|
||||
}, [commandPaletteOpen, commandPaletteSearch, setSelectedPaletteIndex]);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Company Context Provider
|
||||
*
|
||||
* Provides shared company infrastructure to section components, eliminating
|
||||
* the need to thread 80+ props through SectionContentView. Components can
|
||||
* import useCompanyContext() instead of receiving props from the root.
|
||||
*
|
||||
* This context holds stable references to company data and actions that
|
||||
* nearly every section needs: companyId, snapshot, refresh, navigation, etc.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
|
||||
|
||||
export interface CompanyContextValue {
|
||||
companyId: string | null;
|
||||
currentCompany: CompanyRecord | null;
|
||||
snapshot: ProfileSnapshot | null;
|
||||
refresh: () => void;
|
||||
navigateTo: (section: SectionId, entityId?: string) => void;
|
||||
setSection: (section: SectionId) => void;
|
||||
setError: (error: string | null) => void;
|
||||
openPath: (targetPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const CompanyContext = createContext<CompanyContextValue | null>(null);
|
||||
|
||||
interface CompanyContextProviderProps {
|
||||
companyId: string | null;
|
||||
currentCompany: CompanyRecord | null;
|
||||
snapshot: ProfileSnapshot | null;
|
||||
refresh: () => void;
|
||||
navigateTo: (section: SectionId, entityId?: string) => void;
|
||||
setSection: (section: SectionId) => void;
|
||||
setError: (error: string | null) => void;
|
||||
openPath: (targetPath: string) => Promise<void>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CompanyContextProvider({
|
||||
companyId, currentCompany, snapshot, refresh,
|
||||
navigateTo, setSection, setError, openPath, children,
|
||||
}: CompanyContextProviderProps) {
|
||||
const value = useMemo<CompanyContextValue>(() => ({
|
||||
companyId, currentCompany, snapshot, refresh,
|
||||
navigateTo, setSection, setError, openPath,
|
||||
}), [companyId, currentCompany, snapshot, refresh, navigateTo, setSection, setError, openPath]);
|
||||
|
||||
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCompanyContext(): CompanyContextValue {
|
||||
const ctx = useContext(CompanyContext);
|
||||
if (!ctx) throw new Error("useCompanyContext must be used within CompanyContextProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
|
||||
import { useAppStore } from "../state/store";
|
||||
|
||||
export function useCompanyData(
|
||||
snapshot: ProfileSnapshot | null,
|
||||
currentCompany: CompanyRecord | null,
|
||||
) {
|
||||
const selectedRunId = useAppStore((s) => s.selectedRunId);
|
||||
const selectedTaskId = useAppStore((s) => s.selectedTaskId);
|
||||
const selectedApprovalId = useAppStore((s) => s.selectedApprovalId);
|
||||
const selectedAgentId = useAppStore((s) => s.selectedAgentId);
|
||||
const selectedGoalId = useAppStore((s) => s.selectedGoalId);
|
||||
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
|
||||
const selectedWorkspaceId = useAppStore((s) => s.selectedWorkspaceId);
|
||||
|
||||
const tasks = useMemo(() => snapshot?.tasks.filter((task) => !currentCompany || task.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
|
||||
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
|
||||
|
||||
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
|
||||
const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
|
||||
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
|
||||
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
const selectedGoal = companyGoals.find((g) => g.id === selectedGoalId) ?? null;
|
||||
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
|
||||
const selectedWorkspace = companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null;
|
||||
|
||||
const navBadgesSource = useAppStore((s) => s.badges);
|
||||
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => navBadgesSource ? {
|
||||
runs:
|
||||
navBadgesSource.failedRuns > 0
|
||||
? { count: navBadgesSource.failedRuns, tone: "danger" }
|
||||
: navBadgesSource.activeRuns > 0
|
||||
? { count: navBadgesSource.activeRuns, tone: "default" }
|
||||
: undefined,
|
||||
tasks: navBadgesSource.todoTasks > 0 ? { count: navBadgesSource.todoTasks, tone: "default" } : undefined,
|
||||
approvals: navBadgesSource.pendingApprovals > 0 ? { count: navBadgesSource.pendingApprovals, tone: "warn" } : undefined,
|
||||
communication: navBadgesSource.unreadMessages > 0 ? { count: navBadgesSource.unreadMessages, tone: "warn" } : undefined,
|
||||
} : {}, [navBadgesSource]);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
approvals,
|
||||
pendingApprovals,
|
||||
runs,
|
||||
companyAgents,
|
||||
companyGoals,
|
||||
companyProjects,
|
||||
companyWorkspaces,
|
||||
companyCosts,
|
||||
filteredActivity,
|
||||
companySecrets,
|
||||
companyDocuments,
|
||||
companyKnowledge,
|
||||
companyMeetings,
|
||||
companySprints,
|
||||
selectedTask,
|
||||
selectedApproval,
|
||||
selectedRun,
|
||||
selectedAgent,
|
||||
selectedGoal,
|
||||
selectedProject,
|
||||
selectedWorkspace,
|
||||
navBadges,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { ProfileSnapshot, SectionId } from "@shared/types";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { useAppStore } from "../state/store";
|
||||
|
||||
export function useDataLoader(
|
||||
snapshot: ProfileSnapshot | null,
|
||||
section: SectionId,
|
||||
selectedConnectorId: string | null,
|
||||
selectedRunId: string | null,
|
||||
setSnapshot: (snapshot: ProfileSnapshot) => void,
|
||||
setError: (error: string | null) => void,
|
||||
setInitialOnboardingLocked: (locked: boolean) => void,
|
||||
setSelectedConnectorId: (id: string | null) => void,
|
||||
setSelectedRunId: (id: string | null) => void,
|
||||
setBadges: (badges: Parameters<ReturnType<typeof useAppStore.getState>["setBadges"]>[0]) => void,
|
||||
setInboxItems: (items: Parameters<ReturnType<typeof useAppStore.getState>["setInboxItems"]>[0]) => void,
|
||||
setSocialAccounts: (accounts: Parameters<ReturnType<typeof useAppStore.getState>["setSocialAccounts"]>[0]) => void,
|
||||
setBrowserActions: (actions: Parameters<ReturnType<typeof useAppStore.getState>["setBrowserActions"]>[0]) => void,
|
||||
setCompanyMetrics: (metrics: Parameters<ReturnType<typeof useAppStore.getState>["setCompanyMetrics"]>[0]) => void,
|
||||
setStandupReport: (report: Parameters<ReturnType<typeof useAppStore.getState>["setStandupReport"]>[0]) => void,
|
||||
) {
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
|
||||
setSnapshot(next);
|
||||
if (next.companies.length === 0) {
|
||||
setInitialOnboardingLocked(true);
|
||||
}
|
||||
if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id);
|
||||
if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id);
|
||||
if (next.currentCompanyId) {
|
||||
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
|
||||
if (badgeResult.ok) setBadges(badgeResult.data);
|
||||
const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId);
|
||||
if (inboxResult.ok) setInboxItems(inboxResult.data);
|
||||
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
|
||||
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
|
||||
if (browserActionResult.ok) {
|
||||
setBrowserActions(browserActionResult.data);
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
|
||||
}, [selectedConnectorId, selectedRunId]);
|
||||
|
||||
// Section-based data loading
|
||||
useEffect(() => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
const companyId = snapshot.currentCompanyId;
|
||||
let cancelled = false;
|
||||
|
||||
if (section === "inbox") {
|
||||
void window.agentCompany.getInbox(companyId).then((result) => {
|
||||
if (!cancelled && result.ok) setInboxItems(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "performance") {
|
||||
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
|
||||
if (!cancelled && result.ok) setCompanyMetrics(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "standup") {
|
||||
void window.agentCompany.getStandupReport(companyId).then((result) => {
|
||||
if (!cancelled && result.ok) setStandupReport(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "social") {
|
||||
setSocialAccounts(snapshot.socialAccounts?.filter((a) => a.companyId === companyId) ?? []);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
|
||||
}, [section, snapshot]);
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, type MutableRefObject } from "react";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { createDomainEventBatcher, type GranularEventEntry } from "../lib/ipc-batcher";
|
||||
import { useAppStore } from "../state/store";
|
||||
|
||||
export function useDesktopEvents(
|
||||
refreshRef: MutableRefObject<() => Promise<void>>,
|
||||
logBufferRef: MutableRefObject<Record<string, string>>,
|
||||
incrementLogRevision: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
void refreshRef.current();
|
||||
const store = useAppStore;
|
||||
|
||||
async function handleGranularBatch(events: GranularEventEntry[]) {
|
||||
const companyId = store.getState().currentCompanyId;
|
||||
if (!companyId) {
|
||||
void refreshRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
let needsFullRefresh = false;
|
||||
|
||||
for (const entry of events) {
|
||||
// run-updated:completed touches too many entity types -- full refresh
|
||||
if (entry.type === "run-updated" && entry.action === "completed") {
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (entry.type) {
|
||||
case "task-updated": {
|
||||
if (entry.action === "deleted") {
|
||||
store.getState().removeTask(entry.entityId);
|
||||
} else {
|
||||
const result = await window.agentCompany.getTask({ companyId, id: entry.entityId });
|
||||
if (result.ok) {
|
||||
store.getState().patchTask(result.data);
|
||||
} else {
|
||||
store.getState().removeTask(entry.entityId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "run-updated": {
|
||||
if (entry.action === "deleted") {
|
||||
store.getState().removeRun(entry.entityId);
|
||||
} else {
|
||||
const result = await window.agentCompany.getRun({ companyId, id: entry.entityId });
|
||||
if (result.ok) {
|
||||
store.getState().patchRun(result.data);
|
||||
} else {
|
||||
store.getState().removeRun(entry.entityId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "approval-updated": {
|
||||
if (entry.action === "deleted") {
|
||||
store.getState().removeApproval(entry.entityId);
|
||||
} else {
|
||||
const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId });
|
||||
if (result.ok) {
|
||||
store.getState().patchApproval(result.data);
|
||||
} else {
|
||||
store.getState().removeApproval(entry.entityId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent-updated": {
|
||||
const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId });
|
||||
if (result.ok) {
|
||||
store.getState().patchAgent(result.data);
|
||||
} else {
|
||||
needsFullRefresh = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "budget-changed": {
|
||||
// Budget is a computed aggregate -- full refresh is needed
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unknown granular event type -- fall back (per D-05 / PERF-03)
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// IPC error during entity fetch -- fall back to full refresh (per pitfall 1)
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (needsFullRefresh) break;
|
||||
}
|
||||
|
||||
if (needsFullRefresh) {
|
||||
void refreshRef.current();
|
||||
} else {
|
||||
// Refresh sidebar badges after granular patches (per pitfall 3)
|
||||
void store.getState().refreshBadges();
|
||||
}
|
||||
}
|
||||
|
||||
const batcher = createDomainEventBatcher(
|
||||
() => { void refreshRef.current(); },
|
||||
(events) => { void handleGranularBatch(events); },
|
||||
50,
|
||||
);
|
||||
|
||||
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
|
||||
if (event.type === "run-log") {
|
||||
const MAX_LOG_BYTES_PER_RUN = 512_000;
|
||||
const MAX_TRACKED_RUNS = 50;
|
||||
const existing = logBufferRef.current[event.runId] ?? "";
|
||||
const updated = existing.length < MAX_LOG_BYTES_PER_RUN
|
||||
? existing + event.chunk
|
||||
: existing.slice(-MAX_LOG_BYTES_PER_RUN / 2) + event.chunk;
|
||||
logBufferRef.current[event.runId] = updated;
|
||||
const runIds = Object.keys(logBufferRef.current);
|
||||
if (runIds.length > MAX_TRACKED_RUNS) {
|
||||
for (const oldId of runIds.slice(0, runIds.length - MAX_TRACKED_RUNS)) {
|
||||
delete logBufferRef.current[oldId];
|
||||
}
|
||||
}
|
||||
incrementLogRevision();
|
||||
return;
|
||||
}
|
||||
if (event.type === "shortcut") {
|
||||
if (event.action === "toggle-command-palette") store.getState().toggleCommandPalette();
|
||||
if (event.action === "toggle-console") store.getState().toggleConsole();
|
||||
if (event.action === "refresh") void refreshRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.type === "notification-click") {
|
||||
store.getState().navigateTo(event.section, event.entityId);
|
||||
return;
|
||||
}
|
||||
// All data-mutating events go through the enhanced batcher
|
||||
batcher.push(event);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
batcher.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshRef and logBufferRef are stable refs; incrementLogRevision is a stable Zustand setter
|
||||
}, []);
|
||||
}
|
||||
Reference in new issue
Block a user