diff --git a/README.md b/README.md
index 57a6c17d..cfbd35c1 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.
-
+
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 2eb209d8..b00f6b29 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -77,7 +77,8 @@ export default defineConfig({
input: {
index: resolve(__dirname, 'src/main/index.ts'),
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'),
- 'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts')
+ 'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts'),
+ 'team-data-worker': resolve(__dirname, 'src/main/workers/team-data-worker.ts')
},
output: {
// CJS format so bundled deps can use __dirname/require.
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index bfcdcede..6ce68389 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -1,5 +1,6 @@
import { addMainBreadcrumb } from '@main/sentry';
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
+import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient';
import { getAppIconPath } from '@main/utils/appIcon';
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { stripMarkdown } from '@main/utils/textFormatting';
@@ -20,6 +21,7 @@ import {
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
+ TEAM_GET_MESSAGES_PAGE,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
@@ -153,6 +155,7 @@ import type {
TeamCreateRequest,
TeamCreateResponse,
TeamData,
+ MessagesPage,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
@@ -429,6 +432,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
+ ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
@@ -495,6 +499,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
+ ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
ipcMain.removeHandler(TEAM_CREATE_TASK);
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
@@ -632,33 +637,51 @@ async function handleGetData(
let data: TeamData;
setCurrentMainOp('team:getData');
try {
- try {
+ // Prefer worker thread to keep main event loop responsive
+ const worker = getTeamDataWorkerClient();
+ if (worker.isAvailable()) {
+ try {
+ data = await worker.getTeamData(tn);
+ } catch (workerErr) {
+ logger.warn(
+ `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
+ );
+ data = await getTeamDataService().getTeamData(tn);
+ }
+ } else {
data = await getTeamDataService().getTeamData(tn);
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- if (
- message === `Team not found: ${tn}` &&
- getTeamProvisioningService().hasProvisioningRun(tn)
- ) {
- return { success: false, error: 'TEAM_PROVISIONING' };
- }
- // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
- if (message === `Team not found: ${tn}`) {
- const meta = await teamMetaStore.getMeta(tn);
- if (meta) {
- return { success: false, error: 'TEAM_DRAFT' };
- }
- }
- logger.error(`[teams:getData] ${message}`);
- return { success: false, error: message };
}
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (
+ message === `Team not found: ${tn}` &&
+ getTeamProvisioningService().hasProvisioningRun(tn)
+ ) {
+ return { success: false, error: 'TEAM_PROVISIONING' };
+ }
+ // Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
+ if (message === `Team not found: ${tn}`) {
+ const meta = await teamMetaStore.getMeta(tn);
+ if (meta) {
+ return { success: false, error: 'TEAM_DRAFT' };
+ }
+ }
+ logger.error(`[teams:getData] ${message}`);
+ return { success: false, error: message };
} finally {
setCurrentMainOp(null);
}
const getDataMs = Date.now() - startedAt;
+
if (getDataMs >= 1500) {
logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`);
}
+ const teamDataService = getTeamDataService();
+ if (data.processes.some((process) => !process.stoppedAt)) {
+ teamDataService.trackProcessHealthForTeam?.(tn);
+ } else {
+ teamDataService.untrackProcessHealthForTeam?.(tn);
+ }
const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn);
@@ -1590,6 +1613,29 @@ function buildMessageDeliveryText(
return [...hiddenBlocks, baseText].join('\n\n');
}
+async function handleGetMessagesPage(
+ _event: IpcMainInvokeEvent,
+ teamName: unknown,
+ options: unknown
+): Promise> {
+ const vTeam = validateTeamName(teamName);
+ if (!vTeam.valid) {
+ return { success: false, error: vTeam.error ?? 'Invalid teamName' };
+ }
+ const opts = (options && typeof options === 'object' ? options : {}) as {
+ beforeTimestamp?: string;
+ limit?: number;
+ };
+ const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
+ const beforeTimestamp =
+ typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
+
+ return wrapTeamHandler('getMessagesPage', async () => {
+ const service = getTeamDataService();
+ return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
+ });
+}
+
async function handleSendMessage(
_event: IpcMainInvokeEvent,
teamName: unknown,
@@ -2375,6 +2421,20 @@ async function handleGetLogsForTask(
: undefined,
}
: undefined;
+ // Prefer worker thread to keep main event loop responsive.
+ // Call worker directly (not via wrapTeamHandler) so that failures
+ // propagate to the catch block and trigger the main-thread fallback.
+ const worker = getTeamDataWorkerClient();
+ if (worker.isAvailable()) {
+ try {
+ const result = await worker.findLogsForTask(vTeam.value!, vTask.value!, opts);
+ return { success: true, data: result };
+ } catch (workerErr) {
+ logger.warn(
+ `[teams:getLogsForTask] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
+ );
+ }
+ }
return wrapTeamHandler('getLogsForTask', () =>
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!, opts)
);
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index ad1e49d2..1a6af912 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -62,6 +62,7 @@ import type {
CreateTaskRequest,
GlobalTask,
InboxMessage,
+ MessagesPage,
KanbanColumnId,
KanbanState,
ResolvedTeamMember,
@@ -1023,7 +1024,6 @@ export class TeamDataService {
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
mark('enrichBranches');
-
mark('syncComments');
let processes: TeamProcess[] = [];
@@ -1042,7 +1042,9 @@ export class TeamDataService {
'inboxNames'
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
'sentMessages'
- )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} post=${msBetween(
+ )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
+ 'kanbanGc'
+ )} post=${msBetween(
'postStart',
'mergeMessages'
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
@@ -1086,18 +1088,191 @@ export class TeamDataService {
this.processHealthTeams.delete(teamName);
}
+ // Cap messages to keep IPC payloads small. Full history is available
+ // via the paginated getMessagesPage() API. We still include a small
+ // batch here for backward compatibility (notifications, dedup, etc.).
+ const MAX_RETURN_MESSAGES = 50;
+ const cappedMessages =
+ messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
+
return {
teamName,
config,
tasks: tasksWithKanban,
members,
- messages,
+ messages: cappedMessages,
kanbanState,
processes,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
+ /**
+ * Paginated message retrieval for the messages panel.
+ * Uses cursor-based pagination by timestamp to handle live message insertion.
+ */
+ async getMessagesPage(
+ teamName: string,
+ options: { beforeTimestamp?: string; limit: number }
+ ): Promise {
+ const config = await this.configReader.getConfig(teamName);
+ if (!config) {
+ return { messages: [], nextCursor: null, hasMore: false };
+ }
+
+ // Collect all messages from the same sources as getTeamData
+ let messages: InboxMessage[] = [];
+
+ const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
+ this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
+ this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
+ this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
+ ]);
+
+ messages = [...inboxMessages, ...leadTexts, ...sentMessages];
+
+ // Dedup lead_session vs lead_process (same logic as getTeamData)
+ if (leadTexts.length > 0) {
+ const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
+ const getFingerprint = (msg: Pick) =>
+ `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
+ const leadSessionFingerprints = new Set();
+ for (const msg of leadTexts) {
+ if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
+ }
+ messages = messages.filter((m) => {
+ if (m.source !== 'lead_process') return true;
+ if (m.to) return true;
+ return !leadSessionFingerprints.has(getFingerprint(m));
+ });
+ }
+
+ // Enrich: propagate leadSessionId to messages missing it (same as getTeamData)
+ if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
+ messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
+ const anchors: { time: number; sessionId: string }[] = [];
+ for (const msg of messages) {
+ if (msg.leadSessionId) {
+ anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId });
+ }
+ }
+ if (anchors.length > 0) {
+ for (const msg of messages) {
+ if (msg.leadSessionId) continue;
+ const msgTime = Date.parse(msg.timestamp);
+ let best = anchors[0];
+ let bestDist = Math.abs(msgTime - best.time);
+ for (const a of anchors) {
+ const dist = Math.abs(msgTime - a.time);
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = a;
+ } else if (dist > bestDist && a.time > msgTime) {
+ break;
+ }
+ }
+ msg.leadSessionId = best.sessionId;
+ }
+ } else if (config.leadSessionId) {
+ for (const msg of messages) {
+ msg.leadSessionId = config.leadSessionId;
+ }
+ }
+ }
+
+ // Enrich: annotate slash command responses
+ this.annotateSlashCommandResponses(messages);
+
+ // Sort newest-first, with stable tie-breaker by messageId
+ messages.sort((a, b) => {
+ const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
+ if (diff !== 0) return diff;
+ return (a.messageId ?? '').localeCompare(b.messageId ?? '');
+ });
+
+ // Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
+ // to handle multiple messages sharing the same timestamp.
+ if (options.beforeTimestamp) {
+ const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
+ const cursorMs = Date.parse(cursorTs);
+ messages = messages.filter((m) => {
+ const ms = Date.parse(m.timestamp);
+ if (ms < cursorMs) return true;
+ if (ms > cursorMs) return false;
+ // Same timestamp — use messageId tie-breaker
+ if (!cursorId) return false;
+ return (m.messageId ?? '').localeCompare(cursorId) > 0;
+ });
+ }
+
+ // Paginate
+ const hasMore = messages.length > options.limit;
+ const page = messages.slice(0, options.limit);
+ const lastMsg = page[page.length - 1];
+ const nextCursor =
+ hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
+
+ return { messages: page, nextCursor, hasMore };
+ }
+
+ /**
+ * Enriches members with gitBranch when their cwd differs from the lead's.
+ * Mutates members in-place for efficiency (called right after resolveMembers).
+ */
+ private async enrichMemberBranches(
+ members: ResolvedTeamMember[],
+ config: TeamConfig
+ ): Promise {
+ const leadEntry = config.members?.find((member) => isLeadMember(member));
+ const leadCwd = leadEntry?.cwd ?? config.projectPath;
+ if (!leadCwd) return;
+
+ const withTimeout = async (promise: Promise, ms: number): Promise => {
+ let timer: NodeJS.Timeout | null = null;
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_resolve, reject) => {
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
+ }),
+ ]);
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+ };
+
+ let leadBranch: string | null = null;
+ try {
+ leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
+ } catch {
+ return;
+ }
+
+ const candidates = members.filter((member) => member.cwd && member.cwd !== leadCwd);
+ if (candidates.length === 0) return;
+
+ const concurrency = process.platform === 'win32' ? 4 : 8;
+ for (let index = 0; index < candidates.length; index += concurrency) {
+ const batch = candidates.slice(index, index + concurrency);
+ await Promise.all(
+ batch.map(async (member) => {
+ if (!member.cwd) return;
+ try {
+ const branch = await withTimeout(
+ gitIdentityResolver.getBranch(path.normalize(member.cwd)),
+ 2000
+ );
+ if (branch && branch !== leadBranch) {
+ member.gitBranch = branch;
+ }
+ } catch {
+ // Member cwd may not be a git repo - skip silently.
+ }
+ })
+ );
+ }
+ }
+
startProcessHealthPolling(): void {
if (this.processHealthTimer) return;
this.processHealthTimer = setInterval(() => {
@@ -1162,68 +1337,6 @@ export class TeamDataService {
}
}
- /**
- * Enriches members with gitBranch when their cwd differs from the lead's.
- * Mutates members in-place for efficiency (called right after resolveMembers).
- */
- private async enrichMemberBranches(
- members: ResolvedTeamMember[],
- config: TeamConfig
- ): Promise {
- // Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
- const leadEntry = config.members?.find((m) => isLeadMember(m));
- const leadCwd = leadEntry?.cwd ?? config.projectPath;
- if (!leadCwd) return;
-
- const withTimeout = async (p: Promise, ms: number): Promise => {
- let timer: NodeJS.Timeout | null = null;
- try {
- return await Promise.race([
- p,
- new Promise((_resolve, reject) => {
- timer = setTimeout(() => reject(new Error('timeout')), ms);
- }),
- ]);
- } finally {
- if (timer) clearTimeout(timer);
- }
- };
-
- let leadBranch: string | null = null;
- try {
- // Git can hang on some Windows setups (network drives, locked repos, credential prompts).
- // Branch is best-effort; never block team:getData on it.
- leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
- } catch {
- // Lead cwd may not be a git repo — skip enrichment entirely
- return;
- }
-
- const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
- if (candidates.length === 0) return;
-
- const concurrency = process.platform === 'win32' ? 4 : 8;
- for (let i = 0; i < candidates.length; i += concurrency) {
- const batch = candidates.slice(i, i + concurrency);
- await Promise.all(
- batch.map(async (member) => {
- if (!member.cwd) return;
- try {
- const branch = await withTimeout(
- gitIdentityResolver.getBranch(path.normalize(member.cwd)),
- 2000
- );
- if (branch && branch !== leadBranch) {
- member.gitBranch = branch;
- }
- } catch {
- // Member cwd may not be a git repo — skip silently
- }
- })
- );
- }
- }
-
/**
* Ensures a member exists in members.meta.json.
* Members can appear in the UI from three sources (see TeamMemberResolver):
diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts
new file mode 100644
index 00000000..314fe1a0
--- /dev/null
+++ b/src/main/services/team/TeamDataWorkerClient.ts
@@ -0,0 +1,184 @@
+/**
+ * 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;
+const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
+const SAFE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/;
+
+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 */
+ }
+ }
+ // Don't warn here — resolveWorkerPath runs at module load time and
+ // the worker file is expected to be absent during tests.
+ // isAvailable() warns once on first access instead.
+ 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();
+
+ private failWorker(worker: Worker, error: Error): void {
+ if (this.worker !== worker) return;
+
+ this.worker = null;
+ const pendingEntries = Array.from(this.pending.values());
+ this.pending.clear();
+
+ for (const entry of pendingEntries) {
+ entry.reject(error);
+ }
+ }
+
+ isAvailable(): boolean {
+ if (!this.workerPath && !this.warnedUnavailable) {
+ this.warnedUnavailable = true;
+ logger.debug('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;
+
+ const w = new Worker(this.workerPath);
+ this.worker = w;
+
+ w.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));
+ }
+ });
+
+ // Scope error/exit handlers to this specific worker instance.
+ // Without this guard, a stale worker's exit event can reject
+ // pending requests that belong to a newer replacement worker.
+ w.on('error', (err) => {
+ logger.error('Worker error', err);
+ this.failWorker(w, err instanceof Error ? err : new Error(String(err)));
+ });
+
+ w.on('exit', (code) => {
+ if (code !== 0) logger.warn(`Worker exited with code ${code}`);
+ this.failWorker(w, new Error(`Worker exited with code ${code}`));
+ });
+
+ return w;
+ }
+
+ private call(
+ op: TeamDataWorkerRequest['op'],
+ payload: TeamDataWorkerRequest['payload']
+ ): Promise {
+ const worker = this.ensureWorker();
+ const id = makeId();
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ const timeoutError = new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms`);
+ this.failWorker(worker, timeoutError);
+ worker.terminate().catch(() => undefined);
+ reject(timeoutError);
+ }, 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 {
+ if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
+ return this.call('getTeamData', { teamName }) as Promise;
+ }
+
+ async findLogsForTask(
+ teamName: string,
+ taskId: string,
+ options?: {
+ owner?: string;
+ status?: string;
+ intervals?: { startedAt: string; completedAt?: string }[];
+ since?: string;
+ }
+ ): Promise {
+ if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
+ if (!SAFE_ID_RE.test(taskId)) throw new Error('Invalid taskId');
+ return this.call('findLogsForTask', { teamName, taskId, options }) as Promise<
+ MemberLogSummary[]
+ >;
+ }
+
+ dispose(): void {
+ this.worker?.terminate().catch(() => undefined);
+ this.worker = null;
+ for (const [, entry] of this.pending) {
+ entry.reject(new Error('Client disposed'));
+ }
+ this.pending.clear();
+ }
+}
+
+// Singleton
+let singleton: TeamDataWorkerClient | null = null;
+export function getTeamDataWorkerClient(): TeamDataWorkerClient {
+ if (!singleton) singleton = new TeamDataWorkerClient();
+ return singleton;
+}
diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts
index 90d28679..608db488 100644
--- a/src/main/services/team/TeamInboxReader.ts
+++ b/src/main/services/team/TeamInboxReader.ts
@@ -45,7 +45,8 @@ export class TeamInboxReader {
return entries
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
- .map((name) => name.replace(/\.json$/, ''));
+ .map((name) => name.replace(/\.json$/, ''))
+ .filter((name) => name !== '*');
}
async getMessagesFor(teamName: string, member: string): Promise {
diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts
index ed3eea68..6b514754 100644
--- a/src/main/services/team/TeamMemberLogsFinder.ts
+++ b/src/main/services/team/TeamMemberLogsFinder.ts
@@ -31,7 +31,7 @@ const ATTRIBUTION_CACHE_MAX = 5_000;
const SCAN_CONCURRENCY = 15;
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
-const DISCOVERY_CACHE_TTL = 5_000;
+const DISCOVERY_CACHE_TTL = 30_000;
/** Signal sources for subagent member attribution, ordered by reliability. */
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts
new file mode 100644
index 00000000..329e369f
--- /dev/null
+++ b/src/main/services/team/teamDataWorkerTypes.ts
@@ -0,0 +1,32 @@
+/**
+ * Shared request/response types for the team-data-worker thread.
+ */
+
+import type { MemberLogSummary, TeamData } from '@shared/types';
+
+// ── Payloads ──
+
+export interface GetTeamDataPayload {
+ teamName: string;
+}
+
+export interface FindLogsForTaskPayload {
+ teamName: string;
+ taskId: string;
+ options?: {
+ owner?: string;
+ status?: string;
+ intervals?: { startedAt: string; completedAt?: string }[];
+ since?: string;
+ };
+}
+
+// ── Request / Response ──
+
+export type TeamDataWorkerRequest =
+ | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
+ | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
+
+export type TeamDataWorkerResponse =
+ | { id: string; ok: true; result: TeamData | MemberLogSummary[] }
+ | { id: string; ok: false; error: string };
diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts
new file mode 100644
index 00000000..06fa12c2
--- /dev/null
+++ b/src/main/workers/team-data-worker.ts
@@ -0,0 +1,94 @@
+/**
+ * Worker thread for heavy team I/O operations (getTeamData, findLogsForTask).
+ *
+ * Runs in its own event loop, completely isolated from the Electron main thread.
+ * This prevents file-heavy operations (scanning 300+ subagent JSONL files,
+ * parsing large session files) from stalling the main process UI/IPC.
+ */
+
+import { parentPort } from 'node:worker_threads';
+
+import { TeamDataService } from '@main/services/team/TeamDataService';
+import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
+import { createLogger } from '@shared/utils/logger';
+import type { MemberLogSummary } from '@shared/types';
+
+import type {
+ TeamDataWorkerRequest,
+ TeamDataWorkerResponse,
+} from '@main/services/team/teamDataWorkerTypes';
+
+const logger = createLogger('Worker:TeamData');
+
+// Instantiate services with default dependencies — worker has its own event loop
+const teamDataService = new TeamDataService();
+const logsFinder = new TeamMemberLogsFinder();
+
+// In-flight dedup: concurrent calls for the same task piggyback on one request
+const logsInFlight = new Map>();
+// Result cache with TTL to avoid re-scanning files
+const logsResultCache = new Map();
+const LOGS_CACHE_TTL_MS = 10_000;
+
+function respond(msg: TeamDataWorkerResponse): void {
+ parentPort?.postMessage(msg);
+}
+
+parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
+ try {
+ switch (msg.op) {
+ case 'getTeamData': {
+ const result = await teamDataService.getTeamData(msg.payload.teamName);
+ respond({ id: msg.id, ok: true, result });
+ break;
+ }
+ case 'findLogsForTask': {
+ const { teamName, taskId, options } = msg.payload;
+ const intervalsKey = options?.intervals
+ ? options.intervals.map((i) => `${i.startedAt}~${i.completedAt ?? ''}`).join(',')
+ : '';
+ const cacheKey = `${teamName}:${taskId}:${options?.owner ?? ''}:${options?.status ?? ''}:${options?.since ?? ''}:${intervalsKey}`;
+
+ // Check result cache
+ const cached = logsResultCache.get(cacheKey);
+ if (cached && Date.now() - cached.cachedAt < LOGS_CACHE_TTL_MS) {
+ respond({ id: msg.id, ok: true, result: cached.result });
+ break;
+ }
+
+ // Dedup concurrent calls
+ let promise = logsInFlight.get(cacheKey) as Promise | undefined;
+ if (!promise) {
+ promise = logsFinder
+ .findLogsForTask(teamName, taskId, options)
+ .then((result) => {
+ 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;
+ })
+ .finally(() => {
+ logsInFlight.delete(cacheKey);
+ });
+ logsInFlight.set(cacheKey, promise);
+ }
+ const result = await promise;
+ respond({ id: msg.id, ok: true, result });
+ break;
+ }
+ default: {
+ const _exhaustive: never = msg;
+ respond({ id: (_exhaustive as { id: string }).id, ok: false, error: `Unknown op` });
+ }
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ logger.error(`[${msg.op}] ${message}`);
+ respond({ id: msg.id, ok: false, error: message });
+ }
+});
+
+logger.info('team-data-worker started');
diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts
index b159c4cf..45fd3dfe 100644
--- a/src/preload/constants/ipcChannels.ts
+++ b/src/preload/constants/ipcChannels.ts
@@ -231,6 +231,9 @@ export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
/** Send inbox message to team member */
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
+/** Paginated messages for timeline/messages panel */
+export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
+
/** Request review for task */
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
diff --git a/src/preload/index.ts b/src/preload/index.ts
index f79543ae..55c4e3d1 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -152,6 +152,7 @@ import {
TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
+ TEAM_GET_MESSAGES_PAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
@@ -260,6 +261,7 @@ import type {
ScheduleRun,
SendMessageRequest,
SendMessageResult,
+ MessagesPage,
SessionsByIdsOptions,
SessionsPaginationOptions,
SnippetDiff,
@@ -867,6 +869,12 @@ const electronAPI: ElectronAPI = {
sendMessage: async (teamName: string, request: SendMessageRequest) => {
return invokeIpcWithResult(TEAM_SEND_MESSAGE, teamName, request);
},
+ getMessagesPage: async (
+ teamName: string,
+ options?: { beforeTimestamp?: string; limit?: number }
+ ) => {
+ return invokeIpcWithResult(TEAM_GET_MESSAGES_PAGE, teamName, options);
+ },
createTask: async (teamName: string, request: CreateTaskRequest) => {
return invokeIpcWithResult(TEAM_CREATE_TASK, teamName, request);
},
diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts
index 93bd3307..bc2a316e 100644
--- a/src/renderer/api/httpClient.ts
+++ b/src/renderer/api/httpClient.ts
@@ -729,6 +729,9 @@ export class HttpAPIClient implements ElectronAPI {
): Promise => {
throw new Error('Team messaging is not available in browser mode');
},
+ getMessagesPage: async () => {
+ return { messages: [], nextCursor: null, hasMore: false };
+ },
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise => {
throw new Error('Team task creation is not available in browser mode');
},
diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx
index 66b1d5f4..25209ee3 100644
--- a/src/renderer/components/chat/AIChatGroup.tsx
+++ b/src/renderer/components/chat/AIChatGroup.tsx
@@ -171,7 +171,7 @@ const AIChatGroupInner = ({
);
// Notification color map for tool item dots
- const notifications = useStore((s) => s.notifications);
+ const notifications = useStore(useShallow((s) => s.notifications));
const notificationColorMap = useMemo(() => {
const map = new Map();
for (const n of notifications) {
diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx
index d1d05c27..9f30d243 100644
--- a/src/renderer/components/chat/ChatHistory.tsx
+++ b/src/renderer/components/chat/ChatHistory.tsx
@@ -128,7 +128,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null;
const pendingNavigation = thisTab?.pendingNavigation;
- const teamBySessionId = useStore((s) => s.teamBySessionId);
+ const teamBySessionId = useStore(useShallow((s) => s.teamBySessionId));
// Look up whether this session belongs to a team
const sessionTeam = useMemo(() => {
diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx
index 860a5dc3..53575bc3 100644
--- a/src/renderer/components/chat/UserChatGroup.tsx
+++ b/src/renderer/components/chat/UserChatGroup.tsx
@@ -388,15 +388,17 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath;
});
- // Get team members for @mention highlighting
- const members = useStore((s) => s.selectedTeamData?.members);
+ // Get team members for @mention highlighting and team names for @team linkification
+ const { members, teams } = useStore(
+ useShallow((s) => ({
+ members: s.selectedTeamData?.members,
+ teams: s.teams,
+ }))
+ );
const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map()),
[members]
);
-
- // Get team names for @team linkification
- const teams = useStore((s) => s.teams);
const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams]
diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx
index ef017160..6b255e94 100644
--- a/src/renderer/components/chat/items/SubagentItem.tsx
+++ b/src/renderer/components/chat/items/SubagentItem.tsx
@@ -20,6 +20,7 @@ import {
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
+import { useShallow } from 'zustand/react/shallow';
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
@@ -82,7 +83,7 @@ export const SubagentItem: React.FC = ({
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
// Agent configs from .claude/agents/ for color lookup
- const agentConfigs = useStore((s) => s.agentConfigs);
+ const agentConfigs = useStore(useShallow((s) => s.agentConfigs));
// Team member colors (when this subagent is a team member)
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
@@ -171,7 +172,7 @@ export const SubagentItem: React.FC = ({
}, [subagent.messages]);
// Search expansion
- const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds);
+ const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds));
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id);
diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx
index 212de247..112395ad 100644
--- a/src/renderer/components/chat/items/TeammateMessageItem.tsx
+++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx
@@ -10,6 +10,7 @@ import {
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
+import { useShallow } from 'zustand/react/shallow';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@@ -85,14 +86,14 @@ export const TeammateMessageItem: React.FC = ({
const { isLight } = useTheme();
// Get team members for @mention highlighting
- const members = useStore((s) => s.selectedTeamData?.members);
+ const members = useStore(useShallow((s) => s.selectedTeamData?.members));
const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map()),
[members]
);
// Get team names for @team linkification
- const teams = useStore((s) => s.teams);
+ const teams = useStore(useShallow((s) => s.teams));
const teamNames = useMemo(
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
[teams]
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index 9636ff2c..d1d2a941 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -690,7 +690,7 @@ export const MarkdownViewer: React.FC = ({
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
- const teams = useStore((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams));
+ const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
const fallbackTeamColorByName = React.useMemo(() => {
diff --git a/src/renderer/components/common/CliInstallWarningBanner.tsx b/src/renderer/components/common/CliInstallWarningBanner.tsx
index c54d7a64..7a0747ff 100644
--- a/src/renderer/components/common/CliInstallWarningBanner.tsx
+++ b/src/renderer/components/common/CliInstallWarningBanner.tsx
@@ -8,10 +8,11 @@
import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
+import { useShallow } from 'zustand/react/shallow';
import { AlertTriangle } from 'lucide-react';
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
- const cliStatus = useStore((s) => s.cliStatus);
+ const cliStatus = useStore(useShallow((s) => s.cliStatus));
const openDashboard = useStore((s) => s.openDashboard);
// Returns a primitive boolean — minimizes re-renders
diff --git a/src/renderer/components/common/ConnectionStatusBadge.tsx b/src/renderer/components/common/ConnectionStatusBadge.tsx
index e1b890ac..924955ee 100644
--- a/src/renderer/components/common/ConnectionStatusBadge.tsx
+++ b/src/renderer/components/common/ConnectionStatusBadge.tsx
@@ -11,6 +11,7 @@
import { useStore } from '@renderer/store';
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
interface ConnectionStatusBadgeProps {
contextId: string;
@@ -21,10 +22,12 @@ export const ConnectionStatusBadge = ({
contextId,
className,
}: Readonly): React.JSX.Element => {
- const { connectionState, connectedHost } = useStore((s) => ({
- connectionState: s.connectionState,
- connectedHost: s.connectedHost,
- }));
+ const { connectionState, connectedHost } = useStore(
+ useShallow((s) => ({
+ connectionState: s.connectionState,
+ connectedHost: s.connectedHost,
+ }))
+ );
// Local context always shows Monitor icon
if (contextId === 'local') {
diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx
index 3a0c0270..62d6a0b2 100644
--- a/src/renderer/components/common/UpdateBanner.tsx
+++ b/src/renderer/components/common/UpdateBanner.tsx
@@ -6,14 +6,26 @@
import { useStore } from '@renderer/store';
import { CheckCircle, Loader2, X } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
export const UpdateBanner = (): React.JSX.Element | null => {
- const showUpdateBanner = useStore((s) => s.showUpdateBanner);
- const updateStatus = useStore((s) => s.updateStatus);
- const downloadProgress = useStore((s) => s.downloadProgress);
- const availableVersion = useStore((s) => s.availableVersion);
- const installUpdate = useStore((s) => s.installUpdate);
- const dismissUpdateBanner = useStore((s) => s.dismissUpdateBanner);
+ const {
+ showUpdateBanner,
+ updateStatus,
+ downloadProgress,
+ availableVersion,
+ installUpdate,
+ dismissUpdateBanner,
+ } = useStore(
+ useShallow((s) => ({
+ showUpdateBanner: s.showUpdateBanner,
+ updateStatus: s.updateStatus,
+ downloadProgress: s.downloadProgress,
+ availableVersion: s.availableVersion,
+ installUpdate: s.installUpdate,
+ dismissUpdateBanner: s.dismissUpdateBanner,
+ }))
+ );
if (!showUpdateBanner || (updateStatus !== 'downloading' && updateStatus !== 'downloaded')) {
return null;
diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx
index fa5b3465..3b3b0afb 100644
--- a/src/renderer/components/common/UpdateDialog.tsx
+++ b/src/renderer/components/common/UpdateDialog.tsx
@@ -15,15 +15,28 @@ import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { ExternalLink, X } from 'lucide-react';
import remarkGfm from 'remark-gfm';
+import { useShallow } from 'zustand/react/shallow';
export const UpdateDialog = (): React.JSX.Element | null => {
- const showUpdateDialog = useStore((s) => s.showUpdateDialog);
- const updateStatus = useStore((s) => s.updateStatus);
- const availableVersion = useStore((s) => s.availableVersion);
- const releaseNotes = useStore((s) => s.releaseNotes);
- const downloadUpdate = useStore((s) => s.downloadUpdate);
- const installUpdate = useStore((s) => s.installUpdate);
- const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
+ const {
+ showUpdateDialog,
+ updateStatus,
+ availableVersion,
+ releaseNotes,
+ downloadUpdate,
+ installUpdate,
+ dismissUpdateDialog,
+ } = useStore(
+ useShallow((s) => ({
+ showUpdateDialog: s.showUpdateDialog,
+ updateStatus: s.updateStatus,
+ availableVersion: s.availableVersion,
+ releaseNotes: s.releaseNotes,
+ downloadUpdate: s.downloadUpdate,
+ installUpdate: s.installUpdate,
+ dismissUpdateDialog: s.dismissUpdateDialog,
+ }))
+ );
const dialogRef = useRef(null);
diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx
index 8f0c7130..9ff4333f 100644
--- a/src/renderer/components/extensions/ExtensionStoreView.tsx
+++ b/src/renderer/components/extensions/ExtensionStoreView.tsx
@@ -5,6 +5,8 @@
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
@@ -18,7 +20,6 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
-import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
@@ -29,18 +30,41 @@ import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
export const ExtensionStoreView = (): React.JSX.Element => {
const tabId = useTabIdOptional();
- const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
- const fetchApiKeys = useStore((s) => s.fetchApiKeys);
- const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
- const mcpBrowse = useStore((s) => s.mcpBrowse);
- const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
- const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
- const mcpBrowseLoading = useStore((s) => s.mcpBrowseLoading);
- const skillsLoading = useStore((s) => s.skillsLoading);
- const cliStatus = useStore((s) => s.cliStatus);
- const cliInstalled = cliStatus?.installed ?? true; // assume installed until checked
- const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
- const projects = useStore((s) => s.projects);
+ const {
+ fetchPluginCatalog,
+ fetchCliStatus,
+ fetchApiKeys,
+ fetchSkillsCatalog,
+ mcpBrowse,
+ mcpFetchInstalled,
+ pluginCatalogLoading,
+ mcpBrowseLoading,
+ skillsLoading,
+ cliStatus,
+ cliStatusLoading,
+ openDashboard,
+ sessions,
+ projects,
+ } = useStore(
+ useShallow((s) => ({
+ fetchPluginCatalog: s.fetchPluginCatalog,
+ fetchCliStatus: s.fetchCliStatus,
+ fetchApiKeys: s.fetchApiKeys,
+ fetchSkillsCatalog: s.fetchSkillsCatalog,
+ mcpBrowse: s.mcpBrowse,
+ mcpFetchInstalled: s.mcpFetchInstalled,
+ pluginCatalogLoading: s.pluginCatalogLoading,
+ mcpBrowseLoading: s.mcpBrowseLoading,
+ skillsLoading: s.skillsLoading,
+ cliStatus: s.cliStatus,
+ cliStatusLoading: s.cliStatusLoading,
+ openDashboard: s.openDashboard,
+ sessions: s.sessions,
+ projects: s.projects,
+ }))
+ );
+ const cliInstalled = cliStatus?.installed ?? true;
+ const hasOngoingSessions = sessions.some((sess) => sess.isOngoing);
const extensionsTabProjectId = useStore((s) =>
tabId
? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId)
@@ -97,6 +121,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
void fetchPluginCatalog(projectPath ?? undefined);
}, [fetchPluginCatalog, projectPath]);
+ useEffect(() => {
+ void fetchCliStatus();
+ }, [fetchCliStatus]);
+
// Fetch MCP installed state on mount
useEffect(() => {
void mcpFetchInstalled(projectPath ?? undefined);
@@ -121,6 +149,71 @@ export const ExtensionStoreView = (): React.JSX.Element => {
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
+ const cliStatusBanner = useMemo(() => {
+ if (cliStatusLoading || cliStatus === null) {
+ return (
+
+
+
+
Checking Claude CLI availability
+
+ Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
+
+ Claude CLI was found
+ {cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin
+ installs are disabled until you sign in from the Dashboard.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Claude CLI is ready
+
+ Plugins can be installed from this page
+ {cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
+