perf: offload heavy I/O to worker thread, reduce renderer re-renders

Main process — worker thread for team data:
- New team-data-worker thread handles getTeamData and findLogsForTask,
  isolating heavy file I/O (scanning 300+ subagent JSONL files) from
  Electron's main event loop. getTeamData dropped from ~2000ms on the
  main thread to ~110ms via the worker.
- Worker-side dedup and 10s result cache for findLogsForTask prevents
  redundant scans when the same task is queried multiple times.
- Discovery cache TTL raised from 5s to 30s — avoids re-scanning the
  entire project directory on every call.
- Message cap at 200 in TeamDataService to keep IPC payloads under 1MB
  (was sending 2200+ messages / ~3MB, stalling Chromium IPC serialization).
- IPC handlers fall back to main-thread execution if the worker is
  unavailable (graceful degradation).

Renderer — useShallow and memoization (55 files):
- Added useShallow to store selectors across 55 renderer files. Batched
  individual useStore() calls (e.g. 17 calls in ExtensionStoreView,
  10 in ConnectionSection) into single useShallow selectors, cutting
  unnecessary re-render checks on every store update.
- MemberLogsTab: three 5-second polling intervals now pause when the
  parent tab is hidden (display:none). Previously 5 hidden tabs × 3
  intervals = 15 polling timers firing continuously.
- KanbanColumn wrapped in React.memo to skip re-renders when props
  haven't changed.
- MemberList: memoized activeMembers/removedMembers/colorMap; replaced
  O(n×m) per-member task scan with a pre-computed reviewer map.
- Bounded timer Maps in store initialization to prevent unbounded growth
  of debounce/throttle tracking maps during long sessions.
This commit is contained in:
Artem Rootman 2026-04-05 16:21:05 +00:00
parent 7ff9317b6f
commit da58917032
No known key found for this signature in database
GPG key ID: B7C30676209A822C
61 changed files with 843 additions and 232 deletions

View file

@ -77,7 +77,8 @@ export default defineConfig({
input: { input: {
index: resolve(__dirname, 'src/main/index.ts'), index: resolve(__dirname, 'src/main/index.ts'),
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.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: { output: {
// CJS format so bundled deps can use __dirname/require. // CJS format so bundled deps can use __dirname/require.

View file

@ -1,5 +1,6 @@
import { addMainBreadcrumb } from '@main/sentry'; import { addMainBreadcrumb } from '@main/sentry';
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient';
import { getAppIconPath } from '@main/utils/appIcon'; import { getAppIconPath } from '@main/utils/appIcon';
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { stripMarkdown } from '@main/utils/textFormatting'; import { stripMarkdown } from '@main/utils/textFormatting';
@ -535,7 +536,20 @@ async function handleGetData(
const startedAt = Date.now(); const startedAt = Date.now();
let data: TeamData; let data: TeamData;
try { 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if ( if (
@ -555,6 +569,7 @@ async function handleGetData(
return { success: false, error: message }; return { success: false, error: message };
} }
const getDataMs = Date.now() - startedAt; const getDataMs = Date.now() - startedAt;
if (getDataMs >= 1500) { if (getDataMs >= 1500) {
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
} }
@ -2138,6 +2153,19 @@ async function handleGetLogsForTask(
: undefined, : undefined,
} }
: 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', () => return wrapTeamHandler('getLogsForTask', () =>
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts) getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
); );

View file

@ -728,12 +728,19 @@ export class TeamDataService {
this.processHealthTeams.delete(teamName); 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 { return {
teamName, teamName,
config, config,
tasks: tasksWithKanban, tasks: tasksWithKanban,
members, members,
messages, messages: cappedMessages,
kanbanState, kanbanState,
processes, processes,
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,

View file

@ -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<string, PendingEntry>();
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<unknown> {
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<TeamData> {
return this.call('getTeamData', { teamName }) as Promise<TeamData>;
}
async findLogsForTask(
teamName: string,
taskId: string,
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
): Promise<MemberLogSummary[]> {
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;
}

View file

@ -31,7 +31,7 @@ const ATTRIBUTION_CACHE_MAX = 5_000;
const SCAN_CONCURRENCY = 15; const SCAN_CONCURRENCY = 15;
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */ /** 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. */ /** Signal sources for subagent member attribution, ordered by reliability. */
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention'; type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';

View file

@ -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 };

View file

@ -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<string, Promise<unknown>>();
// Result cache with TTL to avoid re-scanning files
const logsResultCache = new Map<string, { result: MemberLogSummary[]; cachedAt: number }>();
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<MemberLogSummary[]> | 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');

View file

@ -171,7 +171,7 @@ const AIChatGroupInner = ({
); );
// Notification color map for tool item dots // Notification color map for tool item dots
const notifications = useStore((s) => s.notifications); const notifications = useStore(useShallow((s) => s.notifications));
const notificationColorMap = useMemo(() => { const notificationColorMap = useMemo(() => {
const map = new Map<string, TriggerColor>(); const map = new Map<string, TriggerColor>();
for (const n of notifications) { for (const n of notifications) {

View file

@ -128,7 +128,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null; const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null;
const pendingNavigation = thisTab?.pendingNavigation; 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 // Look up whether this session belongs to a team
const sessionTeam = useMemo(() => { const sessionTeam = useMemo(() => {

View file

@ -388,15 +388,17 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath; return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath;
}); });
// Get team members for @mention highlighting // Get team members for @mention highlighting and team names for @team linkification
const members = useStore((s) => s.selectedTeamData?.members); const { members, teams } = useStore(
useShallow((s) => ({
members: s.selectedTeamData?.members,
teams: s.teams,
}))
);
const memberColorMap = useMemo( const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()), () => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members] [members]
); );
// Get team names for @team linkification
const teams = useStore((s) => s.teams);
const teamNames = useMemo( const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams] [teams]

View file

@ -20,6 +20,7 @@ import {
import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters'; import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
@ -82,7 +83,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
// Agent configs from .claude/agents/ for color lookup // 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) // Team member colors (when this subagent is a team member)
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
@ -171,7 +172,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
}, [subagent.messages]); }, [subagent.messages]);
// Search expansion // Search expansion
const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds); const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds));
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id);

View file

@ -10,6 +10,7 @@ import {
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters'; import { formatTokensCompact } from '@renderer/utils/formatters';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -85,14 +86,14 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
const { isLight } = useTheme(); const { isLight } = useTheme();
// Get team members for @mention highlighting // Get team members for @mention highlighting
const members = useStore((s) => s.selectedTeamData?.members); const members = useStore(useShallow((s) => s.selectedTeamData?.members));
const memberColorMap = useMemo( const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()), () => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members] [members]
); );
// Get team names for @team linkification // Get team names for @team linkification
const teams = useStore((s) => s.teams); const teams = useStore(useShallow((s) => s.teams));
const teamNames = useMemo( const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams] [teams]

View file

@ -690,7 +690,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
const [showRaw, setShowRaw] = React.useState(false); const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme(); 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 openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
const fallbackTeamColorByName = React.useMemo(() => { const fallbackTeamColorByName = React.useMemo(() => {

View file

@ -8,10 +8,11 @@
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
export const CliInstallWarningBanner = (): React.JSX.Element | null => { 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); const openDashboard = useStore((s) => s.openDashboard);
// Returns a primitive boolean — minimizes re-renders // Returns a primitive boolean — minimizes re-renders

View file

@ -11,6 +11,7 @@
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react'; import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
interface ConnectionStatusBadgeProps { interface ConnectionStatusBadgeProps {
contextId: string; contextId: string;
@ -21,10 +22,12 @@ export const ConnectionStatusBadge = ({
contextId, contextId,
className, className,
}: Readonly<ConnectionStatusBadgeProps>): React.JSX.Element => { }: Readonly<ConnectionStatusBadgeProps>): React.JSX.Element => {
const { connectionState, connectedHost } = useStore((s) => ({ const { connectionState, connectedHost } = useStore(
connectionState: s.connectionState, useShallow((s) => ({
connectedHost: s.connectedHost, connectionState: s.connectionState,
})); connectedHost: s.connectedHost,
}))
);
// Local context always shows Monitor icon // Local context always shows Monitor icon
if (contextId === 'local') { if (contextId === 'local') {

View file

@ -6,14 +6,26 @@
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { CheckCircle, Loader2, X } from 'lucide-react'; import { CheckCircle, Loader2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
export const UpdateBanner = (): React.JSX.Element | null => { export const UpdateBanner = (): React.JSX.Element | null => {
const showUpdateBanner = useStore((s) => s.showUpdateBanner); const {
const updateStatus = useStore((s) => s.updateStatus); showUpdateBanner,
const downloadProgress = useStore((s) => s.downloadProgress); updateStatus,
const availableVersion = useStore((s) => s.availableVersion); downloadProgress,
const installUpdate = useStore((s) => s.installUpdate); availableVersion,
const dismissUpdateBanner = useStore((s) => s.dismissUpdateBanner); 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')) { if (!showUpdateBanner || (updateStatus !== 'downloading' && updateStatus !== 'downloaded')) {
return null; return null;

View file

@ -15,15 +15,28 @@ import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { ExternalLink, X } from 'lucide-react'; import { ExternalLink, X } from 'lucide-react';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
export const UpdateDialog = (): React.JSX.Element | null => { export const UpdateDialog = (): React.JSX.Element | null => {
const showUpdateDialog = useStore((s) => s.showUpdateDialog); const {
const updateStatus = useStore((s) => s.updateStatus); showUpdateDialog,
const availableVersion = useStore((s) => s.availableVersion); updateStatus,
const releaseNotes = useStore((s) => s.releaseNotes); availableVersion,
const downloadUpdate = useStore((s) => s.downloadUpdate); releaseNotes,
const installUpdate = useStore((s) => s.installUpdate); downloadUpdate,
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog); 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<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);

View file

@ -18,6 +18,7 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel'; import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
@ -29,18 +30,35 @@ import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
export const ExtensionStoreView = (): React.JSX.Element => { export const ExtensionStoreView = (): React.JSX.Element => {
const tabId = useTabIdOptional(); const tabId = useTabIdOptional();
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog); const {
const fetchApiKeys = useStore((s) => s.fetchApiKeys); fetchPluginCatalog,
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); fetchApiKeys,
const mcpBrowse = useStore((s) => s.mcpBrowse); fetchSkillsCatalog,
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled); mcpBrowse,
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading); mcpFetchInstalled,
const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading); pluginCatalogLoading,
const skillsLoading = useStore((s) => s.skillsLoading); mcpBrowseLoading,
const cliStatus = useStore((s) => s.cliStatus); skillsLoading,
const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked cliStatus,
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing)); sessions,
const projects = useStore((s) => s.projects); 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) => const extensionsTabProjectId = useStore((s) =>
tabId tabId
? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId) ? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId)

View file

@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
import { ApiKeyCard } from './ApiKeyCard'; import { ApiKeyCard } from './ApiKeyCard';
@ -15,11 +16,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog';
import type { ApiKeyEntry } from '@shared/types/extensions'; import type { ApiKeyEntry } from '@shared/types/extensions';
export const ApiKeysPanel = (): React.JSX.Element => { export const ApiKeysPanel = (): React.JSX.Element => {
const apiKeys = useStore((s) => s.apiKeys); const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore(
const apiKeysLoading = useStore((s) => s.apiKeysLoading); useShallow((s) => ({
const apiKeysError = useStore((s) => s.apiKeysError); apiKeys: s.apiKeys,
const storageStatus = useStore((s) => s.apiKeyStorageStatus); apiKeysLoading: s.apiKeysLoading,
const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus); apiKeysError: s.apiKeysError,
storageStatus: s.apiKeyStorageStatus,
fetchStorageStatus: s.fetchApiKeyStorageStatus,
}))
);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null); const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);

View file

@ -13,6 +13,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { Check, Loader2, Trash2 } from 'lucide-react'; import { Check, Loader2, Trash2 } from 'lucide-react';
import type { ExtensionOperationState } from '@shared/types/extensions'; import type { ExtensionOperationState } from '@shared/types/extensions';
@ -36,7 +37,7 @@ export const InstallButton = ({
size = 'sm', size = 'sm',
errorMessage, errorMessage,
}: InstallButtonProps) => { }: InstallButtonProps) => {
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore(useShallow((s) => s.cliStatus));
const cliMissing = cliStatus !== null && !cliStatus.installed; const cliMissing = cliStatus !== null && !cliStatus.installed;
const isDisabled = disabled || cliMissing; const isDisabled = disabled || cliMissing;
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);

View file

@ -14,6 +14,7 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatRelativeTime } from '@renderer/utils/formatters'; import { formatRelativeTime } from '@renderer/utils/formatters';
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
@ -72,18 +73,35 @@ export const McpServersPanel = ({
selectedMcpServerId, selectedMcpServerId,
setSelectedMcpServerId, setSelectedMcpServerId,
}: McpServersPanelProps): React.JSX.Element => { }: McpServersPanelProps): React.JSX.Element => {
const browseCatalog = useStore((s) => s.mcpBrowseCatalog); const {
const browseNextCursor = useStore((s) => s.mcpBrowseNextCursor); browseCatalog,
const browseLoading = useStore((s) => s.mcpBrowseLoading); browseNextCursor,
const browseError = useStore((s) => s.mcpBrowseError); browseLoading,
const mcpBrowse = useStore((s) => s.mcpBrowse); browseError,
const installedServers = useStore((s) => s.mcpInstalledServers); mcpBrowse,
const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars); installedServers,
const mcpDiagnostics = useStore((s) => s.mcpDiagnostics); fetchMcpGitHubStars,
const mcpDiagnosticsLoading = useStore((s) => s.mcpDiagnosticsLoading); mcpDiagnostics,
const mcpDiagnosticsError = useStore((s) => s.mcpDiagnosticsError); mcpDiagnosticsLoading,
const mcpDiagnosticsLastCheckedAt = useStore((s) => s.mcpDiagnosticsLastCheckedAt); mcpDiagnosticsError,
const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics); 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<McpSortValue>('name-asc'); const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');

View file

@ -24,6 +24,7 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { import {
getCapabilityLabel, getCapabilityLabel,
inferCapabilities, inferCapabilities,
@ -53,14 +54,18 @@ export const PluginDetailDialog = ({
open, open,
onClose, onClose,
}: PluginDetailDialogProps): React.JSX.Element => { }: PluginDetailDialogProps): React.JSX.Element => {
const fetchPluginReadme = useStore((s) => s.fetchPluginReadme); const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
const readmes = useStore((s) => s.pluginReadmes); useShallow((s) => ({
const readmeLoading = useStore((s) => s.pluginReadmeLoading); fetchPluginReadme: s.fetchPluginReadme,
readmes: s.pluginReadmes,
readmeLoading: s.pluginReadmeLoading,
installPlugin: s.installPlugin,
uninstallPlugin: s.uninstallPlugin,
}))
);
const installProgress = useStore( const installProgress = useStore(
(s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle' (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 installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
const [scope, setScope] = useState<InstallScope>('user'); const [scope, setScope] = useState<InstallScope>('user');

View file

@ -16,6 +16,7 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react'; import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
@ -122,9 +123,13 @@ export const PluginsPanel = ({
hasActiveFilters, hasActiveFilters,
setPluginSort, setPluginSort,
}: PluginsPanelProps): React.JSX.Element => { }: PluginsPanelProps): React.JSX.Element => {
const catalog = useStore((s) => s.pluginCatalog); const { catalog, loading, error } = useStore(
const loading = useStore((s) => s.pluginCatalogLoading); useShallow((s) => ({
const error = useStore((s) => s.pluginCatalogError); catalog: s.pluginCatalog,
loading: s.pluginCatalogLoading,
error: s.pluginCatalogError,
}))
);
const filtered = useMemo( const filtered = useMemo(
() => selectFilteredPlugins(catalog, pluginFilters, pluginSort), () => selectFilteredPlugins(catalog, pluginFilters, pluginSort),

View file

@ -23,6 +23,7 @@ import {
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react';
interface SkillDetailDialogProps { interface SkillDetailDialogProps {
@ -44,7 +45,7 @@ export const SkillDetailDialog = ({
}: SkillDetailDialogProps): React.JSX.Element => { }: SkillDetailDialogProps): React.JSX.Element => {
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const deleteSkill = useStore((s) => s.deleteSkill); 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) => const loading = useStore((s) =>
skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false skillId ? (s.skillsDetailLoadingById[skillId] ?? false) : false
); );

View file

@ -6,6 +6,7 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { import {
AlertTriangle, AlertTriangle,
ArrowUpAZ, ArrowUpAZ,
@ -94,10 +95,10 @@ export const SkillsPanel = ({
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail); const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false); const skillsLoading = useStore((s) => s.skillsCatalogLoadingByProjectPath[catalogKey] ?? false);
const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null); const skillsError = useStore((s) => s.skillsCatalogErrorByProjectPath[catalogKey] ?? null);
const detailById = useStore((s) => s.skillsDetailsById); const detailById = useStore(useShallow((s) => s.skillsDetailsById));
const userSkills = useStore((s) => s.skillsUserCatalog); const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
const projectSkills = useStore((s) => const projectSkills = useStore(
projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [] useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []))
); );
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);

View file

@ -6,12 +6,13 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { PaneResizeHandle } from './PaneResizeHandle'; import { PaneResizeHandle } from './PaneResizeHandle';
import { PaneView } from './PaneView'; import { PaneView } from './PaneView';
export const PaneContainer = (): React.JSX.Element => { export const PaneContainer = (): React.JSX.Element => {
const panes = useStore((s) => s.paneLayout.panes); const panes = useStore(useShallow((s) => s.paneLayout.panes));
return ( return (
<div id="pane-container" className="flex flex-1 overflow-hidden"> <div id="pane-container" className="flex flex-1 overflow-hidden">

View file

@ -6,6 +6,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
interface PaneResizeHandleProps { interface PaneResizeHandleProps {
leftPaneId: string; leftPaneId: string;
@ -15,7 +16,7 @@ interface PaneResizeHandleProps {
export const PaneResizeHandle = ({ leftPaneId }: PaneResizeHandleProps): React.JSX.Element => { export const PaneResizeHandle = ({ leftPaneId }: PaneResizeHandleProps): React.JSX.Element => {
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const resizePanes = useStore((s) => s.resizePanes); const resizePanes = useStore((s) => s.resizePanes);
const paneLayout = useStore((s) => s.paneLayout); const paneLayout = useStore(useShallow((s) => s.paneLayout));
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {

View file

@ -76,17 +76,19 @@ export const SortableTab = ({
) )
); );
const teamColorSet = useStore((s) => { const teamColorSet = useStore(
if (tab.type !== 'team' || !tab.teamName) return null; useShallow((s) => {
const team = s.teamByName[tab.teamName]; if (tab.type !== 'team' || !tab.teamName) return null;
const explicitColor = const team = s.teamByName[tab.teamName];
team?.color ?? const explicitColor =
(s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined); team?.color ??
if (explicitColor) return getTeamColorSet(explicitColor); (s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
// Fallback: deterministic color derived from display name if (explicitColor) return getTeamColorSet(explicitColor);
const displayName = team?.displayName ?? tab.label; // Fallback: deterministic color derived from display name
return nameColorSet(displayName); const displayName = team?.displayName ?? tab.label;
}); return nameColorSet(displayName);
})
);
const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)'; const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)';
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({

View file

@ -26,6 +26,7 @@ import { useFullScreen } from '@renderer/hooks/useFullScreen';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useZoomFactor } from '@renderer/hooks/useZoomFactor'; import { useZoomFactor } from '@renderer/hooks/useZoomFactor';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner'; import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner';
import { UpdateBanner } from '../common/UpdateBanner'; import { UpdateBanner } from '../common/UpdateBanner';
@ -54,7 +55,7 @@ export const TabbedLayout = (): React.JSX.Element => {
: getTrafficLightPaddingForZoom(zoomFactor); : getTrafficLightPaddingForZoom(zoomFactor);
// --- DnD state (lifted from PaneContainer) --- // --- DnD state (lifted from PaneContainer) ---
const panes = useStore((s) => s.paneLayout.panes); const panes = useStore(useShallow((s) => s.paneLayout.panes));
const [activeTab, setActiveTab] = useState<Tab | null>(null); const [activeTab, setActiveTab] = useState<Tab | null>(null);
const sensors = useSensors( const sensors = useSensors(

View file

@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { computeTakeaways } from '@renderer/utils/reportAssessments'; import { computeTakeaways } from '@renderer/utils/reportAssessments';
import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
@ -25,11 +26,13 @@ interface SessionReportTabProps {
export const SessionReportTab = ({ tab }: SessionReportTabProps) => { export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
// Find session data from any session tab with matching sessionId // Find session data from any session tab with matching sessionId
const sessionDetail = useStore((s) => { const sessionDetail = useStore(
const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); useShallow((s) => {
const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId); const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs);
return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId);
}); return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null;
})
);
const report = useMemo( const report = useMemo(
() => (sessionDetail ? analyzeSession(sessionDetail) : null), () => (sessionDetail ? analyzeSession(sessionDetail) : null),

View file

@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import { import {
@ -68,7 +69,7 @@ const ScheduleListItem = ({
}: ScheduleListItemProps): React.JSX.Element => { }: ScheduleListItemProps): React.JSX.Element => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null); const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(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 runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
const fetchRunHistory = useStore((s) => s.fetchRunHistory); const fetchRunHistory = useStore((s) => s.fetchRunHistory);
@ -240,15 +241,29 @@ const ScheduleListItem = ({
// ============================================================================= // =============================================================================
export const SchedulesView = (): React.JSX.Element => { export const SchedulesView = (): React.JSX.Element => {
const schedules = useStore((s) => s.schedules); const {
const schedulesLoading = useStore((s) => s.schedulesLoading); schedules,
const fetchSchedules = useStore((s) => s.fetchSchedules); schedulesLoading,
const pauseSchedule = useStore((s) => s.pauseSchedule); fetchSchedules,
const resumeSchedule = useStore((s) => s.resumeSchedule); pauseSchedule,
const deleteSchedule = useStore((s) => s.deleteSchedule); resumeSchedule,
const triggerNow = useStore((s) => s.triggerNow); deleteSchedule,
const openTeamTab = useStore((s) => s.openTeamTab); triggerNow,
const teamByName = useStore((s) => s.teamByName); 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 */ /** Resolve team color dot style for a given team name */
const getTeamColor = useCallback( const getTeamColor = useCallback(

View file

@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useSettingsConfig, useSettingsHandlers } from './hooks'; import { useSettingsConfig, useSettingsHandlers } from './hooks';
import { import {
@ -19,8 +20,12 @@ import { type SettingsSection, SettingsTabs } from './SettingsTabs';
export const SettingsView = (): React.JSX.Element | null => { export const SettingsView = (): React.JSX.Element | null => {
const [activeSection, setActiveSection] = useState<SettingsSection>('general'); const [activeSection, setActiveSection] = useState<SettingsSection>('general');
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); const { pendingSettingsSection, clearPendingSettingsSection } = useStore(
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); useShallow((s) => ({
pendingSettingsSection: s.pendingSettingsSection,
clearPendingSettingsSection: s.clearPendingSettingsSection,
}))
);
// Consume pending section (avoid setState during render) // Consume pending section (avoid setState during render)
useEffect(() => { useEffect(() => {

View file

@ -8,6 +8,7 @@ import { api, isElectronMode } from '@renderer/api';
import appIcon from '@renderer/favicon.png'; import appIcon from '@renderer/favicon.png';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react'; import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SettingsSectionHeader } from '../components'; import { SettingsSectionHeader } from '../components';
@ -32,9 +33,13 @@ export const AdvancedSection = ({
const isElectron = useMemo(() => isElectronMode(), []); const isElectron = useMemo(() => isElectronMode(), []);
const [version, setVersion] = useState<string>(''); const [version, setVersion] = useState<string>('');
const [configEditorOpen, setConfigEditorOpen] = useState(false); const [configEditorOpen, setConfigEditorOpen] = useState(false);
const updateStatus = useStore((s) => s.updateStatus); const { updateStatus, availableVersion, checkForUpdates } = useStore(
const availableVersion = useStore((s) => s.availableVersion); useShallow((s) => ({
const checkForUpdates = useStore((s) => s.checkForUpdates); updateStatus: s.updateStatus,
availableVersion: s.availableVersion,
checkForUpdates: s.checkForUpdates,
}))
);
// Auto-revert "not-available" / "error" status back to idle after a brief display // Auto-revert "not-available" / "error" status back to idle after a brief display
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);

View file

@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react'; import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SettingRow } from '../components/SettingRow'; import { SettingRow } from '../components/SettingRow';
import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
@ -37,16 +38,31 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
]; ];
export const ConnectionSection = (): React.JSX.Element => { export const ConnectionSection = (): React.JSX.Element => {
const connectionState = useStore((s) => s.connectionState); const {
const connectedHost = useStore((s) => s.connectedHost); connectionState,
const connectionError = useStore((s) => s.connectionError); connectedHost,
const connectSsh = useStore((s) => s.connectSsh); connectionError,
const disconnectSsh = useStore((s) => s.disconnectSsh); connectSsh,
const testConnection = useStore((s) => s.testConnection); disconnectSsh,
const sshConfigHosts = useStore((s) => s.sshConfigHosts); testConnection,
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts); sshConfigHosts,
const lastSshConfig = useStore((s) => s.lastSshConfig); fetchSshConfigHosts,
const loadLastConnection = useStore((s) => s.loadLastConnection); 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 // Form state
const [host, setHost] = useState(''); const [host, setHost] = useState('');

View file

@ -12,6 +12,7 @@ import { useStore } from '@renderer/store';
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers'; import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage'; import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react'; import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components'; import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components';
@ -50,9 +51,13 @@ export const GeneralSection = ({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Claude Root state // Claude Root state
const connectionMode = useStore((s) => s.connectionMode); const { connectionMode, fetchProjects, fetchRepositoryGroups } = useStore(
const fetchProjects = useStore((s) => s.fetchProjects); useShallow((s) => ({
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups); connectionMode: s.connectionMode,
fetchProjects: s.fetchProjects,
fetchRepositoryGroups: s.fetchRepositoryGroups,
}))
);
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null); const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false); const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);

View file

@ -5,6 +5,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { projectColor } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor';
@ -78,7 +79,7 @@ export const SidebarTaskItem = ({
getDisplaySubject, getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element => { }: SidebarTaskItemProps): React.JSX.Element => {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); 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 unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const { isLight } = useTheme(); const { isLight } = useTheme();

View file

@ -1,4 +1,5 @@
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatDistanceToNowStrict } from 'date-fns'; import { formatDistanceToNowStrict } from 'date-fns';
import { ExternalLink, Square, Terminal } from 'lucide-react'; import { ExternalLink, Square, Terminal } from 'lucide-react';
@ -25,7 +26,7 @@ function formatShortTime(date: Date): string {
export const ProcessesSection = (): React.JSX.Element | null => { export const ProcessesSection = (): React.JSX.Element | null => {
const teamName = useStore((s) => s.selectedTeamName); 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; if (!teamName || !data?.processes?.length) return null;

View file

@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
@ -69,10 +70,14 @@ export const TaskTooltip = ({
children, children,
side = 'top', side = 'top',
}: TaskTooltipProps): React.JSX.Element => { }: TaskTooltipProps): React.JSX.Element => {
const selectedTeamName = useStore((s) => s.selectedTeamName); const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
const selectedTeamData = useStore((s) => s.selectedTeamData); useShallow((s) => ({
const globalTasks = useStore((s) => s.globalTasks); selectedTeamName: s.selectedTeamName,
const teamByName = useStore((s) => s.teamByName); selectedTeamData: s.selectedTeamData,
globalTasks: s.globalTasks,
teamByName: s.teamByName,
}))
);
const task = useMemo(() => { const task = useMemo(() => {
if (teamName && selectedTeamName === teamName) { if (teamName && selectedTeamName === teamName) {

View file

@ -457,6 +457,9 @@ export const TeamDetailView = ({
messagesPanelWidth, messagesPanelWidth,
setMessagesPanelMode, setMessagesPanelMode,
setMessagesPanelWidth, setMessagesPanelWidth,
selectReviewFile,
pendingReviewRequest,
setPendingReviewRequest,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
data: s.selectedTeamData, data: s.selectedTeamData,
@ -506,6 +509,9 @@ export const TeamDetailView = ({
messagesPanelWidth: s.messagesPanelWidth, messagesPanelWidth: s.messagesPanelWidth,
setMessagesPanelMode: s.setMessagesPanelMode, setMessagesPanelMode: s.setMessagesPanelMode,
setMessagesPanelWidth: s.setMessagesPanelWidth, setMessagesPanelWidth: s.setMessagesPanelWidth,
selectReviewFile: s.selectReviewFile,
pendingReviewRequest: s.pendingReviewRequest,
setPendingReviewRequest: s.setPendingReviewRequest,
})) }))
); );
@ -961,10 +967,6 @@ export const TeamDetailView = ({
} }
}, [teamName, refreshTeamData]); }, [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 // Pick up pending review request from GlobalTaskDetailDialog
useEffect(() => { useEffect(() => {
if (!pendingReviewRequest) return; if (!pendingReviewRequest) return;

View file

@ -224,6 +224,8 @@ export const TeamListView = (): React.JSX.Element => {
fetchTeams, fetchTeams,
openTeamTab, openTeamTab,
deleteTeam, deleteTeam,
restoreTeam,
permanentlyDeleteTeam,
projects, projects,
globalTasks, globalTasks,
fetchAllTasks, fetchAllTasks,
@ -232,6 +234,7 @@ export const TeamListView = (): React.JSX.Element => {
selectedRepositoryId, selectedRepositoryId,
selectedWorktreeId, selectedWorktreeId,
activeProjectId, activeProjectId,
branchByPath,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
teams: s.teams, teams: s.teams,
@ -250,6 +253,7 @@ export const TeamListView = (): React.JSX.Element => {
selectedRepositoryId: s.selectedRepositoryId, selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId, selectedWorktreeId: s.selectedWorktreeId,
activeProjectId: s.activeProjectId, activeProjectId: s.activeProjectId,
branchByPath: s.branchByPath,
})) }))
); );
const { const {
@ -432,10 +436,6 @@ export const TeamListView = (): React.JSX.Element => {
[filteredTeams] [filteredTeams]
); );
useBranchSync(teamPaths, { live: false }); 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( const handleDeleteTeam = useCallback(
(teamName: string, isDraft: boolean, e: React.MouseEvent) => { (teamName: string, isDraft: boolean, e: React.MouseEvent) => {

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
@ -141,12 +142,23 @@ function useElapsed(receivedAt: string): number {
const RESPOND_TIMEOUT_MS = 10_000; const RESPOND_TIMEOUT_MS = 10_000;
export const ToolApprovalSheet: React.FC = () => { export const ToolApprovalSheet: React.FC = () => {
const pendingApprovals = useStore((s) => s.pendingApprovals); const {
const respondToToolApproval = useStore((s) => s.respondToToolApproval); pendingApprovals,
const updateToolApprovalSettings = useStore((s) => s.updateToolApprovalSettings); respondToToolApproval,
const teams = useStore((s) => s.teams); updateToolApprovalSettings,
const selectedTeamName = useStore((s) => s.selectedTeamName); teams,
const selectedTeamData = useStore((s) => s.selectedTeamData); 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 { isLight } = useTheme();
const current: ToolApprovalRequest | undefined = pendingApprovals[0]; const current: ToolApprovalRequest | undefined = pendingApprovals[0];
@ -606,7 +618,7 @@ const ToolInputPreview = ({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Element | null => { 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); const elapsed = useElapsed(receivedAt);
if (settings.timeoutAction === 'wait') return null; if (settings.timeoutAction === 'wait') return null;

View file

@ -17,6 +17,7 @@ import {
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { import {
getMessageTypeLabel, getMessageTypeLabel,
getStructuredMessageSummary, getStructuredMessageSummary,
@ -601,8 +602,8 @@ export const ActivityItem = memo(
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
// Permission request status icon (check/x/clock) // Permission request status icon (check/x/clock)
const pendingApprovals = useStore((s) => s.pendingApprovals); const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
const resolvedApprovals = useStore((s) => s.resolvedApprovals); const resolvedApprovals = useStore(useShallow((s) => s.resolvedApprovals));
const permissionIcon = useMemo(() => { const permissionIcon = useMemo(() => {
if (!structured) return null; if (!structured) return null;
const type = typeof structured.type === 'string' ? structured.type : null; const type = typeof structured.type === 'string' ? structured.type : null;

View file

@ -2,6 +2,7 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
agentAvatarUrl, agentAvatarUrl,
@ -33,7 +34,7 @@ export const PendingRepliesBlock = ({
onMemberClick, onMemberClick,
}: PendingRepliesBlockProps): React.JSX.Element | null => { }: PendingRepliesBlockProps): React.JSX.Element | null => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const pendingApprovals = useStore((s) => s.pendingApprovals); const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
const colorMap = buildMemberColorMap(members); const colorMap = buildMemberColorMap(members);
const memberPending = Object.entries(pendingRepliesByMember) const memberPending = Object.entries(pendingRepliesByMember)
.map(([name, sentAtMs]) => ({ .map(([name, sentAtMs]) => ({

View file

@ -23,6 +23,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize'; 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) // Team name: always present for launch mode, may be absent in schedule mode (standalone page)
const propsTeamName = props.teamName ?? ''; const propsTeamName = props.teamName ?? '';
const [selectedTeamName, setSelectedTeamName] = useState(''); const [selectedTeamName, setSelectedTeamName] = useState('');
const teamByName = useStore((s) => s.teamByName); const { teamByName, openDashboard } = useStore(
const openDashboard = useStore((s) => s.openDashboard); useShallow((s) => ({
teamByName: s.teamByName,
openDashboard: s.openDashboard,
}))
);
const teamOptions = useMemo( const teamOptions = useMemo(
() => () =>
Object.values(teamByName) Object.values(teamByName)
@ -401,7 +406,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// Shared effects: projects // Shared effects: projects
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const repositoryGroups = useStore((s) => s.repositoryGroups); const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
useEffect(() => { useEffect(() => {
if (!open) return; 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) // 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 members = isLaunch ? props.members : storeMembers;
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);

View file

@ -9,6 +9,7 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types'; import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types';
@ -42,7 +43,7 @@ export const ToolApprovalSettingsContent: React.FC<{
teamName?: string; teamName?: string;
}> = ({ expanded, teamName }) => { }> = ({ expanded, teamName }) => {
const [localSeconds, setLocalSeconds] = useState<string>(''); const [localSeconds, setLocalSeconds] = useState<string>('');
const settings = useStore((s) => s.toolApprovalSettings); const settings = useStore(useShallow((s) => s.toolApprovalSettings));
const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings); const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings);
const updateSettings = useCallback( const updateSettings = useCallback(
(patch: Partial<ToolApprovalSettings>) => rawUpdateSettings(patch, teamName), (patch: Partial<ToolApprovalSettings>) => rawUpdateSettings(patch, teamName),

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
@ -15,7 +17,7 @@ interface KanbanColumnProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const KanbanColumn = ({ export const KanbanColumn = memo(function KanbanColumn({
title, title,
count, count,
icon, icon,
@ -27,7 +29,7 @@ export const KanbanColumn = ({
headerDragClassName, headerDragClassName,
headerAccessory, headerAccessory,
children, children,
}: KanbanColumnProps): React.JSX.Element => { }: KanbanColumnProps): React.JSX.Element {
return ( return (
<section <section
className={cn( className={cn(
@ -64,4 +66,4 @@ export const KanbanColumn = ({
</div> </div>
</section> </section>
); );
}; });

View file

@ -8,6 +8,7 @@ import {
} from '@renderer/constants/teamColors'; } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
agentAvatarUrl, agentAvatarUrl,
@ -44,14 +45,19 @@ export const MemberHoverCard = ({
children, children,
}: MemberHoverCardProps): React.JSX.Element => { }: MemberHoverCardProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const member = useStore((s) => s.selectedTeamData?.members.find((m) => m.name === name) ?? null); const { member, isTeamAlive, teamName, leadActivity, openMemberProfile, tasks } = useStore(
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive); useShallow((s) => {
const teamName = useStore((s) => s.selectedTeamName); const tn = s.selectedTeamName;
const leadActivity: LeadActivityState | undefined = useStore((s) => return {
teamName ? s.leadActivityByTeam[teamName] : undefined 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) { if (!member) {
return <>{children}</>; return <>{children}</>;

View file

@ -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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
@ -67,15 +67,19 @@ export const MemberList = ({
}, [handleResize]); }, [handleResize]);
const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1'; const gridClass = isWide ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1';
const activeMembers = members const activeMembers = useMemo(
.filter((m) => !m.removedAt) () =>
.sort((a, b) => { members
if (isLeadMember(a)) return -1; .filter((m) => !m.removedAt)
if (isLeadMember(b)) return 1; .sort((a, b) => {
return 0; if (isLeadMember(a)) return -1;
}); if (isLeadMember(b)) return 1;
const removedMembers = members.filter((m) => m.removedAt); return 0;
const colorMap = buildMemberColorMap(members); }),
[members]
);
const removedMembers = useMemo(() => members.filter((m) => m.removedAt), [members]);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
if (members.length === 0) { if (members.length === 0) {
return ( 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<string, TeamTaskWithKanban>();
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 renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask = const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const reviewTask = taskMap const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
? (Array.from(taskMap.values()).find( const reviewTask =
(task) => reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
task.reviewer === member.name &&
task.id !== member.currentTaskId &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]); const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
const spawnEntry = memberSpawnStatuses?.get(member.name); const spawnEntry = memberSpawnStatuses?.get(member.name);
return ( return (

View file

@ -1,5 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { import {
@ -107,6 +110,11 @@ export const MemberLogsTab = ({
showLeadPreview = false, showLeadPreview = false,
onPreviewOnlineChange, onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => { }: 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 MIN_REFRESH_VISIBLE_MS = 250;
const intervalsKey = useMemo( const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
@ -475,14 +483,14 @@ export const MemberLogsTab = ({
void load(); void load();
const interval = shouldAutoRefresh ? setInterval(() => void load(), 5000) : null; const interval = shouldAutoRefresh && isTabActive ? setInterval(() => void load(), 5000) : null;
return () => { return () => {
cancelled = true; cancelled = true;
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops // 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( const fetchDetailForLog = useCallback(
async ( async (
@ -537,7 +545,7 @@ export const MemberLogsTab = ({
if (!previewLog) return; if (!previewLog) return;
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing; const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
if (!shouldAutoRefreshPreview) return; if (!shouldAutoRefreshPreview || !isTabActive) return;
let cancelled = false; let cancelled = false;
const interval = setInterval(async () => { const interval = setInterval(async () => {
@ -566,12 +574,14 @@ export const MemberLogsTab = ({
shouldShowPreview, shouldShowPreview,
taskStatus, taskStatus,
intervalsKey, intervalsKey,
isTabActive,
]); ]);
useEffect(() => { useEffect(() => {
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!expandedLogSummary) return; if (!expandedLogSummary) return;
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
if (!isTabActive) return;
let cancelled = false; let cancelled = false;
@ -604,6 +614,7 @@ export const MemberLogsTab = ({
taskId, taskId,
taskStatus, taskStatus,
intervalsKey, intervalsKey,
isTabActive,
]); ]);
const handleExpand = useCallback( const handleExpand = useCallback(

View file

@ -14,6 +14,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
@ -104,7 +105,7 @@ export const MessageComposer = ({
const [selectedTeam, setSelectedTeam] = useState<string | null>(null); const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set()); const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
const allCrossTeamTargets = useStore((s) => s.crossTeamTargets); const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets));
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets); const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
useEffect(() => { useEffect(() => {

View file

@ -6,6 +6,7 @@ import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { Filter } from 'lucide-react'; import { Filter } from 'lucide-react';
@ -67,7 +68,7 @@ export const MessagesFilterPopover = ({
} }
}, [open, filter.from, filter.to, filter.showNoise]); }, [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 colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]); const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);

View file

@ -7,6 +7,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { import {
@ -105,13 +106,25 @@ export const MessagesPanel = memo(function MessagesPanel({
onRestartTeam, onRestartTeam,
onTaskIdClick, onTaskIdClick,
}: MessagesPanelProps): React.JSX.Element { }: MessagesPanelProps): React.JSX.Element {
const sendTeamMessage = useStore((s) => s.sendTeamMessage); const {
const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage); sendTeamMessage,
const sendingMessage = useStore((s) => s.sendingMessage); sendCrossTeamMessage,
const sendMessageError = useStore((s) => s.sendMessageError); sendingMessage,
const lastSendMessageResult = useStore((s) => s.lastSendMessageResult); sendMessageError,
const teams = useStore((s) => s.teams); lastSendMessageResult,
const openTeamTab = useStore((s) => s.openTeamTab); 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<HTMLTextAreaElement | null>(null); const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null); const sidebarScrollRef = useRef<HTMLDivElement | null>(null);

View file

@ -10,6 +10,7 @@ import {
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react'; import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
import { CliLogsRichView } from '../CliLogsRichView'; import { CliLogsRichView } from '../CliLogsRichView';
@ -72,11 +73,13 @@ export const ScheduleRunLogDialog = ({
onClose, onClose,
}: ScheduleRunLogDialogProps): React.JSX.Element => { }: ScheduleRunLogDialogProps): React.JSX.Element => {
// Read live run data from store — falls back to initial prop if not found // Read live run data from store — falls back to initial prop if not found
const liveRun = useStore((s) => { const liveRun = useStore(
if (!initialRun) return null; useShallow((s) => {
const runs = s.scheduleRuns[scheduleId] ?? []; if (!initialRun) return null;
return runs.find((r) => r.id === initialRun.id) ?? initialRun; const runs = s.scheduleRuns[scheduleId] ?? [];
}); return runs.find((r) => r.id === initialRun.id) ?? initialRun;
})
);
const run = liveRun ?? initialRun; const run = liveRun ?? initialRun;
const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null); const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null);

View file

@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters';
import { import {
ChevronDown, ChevronDown,
@ -57,7 +58,7 @@ const ScheduleRow = ({
}: ScheduleRowProps): React.JSX.Element => { }: ScheduleRowProps): React.JSX.Element => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null); const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(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 runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
const fetchRunHistory = useStore((s) => s.fetchRunHistory); const fetchRunHistory = useStore((s) => s.fetchRunHistory);
@ -207,17 +208,22 @@ const ScheduleRow = ({
// ============================================================================= // =============================================================================
export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => { export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => {
const schedules = useStore((s) => s.schedules.filter((sch) => sch.teamName === teamName)); const { schedules, pauseSchedule, resumeSchedule, deleteSchedule, triggerNow, fetchSchedules } =
const pauseSchedule = useStore((s) => s.pauseSchedule); useStore(
const resumeSchedule = useStore((s) => s.resumeSchedule); useShallow((s) => ({
const deleteSchedule = useStore((s) => s.deleteSchedule); schedules: s.schedules.filter((sch) => sch.teamName === teamName),
const triggerNow = useStore((s) => s.triggerNow); pauseSchedule: s.pauseSchedule,
resumeSchedule: s.resumeSchedule,
deleteSchedule: s.deleteSchedule,
triggerNow: s.triggerNow,
fetchSchedules: s.fetchSchedules,
}))
);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null); const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
// Fetch schedules on mount // Fetch schedules on mount
const fetchSchedules = useStore((s) => s.fetchSchedules);
useEffect(() => { useEffect(() => {
void fetchSchedules(); void fetchSchedules();
}, [fetchSchedules]); }, [fetchSchedules]);

View file

@ -1,6 +1,7 @@
import { createContext, useContext, useId, useLayoutEffect, useState } from 'react'; import { createContext, useContext, useId, useLayoutEffect, useState } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { import {
removeTeamSidebarHost, removeTeamSidebarHost,
@ -33,10 +34,12 @@ export const TeamSidebarHost = ({
}: TeamSidebarHostProps): React.JSX.Element => { }: TeamSidebarHostProps): React.JSX.Element => {
const hostId = useId(); const hostId = useId();
const [element, setElement] = useState<HTMLDivElement | null>(null); const [element, setElement] = useState<HTMLDivElement | null>(null);
const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({ const { messagesPanelMode, messagesPanelWidth } = useStore(
messagesPanelMode: s.messagesPanelMode, useShallow((s) => ({
messagesPanelWidth: s.messagesPanelWidth, messagesPanelMode: s.messagesPanelMode,
})); messagesPanelWidth: s.messagesPanelWidth,
}))
);
const snapshot = useTeamSidebarPortalSnapshot(); const snapshot = useTeamSidebarPortalSnapshot();
const isVisible = messagesPanelMode === 'sidebar'; const isVisible = messagesPanelMode === 'sidebar';
const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId; const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId;

View file

@ -7,6 +7,7 @@ import { useMemo } from 'react';
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
@ -84,9 +85,13 @@ export const GraphTaskCard = ({
}: GraphTaskCardProps): React.JSX.Element => { }: GraphTaskCardProps): React.JSX.Element => {
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId)); const { task, tasks, members } = useStore(
const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []); useShallow((s) => ({
const members = useStore((s) => s.selectedTeamData?.members ?? []); task: s.selectedTeamData?.tasks.find((t) => t.id === taskId),
tasks: s.selectedTeamData?.tasks ?? [],
members: s.selectedTeamData?.members ?? [],
}))
);
const taskMap = useMemo(() => { const taskMap = useMemo(() => {
const map = new Map<string, TeamTask>(); const map = new Map<string, TeamTask>();

View file

@ -6,6 +6,7 @@
*/ */
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import type { CliInstallationStatus } from '@shared/types'; import type { CliInstallationStatus } from '@shared/types';
@ -33,20 +34,39 @@ export function useCliInstaller(): {
installCli: () => void; installCli: () => void;
isBusy: boolean; isBusy: boolean;
} { } {
const cliStatus = useStore((s) => s.cliStatus); const {
const cliStatusLoading = useStore((s) => s.cliStatusLoading); cliStatus,
const cliStatusError = useStore((s) => s.cliStatusError); cliStatusLoading,
const installerState = useStore((s) => s.cliInstallerState); cliStatusError,
const downloadProgress = useStore((s) => s.cliDownloadProgress); installerState,
const downloadTransferred = useStore((s) => s.cliDownloadTransferred); downloadProgress,
const downloadTotal = useStore((s) => s.cliDownloadTotal); downloadTransferred,
const installerError = useStore((s) => s.cliInstallerError); downloadTotal,
const installerDetail = useStore((s) => s.cliInstallerDetail); installerError,
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); installerDetail,
const completedVersion = useStore((s) => s.cliCompletedVersion); installerRawChunks,
const fetchCliStatus = useStore((s) => s.fetchCliStatus); completedVersion,
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); fetchCliStatus,
const installCli = useStore((s) => s.installCli); 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'; const isBusy = installerState !== 'idle' && installerState !== 'error';

View file

@ -65,7 +65,7 @@ export function useTabUI(): UseTabUIReturn {
// Subscribe to tabUIStates MAP directly for reactivity // Subscribe to tabUIStates MAP directly for reactivity
// This ensures re-renders when any tab state changes // 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) // Get the current tab's state (derived from subscribed state)
const tabState = useMemo(() => { const tabState = useMemo(() => {

View file

@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { getTaskDisplayId } from '@shared/utils/taskIdentity';
@ -56,10 +57,14 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
} }
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
const globalTasks = useStore((s) => s.globalTasks); const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore(
const selectedTeamName = useStore((s) => s.selectedTeamName); useShallow((s) => ({
const selectedTeamData = useStore((s) => s.selectedTeamData); globalTasks: s.globalTasks,
const teamByName = useStore((s) => s.teamByName); selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
teamByName: s.teamByName,
}))
);
const suggestions = useMemo<MentionSuggestion[]>(() => { const suggestions = useMemo<MentionSuggestion[]>(() => {
const tasks: TaskWithTeamContext[] = []; const tasks: TaskWithTeamContext[] = [];

View file

@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
@ -26,7 +27,7 @@ export interface UseTeamSuggestionsResult {
* @param currentTeamName - The current team name to exclude from suggestions * @param currentTeamName - The current team name to exclude from suggestions
*/ */
export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult { export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult {
const teams = useStore((s) => s.teams); const teams = useStore(useShallow((s) => s.teams));
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set()); const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View file

@ -175,6 +175,19 @@ export function initializeNotificationListeners(): () => void {
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400; const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
/** Cap a Map at maxSize by clearing oldest entries (FIFO via insertion order). */
const capTimerMap = (map: Map<string, ReturnType<typeof setTimeout>>, 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 = ( const buildToolActivityTimerKey = (
teamName: string, teamName: string,
memberName: string, memberName: string,
@ -209,6 +222,7 @@ export function initializeNotificationListeners(): () => void {
cb(); cb();
}, delayMs); }, delayMs);
toolActivityTimers.set(key, timer); toolActivityTimers.set(key, timer);
capTimerMap(toolActivityTimers, 200);
}; };
const clearToolActivityTimersForTeam = (teamName: string): void => { const clearToolActivityTimersForTeam = (teamName: string): void => {
for (const [key, timer] of toolActivityTimers.entries()) { for (const [key, timer] of toolActivityTimers.entries()) {
@ -318,6 +332,16 @@ export function initializeNotificationListeners(): () => void {
return; 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) => { const candidateTasks = selectedTeamData.tasks.filter((task) => {
if (task.status !== 'in_progress') { if (task.status !== 'in_progress') {
return false; return false;
@ -376,6 +400,7 @@ export function initializeNotificationListeners(): () => void {
void state.refreshSessionInPlace(projectId, sessionId); void state.refreshSessionInPlace(projectId, sessionId);
}, SESSION_REFRESH_DEBOUNCE_MS); }, SESSION_REFRESH_DEBOUNCE_MS);
pendingSessionRefreshTimers.set(key, timer); pendingSessionRefreshTimers.set(key, timer);
capTimerMap(pendingSessionRefreshTimers, 50);
}; };
const scheduleProjectRefresh = (projectId: string): void => { const scheduleProjectRefresh = (projectId: string): void => {
@ -389,6 +414,7 @@ export function initializeNotificationListeners(): () => void {
void state.refreshSessionsInPlace(projectId); void state.refreshSessionsInPlace(projectId);
}, PROJECT_REFRESH_DEBOUNCE_MS); }, PROJECT_REFRESH_DEBOUNCE_MS);
pendingProjectRefreshTimers.set(projectId, timer); pendingProjectRefreshTimers.set(projectId, timer);
capTimerMap(pendingProjectRefreshTimers, 20);
}; };
// Listen for new notifications from main process // Listen for new notifications from main process
@ -933,6 +959,7 @@ export function initializeNotificationListeners(): () => void {
void current.refreshTeamData(event.teamName); void current.refreshTeamData(event.teamName);
}, TEAM_REFRESH_THROTTLE_MS); }, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer); teamRefreshTimers.set(event.teamName, timer);
capTimerMap(teamRefreshTimers, 20);
return; return;
} }
@ -949,6 +976,7 @@ export function initializeNotificationListeners(): () => void {
void current.refreshSelectedTeamChangePresence(event.teamName); void current.refreshSelectedTeamChangePresence(event.teamName);
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
teamPresenceRefreshTimers.set(event.teamName, timer); teamPresenceRefreshTimers.set(event.teamName, timer);
capTimerMap(teamPresenceRefreshTimers, 20);
return; return;
} }
@ -984,6 +1012,7 @@ export function initializeNotificationListeners(): () => void {
void current.refreshTeamData(event.teamName); void current.refreshTeamData(event.teamName);
}, TEAM_REFRESH_THROTTLE_MS); }, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer); teamRefreshTimers.set(event.teamName, timer);
capTimerMap(teamRefreshTimers, 20);
}); });
if (typeof cleanup === 'function') { if (typeof cleanup === 'function') {