This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 14:03:56 -04:00
1 parent 806200b3d1
commit 5547c29491
216 files changed
+5043 -3352

No files matched your search

+57 -5
View File
@@ -6,6 +6,7 @@ import { unwrap } from "../lib/desktop";
import { useDesktopEvents } from "./useDesktopEvents";
import { useDataLoader } from "./useDataLoader";
import { useCompanyData } from "./useCompanyData";
import type { SectionContextValue } from "./useSectionContext";
export function useCompanyController() {
const snapshot = useAppStore((s) => s.snapshot);
@@ -22,7 +23,14 @@ export function useCompanyController() {
const companyData = useCompanyData(snapshot, currentCompany);
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
// HOOK-005 fix: memoize selectedConnector so its identity is stable.
const selectedConnector = useMemo(
() =>
snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ??
snapshot?.connectors[0] ??
null,
[snapshot, selectedConnectorId],
);
const clearCompanyScopedUiState = useCallback(() => {
const store = useAppStore.getState();
@@ -39,7 +47,7 @@ export function useCompanyController() {
store.setSkillImportModalOpen(false);
}, []);
const { refresh } = useDataLoader(snapshot, section, selectedConnectorId, selectedRunId);
const { refresh } = useDataLoader(snapshot, section);
const logBufferRef = useRef<Record<string, string>>({});
@@ -134,7 +142,15 @@ export function useCompanyController() {
catch (err) { store.setError(err instanceof Error ? err.message : String(err)); }
}, []);
return {
// HOOK-002 fix: return a STABLE useMemo'd object. Deps are each primitive
// field that affects consumers; companyData and snapshot are already
// stable refs thanks to the memoizations above.
const sectionContextValue = useMemo<SectionContextValue>(
() => ({ snapshot, currentCompany, selectedConnector, ...companyData }),
[snapshot, currentCompany, selectedConnector, companyData],
);
return useMemo(() => ({
snapshot,
currentCompany,
selectedConnector,
@@ -144,6 +160,42 @@ export function useCompanyController() {
deleteCompany,
handleOnboardingDone,
openPathWithFeedback,
...companyData,
};
sectionContextValue,
// Fields forwarded from companyData so existing consumers keep working
// without a wider refactor.
tasks: companyData.tasks,
approvals: companyData.approvals,
pendingApprovals: companyData.pendingApprovals,
runs: companyData.runs,
companyAgents: companyData.companyAgents,
companyGoals: companyData.companyGoals,
companyProjects: companyData.companyProjects,
companyWorkspaces: companyData.companyWorkspaces,
companyCosts: companyData.companyCosts,
filteredActivity: companyData.filteredActivity,
companySecrets: companyData.companySecrets,
companyDocuments: companyData.companyDocuments,
companyKnowledge: companyData.companyKnowledge,
companyMeetings: companyData.companyMeetings,
companySprints: companyData.companySprints,
selectedTask: companyData.selectedTask,
selectedApproval: companyData.selectedApproval,
selectedRun: companyData.selectedRun,
selectedAgent: companyData.selectedAgent,
selectedGoal: companyData.selectedGoal,
selectedProject: companyData.selectedProject,
selectedWorkspace: companyData.selectedWorkspace,
navBadges: companyData.navBadges,
}), [
snapshot,
currentCompany,
selectedConnector,
refresh,
setCompany,
deleteCompany,
handleOnboardingDone,
openPathWithFeedback,
sectionContextValue,
companyData,
]);
}
+59 -9
View File
@@ -39,13 +39,37 @@ export function useCompanyData(
const companyMeetings = useMemo(() => filterByCompany(snapshot?.meetings, currentCompany), [snapshot, currentCompany]);
const companySprints = useMemo(() => filterByCompany(snapshot?.sprints, currentCompany), [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;
// HOOK-005 fix: memoize selection derivations so their identity is stable
// across renders when the backing collections and selected id have not
// changed.
const selectedTask = useMemo(
() => (selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null),
[selectedTaskId, tasks],
);
const selectedApproval = useMemo(
() => (selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null),
[selectedApprovalId, approvals],
);
const selectedRun = useMemo(
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
[runs, selectedRunId],
);
const selectedAgent = useMemo(
() => companyAgents.find((a) => a.id === selectedAgentId) ?? null,
[companyAgents, selectedAgentId],
);
const selectedGoal = useMemo(
() => companyGoals.find((g) => g.id === selectedGoalId) ?? null,
[companyGoals, selectedGoalId],
);
const selectedProject = useMemo(
() => companyProjects.find((p) => p.id === selectedProjectId) ?? null,
[companyProjects, selectedProjectId],
);
const selectedWorkspace = useMemo(
() => companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null,
[companyWorkspaces, selectedWorkspaceId],
);
const navBadgesSource = useAppStore((s) => s.badges);
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => navBadgesSource ? {
@@ -60,7 +84,7 @@ export function useCompanyData(
communication: navBadgesSource.unreadMessages > 0 ? { count: navBadgesSource.unreadMessages, tone: "warn" } : undefined,
} : {}, [navBadgesSource]);
return {
return useMemo(() => ({
tasks,
approvals,
pendingApprovals,
@@ -84,5 +108,31 @@ export function useCompanyData(
selectedProject,
selectedWorkspace,
navBadges,
};
}), [
tasks,
approvals,
pendingApprovals,
runs,
companyAgents,
companyGoals,
companyProjects,
companyWorkspaces,
companyCosts,
filteredActivity,
companySecrets,
companyDocuments,
companyKnowledge,
companyMeetings,
companySprints,
selectedTask,
selectedApproval,
selectedRun,
selectedAgent,
selectedGoal,
selectedProject,
selectedWorkspace,
navBadges,
]);
}
export type CompanyData = ReturnType<typeof useCompanyData>;
+27 -12
View File
@@ -6,9 +6,11 @@ import { useAppStore } from "../state/store";
export function useDataLoader(
snapshot: ProfileSnapshot | null,
section: SectionId,
selectedConnectorId: string | null,
selectedRunId: string | null,
) {
// HOOK-006 fix: empty deps. The callback reads selectedConnectorId /
// selectedRunId via getState() at call time so its identity stays stable
// across connector/run selection changes and AppActions consumers do not
// cascade re-render.
const refresh = useCallback(async () => {
const store = useAppStore.getState();
try {
@@ -17,8 +19,10 @@ export function useDataLoader(
if (next.companies.length === 0) {
store.setInitialOnboardingLocked(true);
}
if (!selectedConnectorId && next.connectors[0]) store.setSelectedConnectorId(next.connectors[0].id);
if (!selectedRunId && next.runs[0]) store.setSelectedRunId(next.runs[0].id);
const currentSelectedConnectorId = useAppStore.getState().selectedConnectorId;
const currentSelectedRunId = useAppStore.getState().selectedRunId;
if (!currentSelectedConnectorId && next.connectors[0]) store.setSelectedConnectorId(next.connectors[0].id);
if (!currentSelectedRunId && next.runs[0]) store.setSelectedRunId(next.runs[0].id);
if (next.currentCompanyId) {
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
if (badgeResult.ok) store.setBadges(badgeResult.data);
@@ -34,11 +38,14 @@ export function useDataLoader(
} catch (nextError) {
store.setError(nextError instanceof Error ? nextError.message : String(nextError));
}
}, [selectedConnectorId, selectedRunId]);
}, []);
// Section-based data loading; resets when section OR company changes so
// in-flight requests for the previous company cannot overwrite new state.
const currentCompanyId = snapshot?.currentCompanyId ?? null;
// HOOK-003 fix: IPC effect keyed on [section, currentCompanyId,
// snapshotRevision]. snapshotRevision only bumps on full-snapshot swaps
// (setSnapshot / refreshSnapshot), so granular patchTask/patchRun/etc. do
// not re-trigger inbox/metrics/standup IPC fetches.
const snapshotRevision = useAppStore((s) => s.snapshotRevision);
useEffect(() => {
if (!currentCompanyId) return;
const companyId = currentCompanyId;
@@ -63,14 +70,22 @@ export function useDataLoader(
});
}
if (section === "social") {
store.setSocialAccounts(snapshot?.socialAccounts?.filter((a) => a.companyId === companyId) ?? []);
}
return () => {
cancelled = true;
};
}, [section, currentCompanyId, snapshot]);
}, [section, currentCompanyId, snapshotRevision]);
// Social accounts are read directly from the snapshot (no IPC), so keep
// them reactive to snapshot mutations independently of the IPC effect
// above.
const socialAccounts = snapshot?.socialAccounts;
useEffect(() => {
if (!currentCompanyId) return;
if (section !== "social") return;
useAppStore.getState().setSocialAccounts(
socialAccounts?.filter((a) => a.companyId === currentCompanyId) ?? [],
);
}, [section, currentCompanyId, socialAccounts]);
return { refresh };
}
+4 -2
View File
@@ -93,8 +93,10 @@ export function useDesktopEvents(
break;
}
}
} catch {
// IPC error during entity fetch -- fall back to full refresh (per pitfall 1)
} catch (error) {
console.warn(
`[renderer/useDesktopEvents] granular fetch failed for ${entry.type}/${entry.entityId}; falling back to full refresh: ${error instanceof Error ? error.message : String(error)}`,
);
needsFullRefresh = true;
break;
}
+20 -36
View File
@@ -1,40 +1,24 @@
import { useMemo } from "react";
import type { CompanyRecord } from "@shared/types";
import { useAppStore } from "../state/store";
import { useCompanyData } from "./useCompanyData";
import { createContext, useContext } from "react";
import type { CompanyRecord, ConnectorRecord, ProfileSnapshot } from "@shared/types";
import type { CompanyData } from "./useCompanyData";
/**
* Shared data context for section views. Subscribes to the Zustand store
* for snapshot + current company, derives per-company collections and
* currently-selected entities via useCompanyData.
*
* Sections call this hook directly instead of receiving 30+ props through
* SectionContentView.
* Shared data context for section views. Provided once at the App layer
* (via useCompanyController) so that section roots do not each run
* useCompanyData independently. Fixes HOOK-004 (4x duplicate filter work).
*/
export function useSectionContext() {
const snapshot = useAppStore((s) => s.snapshot);
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 selectedConnectorId = useAppStore((s) => s.selectedConnectorId);
const selectedConnector =
snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ??
snapshot?.connectors[0] ??
null;
return {
snapshot,
currentCompany,
selectedConnector,
...companyData,
};
export interface SectionContextValue extends CompanyData {
snapshot: ProfileSnapshot | null;
currentCompany: CompanyRecord | null;
selectedConnector: ConnectorRecord | null;
}
export const SectionContext = createContext<SectionContextValue | null>(null);
export function useSectionContext(): SectionContextValue {
const value = useContext(SectionContext);
if (!value) {
throw new Error("useSectionContext must be used inside <SectionContext.Provider>");
}
return value;
}
+85 -37
View File
@@ -120,7 +120,13 @@ export function useUIController() {
const handleLocaleChange = useCallback(async (newLocale: AppLocale) => {
useAppStore.getState().setLocale(newLocale);
try { await window.agentCompany.updateSettings({ locale: newLocale }); } catch { /* local state already updated */ }
try {
await window.agentCompany.updateSettings({ locale: newLocale });
} catch (error) {
console.warn(
`[renderer/useUIController] persist locale=${newLocale} failed (local state already updated): ${error instanceof Error ? error.message : String(error)}`,
);
}
}, []);
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
@@ -167,40 +173,82 @@ export function useUIController() {
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
]), []);
return {
pageTransition,
panelTransition,
locale,
t,
handleLocaleChange,
sidebarNav,
sidebarGroups,
navItems,
section,
setSection,
consoleOpen,
setConsoleOpen,
sidebarCollapsed,
setSidebarCollapsed,
companySwitcherOpen,
setCompanySwitcherOpen,
confirmDeleteCompanyId,
setConfirmDeleteCompanyId,
taskViewMode,
setTaskViewMode,
showOnboarding,
setShowOnboarding,
initialOnboardingLocked,
setInitialOnboardingLocked,
commandPaletteOpen,
setCommandPaletteOpen,
commandPaletteSearch,
setCommandPaletteSearch,
selectedPaletteIndex,
setSelectedPaletteIndex,
paletteQuery,
paletteTrapRef,
buildPaletteResults,
buildPaletteActions,
};
// HOOK-001 fix: wrap the 27-field return in a stable useMemo so downstream
// `useMemo([ui, ...])` consumers do not bust on every render. Every field
// is listed in the dep array; Zustand setters from getState() are stable
// references.
return useMemo(
() => ({
pageTransition,
panelTransition,
locale,
t,
handleLocaleChange,
sidebarNav,
sidebarGroups,
navItems,
section,
setSection,
consoleOpen,
setConsoleOpen,
sidebarCollapsed,
setSidebarCollapsed,
companySwitcherOpen,
setCompanySwitcherOpen,
confirmDeleteCompanyId,
setConfirmDeleteCompanyId,
taskViewMode,
setTaskViewMode,
showOnboarding,
setShowOnboarding,
initialOnboardingLocked,
setInitialOnboardingLocked,
commandPaletteOpen,
setCommandPaletteOpen,
commandPaletteSearch,
setCommandPaletteSearch,
selectedPaletteIndex,
setSelectedPaletteIndex,
paletteQuery,
paletteTrapRef,
buildPaletteResults,
buildPaletteActions,
}),
[
pageTransition,
panelTransition,
locale,
t,
handleLocaleChange,
sidebarNav,
sidebarGroups,
navItems,
section,
setSection,
consoleOpen,
setConsoleOpen,
sidebarCollapsed,
setSidebarCollapsed,
companySwitcherOpen,
setCompanySwitcherOpen,
confirmDeleteCompanyId,
setConfirmDeleteCompanyId,
taskViewMode,
setTaskViewMode,
showOnboarding,
setShowOnboarding,
initialOnboardingLocked,
setInitialOnboardingLocked,
commandPaletteOpen,
setCommandPaletteOpen,
commandPaletteSearch,
setCommandPaletteSearch,
selectedPaletteIndex,
setSelectedPaletteIndex,
paletteQuery,
paletteTrapRef,
buildPaletteResults,
buildPaletteActions,
],
);
}