update
This commit is contained in:
1 parent
806200b3d1
commit
5547c29491
216 files changed
+5043
-3352
No files matched your search
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user