diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2eb209d8..b00f6b29 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -77,7 +77,8 @@ export default defineConfig({ input: { index: resolve(__dirname, 'src/main/index.ts'), 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'), - 'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts') + 'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts'), + 'team-data-worker': resolve(__dirname, 'src/main/workers/team-data-worker.ts') }, output: { // CJS format so bundled deps can use __dirname/require. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 4848902b..2c201e2c 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1,5 +1,6 @@ import { addMainBreadcrumb } from '@main/sentry'; import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; +import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient'; import { getAppIconPath } from '@main/utils/appIcon'; import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { stripMarkdown } from '@main/utils/textFormatting'; @@ -535,7 +536,20 @@ async function handleGetData( const startedAt = Date.now(); let data: TeamData; try { - data = await getTeamDataService().getTeamData(tn); + // Prefer worker thread to keep main event loop responsive + const worker = getTeamDataWorkerClient(); + if (worker.isAvailable()) { + try { + data = await worker.getTeamData(tn); + } catch (workerErr) { + logger.warn( + `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` + ); + data = await getTeamDataService().getTeamData(tn); + } + } else { + data = await getTeamDataService().getTeamData(tn); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( @@ -555,6 +569,7 @@ async function handleGetData( return { success: false, error: message }; } const getDataMs = Date.now() - startedAt; + if (getDataMs >= 1500) { logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); } @@ -2138,6 +2153,19 @@ async function handleGetLogsForTask( : undefined, } : undefined; + // Prefer worker thread to keep main event loop responsive + const worker = getTeamDataWorkerClient(); + if (worker.isAvailable()) { + try { + return await wrapTeamHandler('getLogsForTask', () => + worker.findLogsForTask(vTeam.value!, vTask.value!, opts) + ); + } catch (workerErr) { + logger.warn( + `[teams:getLogsForTask] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` + ); + } + } return wrapTeamHandler('getLogsForTask', () => getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts) ); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 92bdbf5e..ef75b4ea 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -728,12 +728,19 @@ export class TeamDataService { this.processHealthTeams.delete(teamName); } + // Cap messages to keep IPC/postMessage payloads under ~300KB. + // Without this, teams with 2000+ messages produce 3MB+ payloads that + // stall Chromium's IPC serialization for ~1 second per transfer. + const MAX_RETURN_MESSAGES = 200; + const cappedMessages = + messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages; + return { teamName, config, tasks: tasksWithKanban, members, - messages, + messages: cappedMessages, kanbanState, processes, warnings: warnings.length > 0 ? warnings : undefined, diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts new file mode 100644 index 00000000..bd585cfb --- /dev/null +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -0,0 +1,169 @@ +/** + * Main-thread client for team-data-worker. + * + * Proxies getTeamData and findLogsForTask calls to a worker thread + * so they don't block the Electron main event loop. + * Falls back to main-thread execution if the worker is unavailable. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { createLogger } from '@shared/utils/logger'; + +import type { MemberLogSummary, TeamData } from '@shared/types'; +import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; + +const logger = createLogger('Service:TeamDataWorkerClient'); +const WORKER_CALL_TIMEOUT_MS = 30_000; + +function makeId(): string { + return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; +} + +function resolveWorkerPath(): string | null { + const baseDir = + typeof __dirname === 'string' && __dirname.length > 0 + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + + const candidates = [ + path.join(baseDir, 'team-data-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'), + ]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + logger.warn('team-data-worker not found in expected locations'); + return null; +} + +type PendingEntry = { + resolve: (v: unknown) => void; + reject: (e: Error) => void; +}; + +export class TeamDataWorkerClient { + private worker: Worker | null = null; + private readonly workerPath: string | null = resolveWorkerPath(); + private warnedUnavailable = false; + private pending = new Map(); + + isAvailable(): boolean { + if (!this.workerPath && !this.warnedUnavailable) { + this.warnedUnavailable = true; + logger.warn('team-data-worker not found; falling back to main-thread execution'); + } + return this.workerPath !== null; + } + + private ensureWorker(): Worker { + if (!this.workerPath) throw new Error('Worker not available'); + if (this.worker) return this.worker; + + this.worker = new Worker(this.workerPath); + + this.worker.on('message', (msg: TeamDataWorkerResponse) => { + const entry = this.pending.get(msg.id); + if (!entry) return; + this.pending.delete(msg.id); + if (msg.ok) { + entry.resolve(msg.result); + } else { + entry.reject(new Error(msg.error)); + } + }); + + this.worker.on('error', (err) => { + logger.error('Worker error', err); + for (const [, entry] of this.pending) { + entry.reject(err instanceof Error ? err : new Error(String(err))); + } + this.pending.clear(); + this.worker = null; + }); + + this.worker.on('exit', (code) => { + if (code !== 0) logger.warn(`Worker exited with code ${code}`); + for (const [, entry] of this.pending) { + entry.reject(new Error(`Worker exited with code ${code}`)); + } + this.pending.clear(); + this.worker = null; + }); + + return this.worker; + } + + private call( + op: TeamDataWorkerRequest['op'], + payload: TeamDataWorkerRequest['payload'] + ): Promise { + const worker = this.ensureWorker(); + const id = makeId(); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + this.worker?.terminate().catch(() => undefined); + this.worker = null; + reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`)); + }, WORKER_CALL_TIMEOUT_MS); + + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + + worker.postMessage({ id, op, payload } as TeamDataWorkerRequest); + }); + } + + async getTeamData(teamName: string): Promise { + return this.call('getTeamData', { teamName }) as Promise; + } + + async findLogsForTask( + teamName: string, + taskId: string, + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + } + ): Promise { + return this.call('findLogsForTask', { teamName, taskId, options }) as Promise< + MemberLogSummary[] + >; + } + + dispose(): void { + this.worker?.terminate().catch(() => undefined); + this.worker = null; + for (const [, entry] of this.pending) { + entry.reject(new Error('Client disposed')); + } + this.pending.clear(); + } +} + +// Singleton +let singleton: TeamDataWorkerClient | null = null; +export function getTeamDataWorkerClient(): TeamDataWorkerClient { + if (!singleton) singleton = new TeamDataWorkerClient(); + return singleton; +} diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index ed3eea68..6b514754 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -31,7 +31,7 @@ const ATTRIBUTION_CACHE_MAX = 5_000; const SCAN_CONCURRENCY = 15; /** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */ -const DISCOVERY_CACHE_TTL = 5_000; +const DISCOVERY_CACHE_TTL = 30_000; /** Signal sources for subagent member attribution, ordered by reliability. */ type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention'; diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts new file mode 100644 index 00000000..329e369f --- /dev/null +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -0,0 +1,32 @@ +/** + * Shared request/response types for the team-data-worker thread. + */ + +import type { MemberLogSummary, TeamData } from '@shared/types'; + +// ── Payloads ── + +export interface GetTeamDataPayload { + teamName: string; +} + +export interface FindLogsForTaskPayload { + teamName: string; + taskId: string; + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + }; +} + +// ── Request / Response ── + +export type TeamDataWorkerRequest = + | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } + | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }; + +export type TeamDataWorkerResponse = + | { id: string; ok: true; result: TeamData | MemberLogSummary[] } + | { id: string; ok: false; error: string }; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts new file mode 100644 index 00000000..2c93a78c --- /dev/null +++ b/src/main/workers/team-data-worker.ts @@ -0,0 +1,87 @@ +/** + * Worker thread for heavy team I/O operations (getTeamData, findLogsForTask). + * + * Runs in its own event loop, completely isolated from the Electron main thread. + * This prevents file-heavy operations (scanning 300+ subagent JSONL files, + * parsing large session files) from stalling the main process UI/IPC. + */ + +import { parentPort } from 'node:worker_threads'; + +import { TeamDataService } from '@main/services/team/TeamDataService'; +import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; +import { createLogger } from '@shared/utils/logger'; +import type { MemberLogSummary } from '@shared/types'; + +import type { + TeamDataWorkerRequest, + TeamDataWorkerResponse, +} from '@main/services/team/teamDataWorkerTypes'; + +const logger = createLogger('Worker:TeamData'); + +// Instantiate services with default dependencies — worker has its own event loop +const teamDataService = new TeamDataService(); +const logsFinder = new TeamMemberLogsFinder(); + +// In-flight dedup: concurrent calls for the same task piggyback on one request +const logsInFlight = new Map>(); +// Result cache with TTL to avoid re-scanning files +const logsResultCache = new Map(); +const LOGS_CACHE_TTL_MS = 10_000; + +function respond(msg: TeamDataWorkerResponse): void { + parentPort?.postMessage(msg); +} + +parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { + try { + switch (msg.op) { + case 'getTeamData': { + const result = await teamDataService.getTeamData(msg.payload.teamName); + respond({ id: msg.id, ok: true, result }); + break; + } + case 'findLogsForTask': { + const { teamName, taskId, options } = msg.payload; + const cacheKey = `${teamName}:${taskId}:${options?.owner ?? ''}`; + + // Check result cache + const cached = logsResultCache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < LOGS_CACHE_TTL_MS) { + respond({ id: msg.id, ok: true, result: cached.result }); + break; + } + + // Dedup concurrent calls + let promise = logsInFlight.get(cacheKey) as Promise | undefined; + if (!promise) { + promise = logsFinder.findLogsForTask(teamName, taskId, options).then((result) => { + logsInFlight.delete(cacheKey); + logsResultCache.set(cacheKey, { result, cachedAt: Date.now() }); + // Cap cache + if (logsResultCache.size > 100) { + const firstKey = logsResultCache.keys().next().value; + if (firstKey !== undefined) logsResultCache.delete(firstKey); + } + return result; + }); + logsInFlight.set(cacheKey, promise); + } + const result = await promise; + respond({ id: msg.id, ok: true, result }); + break; + } + default: { + const _exhaustive: never = msg; + respond({ id: (_exhaustive as { id: string }).id, ok: false, error: `Unknown op` }); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`[${msg.op}] ${message}`); + respond({ id: msg.id, ok: false, error: message }); + } +}); + +logger.info('team-data-worker started'); diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx index 66b1d5f4..25209ee3 100644 --- a/src/renderer/components/chat/AIChatGroup.tsx +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -171,7 +171,7 @@ const AIChatGroupInner = ({ ); // Notification color map for tool item dots - const notifications = useStore((s) => s.notifications); + const notifications = useStore(useShallow((s) => s.notifications)); const notificationColorMap = useMemo(() => { const map = new Map(); for (const n of notifications) { diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index d1d05c27..9f30d243 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -128,7 +128,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null; const pendingNavigation = thisTab?.pendingNavigation; - const teamBySessionId = useStore((s) => s.teamBySessionId); + const teamBySessionId = useStore(useShallow((s) => s.teamBySessionId)); // Look up whether this session belongs to a team const sessionTeam = useMemo(() => { diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 860a5dc3..53575bc3 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -388,15 +388,17 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath; }); - // Get team members for @mention highlighting - const members = useStore((s) => s.selectedTeamData?.members); + // Get team members for @mention highlighting and team names for @team linkification + const { members, teams } = useStore( + useShallow((s) => ({ + members: s.selectedTeamData?.members, + teams: s.teams, + })) + ); const memberColorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), [members] ); - - // Get team names for @team linkification - const teams = useStore((s) => s.teams); const teamNames = useMemo( () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), [teams] diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index ef017160..6b255e94 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -20,6 +20,7 @@ import { import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters'; @@ -82,7 +83,7 @@ export const SubagentItem: React.FC = ({ const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; // Agent configs from .claude/agents/ for color lookup - const agentConfigs = useStore((s) => s.agentConfigs); + const agentConfigs = useStore(useShallow((s) => s.agentConfigs)); // Team member colors (when this subagent is a team member) const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; @@ -171,7 +172,7 @@ export const SubagentItem: React.FC = ({ }, [subagent.messages]); // Search expansion - const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds); + const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds)); const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 212de247..112395ad 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -10,6 +10,7 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -85,14 +86,14 @@ export const TeammateMessageItem: React.FC = ({ const { isLight } = useTheme(); // Get team members for @mention highlighting - const members = useStore((s) => s.selectedTeamData?.members); + const members = useStore(useShallow((s) => s.selectedTeamData?.members)); const memberColorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), [members] ); // Get team names for @team linkification - const teams = useStore((s) => s.teams); + const teams = useStore(useShallow((s) => s.teams)); const teamNames = useMemo( () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), [teams] diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 9636ff2c..d1d2a941 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -690,7 +690,7 @@ export const MarkdownViewer: React.FC = ({ const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); - const teams = useStore((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)); + const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); const fallbackTeamColorByName = React.useMemo(() => { diff --git a/src/renderer/components/common/CliInstallWarningBanner.tsx b/src/renderer/components/common/CliInstallWarningBanner.tsx index c54d7a64..7a0747ff 100644 --- a/src/renderer/components/common/CliInstallWarningBanner.tsx +++ b/src/renderer/components/common/CliInstallWarningBanner.tsx @@ -8,10 +8,11 @@ import { isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle } from 'lucide-react'; export const CliInstallWarningBanner = (): React.JSX.Element | null => { - const cliStatus = useStore((s) => s.cliStatus); + const cliStatus = useStore(useShallow((s) => s.cliStatus)); const openDashboard = useStore((s) => s.openDashboard); // Returns a primitive boolean — minimizes re-renders diff --git a/src/renderer/components/common/ConnectionStatusBadge.tsx b/src/renderer/components/common/ConnectionStatusBadge.tsx index e1b890ac..924955ee 100644 --- a/src/renderer/components/common/ConnectionStatusBadge.tsx +++ b/src/renderer/components/common/ConnectionStatusBadge.tsx @@ -11,6 +11,7 @@ import { useStore } from '@renderer/store'; import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; interface ConnectionStatusBadgeProps { contextId: string; @@ -21,10 +22,12 @@ export const ConnectionStatusBadge = ({ contextId, className, }: Readonly): React.JSX.Element => { - const { connectionState, connectedHost } = useStore((s) => ({ - connectionState: s.connectionState, - connectedHost: s.connectedHost, - })); + const { connectionState, connectedHost } = useStore( + useShallow((s) => ({ + connectionState: s.connectionState, + connectedHost: s.connectedHost, + })) + ); // Local context always shows Monitor icon if (contextId === 'local') { diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx index 3a0c0270..62d6a0b2 100644 --- a/src/renderer/components/common/UpdateBanner.tsx +++ b/src/renderer/components/common/UpdateBanner.tsx @@ -6,14 +6,26 @@ import { useStore } from '@renderer/store'; import { CheckCircle, Loader2, X } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; export const UpdateBanner = (): React.JSX.Element | null => { - const showUpdateBanner = useStore((s) => s.showUpdateBanner); - const updateStatus = useStore((s) => s.updateStatus); - const downloadProgress = useStore((s) => s.downloadProgress); - const availableVersion = useStore((s) => s.availableVersion); - const installUpdate = useStore((s) => s.installUpdate); - const dismissUpdateBanner = useStore((s) => s.dismissUpdateBanner); + const { + showUpdateBanner, + updateStatus, + downloadProgress, + availableVersion, + installUpdate, + dismissUpdateBanner, + } = useStore( + useShallow((s) => ({ + showUpdateBanner: s.showUpdateBanner, + updateStatus: s.updateStatus, + downloadProgress: s.downloadProgress, + availableVersion: s.availableVersion, + installUpdate: s.installUpdate, + dismissUpdateBanner: s.dismissUpdateBanner, + })) + ); if (!showUpdateBanner || (updateStatus !== 'downloading' && updateStatus !== 'downloaded')) { return null; diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index fa5b3465..3b3b0afb 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -15,15 +15,28 @@ import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { ExternalLink, X } from 'lucide-react'; import remarkGfm from 'remark-gfm'; +import { useShallow } from 'zustand/react/shallow'; export const UpdateDialog = (): React.JSX.Element | null => { - const showUpdateDialog = useStore((s) => s.showUpdateDialog); - const updateStatus = useStore((s) => s.updateStatus); - const availableVersion = useStore((s) => s.availableVersion); - const releaseNotes = useStore((s) => s.releaseNotes); - const downloadUpdate = useStore((s) => s.downloadUpdate); - const installUpdate = useStore((s) => s.installUpdate); - const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog); + const { + showUpdateDialog, + updateStatus, + availableVersion, + releaseNotes, + downloadUpdate, + installUpdate, + dismissUpdateDialog, + } = useStore( + useShallow((s) => ({ + showUpdateDialog: s.showUpdateDialog, + updateStatus: s.updateStatus, + availableVersion: s.availableVersion, + releaseNotes: s.releaseNotes, + downloadUpdate: s.downloadUpdate, + installUpdate: s.installUpdate, + dismissUpdateDialog: s.dismissUpdateDialog, + })) + ); const dialogRef = useRef(null); diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 8f0c7130..46c816a7 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -18,6 +18,7 @@ import { import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { ApiKeysPanel } from './apikeys/ApiKeysPanel'; @@ -29,18 +30,35 @@ import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger'; export const ExtensionStoreView = (): React.JSX.Element => { const tabId = useTabIdOptional(); - const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog); - const fetchApiKeys = useStore((s) => s.fetchApiKeys); - const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); - const mcpBrowse = useStore((s) => s.mcpBrowse); - const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled); - const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading); - const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading); - const skillsLoading = useStore((s) => s.skillsLoading); - const cliStatus = useStore((s) => s.cliStatus); - const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked - const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing)); - const projects = useStore((s) => s.projects); + const { + fetchPluginCatalog, + fetchApiKeys, + fetchSkillsCatalog, + mcpBrowse, + mcpFetchInstalled, + pluginCatalogLoading, + mcpBrowseLoading, + skillsLoading, + cliStatus, + sessions, + projects, + } = useStore( + useShallow((s) => ({ + fetchPluginCatalog: s.fetchPluginCatalog, + fetchApiKeys: s.fetchApiKeys, + fetchSkillsCatalog: s.fetchSkillsCatalog, + mcpBrowse: s.mcpBrowse, + mcpFetchInstalled: s.mcpFetchInstalled, + pluginCatalogLoading: s.pluginCatalogLoading, + mcpBrowseLoading: s.mcpBrowseLoading, + skillsLoading: s.skillsLoading, + cliStatus: s.cliStatus, + sessions: s.sessions, + projects: s.projects, + })) + ); + const cliInstalled = cliStatus?.installed ?? true; + const hasOngoingSessions = sessions.some((sess) => sess.isOngoing); const extensionsTabProjectId = useStore((s) => tabId ? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId) diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index 4863ce24..f4a9f824 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; import { ApiKeyCard } from './ApiKeyCard'; @@ -15,11 +16,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; export const ApiKeysPanel = (): React.JSX.Element => { - const apiKeys = useStore((s) => s.apiKeys); - const apiKeysLoading = useStore((s) => s.apiKeysLoading); - const apiKeysError = useStore((s) => s.apiKeysError); - const storageStatus = useStore((s) => s.apiKeyStorageStatus); - const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus); + const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore( + useShallow((s) => ({ + apiKeys: s.apiKeys, + apiKeysLoading: s.apiKeysLoading, + apiKeysError: s.apiKeysError, + storageStatus: s.apiKeyStorageStatus, + fetchStorageStatus: s.fetchApiKeyStorageStatus, + })) + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 5d45a257..f723d25e 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { Check, Loader2, Trash2 } from 'lucide-react'; import type { ExtensionOperationState } from '@shared/types/extensions'; @@ -36,7 +37,7 @@ export const InstallButton = ({ size = 'sm', errorMessage, }: InstallButtonProps) => { - const cliStatus = useStore((s) => s.cliStatus); + const cliStatus = useStore(useShallow((s) => s.cliStatus)); const cliMissing = cliStatus !== null && !cliStatus.installed; const isDisabled = disabled || cliMissing; const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 136713cc..468df397 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -14,6 +14,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; @@ -72,18 +73,35 @@ export const McpServersPanel = ({ selectedMcpServerId, setSelectedMcpServerId, }: McpServersPanelProps): React.JSX.Element => { - const browseCatalog = useStore((s) => s.mcpBrowseCatalog); - const browseNextCursor = useStore((s) => s.mcpBrowseNextCursor); - const browseLoading = useStore((s) => s.mcpBrowseLoading); - const browseError = useStore((s) => s.mcpBrowseError); - const mcpBrowse = useStore((s) => s.mcpBrowse); - const installedServers = useStore((s) => s.mcpInstalledServers); - const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars); - const mcpDiagnostics = useStore((s) => s.mcpDiagnostics); - const mcpDiagnosticsLoading = useStore((s) => s.mcpDiagnosticsLoading); - const mcpDiagnosticsError = useStore((s) => s.mcpDiagnosticsError); - const mcpDiagnosticsLastCheckedAt = useStore((s) => s.mcpDiagnosticsLastCheckedAt); - const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics); + const { + browseCatalog, + browseNextCursor, + browseLoading, + browseError, + mcpBrowse, + installedServers, + fetchMcpGitHubStars, + mcpDiagnostics, + mcpDiagnosticsLoading, + mcpDiagnosticsError, + mcpDiagnosticsLastCheckedAt, + runMcpDiagnostics, + } = useStore( + useShallow((s) => ({ + browseCatalog: s.mcpBrowseCatalog, + browseNextCursor: s.mcpBrowseNextCursor, + browseLoading: s.mcpBrowseLoading, + browseError: s.mcpBrowseError, + mcpBrowse: s.mcpBrowse, + installedServers: s.mcpInstalledServers, + fetchMcpGitHubStars: s.fetchMcpGitHubStars, + mcpDiagnostics: s.mcpDiagnostics, + mcpDiagnosticsLoading: s.mcpDiagnosticsLoading, + mcpDiagnosticsError: s.mcpDiagnosticsError, + mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt, + runMcpDiagnostics: s.runMcpDiagnostics, + })) + ); const [mcpSort, setMcpSort] = useState('name-asc'); diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index d963e3f6..500e4e98 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -24,6 +24,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { getCapabilityLabel, inferCapabilities, @@ -53,14 +54,18 @@ export const PluginDetailDialog = ({ open, onClose, }: PluginDetailDialogProps): React.JSX.Element => { - const fetchPluginReadme = useStore((s) => s.fetchPluginReadme); - const readmes = useStore((s) => s.pluginReadmes); - const readmeLoading = useStore((s) => s.pluginReadmeLoading); + const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( + useShallow((s) => ({ + fetchPluginReadme: s.fetchPluginReadme, + readmes: s.pluginReadmes, + readmeLoading: s.pluginReadmeLoading, + installPlugin: s.installPlugin, + uninstallPlugin: s.uninstallPlugin, + })) + ); const installProgress = useStore( (s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle' ); - const installPlugin = useStore((s) => s.installPlugin); - const uninstallPlugin = useStore((s) => s.uninstallPlugin); const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); const [scope, setScope] = useState('user'); diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 29c22d53..258c35ce 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -16,6 +16,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react'; @@ -122,9 +123,13 @@ export const PluginsPanel = ({ hasActiveFilters, setPluginSort, }: PluginsPanelProps): React.JSX.Element => { - const catalog = useStore((s) => s.pluginCatalog); - const loading = useStore((s) => s.pluginCatalogLoading); - const error = useStore((s) => s.pluginCatalogError); + const { catalog, loading, error } = useStore( + useShallow((s) => ({ + catalog: s.pluginCatalog, + loading: s.pluginCatalogLoading, + error: s.pluginCatalogError, + })) + ); const filtered = useMemo( () => selectFilteredPlugins(catalog, pluginFilters, pluginSort), diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index a6f8741e..1f4c3e25 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -23,6 +23,7 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; interface SkillDetailDialogProps { @@ -44,7 +45,7 @@ export const SkillDetailDialog = ({ }: SkillDetailDialogProps): React.JSX.Element => { const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const deleteSkill = useStore((s) => s.deleteSkill); - const detail = useStore((s) => (skillId ? s.skillsDetailsById[skillId] : undefined)); + const detail = useStore(useShallow((s) => (skillId ? s.skillsDetailsById[skillId] : undefined))); const loading = useStore((s) => skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false ); diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 61cfa407..1898f14d 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -6,6 +6,7 @@ import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, ArrowUpAZ, @@ -94,10 +95,10 @@ export const SkillsPanel = ({ const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false); const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null); - const detailById = useStore((s) => s.skillsDetailsById); - const userSkills = useStore((s) => s.skillsUserCatalog); - const projectSkills = useStore((s) => - projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [] + const detailById = useStore(useShallow((s) => s.skillsDetailsById)); + const userSkills = useStore(useShallow((s) => s.skillsUserCatalog)); + const projectSkills = useStore( + useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [])) ); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); diff --git a/src/renderer/components/layout/PaneContainer.tsx b/src/renderer/components/layout/PaneContainer.tsx index b5065c13..dc5076ef 100644 --- a/src/renderer/components/layout/PaneContainer.tsx +++ b/src/renderer/components/layout/PaneContainer.tsx @@ -6,12 +6,13 @@ import { Fragment } from 'react'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { PaneResizeHandle } from './PaneResizeHandle'; import { PaneView } from './PaneView'; export const PaneContainer = (): React.JSX.Element => { - const panes = useStore((s) => s.paneLayout.panes); + const panes = useStore(useShallow((s) => s.paneLayout.panes)); return (
diff --git a/src/renderer/components/layout/PaneResizeHandle.tsx b/src/renderer/components/layout/PaneResizeHandle.tsx index 848f3e43..ec01dd7c 100644 --- a/src/renderer/components/layout/PaneResizeHandle.tsx +++ b/src/renderer/components/layout/PaneResizeHandle.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; interface PaneResizeHandleProps { leftPaneId: string; @@ -15,7 +16,7 @@ interface PaneResizeHandleProps { export const PaneResizeHandle = ({ leftPaneId }: PaneResizeHandleProps): React.JSX.Element => { const [isResizing, setIsResizing] = useState(false); const resizePanes = useStore((s) => s.resizePanes); - const paneLayout = useStore((s) => s.paneLayout); + const paneLayout = useStore(useShallow((s) => s.paneLayout)); const handleMouseMove = useCallback( (e: MouseEvent) => { diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 0f34ad2a..e6674307 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -76,17 +76,19 @@ export const SortableTab = ({ ) ); - const teamColorSet = useStore((s) => { - if (tab.type !== 'team' || !tab.teamName) return null; - const team = s.teamByName[tab.teamName]; - const explicitColor = - team?.color ?? - (s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined); - if (explicitColor) return getTeamColorSet(explicitColor); - // Fallback: deterministic color derived from display name - const displayName = team?.displayName ?? tab.label; - return nameColorSet(displayName); - }); + const teamColorSet = useStore( + useShallow((s) => { + if (tab.type !== 'team' || !tab.teamName) return null; + const team = s.teamByName[tab.teamName]; + const explicitColor = + team?.color ?? + (s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined); + if (explicitColor) return getTeamColorSet(explicitColor); + // Fallback: deterministic color derived from display name + const displayName = team?.displayName ?? tab.label; + return nameColorSet(displayName); + }) + ); const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)'; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index 3dc5fd39..baa0c6dc 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -26,6 +26,7 @@ import { useFullScreen } from '@renderer/hooks/useFullScreen'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useZoomFactor } from '@renderer/hooks/useZoomFactor'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner'; import { UpdateBanner } from '../common/UpdateBanner'; @@ -54,7 +55,7 @@ export const TabbedLayout = (): React.JSX.Element => { : getTrafficLightPaddingForZoom(zoomFactor); // --- DnD state (lifted from PaneContainer) --- - const panes = useStore((s) => s.paneLayout.panes); + const panes = useStore(useShallow((s) => s.paneLayout.panes)); const [activeTab, setActiveTab] = useState(null); const sensors = useSensors( diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx index f5cd8031..4ddf47bf 100644 --- a/src/renderer/components/report/SessionReportTab.tsx +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { computeTakeaways } from '@renderer/utils/reportAssessments'; import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; @@ -25,11 +26,13 @@ interface SessionReportTabProps { export const SessionReportTab = ({ tab }: SessionReportTabProps) => { // Find session data from any session tab with matching sessionId - const sessionDetail = useStore((s) => { - const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); - const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId); - return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; - }); + const sessionDetail = useStore( + useShallow((s) => { + const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); + const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId); + return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; + }) + ); const report = useMemo( () => (sessionDetail ? analyzeSession(sessionDetail) : null), diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index f80c096e..7e1369e9 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { nameColorSet } from '@renderer/utils/projectColor'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { @@ -68,7 +69,7 @@ const ScheduleListItem = ({ }: ScheduleListItemProps): React.JSX.Element => { const [expanded, setExpanded] = useState(false); const [selectedRun, setSelectedRun] = useState(null); - const runs = useStore((s) => s.scheduleRuns[schedule.id] ?? []); + const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? [])); const runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false); const fetchRunHistory = useStore((s) => s.fetchRunHistory); @@ -240,15 +241,29 @@ const ScheduleListItem = ({ // ============================================================================= export const SchedulesView = (): React.JSX.Element => { - const schedules = useStore((s) => s.schedules); - const schedulesLoading = useStore((s) => s.schedulesLoading); - const fetchSchedules = useStore((s) => s.fetchSchedules); - const pauseSchedule = useStore((s) => s.pauseSchedule); - const resumeSchedule = useStore((s) => s.resumeSchedule); - const deleteSchedule = useStore((s) => s.deleteSchedule); - const triggerNow = useStore((s) => s.triggerNow); - const openTeamTab = useStore((s) => s.openTeamTab); - const teamByName = useStore((s) => s.teamByName); + const { + schedules, + schedulesLoading, + fetchSchedules, + pauseSchedule, + resumeSchedule, + deleteSchedule, + triggerNow, + openTeamTab, + teamByName, + } = useStore( + useShallow((s) => ({ + schedules: s.schedules, + schedulesLoading: s.schedulesLoading, + fetchSchedules: s.fetchSchedules, + pauseSchedule: s.pauseSchedule, + resumeSchedule: s.resumeSchedule, + deleteSchedule: s.deleteSchedule, + triggerNow: s.triggerNow, + openTeamTab: s.openTeamTab, + teamByName: s.teamByName, + })) + ); /** Resolve team color dot style for a given team name */ const getTeamColor = useCallback( diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index 3aff54be..01b860ff 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import { useStore } from '@renderer/store'; import { Loader2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { useSettingsConfig, useSettingsHandlers } from './hooks'; import { @@ -19,8 +20,12 @@ import { type SettingsSection, SettingsTabs } from './SettingsTabs'; export const SettingsView = (): React.JSX.Element | null => { const [activeSection, setActiveSection] = useState('general'); - const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); - const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); + const { pendingSettingsSection, clearPendingSettingsSection } = useStore( + useShallow((s) => ({ + pendingSettingsSection: s.pendingSettingsSection, + clearPendingSettingsSection: s.clearPendingSettingsSection, + })) + ); // Consume pending section (avoid setState during render) useEffect(() => { diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx index 08d1c328..086599af 100644 --- a/src/renderer/components/settings/sections/AdvancedSection.tsx +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -8,6 +8,7 @@ import { api, isElectronMode } from '@renderer/api'; import appIcon from '@renderer/favicon.png'; import { useStore } from '@renderer/store'; import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SettingsSectionHeader } from '../components'; @@ -32,9 +33,13 @@ export const AdvancedSection = ({ const isElectron = useMemo(() => isElectronMode(), []); const [version, setVersion] = useState(''); const [configEditorOpen, setConfigEditorOpen] = useState(false); - const updateStatus = useStore((s) => s.updateStatus); - const availableVersion = useStore((s) => s.availableVersion); - const checkForUpdates = useStore((s) => s.checkForUpdates); + const { updateStatus, availableVersion, checkForUpdates } = useStore( + useShallow((s) => ({ + updateStatus: s.updateStatus, + availableVersion: s.availableVersion, + checkForUpdates: s.checkForUpdates, + })) + ); // Auto-revert "not-available" / "error" status back to idle after a brief display const revertTimerRef = useRef>(undefined); diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx index f85ccf5d..3fa6e937 100644 --- a/src/renderer/components/settings/sections/ConnectionSection.tsx +++ b/src/renderer/components/settings/sections/ConnectionSection.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SettingRow } from '../components/SettingRow'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; @@ -37,16 +38,31 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [ ]; export const ConnectionSection = (): React.JSX.Element => { - const connectionState = useStore((s) => s.connectionState); - const connectedHost = useStore((s) => s.connectedHost); - const connectionError = useStore((s) => s.connectionError); - const connectSsh = useStore((s) => s.connectSsh); - const disconnectSsh = useStore((s) => s.disconnectSsh); - const testConnection = useStore((s) => s.testConnection); - const sshConfigHosts = useStore((s) => s.sshConfigHosts); - const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts); - const lastSshConfig = useStore((s) => s.lastSshConfig); - const loadLastConnection = useStore((s) => s.loadLastConnection); + const { + connectionState, + connectedHost, + connectionError, + connectSsh, + disconnectSsh, + testConnection, + sshConfigHosts, + fetchSshConfigHosts, + lastSshConfig, + loadLastConnection, + } = useStore( + useShallow((s) => ({ + connectionState: s.connectionState, + connectedHost: s.connectedHost, + connectionError: s.connectionError, + connectSsh: s.connectSsh, + disconnectSsh: s.disconnectSsh, + testConnection: s.testConnection, + sshConfigHosts: s.sshConfigHosts, + fetchSshConfigHosts: s.fetchSshConfigHosts, + lastSshConfig: s.lastSshConfig, + loadLastConnection: s.loadLastConnection, + })) + ); // Form state const [host, setHost] = useState(''); diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index dd0ebf11..0e653f52 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -12,6 +12,7 @@ import { useStore } from '@renderer/store'; import { getFullResetState } from '@renderer/store/utils/stateResetHelpers'; import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage'; import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components'; @@ -50,9 +51,13 @@ export const GeneralSection = ({ const [copied, setCopied] = useState(false); // Claude Root state - const connectionMode = useStore((s) => s.connectionMode); - const fetchProjects = useStore((s) => s.fetchProjects); - const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups); + const { connectionMode, fetchProjects, fetchRepositoryGroups } = useStore( + useShallow((s) => ({ + connectionMode: s.connectionMode, + fetchProjects: s.fetchProjects, + fetchRepositoryGroups: s.fetchRepositoryGroups, + })) + ); const [claudeRootInfo, setClaudeRootInfo] = useState(null); const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 53f0384c..6ede9398 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -5,6 +5,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor'; @@ -78,7 +79,7 @@ export const SidebarTaskItem = ({ getDisplaySubject, }: SidebarTaskItemProps): React.JSX.Element => { const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members); + const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const { isLight } = useTheme(); diff --git a/src/renderer/components/team/ProcessesSection.tsx b/src/renderer/components/team/ProcessesSection.tsx index ff6bd0e5..035df0f7 100644 --- a/src/renderer/components/team/ProcessesSection.tsx +++ b/src/renderer/components/team/ProcessesSection.tsx @@ -1,4 +1,5 @@ import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { formatDistanceToNowStrict } from 'date-fns'; import { ExternalLink, Square, Terminal } from 'lucide-react'; @@ -25,7 +26,7 @@ function formatShortTime(date: Date): string { export const ProcessesSection = (): React.JSX.Element | null => { const teamName = useStore((s) => s.selectedTeamName); - const data = useStore((s) => s.selectedTeamData); + const data = useStore(useShallow((s) => s.selectedTeamData)); if (!teamName || !data?.processes?.length) return null; diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index 2c6788fc..03951474 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -69,10 +70,14 @@ export const TaskTooltip = ({ children, side = 'top', }: TaskTooltipProps): React.JSX.Element => { - const selectedTeamName = useStore((s) => s.selectedTeamName); - const selectedTeamData = useStore((s) => s.selectedTeamData); - const globalTasks = useStore((s) => s.globalTasks); - const teamByName = useStore((s) => s.teamByName); + const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore( + useShallow((s) => ({ + selectedTeamName: s.selectedTeamName, + selectedTeamData: s.selectedTeamData, + globalTasks: s.globalTasks, + teamByName: s.teamByName, + })) + ); const task = useMemo(() => { if (teamName && selectedTeamName === teamName) { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index da7dec5a..a052337d 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -457,6 +457,9 @@ export const TeamDetailView = ({ messagesPanelWidth, setMessagesPanelMode, setMessagesPanelWidth, + selectReviewFile, + pendingReviewRequest, + setPendingReviewRequest, } = useStore( useShallow((s) => ({ data: s.selectedTeamData, @@ -506,6 +509,9 @@ export const TeamDetailView = ({ messagesPanelWidth: s.messagesPanelWidth, setMessagesPanelMode: s.setMessagesPanelMode, setMessagesPanelWidth: s.setMessagesPanelWidth, + selectReviewFile: s.selectReviewFile, + pendingReviewRequest: s.pendingReviewRequest, + setPendingReviewRequest: s.setPendingReviewRequest, })) ); @@ -961,10 +967,6 @@ export const TeamDetailView = ({ } }, [teamName, refreshTeamData]); - const selectReviewFile = useStore((s) => s.selectReviewFile); - const pendingReviewRequest = useStore((s) => s.pendingReviewRequest); - const setPendingReviewRequest = useStore((s) => s.setPendingReviewRequest); - // Pick up pending review request from GlobalTaskDetailDialog useEffect(() => { if (!pendingReviewRequest) return; diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 4f10d048..07e0925b 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -224,6 +224,8 @@ export const TeamListView = (): React.JSX.Element => { fetchTeams, openTeamTab, deleteTeam, + restoreTeam, + permanentlyDeleteTeam, projects, globalTasks, fetchAllTasks, @@ -232,6 +234,7 @@ export const TeamListView = (): React.JSX.Element => { selectedRepositoryId, selectedWorktreeId, activeProjectId, + branchByPath, } = useStore( useShallow((s) => ({ teams: s.teams, @@ -250,6 +253,7 @@ export const TeamListView = (): React.JSX.Element => { selectedRepositoryId: s.selectedRepositoryId, selectedWorktreeId: s.selectedWorktreeId, activeProjectId: s.activeProjectId, + branchByPath: s.branchByPath, })) ); const { @@ -432,10 +436,6 @@ export const TeamListView = (): React.JSX.Element => { [filteredTeams] ); useBranchSync(teamPaths, { live: false }); - const branchByPath = useStore((s) => s.branchByPath); - - const restoreTeam = useStore((s) => s.restoreTeam); - const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam); const handleDeleteTeam = useCallback( (teamName: string, isDraft: boolean, e: React.MouseEvent) => { diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index bfc7de63..99ebfc62 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; @@ -141,12 +142,23 @@ function useElapsed(receivedAt: string): number { const RESPOND_TIMEOUT_MS = 10_000; export const ToolApprovalSheet: React.FC = () => { - const pendingApprovals = useStore((s) => s.pendingApprovals); - const respondToToolApproval = useStore((s) => s.respondToToolApproval); - const updateToolApprovalSettings = useStore((s) => s.updateToolApprovalSettings); - const teams = useStore((s) => s.teams); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const selectedTeamData = useStore((s) => s.selectedTeamData); + const { + pendingApprovals, + respondToToolApproval, + updateToolApprovalSettings, + teams, + selectedTeamName, + selectedTeamData, + } = useStore( + useShallow((s) => ({ + pendingApprovals: s.pendingApprovals, + respondToToolApproval: s.respondToToolApproval, + updateToolApprovalSettings: s.updateToolApprovalSettings, + teams: s.teams, + selectedTeamName: s.selectedTeamName, + selectedTeamData: s.selectedTeamData, + })) + ); const { isLight } = useTheme(); const current: ToolApprovalRequest | undefined = pendingApprovals[0]; @@ -606,7 +618,7 @@ const ToolInputPreview = ({ // --------------------------------------------------------------------------- const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Element | null => { - const settings = useStore((s) => s.toolApprovalSettings); + const settings = useStore(useShallow((s) => s.toolApprovalSettings)); const elapsed = useElapsed(receivedAt); if (settings.timeoutAction === 'wait') return null; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 79b39aef..ce0c516e 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -17,6 +17,7 @@ import { import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { getMessageTypeLabel, getStructuredMessageSummary, @@ -601,8 +602,8 @@ export const ActivityItem = memo( const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); // Permission request status icon (check/x/clock) - const pendingApprovals = useStore((s) => s.pendingApprovals); - const resolvedApprovals = useStore((s) => s.resolvedApprovals); + const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); + const resolvedApprovals = useStore(useShallow((s) => s.resolvedApprovals)); const permissionIcon = useMemo(() => { if (!structured) return null; const type = typeof structured.type === 'string' ? structured.type : null; diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index e7946534..36b0e168 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -2,6 +2,7 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, @@ -33,7 +34,7 @@ export const PendingRepliesBlock = ({ onMemberClick, }: PendingRepliesBlockProps): React.JSX.Element | null => { const { isLight } = useTheme(); - const pendingApprovals = useStore((s) => s.pendingApprovals); + const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); const colorMap = buildMemberColorMap(members); const memberPending = Object.entries(pendingRepliesByMember) .map(([name, sentAtMs]) => ({ diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 78ab5652..ab3aa1e7 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -23,6 +23,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; @@ -119,8 +120,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Team name: always present for launch mode, may be absent in schedule mode (standalone page) const propsTeamName = props.teamName ?? ''; const [selectedTeamName, setSelectedTeamName] = useState(''); - const teamByName = useStore((s) => s.teamByName); - const openDashboard = useStore((s) => s.openDashboard); + const { teamByName, openDashboard } = useStore( + useShallow((s) => ({ + teamByName: s.teamByName, + openDashboard: s.openDashboard, + })) + ); const teamOptions = useMemo( () => Object.values(teamByName) @@ -401,7 +406,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Shared effects: projects // --------------------------------------------------------------------------- - const repositoryGroups = useStore((s) => s.repositoryGroups); + const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups)); useEffect(() => { if (!open) return; @@ -490,7 +495,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Mention suggestions (shared — from props in launch, from store in schedule) // --------------------------------------------------------------------------- - const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); + const storeMembers = useStore(useShallow((s) => s.selectedTeamData?.members ?? [])); const members = isLaunch ? props.members : storeMembers; const colorMap = useMemo(() => buildMemberColorMap(members), [members]); diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx index aaebf66b..617f55cb 100644 --- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx +++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx @@ -9,6 +9,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types'; @@ -42,7 +43,7 @@ export const ToolApprovalSettingsContent: React.FC<{ teamName?: string; }> = ({ expanded, teamName }) => { const [localSeconds, setLocalSeconds] = useState(''); - const settings = useStore((s) => s.toolApprovalSettings); + const settings = useStore(useShallow((s) => s.toolApprovalSettings)); const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings); const updateSettings = useCallback( (patch: Partial) => rawUpdateSettings(patch, teamName), diff --git a/src/renderer/components/team/kanban/KanbanColumn.tsx b/src/renderer/components/team/kanban/KanbanColumn.tsx index 68db2d03..1422c8c8 100644 --- a/src/renderer/components/team/kanban/KanbanColumn.tsx +++ b/src/renderer/components/team/kanban/KanbanColumn.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { Badge } from '@renderer/components/ui/badge'; import { cn } from '@renderer/lib/utils'; @@ -15,7 +17,7 @@ interface KanbanColumnProps { children: React.ReactNode; } -export const KanbanColumn = ({ +export const KanbanColumn = memo(function KanbanColumn({ title, count, icon, @@ -27,7 +29,7 @@ export const KanbanColumn = ({ headerDragClassName, headerAccessory, children, -}: KanbanColumnProps): React.JSX.Element => { +}: KanbanColumnProps): React.JSX.Element { return (
); -}; +}); diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index c6d49ec6..2bb12415 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -8,6 +8,7 @@ import { } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, @@ -44,14 +45,19 @@ export const MemberHoverCard = ({ children, }: MemberHoverCardProps): React.JSX.Element => { const { isLight } = useTheme(); - const member = useStore((s) => s.selectedTeamData?.members.find((m) => m.name === name) ?? null); - const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive); - const teamName = useStore((s) => s.selectedTeamName); - const leadActivity: LeadActivityState | undefined = useStore((s) => - teamName ? s.leadActivityByTeam[teamName] : undefined + const { member, isTeamAlive, teamName, leadActivity, openMemberProfile, tasks } = useStore( + useShallow((s) => { + const tn = s.selectedTeamName; + return { + member: s.selectedTeamData?.members.find((m) => m.name === name) ?? null, + isTeamAlive: s.selectedTeamData?.isAlive, + teamName: tn, + leadActivity: tn ? s.leadActivityByTeam[tn] : undefined, + openMemberProfile: s.openMemberProfile, + tasks: s.selectedTeamData?.tasks, + }; + }) ); - const openMemberProfile = useStore((s) => s.openMemberProfile); - const tasks = useStore((s) => s.selectedTeamData?.tasks); if (!member) { return <>{children}; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index efdfc083..26cf68a2 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; @@ -67,15 +67,19 @@ export const MemberList = ({ }, [handleResize]); const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1'; - const activeMembers = members - .filter((m) => !m.removedAt) - .sort((a, b) => { - if (isLeadMember(a)) return -1; - if (isLeadMember(b)) return 1; - return 0; - }); - const removedMembers = members.filter((m) => m.removedAt); - const colorMap = buildMemberColorMap(members); + const activeMembers = useMemo( + () => + members + .filter((m) => !m.removedAt) + .sort((a, b) => { + if (isLeadMember(a)) return -1; + if (isLeadMember(b)) return 1; + return 0; + }), + [members] + ); + const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]); + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); if (members.length === 0) { return ( @@ -85,17 +89,24 @@ export const MemberList = ({ ); } + // Pre-compute reviewer→task map to avoid O(n×m) scan per member + const reviewTaskByMember = useMemo(() => { + const result = new Map(); + if (!taskMap) return result; + for (const task of taskMap.values()) { + if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) { + result.set(task.reviewer, task); + } + } + return result; + }, [taskMap]); + const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => { const currentTask = member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; - const reviewTask = taskMap - ? (Array.from(taskMap.values()).find( - (task) => - task.reviewer === member.name && - task.id !== member.currentTaskId && - (task.reviewState === 'review' || task.kanbanColumn === 'review') - ) ?? null) - : null; + const reviewCandidate = reviewTaskByMember.get(member.name) ?? null; + const reviewTask = + reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]); const spawnEntry = memberSpawnStatuses?.get(member.name); return ( diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index c00289d6..db5be22b 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -1,5 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useStore } from '@renderer/store'; +import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; + import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { @@ -107,6 +110,11 @@ export const MemberLogsTab = ({ showLeadPreview = false, onPreviewOnlineChange, }: MemberLogsTabProps): React.JSX.Element => { + // Visibility check: skip polling when tab is hidden (display:none) to avoid OOM + const tabId = useTabIdOptional(); + const activeTabId = useStore((s) => s.activeTabId); + const isTabActive = tabId ? activeTabId === tabId : true; // default true when no tab context (e.g. standalone dialog) + const MIN_REFRESH_VISIBLE_MS = 250; const intervalsKey = useMemo( () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), @@ -475,14 +483,14 @@ export const MemberLogsTab = ({ void load(); - const interval = shouldAutoRefresh ? setInterval(() => void load(), 5000) : null; + const interval = shouldAutoRefresh && isTabActive ? setInterval(() => void load(), 5000) : null; return () => { cancelled = true; if (interval) clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops - }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince]); + }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince, isTabActive]); const fetchDetailForLog = useCallback( async ( @@ -537,7 +545,7 @@ export const MemberLogsTab = ({ if (!previewLog) return; const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing; - if (!shouldAutoRefreshPreview) return; + if (!shouldAutoRefreshPreview || !isTabActive) return; let cancelled = false; const interval = setInterval(async () => { @@ -566,12 +574,14 @@ export const MemberLogsTab = ({ shouldShowPreview, taskStatus, intervalsKey, + isTabActive, ]); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; + if (!isTabActive) return; let cancelled = false; @@ -604,6 +614,7 @@ export const MemberLogsTab = ({ taskId, taskStatus, intervalsKey, + isTabActive, ]); const handleExpand = useCallback( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index b21e5985..474fe527 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -14,6 +14,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; @@ -104,7 +105,7 @@ export const MessageComposer = ({ const [selectedTeam, setSelectedTeam] = useState(null); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); const [aliveTeams, setAliveTeams] = useState>(new Set()); - const allCrossTeamTargets = useStore((s) => s.crossTeamTargets); + const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets)); const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets); useEffect(() => { diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index f2b57150..2101cddd 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -6,6 +6,7 @@ import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Filter } from 'lucide-react'; @@ -67,7 +68,7 @@ export const MessagesFilterPopover = ({ } }, [open, filter.from, filter.to, filter.showNoise]); - const members = useStore((s) => s.selectedTeamData?.members ?? []); + const members = useStore(useShallow((s) => s.selectedTeamData?.members ?? [])); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const fromOptions = useMemo(() => collectFromOptions(messages), [messages]); diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index b5954ca2..78965e85 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -7,6 +7,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { @@ -105,13 +106,25 @@ export const MessagesPanel = memo(function MessagesPanel({ onRestartTeam, onTaskIdClick, }: MessagesPanelProps): React.JSX.Element { - const sendTeamMessage = useStore((s) => s.sendTeamMessage); - const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage); - const sendingMessage = useStore((s) => s.sendingMessage); - const sendMessageError = useStore((s) => s.sendMessageError); - const lastSendMessageResult = useStore((s) => s.lastSendMessageResult); - const teams = useStore((s) => s.teams); - const openTeamTab = useStore((s) => s.openTeamTab); + const { + sendTeamMessage, + sendCrossTeamMessage, + sendingMessage, + sendMessageError, + lastSendMessageResult, + teams, + openTeamTab, + } = useStore( + useShallow((s) => ({ + sendTeamMessage: s.sendTeamMessage, + sendCrossTeamMessage: s.sendCrossTeamMessage, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + lastSendMessageResult: s.lastSendMessageResult, + teams: s.teams, + openTeamTab: s.openTeamTab, + })) + ); const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); diff --git a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx index d58fe282..2566d72c 100644 --- a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx +++ b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx @@ -10,6 +10,7 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react'; import { CliLogsRichView } from '../CliLogsRichView'; @@ -72,11 +73,13 @@ export const ScheduleRunLogDialog = ({ onClose, }: ScheduleRunLogDialogProps): React.JSX.Element => { // Read live run data from store — falls back to initial prop if not found - const liveRun = useStore((s) => { - if (!initialRun) return null; - const runs = s.scheduleRuns[scheduleId] ?? []; - return runs.find((r) => r.id === initialRun.id) ?? initialRun; - }); + const liveRun = useStore( + useShallow((s) => { + if (!initialRun) return null; + const runs = s.scheduleRuns[scheduleId] ?? []; + return runs.find((r) => r.id === initialRun.id) ?? initialRun; + }) + ); const run = liveRun ?? initialRun; const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null); diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index 11cbcc19..02204416 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { ChevronDown, @@ -57,7 +58,7 @@ const ScheduleRow = ({ }: ScheduleRowProps): React.JSX.Element => { const [expanded, setExpanded] = useState(false); const [selectedRun, setSelectedRun] = useState(null); - const runs = useStore((s) => s.scheduleRuns[schedule.id] ?? []); + const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? [])); const runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false); const fetchRunHistory = useStore((s) => s.fetchRunHistory); @@ -207,17 +208,22 @@ const ScheduleRow = ({ // ============================================================================= export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => { - const schedules = useStore((s) => s.schedules.filter((sch) => sch.teamName === teamName)); - const pauseSchedule = useStore((s) => s.pauseSchedule); - const resumeSchedule = useStore((s) => s.resumeSchedule); - const deleteSchedule = useStore((s) => s.deleteSchedule); - const triggerNow = useStore((s) => s.triggerNow); + const { schedules, pauseSchedule, resumeSchedule, deleteSchedule, triggerNow, fetchSchedules } = + useStore( + useShallow((s) => ({ + schedules: s.schedules.filter((sch) => sch.teamName === teamName), + pauseSchedule: s.pauseSchedule, + resumeSchedule: s.resumeSchedule, + deleteSchedule: s.deleteSchedule, + triggerNow: s.triggerNow, + fetchSchedules: s.fetchSchedules, + })) + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); // Fetch schedules on mount - const fetchSchedules = useStore((s) => s.fetchSchedules); useEffect(() => { void fetchSchedules(); }, [fetchSchedules]); diff --git a/src/renderer/components/team/sidebar/TeamSidebarHost.tsx b/src/renderer/components/team/sidebar/TeamSidebarHost.tsx index b36388bd..cb0367ef 100644 --- a/src/renderer/components/team/sidebar/TeamSidebarHost.tsx +++ b/src/renderer/components/team/sidebar/TeamSidebarHost.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useId, useLayoutEffect, useState } from 'react'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { removeTeamSidebarHost, @@ -33,10 +34,12 @@ export const TeamSidebarHost = ({ }: TeamSidebarHostProps): React.JSX.Element => { const hostId = useId(); const [element, setElement] = useState(null); - const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({ - messagesPanelMode: s.messagesPanelMode, - messagesPanelWidth: s.messagesPanelWidth, - })); + const { messagesPanelMode, messagesPanelWidth } = useStore( + useShallow((s) => ({ + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + })) + ); const snapshot = useTeamSidebarPortalSnapshot(); const isVisible = messagesPanelMode === 'sidebar'; const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId; diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx index 3a2f7a4b..e460f174 100644 --- a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx +++ b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx @@ -7,6 +7,7 @@ import { useMemo } from 'react'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; @@ -84,9 +85,13 @@ export const GraphTaskCard = ({ }: GraphTaskCardProps): React.JSX.Element => { const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; - const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId)); - const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []); - const members = useStore((s) => s.selectedTeamData?.members ?? []); + const { task, tasks, members } = useStore( + useShallow((s) => ({ + task: s.selectedTeamData?.tasks.find((t) => t.id === taskId), + tasks: s.selectedTeamData?.tasks ?? [], + members: s.selectedTeamData?.members ?? [], + })) + ); const taskMap = useMemo(() => { const map = new Map(); diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index a7831d6d..1fa00ead 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -6,6 +6,7 @@ */ import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import type { CliInstallationStatus } from '@shared/types'; @@ -33,20 +34,39 @@ export function useCliInstaller(): { installCli: () => void; isBusy: boolean; } { - const cliStatus = useStore((s) => s.cliStatus); - const cliStatusLoading = useStore((s) => s.cliStatusLoading); - const cliStatusError = useStore((s) => s.cliStatusError); - const installerState = useStore((s) => s.cliInstallerState); - const downloadProgress = useStore((s) => s.cliDownloadProgress); - const downloadTransferred = useStore((s) => s.cliDownloadTransferred); - const downloadTotal = useStore((s) => s.cliDownloadTotal); - const installerError = useStore((s) => s.cliInstallerError); - const installerDetail = useStore((s) => s.cliInstallerDetail); - const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); - const completedVersion = useStore((s) => s.cliCompletedVersion); - const fetchCliStatus = useStore((s) => s.fetchCliStatus); - const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); - const installCli = useStore((s) => s.installCli); + const { + cliStatus, + cliStatusLoading, + cliStatusError, + installerState, + downloadProgress, + downloadTransferred, + downloadTotal, + installerError, + installerDetail, + installerRawChunks, + completedVersion, + fetchCliStatus, + invalidateCliStatus, + installCli, + } = useStore( + useShallow((s) => ({ + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, + cliStatusError: s.cliStatusError, + installerState: s.cliInstallerState, + downloadProgress: s.cliDownloadProgress, + downloadTransferred: s.cliDownloadTransferred, + downloadTotal: s.cliDownloadTotal, + installerError: s.cliInstallerError, + installerDetail: s.cliInstallerDetail, + installerRawChunks: s.cliInstallerRawChunks, + completedVersion: s.cliCompletedVersion, + fetchCliStatus: s.fetchCliStatus, + invalidateCliStatus: s.invalidateCliStatus, + installCli: s.installCli, + })) + ); const isBusy = installerState !== 'idle' && installerState !== 'error'; diff --git a/src/renderer/hooks/useTabUI.ts b/src/renderer/hooks/useTabUI.ts index 91a06a1c..fd7c3ec2 100644 --- a/src/renderer/hooks/useTabUI.ts +++ b/src/renderer/hooks/useTabUI.ts @@ -65,7 +65,7 @@ export function useTabUI(): UseTabUIReturn { // Subscribe to tabUIStates MAP directly for reactivity // This ensures re-renders when any tab state changes - const tabUIStates = useStore((s) => s.tabUIStates); + const tabUIStates = useStore(useShallow((s) => s.tabUIStates)); // Get the current tab's state (derived from subscribed state) const tabState = useMemo(() => { diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index 77a53e40..0c21e6e3 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; @@ -56,10 +57,14 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean { } export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { - const globalTasks = useStore((s) => s.globalTasks); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const selectedTeamData = useStore((s) => s.selectedTeamData); - const teamByName = useStore((s) => s.teamByName); + const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore( + useShallow((s) => ({ + globalTasks: s.globalTasks, + selectedTeamName: s.selectedTeamName, + selectedTeamData: s.selectedTeamData, + teamByName: s.teamByName, + })) + ); const suggestions = useMemo(() => { const tasks: TaskWithTeamContext[] = []; diff --git a/src/renderer/hooks/useTeamSuggestions.ts b/src/renderer/hooks/useTeamSuggestions.ts index a2db4171..9b376167 100644 --- a/src/renderer/hooks/useTeamSuggestions.ts +++ b/src/renderer/hooks/useTeamSuggestions.ts @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -26,7 +27,7 @@ export interface UseTeamSuggestionsResult { * @param currentTeamName - The current team name to exclude from suggestions */ export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult { - const teams = useStore((s) => s.teams); + const teams = useStore(useShallow((s) => s.teams)); const [aliveTeams, setAliveTeams] = useState>(new Set()); const [loading, setLoading] = useState(false); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 27ef20db..f6f2786f 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -175,6 +175,19 @@ export function initializeNotificationListeners(): () => void { const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; + /** Cap a Map at maxSize by clearing oldest entries (FIFO via insertion order). */ + const capTimerMap = (map: Map>, maxSize: number): void => { + if (map.size <= maxSize) return; + const excess = map.size - maxSize; + let cleared = 0; + for (const [key, value] of map) { + if (cleared >= excess) break; + clearTimeout(value); + map.delete(key); + cleared++; + } + }; + const buildToolActivityTimerKey = ( teamName: string, memberName: string, @@ -209,6 +222,7 @@ export function initializeNotificationListeners(): () => void { cb(); }, delayMs); toolActivityTimers.set(key, timer); + capTimerMap(toolActivityTimers, 200); }; const clearToolActivityTimersForTeam = (teamName: string): void => { for (const [key, timer] of toolActivityTimers.entries()) { @@ -318,6 +332,16 @@ export function initializeNotificationListeners(): () => void { return; } + // Cleanup cursors for teams that no longer exist (prevent unbounded growth) + if (inProgressChangePresenceCursorByTeam.size > 50) { + const teamNames = new Set(useStore.getState().teams.map((t) => t.teamName)); + for (const key of inProgressChangePresenceCursorByTeam.keys()) { + if (!teamNames.has(key)) { + inProgressChangePresenceCursorByTeam.delete(key); + } + } + } + const candidateTasks = selectedTeamData.tasks.filter((task) => { if (task.status !== 'in_progress') { return false; @@ -376,6 +400,7 @@ export function initializeNotificationListeners(): () => void { void state.refreshSessionInPlace(projectId, sessionId); }, SESSION_REFRESH_DEBOUNCE_MS); pendingSessionRefreshTimers.set(key, timer); + capTimerMap(pendingSessionRefreshTimers, 50); }; const scheduleProjectRefresh = (projectId: string): void => { @@ -389,6 +414,7 @@ export function initializeNotificationListeners(): () => void { void state.refreshSessionsInPlace(projectId); }, PROJECT_REFRESH_DEBOUNCE_MS); pendingProjectRefreshTimers.set(projectId, timer); + capTimerMap(pendingProjectRefreshTimers, 20); }; // Listen for new notifications from main process @@ -933,6 +959,7 @@ export function initializeNotificationListeners(): () => void { void current.refreshTeamData(event.teamName); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); + capTimerMap(teamRefreshTimers, 20); return; } @@ -949,6 +976,7 @@ export function initializeNotificationListeners(): () => void { void current.refreshSelectedTeamChangePresence(event.teamName); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); teamPresenceRefreshTimers.set(event.teamName, timer); + capTimerMap(teamPresenceRefreshTimers, 20); return; } @@ -984,6 +1012,7 @@ export function initializeNotificationListeners(): () => void { void current.refreshTeamData(event.teamName); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); + capTimerMap(teamRefreshTimers, 20); }); if (typeof cleanup === 'function') {