diff --git a/src/main/index.ts b/src/main/index.ts index db9e7d37..35745047 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -62,6 +62,7 @@ import { UpdaterService, } from './services'; +import type { FileChangeEvent } from '@main/types'; import type { TeamChangeEvent } from '@shared/types'; const logger = createLogger('App'); @@ -76,18 +77,51 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500; /** Messages sent from our UI (user_sent) — suppress notifications for these. */ const suppressedSources = new Set(['user_sent']); +// --- Team display name cache (avoid listTeams() on every notification) --- +const TEAM_DISPLAY_NAME_TTL_MS = 30_000; +const teamDisplayNameCache = new Map(); +let teamListInFlight: Promise> | null = null; + +async function refreshTeamDisplayNameCache(): Promise> { + if (teamListInFlight) { + return teamListInFlight; + } + + teamListInFlight = (async () => { + const out = new Map(); + try { + if (!teamDataService) return out; + const summary = await teamDataService.listTeams(); + for (const team of summary) { + if (team?.teamName) { + out.set(team.teamName, team.displayName || team.teamName); + } + } + } catch { + // ignore + } finally { + teamListInFlight = null; + } + return out; + })(); + + return teamListInFlight; +} + /** Resolve human-friendly team display name, falling back to raw teamName. */ async function resolveTeamDisplayName(teamName: string): Promise { - try { - if (teamDataService) { - const summary = await teamDataService.listTeams(); - const team = summary.find((t) => t.teamName === teamName); - if (team?.displayName) return team.displayName; - } - } catch { - // fallback + const cached = teamDisplayNameCache.get(teamName); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; } - return teamName; + + const map = await refreshTeamDisplayNameCache(); + const resolved = map.get(teamName) ?? teamName; + teamDisplayNameCache.set(teamName, { + value: resolved, + expiresAt: Date.now() + TEAM_DISPLAY_NAME_TTL_MS, + }); + return resolved; } async function notifyNewInboxMessages(teamName: string, detail: string): Promise { @@ -220,9 +254,37 @@ function wireFileWatcherEvents(context: ServiceContext): void { } // Wire file-change events to renderer and HTTP SSE + const SCAN_CACHE_INVALIDATE_DEBOUNCE_MS = 250; + let scanCacheInvalidateTimer: ReturnType | null = null; + const scheduleScanCacheInvalidation = (): void => { + if (scanCacheInvalidateTimer) { + clearTimeout(scanCacheInvalidateTimer); + } + scanCacheInvalidateTimer = setTimeout(() => { + scanCacheInvalidateTimer = null; + context.projectScanner.clearScanCache(); + }, SCAN_CACHE_INVALIDATE_DEBOUNCE_MS); + }; + const fileChangeHandler = (event: unknown): void => { - // Invalidate the scan cache so the next IPC request sees fresh data - context.projectScanner.clearScanCache(); + // Avoid triggering a full project rescan on every session append. + // The ProjectScanner already has a short TTL cache; we only invalidate for + // structural changes (add/unlink), and we debounce bursts of events. + try { + if (event && typeof event === 'object') { + const row = event as Partial; + const isSubagent = row.isSubagent === true; + const changeType = row.type; + if (!isSubagent && (changeType === 'add' || changeType === 'unlink')) { + scheduleScanCacheInvalidation(); + } + } else { + // Fallback: if we can't classify the event, invalidate (debounced). + scheduleScanCacheInvalidation(); + } + } catch { + // ignore + } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('file-change', event); @@ -230,7 +292,13 @@ function wireFileWatcherEvents(context: ServiceContext): void { httpServer?.broadcast('file-change', event); }; context.fileWatcher.on('file-change', fileChangeHandler); - fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler); + fileChangeCleanup = () => { + context.fileWatcher.off('file-change', fileChangeHandler); + if (scanCacheInvalidateTimer) { + clearTimeout(scanCacheInvalidateTimer); + scanCacheInvalidateTimer = null; + } + }; // Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above) const todoChangeHandler = (event: unknown): void => { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 95d39107..75e348c3 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -12,7 +12,6 @@ import { createLogger } from '@shared/utils/logger'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import * as readline from 'readline'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; @@ -969,58 +968,76 @@ export class TeamDataService { const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead'; - const texts: InboxMessage[] = []; - - const stream = fs.createReadStream(jsonlPath, { encoding: 'utf8' }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + // Optimization: read from the end of the JSONL file (we only need the last N texts). + // The full file can be huge; scanning from the start causes long stalls on Windows. + const MAX_SCAN_BYTES = 8 * 1024 * 1024; // 8MB tail cap + const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB + const textsReversed: InboxMessage[] = []; + const handle = await fs.promises.open(jsonlPath, 'r'); try { - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; + const stat = await handle.stat(); + const fileSize = stat.size; - let msg: Record; - try { - msg = JSON.parse(trimmed) as Record; - } catch { - continue; + let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); + while (textsReversed.length < MAX_LEAD_TEXTS && scanBytes <= MAX_SCAN_BYTES) { + const start = Math.max(0, fileSize - scanBytes); + const buffer = Buffer.alloc(scanBytes); + await handle.read(buffer, 0, scanBytes, start); + const chunk = buffer.toString('utf8'); + + const lines = chunk.split(/\r?\n/); + // If we started mid-file, the first line may be partial — drop it. + const fromIndex = start > 0 ? 1 : 0; + + for (let i = lines.length - 1; i >= fromIndex; i--) { + const trimmed = lines[i]?.trim(); + if (!trimmed) continue; + + let msg: Record; + try { + msg = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + if (msg.type !== 'assistant') continue; + + const message = (msg.message ?? msg) as Record; + const content = message.content; + if (!Array.isArray(content)) continue; + + const timestamp = + typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString(); + + for (const block of content as Record[]) { + if (block.type !== 'text' || typeof block.text !== 'string') continue; + const text = block.text.trim(); + if (text.length < MIN_TEXT_LENGTH) continue; + textsReversed.push({ + from: leadName, + text, + timestamp, + read: true, + source: 'lead_session', + }); + if (textsReversed.length >= MAX_LEAD_TEXTS) break; + } + if (textsReversed.length >= MAX_LEAD_TEXTS) break; } - if (msg.type !== 'assistant') continue; - - const message = (msg.message ?? msg) as Record; - const content = message.content; - if (!Array.isArray(content)) continue; - - const timestamp = - typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString(); - - for (const block of content as Record[]) { - if (block.type !== 'text' || typeof block.text !== 'string') continue; - - const text = block.text.trim(); - if (text.length < MIN_TEXT_LENGTH) continue; - - texts.push({ - from: leadName, - text, - timestamp, - read: true, - source: 'lead_session', - }); - } + if (textsReversed.length >= MAX_LEAD_TEXTS) break; + if (scanBytes === fileSize) break; + scanBytes = Math.min(fileSize, scanBytes * 2); } } finally { - rl.close(); - stream.destroy(); + await handle.close(); } - // Keep only the last N texts - if (texts.length > MAX_LEAD_TEXTS) { - return texts.slice(-MAX_LEAD_TEXTS); - } - - return texts; + // Convert back to chronological order (old behavior) and keep the last N texts. + textsReversed.reverse(); + const texts = textsReversed; + return texts.length > MAX_LEAD_TEXTS ? texts.slice(-MAX_LEAD_TEXTS) : texts; } async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise { diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx index fefd89d2..efc1c1a9 100644 --- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useStore } from '@renderer/store'; import { ExternalLink } from 'lucide-react'; @@ -6,7 +6,7 @@ import { useShallow } from 'zustand/react/shallow'; import { TaskDetailDialog } from './TaskDetailDialog'; -import type { TeamTaskWithKanban } from '@shared/types'; +import type { GlobalTask, TeamTaskWithKanban } from '@shared/types'; /** * Global wrapper around TaskDetailDialog. @@ -17,36 +17,59 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { const { globalTaskDetail, closeGlobalTaskDetail, + selectedTeamName, selectedTeamData, selectedTeamLoading, + selectTeam, openTeamTab, setPendingReviewRequest, + globalTasks, } = useStore( useShallow((s) => ({ globalTaskDetail: s.globalTaskDetail, closeGlobalTaskDetail: s.closeGlobalTaskDetail, + selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, selectedTeamLoading: s.selectedTeamLoading, + selectTeam: s.selectTeam, openTeamTab: s.openTeamTab, setPendingReviewRequest: s.setPendingReviewRequest, + globalTasks: s.globalTasks, })) ); - const taskMap = useMemo(() => { - const map = new Map(); - if (!selectedTeamData) return map; - for (const t of selectedTeamData.tasks) map.set(t.id, t); - return map; - }, [selectedTeamData]); - - const activeMembers = useMemo( - () => selectedTeamData?.members.filter((m) => !m.removedAt) ?? [], - [selectedTeamData] - ); - const teamName = globalTaskDetail?.teamName ?? ''; const taskId = globalTaskDetail?.taskId ?? ''; + // Load full team data in the background to enable "as before" details (logs/changes/members). + useEffect(() => { + if (!globalTaskDetail) return; + if (selectedTeamName === teamName && selectedTeamData) return; + void selectTeam(teamName, { skipProjectAutoSelect: true }); + }, [globalTaskDetail, selectTeam, selectedTeamData, selectedTeamName, teamName]); + + const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData; + + const taskMap = useMemo(() => { + const map = new Map(); + if (!globalTaskDetail) return map; + if (isFullTeamLoaded && selectedTeamData) { + for (const t of selectedTeamData.tasks) map.set(t.id, t); + return map; + } + for (const t of globalTasks) { + if (t.teamName === globalTaskDetail.teamName) { + map.set(t.id, t); + } + } + return map; + }, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]); + + const activeMembers = useMemo( + () => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []), + [isFullTeamLoaded, selectedTeamData] + ); + const handleOpenTeam = useCallback((): void => { closeGlobalTaskDetail(); openTeamTab(teamName, undefined, taskId); @@ -63,13 +86,16 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { if (!globalTaskDetail) return null; - const task = taskMap.get(taskId) ?? null; - const kanbanTaskState = selectedTeamData?.kanbanState.tasks[taskId]; + const task = (taskMap.get(taskId) as GlobalTask | undefined) ?? null; + const kanbanTaskState = isFullTeamLoaded + ? selectedTeamData?.kanbanState.tasks[taskId] + : undefined; return ( { members={activeMembers} onClose={closeGlobalTaskDetail} onOwnerChange={undefined} - onViewChanges={handleViewChanges} + onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined} headerExtra={