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.

-image +demo 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. +

+
+
+ ); + } + + if (!cliStatus.installed) { + return ( +
+ +
+

Claude CLI is not available

+

+ Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to + install it and retry. +

+
+ +
+ ); + } + + if (!cliStatus.authLoggedIn) { + return ( +
+ +
+

Claude CLI needs sign-in

+

+ 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}` : ''}. +

+
+
+ ); + }, [cliStatus, cliStatusLoading, openDashboard]); // Browser mode guard if (!api.plugins && !api.mcpRegistry && !api.skills) { @@ -138,6 +231,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { return (
+ {cliStatusBanner}
{/* Header */}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index 4863ce24..f4a9f824 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; import { ApiKeyCard } from './ApiKeyCard'; @@ -15,11 +16,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; export const ApiKeysPanel = (): React.JSX.Element => { - const apiKeys = useStore((s) => s.apiKeys); - const apiKeysLoading = useStore((s) => s.apiKeysLoading); - const apiKeysError = useStore((s) => s.apiKeysError); - const storageStatus = useStore((s) => s.apiKeyStorageStatus); - const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus); + const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore( + useShallow((s) => ({ + apiKeys: s.apiKeys, + apiKeysLoading: s.apiKeysLoading, + apiKeysError: s.apiKeysError, + storageStatus: s.apiKeyStorageStatus, + fetchStorageStatus: s.fetchApiKeyStorageStatus, + })) + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 5d45a257..f7e7ee3b 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { Check, Loader2, Trash2 } from 'lucide-react'; import type { ExtensionOperationState } from '@shared/types/extensions'; @@ -36,9 +37,25 @@ export const InstallButton = ({ size = 'sm', errorMessage, }: InstallButtonProps) => { - const cliStatus = useStore((s) => s.cliStatus); - const cliMissing = cliStatus !== null && !cliStatus.installed; - const isDisabled = disabled || cliMissing; + const { cliStatus, cliStatusLoading } = useStore( + useShallow((s) => ({ + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, + })) + ); + const cliUnknown = cliStatus === null; + const cliMissing = cliStatus?.installed === false; + const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; + const disableReason = cliStatusLoading + ? 'Checking Claude CLI status...' + : cliUnknown + ? 'Checking Claude CLI availability...' + : cliMissing + ? 'Claude CLI required. Install it from the Dashboard.' + : authMissing + ? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.' + : null; + const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); useEffect(() => { @@ -91,23 +108,30 @@ export const InstallButton = ({ ); - if (errorMessage) { + const tooltipMessage = disableReason ?? errorMessage; + + if (tooltipMessage) { return ( - - - - {retryButton} - - {errorMessage} - - +
+ + + + {retryButton} + + {tooltipMessage} + + + {errorMessage && !disableReason ? ( +

{errorMessage}

+ ) : null} +
); } return retryButton; } - // idle — wrap in tooltip when CLI missing + // idle — wrap in tooltip when install is unavailable const button = isInstalled ? ( +
+ )}
); }; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 43ec377d..2f2fa1e1 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -14,6 +14,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; @@ -104,7 +105,7 @@ export const MessageComposer = ({ const [selectedTeam, setSelectedTeam] = useState(null); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); const [aliveTeams, setAliveTeams] = useState>(new Set()); - const allCrossTeamTargets = useStore((s) => s.crossTeamTargets); + const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets)); const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets); useEffect(() => { diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 42294c63..9716b134 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -6,6 +6,7 @@ import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Filter } from 'lucide-react'; diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 396622b1..71ec1294 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -7,6 +8,8 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { createLogger } from '@shared/utils/logger'; @@ -111,13 +114,96 @@ export const MessagesPanel = memo(function MessagesPanel({ onRestartTeam, onTaskIdClick, }: MessagesPanelProps): React.JSX.Element { - const sendTeamMessage = useStore((s) => s.sendTeamMessage); - const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage); - const sendingMessage = useStore((s) => s.sendingMessage); - const sendMessageError = useStore((s) => s.sendMessageError); - const lastSendMessageResult = useStore((s) => s.lastSendMessageResult); - const teams = useStore((s) => s.teams); - const openTeamTab = useStore((s) => s.openTeamTab); + const { + sendTeamMessage, + sendCrossTeamMessage, + sendingMessage, + sendMessageError, + lastSendMessageResult, + teams, + openTeamTab, + } = useStore( + useShallow((s) => ({ + sendTeamMessage: s.sendTeamMessage, + sendCrossTeamMessage: s.sendCrossTeamMessage, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + lastSendMessageResult: s.lastSendMessageResult, + teams: s.teams, + openTeamTab: s.openTeamTab, + })) + ); + + // ── Paginated message fetching ── + // Messages are now fetched via getMessagesPage API instead of coming + // from getTeamData. The `messages` prop is used as initial seed if non-empty. + const PAGE_SIZE = 50; + const [fetchedMessages, setFetchedMessages] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [messagesLoading, setMessagesLoading] = useState(false); + const fetchIdRef = useRef(0); + + // Initial fetch on mount or team change + useEffect(() => { + const id = ++fetchIdRef.current; + setMessagesLoading(true); + void (async () => { + try { + const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); + if (fetchIdRef.current !== id) return; + setFetchedMessages(page.messages); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + } catch { + // Fallback: use prop messages if API fails + if (fetchIdRef.current === id && messages.length > 0) { + setFetchedMessages(messages); + } + } finally { + if (fetchIdRef.current === id) setMessagesLoading(false); + } + })(); + }, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change + + // Auto-refresh: poll for NEW messages only (prepend to head). + // Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow. + useEffect(() => { + if (!isTeamAlive && leadActivity !== 'active') return; + const interval = setInterval(async () => { + try { + const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); + setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); + } catch { + // best-effort + } + }, 5000); + return () => clearInterval(interval); + }, [teamName, isTeamAlive, leadActivity]); + + const loadOlderMessages = useCallback(async () => { + if (!nextCursor || messagesLoading) return; + setMessagesLoading(true); + try { + const page = await api.teams.getMessagesPage(teamName, { + beforeTimestamp: nextCursor, + limit: PAGE_SIZE, + }); + setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + } catch { + // best-effort + } finally { + setMessagesLoading(false); + } + }, [teamName, nextCursor, messagesLoading]); + + // Use fetched messages, fall back to prop messages during initial load + const effectiveMessages = useMemo(() => { + if (fetchedMessages.length === 0) return messages; + return mergeTeamMessages(fetchedMessages, messages); + }, [fetchedMessages, messages]); const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); @@ -189,7 +275,7 @@ export const MessagesPanel = memo(function MessagesPanel({ const filteredMessages = useMemo(() => { const startedAt = performance.now(); - const result = filterTeamMessages(messages, { + const result = filterTeamMessages(effectiveMessages, { timeWindow, filter: messagesFilter, searchQuery: messagesSearchQuery, @@ -197,17 +283,17 @@ export const MessagesPanel = memo(function MessagesPanel({ const ms = performance.now() - startedAt; if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { logger.warn( - `[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${messages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ + `[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ messagesFilter.showNoise ? 'on' : 'off' }` ); } return result; - }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); + }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); const activityTimelineMessages = useMemo(() => { const startedAt = performance.now(); - const result = filterTeamMessages(messages, { + const result = filterTeamMessages(effectiveMessages, { includePassiveIdlePeerSummariesWhenNoiseHidden: true, timeWindow, filter: messagesFilter, @@ -216,22 +302,22 @@ export const MessagesPanel = memo(function MessagesPanel({ const ms = performance.now() - startedAt; if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) { logger.warn( - `[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${messages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ + `[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${ messagesFilter.showNoise ? 'on' : 'off' }` ); } return result; - }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); + }, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]); const replyCandidateMessages = useMemo( () => - messages.filter( + effectiveMessages.filter( (m) => m.messageKind !== 'task_comment_notification' && !shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '') ), - [messages] + [effectiveMessages] ); // Resolve the expanded item from filtered messages @@ -320,7 +406,7 @@ export const MessagesPanel = memo(function MessagesPanel({ } } if (changed) onPendingReplyChange(() => next); - }, [replyCandidateMessages, pendingRepliesByMember, onPendingReplyChange]); + }, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]); const handleSend = useCallback( ( @@ -403,7 +489,7 @@ export const MessagesPanel = memo(function MessagesPanel({ teamName={teamName} members={members} filter={messagesFilter} - messages={messages} + messages={effectiveMessages} open={messagesFilterOpen} onOpenChange={setMessagesFilterOpen} onApply={setMessagesFilter} @@ -451,7 +537,7 @@ export const MessagesPanel = memo(function MessagesPanel({ + {hasMore && ( +
+ +
+ )} + {hasMore && ( +
+ +
+ )} { // Read live run data from store — falls back to initial prop if not found - const liveRun = useStore((s) => { - if (!initialRun) return null; - const runs = s.scheduleRuns[scheduleId] ?? []; - return runs.find((r) => r.id === initialRun.id) ?? initialRun; - }); + const liveRun = useStore( + useShallow((s) => { + if (!initialRun) return null; + const runs = s.scheduleRuns[scheduleId] ?? []; + return runs.find((r) => r.id === initialRun.id) ?? initialRun; + }) + ); const run = liveRun ?? initialRun; const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null); diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index 11cbcc19..02204416 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { ChevronDown, @@ -57,7 +58,7 @@ const ScheduleRow = ({ }: ScheduleRowProps): React.JSX.Element => { const [expanded, setExpanded] = useState(false); const [selectedRun, setSelectedRun] = useState(null); - const runs = useStore((s) => s.scheduleRuns[schedule.id] ?? []); + const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? [])); const runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false); const fetchRunHistory = useStore((s) => s.fetchRunHistory); @@ -207,17 +208,22 @@ const ScheduleRow = ({ // ============================================================================= export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => { - const schedules = useStore((s) => s.schedules.filter((sch) => sch.teamName === teamName)); - const pauseSchedule = useStore((s) => s.pauseSchedule); - const resumeSchedule = useStore((s) => s.resumeSchedule); - const deleteSchedule = useStore((s) => s.deleteSchedule); - const triggerNow = useStore((s) => s.triggerNow); + const { schedules, pauseSchedule, resumeSchedule, deleteSchedule, triggerNow, fetchSchedules } = + useStore( + useShallow((s) => ({ + schedules: s.schedules.filter((sch) => sch.teamName === teamName), + pauseSchedule: s.pauseSchedule, + resumeSchedule: s.resumeSchedule, + deleteSchedule: s.deleteSchedule, + triggerNow: s.triggerNow, + fetchSchedules: s.fetchSchedules, + })) + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); // Fetch schedules on mount - const fetchSchedules = useStore((s) => s.fetchSchedules); useEffect(() => { void fetchSchedules(); }, [fetchSchedules]); diff --git a/src/renderer/components/team/sidebar/TeamSidebarHost.tsx b/src/renderer/components/team/sidebar/TeamSidebarHost.tsx index b36388bd..cb0367ef 100644 --- a/src/renderer/components/team/sidebar/TeamSidebarHost.tsx +++ b/src/renderer/components/team/sidebar/TeamSidebarHost.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useId, useLayoutEffect, useState } from 'react'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { removeTeamSidebarHost, @@ -33,10 +34,12 @@ export const TeamSidebarHost = ({ }: TeamSidebarHostProps): React.JSX.Element => { const hostId = useId(); const [element, setElement] = useState(null); - const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({ - messagesPanelMode: s.messagesPanelMode, - messagesPanelWidth: s.messagesPanelWidth, - })); + const { messagesPanelMode, messagesPanelWidth } = useStore( + useShallow((s) => ({ + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + })) + ); const snapshot = useTeamSidebarPortalSnapshot(); const isVisible = messagesPanelMode === 'sidebar'; const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId; diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx index 3a2f7a4b..e460f174 100644 --- a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx +++ b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx @@ -7,6 +7,7 @@ import { useMemo } from 'react'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; @@ -84,9 +85,13 @@ export const GraphTaskCard = ({ }: GraphTaskCardProps): React.JSX.Element => { const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; - const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId)); - const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []); - const members = useStore((s) => s.selectedTeamData?.members ?? []); + const { task, tasks, members } = useStore( + useShallow((s) => ({ + task: s.selectedTeamData?.tasks.find((t) => t.id === taskId), + tasks: s.selectedTeamData?.tasks ?? [], + members: s.selectedTeamData?.members ?? [], + })) + ); const taskMap = useMemo(() => { const map = new Map(); diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index 7b3d2cd0..3f601835 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -6,6 +6,7 @@ */ import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import type { CliInstallationStatus, CliProviderId } from '@shared/types'; @@ -39,23 +40,45 @@ export function useCliInstaller(): { installCli: () => void; isBusy: boolean; } { - const cliStatus = useStore((s) => s.cliStatus); - const cliStatusLoading = useStore((s) => s.cliStatusLoading); - const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading); - const cliStatusError = useStore((s) => s.cliStatusError); - const installerState = useStore((s) => s.cliInstallerState); - const downloadProgress = useStore((s) => s.cliDownloadProgress); - const downloadTransferred = useStore((s) => s.cliDownloadTransferred); - const downloadTotal = useStore((s) => s.cliDownloadTotal); - const installerError = useStore((s) => s.cliInstallerError); - const installerDetail = useStore((s) => s.cliInstallerDetail); - const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); - const completedVersion = useStore((s) => s.cliCompletedVersion); - const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); - const fetchCliStatus = useStore((s) => s.fetchCliStatus); - const fetchCliProviderStatus = useStore((s) => s.fetchCliProviderStatus); - const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); - const installCli = useStore((s) => s.installCli); + const { + cliStatus, + cliStatusLoading, + cliProviderStatusLoading, + cliStatusError, + installerState, + downloadProgress, + downloadTransferred, + downloadTotal, + installerError, + installerDetail, + installerRawChunks, + completedVersion, + bootstrapCliStatus, + fetchCliStatus, + fetchCliProviderStatus, + invalidateCliStatus, + installCli, + } = useStore( + useShallow((s) => ({ + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, + cliProviderStatusLoading: s.cliProviderStatusLoading, + 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, + bootstrapCliStatus: s.bootstrapCliStatus, + fetchCliStatus: s.fetchCliStatus, + fetchCliProviderStatus: s.fetchCliProviderStatus, + invalidateCliStatus: s.invalidateCliStatus, + installCli: s.installCli, + })) + ); const isBusy = installerState !== 'idle' && installerState !== 'error' && installerState !== 'completed'; diff --git a/src/renderer/hooks/useTabUI.ts b/src/renderer/hooks/useTabUI.ts index 91a06a1c..fd7c3ec2 100644 --- a/src/renderer/hooks/useTabUI.ts +++ b/src/renderer/hooks/useTabUI.ts @@ -65,7 +65,7 @@ export function useTabUI(): UseTabUIReturn { // Subscribe to tabUIStates MAP directly for reactivity // This ensures re-renders when any tab state changes - const tabUIStates = useStore((s) => s.tabUIStates); + const tabUIStates = useStore(useShallow((s) => s.tabUIStates)); // Get the current tab's state (derived from subscribed state) const tabState = useMemo(() => { diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index 77a53e40..0c21e6e3 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; @@ -56,10 +57,14 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean { } export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { - const globalTasks = useStore((s) => s.globalTasks); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const selectedTeamData = useStore((s) => s.selectedTeamData); - const teamByName = useStore((s) => s.teamByName); + const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore( + useShallow((s) => ({ + globalTasks: s.globalTasks, + selectedTeamName: s.selectedTeamName, + selectedTeamData: s.selectedTeamData, + teamByName: s.teamByName, + })) + ); const suggestions = useMemo(() => { const tasks: TaskWithTeamContext[] = []; diff --git a/src/renderer/hooks/useTeamSuggestions.ts b/src/renderer/hooks/useTeamSuggestions.ts index a2db4171..9b376167 100644 --- a/src/renderer/hooks/useTeamSuggestions.ts +++ b/src/renderer/hooks/useTeamSuggestions.ts @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -26,7 +27,7 @@ export interface UseTeamSuggestionsResult { * @param currentTeamName - The current team name to exclude from suggestions */ export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult { - const teams = useStore((s) => s.teams); + const teams = useStore(useShallow((s) => s.teams)); const [aliveTeams, setAliveTeams] = useState>(new Set()); const [loading, setLoading] = useState(false); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index b589975a..5df9f33e 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -407,6 +407,16 @@ export function initializeNotificationListeners(): () => void { return; } + // Cleanup cursors for teams that no longer exist (prevent unbounded growth) + if (inProgressChangePresenceCursorByTeam.size > 50) { + const teamNames = new Set(useStore.getState().teams.map((t) => t.teamName)); + for (const key of inProgressChangePresenceCursorByTeam.keys()) { + if (!teamNames.has(key)) { + inProgressChangePresenceCursorByTeam.delete(key); + } + } + } + const candidateTasks = selectedTeamData.tasks.filter((task) => { if (task.status !== 'in_progress') { return false; diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 067a1eba..7d283fb1 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -4,6 +4,7 @@ */ import { api } from '@renderer/api'; +import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import type { AppState } from '../types'; import type { @@ -143,6 +144,10 @@ function getSkillsCatalogKey(projectPath?: string): string { /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; +const CLI_AUTH_REQUIRED_MESSAGE = + 'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.'; +const CLI_STATUS_UNKNOWN_MESSAGE = + 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; export const createExtensionsSlice: StateCreator = ( set, @@ -552,8 +557,36 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const preflightState = get(); + if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { + try { + await preflightState.fetchCliStatus(); + } catch { + // fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below. + } + } + + const cliStatus = get().cliStatus; + const preflightError = + cliStatus === null + ? CLI_STATUS_UNKNOWN_MESSAGE + : !cliStatus.installed + ? CLI_NOT_FOUND_MESSAGE + : !cliStatus.authLoggedIn + ? CLI_AUTH_REQUIRED_MESSAGE + : null; + + if (preflightError) { + set((prev) => ({ + pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, + installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, + })); + return; + } + set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, + installErrors: { ...prev.installErrors, [request.pluginId]: '' }, })); try { diff --git a/src/renderer/utils/mergeTeamMessages.ts b/src/renderer/utils/mergeTeamMessages.ts new file mode 100644 index 00000000..7a0b3fcc --- /dev/null +++ b/src/renderer/utils/mergeTeamMessages.ts @@ -0,0 +1,27 @@ +import { toMessageKey } from './teamMessageKey'; + +import type { InboxMessage } from '@shared/types'; + +function compareMessages(a: InboxMessage, b: InboxMessage): number { + const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp); + if (diff !== 0) return diff; + return toMessageKey(a).localeCompare(toMessageKey(b)); +} + +/** + * Merge multiple message arrays into one newest-first list with stable deduplication. + * + * Later arrays win for duplicate keys so callers can overlay fresher/live message data + * on top of paginated history without losing already-loaded older pages. + */ +export function mergeTeamMessages(...messageLists: readonly InboxMessage[][]): InboxMessage[] { + const merged = new Map(); + + for (const list of messageLists) { + for (const message of list) { + merged.set(toMessageKey(message), message); + } + } + + return Array.from(merged.values()).sort(compareMessages); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 21bf41fc..9413db3f 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -55,6 +55,7 @@ import type { ReplaceMembersRequest, SendMessageRequest, SendMessageResult, + MessagesPage, TaskAttachmentMeta, TaskChangePresenceState, TaskComment, @@ -437,6 +438,10 @@ export interface TeamsAPI { getProvisioningStatus: (runId: string) => Promise; cancelProvisioning: (runId: string) => Promise; sendMessage: (teamName: string, request: SendMessageRequest) => Promise; + getMessagesPage: ( + teamName: string, + options?: { beforeTimestamp?: string; limit?: number } + ) => Promise; createTask: (teamName: string, request: CreateTaskRequest) => Promise; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c0b6df1a..8b705d9f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -406,6 +406,14 @@ export interface InboxMessage { commandOutput?: CommandOutputMeta; } +/** Cursor-based paginated messages response. */ +export interface MessagesPage { + messages: InboxMessage[]; + /** Opaque cursor string for fetching older messages. Null when no more pages. */ + nextCursor: string | null; + hasMore: boolean; +} + export type AgentActionMode = 'do' | 'ask' | 'delegate'; export interface SendMessageRequest { diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index ea9fecbc..728f1575 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -651,6 +651,14 @@ describe('ChangeExtractorService', () => { const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true }); const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + // Flush microtasks so freshPromise advances past its internal awaits + // and reaches the worker mock before we resolve the stale deferred. + // Without this, CI timing can cause the stale resolution to race with + // the fresh worker call, making the test flaky. + await vi.advanceTimersByTimeAsync?.(0).catch(() => undefined); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); first.resolve(makeTaskChangeResult()); const stale = await stalePromise; const fresh = await freshPromise; diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 88d1302f..a2e077bb 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -3721,4 +3721,97 @@ describe('TeamDataService', () => { warnSpy.mockRestore(); } }); + + describe('getMessagesPage', () => { + function createPaginationService(messages: Array<{ from: string; text: string; timestamp: string; messageId?: string; source?: string; leadSessionId?: string }>) { + return new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + leadSessionId: 'lead-1', + })), + } as never, + { getTasks: vi.fn(async () => []) } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => + messages.map((m) => ({ ...m, read: true })) + ), + } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never, + {} as never, + {} as never, + { readMessages: vi.fn(async () => []) } as never, + ); + } + + it('returns first page with cursor and hasMore', async () => { + const msgs = Array.from({ length: 5 }, (_, i) => ({ + from: 'alice', + text: `msg-${i}`, + timestamp: `2026-01-01T00:00:0${i}.000Z`, + messageId: `m${i}`, + source: 'inbox' as const, + })); + const service = createPaginationService(msgs); + const page = await service.getMessagesPage('my-team', { limit: 3 }); + + expect(page.messages).toHaveLength(3); + expect(page.hasMore).toBe(true); + expect(page.nextCursor).toBeTruthy(); + // Newest first + expect(page.messages[0].messageId).toBe('m4'); + }); + + it('cursor excludes already-seen messages without losing same-timestamp messages', async () => { + const msgs = [ + { from: 'a', text: '1', timestamp: '2026-01-01T00:00:02.000Z', messageId: 'x1' }, + { from: 'b', text: '2', timestamp: '2026-01-01T00:00:02.000Z', messageId: 'x2' }, + { from: 'c', text: '3', timestamp: '2026-01-01T00:00:01.000Z', messageId: 'x3' }, + ]; + const service = createPaginationService(msgs); + const page1 = await service.getMessagesPage('my-team', { limit: 1 }); + expect(page1.messages).toHaveLength(1); + expect(page1.hasMore).toBe(true); + + const page2 = await service.getMessagesPage('my-team', { + beforeTimestamp: page1.nextCursor!, + limit: 10, + }); + // Should get the remaining 2 messages, not lose the one with same timestamp + expect(page2.messages.length).toBeGreaterThanOrEqual(1); + const allIds = [...page1.messages, ...page2.messages].map((m) => m.messageId); + expect(new Set(allIds).size).toBe(allIds.length); // no duplicates + }); + + it('annotates slash command results in paginated path', async () => { + const msgs = [ + { + from: 'user', + text: '/cost', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'cmd1', + source: 'user_sent', + leadSessionId: 'lead-1', + }, + { + from: 'team-lead', + text: 'Total cost: $1.05', + timestamp: '2026-01-01T00:00:01.000Z', + messageId: 'resp1', + source: 'lead_process', + leadSessionId: 'lead-1', + }, + ]; + const service = createPaginationService(msgs); + const page = await service.getMessagesPage('my-team', { limit: 10 }); + const result = page.messages.find((m) => m.messageId === 'resp1'); + expect(result?.messageKind).toBe('slash_command_result'); + }); + }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 4955c0ea..623a7b06 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -262,6 +262,24 @@ describe('extensionsSlice', () => { describe('installPlugin', () => { it('sets progress to pending then success', async () => { + store.setState({ + cliStatus: { + flavor: 'claude', + displayName: 'Claude', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth_token', + providers: [], + }, + }); const plugins = [makePlugin({ pluginId: 'a@m' })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); @@ -276,6 +294,24 @@ describe('extensionsSlice', () => { }); it('sets progress to error on failure', async () => { + store.setState({ + cliStatus: { + flavor: 'claude', + displayName: 'Claude', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth_token', + providers: [], + }, + }); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'error', error: 'Not found', diff --git a/test/renderer/utils/mergeTeamMessages.test.ts b/test/renderer/utils/mergeTeamMessages.test.ts new file mode 100644 index 00000000..d8da3645 --- /dev/null +++ b/test/renderer/utils/mergeTeamMessages.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeTeamMessages } from '../../../src/renderer/utils/mergeTeamMessages'; + +import type { InboxMessage } from '@shared/types'; + +function makeMessage( + overrides: Partial & Pick +): InboxMessage { + const { from, text, timestamp, ...rest } = overrides; + return { + from, + text, + timestamp, + read: rest.read ?? true, + ...rest, + }; +} + +describe('mergeTeamMessages', () => { + it('deduplicates by stable message key and keeps newest-first order', () => { + const older = makeMessage({ + from: 'alice', + text: 'older', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'm1', + }); + const newer = makeMessage({ + from: 'bob', + text: 'newer', + timestamp: '2026-01-01T00:00:01.000Z', + messageId: 'm2', + }); + const merged = mergeTeamMessages([older], [newer]); + + expect(merged.map((message) => message.messageId)).toEqual(['m2', 'm1']); + }); + + it('lets later arrays overlay duplicate messages', () => { + const persisted = makeMessage({ + from: 'team-lead', + text: 'hello', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'm1', + summary: 'persisted', + }); + const live = makeMessage({ + from: 'team-lead', + text: 'hello', + timestamp: '2026-01-01T00:00:00.000Z', + messageId: 'm1', + summary: 'live', + source: 'lead_process', + }); + + const merged = mergeTeamMessages([persisted], [live]); + + expect(merged).toHaveLength(1); + expect(merged[0].summary).toBe('live'); + expect(merged[0].source).toBe('lead_process'); + }); +});