diff --git a/.github/workflows/reviewrouter-codex.yml b/.github/workflows/reviewrouter-codex.yml index e614860d..2fee5e36 100644 --- a/.github/workflows/reviewrouter-codex.yml +++ b/.github/workflows/reviewrouter-codex.yml @@ -17,7 +17,7 @@ jobs: steps: - name: ReviewRouter Codex OAuth review id: run_codex - uses: 777genius/review-router@c23ae97660c3674a2ebc9076bb5cd4f1bbd85657 + uses: 777genius/review-router@97fdbdf1685350ac9a7f29e0430e82c2360c2821 with: mode: codex-oauth-rotating api-url: "https://api.reviewrouter.site" diff --git a/landing/assets/images/footer/robot-lead-lounge-v1.webp b/landing/assets/images/footer/robot-lead-lounge-v1.webp index 67876934..da1bd5a8 100644 Binary files a/landing/assets/images/footer/robot-lead-lounge-v1.webp and b/landing/assets/images/footer/robot-lead-lounge-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp b/landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp index 27b392ae..b8ffa625 100644 Binary files a/landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp and b/landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp b/landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp index ce12d9ba..707fc8f4 100644 Binary files a/landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp and b/landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp b/landing/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp index d081566c..b466bd55 100644 Binary files a/landing/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp and b/landing/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp b/landing/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp index 90f27c1e..1bc007ca 100644 Binary files a/landing/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp and b/landing/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp b/landing/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp index f49a5450..3c18cf3b 100644 Binary files a/landing/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp and b/landing/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-red-purple-handshake-v1.webp b/landing/assets/images/hero/robots/robot-red-purple-handshake-v1.webp index 98a0c794..5399a422 100644 Binary files a/landing/assets/images/hero/robots/robot-red-purple-handshake-v1.webp and b/landing/assets/images/hero/robots/robot-red-purple-handshake-v1.webp differ diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts index 368d590f..68d9b8ff 100644 --- a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -332,17 +332,27 @@ export function useCodexAccountSnapshot(options: { [applySnapshot, electronMode, options.enabled] ); + const waitingForInitialRefresh = + electronMode && + options.enabled && + initialRefreshDelayMs > 0 && + snapshot === null && + !initialRefreshAttempted; + const effectiveLoading = loading || waitingForInitialRefresh; + const effectiveRateLimitsLoading = + rateLimitsLoading || (waitingForInitialRefresh && options.includeRateLimits === true); + return useMemo( () => ({ snapshot, - loading, - rateLimitsLoading, + loading: effectiveLoading, + rateLimitsLoading: effectiveRateLimitsLoading, error, refresh, startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })), cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()), logout: () => runAction(() => api.logoutCodexAccount()), }), - [error, loading, rateLimitsLoading, refresh, runAction, snapshot] + [effectiveLoading, effectiveRateLimitsLoading, error, refresh, runAction, snapshot] ); } diff --git a/src/features/localization/renderer/locales/ar/team.json b/src/features/localization/renderer/locales/ar/team.json index df6bc31c..58a7b186 100644 --- a/src/features/localization/renderer/locales/ar/team.json +++ b/src/features/localization/renderer/locales/ar/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Reused recent cross-team request", - "teamOffline": "الفريق غير المباشر" + "teamOffline": "غير متصل" }, "revision": { "editing": "جارٍ تعديل الرسالة السابقة", diff --git a/src/features/localization/renderer/locales/bn/team.json b/src/features/localization/renderer/locales/bn/team.json index 0e559446..1ed3e660 100644 --- a/src/features/localization/renderer/locales/bn/team.json +++ b/src/features/localization/renderer/locales/bn/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "সম্প্রতি ব্যবহৃত ক্রস-টেম অনুরোধ", - "teamOffline": "অফলাইন অবস্থায় ব্যবহারের জন্য প্রস্তুত করা হচ্ছে" + "teamOffline": "অফলাইন" }, "revision": { "editing": "আগের বার্তা সম্পাদনা করা হচ্ছে", diff --git a/src/features/localization/renderer/locales/de/team.json b/src/features/localization/renderer/locales/de/team.json index 25f61945..dc6c6057 100644 --- a/src/features/localization/renderer/locales/de/team.json +++ b/src/features/localization/renderer/locales/de/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Neuer Cross-Dampf-Antrag", - "teamOffline": "Team offline" + "teamOffline": "offline" }, "revision": { "editing": "Vorherige Nachricht wird bearbeitet", diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json index 56d965f6..4b9fb7c9 100644 --- a/src/features/localization/renderer/locales/en/team.json +++ b/src/features/localization/renderer/locales/en/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Reused recent cross-team request", - "teamOffline": "Team offline" + "teamOffline": "offline" }, "revision": { "editing": "Editing previous message", diff --git a/src/features/localization/renderer/locales/es/team.json b/src/features/localization/renderer/locales/es/team.json index ba98cf81..16b11e22 100644 --- a/src/features/localization/renderer/locales/es/team.json +++ b/src/features/localization/renderer/locales/es/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Reutilización reciente de la solicitud de equipo cruzado", - "teamOffline": "Team offline" + "teamOffline": "sin conexión" }, "revision": { "editing": "Editando mensaje anterior", diff --git a/src/features/localization/renderer/locales/fr/team.json b/src/features/localization/renderer/locales/fr/team.json index 5cdaa1e6..67e06b65 100644 --- a/src/features/localization/renderer/locales/fr/team.json +++ b/src/features/localization/renderer/locales/fr/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Réutilisée récente demande cross-team", - "teamOffline": "Équipe hors ligne" + "teamOffline": "hors ligne" }, "revision": { "editing": "Modification du message précédent", diff --git a/src/features/localization/renderer/locales/hi/team.json b/src/features/localization/renderer/locales/hi/team.json index 0dc47019..276102c7 100644 --- a/src/features/localization/renderer/locales/hi/team.json +++ b/src/features/localization/renderer/locales/hi/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "हाल के क्रॉस-टीम अनुरोध का पुन: उपयोग किया", - "teamOffline": "टीम ऑफलाइन" + "teamOffline": "ऑफलाइन" }, "revision": { "editing": "पिछला संदेश संपादित हो रहा है", diff --git a/src/features/localization/renderer/locales/id/team.json b/src/features/localization/renderer/locales/id/team.json index cdbcdf5e..e1d50d97 100644 --- a/src/features/localization/renderer/locales/id/team.json +++ b/src/features/localization/renderer/locales/id/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Mengulang permintaan tim-cross- baru-baru ini", - "teamOffline": "Tim luring" + "teamOffline": "offline" }, "revision": { "editing": "Mengedit pesan sebelumnya", diff --git a/src/features/localization/renderer/locales/ja/team.json b/src/features/localization/renderer/locales/ja/team.json index 2f393406..c38c254b 100644 --- a/src/features/localization/renderer/locales/ja/team.json +++ b/src/features/localization/renderer/locales/ja/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "最近のクロスチームリクエストを再利用", - "teamOffline": "オフラインチーム" + "teamOffline": "オフライン" }, "revision": { "editing": "前のメッセージを編集中", diff --git a/src/features/localization/renderer/locales/ko/team.json b/src/features/localization/renderer/locales/ko/team.json index b3f41d7e..b13ab962 100644 --- a/src/features/localization/renderer/locales/ko/team.json +++ b/src/features/localization/renderer/locales/ko/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "최근 Cross-team 요청 사용", - "teamOffline": "팀 오프라인" + "teamOffline": "오프라인" }, "revision": { "editing": "이전 메시지 편집 중", diff --git a/src/features/localization/renderer/locales/pt/team.json b/src/features/localization/renderer/locales/pt/team.json index 365ff9b3..0be67e42 100644 --- a/src/features/localization/renderer/locales/pt/team.json +++ b/src/features/localization/renderer/locales/pt/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Reutilizar o pedido de equipa cruzada recente", - "teamOffline": "Equipa offline" + "teamOffline": "offline" }, "revision": { "editing": "A editar a mensagem anterior", diff --git a/src/features/localization/renderer/locales/ru/team.json b/src/features/localization/renderer/locales/ru/team.json index ad176aef..dfb710dc 100644 --- a/src/features/localization/renderer/locales/ru/team.json +++ b/src/features/localization/renderer/locales/ru/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "Повторно использован недавний cross-team request", - "teamOffline": "Команда offline" + "teamOffline": "оффлайн" }, "revision": { "editing": "Редактируется предыдущее сообщение", diff --git a/src/features/localization/renderer/locales/ur/team.json b/src/features/localization/renderer/locales/ur/team.json index 19f05cca..3d96b644 100644 --- a/src/features/localization/renderer/locales/ur/team.json +++ b/src/features/localization/renderer/locales/ur/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "حالیہ صلیبی درخواست استعمال کریں", - "teamOffline": "گروپ" + "teamOffline": "آف لائن" }, "revision": { "editing": "پچھلا پیغام ترمیم ہو رہا ہے", diff --git a/src/features/localization/renderer/locales/zh/team.json b/src/features/localization/renderer/locales/zh/team.json index 7cab2856..182b1848 100644 --- a/src/features/localization/renderer/locales/zh/team.json +++ b/src/features/localization/renderer/locales/zh/team.json @@ -1485,7 +1485,7 @@ }, "status": { "reusedCrossTeamRequest": "重新使用最近的跨小组请求", - "teamOffline": "团队离线" + "teamOffline": "离线" }, "revision": { "editing": "正在编辑上一条消息", diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts index f70bbd5e..c97e2d53 100644 --- a/src/features/localization/renderer/resources.d.ts +++ b/src/features/localization/renderer/resources.d.ts @@ -4236,7 +4236,7 @@ export default interface Resources { }; status: { reusedCrossTeamRequest: 'Reused recent cross-team request'; - teamOffline: 'Team offline'; + teamOffline: 'offline'; }; teamSelector: { current: 'current'; diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts index 89d00b9a..3a769591 100644 --- a/src/features/tmux-installer/main/composition/runtimeSupport.ts +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -1,5 +1,6 @@ import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; import { + type ListRuntimeProcessesOptions, type RuntimeProcessTableRow, type TmuxPaneRuntimeInfo, TmuxPlatformCommandExecutor, @@ -34,10 +35,10 @@ export async function listTmuxPaneRuntimeInfoForCurrentPlatform( return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds); } -export async function listRuntimeProcessTableForCurrentPlatform(): Promise< - RuntimeProcessTableRow[] -> { - return runtimeCommandExecutor.listRuntimeProcesses(); +export async function listRuntimeProcessTableForCurrentPlatform( + options: ListRuntimeProcessesOptions = {} +): Promise { + return runtimeCommandExecutor.listRuntimeProcesses(options); } export async function sendKeysToTmuxPaneForCurrentPlatform( diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index a3409800..aee9b701 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -28,11 +28,65 @@ export interface RuntimeProcessTableRow { pid: number; ppid: number; command: string; + cpuPercent?: number; + rssBytes?: number; +} + +export interface ListRuntimeProcessesOptions { + bypassCache?: boolean; +} + +/** + * Short-lived cache window for the global process table. + * + * `listRuntimeProcesses` spawns a full `ps -ax`, which is expensive when forked + * from the large Electron main process. Runtime liveness/telemetry callers fire + * very frequently (every team file change invalidates their per-team snapshot + * caches), so without throttling here the main process spawns `ps` dozens of + * times per second while a team runs. Runtime liveness can tolerate a small + * delay because verdicts are identity- (team+agent+command) not bare-PID + * matched, and OpenCode host cleanup re-validates each PID against live state + * before acting. Keep this cache long enough to collapse bursts from concurrent + * team refreshes, but short enough that stale "alive" UI is brief. + */ +const RUNTIME_PROCESS_TABLE_CACHE_TTL_MS = 30_000; + +interface RuntimeProcessTableCacheEntry { + rows: RuntimeProcessTableRow[]; + expiresAtMs: number; } export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] { const rows: RuntimeProcessTableRow[] = []; for (const line of output.split('\n')) { + const enrichedMatch = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line); + if (enrichedMatch) { + const pid = Number.parseInt(enrichedMatch[1], 10); + const ppid = Number.parseInt(enrichedMatch[2], 10); + // `ps` formats the %cpu column with the locale decimal separator (e.g. "12,5" on + // de_DE/fr_FR locales, which the runtime inherits via process.env). Normalize the + // comma to a dot so Number() does not return NaN — otherwise the enriched parse would + // fail its isFinite guard and fall back to the basic parser, leaking the pcpu/rss + // columns into `command`. + const cpuPercent = Number(enrichedMatch[3]?.replace(',', '.')); + const rssKb = Number(enrichedMatch[4]?.replace(',', '.')); + const command = enrichedMatch[5]?.trim() ?? ''; + if ( + Number.isFinite(pid) && + pid > 0 && + Number.isFinite(ppid) && + ppid >= 0 && + Number.isFinite(cpuPercent) && + cpuPercent >= 0 && + Number.isFinite(rssKb) && + rssKb >= 0 && + command.length > 0 + ) { + rows.push({ pid, ppid, command, cpuPercent, rssBytes: Math.round(rssKb * 1024) }); + continue; + } + } + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); if (!match) continue; @@ -55,6 +109,8 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow export class TmuxPlatformCommandExecutor { readonly #wslService: TmuxWslService; readonly #packageManagerResolver: TmuxPackageManagerResolver; + #runtimeProcessTableCache: RuntimeProcessTableCacheEntry | null = null; + #runtimeProcessTableInFlight: Promise | null = null; constructor( wslService = new TmuxWslService(), @@ -166,10 +222,42 @@ export class TmuxPlatformCommandExecutor { return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid])); } - async listRuntimeProcesses(): Promise { + async listRuntimeProcesses( + options: ListRuntimeProcessesOptions = {} + ): Promise { + const cached = this.#runtimeProcessTableCache; + if (options.bypassCache !== true && cached && cached.expiresAtMs > Date.now()) { + return cached.rows; + } + if (this.#runtimeProcessTableInFlight) { + return this.#runtimeProcessTableInFlight; + } + const request = this.#readRuntimeProcessesUncached() + .then((rows) => { + this.#runtimeProcessTableCache = { + rows, + expiresAtMs: Date.now() + RUNTIME_PROCESS_TABLE_CACHE_TTL_MS, + }; + return rows; + }) + .finally(() => { + if (this.#runtimeProcessTableInFlight === request) { + this.#runtimeProcessTableInFlight = null; + } + }); + this.#runtimeProcessTableInFlight = request; + return request; + } + + async #readRuntimeProcessesUncached(): Promise { const result = process.platform === 'win32' - ? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command=']) + ? await this.#wslService.execInPreferredDistro([ + 'ps', + '-ax', + '-o', + 'pid=,ppid=,pcpu=,rss=,command=', + ]) : await this.#execNativePs(); if (result.exitCode !== 0) { throw new Error(result.stderr || 'Failed to list runtime processes'); @@ -251,7 +339,7 @@ export class TmuxPlatformCommandExecutor { return new Promise((resolve) => { execFile( 'ps', - ['-ax', '-o', 'pid=,ppid=,command='], + ['-ax', '-o', 'pid=,ppid=,pcpu=,rss=,command='], { env: process.env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 }, (error, stdout, stderr) => { const errorCode = diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts index bd9d3166..41cc104e 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -13,7 +13,10 @@ vi.mock('node:child_process', async () => { import * as childProcess from 'node:child_process'; import * as fs from 'node:fs'; -import { TmuxPlatformCommandExecutor } from '../TmuxPlatformCommandExecutor'; +import { + parseRuntimeProcessTable, + TmuxPlatformCommandExecutor, +} from '../TmuxPlatformCommandExecutor'; function setPlatform(value: string): void { Object.defineProperty(process, 'platform', { @@ -100,11 +103,28 @@ describe('TmuxPlatformCommandExecutor', () => { ); }); + it('parses the %cpu column when the locale uses a comma decimal separator', () => { + // de_DE/fr_FR locales make `ps` print pcpu as e.g. "7,5". The enriched parser must + // normalize the comma so the row keeps its cpu/rss metrics and does not leak the + // numeric columns into `command` via the basic fallback parser. + const rows = parseRuntimeProcessTable(' 42 1 7,5 128 opencode runtime --team-name demo\n'); + + expect(rows).toEqual([ + { + pid: 42, + ppid: 1, + command: 'opencode runtime --team-name demo', + cpuPercent: 7.5, + rssBytes: 131_072, + }, + ]); + }); + it('lists runtime processes inside WSL on Windows instead of using host ps', async () => { setPlatform('win32'); const execInPreferredDistro = vi.fn(async () => ({ exitCode: 0, - stdout: ' 42 1 opencode runtime --team-name demo\n', + stdout: ' 42 1 7.5 128 opencode runtime --team-name demo\n', stderr: '', })); const executor = new TmuxPlatformCommandExecutor( @@ -116,9 +136,55 @@ describe('TmuxPlatformCommandExecutor', () => { ); await expect(executor.listRuntimeProcesses()).resolves.toEqual([ - { pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' }, + { + pid: 42, + ppid: 1, + command: 'opencode runtime --team-name demo', + cpuPercent: 7.5, + rssBytes: 131_072, + }, + ]); + expect(execInPreferredDistro).toHaveBeenCalledWith([ + 'ps', + '-ax', + '-o', + 'pid=,ppid=,pcpu=,rss=,command=', ]); - expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']); expect(childProcess.execFile).not.toHaveBeenCalled(); }); + + it('can bypass the runtime process table cache for fresh process reads', async () => { + setPlatform('win32'); + const execInPreferredDistro = vi + .fn() + .mockResolvedValueOnce({ + exitCode: 0, + stdout: ' 42 1 1.0 128 opencode runtime --team-name demo --agent-id alice@demo\n', + stderr: '', + }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: ' 43 1 1.0 128 opencode runtime --team-name demo --agent-id alice@demo\n', + stderr: '', + }); + const executor = new TmuxPlatformCommandExecutor( + { + execInPreferredDistro, + getPersistedPreferredDistroSync: () => 'Ubuntu', + } as never, + {} as never + ); + + await expect(executor.listRuntimeProcesses()).resolves.toEqual([ + expect.objectContaining({ pid: 42 }), + ]); + await expect(executor.listRuntimeProcesses()).resolves.toEqual([ + expect.objectContaining({ pid: 42 }), + ]); + await expect(executor.listRuntimeProcesses({ bypassCache: true })).resolves.toEqual([ + expect.objectContaining({ pid: 43 }), + ]); + + expect(execInPreferredDistro).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/main/index.ts b/src/main/index.ts index c3069f59..196494b0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -62,6 +62,11 @@ import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '@main/services/runtime/ope import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; +import { + computeTeamWatchScope, + setAliveTeamsProvider, + setTeamWatchScopeChangeListener, +} from '@main/services/infrastructure/teamWatchScope'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { SchedulerService } from '@main/services/schedule/SchedulerService'; @@ -1412,8 +1417,23 @@ function wireFileWatcherEvents(context: ServiceContext): void { } }; context.fileWatcher.on('team-change', teamChangeHandler); + + // Scope team-root/task file watching to alive + UI-engaged teams so it no longer + // scales with the number of teams on disk. Inboxes and the teams root stay fully + // watched, so cross-team delivery, the lead inbox relay, and notifications are + // unaffected. Unsetting the provider on cleanup reverts to watching every team. + setAliveTeamsProvider(() => teamProvisioningService.getAliveTeamNames()); + setTeamWatchScopeChangeListener(() => { + void context.fileWatcher.refreshTeamWatchScope(); + }); + context.fileWatcher.setTeamWatchScopeProvider(() => computeTeamWatchScope()); + void context.fileWatcher.refreshTeamWatchScope(); + teamChangeCleanup = () => { context.fileWatcher.off('team-change', teamChangeHandler); + setAliveTeamsProvider(null); + setTeamWatchScopeChangeListener(null); + context.fileWatcher.setTeamWatchScopeProvider(null); reconcileScheduler?.dispose(); }; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 64898634..9f0d44df 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -4,6 +4,7 @@ import { } from '@features/agent-attachments/contracts'; import { addMainBreadcrumb } from '@main/sentry'; import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; +import { markTeamEngaged } from '@main/services/infrastructure/teamWatchScope'; import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient'; import { getAppIconPath } from '@main/utils/appIcon'; import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder'; @@ -193,6 +194,7 @@ import type { CreateTaskRequest, EffortLevel, GlobalTask, + InboxMessage, IpcResult, KanbanColumnId, LeadActivitySnapshot, @@ -330,6 +332,33 @@ function noteHeavyTeamDataWorkerFallback(operation: string): void { ); } +async function getNewestMessagesPageWithLiveOverlay(input: { + teamName: string; + limit: number; + liveMessages: InboxMessage[]; + includeUndefinedCursorInFallback?: boolean; +}): Promise { + const { teamName, limit, liveMessages } = input; + const worker = getTeamDataWorkerClient(); + const options = input.includeUndefinedCursorInFallback + ? { cursor: undefined, limit, liveMessages } + : { limit, liveMessages }; + if (worker.isAvailable()) { + try { + return await worker.getMessagesPage(teamName, options); + } catch (workerErr) { + logger.warn( + `[teams:getMessagesPage] worker failed for live overlay, falling back: ${ + workerErr instanceof Error ? workerErr.message : workerErr + }` + ); + } + } + + noteHeavyTeamDataWorkerFallback('teams:getMessagesPage.liveOverlay'); + return getTeamDataService().getMessagesPage(teamName, options); +} + function invalidateTeamRosterSnapshotCaches(teamName: string): void { TeamConfigReader.invalidateTeam(teamName); const teamDataService = getTeamDataService(); @@ -869,6 +898,9 @@ async function handleGetData( return { success: false, error: optionsResult.error }; } const tn = validated.value!; + // The UI is fetching this team, so keep its team-root/task artifacts watched + // (idle teams the UI never opens are not watched, to scale with team count). + markTeamEngaged(tn); const getDataOptions = optionsResult.value; const startedAt = Date.now(); let data: TeamViewSnapshot; @@ -988,7 +1020,8 @@ async function handleGetData( let merged = mergeLiveLeadProcessMessages(durableMessages, live); if (durableMessages.length >= 50) { try { - const newestPage = await teamDataService.getMessagesPage(tn, { + const newestPage = await getNewestMessagesPageWithLiveOverlay({ + teamName: tn, limit: 50, liveMessages: live, }); @@ -1935,6 +1968,9 @@ async function handleCreateTeam( return wrapTeamHandler('create', async () => { addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName }); launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create'); + // Keep this team's team-root/task artifacts file-watched while createTeam writes + // its initial config, tasks, inboxes, and launch state. + markTeamEngaged(validation.value.teamName); try { const response = await getTeamProvisioningService().createTeam( validation.value, @@ -2100,6 +2136,9 @@ async function handleLaunchTeam( return wrapTeamHandler('create', async () => { launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch'); + // Draft launch runs through createTeam, so it needs the same immediate watch scope + // as a normal launch before startup files begin changing. + markTeamEngaged(tn); try { const response = await getTeamProvisioningService().createTeam( createRequest, @@ -2180,6 +2219,10 @@ async function handleLaunchTeam( return wrapTeamHandler('launch', async () => { addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! }); launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch'); + // Keep this team's team-root/task artifacts file-watched for the whole launch (and the + // engaged TTL after), so the lead's immediate startup writes are not missed during the + // 0-30s window before the periodic watch-scope reconcile would otherwise pick it up. + markTeamEngaged(validatedTeamName.value!); try { const response = await getTeamProvisioningService().launchTeam( { @@ -2708,10 +2751,11 @@ async function handleGetMessagesPage( cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(teamName) : []; if (liveMessages.length > 0) { - page = await getTeamDataService().getMessagesPage(teamName, { - cursor, + page = await getNewestMessagesPageWithLiveOverlay({ + teamName, limit, liveMessages, + includeUndefinedCursorInFallback: true, }); scanNotifications(page); return page; diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 9e2ff08e..b689db4c 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -94,6 +94,11 @@ export class FileWatcher extends EventEmitter { private todosPath: string; private teamsPath: string; private tasksPath: string; + // Optional scope for team-root/task watching (alive ∪ engaged teams). Inboxes + // and the teams root are always watched. Null => watch every team (fallback). + private teamWatchScopeProvider: (() => ReadonlySet | null) | null = null; + private teamsRegistry: TeamTaskWatchRegistry | null = null; + private tasksRegistry: TeamTaskWatchRegistry | null = null; private dataCache: DataCache; private fsProvider: FileSystemProvider; private notificationManager: NotificationManager | null = null; @@ -127,7 +132,7 @@ export class FileWatcher extends EventEmitter { private disposed = false; /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files). * Floored to second granularity because filesystem birthtimeMs may have lower resolution - * than Date.now() — without this, a file created in the same millisecond-window could + * than Date.now() - without this, a file created in the same millisecond-window could * appear older than the watcher on some platforms (e.g. ext4 on Linux). */ private readonly instanceCreatedAt = Math.floor(Date.now() / 1000) * 1000; @@ -246,6 +251,31 @@ export class FileWatcher extends EventEmitter { /** * Sets the filesystem provider. Used when switching between local and SSH modes. */ + /** + * Inject the provider that decides which teams' team-root and task artifacts + * are watched (typically alive ∪ engaged teams). The teams root and every + * team's inboxes are always watched. Returning null (or leaving the provider + * unset) watches every team - the safe fallback / original behavior. + * + * Only the chokidar registry path is scoped; the EMFILE polling fallback still + * watches every team so a scope change can never be mistaken for a deletion. + */ + setTeamWatchScopeProvider(provider: (() => ReadonlySet | null) | null): void { + this.teamWatchScopeProvider = provider; + } + + /** + * Recompute the watched team set immediately, e.g. right after a team launches, + * stops, or becomes engaged in the UI. Safe to call frequently: it no-ops when + * the resolved target set is unchanged and coalesces with in-flight reconciles. + */ + async refreshTeamWatchScope(): Promise { + await Promise.all([ + this.teamsRegistry?.requestReconcile(), + this.tasksRegistry?.requestReconcile(), + ]); + } + setFileSystemProvider(provider: FileSystemProvider): void { this.fsProvider = provider; } @@ -545,8 +575,15 @@ export class FileWatcher extends EventEmitter { } }, onError, + getScopedTeamNames: () => this.teamWatchScopeProvider?.() ?? null, }); + if (watcherType === 'teams') { + this.teamsRegistry = registry; + } else { + this.tasksRegistry = registry; + } + try { await registry.start(); } catch (error) { diff --git a/src/main/services/infrastructure/TeamTaskWatchRegistry.ts b/src/main/services/infrastructure/TeamTaskWatchRegistry.ts index 334279b1..a8cba329 100644 --- a/src/main/services/infrastructure/TeamTaskWatchRegistry.ts +++ b/src/main/services/infrastructure/TeamTaskWatchRegistry.ts @@ -14,10 +14,30 @@ export interface TeamTaskWatchRegistryOptions { rootPath: string; onChange: (eventType: TeamTaskWatchEventType, relativePath: string) => void; onError: (error: unknown) => void; + /** + * Optional provider for the set of team names whose team-root and task + * artifacts should be watched. The root directory is always watched (to detect + * new/removed teams), and for the 'teams' kind every team's `inboxes/` is + * always watched (cross-team message delivery and notifications must stay + * immediate). Return `null` (or omit the provider) to watch every team - the + * original behavior and the safe fallback. + * + * Scoping exists because team-root (config/kanban/processes/meta) and task + * artifacts only change for teams that are running or currently engaged in the + * UI; idle teams are static, so watching all of them is pure overhead that + * scales with the number of teams on disk. + */ + getScopedTeamNames?: () => ReadonlySet | null; } const RECONCILE_INTERVAL_MS = 30_000; +// Coalesce bursts of directory add/remove events (e.g. a team launch creating +// many dirs/files) into a single target reconcile + watcher rebuild. collectTargets +// re-reads the current directory state, so a trailing reconcile still sees every +// change; this only avoids rebuilding the whole watcher once per event in a burst. +const RECONCILE_DEBOUNCE_MS = 250; + // Keep this list aligned with FileWatcher.processTeamsChange(). // If a new team artifact should produce TeamChangeEvent, add it here too. const TEAM_ROOT_FILES = new Set([ @@ -51,11 +71,11 @@ export class TeamTaskWatchRegistry { private reconcileTimer: NodeJS.Timeout | null = null; private targets = new Set(); private targetKey = ''; - private initialTargetsCaptured = false; private closed = false; private generation = 0; private reconcileInProgress = false; private reconcileAgain = false; + private reconcileDebounceTimer: NodeJS.Timeout | null = null; constructor(private readonly options: TeamTaskWatchRegistryOptions) {} @@ -76,6 +96,37 @@ export class TeamTaskWatchRegistry { this.reconcileTimer.unref(); } + /** + * Force an immediate target reconciliation. Call this when the scoped team set + * changes (a team launches, stops, or becomes engaged in the UI) so the watch + * set updates without waiting for the periodic reconcile. Safe to call often: + * it no-ops when the resulting target set is unchanged and coalesces with any + * in-flight reconcile. + */ + async requestReconcile(): Promise { + await this.reconcileTargets(); + } + + /** + * Debounced target reconcile for high-frequency directory events. Bursts of + * add/remove dir events (notably while a team launch creates many dirs/files) + * collapse into a single rebuild after a short window instead of tearing down + * and recreating the whole watcher once per event. Correctness is preserved: + * collectTargets re-reads the current directory state, so the trailing reconcile + * still sees every change, and emitExistingFilesForNewTargets backfills files + * created before the rebuild. + */ + private scheduleReconcile(): void { + if (this.closed || this.reconcileDebounceTimer) { + return; + } + this.reconcileDebounceTimer = setTimeout(() => { + this.reconcileDebounceTimer = null; + void this.reconcileTargets(); + }, RECONCILE_DEBOUNCE_MS); + this.reconcileDebounceTimer.unref?.(); + } + async close(): Promise { this.closed = true; this.generation += 1; @@ -84,6 +135,10 @@ export class TeamTaskWatchRegistry { clearInterval(this.reconcileTimer); this.reconcileTimer = null; } + if (this.reconcileDebounceTimer) { + clearTimeout(this.reconcileDebounceTimer); + this.reconcileDebounceTimer = null; + } const watcher = this.watcher; this.watcher = null; @@ -108,8 +163,7 @@ export class TeamTaskWatchRegistry { const targets = await this.collectTargets(); const nextKey = targets.join('\n'); if (nextKey !== this.targetKey) { - const addedTargets = targets.filter((target) => !this.targets.has(target)); - await this.rebuildWatcher(targets, nextKey, addedTargets); + await this.applyTargetSet(targets, nextKey); } } catch (error) { if (options.rethrowErrors) { @@ -128,38 +182,57 @@ export class TeamTaskWatchRegistry { } } - private async rebuildWatcher( - targets: string[], - nextKey: string, - addedTargets: string[] - ): Promise { - const generation = this.generation + 1; - this.generation = generation; - - const previousWatcher = this.watcher; - this.watcher = null; - if (previousWatcher) { - await this.closeWatcher(previousWatcher); + private async applyTargetSet(targets: string[], nextKey: string): Promise { + if (this.closed) { + return; } - - if (this.closed || generation !== this.generation) { + // First time: create the watcher with the full target set. ignoreInitial keeps + // the app-startup baseline silent so old files are not replayed. + if (!this.watcher) { + this.createWatcher(targets, nextKey); return; } - const nextWatcher = watch(targets, { + // Incrementally update the existing watcher rather than tearing it down and + // recreating it. A full rebuild re-opens an fd for EVERY watched file (kqueue + // on macOS opens one fd per file), so during a launch that adds dirs in bursts + // it re-opened the entire (large) watched set repeatedly. add()/unwatch() touch + // only the delta. emitExistingFilesForNewTargets still backfills files that + // already exist in newly added dirs, preserving the previous event surface + // (chokidar's own add() scan only re-confirms those same files, idempotently). + const nextSet = new Set(targets); + const addedTargets = targets.filter((target) => !this.targets.has(target)); + const removedTargets = [...this.targets].filter((target) => !nextSet.has(target)); + const generation = this.generation; + + if (removedTargets.length > 0) { + this.watcher.unwatch(removedTargets); + } + if (addedTargets.length > 0) { + this.watcher.add(addedTargets); + } + this.targets = nextSet; + this.targetKey = nextKey; + + if (addedTargets.length > 0) { + await this.emitExistingFilesForNewTargets(addedTargets, generation); + } + } + + private createWatcher(targets: string[], nextKey: string): void { + const generation = this.generation + 1; + this.generation = generation; + + const watcher = watch(targets, { ignoreInitial: true, ignorePermissionErrors: true, followSymlinks: false, depth: 0, }); - this.watcher = nextWatcher; + this.watcher = watcher; this.targets = new Set(targets); this.targetKey = nextKey; - // First registry build is app startup baseline and must not emit old files. - // Later rebuilds can emit existing files only for newly added targets. - const shouldEmitExistingFiles = this.initialTargetsCaptured; - this.initialTargetsCaptured = true; const handleEvent = (eventType: TeamTaskWatchEventType, changedPath?: string): void => { if (this.closed || generation !== this.generation || !changedPath) { @@ -172,9 +245,10 @@ export class TeamTaskWatchRegistry { } // addDir/unlinkDir can make the watch target set stale immediately. - // Periodic reconciliation is the backup path if the directory event is missed. + // Debounced so a burst of dir events (e.g. a team launch) coalesces into one + // reconcile; periodic reconciliation is the backup path if an event is missed. if (this.shouldReconcile(eventType, relativePath)) { - void this.reconcileTargets(); + this.scheduleReconcile(); } if (!this.shouldEmit(eventType, relativePath)) { @@ -184,20 +258,16 @@ export class TeamTaskWatchRegistry { this.options.onChange(eventType, relativePath); }; - nextWatcher.on('add', (changedPath) => handleEvent('add', changedPath)); - nextWatcher.on('change', (changedPath) => handleEvent('change', changedPath)); - nextWatcher.on('unlink', (changedPath) => handleEvent('unlink', changedPath)); - nextWatcher.on('addDir', (changedPath) => handleEvent('addDir', changedPath)); - nextWatcher.on('unlinkDir', (changedPath) => handleEvent('unlinkDir', changedPath)); - nextWatcher.on('error', (error) => { + watcher.on('add', (changedPath) => handleEvent('add', changedPath)); + watcher.on('change', (changedPath) => handleEvent('change', changedPath)); + watcher.on('unlink', (changedPath) => handleEvent('unlink', changedPath)); + watcher.on('addDir', (changedPath) => handleEvent('addDir', changedPath)); + watcher.on('unlinkDir', (changedPath) => handleEvent('unlinkDir', changedPath)); + watcher.on('error', (error) => { if (!this.closed && generation === this.generation) { this.options.onError(error); } }); - - if (shouldEmitExistingFiles) { - await this.emitExistingFilesForNewTargets(addedTargets, generation); - } } private async emitExistingFilesForNewTargets( @@ -237,6 +307,8 @@ export class TeamTaskWatchRegistry { // emitting user-visible events for those artifacts. const targets = new Set([path.normalize(this.options.rootPath)]); const rootEntries = await this.readDirectory(this.options.rootPath); + // null => no scoping: watch every team (original behavior / safe fallback). + const scopedTeams = this.options.getScopedTeamNames?.() ?? null; for (const entry of rootEntries) { if (!entry.isDirectory()) { @@ -244,7 +316,14 @@ export class TeamTaskWatchRegistry { } const teamPath = path.join(this.options.rootPath, entry.name); - targets.add(path.normalize(teamPath)); + const inScope = scopedTeams === null || scopedTeams.has(entry.name); + + // Team-root and task artifacts only change for running/engaged teams, so + // scope those. Inboxes are always watched so cross-team delivery and + // notifications to non-visible teams stay immediate. + if (inScope) { + targets.add(path.normalize(teamPath)); + } if (this.options.kind === 'teams') { const inboxPath = path.join(teamPath, 'inboxes'); diff --git a/src/main/services/infrastructure/teamWatchScope.ts b/src/main/services/infrastructure/teamWatchScope.ts new file mode 100644 index 00000000..0d3f409f --- /dev/null +++ b/src/main/services/infrastructure/teamWatchScope.ts @@ -0,0 +1,91 @@ +/** + * Decides which teams' team-root and task artifacts should be file-watched. + * + * The scope is (teams with a live runtime run) ∪ (teams recently engaged in the + * UI). FileWatcher always watches the teams root and every team's `inboxes/` + * regardless of this scope, so cross-team message delivery, the lead inbox→stdin + * relay, and notifications are unaffected. This module only narrows the heavier + * per-team team-root (config/kanban/processes/meta) and task watching, which + * otherwise scales with the number of teams on disk and dominates startup cost. + * + * Module-level state mirrors the existing IPC/registry singletons in this layer. + */ + +const ENGAGED_TTL_MS = 5 * 60_000; + +const engagedAtByTeam = new Map(); +let aliveTeamsProvider: (() => Iterable) | null = null; +let scopeChangeListener: (() => void) | null = null; + +export function setAliveTeamsProvider(provider: (() => Iterable) | null): void { + aliveTeamsProvider = provider; +} + +export function setTeamWatchScopeChangeListener(listener: (() => void) | null): void { + scopeChangeListener = listener; +} + +export function notifyTeamWatchScopeChanged(): void { + scopeChangeListener?.(); +} + +function collectAliveTeams(scope: Set): boolean { + if (!aliveTeamsProvider) { + return true; + } + try { + for (const teamName of aliveTeamsProvider()) { + if (teamName) { + scope.add(teamName); + } + } + return true; + } catch { + // A provider failure must never narrow watching. Returning null below is the + // safe fallback: watch every team, matching the original behavior. + return false; + } +} + +/** + * Current set of teams whose team-root/task artifacts should be watched. Prunes + * engaged entries past their TTL as a side effect of being called. + */ +export function computeTeamWatchScope(nowMs: number = Date.now()): ReadonlySet | null { + const scope = new Set(); + if (!collectAliveTeams(scope)) { + return null; + } + for (const [teamName, engagedAt] of engagedAtByTeam) { + if (nowMs - engagedAt <= ENGAGED_TTL_MS) { + scope.add(teamName); + } else { + engagedAtByTeam.delete(teamName); + } + } + return scope; +} + +/** + * Mark a team as engaged in the UI (opened or refreshed). Notifies the scope + * change listener only when this newly brings the team into scope, so repeated + * calls for an already-watched team stay cheap and do not churn the watcher. + */ +export function markTeamEngaged(teamName: string, nowMs: number = Date.now()): void { + if (!teamName) { + return; + } + const currentScope = computeTeamWatchScope(nowMs); + const wasInScope = currentScope?.has(teamName) === true; + engagedAtByTeam.set(teamName, nowMs); + if (!wasInScope) { + scopeChangeListener?.(); + } +} + +/** Test helper: clear engaged state and wiring. */ +export function resetTeamWatchScopeForTests(): void { + engagedAtByTeam.clear(); + aliveTeamsProvider = null; + scopeChangeListener = null; +} diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 2e4b3cca..79c2131f 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,5 +1,6 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; +import { CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE } from '@shared/types/cliInstaller'; import { createLogger } from '@shared/utils/logger'; import { createDefaultCliExtensionCapabilities, @@ -413,7 +414,7 @@ function createRuntimeStatusErrorProviderStatus( return { ...createDefaultProviderStatus(providerId), verificationState: 'error', - statusMessage: 'Provider status unavailable', + statusMessage: CLI_PROVIDER_STATUS_UNAVAILABLE_MESSAGE, detailMessage, }; } diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 1a89b335..8cf34f46 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -41,6 +41,11 @@ interface StoredApiKeyAccessOptions { allowedStoredApiKeyEnvVarNames?: readonly string[]; } +interface CodexLaunchSnapshotRefreshOptions { + refreshRuntimeMissing?: boolean; + refreshBlockedLaunch?: boolean; +} + const PROVIDER_CAPABILITIES: Record< CliProviderId, Pick @@ -605,6 +610,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ @@ -687,6 +693,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ @@ -771,6 +778,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); const runtimeEnv = { ...env }; applyCodexRuntimeContextEnv(runtimeEnv, snapshot); @@ -882,6 +890,7 @@ export class ProviderConnectionService { const snapshot = await this.getCodexLaunchSnapshot(env, { refreshRuntimeMissing: true, + refreshBlockedLaunch: true, }); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, @@ -1267,10 +1276,21 @@ export class ProviderConnectionService { private async getCodexLaunchSnapshot( env: NodeJS.ProcessEnv, - options?: { refreshRuntimeMissing?: boolean } + options?: CodexLaunchSnapshotRefreshOptions ): Promise { let snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); - if (!options?.refreshRuntimeMissing || snapshot.appServerState !== 'runtime-missing') { + const readiness = evaluateCodexLaunchReadiness({ + preferredAuthMode: snapshot.preferredAuthMode, + managedAccount: snapshot.managedAccount, + apiKey: snapshot.apiKey, + appServerState: snapshot.appServerState, + appServerStatusMessage: snapshot.appServerStatusMessage, + localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent, + }); + const shouldRefresh = + (options?.refreshRuntimeMissing === true && snapshot.appServerState === 'runtime-missing') || + (options?.refreshBlockedLaunch === true && !readiness.launchAllowed); + if (!shouldRefresh) { return snapshot; } @@ -1280,7 +1300,7 @@ export class ProviderConnectionService { env ); } catch { - // Keep the original runtime-missing snapshot so callers still report the concrete issue. + // Keep the original blocked snapshot so callers still report the concrete issue. } return snapshot; diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 9b3e651d..7c7b97aa 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -1,12 +1,11 @@ +import { readJsonlLines } from '@main/utils/jsonlLineReader'; import { getHomeDir } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { normalizePathForComparison } from '@shared/utils/platformPath'; import { createHash } from 'crypto'; import { diffLines } from 'diff'; -import { createReadStream } from 'fs'; import { access, readFile } from 'fs/promises'; import * as path from 'path'; -import * as readline from 'readline'; import type { GitDiffFallback } from './GitDiffFallback'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; @@ -407,10 +406,7 @@ export class FileContentResolver { targetFilePath: string ): Promise { try { - const stream = createReadStream(logPath, { encoding: 'utf8' }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - - for await (const line of rl) { + for await (const line of readJsonlLines(logPath)) { const trimmed = line.trim(); if (!trimmed) continue; @@ -431,17 +427,12 @@ export class FileContentResolver { const backupFileName = trackedFileBackups[targetFilePath]; if (backupFileName) { - rl.close(); - stream.destroy(); return backupFileName; } } catch { // Skip malformed JSON } } - - rl.close(); - stream.destroy(); } catch { logger.debug(`Не удалось прочитать JSONL для file-history: ${logPath}`); } diff --git a/src/main/services/team/LaunchIoGovernor.ts b/src/main/services/team/LaunchIoGovernor.ts index c7fafa1e..76b4edb9 100644 --- a/src/main/services/team/LaunchIoGovernor.ts +++ b/src/main/services/team/LaunchIoGovernor.ts @@ -49,7 +49,7 @@ interface OperationState { } export const DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS = 3_000; -export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 15_000; +export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 120_000; export const DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS = 10 * 60_000; const DEFAULT_WARNING_COOLDOWN_MS = 10_000; diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts index 5a868e3a..0d6cf532 100644 --- a/src/main/services/team/MemberStatsComputer.ts +++ b/src/main/services/team/MemberStatsComputer.ts @@ -1,6 +1,5 @@ +import { readJsonlLines } from '@main/utils/jsonlLineReader'; import { createLogger } from '@shared/utils/logger'; -import { createReadStream } from 'fs'; -import * as readline from 'readline'; import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { countLineChanges } from './UnifiedLineCounter'; @@ -179,10 +178,7 @@ export class MemberStatsComputer { }; try { - const stream = createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - - for await (const line of rl) { + for await (const line of readJsonlLines(filePath)) { const trimmed = line.trim(); if (!trimmed) continue; @@ -332,9 +328,6 @@ export class MemberStatsComputer { // Skip malformed lines } } - - rl.close(); - stream.destroy(); } catch (err) { logger.debug(`Failed to parse file ${filePath}: ${String(err)}`); } diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts index dd3a1bd5..a75db4f1 100644 --- a/src/main/services/team/TaskBoundaryParser.ts +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -1,7 +1,6 @@ +import { readJsonlLines } from '@main/utils/jsonlLineReader'; import { createLogger } from '@shared/utils/logger'; -import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; -import * as readline from 'readline'; import { canonicalizeAgentTeamsToolName, @@ -102,10 +101,7 @@ export class TaskBoundaryParser { let detectedMechanism: DetectedMechanism = 'none'; try { - const stream = createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - - for await (const line of rl) { + for await (const line of readJsonlLines(filePath)) { lineNumber++; const trimmed = line.trim(); if (!trimmed) continue; @@ -149,9 +145,6 @@ export class TaskBoundaryParser { // Пропускаем невалидные строки } } - - rl.close(); - stream.destroy(); } catch (err) { logger.debug(`Error reading file ${filePath}: ${String(err)}`); } diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 158224cf..4cd33205 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -109,7 +109,7 @@ function normalizeProjectPathCandidate(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function resolveProjectPathFromConfig( +export function resolveProjectPathFromConfig( config: Pick ): string | undefined { const direct = normalizeProjectPathCandidate(config.projectPath); @@ -222,6 +222,26 @@ function cloneTeamSummaries(teams: readonly TeamSummary[]): TeamSummary[] { return structuredClone([...teams]); } +// Deep-freeze a team-summary snapshot so it can be shared by every listTeams() reader +// (and concurrent in-flight awaiters) instead of deep-cloning all summaries on every +// call -- that per-read structuredClone was the single largest memory allocator during +// launch. Consumers treat the result as read-only (audited: all iterate / map / filter +// / serialize, none mutate), and freezing turns any stray future mutation into a loud +// error instead of silent cross-caller corruption. +function freezeTeamSummariesDeep(teams: TeamSummary[]): TeamSummary[] { + const freeze = (value: unknown): void => { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return; + } + Object.freeze(value); + for (const nested of Object.values(value as Record)) { + freeze(nested); + } + }; + freeze(teams); + return teams; +} + function classifyConfigReadTiming(timing: { statMs: number | null; readMs: number | null; @@ -353,15 +373,23 @@ export class TeamConfigReader { const teamsBasePath = getTeamsBasePath(); const cached = TeamConfigReader.listTeamsCacheByBasePath.get(teamsBasePath); if (cached && cached.expiresAt > Date.now()) { - return cloneTeamSummaries(cached.value); + // Frozen, independent snapshot -> safe to hand out directly. The per-read + // structuredClone that used to be here was the top memory allocator on launch. + return cached.value; } const existingRequest = TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath); if (existingRequest?.generationAtStart === TeamConfigReader.listTeamsGeneration) { - return cloneTeamSummaries(await existingRequest.promise); + return existingRequest.promise; } - const request = this.listTeamsUncached(teamsBasePath); + // Build ONE frozen, independent snapshot shared by this load's cache entry, its + // in-flight awaiters, and every later reader. cloneTeamSummaries() makes the copy + // independent of any cached config the (worker or fallback) loader may return; + // freezing then lets all readers share it without per-call deep clones. + const request = this.listTeamsUncached(teamsBasePath).then((teams) => + freezeTeamSummariesDeep(cloneTeamSummaries(teams)) + ); const generationAtStart = TeamConfigReader.listTeamsGeneration; TeamConfigReader.listTeamsInFlightByBasePath.set(teamsBasePath, { promise: request, @@ -369,14 +397,14 @@ export class TeamConfigReader { }); try { - const teams = await request; + const frozenTeams = await request; if (TeamConfigReader.listTeamsGeneration === generationAtStart) { TeamConfigReader.listTeamsCacheByBasePath.set(teamsBasePath, { - value: cloneTeamSummaries(teams), + value: frozenTeams, expiresAt: Date.now() + LIST_TEAMS_CACHE_TTL_MS, }); } - return cloneTeamSummaries(teams); + return frozenTeams; } finally { if (TeamConfigReader.listTeamsInFlightByBasePath.get(teamsBasePath)?.promise === request) { TeamConfigReader.listTeamsInFlightByBasePath.delete(teamsBasePath); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index fbac3aee..bf0a36be 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -33,16 +33,13 @@ import { import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { MemberActivityMetaService } from './MemberActivityMetaService'; -import { - getLiveLeadProcessMessageKey, - mergeLiveLeadProcessMessages, -} from './mergeLiveLeadProcessMessages'; +import { mergeLiveLeadProcessMessagesPage } from './mergeLiveLeadProcessMessages'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { choosePreferredLaunchSnapshot, readBootstrapLaunchSnapshot, } from './TeamBootstrapStateReader'; -import { TeamConfigReader } from './TeamConfigReader'; +import { resolveProjectPathFromConfig, TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamKanbanManager } from './TeamKanbanManager'; @@ -111,6 +108,7 @@ const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; const MEMBER_RUNTIME_ADVISORY_SNAPSHOT_BUDGET_MS = 250; +const GLOBAL_TASK_TEAM_CONFIG_CONCURRENCY = 12; const MIXED_TEAM_LIVE_MUTATION_BLOCK_MESSAGE = 'Live roster mutation on a running mixed team is not supported in V1. Stop the team, edit the roster, then relaunch.'; @@ -218,6 +216,12 @@ interface EligibleTaskCommentNotification { summary: string; } +interface TaskCommentNotificationTeamContext { + deletedAt?: string; + leadName?: string; + leadSessionId?: string; +} + interface TaskChangeLogSourceSnapshot { projectFingerprint: string | null; logSourceGeneration: string | null; @@ -230,6 +234,36 @@ interface FileWatchReconcileDiagnostics { lastPressureLogAt: number; } +interface GlobalTaskTeamInfo { + displayName: string; + projectPath?: string; + deletedAt?: string; +} + +async function mapLimitLocal( + items: readonly T[], + limit: number, + mapper: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(Math.max(1, limit), items.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + results[index] = await mapper(items[index]); + } + }) + ); + + return results; +} + function applyDistinctRosterColors( members: readonly T[] ): T[] { @@ -420,10 +454,71 @@ export class TeamDataService { return readConfigForUiSnapshot(this.configReader, teamName); } + private async readGlobalTaskTeamInfoFromListTeams(): Promise> { + const teams = await this.configReader.listTeams(); + const teamInfoMap = new Map(); + for (const team of teams) { + teamInfoMap.set(team.teamName, { + displayName: team.displayName, + projectPath: team.projectPath, + deletedAt: team.deletedAt, + }); + } + return teamInfoMap; + } + + private async readGlobalTaskTeamInfo( + rawTasks: readonly (TeamTask & { teamName: string })[] + ): Promise> { + const canReadConfigDirectly = + typeof (this.configReader as { getConfigSnapshot?: unknown }).getConfigSnapshot === + 'function' || + typeof (this.configReader as { getConfig?: unknown }).getConfig === 'function'; + if (!canReadConfigDirectly) { + return this.readGlobalTaskTeamInfoFromListTeams(); + } + + const teamNames = [...new Set(rawTasks.map((task) => task.teamName))]; + const entries = await mapLimitLocal( + teamNames, + GLOBAL_TASK_TEAM_CONFIG_CONCURRENCY, + async (teamName) => { + const config = await readConfigForUiSnapshot(this.configReader, teamName).catch(() => null); + const displayName = config?.name?.trim(); + if (!config || !displayName) { + return null; + } + return [ + teamName, + { + displayName, + projectPath: resolveProjectPathFromConfig(config), + deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined, + }, + ] as const; + } + ); + + if (entries.some((entry) => entry === null)) { + return this.readGlobalTaskTeamInfoFromListTeams(); + } + + return new Map(entries.filter((entry): entry is NonNullable => entry !== null)); + } + private invalidateGlobalTaskProjectionCache(): void { TeamTaskReader.invalidateAllTasksCache(); } + private async readTasksForUiSnapshot(teamName: string): Promise { + const snapshotReader = this.taskReader as TeamTaskReader & { + getTasksProjectionSnapshot?: (teamName: string) => Promise; + }; + return typeof snapshotReader.getTasksProjectionSnapshot === 'function' + ? snapshotReader.getTasksProjectionSnapshot(teamName) + : this.taskReader.getTasks(teamName); + } + private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); } @@ -927,7 +1022,7 @@ export class TeamDataService { : null; const [tasks, kanbanState, presenceIndex] = await Promise.all([ - this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]), + this.readTasksForUiSnapshot(teamName).catch(() => [] as readonly TeamTask[]), this.kanbanManager .getState(teamName) .catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState), @@ -1021,26 +1116,30 @@ export class TeamDataService { } async getAllTasks(): Promise { - const rawTasks = await this.taskReader.getAllTasks(); - const teams = await this.configReader.listTeams(); + const taskReader = this.taskReader as TeamTaskReader & { + getAllTasksProjectionSnapshot?: () => Promise; + }; + const rawTasks = + typeof taskReader.getAllTasksProjectionSnapshot === 'function' + ? await taskReader.getAllTasksProjectionSnapshot() + : await taskReader.getAllTasks(); + const teamInfoMap = await this.readGlobalTaskTeamInfo(rawTasks); - const teamInfoMap = new Map< - string, - { displayName: string; projectPath?: string; deletedAt?: string } - >(); - for (const team of teams) { - teamInfoMap.set(team.teamName, { - displayName: team.displayName, - projectPath: team.projectPath, - deletedAt: team.deletedAt, - }); + const MAX_GLOBAL_TASKS_EXPORTED = 500; + let tasksToExport = rawTasks.filter((task) => teamInfoMap.has(task.teamName)); + if (tasksToExport.length > MAX_GLOBAL_TASKS_EXPORTED) { + // Prefer newest first before reading kanban and building the lightweight IPC projection. + tasksToExport = tasksToExport + .slice() + .sort((a, b) => { + const at = Date.parse(a.updatedAt ?? a.createdAt ?? '') || 0; + const bt = Date.parse(b.updatedAt ?? b.createdAt ?? '') || 0; + return bt - at; + }) + .slice(0, MAX_GLOBAL_TASKS_EXPORTED); } - const deletedTeams = new Set(teams.filter((t) => t.deletedAt).map((t) => t.teamName)); - - const teamNames = [ - ...new Set(rawTasks.map((t) => t.teamName).filter((n) => teamInfoMap.has(n))), - ]; + const teamNames = [...new Set(tasksToExport.map((task) => task.teamName))]; const kanbanByTeam = new Map(); await Promise.all( teamNames.map(async (teamName) => { @@ -1055,10 +1154,7 @@ export class TeamDataService { const out: GlobalTask[] = []; let processed = 0; - for (const task of rawTasks) { - if (!teamInfoMap.has(task.teamName)) { - continue; - } + for (const task of tasksToExport) { const info = teamInfoMap.get(task.teamName)!; const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id]; const reviewState = this.resolveTaskReviewState(task, kanbanTaskState); @@ -1106,7 +1202,7 @@ export class TeamDataService { kanbanColumn, teamName: task.teamName, teamDisplayName: info.displayName, - teamDeleted: deletedTeams.has(task.teamName) || undefined, + teamDeleted: Boolean(info.deletedAt) || undefined, }); processed++; if (processed % TASK_MAP_YIELD_EVERY === 0) { @@ -1114,18 +1210,6 @@ export class TeamDataService { } } - // Hard cap: keep renderer responsive even with huge task sets. - const MAX_GLOBAL_TASKS_EXPORTED = 500; - if (out.length > MAX_GLOBAL_TASKS_EXPORTED) { - // Prefer newest first if timestamps exist. - out.sort((a, b) => { - const at = Date.parse(a.updatedAt ?? a.createdAt ?? '') || 0; - const bt = Date.parse(b.updatedAt ?? b.createdAt ?? '') || 0; - return bt - at; - }); - return out.slice(0, MAX_GLOBAL_TASKS_EXPORTED); - } - return out; } @@ -1312,7 +1396,7 @@ export class TeamDataService { label: 'tasks', createFallback: () => [], warningText: 'Tasks failed to load', - load: () => this.taskReader.getTasks(teamName), + load: () => this.readTasksForUiSnapshot(teamName), }) ); const [ @@ -1349,7 +1433,7 @@ export class TeamDataService { if (launchStateStepResult.warning) warnings.push(launchStateStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); - const tasks: TeamTask[] = tasksStepResult.value; + const tasks: readonly TeamTask[] = tasksStepResult.value; const inboxNames: string[] = inboxNamesStepResult.value; mark('postStart'); @@ -1498,9 +1582,6 @@ export class TeamDataService { ): Promise { const feed = await this.messageFeedService.getFeed(teamName); const newestDurableMessages = feed.messages; - const durableMessageIndexByKey = new Map( - newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index]) - ); let messages = newestDurableMessages; if (options.cursor) { @@ -1527,55 +1608,12 @@ export class TeamDataService { // Merge live lead thoughts against the full durable newest-page history so we do not // re-introduce persisted thoughts that have simply paged off the first durable page. - const displayMessages = mergeLiveLeadProcessMessages( - newestDurableMessages, - options.liveMessages - ).slice(0, options.limit); - - if (displayMessages.length === 0) { - return { - messages: displayMessages, - nextCursor: null, - hasMore: false, - feedRevision: feed.feedRevision, - }; - } - - let lastDurableDisplayed: InboxMessage | null = null; - for (let index = displayMessages.length - 1; index >= 0; index -= 1) { - const candidate = displayMessages[index]; - if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) { - lastDurableDisplayed = candidate; - break; - } - } - - if (!lastDurableDisplayed) { - const boundary = displayMessages[displayMessages.length - 1]; - return { - messages: displayMessages, - nextCursor: - newestDurableMessages.length > 0 - ? `${boundary.timestamp}|${boundary.messageId ?? ''}` - : null, - hasMore: newestDurableMessages.length > 0, - feedRevision: feed.feedRevision, - }; - } - - const durableIndex = - durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ?? - Number.POSITIVE_INFINITY; - const durableHasMore = durableIndex < newestDurableMessages.length - 1; - - return { - messages: displayMessages, - nextCursor: durableHasMore - ? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}` - : null, - hasMore: durableHasMore, + return mergeLiveLeadProcessMessagesPage({ + durableMessages: newestDurableMessages, + liveMessages: options.liveMessages, + limit: options.limit, feedRevision: feed.feedRevision, - }; + }); } async getMessageFeed( @@ -2441,6 +2479,11 @@ export class TeamDataService { await this.processTaskCommentNotifications(team.teamName, undefined, { seedHistoricalIfJournalMissing: true, recoverPending: true, + teamContext: { + deletedAt: team.deletedAt, + leadName: team.leadName, + leadSessionId: team.leadSessionId, + }, }); } catch (error) { logger.warn( @@ -2722,20 +2765,28 @@ export class TeamDataService { options?: { seedHistoricalIfJournalMissing?: boolean; recoverPending?: boolean; + teamContext?: TaskCommentNotificationTeamContext; } ): Promise { const seedHistoricalIfJournalMissing = options?.seedHistoricalIfJournalMissing === true; const recoverPending = options?.recoverPending === true; - let config: TeamConfig | null = null; - try { - config = await readConfigForUiSnapshot(this.configReader, teamName); - } catch { - return; - } - if (!config || config.deletedAt) return; + const teamContext = options?.teamContext; + if (teamContext?.deletedAt) return; - const leadName = this.resolveLeadNameFromConfig(config); - const leadSessionId = config.leadSessionId; + let leadName = teamContext?.leadName?.trim() ?? ''; + let leadSessionId = teamContext?.leadSessionId; + if (!leadName) { + let config: TeamConfig | null = null; + try { + config = await readConfigForUiSnapshot(this.configReader, teamName); + } catch { + return; + } + if (!config || config.deletedAt) return; + + leadName = this.resolveLeadNameFromConfig(config); + leadSessionId = config.leadSessionId; + } if (!leadName.trim()) return; const journalExists = await this.taskCommentNotificationJournal.exists(teamName); @@ -3160,12 +3211,11 @@ export class TeamDataService { } } finally { const current = this.fileWatchReconcileDiagnostics.get(teamName); - if (!current) { - return; - } - current.inFlight = Math.max(0, current.inFlight - 1); - if (current.inFlight === 0 && Date.now() - current.windowStartedAt > 30_000) { - this.fileWatchReconcileDiagnostics.delete(teamName); + if (current) { + current.inFlight = Math.max(0, current.inFlight - 1); + if (current.inFlight === 0 && Date.now() - current.windowStartedAt > 30_000) { + this.fileWatchReconcileDiagnostics.delete(teamName); + } } } } @@ -3210,51 +3260,37 @@ export class TeamDataService { return sessionIds; } - private async extractLeadAssistantTextsFromJsonl( - jsonlPath: string, + private async readLeadSessionJsonlTailLines(jsonlPath: string): Promise { + const MAX_SCAN_BYTES = 8 * 1024 * 1024; + const handle = await fs.promises.open(jsonlPath, 'r'); + try { + const stat = await handle.stat(); + const fileSize = stat.size; + const scanBytes = Math.min(MAX_SCAN_BYTES, fileSize); + 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/); + const fromIndex = start > 0 ? 1 : 0; + return lines + .slice(fromIndex) + .map((line) => line.trim()) + .filter(Boolean); + } finally { + await handle.close(); + } + } + + private async extractLeadAssistantTextsFromJsonlLines( + rawLines: readonly string[], leadName: string, leadSessionId: string, maxTexts: number ): Promise { if (maxTexts <= 0) return []; - - const MAX_SCAN_BYTES = 8 * 1024 * 1024; - const INITIAL_SCAN_BYTES = 256 * 1024; - - const rawLinesReversed: string[] = []; - const seenRawLines = new Set(); const seenMessageIds = new Set(); - const handle = await fs.promises.open(jsonlPath, 'r'); - try { - const stat = await handle.stat(); - const fileSize = stat.size; - - let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); - while (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/); - const fromIndex = start > 0 ? 1 : 0; - - for (let i = lines.length - 1; i >= fromIndex; i--) { - const trimmed = lines[i]?.trim(); - if (!trimmed) continue; - if (seenRawLines.has(trimmed)) continue; - seenRawLines.add(trimmed); - rawLinesReversed.push(trimmed); - } - - if (scanBytes === fileSize) break; - scanBytes = Math.min(fileSize, scanBytes * 2); - } - } finally { - await handle.close(); - } - - const rawLines = rawLinesReversed.reverse(); const texts: InboxMessage[] = []; let syntheticBuffer: { firstMsg: Record; @@ -3440,13 +3476,15 @@ export class TeamDataService { } const parse = async (): Promise => { + const rawLines = await this.readLeadSessionJsonlTailLines(jsonlPath); const [assistantTexts, commandResults] = await Promise.all([ - this.extractLeadAssistantTextsFromJsonl(jsonlPath, leadName, leadSessionId, maxTexts), + this.extractLeadAssistantTextsFromJsonlLines(rawLines, leadName, leadSessionId, maxTexts), extractLeadSessionMessagesFromJsonl({ jsonlPath, leadName, leadSessionId, maxMessages: maxTexts, + rawLines, }), ]); const combined = [...assistantTexts, ...commandResults]; diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 582c783d..276bdf9c 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -15,6 +15,7 @@ import { createLogger } from '@shared/utils/logger'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; import type { + InboxMessage, MemberLogSummary, MessagesPage, TeamGetDataOptions, @@ -81,6 +82,17 @@ function getTeamDataRequestPayload( return normalizedOptions ? { teamName, options: normalizedOptions } : { teamName }; } +function getLiveMessagesRequestKey(liveMessages?: InboxMessage[]): unknown { + if (!liveMessages?.length) return undefined; + return liveMessages.map((message) => ({ + messageId: message.messageId, + timestamp: message.timestamp, + source: message.source, + from: message.from, + text: message.text, + })); +} + function summarizeWorkerRequest(request: TeamDataWorkerRequest): Record { switch (request.op) { case 'warmup': @@ -98,6 +110,7 @@ function summarizeWorkerRequest(request: TeamDataWorkerRequest): Record { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); const key = JSON.stringify({ teamName, cursor: options.cursor ?? null, limit: options.limit, + liveMessages: getLiveMessagesRequestKey(options.liveMessages), }); const existing = this.getMessagesPageInFlight.get(key); if (existing) return existing; diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 032b6d47..3326e318 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -9,6 +9,44 @@ import type { InboxMessage } from '@shared/types'; const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files const INBOX_READ_CONCURRENCY = process.platform === 'win32' ? 4 : 12; +const INBOX_FILE_CACHE_MAX_ENTRIES = 1_024; + +interface InboxFileSignature { + size: number; + mtimeMs: number; + ctimeMs: number; + dev: number; + ino: number; +} + +interface CachedInboxFile { + signature: InboxFileSignature; + messages: InboxMessage[]; +} + +function buildInboxFileSignature(stat: fs.Stats): InboxFileSignature { + return { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + dev: stat.dev, + ino: stat.ino, + }; +} + +function inboxFileSignaturesEqual(a: InboxFileSignature, b: InboxFileSignature): boolean { + return ( + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.dev === b.dev && + a.ino === b.ino + ); +} + +function cloneInboxMessages(messages: readonly InboxMessage[]): InboxMessage[] { + return structuredClone([...messages]); +} async function mapLimit( items: readonly T[], @@ -30,6 +68,43 @@ async function mapLimit( } export class TeamInboxReader { + private readonly inboxFileCache = new Map(); + + private getCachedMessages( + inboxPath: string, + signature: InboxFileSignature + ): InboxMessage[] | undefined { + const cached = this.inboxFileCache.get(inboxPath); + if (!cached) { + return undefined; + } + if (!inboxFileSignaturesEqual(cached.signature, signature)) { + this.inboxFileCache.delete(inboxPath); + return undefined; + } + return cloneInboxMessages(cached.messages); + } + + private setCachedMessages( + inboxPath: string, + signature: InboxFileSignature, + messages: readonly InboxMessage[] + ): void { + if ( + !this.inboxFileCache.has(inboxPath) && + this.inboxFileCache.size >= INBOX_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.inboxFileCache.keys().next().value; + if (oldestKey) { + this.inboxFileCache.delete(oldestKey); + } + } + this.inboxFileCache.set(inboxPath, { + signature, + messages: cloneInboxMessages(messages), + }); + } + async listInboxNames(teamName: string): Promise { const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); @@ -53,15 +128,23 @@ export class TeamInboxReader { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`); let raw: string; + let signature: InboxFileSignature; try { const stat = await fs.promises.stat(inboxPath); // Avoid hangs on non-regular files (FIFO, sockets) and unbounded memory usage on huge files. if (!stat.isFile() || stat.size > MAX_INBOX_FILE_BYTES) { + this.inboxFileCache.delete(inboxPath); return []; } + signature = buildInboxFileSignature(stat); + const cached = this.getCachedMessages(inboxPath, signature); + if (cached) { + return cached; + } raw = await readFileUtf8WithTimeout(inboxPath, 5_000); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.inboxFileCache.delete(inboxPath); return []; } if (error instanceof FileReadTimeoutError) { @@ -74,9 +157,11 @@ export class TeamInboxReader { try { parsed = JSON.parse(raw) as unknown; } catch { + this.setCachedMessages(inboxPath, signature, []); return []; } if (!Array.isArray(parsed)) { + this.setCachedMessages(inboxPath, signature, []); return []; } @@ -199,7 +284,8 @@ export class TeamInboxReader { return bt - at; }); - return messages; + this.setCachedMessages(inboxPath, signature, messages); + return cloneInboxMessages(messages); } async getMessages(teamName: string): Promise { diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index d25cb780..fd78b910 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -449,6 +449,14 @@ export class TeamMessageFeedService { messages: cached.messages, }; } + if (cached && !cacheDirty && cacheExpired) { + this.refreshCleanExpiredCacheInBackground(teamName, cached, now); + return { + teamName, + feedRevision: cached.feedRevision, + messages: cached.messages, + }; + } const existingRequest = this.inFlightByTeam.get(teamName); const generationAtStart = this.getGeneration(teamName); @@ -479,6 +487,43 @@ export class TeamMessageFeedService { return this.generationByTeam.get(teamName) ?? 0; } + private refreshCleanExpiredCacheInBackground( + teamName: string, + cached: TeamMessageFeedCacheEntry, + now: number + ): void { + const generationAtStart = this.getGeneration(teamName); + const existingRequest = this.inFlightByTeam.get(teamName); + if (existingRequest?.generationAtStart === generationAtStart) { + return; + } + + const request = this.buildFeed(teamName, cached, now, false, true, generationAtStart).catch( + (error) => { + logger.debug( + `[${teamName}] background message feed refresh failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return { + teamName, + feedRevision: cached.feedRevision, + messages: cached.messages, + }; + } + ); + + const trackedRequest = request.finally(() => { + if (this.inFlightByTeam.get(teamName)?.promise === trackedRequest) { + this.inFlightByTeam.delete(teamName); + } + }); + this.inFlightByTeam.set(teamName, { + promise: trackedRequest, + generationAtStart, + }); + } + private async buildFeed( teamName: string, cached: TeamMessageFeedCacheEntry | undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 045d3392..dc07d9e6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -54,6 +54,7 @@ import { } from '@features/workspace-trust/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; +import { notifyTeamWatchScopeChanged } from '@main/services/infrastructure/teamWatchScope'; import { prepareAgentChildProcessWritableEnv } from '@main/services/runtime/agentChildProcessPreflight'; import { getAppIconPath } from '@main/utils/appIcon'; import { @@ -314,10 +315,9 @@ import { extractBootstrapFailureReason, extractHeartbeatTimestamp, extractTranscriptMessageText, - getBootstrapTranscriptSuccessSource, + getBootstrapTranscriptSuccessSourceFromNormalized, getCanonicalSendMessageFieldRule, getCanonicalSendMessageToolRule, - isBootstrapTranscriptContextText, isTaskBoardSnapshotWorkCandidate, normalizeMemberDiagnosticText, shouldUseGeminiStagedLaunch, @@ -598,6 +598,15 @@ interface PersistedRuntimeMemberLike { runtimeSessionId?: string; } +interface PersistedTeamConfigCacheEntry { + path: string; + size: number; + mtimeMs: number; + ctimeMs: number; + projectPath: string | null; + members: PersistedRuntimeMemberLike[]; +} + type RelayInboxMessage = InboxMessage & { messageId: string }; interface RelayInboxMessageView { @@ -687,6 +696,145 @@ type BootstrapTranscriptOutcome = reason: string; }; +interface BootstrapTranscriptOutcomeCacheEntry { + mtimeMs: number; + size: number; + outcome: BootstrapTranscriptOutcome | null; +} + +interface BootstrapTranscriptOutcomeLookupCacheEntry { + expiresAtMs: number; + outcome: BootstrapTranscriptOutcome | null; +} + +interface BootstrapTranscriptOutcomeCandidate { + text: string; + // text.replace(/\s+/g,' ').trim().toLowerCase(), computed once and reused across + // members so success/context detection does not re-normalize the same line. + normalizedText: string; + observedAt: string; + parsedAgentName: string | null; + // The shared parsed-tail line this candidate was built from. Used to memoize the + // pure extractBootstrapFailureReason() result on the line itself so an N-member + // team extracts each line's failure reason at most once instead of once per member. + parsedLine: ParsedBootstrapTranscriptTailLine; +} + +interface ParsedBootstrapTranscriptTailLine { + rawTimestamp: string | null; + timestampMs: number; + text: string | null; + normalizedText: string | null; + parsedAgentName: string | null; + // Memoized extractBootstrapFailureReason(text): undefined = not computed yet, + // null = computed/no failure reason, string = the failure reason. Lives as long as + // this line's parse-cache entry (filePath + mtime + size); a file change re-parses + // into fresh line objects, so the memo cannot drift from the line's text. + bootstrapFailureReason?: string | null; + bootstrapContextCandidateByTeam?: Map; + bootstrapContextMemberMatchByName?: Map; + bootstrapSuccessSourceByTeamMember?: Map; +} + +interface ParsedBootstrapTranscriptTailCacheEntry { + mtimeMs: number; + size: number; + lines: ParsedBootstrapTranscriptTailLine[]; +} + +function isNormalizedBootstrapTranscriptContextCandidateText( + normalizedText: string, + normalizedTeamName: string +): boolean { + if (!normalizedText || !normalizedTeamName) { + return false; + } + if (!normalizedText.includes(normalizedTeamName)) { + return false; + } + return ( + normalizedText.includes('bootstrap') || + normalizedText.includes('bootstrapping') || + normalizedText.includes('member briefing') || + normalizedText.includes('task briefing') + ); +} + +function isNormalizedBootstrapTranscriptContextMemberText( + normalizedText: string, + normalizedMemberName: string +): boolean { + return !!normalizedMemberName && normalizedText.includes(normalizedMemberName); +} + +function getCachedBootstrapContextCandidateForLine( + line: ParsedBootstrapTranscriptTailLine, + normalizedText: string, + normalizedTeamName: string +): boolean { + let candidateByTeam = line.bootstrapContextCandidateByTeam; + if (!candidateByTeam) { + candidateByTeam = new Map(); + line.bootstrapContextCandidateByTeam = candidateByTeam; + } + const cached = candidateByTeam.get(normalizedTeamName); + if (cached !== undefined) { + return cached; + } + const value = isNormalizedBootstrapTranscriptContextCandidateText( + normalizedText, + normalizedTeamName + ); + candidateByTeam.set(normalizedTeamName, value); + return value; +} + +function getCachedBootstrapContextMemberMatchForLine( + line: ParsedBootstrapTranscriptTailLine, + normalizedText: string, + normalizedMemberName: string +): boolean { + let matchByName = line.bootstrapContextMemberMatchByName; + if (!matchByName) { + matchByName = new Map(); + line.bootstrapContextMemberMatchByName = matchByName; + } + const cached = matchByName.get(normalizedMemberName); + if (cached !== undefined) { + return cached; + } + const value = isNormalizedBootstrapTranscriptContextMemberText( + normalizedText, + normalizedMemberName + ); + matchByName.set(normalizedMemberName, value); + return value; +} + +function getCachedBootstrapSuccessSourceForLine( + line: ParsedBootstrapTranscriptTailLine, + normalizedText: string, + normalizedTeamName: string, + normalizedMemberName: string +): BootstrapTranscriptSuccessSource | null { + let sourceByTeamMember = line.bootstrapSuccessSourceByTeamMember; + if (!sourceByTeamMember) { + sourceByTeamMember = new Map(); + line.bootstrapSuccessSourceByTeamMember = sourceByTeamMember; + } + const cacheKey = `${normalizedTeamName}\0${normalizedMemberName}`; + if (sourceByTeamMember.has(cacheKey)) { + return sourceByTeamMember.get(cacheKey) ?? null; + } + const source = getBootstrapTranscriptSuccessSourceFromNormalized( + normalizedText, + normalizedTeamName, + normalizedMemberName + ); + sourceByTeamMember.set(cacheKey, source); + return source; +} + import type { ActiveToolCall, AgentActionMode, @@ -772,10 +920,19 @@ interface RuntimeProcessLoadStats extends RuntimeProcessUsageStats { type RuntimeTelemetryProcessSource = 'native' | 'wsl' | 'windows-host'; -interface RuntimeTelemetryProcessTableRow extends RuntimeProcessTableRow { +interface RuntimeTelemetryProcessTableRow extends RuntimeProcessTableRow, RuntimeProcessUsageStats { runtimeTelemetrySource?: RuntimeTelemetryProcessSource; } +interface RuntimeProcessRowsCacheEntry { + expiresAtMs: number; + generation: number; + runId: string | null; + sampledAtMs: number; + rows: RuntimeTelemetryProcessTableRow[] | null; + includesWindowsHostRows: boolean; +} + class RuntimeTelemetryTimeoutError extends Error { constructor(message: string) { super(message); @@ -3281,6 +3438,10 @@ export class TeamProvisioningService { private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; private static readonly BOOTSTRAP_FAILURE_TAIL_BYTES = 128 * 1024; + // A transcript whose mtime predates the lookup window (minus slack for clock skew + // between the line timestamp source and the filesystem) cannot hold a line at/after + // sinceMs, so it is skipped without opening it. The slack keeps detection safe. + private static readonly BOOTSTRAP_TRANSCRIPT_MTIME_SLACK_MS = 5_000; private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000; private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000; private static readonly SAME_TEAM_NATIVE_DELIVERY_GRACE_MS = 15_000; @@ -3289,15 +3450,26 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; + private static readonly PERSISTED_AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 10_000; + private static readonly RUNTIME_RESOURCE_TELEMETRY_CACHE_TTL_MS = 60_000; + private static readonly RUNTIME_RESOURCE_TELEMETRY_FAILURE_CACHE_TTL_MS = 10_000; + private static readonly RUNTIME_RESOURCE_SAMPLE_MIN_INTERVAL_MS = 30_000; private static readonly AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT = 60; + private static readonly BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES = 2_048; + private static readonly PERSISTED_BOOTSTRAP_TRANSCRIPT_OUTCOME_LOOKUP_CACHE_TTL_MS = 10_000; private static readonly MAX_RUNTIME_TREE_PIDS_PER_ROOT = 64; private static readonly MAX_RUNTIME_USAGE_PIDS_PER_SNAPSHOT = 512; private static readonly RUNTIME_PROCESS_TABLE_TIMEOUT_MS = 1_500; private static readonly RUNTIME_WINDOWS_PROCESS_TABLE_TIMEOUT_MS = 1_500; + private static readonly RUNTIME_LIVENESS_PROCESS_TABLE_CACHE_TTL_MS = 5_000; + private static readonly RUNTIME_LIVENESS_PROCESS_TABLE_FAILURE_CACHE_TTL_MS = 2_000; + private static readonly RUNTIME_PROCESS_USAGE_CACHE_TTL_MS = 30_000; + private static readonly RUNTIME_PROCESS_USAGE_CACHE_MAX_ENTRIES = 4_096; private static readonly RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS = 2_000; private static readonly RUNTIME_PIDUSAGE_SINGLE_TIMEOUT_MS = 750; private static readonly RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY = 16; private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500; + private static readonly PERSISTED_MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 5_000; private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000; private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000; private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000; @@ -3349,6 +3521,21 @@ export class TeamProvisioningService { string, PersistedTranscriptClaudeLogsCacheEntry >(); + private readonly bootstrapTranscriptOutcomeCache = new Map< + string, + BootstrapTranscriptOutcomeCacheEntry + >(); + // Shared parsed-tail cache keyed by filePath (validated by mtime+size) so the + // same growing transcript is read + JSON.parsed ONCE per change instead of once + // per member per poll. The per-member outcome scan below is unchanged. + private readonly parsedBootstrapTranscriptTailCache = new Map< + string, + ParsedBootstrapTranscriptTailCacheEntry + >(); + private readonly bootstrapTranscriptOutcomeLookupCache = new Map< + string, + BootstrapTranscriptOutcomeLookupCacheEntry + >(); private readonly teamOpLocks = new Map>(); private readonly leadInboxRelayInFlight = new Map>(); private readonly relayedLeadInboxMessageIds = new Map>(); @@ -3388,14 +3575,16 @@ export class TeamProvisioningService { >(); private readonly runtimeProcessRowsForUsageSnapshotByTeam = new Map< string, + RuntimeProcessRowsCacheEntry + >(); + private readonly runtimeProcessUsageStatsCacheByPid = new Map< + number, { expiresAtMs: number; - generation: number; - runId: string | null; - rows: RuntimeTelemetryProcessTableRow[] | null; - includesWindowsHostRows: boolean; + stats: RuntimeProcessUsageStats | null; } >(); + private readonly persistedTeamConfigCache = new Map(); private readonly agentRuntimeSnapshotInFlightByTeam = new Map< string, { @@ -3426,7 +3615,7 @@ export class TeamProvisioningService { { expiresAtMs: number; generation: number; - runId: string; + runId: string | null; snapshot: MemberSpawnStatusesSnapshot; } >(); @@ -3687,7 +3876,9 @@ export class TeamProvisioningService { this.agentRuntimeSnapshotInFlightByTeam.delete(teamName); this.liveTeamAgentRuntimeMetadataCache.delete(teamName); this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); - this.runtimeProcessRowsForUsageSnapshotByTeam.delete(teamName); + this.persistedTeamConfigCache.delete(teamName); + // Process table rows are TTL-bound. Resource telemetry can use the longer + // TTL, while liveness only reuses rows through a short age gate. } private cloneMemberSpawnStatusesSnapshot( @@ -4843,10 +5034,40 @@ export class TeamProvisioningService { return this.aliveRunByTeam.get(teamName) ?? null; } + private setAliveRunId(teamName: string, runId: string): void { + if (!teamName || !runId || this.aliveRunByTeam.get(teamName) === runId) { + return; + } + this.aliveRunByTeam.set(teamName, runId); + notifyTeamWatchScopeChanged(); + } + + private deleteAliveRunId(teamName: string): void { + if (this.aliveRunByTeam.delete(teamName)) { + notifyTeamWatchScopeChanged(); + } + } + + /** + * Snapshot of teams that currently have a live runtime run. Used to keep the + * file-watch scope covering running teams (read-only; the map is maintained as + * runs start and stop). + */ + getAliveTeamNames(): string[] { + return [...this.aliveRunByTeam.keys()]; + } + private getTrackedRunId(teamName: string): string | null { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private getAgentRuntimeSnapshotCacheTtlMs(teamName: string, runId: string | null): number { + if (runId || this.runtimeAdapterRunByTeam.has(teamName)) { + return TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS; + } + return TeamProvisioningService.PERSISTED_AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS; + } + private canDeliverToTrackedRuntimeRun(teamName: string, runId: string): boolean { const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId); if ( @@ -5048,7 +5269,7 @@ export class TeamProvisioningService { this.deleteSecondaryRuntimeRun(teamName, laneId); if (laneId === 'primary') { this.runtimeAdapterRunByTeam.delete(teamName); - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); this.provisioningRunByTeam.delete(teamName); this.invalidateRuntimeSnapshotCaches(teamName); } @@ -10301,6 +10522,7 @@ export class TeamProvisioningService { peekAutoResumeService()?.cancelPendingAutoResume(teamName); this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true }); this.invalidateRuntimeSnapshotCaches(teamName); + this.runtimeProcessRowsForUsageSnapshotByTeam.delete(teamName); this.retainedClaudeLogsByTeam.delete(teamName); this.persistedTranscriptClaudeLogsCache.delete(teamName); this.leadInboxRelayInFlight.delete(teamName); @@ -13802,6 +14024,17 @@ export class TeamProvisioningService { source?: 'live' | 'persisted' | 'merged'; }> { const readPersistedStatuses = async (resolvedRunId: string | null) => { + const generationAtStart = this.getMemberSpawnStatusesCacheGeneration(teamName); + const cached = this.memberSpawnStatusesSnapshotCache.get(teamName); + if ( + cached && + cached.expiresAtMs > Date.now() && + cached.runId === resolvedRunId && + cached.generation === generationAtStart + ) { + return this.cloneMemberSpawnStatusesSnapshot(cached.snapshot); + } + const repairSnapshot = await this.readTaskActivityRepairLaunchSnapshot(teamName); this.repairStaleTaskActivityIntervalsOnce(teamName, repairSnapshot); const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName); @@ -13810,20 +14043,23 @@ export class TeamProvisioningService { this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot), }); const runtimeObservedAt = nowIso(); - for (const [memberName, entry] of Object.entries(nextStatuses)) { - if (entry.runtimeAlive === true) { - this.taskActivityIntervalService.resumeActiveIntervalsForMember( - teamName, - memberName, - runtimeObservedAt - ); - } + const aliveMemberNames = Object.entries(nextStatuses) + .filter(([, entry]) => entry.runtimeAlive === true) + .map(([memberName]) => memberName); + if (aliveMemberNames.length > 0) { + // Resume all alive members in a single locked task-file pass per cycle + // instead of one synchronous lock + full task read per member. + this.taskActivityIntervalService.resumeActiveIntervalsForMembers( + teamName, + aliveMemberNames, + runtimeObservedAt + ); } const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; const summary = expectedMembers ? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses) : undefined; - return { + const persistedSnapshot = { statuses: nextStatuses, runId: resolvedRunId, teamLaunchState: summary @@ -13835,6 +14071,20 @@ export class TeamProvisioningService { summary: summary ?? snapshot?.summary, source: 'persisted' as const, }; + if ( + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === resolvedRunId + ) { + this.memberSpawnStatusesSnapshotCache.set(teamName, { + expiresAtMs: + Date.now() + + TeamProvisioningService.PERSISTED_MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS, + generation: generationAtStart, + runId: resolvedRunId, + snapshot: this.cloneMemberSpawnStatusesSnapshot(persistedSnapshot), + }); + } + return persistedSnapshot; }; const runId = this.getTrackedRunId(teamName); @@ -14095,7 +14345,17 @@ export class TeamProvisioningService { const runtimeUsagePids = [ ...new Set([...runtimeUsageTreesByRootPid.values()].flatMap((tree) => tree.pids)), ]; - usageStatsByPid = await this.readProcessUsageStatsByPid(runtimeUsagePids); + usageStatsByPid = this.buildProcessUsageStatsFromRows(runtimeProcessRows, runtimeUsagePids); + const pidsMissingUsageStats = runtimeUsagePids.filter((pid) => !usageStatsByPid.has(pid)); + if ( + pidsMissingUsageStats.length > 0 && + this.shouldSampleMissingRuntimeUsageStatsWithPidusage() + ) { + const sampledUsageStats = await this.readProcessUsageStatsByPid(pidsMissingUsageStats); + for (const [pid, stats] of sampledUsageStats) { + usageStatsByPid.set(pid, stats); + } + } } catch (error) { logger.debug( `[${teamName}] Runtime telemetry sampling failed; continuing without resource metrics: ${ @@ -14401,10 +14661,13 @@ export class TeamProvisioningService { !usageStatsByPid.has(rssPid) && isSharedOpenCodeHost && typeof rssPid === 'number' && - rssPid > 0 + rssPid > 0 && + this.isRuntimePidusageTelemetryEnabled() ) { try { - const refreshedUsageStats = (await this.readProcessUsageStatsByPid([rssPid])).get(rssPid); + const refreshedUsageStats = ( + await this.readProcessUsageStatsByPid([rssPid], { ignoreCachedMisses: true }) + ).get(rssPid); if (refreshedUsageStats) { usageStatsByPid.set(rssPid, refreshedUsageStats); } @@ -14537,7 +14800,7 @@ export class TeamProvisioningService { this.getTrackedRunId(teamName) === runId ) { this.agentRuntimeSnapshotCache.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + expiresAtMs: Date.now() + this.getAgentRuntimeSnapshotCacheTtlMs(teamName, runId), snapshot, }); } @@ -20701,7 +20964,7 @@ export class TeamProvisioningService { laneId: 'primary', }).catch(() => undefined); this.runtimeAdapterRunByTeam.delete(input.request.teamName); - this.aliveRunByTeam.delete(input.request.teamName); + this.deleteAliveRunId(input.request.teamName); this.invalidateRuntimeSnapshotCaches(input.request.teamName); } else { this.runtimeAdapterRunByTeam.set(input.request.teamName, { @@ -20710,7 +20973,7 @@ export class TeamProvisioningService { cwd: launchCwd, members: result.members, }); - this.aliveRunByTeam.set(input.request.teamName, runId); + this.setAliveRunId(input.request.teamName, runId); this.invalidateRuntimeSnapshotCaches(input.request.teamName); } if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { @@ -21882,7 +22145,7 @@ export class TeamProvisioningService { emitDismiss: true, }); this.runtimeAdapterRunByTeam.delete(teamName); - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } @@ -21961,7 +22224,7 @@ export class TeamProvisioningService { this.runtimeAdapterRunByTeam.delete(teamName); } if (this.aliveRunByTeam.get(teamName) === runId) { - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); } if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); @@ -21978,7 +22241,7 @@ export class TeamProvisioningService { const timestamp = nowIso(); this.provisioningRunByTeam.delete(teamName); this.runtimeAdapterRunByTeam.delete(teamName); - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); this.invalidateRuntimeSnapshotCaches(teamName); const progress: TeamProvisioningProgress = { runId, @@ -25259,31 +25522,57 @@ export class TeamProvisioningService { let processRows: RuntimeTelemetryProcessTableRow[] = []; let processTableAvailable = true; - try { - processRows = - this.normalizeRuntimeProcessRowsForTelemetry( - await this.withRuntimeTelemetryTimeout( - listRuntimeProcessTableForCurrentPlatform(), - TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS, - 'process table runtime snapshot' - ), - process.platform === 'win32' ? 'wsl' : 'native' - ) ?? []; - } catch (error) { - processTableAvailable = false; - logger.debug( - `[${teamName}] Failed to read process table for runtime snapshot: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - this.runtimeProcessRowsForUsageSnapshotByTeam.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, - generation: generationAtStart, - runId, - rows: processTableAvailable ? processRows : null, - includesWindowsHostRows: false, + let processRowsReadForMetadata = false; + const shouldReadProcessTable = this.shouldReadProcessTableForLiveRuntimeMetadata({ + metadataByMember, + launchSnapshot: persistedLaunchSnapshot, + paneInfoById, }); + if (shouldReadProcessTable) { + const cachedRows = this.readCachedRuntimeProcessRowsForLiveRuntimeMetadata(teamName, runId); + if (cachedRows) { + processTableAvailable = cachedRows.rows !== null; + processRows = cachedRows.rows ?? []; + } else { + processRowsReadForMetadata = true; + try { + processRows = + this.normalizeRuntimeProcessRowsForTelemetry( + await this.withRuntimeTelemetryTimeout( + listRuntimeProcessTableForCurrentPlatform(), + TeamProvisioningService.RUNTIME_PROCESS_TABLE_TIMEOUT_MS, + 'process table runtime snapshot' + ), + process.platform === 'win32' ? 'wsl' : 'native' + ) ?? []; + } catch (error) { + processTableAvailable = false; + logger.debug( + `[${teamName}] Failed to read process table for runtime snapshot: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + } + if ( + processRowsReadForMetadata && + this.getRuntimeSnapshotCacheGeneration(teamName) === generationAtStart + ) { + const sampledAtMs = Date.now(); + this.runtimeProcessRowsForUsageSnapshotByTeam.set(teamName, { + expiresAtMs: + sampledAtMs + + (processTableAvailable + ? TeamProvisioningService.RUNTIME_RESOURCE_TELEMETRY_CACHE_TTL_MS + : TeamProvisioningService.RUNTIME_RESOURCE_TELEMETRY_FAILURE_CACHE_TTL_MS), + generation: generationAtStart, + runId, + sampledAtMs, + rows: processTableAvailable ? processRows : null, + includesWindowsHostRows: false, + }); + } let windowsHostProcessRows: RuntimeTelemetryProcessTableRow[] | null = null; let windowsHostProcessTableAvailable = false; const getWindowsHostProcessRows = async (): Promise => { @@ -25439,7 +25728,7 @@ export class TeamProvisioningService { this.getTrackedRunId(teamName) === runId ) { this.liveTeamAgentRuntimeMetadataCache.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + expiresAtMs: Date.now() + this.getAgentRuntimeSnapshotCacheTtlMs(teamName, runId), metadata: this.cloneLiveTeamAgentRuntimeMetadata(metadataByMember), runId, }); @@ -25561,6 +25850,17 @@ export class TeamProvisioningService { ...(params.pid ? { pid: params.pid } : {}), ...(params.runtimePid ? { runtimePid: params.runtimePid } : {}), }; + const lastSample = existingHistory.at(-1); + const lastSampleMs = lastSample ? Date.parse(lastSample.timestamp) : Number.NaN; + const sampleMs = Date.parse(sample.timestamp); + const sampledRecently = + Number.isFinite(lastSampleMs) && + Number.isFinite(sampleMs) && + sampleMs - lastSampleMs >= 0 && + sampleMs - lastSampleMs < TeamProvisioningService.RUNTIME_RESOURCE_SAMPLE_MIN_INTERVAL_MS; + if (sampledRecently) { + return existingHistory.map((entry) => ({ ...entry })); + } const nextHistory = [...existingHistory, sample].slice( -TeamProvisioningService.AGENT_RUNTIME_RESOURCE_HISTORY_LIMIT ); @@ -25620,9 +25920,18 @@ export class TeamProvisioningService { if (!stat || typeof stat !== 'object') { return undefined; } - const candidate = stat as { memory?: unknown; cpu?: unknown }; - const rssBytes = normalizeRuntimeTelemetryNumber(candidate.memory); - const cpuPercent = normalizeRuntimeTelemetryNumber(candidate.cpu); + const candidate = stat as { + memory?: unknown; + cpu?: unknown; + rssBytes?: unknown; + cpuPercent?: unknown; + }; + const rssBytes = + normalizeRuntimeTelemetryNumber(candidate.memory) ?? + normalizeRuntimeTelemetryNumber(candidate.rssBytes); + const cpuPercent = + normalizeRuntimeTelemetryNumber(candidate.cpu) ?? + normalizeRuntimeTelemetryNumber(candidate.cpuPercent); const normalized: RuntimeProcessUsageStats = { ...(rssBytes != null && rssBytes >= 0 ? { rssBytes } : {}), ...(cpuPercent != null && cpuPercent >= 0 ? { cpuPercent } : {}), @@ -25654,10 +25963,12 @@ export class TeamProvisioningService { candidate.runtimeTelemetrySource === 'windows-host' ? candidate.runtimeTelemetrySource : undefined); + const usageStats = this.normalizeRuntimeProcessUsageStats(candidate); normalizedRows.push({ pid: Math.floor(pid), ppid: Math.floor(ppid), command, + ...(usageStats ?? {}), ...(runtimeTelemetrySource ? { runtimeTelemetrySource } : {}), }); } @@ -25665,6 +25976,70 @@ export class TeamProvisioningService { return normalizedRows; } + private shouldReadProcessTableForLiveRuntimeMetadata(params: { + metadataByMember: ReadonlyMap; + launchSnapshot: PersistedTeamLaunchSnapshot | null | undefined; + paneInfoById: ReadonlyMap; + }): boolean { + for (const [memberName, metadata] of params.metadataByMember.entries()) { + if (metadata.agentId?.trim()) { + return true; + } + const paneId = metadata.tmuxPaneId?.trim() ?? ''; + if (paneId && params.paneInfoById.has(paneId)) { + return true; + } + const launchRuntimePid = params.launchSnapshot?.members[memberName]?.runtimePid; + if ( + (typeof metadata.metricsPid === 'number' && + Number.isFinite(metadata.metricsPid) && + metadata.metricsPid > 0) || + (typeof launchRuntimePid === 'number' && + Number.isFinite(launchRuntimePid) && + launchRuntimePid > 0) + ) { + return true; + } + } + return false; + } + + private readCachedRuntimeProcessRowsForLiveRuntimeMetadata( + teamName: string, + runId: string | null + ): { rows: RuntimeTelemetryProcessTableRow[] | null } | null { + const cached = this.runtimeProcessRowsForUsageSnapshotByTeam.get(teamName); + const nowMs = Date.now(); + if (!cached || cached.expiresAtMs <= nowMs || cached.runId !== runId) { + return null; + } + + // Process table rows are sampled global OS state. Do not tie reuse to + // runtime snapshot generation: launch progress invalidates runtime metadata + // frequently, and the age gate below keeps liveness freshness bounded. + const sampledAtMs = + typeof cached.sampledAtMs === 'number' && Number.isFinite(cached.sampledAtMs) + ? cached.sampledAtMs + : 0; + const maxAgeMs = + cached.rows === null + ? TeamProvisioningService.RUNTIME_LIVENESS_PROCESS_TABLE_FAILURE_CACHE_TTL_MS + : TeamProvisioningService.RUNTIME_LIVENESS_PROCESS_TABLE_CACHE_TTL_MS; + if (sampledAtMs <= 0 || nowMs - sampledAtMs > maxAgeMs) { + return null; + } + + if (cached.rows === null) { + return { rows: null }; + } + + const rows = + this.normalizeRuntimeProcessRowsForTelemetry(cached.rows)?.filter( + (row) => row.runtimeTelemetrySource !== 'windows-host' + ) ?? []; + return { rows }; + } + private async readRuntimeProcessRowsForUsageSnapshot( teamName: string, options: { includeWindowsHostRows?: boolean } = {} @@ -25673,18 +26048,12 @@ export class TeamProvisioningService { process.platform === 'win32' && options.includeWindowsHostRows === true; const cached = this.runtimeProcessRowsForUsageSnapshotByTeam.get(teamName); const canUseCached = - cached && - cached.expiresAtMs > Date.now() && - cached.generation === this.getRuntimeSnapshotCacheGeneration(teamName) && - cached.runId === this.getTrackedRunId(teamName); + cached && cached.expiresAtMs > Date.now() && cached.runId === this.getTrackedRunId(teamName); if (canUseCached && (!includeWindowsHostRows || cached.includesWindowsHostRows)) { return cached.rows; } - let rows = - canUseCached && cached.rows - ? this.normalizeRuntimeProcessRowsForTelemetry(cached.rows) - : null; + let rows = canUseCached && cached.rows ? cached.rows : null; let runtimeProcessTableAvailable = rows != null; try { if (!rows) { @@ -25732,10 +26101,16 @@ export class TeamProvisioningService { } const resultRows = rows && rows.length > 0 ? rows : runtimeProcessTableAvailable ? [] : null; + const sampledAtMs = Date.now(); this.runtimeProcessRowsForUsageSnapshotByTeam.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + expiresAtMs: + sampledAtMs + + (resultRows === null + ? TeamProvisioningService.RUNTIME_RESOURCE_TELEMETRY_FAILURE_CACHE_TTL_MS + : TeamProvisioningService.RUNTIME_RESOURCE_TELEMETRY_CACHE_TTL_MS), generation: this.getRuntimeSnapshotCacheGeneration(teamName), runId: this.getTrackedRunId(teamName), + sampledAtMs, rows: resultRows, includesWindowsHostRows, }); @@ -25746,7 +26121,7 @@ export class TeamProvisioningService { processRows: readonly RuntimeTelemetryProcessTableRow[] ): Map { const childrenByParent = new Map(); - for (const row of this.normalizeRuntimeProcessRowsForTelemetry(processRows) ?? []) { + for (const row of processRows) { const current = childrenByParent.get(row.ppid) ?? []; current.push(row); childrenByParent.set(row.ppid, current); @@ -25759,12 +26134,11 @@ export class TeamProvisioningService { processRows: readonly RuntimeTelemetryProcessTableRow[] | null, rootOwnersByPid: Map> ): void { - const normalizedRows = this.normalizeRuntimeProcessRowsForTelemetry(processRows); - if (!normalizedRows || normalizedRows.length === 0) { + if (!processRows || processRows.length === 0) { return; } - for (const row of normalizedRows) { + for (const row of processRows) { if (process.platform === 'win32' && row.runtimeTelemetrySource === 'wsl') { continue; } @@ -25836,10 +26210,16 @@ export class TeamProvisioningService { const childrenByParent = this.buildRuntimeProcessChildrenByParent(normalizedProcessRows); const rowByPid = new Map(normalizedProcessRows.map((row) => [row.pid, row])); + const missingRootPids: number[] = []; for (const rootPid of uniqueRoots) { const pids: number[] = []; let truncated = false; const rootProcessRow = rowByPid.get(rootPid); + if (!rootProcessRow) { + missingRootPids.push(rootPid); + usageTreesByRootPid.set(rootPid, { pids: [], truncated: false }); + continue; + } if (process.platform === 'win32' && rootProcessRow?.runtimeTelemetrySource === 'wsl') { usageTreesByRootPid.set(rootPid, { pids: [], truncated: false }); continue; @@ -25902,6 +26282,15 @@ export class TeamProvisioningService { usageTreesByRootPid.set(rootPid, { pids, truncated }); } + for (const rootPid of missingRootPids) { + if (scheduledPids.size >= TeamProvisioningService.MAX_RUNTIME_USAGE_PIDS_PER_SNAPSHOT) { + usageTreesByRootPid.set(rootPid, { pids: [], truncated: true }); + continue; + } + scheduledPids.add(rootPid); + usageTreesByRootPid.set(rootPid, { pids: [rootPid], truncated: false }); + } + return usageTreesByRootPid; } @@ -26025,8 +26414,43 @@ export class TeamProvisioningService { } } - private async readProcessUsageStatsByPid( + private buildProcessUsageStatsFromRows( + processRows: readonly RuntimeTelemetryProcessTableRow[] | null, pids: readonly number[] + ): Map { + const usageStatsByPid = new Map(); + const requestedPids = new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0)); + if (!Array.isArray(processRows) || requestedPids.size === 0) { + return usageStatsByPid; + } + + for (const row of processRows) { + if (!requestedPids.has(row.pid)) { + continue; + } + const usageStats = this.normalizeRuntimeProcessUsageStats(row); + if (usageStats) { + usageStatsByPid.set(row.pid, usageStats); + } + } + return usageStatsByPid; + } + + private shouldSampleMissingRuntimeUsageStatsWithPidusage(): boolean { + // CPU/RSS telemetry already comes from the enriched process table in the + // default path. If this opt-in is enabled, preserve the older fallback for + // missing rows across platforms. + return this.isRuntimePidusageTelemetryEnabled(); + } + + private isRuntimePidusageTelemetryEnabled(): boolean { + const value = process.env.CLAUDE_TEAM_RUNTIME_PIDUSAGE_ENABLED?.trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'yes'; + } + + private async readProcessUsageStatsByPid( + pids: readonly number[], + cacheOptions: { ignoreCachedMisses?: boolean } = {} ): Promise> { const pidCandidates = Array.isArray(pids) ? pids : []; const uniquePids = [...new Set(pidCandidates.filter((pid) => Number.isFinite(pid) && pid > 0))]; @@ -26035,20 +26459,83 @@ export class TeamProvisioningService { } const usageStatsByPid = new Map(); + const pidsToRead: number[] = []; + const now = Date.now(); + for (const pid of uniquePids) { + const cached = this.runtimeProcessUsageStatsCacheByPid.get(pid); + if (cached && cached.expiresAtMs > now) { + if (cached.stats) { + usageStatsByPid.set(pid, { ...cached.stats }); + continue; + } + if (!cacheOptions.ignoreCachedMisses) { + continue; + } + } + if (cached) { + this.runtimeProcessUsageStatsCacheByPid.delete(pid); + } + pidsToRead.push(pid); + } + if (pidsToRead.length === 0) { + return usageStatsByPid; + } + if (!this.isRuntimePidusageTelemetryEnabled()) { + return usageStatsByPid; + } + + const rememberUsageStats = ( + pid: number, + stats: RuntimeProcessUsageStats | null | undefined + ): void => { + const normalized = stats ? { ...stats } : null; + const nowMs = Date.now(); + for (const [cachedPid, cached] of this.runtimeProcessUsageStatsCacheByPid) { + if (cached.expiresAtMs <= nowMs) { + this.runtimeProcessUsageStatsCacheByPid.delete(cachedPid); + } + } + while ( + !this.runtimeProcessUsageStatsCacheByPid.has(pid) && + this.runtimeProcessUsageStatsCacheByPid.size >= + TeamProvisioningService.RUNTIME_PROCESS_USAGE_CACHE_MAX_ENTRIES + ) { + const oldestPid = this.runtimeProcessUsageStatsCacheByPid.keys().next().value; + if (oldestPid == null) { + break; + } + this.runtimeProcessUsageStatsCacheByPid.delete(oldestPid); + } + this.runtimeProcessUsageStatsCacheByPid.set(pid, { + expiresAtMs: nowMs + TeamProvisioningService.RUNTIME_PROCESS_USAGE_CACHE_TTL_MS, + stats: normalized, + }); + if (normalized) { + usageStatsByPid.set(pid, { ...normalized }); + } + }; + const options = RUNTIME_PIDUSAGE_OPTIONS; try { const statsByPid = await this.withRuntimeTelemetryTimeout( - pidusage(uniquePids, options), + pidusage(pidsToRead, options), TeamProvisioningService.RUNTIME_PIDUSAGE_BATCH_TIMEOUT_MS, 'pidusage batch runtime telemetry' ); + const observedPids = new Set(); for (const [rawPid, stat] of Object.entries( statsByPid && typeof statsByPid === 'object' ? statsByPid : {} )) { const pid = Number.parseInt(rawPid, 10); const usageStats = this.normalizeRuntimeProcessUsageStats(stat); - if (Number.isFinite(pid) && pid > 0 && usageStats) { - usageStatsByPid.set(pid, usageStats); + if (Number.isFinite(pid) && pid > 0) { + observedPids.add(pid); + rememberUsageStats(pid, usageStats); + } + } + for (const pid of pidsToRead) { + if (!observedPids.has(pid)) { + rememberUsageStats(pid, null); } } return usageStatsByPid; @@ -26066,10 +26553,10 @@ export class TeamProvisioningService { for ( let offset = 0; - offset < uniquePids.length; + offset < pidsToRead.length; offset += TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY ) { - const chunk = uniquePids.slice( + const chunk = pidsToRead.slice( offset, offset + TeamProvisioningService.RUNTIME_PIDUSAGE_FALLBACK_CONCURRENCY ); @@ -26082,13 +26569,12 @@ export class TeamProvisioningService { `pidusage runtime telemetry pid=${pid}` ); const usageStats = this.normalizeRuntimeProcessUsageStats(stat); - if (usageStats) { - usageStatsByPid.set(pid, usageStats); - } + rememberUsageStats(pid, usageStats); } catch (error) { if (error instanceof RuntimeTelemetryTimeoutError) { logger.debug(error.message); } + rememberUsageStats(pid, null); // Process likely exited between discovery and sampling. } }) @@ -27172,7 +27658,7 @@ export class TeamProvisioningService { }); run.onProgress(progress); this.provisioningRunByTeam.delete(run.teamName); - this.aliveRunByTeam.set(run.teamName, run.runId); + this.setAliveRunId(run.teamName, run.runId); logger.warn( `[${run.teamName}] Recovered ready state from completed deterministic bootstrap snapshot after post-bootstrap finalization delay.` ); @@ -29966,6 +30452,19 @@ export class TeamProvisioningService { memberName: string, sinceMs: number | null ): Promise { + const lookupCacheKey = this.buildBootstrapTranscriptOutcomeLookupCacheKey( + teamName, + memberName, + sinceMs + ); + const cachedLookup = this.getPersistedBootstrapTranscriptOutcomeLookupCacheEntry( + teamName, + lookupCacheKey + ); + if (cachedLookup !== undefined) { + return this.cloneBootstrapTranscriptOutcome(cachedLookup); + } + let summaries: Awaited>; try { summaries = await this.memberLogsFinder.findMemberLogs(teamName, memberName, sinceMs); @@ -29992,7 +30491,69 @@ export class TeamProvisioningService { ...(await this.readBootstrapTranscriptOutcomesInProjectRoot(teamName, memberName, sinceMs)) ); - return this.selectLatestBootstrapTranscriptOutcome(outcomes); + const outcome = this.selectLatestBootstrapTranscriptOutcome(outcomes); + this.setPersistedBootstrapTranscriptOutcomeLookupCacheEntry(teamName, lookupCacheKey, outcome); + return outcome; + } + + private cloneBootstrapTranscriptOutcome( + outcome: BootstrapTranscriptOutcome | null + ): BootstrapTranscriptOutcome | null { + return outcome ? { ...outcome } : null; + } + + private buildBootstrapTranscriptOutcomeLookupCacheKey( + teamName: string, + memberName: string, + sinceMs: number | null + ): string { + return [teamName.trim().toLowerCase(), memberName.trim().toLowerCase(), sinceMs ?? ''].join( + '\0' + ); + } + + private getPersistedBootstrapTranscriptOutcomeLookupCacheEntry( + teamName: string, + cacheKey: string + ): BootstrapTranscriptOutcome | null | undefined { + if (this.getTrackedRunId(teamName) || this.runtimeAdapterRunByTeam.has(teamName)) { + return undefined; + } + const cached = this.bootstrapTranscriptOutcomeLookupCache.get(cacheKey); + if (!cached) { + return undefined; + } + if (cached.expiresAtMs <= Date.now()) { + this.bootstrapTranscriptOutcomeLookupCache.delete(cacheKey); + return undefined; + } + return cached.outcome; + } + + private setPersistedBootstrapTranscriptOutcomeLookupCacheEntry( + teamName: string, + cacheKey: string, + outcome: BootstrapTranscriptOutcome | null + ): void { + if (this.getTrackedRunId(teamName) || this.runtimeAdapterRunByTeam.has(teamName)) { + return; + } + if ( + !this.bootstrapTranscriptOutcomeLookupCache.has(cacheKey) && + this.bootstrapTranscriptOutcomeLookupCache.size >= + TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.bootstrapTranscriptOutcomeLookupCache.keys().next().value; + if (oldestKey) { + this.bootstrapTranscriptOutcomeLookupCache.delete(oldestKey); + } + } + this.bootstrapTranscriptOutcomeLookupCache.set(cacheKey, { + expiresAtMs: + Date.now() + + TeamProvisioningService.PERSISTED_BOOTSTRAP_TRANSCRIPT_OUTCOME_LOOKUP_CACHE_TTL_MS, + outcome: this.cloneBootstrapTranscriptOutcome(outcome), + }); } private async readRecentBootstrapTranscriptOutcome( @@ -30005,8 +30566,8 @@ export class TeamProvisioningService { contextMemberNames?: readonly string[]; } = {} ): Promise { - let handle: fs.promises.FileHandle | null = null; const normalizedMemberName = memberName.trim().toLowerCase(); + const normalizedTeamName = teamName.trim().toLowerCase(); const contextMemberNames = Array.from( new Set( [memberName, ...(options.contextMemberNames ?? [])] @@ -30014,114 +30575,243 @@ export class TeamProvisioningService { .filter(Boolean) ) ); + const normalizedContextMemberNames = contextMemberNames.map((name) => + name.trim().toLowerCase() + ); + const cacheKey = this.buildBootstrapTranscriptOutcomeCacheKey({ + filePath, + sinceMs, + memberName: normalizedMemberName, + teamName, + allowAnonymousFailure: options.allowAnonymousFailure === true, + contextMemberNames, + }); try { - handle = await fs.promises.open(filePath, 'r'); - const stat = await handle.stat(); + // Stat without opening: on a cache hit we must NOT open the file. During a + // tracked launch the per-member lookup cache is bypassed, so this scan runs + // for every recent session file x every member x every poll; opening before + // the cache check turned every one of those into a wasted open() syscall. + const stat = await fs.promises.stat(filePath); if (!stat.isFile() || stat.size <= 0) { return null; } - const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES); - const buffer = Buffer.alloc(stat.size - start); - if (buffer.length === 0) { - return null; - } - await handle.read(buffer, 0, buffer.length, start); - const lines = buffer.toString('utf8').split('\n'); - if (start > 0) { - lines.shift(); + const cached = this.bootstrapTranscriptOutcomeCache.get(cacheKey); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached.outcome; } + // Parse the transcript tail once per (filePath, mtime, size) and share it + // across members. getParsedBootstrapTranscriptTail opens the file ITSELF only + // when its parse cache misses, so a shared-cache hit also avoids the open. + const parsedLines = await this.getParsedBootstrapTranscriptTail(filePath, stat); + const shouldCollectBootstrapContext = options.allowAnonymousFailure !== true; const bootstrapContextMembers = new Set(); - for (const rawLine of lines) { - const line = rawLine?.trim(); - if (!line) continue; - let parsed: { timestamp?: unknown } | null = null; - try { - parsed = JSON.parse(line) as { timestamp?: unknown }; - } catch { - continue; - } - const timestampMs = - typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; + const candidates: BootstrapTranscriptOutcomeCandidate[] = []; + for (const parsedLine of parsedLines) { + const { timestampMs, parsedAgentName, text, rawTimestamp, normalizedText } = parsedLine; if (sinceMs != null && (!Number.isFinite(timestampMs) || timestampMs < sinceMs)) { continue; } - const parsedAgentName = - typeof (parsed as { agentName?: unknown }).agentName === 'string' - ? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null - : null; if ( parsedAgentName && !matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName) ) { continue; } - const text = extractTranscriptMessageText(parsed); if (!text) { continue; } - for (const contextMemberName of contextMemberNames) { - if (isBootstrapTranscriptContextText(text, teamName, contextMemberName)) { - bootstrapContextMembers.add(contextMemberName.trim().toLowerCase()); + const lineNormalizedText = normalizedText ?? ''; + if (shouldCollectBootstrapContext) { + const isBootstrapContextLine = getCachedBootstrapContextCandidateForLine( + parsedLine, + lineNormalizedText, + normalizedTeamName + ); + if (isBootstrapContextLine) { + for (const contextMemberName of normalizedContextMemberNames) { + if ( + getCachedBootstrapContextMemberMatchForLine( + parsedLine, + lineNormalizedText, + contextMemberName + ) + ) { + bootstrapContextMembers.add(contextMemberName); + } + } } } + candidates.push({ + text, + normalizedText: lineNormalizedText, + observedAt: + rawTimestamp && rawTimestamp.length > 0 ? rawTimestamp : new Date().toISOString(), + parsedAgentName, + parsedLine, + }); } const hasUnambiguousMatchingBootstrapContext = - bootstrapContextMembers.size === 1 && bootstrapContextMembers.has(normalizedMemberName); - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]?.trim(); - if (!line) continue; - let parsed: { timestamp?: unknown } | null = null; - try { - parsed = JSON.parse(line) as { timestamp?: unknown }; - } catch { - continue; + shouldCollectBootstrapContext && + bootstrapContextMembers.size === 1 && + bootstrapContextMembers.has(normalizedMemberName); + let outcome: BootstrapTranscriptOutcome | null = null; + for (let index = candidates.length - 1; index >= 0; index -= 1) { + const candidate = candidates[index]; + if (!candidate) continue; + // Lazy + memoized on the shared parsed line: computed at most once per line + // across all members and re-scans, and only for lines this newest-first loop + // actually reaches (lines past the first match are never extracted). + const cachedLine = candidate.parsedLine; + if (cachedLine.bootstrapFailureReason === undefined) { + cachedLine.bootstrapFailureReason = extractBootstrapFailureReason(candidate.text); } - const timestampMs = - typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; - if (sinceMs != null) { - if (!Number.isFinite(timestampMs) || timestampMs < sinceMs) { - continue; - } - } - const parsedAgentName = - typeof (parsed as { agentName?: unknown }).agentName === 'string' - ? (parsed as { agentName?: string }).agentName?.trim().toLowerCase() || null - : null; - if ( - parsedAgentName && - !matchesObservedMemberNameForExpected(parsedAgentName, normalizedMemberName) - ) { - continue; - } - const text = extractTranscriptMessageText(parsed); - if (!text) continue; - const observedAt = - typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0 - ? parsed.timestamp.trim() - : new Date().toISOString(); - const reason = extractBootstrapFailureReason(text); + const reason = cachedLine.bootstrapFailureReason; if (reason) { if ( - !parsedAgentName && + !candidate.parsedAgentName && options.allowAnonymousFailure !== true && !hasUnambiguousMatchingBootstrapContext ) { continue; } - return { kind: 'failure', observedAt, reason }; + outcome = { kind: 'failure', observedAt: candidate.observedAt, reason }; + break; } - const successSource = getBootstrapTranscriptSuccessSource(text, teamName, memberName); + const successSource = getCachedBootstrapSuccessSourceForLine( + cachedLine, + candidate.normalizedText, + normalizedTeamName, + normalizedMemberName + ); if (successSource) { - return { kind: 'success', observedAt, source: successSource }; + outcome = { kind: 'success', observedAt: candidate.observedAt, source: successSource }; + break; } } + this.setBootstrapTranscriptOutcomeCacheEntry(cacheKey, { + mtimeMs: stat.mtimeMs, + size: stat.size, + outcome, + }); + return outcome; } catch { return null; - } finally { - await handle?.close().catch(() => undefined); } + } - return null; + private buildBootstrapTranscriptOutcomeCacheKey(input: { + filePath: string; + sinceMs: number | null; + memberName: string; + teamName: string; + allowAnonymousFailure: boolean; + contextMemberNames: readonly string[]; + }): string { + const normalizedContextMembers = Array.from( + new Set(input.contextMemberNames.map((name) => name.trim().toLowerCase()).filter(Boolean)) + ) + .sort() + .join('\0'); + return [ + input.filePath, + input.sinceMs ?? '', + input.memberName, + input.teamName.trim().toLowerCase(), + input.allowAnonymousFailure ? '1' : '0', + normalizedContextMembers, + ].join('\0'); + } + + private async getParsedBootstrapTranscriptTail( + filePath: string, + stat: { mtimeMs: number; size: number } + ): Promise { + const cached = this.parsedBootstrapTranscriptTailCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached.lines; + } + const lines: ParsedBootstrapTranscriptTailLine[] = []; + const start = Math.max(0, stat.size - TeamProvisioningService.BOOTSTRAP_FAILURE_TAIL_BYTES); + const length = stat.size - start; + if (length > 0) { + // Open lazily: only a genuine parse-cache miss (file changed since last + // parse) reaches here, so we never open a file whose tail is already cached. + const handle = await fs.promises.open(filePath, 'r'); + let rawLines: string[]; + try { + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, start); + rawLines = buffer.toString('utf8').split('\n'); + } finally { + await handle.close().catch(() => undefined); + } + if (start > 0) { + rawLines.shift(); + } + for (const rawLine of rawLines) { + const line = rawLine?.trim(); + if (!line) continue; + let parsed: { timestamp?: unknown; agentName?: unknown } | null = null; + try { + parsed = JSON.parse(line) as { timestamp?: unknown; agentName?: unknown }; + } catch { + continue; + } + const rawTimestamp = + typeof parsed.timestamp === 'string' && parsed.timestamp.trim().length > 0 + ? parsed.timestamp.trim() + : null; + const timestampMs = + typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN; + const parsedAgentName = + typeof parsed.agentName === 'string' + ? parsed.agentName.trim().toLowerCase() || null + : null; + const text = extractTranscriptMessageText(parsed); + const normalizedText = text ? text.replace(/\s+/g, ' ').trim().toLowerCase() : null; + lines.push({ rawTimestamp, timestampMs, text, normalizedText, parsedAgentName }); + } + } + this.setParsedBootstrapTranscriptTailCacheEntry(filePath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + lines, + }); + return lines; + } + + private setParsedBootstrapTranscriptTailCacheEntry( + filePath: string, + entry: ParsedBootstrapTranscriptTailCacheEntry + ): void { + if ( + !this.parsedBootstrapTranscriptTailCache.has(filePath) && + this.parsedBootstrapTranscriptTailCache.size >= + TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.parsedBootstrapTranscriptTailCache.keys().next().value; + if (oldestKey) { + this.parsedBootstrapTranscriptTailCache.delete(oldestKey); + } + } + this.parsedBootstrapTranscriptTailCache.set(filePath, entry); + } + + private setBootstrapTranscriptOutcomeCacheEntry( + cacheKey: string, + entry: BootstrapTranscriptOutcomeCacheEntry + ): void { + if ( + !this.bootstrapTranscriptOutcomeCache.has(cacheKey) && + this.bootstrapTranscriptOutcomeCache.size >= + TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_OUTCOME_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.bootstrapTranscriptOutcomeCache.keys().next().value; + if (oldestKey) { + this.bootstrapTranscriptOutcomeCache.delete(oldestKey); + } + } + this.bootstrapTranscriptOutcomeCache.set(cacheKey, entry); } private async readBootstrapTranscriptOutcomesInProjectRoot( @@ -30162,8 +30852,27 @@ export class TeamProvisioningService { if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) { continue; } + const candidatePath = path.join(projectDir, entry.name); + // Project dirs can hold hundreds of old session transcripts. A file last + // modified before the lookup window cannot contain a bootstrap line at/after + // sinceMs (append-only: line timestamp <= write time <= mtime), so + // readRecentBootstrapTranscriptOutcome would return null. Skip it with a + // cheap stat instead of opening + tail-reading every file each poll. + if (sinceMs != null) { + try { + const candidateStat = await fs.promises.stat(candidatePath); + if ( + candidateStat.mtimeMs < + sinceMs - TeamProvisioningService.BOOTSTRAP_TRANSCRIPT_MTIME_SLACK_MS + ) { + continue; + } + } catch { + continue; + } + } const outcome = await this.readRecentBootstrapTranscriptOutcome( - path.join(projectDir, entry.name), + candidatePath, sinceMs, memberName, teamName, @@ -30733,7 +31442,7 @@ export class TeamProvisioningService { await this.stopMixedSecondaryRuntimeLanes(teamName); } this.provisioningRunByTeam.delete(teamName); - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName); return; } @@ -30931,7 +31640,7 @@ export class TeamProvisioningService { laneId: 'primary', }).catch(() => undefined); this.runtimeAdapterRunByTeam.delete(teamName); - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); this.provisioningRunByTeam.delete(teamName); this.invalidateRuntimeSnapshotCaches(teamName); return; @@ -30953,7 +31662,7 @@ export class TeamProvisioningService { emitDismiss: true, }); this.runtimeAdapterRunByTeam.delete(teamName); - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } @@ -31015,7 +31724,7 @@ export class TeamProvisioningService { laneId: 'primary', }).catch(() => undefined); this.runtimeAdapterRunByTeam.delete(teamName); - this.aliveRunByTeam.delete(teamName); + this.deleteAliveRunId(teamName); this.provisioningRunByTeam.delete(teamName); this.teamChangeEmitter?.({ type: 'process', @@ -31051,32 +31760,74 @@ export class TeamProvisioningService { } } - private readPersistedTeamProjectPath(teamName: string): string | null { + private clonePersistedRuntimeMember( + member: PersistedRuntimeMemberLike + ): PersistedRuntimeMemberLike { + return { ...member }; + } + + private isPersistedRuntimeMemberLike(member: unknown): member is PersistedRuntimeMemberLike { + return !!member && typeof member === 'object'; + } + + private readPersistedTeamConfig(teamName: string): PersistedTeamConfigCacheEntry | null { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + let stat: fs.Stats; + try { + stat = fs.statSync(configPath); + } catch { + this.persistedTeamConfigCache.delete(teamName); + return null; + } + + const cached = this.persistedTeamConfigCache.get(teamName); + if ( + cached && + cached.path === configPath && + cached.size === stat.size && + cached.mtimeMs === stat.mtimeMs && + cached.ctimeMs === stat.ctimeMs + ) { + return cached; + } + try { const raw = fs.readFileSync(configPath, 'utf8'); - const parsed = JSON.parse(raw) as { projectPath?: unknown }; + const parsed = JSON.parse(raw) as { projectPath?: unknown; members?: unknown }; const projectPath = typeof parsed.projectPath === 'string' ? parsed.projectPath.trim() : ''; - return projectPath || null; + const members = Array.isArray(parsed.members) + ? parsed.members + .filter((member): member is PersistedRuntimeMemberLike => + this.isPersistedRuntimeMemberLike(member) + ) + .map((member) => this.clonePersistedRuntimeMember(member)) + : []; + const entry: PersistedTeamConfigCacheEntry = { + path: configPath, + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + projectPath: projectPath || null, + members, + }; + this.persistedTeamConfigCache.set(teamName, entry); + return entry; } catch { + this.persistedTeamConfigCache.delete(teamName); return null; } } + private readPersistedTeamProjectPath(teamName: string): string | null { + return this.readPersistedTeamConfig(teamName)?.projectPath ?? null; + } + private readPersistedRuntimeMembers(teamName: string): PersistedRuntimeMemberLike[] { - const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); - try { - const raw = fs.readFileSync(configPath, 'utf8'); - const parsed = JSON.parse(raw) as { members?: unknown }; - if (!Array.isArray(parsed.members)) { - return []; - } - return parsed.members.filter((member): member is PersistedRuntimeMemberLike => { - return !!member && typeof member === 'object'; - }); - } catch { - return []; - } + return ( + this.readPersistedTeamConfig(teamName)?.members.map((member) => + this.clonePersistedRuntimeMember(member) + ) ?? [] + ); } private listPersistedTeamNames(): string[] { @@ -32859,7 +33610,7 @@ export class TeamProvisioningService { cwd: entry.cwd, members: committed.members, }); - this.aliveRunByTeam.set(entry.approval.teamName, entry.approval.runId); + this.setAliveRunId(entry.approval.teamName, entry.approval.runId); } this.syncOpenCodeRuntimeToolApprovals({ teamName: entry.approval.teamName, @@ -33520,7 +34271,7 @@ export class TeamProvisioningService { }); run.onProgress(progress); this.provisioningRunByTeam.delete(run.teamName); - this.aliveRunByTeam.set(run.teamName, run.runId); + this.setAliveRunId(run.teamName, run.runId); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); if (!run.deterministicBootstrap && shouldUseGeminiStagedLaunch(run.request.providerId)) { @@ -33715,7 +34466,7 @@ export class TeamProvisioningService { }); } this.provisioningRunByTeam.delete(run.teamName); - this.aliveRunByTeam.set(run.teamName, run.runId); + this.setAliveRunId(run.teamName, run.runId); logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); if (!run.deterministicBootstrap && shouldUseGeminiStagedLaunch(run.request.providerId)) { @@ -34401,7 +35152,7 @@ export class TeamProvisioningService { this.provisioningRunByTeam.delete(run.teamName); } if (this.aliveRunByTeam.get(run.teamName) === run.runId) { - this.aliveRunByTeam.delete(run.teamName); + this.deleteAliveRunId(run.teamName); } if (!hasNewerTrackedRun) { this.clearSecondaryRuntimeRuns(run.teamName); diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 558ffb0c..8f88ba51 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -45,6 +45,10 @@ export interface ResolvedTeamMemberRuntimeLiveness { const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']); const SECRET_FLAG_PATTERN = /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; +const CLI_ARG_VALUES_CACHE_MAX_COMMANDS = 1_000; +const CLI_ARG_EQUALS_CACHE_MAX_KEYS_PER_COMMAND = 100; +const cliArgValuesCache = new Map>(); +const cliArgEqualsCache = new Map>(); function basenameCommand(command: string | undefined): string { const firstToken = command?.trim().split(/\s+/, 1)[0] ?? ''; @@ -68,7 +72,21 @@ function escapeRegexLiteral(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -export function extractCliArgValues(command: string, argName: string): string[] { +function getCachedCliArgValues(command: string, argName: string): readonly string[] { + if (!command.includes(argName)) { + return []; + } + + const cachedByArg = cliArgValuesCache.get(command); + const cachedValues = cachedByArg?.get(argName); + if (cachedValues) { + if (cachedByArg) { + cliArgValuesCache.delete(command); + cliArgValuesCache.set(command, cachedByArg); + } + return cachedValues; + } + const escapedArg = escapeRegexLiteral(argName); const pattern = new RegExp( `(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`, @@ -80,9 +98,68 @@ export function extractCliArgValues(command: string, argName: string): string[] const value = (match[2] ?? match[3] ?? match[4] ?? '').trim(); if (value) values.push(value); } + const nextByArg = cachedByArg ?? new Map(); + nextByArg.set(argName, values); + cliArgValuesCache.delete(command); + cliArgValuesCache.set(command, nextByArg); + while (cliArgValuesCache.size > CLI_ARG_VALUES_CACHE_MAX_COMMANDS) { + const oldestKey = cliArgValuesCache.keys().next().value; + if (oldestKey === undefined) break; + cliArgValuesCache.delete(oldestKey); + } return values; } +function getCachedCliArgEquals( + command: string, + argName: string, + normalizedExpected: string +): boolean | undefined { + const cachedByKey = cliArgEqualsCache.get(command); + if (!cachedByKey) { + return undefined; + } + const cacheKey = `${argName}\0${normalizedExpected}`; + const cached = cachedByKey.get(cacheKey); + if (cached !== undefined) { + cliArgEqualsCache.delete(command); + cliArgEqualsCache.set(command, cachedByKey); + } + return cached; +} + +function setCachedCliArgEquals( + command: string, + argName: string, + normalizedExpected: string, + value: boolean +): void { + let cachedByKey = cliArgEqualsCache.get(command); + if (!cachedByKey) { + cachedByKey = new Map(); + } + const cacheKey = `${argName}\0${normalizedExpected}`; + if (!cachedByKey.has(cacheKey) && cachedByKey.size >= CLI_ARG_EQUALS_CACHE_MAX_KEYS_PER_COMMAND) { + const oldestKey = cachedByKey.keys().next().value; + if (oldestKey !== undefined) { + cachedByKey.delete(oldestKey); + } + } + cachedByKey.set(cacheKey, value); + cliArgEqualsCache.delete(command); + cliArgEqualsCache.set(command, cachedByKey); + while (cliArgEqualsCache.size > CLI_ARG_VALUES_CACHE_MAX_COMMANDS) { + const oldestCommand = cliArgEqualsCache.keys().next().value; + if (oldestCommand === undefined) break; + cliArgEqualsCache.delete(oldestCommand); + } +} + +export function extractCliArgValues(command: string, argName: string): string[] { + const values = getCachedCliArgValues(command, argName); + return [...values]; +} + export function commandArgEquals( command: string, argName: string, @@ -90,7 +167,17 @@ export function commandArgEquals( ): boolean { const normalizedExpected = expected?.trim(); if (!normalizedExpected) return false; - return extractCliArgValues(command, argName).some((value) => value === normalizedExpected); + if (!command.includes(argName)) return false; + if (!command.includes(normalizedExpected)) return false; + const cached = getCachedCliArgEquals(command, argName, normalizedExpected); + if (cached !== undefined) { + return cached; + } + const value = getCachedCliArgValues(command, argName).some( + (argValue) => argValue === normalizedExpected + ); + setCachedCliArgEquals(command, argName, normalizedExpected, value); + return value; } function collectDescendants( @@ -128,6 +215,28 @@ function isVerifiedRuntimeProcess(params: { ); } +function findNewestVerifiedRuntimeProcess(params: { + rows: readonly RuntimeProcessTableRow[]; + teamName: string; + agentId?: string; +}): RuntimeProcessTableRow | undefined { + const agentId = params.agentId?.trim(); + if (!agentId) { + return undefined; + } + + let newest: RuntimeProcessTableRow | undefined; + for (const row of params.rows) { + if (!isVerifiedRuntimeProcess({ row, teamName: params.teamName, agentId })) { + continue; + } + if (!newest || row.pid > newest.pid) { + newest = row; + } + } + return newest; +} + function isOpenCodeRuntimeProcess(command: string | undefined): boolean { return (command ?? '').toLowerCase().includes('opencode'); } @@ -206,11 +315,11 @@ export function resolveTeamMemberRuntimeLiveness( }); } - const verifiedProcess = input.processRows - .filter((row) => - isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId }) - ) - .sort((left, right) => right.pid - left.pid)[0]; + const verifiedProcess = findNewestVerifiedRuntimeProcess({ + rows: input.processRows, + teamName: input.teamName, + agentId: input.agentId, + }); if (verifiedProcess) { return result({ alive: true, @@ -304,11 +413,11 @@ export function resolveTeamMemberRuntimeLiveness( const pane = input.pane; if (pane) { const descendants = collectDescendants(input.processRows, pane.panePid); - const verifiedDescendant = descendants - .filter((row) => - isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId }) - ) - .sort((left, right) => right.pid - left.pid)[0]; + const verifiedDescendant = findNewestVerifiedRuntimeProcess({ + rows: descendants, + teamName: input.teamName, + agentId: input.agentId, + }); if (verifiedDescendant) { return result({ alive: true, diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts index 00b8987b..3298f3f3 100644 --- a/src/main/services/team/TeamTaskActivityIntervalService.ts +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -18,11 +18,36 @@ interface ActivityIntervalResult { failed?: boolean; } +interface TaskDirectorySignature { + key: string; +} + +interface TaskFileSignature { + size: number; + mtimeMs: number; + ctimeMs: number; + dev: number; + ino: number; +} + +interface CachedActivityTaskFile { + signature: TaskFileSignature; + task: MutableTeamTask | null; +} + +interface ResumeMembersCacheEntry { + memberKey: string; + signatureKey: string; +} + +type MemberActivityNoopOperation = 'pause-member' | 'resume-member'; + type MutableTeamTask = TeamTask & { reviewIntervals?: TaskReviewInterval[]; }; const CRASH_REPAIR_GRACE_MS = 5_000; +const TASK_FILE_CACHE_MAX_ENTRIES = 8_192; const logger = createLogger('Service:TeamTaskActivityIntervalService'); function normalizeMemberName(value: string | null | undefined): string { @@ -306,29 +331,145 @@ function materializePausedReviewInterval( return true; } -function readTaskFile(filePath: string): MutableTeamTask | null { - try { - const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; - return parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null; - } catch { - return null; - } -} - function writeTaskFile(filePath: string, task: MutableTeamTask): void { const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(task, null, 2)); fs.renameSync(tempPath, filePath); } +function buildTaskFileSignature(stat: fs.Stats): TaskFileSignature { + return { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + dev: stat.dev, + ino: stat.ino, + }; +} + +function taskFileSignaturesEqual(a: TaskFileSignature, b: TaskFileSignature): boolean { + return ( + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.dev === b.dev && + a.ino === b.ino + ); +} + export class TeamTaskActivityIntervalService { - private mutateTeamTasks( + private readonly resumeMembersCache = new Map(); + private readonly memberActivityNoopCache = new Map(); + private readonly taskFileCache = new Map(); + + private getBoardStateLockPath(teamName: string): string { + return `${path.join(getTeamsBasePath(), teamName, 'board-state')}.lock`; + } + + private getMemberActivityNoopCacheKey( teamName: string, - mutate: (task: MutableTeamTask) => boolean + operation: MemberActivityNoopOperation, + memberKey: string + ): string { + return `${teamName}\u0000${operation}\u0000${memberKey}`; + } + + private clearMemberActivityNoopCacheForTeam(teamName: string): void { + const prefix = `${teamName}\u0000`; + for (const key of this.memberActivityNoopCache.keys()) { + if (key.startsWith(prefix)) { + this.memberActivityNoopCache.delete(key); + } + } + } + + private clearActivityNoopCachesForTeam(teamName: string): void { + this.clearMemberActivityNoopCacheForTeam(teamName); + this.resumeMembersCache.delete(teamName); + } + + private getCachedTaskFile( + filePath: string, + signature: TaskFileSignature + ): MutableTeamTask | null | undefined { + const cached = this.taskFileCache.get(filePath); + if (!cached) return undefined; + if (!taskFileSignaturesEqual(cached.signature, signature)) { + this.taskFileCache.delete(filePath); + return undefined; + } + return cached.task ? structuredClone(cached.task) : null; + } + + private setCachedTaskFile( + filePath: string, + signature: TaskFileSignature, + task: MutableTeamTask | null + ): void { + if ( + !this.taskFileCache.has(filePath) && + this.taskFileCache.size >= TASK_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.taskFileCache.keys().next().value; + if (oldestKey) { + this.taskFileCache.delete(oldestKey); + } + } + this.taskFileCache.set(filePath, { + signature, + task: task ? structuredClone(task) : null, + }); + } + + private readTaskFile(filePath: string): MutableTeamTask | null { + let signature: TaskFileSignature; + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + this.taskFileCache.delete(filePath); + return null; + } + signature = buildTaskFileSignature(stat); + const cached = this.getCachedTaskFile(filePath, signature); + if (cached !== undefined) { + return cached; + } + } catch { + this.taskFileCache.delete(filePath); + return null; + } + + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + const task = parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null; + this.setCachedTaskFile(filePath, signature, task); + return task; + } catch { + this.setCachedTaskFile(filePath, signature, null); + return null; + } + } + + private cacheWrittenTaskFile(filePath: string, task: MutableTeamTask): void { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + this.taskFileCache.delete(filePath); + return; + } + this.setCachedTaskFile(filePath, buildTaskFileSignature(stat), task); + } catch { + this.taskFileCache.delete(filePath); + } + } + + private mutateTeamTasksWithLock( + teamName: string, + run: () => ActivityIntervalResult ): ActivityIntervalResult { const lockScope = path.join(getTeamsBasePath(), teamName, 'board-state'); try { - return withFileLockSync(lockScope, () => this.mutateTeamTasksUnlocked(teamName, mutate)); + return withFileLockSync(lockScope, run); } catch (error) { logger.warn( `[${teamName}] Failed to update task activity intervals: ${ @@ -339,6 +480,65 @@ export class TeamTaskActivityIntervalService { } } + private mutateTeamTasks( + teamName: string, + mutate: (task: MutableTeamTask) => boolean + ): ActivityIntervalResult { + const result = this.mutateTeamTasksWithLock(teamName, () => + this.mutateTeamTasksUnlocked(teamName, mutate) + ); + if (result.changedTasks > 0 || result.failed) { + this.clearActivityNoopCachesForTeam(teamName); + } + return result; + } + + private mutateMemberTasksWithNoopCache( + teamName: string, + operation: MemberActivityNoopOperation, + memberKey: string, + mutate: (task: MutableTeamTask) => boolean + ): ActivityIntervalResult { + const cacheKey = this.getMemberActivityNoopCacheKey(teamName, operation, memberKey); + const cachedSignatureKey = this.memberActivityNoopCache.get(cacheKey); + if (cachedSignatureKey) { + const beforeLockSignature = this.readTaskDirectorySignature(teamName); + if ( + beforeLockSignature && + beforeLockSignature.key === cachedSignatureKey && + !fs.existsSync(this.getBoardStateLockPath(teamName)) + ) { + return { changedTasks: 0 }; + } + } + + const result = this.mutateTeamTasksWithLock(teamName, () => { + const beforeSignature = this.readTaskDirectorySignature(teamName); + if (beforeSignature && this.memberActivityNoopCache.get(cacheKey) === beforeSignature.key) { + return { changedTasks: 0 }; + } + + const mutationResult = this.mutateTeamTasksUnlocked(teamName, mutate); + if (mutationResult.changedTasks > 0) { + this.clearActivityNoopCachesForTeam(teamName); + return mutationResult; + } + + const nextSignature = beforeSignature ?? this.readTaskDirectorySignature(teamName); + if (nextSignature) { + this.memberActivityNoopCache.set(cacheKey, nextSignature.key); + } else { + this.memberActivityNoopCache.delete(cacheKey); + } + return mutationResult; + }); + + if (result.changedTasks > 0 || result.failed) { + this.clearActivityNoopCachesForTeam(teamName); + } + return result; + } + private mutateTeamTasksUnlocked( teamName: string, mutate: (task: MutableTeamTask) => boolean @@ -358,10 +558,11 @@ export class TeamTaskActivityIntervalService { for (const fileName of entries) { if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; const filePath = path.join(tasksDir, fileName); - const task = readTaskFile(filePath); + const task = this.readTaskFile(filePath); if (!task) continue; if (!mutate(task)) continue; writeTaskFile(filePath, task); + this.cacheWrittenTaskFile(filePath, task); changedTasks += 1; } @@ -371,6 +572,38 @@ export class TeamTaskActivityIntervalService { return { changedTasks }; } + private readTaskDirectorySignature(teamName: string): TaskDirectorySignature | null { + const tasksDir = path.join(getTasksBasePath(), teamName); + let entries: string[]; + try { + entries = fs + .readdirSync(tasksDir) + .filter((fileName) => fileName.endsWith('.json') && !fileName.startsWith('.')) + .sort(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { key: 'missing' }; + } + return null; + } + + const parts: string[] = []; + for (const fileName of entries) { + try { + const stat = fs.statSync(path.join(tasksDir, fileName)); + if (!stat.isFile()) continue; + parts.push([fileName, stat.size, stat.mtimeMs, stat.ctimeMs].join('\0')); + } catch { + return null; + } + } + return { key: parts.join('\0\0') }; + } + + private makeMemberSetKey(memberKeys: ReadonlySet): string { + return [...memberKeys].sort().join('\0'); + } + pauseActiveIntervalsForTeam( teamName: string, at = new Date().toISOString() @@ -389,13 +622,19 @@ export class TeamTaskActivityIntervalService { memberName: string, at = new Date().toISOString() ): ActivityIntervalResult { - return this.mutateTeamTasks(teamName, (task) => { + const memberKey = normalizeMemberName(memberName); + const mutate = (task: MutableTeamTask): boolean => { const changedWork = closeOpenWorkIntervals(task, at, memberName); const changedReview = closeOpenReviewIntervals(task, at, memberName); const materializedWork = materializePausedWorkInterval(task, at, memberName); const materializedReview = materializePausedReviewInterval(task, at, memberName); return changedWork || changedReview || materializedWork || materializedReview; - }); + }; + + if (!memberKey) { + return this.mutateTeamTasks(teamName, mutate); + } + return this.mutateMemberTasksWithNoopCache(teamName, 'pause-member', memberKey, mutate); } resumeActiveIntervalsForMember( @@ -406,7 +645,7 @@ export class TeamTaskActivityIntervalService { const memberKey = normalizeMemberName(memberName); if (!memberKey) return { changedTasks: 0 }; - return this.mutateTeamTasks(teamName, (task) => { + return this.mutateMemberTasksWithNoopCache(teamName, 'resume-member', memberKey, (task) => { let changed = false; if ( @@ -443,6 +682,109 @@ export class TeamTaskActivityIntervalService { }); } + /** + * Batched equivalent of resumeActiveIntervalsForMember for several members in a + * single task-file pass. During launch the live-status loop resumes every alive + * member every audit cycle; doing that per member meant one synchronous + * file-lock + read of every task file PER member PER cycle. This applies the + * identical per-member resume logic against a member set in one locked pass, so + * the mutations are exactly the same but the lock + reads happen once per cycle + * instead of once per member. After a no-op pass, a task-file signature skips + * unchanged repeat cycles without parsing every task JSON again. + */ + resumeActiveIntervalsForMembers( + teamName: string, + memberNames: readonly string[], + at = new Date().toISOString() + ): ActivityIntervalResult { + const memberKeys = new Set( + memberNames.map((name) => normalizeMemberName(name)).filter((key): key is string => !!key) + ); + if (memberKeys.size === 0) return { changedTasks: 0 }; + const memberKey = this.makeMemberSetKey(memberKeys); + + const cachedBeforeLock = this.resumeMembersCache.get(teamName); + if (cachedBeforeLock?.memberKey === memberKey) { + const beforeLockSignature = this.readTaskDirectorySignature(teamName); + if ( + beforeLockSignature && + cachedBeforeLock.signatureKey === beforeLockSignature.key && + !fs.existsSync(this.getBoardStateLockPath(teamName)) + ) { + return { changedTasks: 0 }; + } + } + + const result = this.mutateTeamTasksWithLock(teamName, () => { + const beforeSignature = this.readTaskDirectorySignature(teamName); + const cached = this.resumeMembersCache.get(teamName); + if ( + beforeSignature && + cached?.memberKey === memberKey && + cached.signatureKey === beforeSignature.key + ) { + return { changedTasks: 0 }; + } + + const mutationResult = this.mutateTeamTasksUnlocked(teamName, (task) => { + let changed = false; + + if ( + task.status === 'in_progress' && + memberKeys.has(normalizeMemberName(task.owner)) && + !hasOpenWorkInterval(task) + ) { + const activeStartedAt = getActiveWorkStartedAt(task); + task.workIntervals = [ + ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), + { startedAt: resumeStartIso(activeStartedAt, at) }, + ]; + changed = true; + } + + const activeReview = getActiveReviewStart(task); + if ( + task.status === 'completed' && + activeReview && + memberKeys.has(normalizeMemberName(activeReview.reviewer)) && + !hasOpenReviewInterval(task, activeReview.reviewer) + ) { + task.reviewIntervals = [ + ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), + { + reviewer: activeReview.reviewer, + startedAt: resumeStartIso(activeReview.startedAt, at), + }, + ]; + changed = true; + } + + return changed; + }); + + const nextSignature = + mutationResult.changedTasks > 0 + ? this.readTaskDirectorySignature(teamName) + : beforeSignature; + if (nextSignature) { + this.resumeMembersCache.set(teamName, { + memberKey, + signatureKey: nextSignature.key, + }); + } else { + this.resumeMembersCache.delete(teamName); + } + return mutationResult; + }); + + if (result.failed) { + this.clearActivityNoopCachesForTeam(teamName); + } else if (result.changedTasks > 0) { + this.clearMemberActivityNoopCacheForTeam(teamName); + } + return result; + } + repairStaleIntervalsAfterCrash( teamName: string, launchSnapshot?: PersistedTeamLaunchSnapshot | null diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 3edf2ca5..f6f0ab77 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -22,7 +22,8 @@ import type { const logger = createLogger('Service:TeamTaskReader'); const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; -const ALL_TASKS_CACHE_TTL_MS = 5_000; +const ALL_TASKS_CACHE_TTL_MS = 30_000; +const TASK_FILE_CACHE_MAX_ENTRIES = 8_192; interface CachedAllTasks { value: (TeamTask & { teamName: string })[]; @@ -34,8 +35,52 @@ interface InFlightAllTasks { generationAtStart: number; } -function cloneTasks(tasks: T[]): T[] { - return structuredClone(tasks); +interface TaskFileSignature { + size: number; + mtimeMs: number; + ctimeMs: number; + dev: number; + ino: number; +} + +interface CachedTaskFile { + signature: TaskFileSignature; + task: TeamTask | null; +} + +interface CachedTeamTasks { + signaturesByFile: Map; + value: TeamTask[]; +} + +interface ScannedTaskFile { + file: string; + taskPath: string; + signature: TaskFileSignature; +} + +function cloneTasks(tasks: readonly T[]): T[] { + return structuredClone([...tasks]); +} + +function buildTaskFileSignature(stat: fs.Stats): TaskFileSignature { + return { + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + dev: stat.dev, + ino: stat.ino, + }; +} + +function taskFileSignaturesEqual(a: TaskFileSignature, b: TaskFileSignature): boolean { + return ( + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.dev === b.dev && + a.ino === b.ino + ); } /** @@ -82,12 +127,81 @@ export class TeamTaskReader { private static allTasksCache: CachedAllTasks | null = null; private static allTasksInFlight: InFlightAllTasks | null = null; private static allTasksGeneration = 0; + private static taskFileCache = new Map(); + private static teamTasksCache = new Map(); static invalidateAllTasksCache(): void { TeamTaskReader.allTasksCache = null; + TeamTaskReader.taskFileCache.clear(); + TeamTaskReader.teamTasksCache.clear(); TeamTaskReader.allTasksGeneration += 1; } + private static getCachedTaskFileSnapshot( + taskPath: string, + signature: TaskFileSignature + ): TeamTask | null | undefined { + const cached = TeamTaskReader.taskFileCache.get(taskPath); + if (!cached) { + return undefined; + } + if (!taskFileSignaturesEqual(cached.signature, signature)) { + TeamTaskReader.taskFileCache.delete(taskPath); + return undefined; + } + return cached.task; + } + + private static setCachedTaskFile( + taskPath: string, + signature: TaskFileSignature, + task: TeamTask | null + ): void { + if ( + !TeamTaskReader.taskFileCache.has(taskPath) && + TeamTaskReader.taskFileCache.size >= TASK_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = TeamTaskReader.taskFileCache.keys().next().value; + if (oldestKey) { + TeamTaskReader.taskFileCache.delete(oldestKey); + } + } + TeamTaskReader.taskFileCache.set(taskPath, { + signature, + task, + }); + } + + private static getCachedTeamTasks( + teamName: string, + scannedFiles: readonly ScannedTaskFile[] + ): readonly TeamTask[] | null { + const cached = TeamTaskReader.teamTasksCache.get(teamName); + if (!cached || cached.signaturesByFile.size !== scannedFiles.length) { + return null; + } + + for (const file of scannedFiles) { + const cachedSignature = cached.signaturesByFile.get(file.file); + if (!cachedSignature || !taskFileSignaturesEqual(cachedSignature, file.signature)) { + return null; + } + } + + return cached.value; + } + + private static setCachedTeamTasks( + teamName: string, + scannedFiles: readonly ScannedTaskFile[], + tasks: TeamTask[] + ): void { + TeamTaskReader.teamTasksCache.set(teamName, { + signaturesByFile: new Map(scannedFiles.map((file) => [file.file, file.signature] as const)), + value: tasks, + }); + } + /** * Returns the next available numeric task ID by scanning ALL task files * (including _internal ones) to avoid ID collisions. @@ -118,6 +232,11 @@ export class TeamTaskReader { } async getTasks(teamName: string): Promise { + const tasks = await this.getTasksProjectionSnapshot(teamName); + return cloneTasks(tasks); + } + + async getTasksProjectionSnapshot(teamName: string): Promise { const tasksDir = path.join(getTasksBasePath(), teamName); let entries: string[]; @@ -130,8 +249,8 @@ export class TeamTaskReader { throw error; } - const tasks: TeamTask[] = []; - let processed = 0; + const scannedFiles: ScannedTaskFile[] = []; + let canCacheTeamSnapshot = true; for (const file of entries) { if ( !file.endsWith('.json') || @@ -147,6 +266,39 @@ export class TeamTaskReader { const fileStat = await fs.promises.stat(taskPath); if (!fileStat.isFile() || fileStat.size > MAX_TASK_FILE_BYTES) { logger.debug(`Skipping suspicious task file: ${taskPath}`); + TeamTaskReader.taskFileCache.delete(taskPath); + TeamTaskReader.teamTasksCache.delete(teamName); + canCacheTeamSnapshot = false; + continue; + } + scannedFiles.push({ + file, + taskPath, + signature: buildTaskFileSignature(fileStat), + }); + } catch { + TeamTaskReader.taskFileCache.delete(taskPath); + TeamTaskReader.teamTasksCache.delete(teamName); + canCacheTeamSnapshot = false; + logger.debug(`Skipping invalid task file: ${taskPath}`); + } + } + + const cachedTeamTasks = TeamTaskReader.getCachedTeamTasks(teamName, scannedFiles); + if (cachedTeamTasks) { + return cachedTeamTasks; + } + + const tasks: TeamTask[] = []; + let processed = 0; + for (const scannedFile of scannedFiles) { + const { taskPath, signature } = scannedFile; + try { + const cachedTask = TeamTaskReader.getCachedTaskFileSnapshot(taskPath, signature); + if (cachedTask !== undefined) { + if (cachedTask) { + tasks.push(cachedTask); + } continue; } const raw = await readFileUtf8WithTimeout(taskPath, 5_000); @@ -154,13 +306,14 @@ export class TeamTaskReader { // Skip internal CLI tracking entries (spawned subagent bookkeeping) const metadata = parsed.metadata as Record | undefined; if (metadata?._internal === true) { + TeamTaskReader.setCachedTaskFile(taskPath, signature, null); continue; } const subject = typeof parsed.subject === 'string' ? parsed.subject : ''; const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined; let updatedAt: string | undefined; try { - updatedAt = fileStat.mtime.toISOString(); + updatedAt = new Date(signature.mtimeMs).toISOString(); } catch { /* leave undefined */ } @@ -361,10 +514,15 @@ export class TeamTaskReader { : undefined, } satisfies Record; if (task.status === 'deleted') { + TeamTaskReader.setCachedTaskFile(taskPath, signature, null); continue; } + TeamTaskReader.setCachedTaskFile(taskPath, signature, task); tasks.push(task); } catch { + TeamTaskReader.taskFileCache.delete(taskPath); + TeamTaskReader.teamTasksCache.delete(teamName); + canCacheTeamSnapshot = false; logger.debug(`Skipping invalid task file: ${taskPath}`); } processed++; @@ -390,6 +548,10 @@ export class TeamTaskReader { return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' }); }); + if (canCacheTeamSnapshot) { + TeamTaskReader.setCachedTeamTasks(teamName, scannedFiles, tasks); + } + return tasks; } @@ -478,28 +640,31 @@ export class TeamTaskReader { } async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> { + const tasks = await this.getAllTasksProjectionSnapshot(); + return cloneTasks(tasks); + } + + async getAllTasksProjectionSnapshot(): Promise { const startedAt = Date.now(); const cached = TeamTaskReader.allTasksCache; if (cached && cached.expiresAt > Date.now()) { - const cloned = cloneTasks(cached.value); const ms = Date.now() - startedAt; if (ms >= 1500) { - logger.warn(`[getAllTasks] cache clone slow ms=${ms} tasks=${cloned.length}`); + logger.warn(`[getAllTasks] cache read slow ms=${ms} tasks=${cached.value.length}`); } - return cloned; + return cached.value; } if (TeamTaskReader.allTasksInFlight?.generationAtStart === TeamTaskReader.allTasksGeneration) { const waitedAt = Date.now(); const tasks = await TeamTaskReader.allTasksInFlight.promise; - const cloned = cloneTasks(tasks); const ms = Date.now() - startedAt; if (ms >= 1500) { logger.warn( - `[getAllTasks] in-flight wait slow ms=${ms} waitMs=${Date.now() - waitedAt} tasks=${cloned.length}` + `[getAllTasks] in-flight wait slow ms=${ms} waitMs=${Date.now() - waitedAt} tasks=${tasks.length}` ); } - return cloned; + return tasks; } const request = this.readAllTasksUncached(); @@ -512,16 +677,15 @@ export class TeamTaskReader { const tasks = await request; if (TeamTaskReader.allTasksGeneration === generationAtStart) { TeamTaskReader.allTasksCache = { - value: cloneTasks(tasks), + value: tasks, expiresAt: Date.now() + ALL_TASKS_CACHE_TTL_MS, }; } - const cloned = cloneTasks(tasks); const ms = Date.now() - startedAt; if (ms >= 1500) { - logger.warn(`[getAllTasks] total slow ms=${ms} tasks=${cloned.length}`); + logger.warn(`[getAllTasks] total slow ms=${ms} tasks=${tasks.length}`); } - return cloned; + return tasks; } finally { if (TeamTaskReader.allTasksInFlight?.promise === request) { TeamTaskReader.allTasksInFlight = null; @@ -577,7 +741,7 @@ export class TeamTaskReader { for (const entry of entries) { if (!entry.isDirectory()) continue; try { - const tasks = await this.getTasks(entry.name); + const tasks = await this.getTasksProjectionSnapshot(entry.name); for (const task of tasks) { result.push({ ...task, teamName: entry.name }); } diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index b37bb4a1..b6f583d5 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -8,19 +8,34 @@ import { } from '@main/utils/pathDecoder'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { createReadStream, type Dirent } from 'fs'; +import { createHash } from 'crypto'; +import { type Dirent } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; -import * as readline from 'readline'; +import { StringDecoder } from 'string_decoder'; +import { JsonTeamTranscriptAffinityIndexStore } from './cache/JsonTeamTranscriptAffinityIndexStore'; import { TeamConfigReader } from './TeamConfigReader'; +import type { + PersistedTeamTranscriptAffinityEntry, + PersistedTeamTranscriptAffinityIndex, + TeamTranscriptAffinityFileSignature, + TeamTranscriptAffinityIndexStore, + TeamTranscriptAffinityMatchSource, +} from './cache/teamTranscriptAffinityIndexTypes'; import type { TeamConfig } from '@shared/types'; const logger = createLogger('Service:TeamTranscriptProjectResolver'); const SESSION_DISCOVERY_CACHE_TTL = 30_000; const TEAM_AFFINITY_SCAN_LINES = 40; +// Read size for the head-window affinity scan. Read in chunks (not the whole file) +// so a transcript whose head holds the team's first TEAM_AFFINITY_SCAN_LINES lines +// is decided after reading just those, not the entire (possibly huge) file. +const TEAM_AFFINITY_READ_CHUNK_BYTES = 64 * 1024; +const TEAM_AFFINITY_FILE_CACHE_MAX_ENTRIES = 4_096; +const TEAM_AFFINITY_HEAD_METADATA_CACHE_MAX_ENTRIES = 4_096; const ROOT_DISCOVERY_CONCURRENCY = 12; const FAST_CONTEXT_ROOT_DISCOVERY_MTIME_GRACE_MS = 24 * 60 * 60_000; @@ -57,6 +72,13 @@ interface TeamTranscriptProjectContextOptions { includeTeamSubagentSessionDiscovery?: boolean; } +interface TeamTranscriptFileStat { + mtimeMs: number; + size: number; + ctimeMs?: number; + isFile: () => boolean; +} + type ScannedSessionProjectMatch = Omit & { projectPath?: string; }; @@ -146,23 +168,7 @@ function extractTextContent(entry: Record): string | null { return null; } -function extractDirectTeamName(entry: Record): string | null { - if (typeof entry.teamName === 'string') { - return entry.teamName.trim().toLowerCase(); - } - - const process = entry.process as Record | undefined; - const processTeam = process?.team as Record | undefined; - if (typeof processTeam?.teamName === 'string') { - return processTeam.teamName.trim().toLowerCase(); - } - - return null; -} - -function lineMentionsTeam(text: string, teamName: string): boolean { - const normalizedText = text.trim().toLowerCase(); - const normalizedTeam = teamName.trim().toLowerCase(); +function lineMentionsNormalizedTeam(normalizedText: string, normalizedTeam: string): boolean { if (!normalizedText.includes(normalizedTeam)) { return false; } @@ -178,26 +184,52 @@ function lineMentionsTeam(text: string, teamName: string): boolean { ); } -function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean { +function collectNestedTeamNames(value: unknown, teamNames: Set, depth: number = 0): void { if (!value || depth > 8 || typeof value !== 'object') { - return false; + return; } if (Array.isArray(value)) { - return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1)); + for (const item of value) { + collectNestedTeamNames(item, teamNames, depth + 1); + } + return; } const entry = value as Record; - if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) { - return true; + if (typeof entry.teamName === 'string') { + const normalizedTeamName = entry.teamName.trim().toLowerCase(); + if (normalizedTeamName) { + teamNames.add(normalizedTeamName); + } } - return Object.entries(entry).some(([key, nested]) => { + for (const [key, nested] of Object.entries(entry)) { if (key === 'teamName') { - return false; + continue; } - return entryContainsNestedTeamName(nested, teamName, depth + 1); - }); + collectNestedTeamNames(nested, teamNames, depth + 1); + } +} + +function parseTeamAffinityHeadLine(rawLine: string): TeamAffinityHeadLineMetadata { + const empty: TeamAffinityHeadLineMetadata = { + nestedTeamNames: new Set(), + normalizedTextContent: null, + }; + + try { + const entry = JSON.parse(rawLine) as Record; + const nestedTeamNames = new Set(); + collectNestedTeamNames(entry, nestedTeamNames); + const textContent = extractTextContent(entry); + return { + nestedTeamNames, + normalizedTextContent: textContent ? textContent.trim().toLowerCase() : null, + }; + } catch { + return empty; + } } function collectKnownSessionIds(config: TeamConfig): string[] { @@ -236,14 +268,61 @@ export interface TeamTranscriptProjectLiveBaseContext { config: TeamConfig; } +interface TeamAffinityFileCacheEntry { + mtimeMs: number; + size: number; + ctimeMs?: number; + belongsToTeam: boolean; + inspectedLineCount: number; + headFingerprint: string; + // True when the verdict was decided after inspecting a FULL head window + // (>= TEAM_AFFINITY_SCAN_LINES non-empty lines). For append-only transcripts the + // head is immutable, so a `false` verdict from a full window stays valid while the + // file only grows — letting us cache negatives durably instead of re-streaming + // every non-matching transcript on each bootstrap poll. + headWindowFull: boolean; +} + +interface TeamAffinityHeadLineMetadata { + nestedTeamNames: Set; + normalizedTextContent: string | null; +} + +interface TeamAffinityHeadMetadataCacheEntry { + mtimeMs: number; + size: number; + ctimeMs?: number; + inspectedLineCount: number; + headFingerprint: string; + lines: TeamAffinityHeadLineMetadata[]; +} + +interface TeamAffinityEvaluation { + belongsToTeam: boolean; + inspectedLineCount: number; + matchSource: TeamTranscriptAffinityMatchSource; +} + +interface TeamAffinityInspectionResult extends TeamAffinityEvaluation { + headWindowFull: boolean; + indexable: boolean; +} + export class TeamTranscriptProjectResolver { private readonly contextCache = new Map< string, { value: TeamTranscriptProjectContext; expiresAt: number } >(); + private readonly teamAffinityFileCache = new Map(); + private readonly teamAffinityHeadMetadataCache = new Map< + string, + TeamAffinityHeadMetadataCacheEntry + >(); + constructor( - private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader() + private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader(), + private readonly affinityIndexStore: TeamTranscriptAffinityIndexStore = new JsonTeamTranscriptAffinityIndexStore() ) {} private readConfigForObservation(teamName: string): Promise { @@ -337,6 +416,7 @@ export class TeamTranscriptProjectResolver { const sessionIds = await this.discoverSessionIds( teamName, resolution.projectDir, + resolution.projectId, resolvedConfig, options ); @@ -487,6 +567,7 @@ export class TeamTranscriptProjectResolver { } const teamRootSessionIds = await this.listTeamRootSessionIds( dirCandidate.projectDir, + dirCandidate.projectId, teamName ); if (teamRootSessionIds.length > 0) { @@ -797,6 +878,7 @@ export class TeamTranscriptProjectResolver { private async discoverSessionIds( teamName: string, projectDir: string, + projectId: string, config: TeamConfig, options?: TeamTranscriptProjectContextOptions ): Promise { @@ -807,7 +889,7 @@ export class TeamTranscriptProjectResolver { ? null : teamLifecycleMtimeCutoffMs(config); const [teamRootSessionIds, teamSubagentSessionIds] = await Promise.all([ - this.listTeamRootSessionIds(projectDir, teamName, rootMtimeSinceMs), + this.listTeamRootSessionIds(projectDir, projectId, teamName, rootMtimeSinceMs), includeTeamSubagentSessionDiscovery ? this.listTeamSubagentSessionIds(projectDir, teamName) : Promise.resolve([]), @@ -941,30 +1023,57 @@ export class TeamTranscriptProjectResolver { private async collectRootJsonlSessionIds( rootJsonlEntries: Dirent[], projectDir: string, + projectId: string, teamName: string, mtimeSinceMs?: number | null ): Promise { const discovered = new Set(); + const rootFileNames = new Set(rootJsonlEntries.map((entry) => entry.name)); + const indexEnabled = this.isPersistentAffinityIndexEnabled(); + const affinityIndex = indexEnabled + ? await this.loadTeamTranscriptAffinityIndex(teamName, projectId) + : null; + const shouldPruneAffinityIndex = Boolean( + affinityIndex && + Object.keys(affinityIndex.entries).some((fileName) => !rootFileNames.has(fileName)) + ); + const pendingIndexEntries: PersistedTeamTranscriptAffinityEntry[] = []; let nextIndex = 0; const scanNextRootEntry = async (): Promise => { while (nextIndex < rootJsonlEntries.length) { const entry = rootJsonlEntries[nextIndex++]; const filePath = path.join(projectDir, entry.name); - if (mtimeSinceMs != null) { - try { - const stat = await fs.stat(filePath); - if (!stat.isFile() || stat.mtimeMs < mtimeSinceMs) { - continue; - } - } catch { - continue; - } - } - if (!(await this.fileBelongsToTeam(filePath, teamName))) { + let fileStat: TeamTranscriptFileStat; + try { + fileStat = await fs.stat(filePath); + } catch { continue; } - discovered.add(entry.name.slice(0, -'.jsonl'.length)); + if (!fileStat.isFile() || (mtimeSinceMs != null && fileStat.mtimeMs < mtimeSinceMs)) { + continue; + } + + const indexedBelongsToTeam = indexEnabled + ? this.decideTeamAffinityFromIndex(affinityIndex?.entries[entry.name], fileStat) + : null; + if (indexedBelongsToTeam !== null) { + if (indexedBelongsToTeam) { + discovered.add(entry.name.slice(0, -'.jsonl'.length)); + } + continue; + } + + const inspection = await this.inspectFileTeamAffinity(filePath, teamName, fileStat); + if (inspection.belongsToTeam) { + discovered.add(entry.name.slice(0, -'.jsonl'.length)); + } + if (inspection.indexable) { + const indexEntry = this.buildTeamAffinityIndexEntry(entry.name, fileStat, inspection); + if (indexEntry) { + pendingIndexEntries.push(indexEntry); + } + } } }; @@ -974,11 +1083,26 @@ export class TeamTranscriptProjectResolver { ) ); + if (indexEnabled && (pendingIndexEntries.length > 0 || shouldPruneAffinityIndex)) { + await this.affinityIndexStore + .upsertProjectEntries({ + teamName, + projectId, + projectDir, + rootFileNames, + entries: pendingIndexEntries, + }) + .catch((error) => { + logger.debug(`Failed to write transcript affinity index: ${String(error)}`); + }); + } + return [...discovered]; } private async listTeamRootSessionIds( projectDir: string, + projectId: string, teamName: string, mtimeSinceMs?: number | null ): Promise { @@ -990,52 +1114,414 @@ export class TeamTranscriptProjectResolver { const rootJsonlEntries = dirEntries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); - return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName, mtimeSinceMs); + return this.collectRootJsonlSessionIds( + rootJsonlEntries, + projectDir, + projectId, + teamName, + mtimeSinceMs + ); } - private async fileBelongsToTeam(filePath: string, teamName: string): Promise { - const stream = createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + private async fileBelongsToTeam( + filePath: string, + teamName: string, + precomputedStat?: TeamTranscriptFileStat + ): Promise { + return (await this.inspectFileTeamAffinity(filePath, teamName, precomputedStat)).belongsToTeam; + } + + private async inspectFileTeamAffinity( + filePath: string, + teamName: string, + precomputedStat?: TeamTranscriptFileStat + ): Promise { + const emptyResult: TeamAffinityInspectionResult = { + belongsToTeam: false, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: false, + indexable: false, + }; const normalizedTeam = teamName.trim().toLowerCase(); + if (!normalizedTeam) { + return emptyResult; + } + // Reuse the caller's stat when it already statted this exact file (the mtime-window + // filter in collectRootJsonlSessionIds does). On the live resolution path this drops + // a second fs.stat of the same file per entry, every poll — and using a single stat + // snapshot is also more consistent than two reads that could straddle a write. + let fileStat: TeamTranscriptFileStat; + if (precomputedStat) { + fileStat = precomputedStat; + } else { + try { + fileStat = await fs.stat(filePath); + } catch { + return emptyResult; + } + } + + if (!fileStat.isFile()) { + return emptyResult; + } + + const cacheKey = this.buildTeamAffinityFileCacheKey(filePath, normalizedTeam); + const cached = this.teamAffinityFileCache.get(cacheKey); + if (cached) { + if (this.teamTranscriptFileSignaturesMatch(cached, fileStat)) { + return { + belongsToTeam: cached.belongsToTeam, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: cached.headWindowFull, + indexable: false, + }; + } + // A positive affinity is decided by early "head" lines that persist as an + // append-only transcript grows, so a `true` result stays valid while the file + // only grows (size >= cached). This avoids re-streaming the team's own + // continuously-growing transcripts on every bootstrap poll. A `false` result + // is still re-checked on any change, since a short file may later grow head + // lines that mention the team; a shrink (rewrite/truncate) also forces a re-scan. + if ( + cached.belongsToTeam && + fileStat.size >= cached.size && + (await this.isCachedTeamAffinityHeadCurrent(filePath, cached)) + ) { + return { + belongsToTeam: true, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: cached.headWindowFull, + indexable: false, + }; + } + // A `false` decided from a FULL head window is durable while the file only + // grows: the first TEAM_AFFINITY_SCAN_LINES lines of an append-only transcript + // are immutable, so growth cannot introduce a team mention inside the inspected + // window. A shrink/rewrite makes size < cached.size and falls through to a + // re-scan below, identically to the positive path. This is the main launch win: + // non-matching transcripts in the project dir are no longer re-streamed + + // re-parsed on every bootstrap poll. + if ( + !cached.belongsToTeam && + cached.headWindowFull && + fileStat.size >= cached.size && + (await this.isCachedTeamAffinityHeadCurrent(filePath, cached)) + ) { + return { + belongsToTeam: false, + inspectedLineCount: 0, + matchSource: 'none', + headWindowFull: true, + indexable: false, + }; + } + } + + const headMetadata = await this.getTeamAffinityHeadMetadata(filePath, fileStat); + if (!headMetadata) { + return emptyResult; + } + const evaluation = this.evaluateTeamAffinityHeadMetadata(headMetadata, normalizedTeam); + const headWindowFull = evaluation.inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES; + + this.setTeamAffinityFileCacheEntry(cacheKey, { + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + ...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) + ? { ctimeMs: fileStat.ctimeMs } + : {}), + belongsToTeam: evaluation.belongsToTeam, + inspectedLineCount: headMetadata.inspectedLineCount, + headFingerprint: headMetadata.headFingerprint, + headWindowFull, + }); + return { + ...evaluation, + headWindowFull, + indexable: true, + }; + } + + private evaluateTeamAffinityHeadMetadata( + metadata: TeamAffinityHeadMetadataCacheEntry, + normalizedTeam: string + ): TeamAffinityEvaluation { + let inspectedLineCount = 0; + for (const line of metadata.lines) { + inspectedLineCount += 1; + if (line.nestedTeamNames.has(normalizedTeam)) { + return { belongsToTeam: true, inspectedLineCount, matchSource: 'nested_team_name' }; + } + if ( + line.normalizedTextContent && + lineMentionsNormalizedTeam(line.normalizedTextContent, normalizedTeam) + ) { + return { belongsToTeam: true, inspectedLineCount, matchSource: 'text_team_mention' }; + } + } + return { + belongsToTeam: false, + inspectedLineCount: metadata.inspectedLineCount, + matchSource: 'none', + }; + } + + private isPersistentAffinityIndexEnabled(): boolean { + return process.env.CLAUDE_TEAM_TRANSCRIPT_AFFINITY_INDEX !== '0'; + } + + private async loadTeamTranscriptAffinityIndex( + teamName: string, + projectId: string + ): Promise { try { - let inspected = 0; - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } + return await this.affinityIndexStore.loadProject(teamName, projectId); + } catch (error) { + logger.debug(`Failed to load transcript affinity index: ${String(error)}`); + return null; + } + } - inspected += 1; - try { - const entry = JSON.parse(trimmed) as Record; - const directTeamName = extractDirectTeamName(entry); - if (directTeamName === normalizedTeam) { - return true; - } - if (entryContainsNestedTeamName(entry, normalizedTeam)) { - return true; - } + private decideTeamAffinityFromIndex( + entry: PersistedTeamTranscriptAffinityEntry | undefined, + fileStat: TeamTranscriptFileStat + ): boolean | null { + if (!entry) { + return null; + } + if (!this.teamTranscriptFileSignaturesMatch(entry.signature, fileStat)) { + return null; + } + return entry.verdict === 'belongs'; + } - const textContent = extractTextContent(entry); - if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { - return true; - } - } catch { - // ignore malformed head lines - } + private teamTranscriptFileSignaturesMatch( + cached: { size: number; mtimeMs: number; ctimeMs?: number }, + fileStat: { size: number; mtimeMs: number; ctimeMs?: number } + ): boolean { + if (cached.size !== fileStat.size || cached.mtimeMs !== fileStat.mtimeMs) { + return false; + } + const cachedCtimeMs = + cached.ctimeMs != null && Number.isFinite(cached.ctimeMs) ? cached.ctimeMs : null; + const currentCtimeMs = + fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) ? fileStat.ctimeMs : null; + if (cachedCtimeMs !== null || currentCtimeMs !== null) { + return cachedCtimeMs !== null && currentCtimeMs !== null && cachedCtimeMs === currentCtimeMs; + } + return true; + } - if (inspected >= TEAM_AFFINITY_SCAN_LINES) { + private buildTeamAffinityIndexEntry( + fileName: string, + fileStat: TeamTranscriptFileStat, + inspection: TeamAffinityInspectionResult + ): PersistedTeamTranscriptAffinityEntry | null { + if ( + fileName.length <= '.jsonl'.length || + !fileName.endsWith('.jsonl') || + fileName.includes('/') || + fileName.includes('\\') + ) { + return null; + } + + const sessionId = fileName.slice(0, -'.jsonl'.length); + const signature: TeamTranscriptAffinityFileSignature = { + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + ...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) + ? { ctimeMs: fileStat.ctimeMs } + : {}), + }; + + return { + fileName, + sessionId, + signature, + verdict: inspection.belongsToTeam ? 'belongs' : 'does_not_belong', + headWindowFull: inspection.headWindowFull, + inspectedLineCount: inspection.inspectedLineCount, + matchSource: inspection.matchSource, + writtenAt: new Date().toISOString(), + }; + } + + private async isCachedTeamAffinityHeadCurrent( + filePath: string, + cached: TeamAffinityFileCacheEntry + ): Promise { + if (cached.inspectedLineCount <= 0) { + return false; + } + + const fingerprint = createHash('sha256'); + let inspectedLineCount = 0; + const inspectHeadLine = (rawLine: string): boolean => { + const trimmed = rawLine.trim(); + if (!trimmed) { + return false; + } + inspectedLineCount += 1; + fingerprint.update(trimmed); + fingerprint.update('\n'); + return inspectedLineCount >= cached.inspectedLineCount; + }; + + let handle: fs.FileHandle | null = null; + try { + handle = await fs.open(filePath, 'r'); + const decoder = new StringDecoder('utf8'); + const chunk = Buffer.allocUnsafe(TEAM_AFFINITY_READ_CHUNK_BYTES); + let pending = ''; + let position = 0; + let stop = false; + while (!stop) { + const { bytesRead } = await handle.read(chunk, 0, chunk.length, position); + if (bytesRead <= 0) { + pending += decoder.end(); + if (pending.length > 0) { + inspectHeadLine(pending); + } break; } + position += bytesRead; + pending += decoder.write(chunk.subarray(0, bytesRead)); + let newlineIndex = pending.indexOf('\n'); + while (newlineIndex !== -1) { + const line = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + if (inspectHeadLine(line)) { + stop = true; + break; + } + newlineIndex = pending.indexOf('\n'); + } } } catch { return false; } finally { - rl.close(); - stream.destroy(); + await handle?.close().catch(() => undefined); } - return false; + return ( + inspectedLineCount === cached.inspectedLineCount && + fingerprint.digest('hex') === cached.headFingerprint + ); + } + + private async getTeamAffinityHeadMetadata( + filePath: string, + fileStat: { mtimeMs: number; size: number; ctimeMs?: number } + ): Promise { + const cached = this.teamAffinityHeadMetadataCache.get(filePath); + if (cached && this.teamTranscriptFileSignaturesMatch(cached, fileStat)) { + return cached; + } + if (cached) { + this.teamAffinityHeadMetadataCache.delete(filePath); + } + + const lines: TeamAffinityHeadLineMetadata[] = []; + const fingerprint = createHash('sha256'); + let inspectedLineCount = 0; + const inspectHeadLine = (rawLine: string): boolean => { + const trimmed = rawLine.trim(); + if (!trimmed) { + return false; + } + inspectedLineCount += 1; + fingerprint.update(trimmed); + fingerprint.update('\n'); + lines.push(parseTeamAffinityHeadLine(trimmed)); + return inspectedLineCount >= TEAM_AFFINITY_SCAN_LINES; + }; + + let handle: fs.FileHandle | null = null; + try { + handle = await fs.open(filePath, 'r'); + const decoder = new StringDecoder('utf8'); + const chunk = Buffer.allocUnsafe(TEAM_AFFINITY_READ_CHUNK_BYTES); + let pending = ''; + let position = 0; + let stop = false; + while (!stop) { + const { bytesRead } = await handle.read(chunk, 0, chunk.length, position); + if (bytesRead <= 0) { + // EOF: flush the decoder and honor a final line with no trailing newline. + pending += decoder.end(); + if (pending.length > 0) { + inspectHeadLine(pending); + } + break; + } + position += bytesRead; + pending += decoder.write(chunk.subarray(0, bytesRead)); + let newlineIndex = pending.indexOf('\n'); + while (newlineIndex !== -1) { + const line = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + if (inspectHeadLine(line)) { + stop = true; + break; + } + newlineIndex = pending.indexOf('\n'); + } + } + } catch { + return null; + } finally { + await handle?.close().catch(() => undefined); + } + + const entry = { + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + ...(fileStat.ctimeMs != null && Number.isFinite(fileStat.ctimeMs) + ? { ctimeMs: fileStat.ctimeMs } + : {}), + inspectedLineCount, + headFingerprint: fingerprint.digest('hex'), + lines, + }; + this.setTeamAffinityHeadMetadataCacheEntry(filePath, entry); + return entry; + } + + private buildTeamAffinityFileCacheKey(filePath: string, normalizedTeam: string): string { + return `${normalizedTeam}\0${filePath}`; + } + + private setTeamAffinityFileCacheEntry(cacheKey: string, entry: TeamAffinityFileCacheEntry): void { + if ( + !this.teamAffinityFileCache.has(cacheKey) && + this.teamAffinityFileCache.size >= TEAM_AFFINITY_FILE_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.teamAffinityFileCache.keys().next().value; + if (oldestKey) { + this.teamAffinityFileCache.delete(oldestKey); + } + } + this.teamAffinityFileCache.set(cacheKey, entry); + } + + private setTeamAffinityHeadMetadataCacheEntry( + filePath: string, + entry: TeamAffinityHeadMetadataCacheEntry + ): void { + if ( + !this.teamAffinityHeadMetadataCache.has(filePath) && + this.teamAffinityHeadMetadataCache.size >= TEAM_AFFINITY_HEAD_METADATA_CACHE_MAX_ENTRIES + ) { + const oldestKey = this.teamAffinityHeadMetadataCache.keys().next().value; + if (oldestKey) { + this.teamAffinityHeadMetadataCache.delete(oldestKey); + } + } + this.teamAffinityHeadMetadataCache.set(filePath, entry); } } diff --git a/src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts b/src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts new file mode 100644 index 00000000..52ee6c34 --- /dev/null +++ b/src/main/services/team/cache/JsonTeamTranscriptAffinityIndexStore.ts @@ -0,0 +1,170 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { + normalizeTeamTranscriptAffinityIndex, + toTeamTranscriptAffinityIndex, +} from './teamTranscriptAffinityIndexSchema'; +import { + type PersistedTeamTranscriptAffinityEntry, + type PersistedTeamTranscriptAffinityIndex, + TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT, + TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + type TeamTranscriptAffinityIndexStore, +} from './teamTranscriptAffinityIndexTypes'; + +const logger = createLogger('Service:JsonTeamTranscriptAffinityIndexStore'); + +const READ_TIMEOUT_MS = 5_000; + +function encodeFileSegment(value: string): string { + return encodeURIComponent(value); +} + +function sortEntriesByFreshness( + entries: PersistedTeamTranscriptAffinityEntry[] +): PersistedTeamTranscriptAffinityEntry[] { + return [...entries].sort((left, right) => { + const rightWrittenAt = Date.parse(right.writtenAt); + const leftWrittenAt = Date.parse(left.writtenAt); + return rightWrittenAt - leftWrittenAt || right.fileName.localeCompare(left.fileName); + }); +} + +export class JsonTeamTranscriptAffinityIndexStore implements TeamTranscriptAffinityIndexStore { + private readonly writeChains = new Map>(); + + constructor(private readonly options: { maxEntriesPerProject?: number } = {}) {} + + private get maxEntriesPerProject(): number { + return Math.max( + 1, + this.options.maxEntriesPerProject ?? TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT + ); + } + + private filePath(teamName: string, projectId: string): string { + return path.join( + getTeamsBasePath(), + teamName, + 'cache', + 'transcript-affinity', + `${encodeFileSegment(projectId)}.json` + ); + } + + private writeChainKey(teamName: string, projectId: string): string { + return `${teamName}\0${projectId}`; + } + + private async readIndex( + teamName: string, + projectId: string + ): Promise { + const filePath = this.filePath(teamName, projectId); + let content: string; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS); + try { + content = await fs.readFile(filePath, { + encoding: 'utf8', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.debug(`Failed to read transcript affinity index ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + logger.debug(`Corrupted transcript affinity index ${filePath}: ${String(error)}`); + await fs.unlink(filePath).catch(() => undefined); + return null; + } + + const normalized = normalizeTeamTranscriptAffinityIndex(parsed); + if (!normalized || normalized.teamName !== teamName || normalized.projectId !== projectId) { + await fs.unlink(filePath).catch(() => undefined); + return null; + } + + return normalized; + } + + async loadProject( + teamName: string, + projectId: string + ): Promise { + return this.readIndex(teamName, projectId); + } + + async upsertProjectEntries(input: { + teamName: string; + projectId: string; + projectDir: string; + rootFileNames: ReadonlySet; + entries: readonly PersistedTeamTranscriptAffinityEntry[]; + }): Promise { + const chainKey = this.writeChainKey(input.teamName, input.projectId); + const write = async (): Promise => { + const current = await this.readIndex(input.teamName, input.projectId); + const entries = new Map(); + + for (const [fileName, entry] of Object.entries(current?.entries ?? {})) { + if (input.rootFileNames.has(fileName)) { + entries.set(fileName, entry); + } + } + + for (const entry of input.entries) { + if (input.rootFileNames.has(entry.fileName)) { + entries.set(entry.fileName, entry); + } + } + + const cappedEntries = sortEntriesByFreshness([...entries.values()]).slice( + 0, + this.maxEntriesPerProject + ); + const next = toTeamTranscriptAffinityIndex({ + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + teamName: input.teamName, + projectId: input.projectId, + projectDir: input.projectDir, + writtenAt: new Date().toISOString(), + entries: Object.fromEntries(cappedEntries.map((entry) => [entry.fileName, entry])), + }); + + await atomicWriteAsync( + this.filePath(input.teamName, input.projectId), + `${JSON.stringify(next, null, 2)}\n` + ); + }; + + const previous = this.writeChains.get(chainKey) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(write) + .finally(() => { + if (this.writeChains.get(chainKey) === next) { + this.writeChains.delete(chainKey); + } + }); + + this.writeChains.set(chainKey, next); + await next; + } +} diff --git a/src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts b/src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts new file mode 100644 index 00000000..61f3f0c6 --- /dev/null +++ b/src/main/services/team/cache/teamTranscriptAffinityIndexSchema.ts @@ -0,0 +1,162 @@ +import { + type PersistedTeamTranscriptAffinityEntry, + type PersistedTeamTranscriptAffinityIndex, + TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + type TeamTranscriptAffinityFileSignature, + type TeamTranscriptAffinityMatchSource, + type TeamTranscriptAffinityVerdict, +} from './teamTranscriptAffinityIndexTypes'; + +function isIsoString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value)); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isValidFileName(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > '.jsonl'.length && + value.endsWith('.jsonl') && + !value.includes('/') && + !value.includes('\\') + ); +} + +function sessionIdFromFileName(fileName: string): string { + return fileName.slice(0, -'.jsonl'.length); +} + +function normalizeVerdict(value: unknown): TeamTranscriptAffinityVerdict | null { + return value === 'belongs' || value === 'does_not_belong' ? value : null; +} + +function normalizeMatchSource(value: unknown): TeamTranscriptAffinityMatchSource | null { + return value === 'nested_team_name' || value === 'text_team_mention' || value === 'none' + ? value + : null; +} + +function normalizeSignature(value: unknown): TeamTranscriptAffinityFileSignature | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + if (!isFiniteNonNegativeNumber(raw.size) || !isFiniteNonNegativeNumber(raw.mtimeMs)) { + return null; + } + if (raw.ctimeMs != null && !isFiniteNonNegativeNumber(raw.ctimeMs)) { + return null; + } + + return { + size: raw.size, + mtimeMs: raw.mtimeMs, + ...(raw.ctimeMs != null ? { ctimeMs: raw.ctimeMs } : {}), + }; +} + +export function normalizeTeamTranscriptAffinityEntry( + fileName: string, + value: unknown +): PersistedTeamTranscriptAffinityEntry | null { + if (!isValidFileName(fileName) || !value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + const verdict = normalizeVerdict(raw.verdict); + const signature = normalizeSignature(raw.signature); + const matchSource = normalizeMatchSource(raw.matchSource); + const expectedSessionId = sessionIdFromFileName(fileName); + + if ( + raw.fileName !== fileName || + raw.sessionId !== expectedSessionId || + !signature || + !verdict || + typeof raw.headWindowFull !== 'boolean' || + !Number.isInteger(raw.inspectedLineCount) || + !isFiniteNonNegativeNumber(raw.inspectedLineCount) || + !matchSource || + !isIsoString(raw.writtenAt) + ) { + return null; + } + + return { + fileName, + sessionId: expectedSessionId, + signature, + verdict, + headWindowFull: raw.headWindowFull, + inspectedLineCount: raw.inspectedLineCount, + matchSource, + writtenAt: raw.writtenAt, + }; +} + +export function normalizeTeamTranscriptAffinityIndex( + value: unknown +): PersistedTeamTranscriptAffinityIndex | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + if ( + raw.version !== TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION || + typeof raw.teamName !== 'string' || + raw.teamName.length === 0 || + typeof raw.projectId !== 'string' || + raw.projectId.length === 0 || + typeof raw.projectDir !== 'string' || + raw.projectDir.length === 0 || + !isIsoString(raw.writtenAt) || + !raw.entries || + typeof raw.entries !== 'object' + ) { + return null; + } + + const entries: Record = {}; + for (const [fileName, entry] of Object.entries(raw.entries as Record)) { + const normalized = normalizeTeamTranscriptAffinityEntry(fileName, entry); + if (normalized) { + entries[fileName] = normalized; + } + } + + return { + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + teamName: raw.teamName, + projectId: raw.projectId, + projectDir: raw.projectDir, + writtenAt: raw.writtenAt, + entries, + }; +} + +export function toTeamTranscriptAffinityIndex( + value: PersistedTeamTranscriptAffinityIndex +): PersistedTeamTranscriptAffinityIndex { + const entries: Record = {}; + for (const [fileName, entry] of Object.entries(value.entries)) { + const normalized = normalizeTeamTranscriptAffinityEntry(fileName, entry); + if (normalized) { + entries[fileName] = normalized; + } + } + + return { + version: TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION, + teamName: value.teamName, + projectId: value.projectId, + projectDir: value.projectDir, + writtenAt: value.writtenAt, + entries, + }; +} diff --git a/src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts b/src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts new file mode 100644 index 00000000..2a80c505 --- /dev/null +++ b/src/main/services/team/cache/teamTranscriptAffinityIndexTypes.ts @@ -0,0 +1,47 @@ +export const TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION = 1; +export const TEAM_TRANSCRIPT_AFFINITY_INDEX_MAX_ENTRIES_PER_PROJECT = 20_000; + +export type TeamTranscriptAffinityVerdict = 'belongs' | 'does_not_belong'; + +export type TeamTranscriptAffinityMatchSource = 'nested_team_name' | 'text_team_mention' | 'none'; + +export interface TeamTranscriptAffinityFileSignature { + size: number; + mtimeMs: number; + ctimeMs?: number; +} + +export interface PersistedTeamTranscriptAffinityEntry { + fileName: string; + sessionId: string; + signature: TeamTranscriptAffinityFileSignature; + verdict: TeamTranscriptAffinityVerdict; + headWindowFull: boolean; + inspectedLineCount: number; + matchSource: TeamTranscriptAffinityMatchSource; + writtenAt: string; +} + +export interface PersistedTeamTranscriptAffinityIndex { + version: typeof TEAM_TRANSCRIPT_AFFINITY_INDEX_SCHEMA_VERSION; + teamName: string; + projectId: string; + projectDir: string; + writtenAt: string; + entries: Record; +} + +export interface TeamTranscriptAffinityIndexStore { + loadProject( + teamName: string, + projectId: string + ): Promise; + + upsertProjectEntries(input: { + teamName: string; + projectId: string; + projectDir: string; + rootFileNames: ReadonlySet; + entries: readonly PersistedTeamTranscriptAffinityEntry[]; + }): Promise; +} diff --git a/src/main/services/team/fileLock.ts b/src/main/services/team/fileLock.ts index 9152bbda..0a16f976 100644 --- a/src/main/services/team/fileLock.ts +++ b/src/main/services/team/fileLock.ts @@ -69,19 +69,58 @@ function removeLockPath(lockPath: string): void { } } +function writeLockFile(lockPath: string): void { + const fd = fs.openSync(lockPath, 'wx'); + let closeError: unknown = null; + try { + fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`); + } finally { + try { + fs.closeSync(fd); + } catch (err) { + closeError = err; + } + } + if (closeError) { + throw closeError instanceof Error ? closeError : new Error('Failed to close file lock'); + } +} + +function isExistingLockError(code: string | undefined): boolean { + return code === 'EEXIST' || code === 'EISDIR'; +} + function tryAcquire(lockPath: string, options: Required): boolean { try { - const dir = path.dirname(lockPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - const fd = fs.openSync(lockPath, 'wx'); - fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`); - fs.closeSync(fd); + // Fast path: assume the lock directory already exists (the common case once a + // team dir is created). This drops an existsSync(dir) stat from EVERY acquire, + // which adds up across the many lock cycles during a team launch. + writeLockFile(lockPath); return true; } catch (err) { const code = (err as NodeJS.ErrnoException).code; - if (code === 'EEXIST' || code === 'EISDIR') { + if (code === 'ENOENT') { + // Lock directory missing - create it lazily and acquire in the same call, so + // first-acquire latency in a fresh dir is unchanged. + try { + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + writeLockFile(lockPath); + return true; + } catch (retryError) { + const retryCode = (retryError as NodeJS.ErrnoException).code; + if (retryCode === 'ENOENT') { + return false; + } + if (isExistingLockError(retryCode)) { + if (shouldBreakExistingLock(lockPath, options.staleTimeoutMs)) { + removeLockPath(lockPath); + } + return false; + } + throw retryError; + } + } + if (isExistingLockError(code)) { if (shouldBreakExistingLock(lockPath, options.staleTimeoutMs)) { removeLockPath(lockPath); } diff --git a/src/main/services/team/leadSessionMessageExtractor.ts b/src/main/services/team/leadSessionMessageExtractor.ts index 0124f395..e90f52f6 100644 --- a/src/main/services/team/leadSessionMessageExtractor.ts +++ b/src/main/services/team/leadSessionMessageExtractor.ts @@ -9,13 +9,13 @@ import type { ParsedMessage } from '@main/types'; import type { CommandOutputMeta, InboxMessage, SlashCommandMeta } from '@shared/types'; const MAX_SCAN_BYTES = 8 * 1024 * 1024; -const INITIAL_SCAN_BYTES = 256 * 1024; interface LeadSessionMessageExtractorOptions { jsonlPath: string; leadName: string; leadSessionId: string; maxMessages: number; + rawLines?: readonly string[]; } function getMessageText(message: ParsedMessage): string { @@ -98,19 +98,41 @@ export async function extractLeadSessionMessagesFromJsonl({ leadName, leadSessionId, maxMessages, + rawLines, }: LeadSessionMessageExtractorOptions): Promise { if (maxMessages <= 0) return []; const parsedMessagesReversed: ParsedMessage[] = []; const seenScanKeys = new Set(); - const handle = await fs.promises.open(jsonlPath, 'r'); + const collectLine = (rawLine: string | undefined): void => { + const trimmed = rawLine?.trim(); + if (!trimmed) return; - try { - const stat = await handle.stat(); - const fileSize = stat.size; + let parsed: ParsedMessage | null = null; + try { + parsed = parseJsonlLine(trimmed); + } catch { + parsed = null; + } + if (!parsed || parsed.isSidechain) return; - let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); - while (scanBytes <= MAX_SCAN_BYTES) { + const scanKey = buildScanKey(parsed, trimmed); + if (seenScanKeys.has(scanKey)) return; + seenScanKeys.add(scanKey); + parsedMessagesReversed.push(parsed); + }; + + if (rawLines) { + for (let i = rawLines.length - 1; i >= 0; i--) { + collectLine(rawLines[i]); + } + } else { + const handle = await fs.promises.open(jsonlPath, 'r'); + + try { + const stat = await handle.stat(); + const fileSize = stat.size; + const scanBytes = Math.min(MAX_SCAN_BYTES, fileSize); const start = Math.max(0, fileSize - scanBytes); const buffer = Buffer.alloc(scanBytes); await handle.read(buffer, 0, scanBytes, start); @@ -120,28 +142,11 @@ export async function extractLeadSessionMessagesFromJsonl({ const fromIndex = start > 0 ? 1 : 0; for (let i = lines.length - 1; i >= fromIndex; i--) { - const trimmed = lines[i]?.trim(); - if (!trimmed) continue; - - let parsed: ParsedMessage | null = null; - try { - parsed = parseJsonlLine(trimmed); - } catch { - parsed = null; - } - if (!parsed || parsed.isSidechain) continue; - - const scanKey = buildScanKey(parsed, trimmed); - if (seenScanKeys.has(scanKey)) continue; - seenScanKeys.add(scanKey); - parsedMessagesReversed.push(parsed); + collectLine(lines[i]); } - - if (scanBytes === fileSize) break; - scanBytes = Math.min(fileSize, scanBytes * 2); + } finally { + await handle.close(); } - } finally { - await handle.close(); } const parsedMessages = parsedMessagesReversed.reverse(); diff --git a/src/main/services/team/mergeLiveLeadProcessMessages.ts b/src/main/services/team/mergeLiveLeadProcessMessages.ts index 5b613caf..b72083f3 100644 --- a/src/main/services/team/mergeLiveLeadProcessMessages.ts +++ b/src/main/services/team/mergeLiveLeadProcessMessages.ts @@ -1,4 +1,4 @@ -import type { InboxMessage } from '@shared/types'; +import type { InboxMessage, MessagesPage } from '@shared/types'; export function getLiveLeadProcessMessageKey(message: { messageId?: string; @@ -71,3 +71,65 @@ export function mergeLiveLeadProcessMessages( merged.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)); return merged; } + +export function mergeLiveLeadProcessMessagesPage(input: { + durableMessages: InboxMessage[]; + liveMessages: InboxMessage[]; + limit: number; + feedRevision: string; + durableHasMoreAfterWindow?: boolean; +}): MessagesPage { + const displayMessages = mergeLiveLeadProcessMessages( + input.durableMessages, + input.liveMessages + ).slice(0, input.limit); + + if (displayMessages.length === 0) { + return { + messages: displayMessages, + nextCursor: null, + hasMore: false, + feedRevision: input.feedRevision, + }; + } + + const durableMessageIndexByKey = new Map( + input.durableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index]) + ); + let lastDurableDisplayed: InboxMessage | null = null; + for (let index = displayMessages.length - 1; index >= 0; index -= 1) { + const candidate = displayMessages[index]; + if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) { + lastDurableDisplayed = candidate; + break; + } + } + + if (!lastDurableDisplayed) { + const boundary = displayMessages[displayMessages.length - 1]; + return { + messages: displayMessages, + nextCursor: + input.durableMessages.length > 0 || input.durableHasMoreAfterWindow + ? `${boundary.timestamp}|${boundary.messageId ?? ''}` + : null, + hasMore: input.durableMessages.length > 0 || Boolean(input.durableHasMoreAfterWindow), + feedRevision: input.feedRevision, + }; + } + + const durableIndex = + durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ?? + Number.POSITIVE_INFINITY; + const durableHasMore = + durableIndex < input.durableMessages.length - 1 || Boolean(input.durableHasMoreAfterWindow); + + return { + messages: displayMessages, + nextCursor: durableHasMore + ? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}` + : null, + hasMore: durableHasMore, + feedRevision: input.feedRevision, + }; +} diff --git a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts index 70ff30b4..cdb2b565 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup.ts @@ -60,10 +60,12 @@ export async function cleanupManagedOpenCodeServeProcesses( diagnostics: [], }; - const rows = await ( + const listProcessRows = options.listProcessRows ?? - (platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessTableForCurrentPlatform) - )(); + (platform === 'win32' + ? listWindowsProcessTable + : () => listRuntimeProcessTableForCurrentPlatform({ bypassCache: true })); + const rows = await listProcessRows(); const excludePids = options.excludePids ?? new Set(); const requiredDetailsMarkers = options.requiredDetailsMarkers ?? []; const readDetails = diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 39376c22..1f501ae1 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -976,6 +976,7 @@ function isOpenCodeSessionRefreshScheduledReason(message: string | null | undefi normalized === 'opencode_prompt_delivery_session_refresh_scheduled' || normalized === 'opencode session refresh scheduled after resolved behavior changed' || normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' || + normalized === 'opencode_session_stale_observe_scheduled_after_accepted_prompt' || normalized === 'opencode session changed; refreshing the session before retry' ); } diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts index 6458b8ac..10f0a0f9 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -61,6 +61,7 @@ function isOpenCodeRuntimeDeliverySessionRefreshScheduledDiagnostic(message: str normalized === 'opencode_prompt_delivery_session_refresh_scheduled' || normalized === 'opencode session refresh scheduled after resolved behavior changed' || normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' || + normalized === 'opencode_session_stale_observe_scheduled_after_accepted_prompt' || normalized === 'opencode session changed; refreshing the session before retry' ); } diff --git a/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts index 4bc8c574..a700ef7a 100644 --- a/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts +++ b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts @@ -265,9 +265,13 @@ export function isBootstrapTranscriptSuccessText( export function getBootstrapTranscriptSuccessSource( text: string, teamName: string, - memberName: string + memberName: string, + // Optional pre-normalized text, MUST equal text.replace(/\s+/g,' ').trim().toLowerCase(). + // Lets callers that scan one line against many members normalize it once. + precomputedNormalizedText?: string ): BootstrapTranscriptSuccessSource | null { - const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); + const normalizedText = + precomputedNormalizedText ?? text.replace(/\s+/g, ' ').trim().toLowerCase(); if (!normalizedText) { return null; } @@ -278,6 +282,22 @@ export function getBootstrapTranscriptSuccessSource( return null; } + return getBootstrapTranscriptSuccessSourceFromNormalized( + normalizedText, + normalizedTeamName, + normalizedMemberName + ); +} + +export function getBootstrapTranscriptSuccessSourceFromNormalized( + normalizedText: string, + normalizedTeamName: string, + normalizedMemberName: string +): BootstrapTranscriptSuccessSource | null { + if (!normalizedText || !normalizedTeamName || !normalizedMemberName) { + return null; + } + if ( normalizedText.startsWith( `member briefing for ${normalizedMemberName} on team "${normalizedTeamName}" (${normalizedTeamName}).` @@ -298,9 +318,13 @@ export function getBootstrapTranscriptSuccessSource( export function isBootstrapTranscriptContextText( text: string, teamName: string, - memberName: string + memberName: string, + // Optional pre-normalized text, MUST equal text.replace(/\s+/g,' ').trim().toLowerCase(). + // Lets callers that scan one line against many members normalize it once. + precomputedNormalizedText?: string ): boolean { - const normalizedText = text.replace(/\s+/g, ' ').trim().toLowerCase(); + const normalizedText = + precomputedNormalizedText ?? text.replace(/\s+/g, ' ').trim().toLowerCase(); const normalizedTeamName = teamName.trim().toLowerCase(); const normalizedMemberName = memberName.trim().toLowerCase(); if (!normalizedText || !normalizedTeamName || !normalizedMemberName) { diff --git a/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts b/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts index 930faeec..4a1c43a8 100644 --- a/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts +++ b/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts @@ -49,7 +49,8 @@ function isCleanOpenCodeSessionRefreshDiagnostic(message: string): boolean { refreshMarkerText === 'opencode session changed; refreshing the session before retry' || refreshMarkerText === 'opencode session refresh scheduled after resolved behavior changed' || refreshMarkerText === 'opencode_prompt_delivery_session_refresh_scheduled' || - refreshMarkerText === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' + refreshMarkerText === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' || + refreshMarkerText === 'opencode_session_stale_observe_scheduled_after_accepted_prompt' ) { return true; } diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 3c8976b1..ee88bfed 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -78,6 +78,9 @@ const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; const STREAM_LAYOUT_CACHE_TTL_MS = 1_000; const STREAM_LAYOUT_BUILD_WARN_MS = 3_000; const RUNTIME_FALLBACK_WARN_MS = 3_000; +const OPENCODE_RUNTIME_FALLBACK_HIT_CACHE_TTL_MS = 1_000; +const OPENCODE_RUNTIME_FALLBACK_MISS_CACHE_TTL_MS = 3_000; +const OPENCODE_RUNTIME_FALLBACK_CACHE_MAX_ENTRIES = 256; const INFERRED_CANDIDATE_SELECTION_WARN_COUNT = 100; const HISTORICAL_RAW_PROBE_WARN_MS = 3_000; const HISTORICAL_RAW_PROBE_WARN_FILE_COUNT = 500; @@ -1658,6 +1661,19 @@ export class BoardTaskLogStreamService { } >(); + private readonly openCodeRuntimeFallbackCache = new Map< + string, + { + expiresAt: number; + response: BoardTaskLogStreamResponse | null; + } + >(); + + private readonly openCodeRuntimeFallbackInFlight = new Map< + string, + Promise + >(); + constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), @@ -1684,6 +1700,26 @@ export class BoardTaskLogStreamService { return `${teamName}::${taskId}`; } + private buildOpenCodeRuntimeFallbackCacheKey(teamName: string, taskId: string): string { + return `${teamName}::${taskId}`; + } + + private pruneOpenCodeRuntimeFallbackCache(nowMs: number): void { + for (const [cacheKey, cached] of this.openCodeRuntimeFallbackCache) { + if (cached.expiresAt <= nowMs) { + this.openCodeRuntimeFallbackCache.delete(cacheKey); + } + } + + while (this.openCodeRuntimeFallbackCache.size > OPENCODE_RUNTIME_FALLBACK_CACHE_MAX_ENTRIES) { + const oldestKey = this.openCodeRuntimeFallbackCache.keys().next().value; + if (oldestKey == null) { + break; + } + this.openCodeRuntimeFallbackCache.delete(oldestKey); + } + } + private getTranscriptDiscoveryGeneration(teamName: string): number { const locator = this.transcriptSourceLocator as { getGeneration?: (teamName: string) => number; @@ -2237,17 +2273,49 @@ export class BoardTaskLogStreamService { teamName: string, taskId: string ): Promise { - const startedAt = Date.now(); - const fallback = await this.openCodeRuntimeFallbackSource.getTaskLogStream(teamName, taskId); - const elapsedMs = Date.now() - startedAt; - if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { - logger.warn( - `Slow task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( - fallback - )} elapsedMs=${elapsedMs}` - ); + const cacheKey = this.buildOpenCodeRuntimeFallbackCacheKey(teamName, taskId); + const nowMs = Date.now(); + const cached = this.openCodeRuntimeFallbackCache.get(cacheKey); + if (cached && cached.expiresAt > nowMs) { + return cached.response; } - return fallback; + + const existingInFlight = this.openCodeRuntimeFallbackInFlight.get(cacheKey); + if (existingInFlight) { + return existingInFlight; + } + + const startedAt = Date.now(); + const request = this.openCodeRuntimeFallbackSource + .getTaskLogStream(teamName, taskId) + .then((fallback) => { + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { + logger.warn( + `Slow task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( + fallback + )} elapsedMs=${elapsedMs}` + ); + } + + const cacheTtlMs = fallback + ? OPENCODE_RUNTIME_FALLBACK_HIT_CACHE_TTL_MS + : OPENCODE_RUNTIME_FALLBACK_MISS_CACHE_TTL_MS; + this.openCodeRuntimeFallbackCache.set(cacheKey, { + expiresAt: Date.now() + cacheTtlMs, + response: fallback, + }); + this.pruneOpenCodeRuntimeFallbackCache(Date.now()); + return fallback; + }) + .finally(() => { + if (this.openCodeRuntimeFallbackInFlight.get(cacheKey) === request) { + this.openCodeRuntimeFallbackInFlight.delete(cacheKey); + } + }); + + this.openCodeRuntimeFallbackInFlight.set(cacheKey, request); + return request; } private async loadCodexNativeTraceFallback( diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index 5ef127df..7438730f 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -3,6 +3,7 @@ */ import type { + InboxMessage, MemberLogSummary, MessagesPage, TeamGetDataOptions, @@ -22,6 +23,7 @@ export interface GetMessagesPagePayload { options: { cursor?: string | null; limit: number; + liveMessages?: InboxMessage[]; }; } diff --git a/src/main/utils/jsonlLineReader.ts b/src/main/utils/jsonlLineReader.ts new file mode 100644 index 00000000..15321a7d --- /dev/null +++ b/src/main/utils/jsonlLineReader.ts @@ -0,0 +1,47 @@ +import { createReadStream } from 'fs'; + +/** + * Async generator that yields the lines of a JSONL file using a chunked stream read + * plus a plain `\n` split, as a drop-in replacement for + * `for await (const line of readline.createInterface({ input, crlfDelay: Infinity }))`. + * + * readline runs an expensive Unicode line-break regex (`\r?\n | \r | U+2028 | U+2029`) + * and extra stream/string-decoder machinery on every chunk. JSONL is strictly + * newline-delimited, so a plain `\n` split is cheaper and more correct here: it will + * not split on a bare `\r` or a Unicode line/paragraph separator that appears *inside* + * a JSON string value, which readline would. + * + * The stream is opened with utf8 encoding, so the runtime's StringDecoder reassembles + * multi-byte characters that straddle a chunk boundary before we split - string + * concatenation + `indexOf('\n')` is therefore safe. + * + * Semantics match the readline loop the callers replace: + * - every line is yielded IN ORDER, INCLUDING empty lines (so callers tracking a + * 1-based line number stay correct); + * - a trailing `\r` (from a CRLF ending) is stripped, exactly as readline does; + * - a final line with no trailing newline is still yielded; + * - breaking/returning out of the `for await` destroys the underlying stream via the + * generator's `finally`. + */ +export async function* readJsonlLines(filePath: string): AsyncGenerator { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + let pending = ''; + try { + for await (const chunk of stream) { + pending += chunk as string; + let newlineIndex = pending.indexOf('\n'); + while (newlineIndex !== -1) { + const line = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + yield line.endsWith('\r') ? line.slice(0, -1) : line; + newlineIndex = pending.indexOf('\n'); + } + } + // Honor a final line that has no trailing newline (readline yields it too). + if (pending.length > 0) { + yield pending.endsWith('\r') ? pending.slice(0, -1) : pending; + } + } finally { + stream.destroy(); + } +} diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 60362205..638a3965 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -352,6 +352,14 @@ function cloneCached(value: T): T { : (JSON.parse(JSON.stringify(value)) as T); } +function dateFromFingerprintMs(ms: unknown): Date | null { + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) { + return null; + } + const date = new Date(ms); + return Number.isFinite(date.getTime()) ? date : null; +} + async function statPathFingerprint(filePath: string): Promise { try { const stat = await fs.promises.stat(filePath, { bigint: true }); @@ -637,7 +645,7 @@ function applyCachedTaskReadResult( bumpSkipReason(taskDiag.skipReasons, cached.skipReason); return; } - tasks.push(cloneCached(cached.task)); + tasks.push(cached.task); } function pruneTaskFileCache( @@ -952,7 +960,7 @@ async function listTeams( if (dependencyFingerprint.cacheSafe && cached?.fingerprint === dependencyFingerprint.value) { cached.lastUsedAt = nowMs(); diag.cacheHits++; - return cloneCached(cached.summary); + return cached.summary; } diag.cacheMisses++; @@ -1460,7 +1468,6 @@ async function readTasksDirForTeam( } taskDiag.cacheMisses++; - const stat = await fs.promises.stat(taskPath); const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs); const parsed = JSON.parse(raw) as ParsedTask; const metadata = parsed.metadata; @@ -1504,11 +1511,12 @@ async function readTasksDirForTeam( typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined; let updatedAt: string | undefined; try { + const birthtime = dateFromFingerprintMs(pathFingerprint.birthtimeMs); + const mtime = dateFromFingerprintMs(pathFingerprint.mtimeMs); if (!createdAt) { - const bt = stat.birthtime.getTime(); - createdAt = (bt > 0 ? stat.birthtime : stat.mtime).toISOString(); + createdAt = (birthtime ?? mtime)?.toISOString(); } - updatedAt = stat.mtime.toISOString(); + updatedAt = mtime?.toISOString(); } catch { /* ignore */ } @@ -1559,7 +1567,7 @@ async function readTasksDirForTeam( status, workIntervals: normalizeWorkIntervals(parsed), reviewIntervals: normalizeReviewIntervals(parsed), - historyEvents: normalizeHistoryEvents(parsed), + historyEvents, blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined, related: Array.isArray(parsed.related) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a3089b46..f22f23a7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -9,7 +9,7 @@ import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; import { type SplashSceneHandle, startSplashScene } from './components/splash/splashScene'; import { ToolApprovalSheet } from './components/team/ToolApprovalSheet'; -import { useTheme } from './hooks/useTheme'; +import { useThemeController } from './hooks/useTheme'; import { api } from './api'; import { useStore } from './store'; @@ -33,7 +33,7 @@ const SPLASH_REDUCED_AVATAR_READY_MAX_WAIT_MS = 160; export const App = (): React.JSX.Element => { // Initialize theme on app load - useTheme(); + useThemeController(); const appConfig = useStore((s) => s.appConfig); // Upgrade the static preload splash, then dismiss it after the scene is visible. diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index 5069b98c..bc7ac872 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -68,12 +68,12 @@ export const MoreMenu = ({ openTeamsTab, } = useStore( useShallow((s) => ({ - openCommandPalette: () => s.openCommandPalette(), - openExtensionsTab: () => s.openExtensionsTab(), - openSessionReport: (tabId: string) => s.openSessionReport(tabId), - openSchedulesTab: () => s.openSchedulesTab(), - openSettingsTab: () => s.openSettingsTab(), - openTeamsTab: () => s.openTeamsTab(), + openCommandPalette: s.openCommandPalette, + openExtensionsTab: s.openExtensionsTab, + openSessionReport: s.openSessionReport, + openSchedulesTab: s.openSchedulesTab, + openSettingsTab: s.openSettingsTab, + openTeamsTab: s.openTeamsTab, })) ); diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 4ab940d5..aa077f90 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -86,6 +86,7 @@ const PaneLazyFallback = (): React.JSX.Element => { const PaneTabSlot = ({ tab, isActive, isPaneFocused }: PaneTabSlotProps): React.JSX.Element => { const [hasActivated, setHasActivated] = useState(isActive); + const shouldRenderContent = hasActivated && (tab.type !== 'teams' || isActive); useEffect(() => { if (isActive) { @@ -95,7 +96,7 @@ const PaneTabSlot = ({ tab, isActive, isPaneFocused }: PaneTabSlotProps): React. return (
- {hasActivated && ( + {shouldRenderContent && ( }> {tab.type === 'dashboard' && } {tab.type === 'notifications' && } diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index c125fae9..f4b5767f 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -6,10 +6,12 @@ import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups'; import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState'; +import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { markTaskUnread } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { projectColor } from '@renderer/utils/projectColor'; import { @@ -58,7 +60,7 @@ import { } from './taskFiltersState'; import type { TaskFiltersState } from './taskFiltersState'; -import type { GlobalTask, TeamSummary } from '@shared/types'; +import type { GlobalTask, LeadActivityState, TeamSummary } from '@shared/types'; const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping'; @@ -161,6 +163,17 @@ const dateCategoryLabels: Record = { Older: 'Earlier', }; +type ProjectTaskGroupData = ReturnType[number]; +const EMPTY_TASKS: GlobalTask[] = []; +const EMPTY_PROJECT_GROUPS: ProjectTaskGroupData[] = []; +const EMPTY_DATE_GROUPS: ReturnType = { + Today: [], + Yesterday: [], + 'Previous 7 Days': [], + Older: [], +}; +const EMPTY_DATE_CATEGORIES: ReturnType = []; + function applySearch(tasks: GlobalTask[], query: string): GlobalTask[] { if (!query.trim()) return tasks; const q = query.toLowerCase(); @@ -190,6 +203,749 @@ function buildTaskTeamSummary(task: GlobalTask): TeamSummary { }; } +function buildTaskLocalPresentationKey(task: GlobalTask): string { + return `${task.teamName}:${task.id}`; +} + +function buildTaskLocalPresentationState( + task: GlobalTask, + pinnedIds: ReadonlySet, + archivedIds: ReadonlySet, + renamedSubjects: ReadonlyMap +): TaskLocalPresentationState { + const key = buildTaskLocalPresentationKey(task); + return { + key, + pinned: pinnedIds.has(key), + archived: archivedIds.has(key), + renamedSubject: renamedSubjects.get(key), + }; +} + +function buildTaskLocalPresentationByTask( + tasks: readonly GlobalTask[], + pinnedIds: ReadonlySet, + archivedIds: ReadonlySet, + renamedSubjects: ReadonlyMap +): WeakMap { + const presentationByTask = new WeakMap(); + for (const task of tasks) { + presentationByTask.set( + task, + buildTaskLocalPresentationState(task, pinnedIds, archivedIds, renamedSubjects) + ); + } + return presentationByTask; +} + +type TaskRowAction = (teamName: string, taskId: string) => void; +type TaskRowDeleteAction = (teamName: string, taskId: string) => void | Promise; +type TeamBooleanResolver = (teamName: string) => boolean; +type TaskOwnerColorResolver = (task: GlobalTask) => string | null | undefined; +type TeamHeaderFormatter = (teamDisplayName: string) => string; +type ProjectGroupVisibleCountChange = (projectKey: string, visibleCount: number) => void; +type TeamMemberColorInput = Parameters[0][number]; + +interface TaskLocalPresentationState { + key: string; + pinned: boolean; + archived: boolean; + renamedSubject: string | undefined; +} + +type TaskLocalPresentationResolver = (task: GlobalTask) => TaskLocalPresentationState; +interface SidebarTeamsDerived { + identityKey: string; + filterTeams: { teamName: string; displayName: string }[]; + statusSummaries: TeamSummary[]; + memberColorByTeam: Map>; +} + +let cachedSidebarTeamsSignature: string | null = null; +let cachedSidebarTeamsSource: readonly TeamSummary[] | null = null; +let cachedSidebarTeamsDerived: SidebarTeamsDerived = { + identityKey: '', + filterTeams: [], + statusSummaries: [], + memberColorByTeam: new Map(), +}; +const cachedSidebarTeamSignatureByTeam = new WeakMap(); +let cachedLeadOfflineTeamsSource: Partial> | null = null; +let cachedLeadOfflineTeamsSignature = ''; +let cachedLeadOfflineTeamNames: string[] = []; + +function encodeSignaturePart(part: unknown): string { + const text = part == null ? '' : String(part); + return `${text.length}:${text}|`; +} + +function pushSignaturePart(parts: string[], part: unknown): void { + parts.push(encodeSignaturePart(part)); +} + +function getSidebarTeamSignature(team: TeamSummary): string { + const cached = cachedSidebarTeamSignatureByTeam.get(team); + if (cached !== undefined) return cached; + + let signature = ''; + signature += encodeSignaturePart(team.teamName); + signature += encodeSignaturePart(team.displayName); + signature += encodeSignaturePart(team.projectPath); + signature += encodeSignaturePart(team.lastActivity); + signature += encodeSignaturePart(team.partialLaunchFailure ? 1 : 0); + signature += encodeSignaturePart(team.teamLaunchState); + for (const member of team.members ?? []) { + const colorMember = member as TeamMemberColorInput; + signature += encodeSignaturePart(colorMember.name); + signature += encodeSignaturePart(colorMember.color); + signature += encodeSignaturePart(colorMember.agentType); + signature += encodeSignaturePart(colorMember.removedAt); + } + + cachedSidebarTeamSignatureByTeam.set(team, signature); + return signature; +} + +function buildSidebarTeamsSignature(teams: readonly TeamSummary[]): string { + let signature = ''; + for (const team of teams) { + signature += getSidebarTeamSignature(team); + } + return signature; +} + +function buildTeamNamesIdentityKey(teams: readonly TeamSummary[]): string { + const signatureParts: string[] = []; + for (const team of teams) { + pushSignaturePart(signatureParts, team.teamName); + } + return signatureParts.join(''); +} + +function selectLeadOfflineTeamNames( + leadActivityByTeam: Partial> +): string[] { + if (leadActivityByTeam === cachedLeadOfflineTeamsSource) { + return cachedLeadOfflineTeamNames; + } + + const offlineTeamNames: string[] = []; + for (const [teamName, activity] of Object.entries(leadActivityByTeam)) { + if (activity === 'offline') { + offlineTeamNames.push(teamName); + } + } + offlineTeamNames.sort(); + + const signatureParts: string[] = []; + for (const teamName of offlineTeamNames) { + pushSignaturePart(signatureParts, teamName); + } + const signature = signatureParts.join(''); + + if (signature === cachedLeadOfflineTeamsSignature) { + cachedLeadOfflineTeamsSource = leadActivityByTeam; + return cachedLeadOfflineTeamNames; + } + + cachedLeadOfflineTeamsSource = leadActivityByTeam; + cachedLeadOfflineTeamsSignature = signature; + cachedLeadOfflineTeamNames = offlineTeamNames; + return cachedLeadOfflineTeamNames; +} + +function selectSidebarTeamsDerived(teams: readonly TeamSummary[]): SidebarTeamsDerived { + if (teams === cachedSidebarTeamsSource) { + return cachedSidebarTeamsDerived; + } + + const signature = buildSidebarTeamsSignature(teams); + if (signature === cachedSidebarTeamsSignature) { + cachedSidebarTeamsSource = teams; + return cachedSidebarTeamsDerived; + } + + const memberColorByTeam = new Map>(); + for (const team of teams) { + if (team.members && team.members.length > 0) { + memberColorByTeam.set(team.teamName, buildMemberColorMap(team.members)); + } + } + + cachedSidebarTeamsSource = teams; + cachedSidebarTeamsSignature = signature; + cachedSidebarTeamsDerived = { + identityKey: buildTeamNamesIdentityKey(teams), + filterTeams: teams.map((team) => ({ + teamName: team.teamName, + displayName: team.displayName, + })), + statusSummaries: teams.map((team) => ({ + teamName: team.teamName, + displayName: team.displayName, + description: '', + memberCount: team.memberCount, + taskCount: team.taskCount, + projectPath: team.projectPath, + lastActivity: team.lastActivity, + partialLaunchFailure: team.partialLaunchFailure, + teamLaunchState: team.teamLaunchState, + })), + memberColorByTeam, + }; + return cachedSidebarTeamsDerived; +} + +interface GlobalTaskRowProps { + task: GlobalTask; + taskLocalKey: string; + isPinned: boolean; + isArchived: boolean; + isNew: boolean; + teamOffline: boolean; + renamingKey: string | null; + hideTeamName?: boolean; + hideProjectName?: boolean; + showTeamName?: boolean; + isLight: boolean; + onTogglePin: TaskRowAction; + onToggleArchive: TaskRowAction; + onMarkUnread: TaskRowAction; + onRename: TaskRowAction; + onDelete: TaskRowDeleteAction; + onRenameComplete: (teamName: string, taskId: string, newSubject: string) => void; + onRenameCancel: () => void; + displaySubjectOverride?: string; + ownerColorName?: string | null; +} + +function taskCommentsDisplayEqual( + prev: GlobalTask['comments'], + next: GlobalTask['comments'] +): boolean { + if (prev === next) return true; + if (!prev || !next) return (prev?.length ?? 0) === (next?.length ?? 0); + if (prev.length !== next.length) return false; + for (let i = 0; i < prev.length; i += 1) { + if (prev[i].id !== next[i].id || prev[i].createdAt !== next[i].createdAt) { + return false; + } + } + return true; +} + +function taskSidebarFieldsEqual(prev: GlobalTask, next: GlobalTask): boolean { + return ( + prev === next || + (prev.id === next.id && + prev.teamName === next.teamName && + prev.teamDisplayName === next.teamDisplayName && + prev.teamDeleted === next.teamDeleted && + prev.subject === next.subject && + prev.owner === next.owner && + prev.status === next.status && + prev.createdAt === next.createdAt && + prev.updatedAt === next.updatedAt && + prev.projectPath === next.projectPath && + prev.reviewState === next.reviewState && + prev.kanbanColumn === next.kanbanColumn && + prev.deletedAt === next.deletedAt && + taskCommentsDisplayEqual(prev.comments, next.comments)) + ); +} + +function effectiveRenamingKey(taskLocalKey: string, renamingKey: string | null): string | null { + return renamingKey === taskLocalKey ? renamingKey : null; +} + +const GlobalTaskRow = memo( + function GlobalTaskRow({ + task, + taskLocalKey, + isPinned, + isArchived, + isNew, + teamOffline, + renamingKey, + hideTeamName, + hideProjectName, + showTeamName, + isLight, + onTogglePin, + onToggleArchive, + onMarkUnread, + onRename, + onDelete, + onRenameComplete, + onRenameCancel, + displaySubjectOverride, + ownerColorName, + }: GlobalTaskRowProps): React.JSX.Element { + const rowRenamingKey = effectiveRenamingKey(taskLocalKey, renamingKey); + + const handleTogglePin = useCallback(() => { + onTogglePin(task.teamName, task.id); + }, [onTogglePin, task.id, task.teamName]); + + const handleToggleArchive = useCallback(() => { + onToggleArchive(task.teamName, task.id); + }, [onToggleArchive, task.id, task.teamName]); + + const handleMarkUnread = useCallback(() => { + onMarkUnread(task.teamName, task.id); + }, [onMarkUnread, task.id, task.teamName]); + + const handleRename = useCallback(() => { + onRename(task.teamName, task.id); + }, [onRename, task.id, task.teamName]); + + const handleDelete = useCallback(() => { + void onDelete(task.teamName, task.id); + }, [onDelete, task.id, task.teamName]); + + return ( + + + + + + ); + }, + (prev, next) => + taskSidebarFieldsEqual(prev.task, next.task) && + prev.taskLocalKey === next.taskLocalKey && + prev.isPinned === next.isPinned && + prev.isArchived === next.isArchived && + prev.isNew === next.isNew && + prev.teamOffline === next.teamOffline && + effectiveRenamingKey(prev.taskLocalKey, prev.renamingKey) === + effectiveRenamingKey(next.taskLocalKey, next.renamingKey) && + prev.hideTeamName === next.hideTeamName && + prev.hideProjectName === next.hideProjectName && + prev.showTeamName === next.showTeamName && + prev.isLight === next.isLight && + prev.onTogglePin === next.onTogglePin && + prev.onToggleArchive === next.onToggleArchive && + prev.onMarkUnread === next.onMarkUnread && + prev.onRename === next.onRename && + prev.onDelete === next.onDelete && + prev.onRenameComplete === next.onRenameComplete && + prev.onRenameCancel === next.onRenameCancel && + prev.displaySubjectOverride === next.displaySubjectOverride && + prev.ownerColorName === next.ownerColorName +); + +interface TaskRowsProps { + tasks: GlobalTask[]; + visibleCount?: number; + keyPrefix?: string; + getTaskLocalPresentation: TaskLocalPresentationResolver; + isNewTask: (task: GlobalTask) => boolean; + isTeamOffline: TeamBooleanResolver; + renamingKey: string | null; + hideTeamName?: boolean; + hideProjectName?: boolean; + showTeamName?: boolean; + isLight: boolean; + showTeamHeader?: boolean; + pinnedOverride?: boolean; + archivedOverride?: boolean; + formatTeamHeader?: TeamHeaderFormatter; + onTogglePin: TaskRowAction; + onToggleArchive: TaskRowAction; + onMarkUnread: TaskRowAction; + onRename: TaskRowAction; + onDelete: TaskRowDeleteAction; + onRenameComplete: (teamName: string, taskId: string, newSubject: string) => void; + onRenameCancel: () => void; + getOwnerColorName: TaskOwnerColorResolver; +} + +type TaskRowsDerivedProps = Pick< + TaskRowsProps, + | 'tasks' + | 'visibleCount' + | 'getTaskLocalPresentation' + | 'isNewTask' + | 'isTeamOffline' + | 'pinnedOverride' + | 'archivedOverride' + | 'getOwnerColorName' +>; + +function getTaskRowsVisibleTasks( + props: Pick +): GlobalTask[] { + return typeof props.visibleCount === 'number' + ? props.tasks.slice(0, props.visibleCount) + : props.tasks; +} + +function areTaskRowsDerivedValuesEqual( + prev: TaskRowsDerivedProps, + next: TaskRowsDerivedProps +): boolean { + const prevVisibleTasks = getTaskRowsVisibleTasks(prev); + const nextVisibleTasks = getTaskRowsVisibleTasks(next); + if (!areTaskSidebarArraysEqual(prevVisibleTasks, nextVisibleTasks)) { + return false; + } + + for (let index = 0; index < prevVisibleTasks.length; index += 1) { + const prevTask = prevVisibleTasks[index]; + const nextTask = nextVisibleTasks[index]; + if (!prevTask || !nextTask) { + return false; + } + const prevLocalPresentation = prev.getTaskLocalPresentation(prevTask); + const nextLocalPresentation = next.getTaskLocalPresentation(nextTask); + if ( + (prev.pinnedOverride ?? prevLocalPresentation.pinned) !== + (next.pinnedOverride ?? nextLocalPresentation.pinned) || + (prev.archivedOverride ?? prevLocalPresentation.archived) !== + (next.archivedOverride ?? nextLocalPresentation.archived) || + prev.isNewTask(prevTask) !== next.isNewTask(nextTask) || + prev.isTeamOffline(prevTask.teamName) !== next.isTeamOffline(nextTask.teamName) || + prevLocalPresentation.renamedSubject !== nextLocalPresentation.renamedSubject || + prev.getOwnerColorName(prevTask) !== next.getOwnerColorName(nextTask) + ) { + return false; + } + } + + return true; +} + +const TaskRows = memo(function TaskRows({ + tasks, + visibleCount, + keyPrefix = '', + getTaskLocalPresentation, + isNewTask, + isTeamOffline, + renamingKey, + hideTeamName, + hideProjectName, + showTeamName, + isLight, + showTeamHeader, + pinnedOverride, + archivedOverride, + formatTeamHeader, + onTogglePin, + onToggleArchive, + onMarkUnread, + onRename, + onDelete, + onRenameComplete, + onRenameCancel, + getOwnerColorName, +}: TaskRowsProps): React.JSX.Element { + let lastTeam: string | null = null; + const visibleTasks = typeof visibleCount === 'number' ? tasks.slice(0, visibleCount) : tasks; + + return ( + <> + {visibleTasks.map((task) => { + const taskLocalPresentation = getTaskLocalPresentation(task); + const taskKey = `${keyPrefix}${task.teamName}-${task.id}`; + const row = ( + + ); + + if (!showTeamHeader || !formatTeamHeader) { + return row; + } + + const shouldShowTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + + return ( +
+ {shouldShowTeamHeader && ( +
+ {formatTeamHeader(task.teamDisplayName)} +
+ )} + {row} +
+ ); + })} + + ); +}, areTaskRowsPropsEqual); + +function areTaskRowsPropsEqual(prev: TaskRowsProps, next: TaskRowsProps): boolean { + return ( + prev.visibleCount === next.visibleCount && + prev.keyPrefix === next.keyPrefix && + prev.hideTeamName === next.hideTeamName && + prev.hideProjectName === next.hideProjectName && + prev.showTeamName === next.showTeamName && + prev.isLight === next.isLight && + prev.showTeamHeader === next.showTeamHeader && + prev.pinnedOverride === next.pinnedOverride && + prev.archivedOverride === next.archivedOverride && + prev.formatTeamHeader === next.formatTeamHeader && + prev.renamingKey === next.renamingKey && + prev.onTogglePin === next.onTogglePin && + prev.onToggleArchive === next.onToggleArchive && + prev.onMarkUnread === next.onMarkUnread && + prev.onRename === next.onRename && + prev.onDelete === next.onDelete && + prev.onRenameComplete === next.onRenameComplete && + prev.onRenameCancel === next.onRenameCancel && + areTaskRowsDerivedValuesEqual(prev, next) + ); +} + +function areTaskSidebarArraysEqual( + prev: readonly GlobalTask[], + next: readonly GlobalTask[] +): boolean { + if (prev === next) return true; + if (prev.length !== next.length) return false; + for (let i = 0; i < prev.length; i += 1) { + if (!taskSidebarFieldsEqual(prev[i], next[i])) { + return false; + } + } + return true; +} + +interface ProjectTaskGroupProps { + group: ProjectTaskGroupData; + isCollapsed: boolean; + visibleCount: number; + noProjectGroupColor: ReturnType; + showMoreLabel: string; + showLessLabel: string; + getTaskLocalPresentation: TaskLocalPresentationResolver; + isNewTask: (task: GlobalTask) => boolean; + isTeamOffline: TeamBooleanResolver; + renamingKey: string | null; + isLight: boolean; + formatTeamHeader: TeamHeaderFormatter; + onToggleGroup: (projectKey: string) => void; + onVisibleCountChange: ProjectGroupVisibleCountChange; + onTogglePin: TaskRowAction; + onToggleArchive: TaskRowAction; + onMarkUnread: TaskRowAction; + onRename: TaskRowAction; + onDelete: TaskRowDeleteAction; + onRenameComplete: (teamName: string, taskId: string, newSubject: string) => void; + onRenameCancel: () => void; + getOwnerColorName: TaskOwnerColorResolver; +} + +const ProjectTaskGroup = memo( + function ProjectTaskGroup({ + group, + isCollapsed, + visibleCount, + noProjectGroupColor, + showMoreLabel, + showLessLabel, + getTaskLocalPresentation, + isNewTask, + isTeamOffline, + renamingKey, + isLight, + formatTeamHeader, + onToggleGroup, + onVisibleCountChange, + onTogglePin, + onToggleArchive, + onMarkUnread, + onRename, + onDelete, + onRenameComplete, + onRenameCancel, + getOwnerColorName, + }: ProjectTaskGroupProps): React.JSX.Element | null { + if (group.tasks.length === 0) return null; + + const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; + const groupColor = isNoProjectGroup ? noProjectGroupColor : projectColor(group.projectLabel); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); + + return ( +
+ + {!isCollapsed && ( + + )} + {!isCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )} +
+ ); + }, + (prev, next) => + prev.group.projectKey === next.group.projectKey && + prev.group.projectLabel === next.group.projectLabel && + prev.group.tasks.length === next.group.tasks.length && + prev.isCollapsed === next.isCollapsed && + prev.visibleCount === next.visibleCount && + prev.noProjectGroupColor === next.noProjectGroupColor && + prev.showMoreLabel === next.showMoreLabel && + prev.showLessLabel === next.showLessLabel && + prev.renamingKey === next.renamingKey && + prev.isLight === next.isLight && + prev.formatTeamHeader === next.formatTeamHeader && + prev.onToggleGroup === next.onToggleGroup && + prev.onVisibleCountChange === next.onVisibleCountChange && + prev.onTogglePin === next.onTogglePin && + prev.onToggleArchive === next.onToggleArchive && + prev.onMarkUnread === next.onMarkUnread && + prev.onRename === next.onRename && + prev.onDelete === next.onDelete && + prev.onRenameComplete === next.onRenameComplete && + prev.onRenameCancel === next.onRenameCancel && + areTaskRowsDerivedValuesEqual( + { + tasks: prev.group.tasks, + visibleCount: prev.visibleCount, + getTaskLocalPresentation: prev.getTaskLocalPresentation, + isNewTask: prev.isNewTask, + isTeamOffline: prev.isTeamOffline, + getOwnerColorName: prev.getOwnerColorName, + }, + { + tasks: next.group.tasks, + visibleCount: next.visibleCount, + getTaskLocalPresentation: next.getTaskLocalPresentation, + isNewTask: next.isNewTask, + isTeamOffline: next.isTeamOffline, + getOwnerColorName: next.getOwnerColorName, + } + ) +); + export const GlobalTaskList = memo(function GlobalTaskList({ hideHeader = false, filters: externalFilters, @@ -198,6 +954,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, }: GlobalTaskListProps = {}): React.JSX.Element { const { t } = useAppTranslation('common'); + const { isLight } = useTheme(); const { globalTasks, globalTasksLoading, @@ -215,10 +972,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({ repositoryGroupsLoading, repositoryGroupsInitialized, repositoryGroupsError, - teams, provisioningRuns, currentProvisioningRunIdByTeam, - leadActivityByTeam, + leadOfflineTeamNames, } = useStore( useShallow((s) => ({ globalTasks: s.globalTasks, @@ -237,12 +993,12 @@ export const GlobalTaskList = memo(function GlobalTaskList({ repositoryGroupsLoading: s.repositoryGroupsLoading, repositoryGroupsInitialized: s.repositoryGroupsInitialized, repositoryGroupsError: s.repositoryGroupsError, - teams: s.teams, provisioningRuns: s.provisioningRuns, currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam, - leadActivityByTeam: s.leadActivityByTeam, + leadOfflineTeamNames: selectLeadOfflineTeamNames(s.leadActivityByTeam), })) ); + const sidebarTeams = useStore((s) => selectSidebarTeamsDerived(s.teams)); const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); @@ -267,6 +1023,39 @@ export const GlobalTaskList = memo(function GlobalTaskList({ const taskLocalState = useTaskLocalState(); const electronMode = isElectronMode(); + const taskLocalPresentationByTask = useMemo( + () => + buildTaskLocalPresentationByTask( + globalTasks, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects + ), + [ + globalTasks, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects, + ] + ); + + const getTaskLocalPresentation = useCallback( + (task: GlobalTask): TaskLocalPresentationState => + taskLocalPresentationByTask.get(task) ?? + buildTaskLocalPresentationState( + task, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects + ), + [ + taskLocalPresentationByTask, + taskLocalState.pinnedIds, + taskLocalState.archivedIds, + taskLocalState.renamedSubjects, + ] + ); + const provisioningState = useMemo( () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), [currentProvisioningRunIdByTeam, provisioningRuns] @@ -295,14 +1084,14 @@ export const GlobalTaskList = memo(function GlobalTaskList({ isInitialTaskLoadRef.current = false; for (const t of globalTasks) { // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); + knownTaskIdsRef.current.add(buildTaskLocalPresentationKey(t)); } return new Set(); } const newIds = new Set(); for (const t of globalTasks) { - const key = `${t.teamName}:${t.id}`; + const key = buildTaskLocalPresentationKey(t); // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (!knownTaskIdsRef.current.has(key)) { newIds.add(key); @@ -314,7 +1103,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ }, [globalTasks, globalTasksInitialized]); const isNewTask = useCallback( - (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), + (task: GlobalTask): boolean => newTaskIds.has(buildTaskLocalPresentationKey(task)), [newTaskIds] ); @@ -329,7 +1118,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ return () => { cancelled = true; }; - }, [fetchAliveTeams, teams]); + }, [fetchAliveTeams, sidebarTeams.identityKey]); const readyProgressRefreshKey = useMemo(() => { return Object.entries(currentProvisioningRunIdByTeam) @@ -360,9 +1149,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({ const offlineTeamNames = useMemo(() => { const result = new Set(); + const leadOfflineTeams = new Set(leadOfflineTeamNames); if (aliveTeamsInitialized) { const teamSummariesByName = new Map(); - for (const team of teams) { + for (const team of sidebarTeams.statusSummaries) { teamSummariesByName.set(team.teamName, team); } for (const task of globalTasks) { @@ -372,33 +1162,61 @@ export const GlobalTaskList = memo(function GlobalTaskList({ } for (const team of teamSummariesByName.values()) { + if (leadOfflineTeams.has(team.teamName)) { + result.add(team.teamName); + continue; + } const status = resolveTeamStatus( team, team.teamName, aliveTeams, getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), - leadActivityByTeam + {} ); if (!isTeamListStatusRunning(status)) { result.add(team.teamName); } } } - for (const [teamName, activity] of Object.entries(leadActivityByTeam)) { - if (activity === 'offline') { - result.add(teamName); - } + for (const teamName of leadOfflineTeamNames) { + result.add(teamName); } return result; }, [ aliveTeams, aliveTeamsInitialized, globalTasks, - leadActivityByTeam, + leadOfflineTeamNames, provisioningState, - teams, + sidebarTeams.statusSummaries, ]); + const getOwnerColorName = useCallback( + (task: GlobalTask): string | null | undefined => { + if (!task.owner) return null; + const teamColorMap = sidebarTeams.memberColorByTeam.get(task.teamName); + return teamColorMap ? (teamColorMap.get(task.owner) ?? null) : undefined; + }, + [sidebarTeams.memberColorByTeam] + ); + const isTeamOffline = useCallback( + (teamName: string): boolean => offlineTeamNames.has(teamName), + [offlineTeamNames] + ); + const formatTeamHeader = useCallback( + (teamDisplayName: string): string => t('tasksPanel.teamLabel', { team: teamDisplayName }), + [t] + ); + const handleProjectGroupVisibleCountChange = useCallback( + (projectKey: string, visibleCount: number): void => { + setProjectRequestedVisibleCountByKey((prev) => ({ + ...prev, + [projectKey]: visibleCount, + })); + }, + [] + ); + const setGroupingMode = (mode: TaskGroupingMode): void => { setGroupingModeState(mode); saveGroupingMode(mode); @@ -425,6 +1243,24 @@ export const GlobalTaskList = memo(function GlobalTaskList({ markTaskUnread(teamName, taskId); }, []); + const handleToggleTaskPin = useCallback( + (teamName: string, taskId: string): void => { + taskLocalState.togglePin(teamName, taskId); + }, + [taskLocalState] + ); + + const handleToggleTaskArchive = useCallback( + (teamName: string, taskId: string): void => { + taskLocalState.toggleArchive(teamName, taskId); + }, + [taskLocalState] + ); + + const handleStartTaskRename = useCallback((teamName: string, taskId: string): void => { + setRenamingTaskKey(`${teamName}:${taskId}`); + }, []); + const handleDeleteTask = useCallback( async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ @@ -462,6 +1298,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({ }, [fetchAllTasks, globalTasksLoading]); useEffect(() => { + if (!filtersPopoverOpen) { + return; + } if ( viewMode === 'grouped' && !repositoryGroupsInitialized && @@ -475,6 +1314,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ }, [ fetchProjects, fetchRepositoryGroups, + filtersPopoverOpen, projectsError, projectsInitialized, projectsLoading, @@ -513,8 +1353,8 @@ export const GlobalTaskList = memo(function GlobalTaskList({ // Resolve project filter from filters state const selectedProjectPath = filters.projectPath; const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] + () => globalTasks.some((t) => getTaskLocalPresentation(t).archived), + [globalTasks, getTaskLocalPresentation] ); const effectiveShowArchived = showArchived && hasArchivedTasks; @@ -537,9 +1377,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({ result = applySearch(result, searchQuery); // Archive filtering if (effectiveShowArchived) { - result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); + result = result.filter((t) => getTaskLocalPresentation(t).archived); } else { - result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); + result = result.filter((t) => !getTaskLocalPresentation(t).archived); } return result; }, [ @@ -551,26 +1391,36 @@ export const GlobalTaskList = memo(function GlobalTaskList({ searchQuery, readState, effectiveShowArchived, - taskLocalState, + getTaskLocalPresentation, ]); // Split into pinned and normal (non-pinned) tasks const pinnedTasks = useMemo( - () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] + () => filtered.filter((t) => getTaskLocalPresentation(t).pinned), + [filtered, getTaskLocalPresentation] ); const normalTasks = useMemo( - () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] + () => filtered.filter((t) => !getTaskLocalPresentation(t).pinned), + [filtered, getTaskLocalPresentation] ); + const sortedPinnedTasks = useMemo(() => sortTasksByFreshness(pinnedTasks), [pinnedTasks]); const sortedFlat = useMemo( - () => applySortMode(normalTasks, sortMode, readState), - [normalTasks, sortMode, readState] + () => (groupingMode === 'none' ? applySortMode(normalTasks, sortMode, readState) : EMPTY_TASKS), + [groupingMode, normalTasks, sortMode, readState] + ); + const grouped = useMemo( + () => (groupingMode === 'time' ? groupTasksByDate(normalTasks) : EMPTY_DATE_GROUPS), + [groupingMode, normalTasks] + ); + const categories = useMemo( + () => (groupingMode === 'time' ? getNonEmptyTaskCategories(grouped) : EMPTY_DATE_CATEGORIES), + [grouped, groupingMode] + ); + const projectGroups = useMemo( + () => (groupingMode === 'project' ? groupTasksByProject(normalTasks) : EMPTY_PROJECT_GROUPS), + [groupingMode, normalTasks] ); - const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); - const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); - const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); // Collapsed group keys for each grouping mode const projectGroupKeys = useMemo( @@ -591,9 +1441,22 @@ export const GlobalTaskList = memo(function GlobalTaskList({ syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), [projectRequestedVisibleCountByKey, projectGroupVisibility] ); + const taskFilterTeams = useMemo(() => sidebarTeams.filterTeams, [sidebarTeams.filterTeams]); - const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); - const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); + const { isCollapsed: isProjectGroupCollapsed, toggle: toggleProjectGroup } = useCollapsedGroups( + 'project', + projectGroupKeys + ); + const { isCollapsed: isTimeGroupCollapsed, toggle: toggleTimeGroup } = useCollapsedGroups( + 'time', + timeGroupKeys + ); + const handleToggleProjectGroup = useCallback( + (projectKey: string): void => { + toggleProjectGroup(projectKey); + }, + [toggleProjectGroup] + ); const hasContent = pinnedTasks.length > 0 || @@ -693,7 +1556,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ ({ teamName: t.teamName, displayName: t.displayName }))} + teams={taskFilterTeams} projectOptions={projectFilterOptions} filters={filters} onFiltersChange={setFilters} @@ -708,31 +1571,26 @@ export const GlobalTaskList = memo(function GlobalTaskList({ {t('tasksPanel.pinned')}
- {sortTasksByFreshness(pinnedTasks).map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} + )} @@ -815,177 +1673,71 @@ export const GlobalTaskList = memo(function GlobalTaskList({ )} - {groupingMode === 'none' && - sortedFlat.map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} + {groupingMode === 'none' && ( + + )} {groupingMode === 'project' && projectGroups.map((group) => { - if (group.tasks.length === 0) return null; - const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); - const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; - const groupColor = isNoProjectGroup - ? noProjectGroupColor - : projectColor(group.projectLabel); const visibleCount = getProjectGroupVisibleCount( projectVisibleCountByKey[group.projectKey], group.tasks.length ); - const visibleTasks = group.tasks.slice(0, visibleCount); - const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); - const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); - let lastTeam: string | null = null; return ( -
- - {!isGroupCollapsed && - visibleTasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- {t('tasksPanel.teamLabel', { team: task.teamDisplayName })} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} - {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( -
- {showMoreVisible && ( - - )} - {showLessVisible && ( - - )} -
- )} -
+ ); })} {groupingMode === 'time' && categories.map((category) => { const tasks = grouped[category]; - const isGroupCollapsed = timeCollapsed.isCollapsed(category); - let lastTeam: string | null = null; + const isGroupCollapsed = isTimeGroupCollapsed(category); return (
- {!isGroupCollapsed && - tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - - return ( -
- {showTeamHeader && ( -
- {t('tasksPanel.teamLabel', { team: task.teamDisplayName })} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} + {!isGroupCollapsed && ( + + )}
); })} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 2b5c1a12..110c45ab 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -1,7 +1,6 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; @@ -70,6 +69,8 @@ interface SidebarTaskItemProps { hideTeamName?: boolean; hideProjectName?: boolean; showTeamName?: boolean; + /** Optional theme value from list parents to avoid one theme subscription per row. */ + isLight?: boolean; /** Pauses the in-progress spinner when the parent team is offline. */ teamOffline?: boolean; /** The composite key "teamName:taskId" of the task being renamed, or null */ @@ -80,28 +81,38 @@ interface SidebarTaskItemProps { onRenameCancel?: () => void; /** Returns a custom display subject if the task was renamed locally */ getDisplaySubject?: (task: GlobalTask) => string | undefined; + /** Precomputed custom display subject from list parents. */ + displaySubjectOverride?: string; + ownerColorName?: string | null; } -export const SidebarTaskItem = memo(function SidebarTaskItem({ +const SidebarTaskItemContent = ({ task, hideTeamName, hideProjectName, showTeamName, + isLight, teamOffline = false, renamingKey, onRenameComplete, onRenameCancel, getDisplaySubject, -}: SidebarTaskItemProps): React.JSX.Element { + displaySubjectOverride, + ownerColorName, +}: SidebarTaskItemProps & { isLight: boolean }): React.JSX.Element => { const { t } = useAppTranslation('team'); const { t: tCommon } = useAppTranslation('common'); const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); + const shouldResolveOwnerColorFromStore = ownerColorName === undefined; + const teamMembers = useStore( + useShallow((s) => + shouldResolveOwnerColorFromStore ? s.teamByName[task.teamName]?.members : undefined + ) + ); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); - const { isLight } = useTheme(); const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const displaySubject = displaySubjectOverride ?? getDisplaySubject?.(task) ?? task.subject; const [editValue, setEditValue] = useState(displaySubject); const inputRef = useRef(null); // Focus input when rename starts @@ -143,12 +154,17 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({ ); const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt, tCommon('tasks.date.yesterday')); + const resolvedOwnerColorName = useMemo(() => { + if (!task.owner) return null; + if (!shouldResolveOwnerColorFromStore) return ownerColorName; + if (!teamMembers) return null; + return buildMemberColorMap(teamMembers).get(task.owner) ?? null; + }, [ownerColorName, shouldResolveOwnerColorFromStore, task.owner, teamMembers]); + const ownerColorSet = useMemo(() => { - if (!teamMembers || !task.owner) return null; - const colorMap = buildMemberColorMap(teamMembers); - const colorName = colorMap.get(task.owner); + const colorName = resolvedOwnerColorName; return colorName ? getTeamColorSet(colorName) : null; - }, [teamMembers, task.owner]); + }, [resolvedOwnerColorName]); const ownerTextColor = useMemo(() => { if (!ownerColorSet) return undefined; @@ -178,7 +194,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({ return ( ); +}; + +const ThemedSidebarTaskItem = (props: SidebarTaskItemProps): React.JSX.Element => { + const { isLight } = useTheme(); + return ; +}; + +export const SidebarTaskItem = memo(function SidebarTaskItem( + props: SidebarTaskItemProps +): React.JSX.Element { + if (typeof props.isLight === 'boolean') { + return ; + } + return ; }); diff --git a/src/renderer/components/sidebar/TaskContextMenu.tsx b/src/renderer/components/sidebar/TaskContextMenu.tsx index b5a306ad..f01c8686 100644 --- a/src/renderer/components/sidebar/TaskContextMenu.tsx +++ b/src/renderer/components/sidebar/TaskContextMenu.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { useAppTranslation } from '@features/localization/renderer'; import { ContextMenu, @@ -22,6 +24,83 @@ export interface TaskContextMenuProps { children: React.ReactNode; } +type TaskContextMenuContentProps = Pick< + TaskContextMenuProps, + | 'isPinned' + | 'isArchived' + | 'onTogglePin' + | 'onToggleArchive' + | 'onMarkUnread' + | 'onRename' + | 'onDelete' +>; + +const TaskContextMenuLazyContent = ({ + isPinned, + isArchived, + onTogglePin, + onToggleArchive, + onMarkUnread, + onRename, + onDelete, +}: TaskContextMenuContentProps): React.JSX.Element => { + const { t } = useAppTranslation('common'); + + return ( + e.preventDefault()}> + + {isPinned ? ( + <> + + {t('taskContextMenu.unpin')} + + ) : ( + <> + + {t('taskContextMenu.pin')} + + )} + + + + + {t('taskContextMenu.rename')} + + + + + {t('taskContextMenu.markUnread')} + + + + + + {isArchived ? ( + <> + + {t('taskContextMenu.unarchive')} + + ) : ( + <> + + {t('taskContextMenu.archive')} + + )} + + + {onDelete && ( + <> + + + + {t('taskContextMenu.deleteTask')} + + + )} + + ); +}; + export const TaskContextMenu = ({ task: _task, isPinned, @@ -33,64 +112,24 @@ export const TaskContextMenu = ({ onDelete, children, }: TaskContextMenuProps): React.JSX.Element => { - const { t } = useAppTranslation('common'); + const [open, setOpen] = useState(false); return ( - +
{children}
- e.preventDefault()}> - - {isPinned ? ( - <> - - {t('taskContextMenu.unpin')} - - ) : ( - <> - - {t('taskContextMenu.pin')} - - )} - - - - - {t('taskContextMenu.rename')} - - - - - {t('taskContextMenu.markUnread')} - - - - - - {isArchived ? ( - <> - - {t('taskContextMenu.unarchive')} - - ) : ( - <> - - {t('taskContextMenu.archive')} - - )} - - - {onDelete && ( - <> - - - - {t('taskContextMenu.deleteTask')} - - - )} - + {open ? ( + + ) : null}
); }; diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx index 1753cbb9..7d5d348c 100644 --- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx +++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx @@ -53,7 +53,7 @@ export const TaskFiltersPopover = ({ }, [open, filters]); const allSelected = - STATUS_OPTIONS.length > 0 && STATUS_OPTIONS.every((opt) => draft.statusIds.has(opt.id)); + open && STATUS_OPTIONS.length > 0 && STATUS_OPTIONS.every((opt) => draft.statusIds.has(opt.id)); const handleSelectAll = (): void => { if (allSelected) { @@ -89,123 +89,125 @@ export const TaskFiltersPopover = ({ - -
-
-
- - {t('taskFilters.status')} - - -
-
- {STATUS_OPTIONS.map((opt) => ( -
+ + ) : null} ); }; diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index 7031ce99..453a4d43 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -17,13 +17,19 @@ import { import { MemberHoverCard } from './members/MemberHoverCard'; +import type { ResolvedTeamMember } from '@shared/types'; + interface MemberBadgeProps { name: string; color?: string; /** Owning team context for hover-card store lookups. */ teamName?: string; + /** Pre-resolved theme flag from callers that already read theme state. */ + isLight?: boolean; /** Avatar + badge size variant */ size?: 'xs' | 'sm' | 'md'; + /** Pre-resolved avatar URL from a caller that already owns the member roster. */ + avatarUrl?: string; /** Hide the avatar icon, show only the name badge */ hideAvatar?: boolean; onClick?: (name: string) => void; @@ -31,30 +37,47 @@ interface MemberBadgeProps { disableHoverCard?: boolean; } +const EMPTY_TEAM_MEMBERS: readonly ResolvedTeamMember[] = []; +const memberAvatarMapCache = new WeakMap>(); + +function getCachedMemberAvatarMap(members: readonly ResolvedTeamMember[]): Map { + const cached = memberAvatarMapCache.get(members); + if (cached) { + return cached; + } + + const next = buildMemberAvatarMap(members); + memberAvatarMapCache.set(members, next); + return next; +} + /** * Reusable member avatar + colored name badge. * Avatar is rendered OUTSIDE the badge, to the left. * When onClick is provided, both avatar and badge are clickable as one unit. * Wrapped in MemberHoverCard to show member info on hover. */ -export const MemberBadge = memo( +type MemberBadgeContentProps = Omit & { + isLight: boolean; +}; + +type MemberBadgeResolvedContentProps = MemberBadgeContentProps & { + resolvedAvatarUrl?: string; +}; + +const MemberBadgeResolvedContent = memo( ({ name, color, teamName, + isLight, size = 'sm', + resolvedAvatarUrl, hideAvatar, onClick, disableHoverCard, - }: MemberBadgeProps): React.JSX.Element => { + }: MemberBadgeResolvedContentProps): React.JSX.Element => { const colors = getTeamColorSet(color ?? ''); - const { isLight } = useTheme(); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const effectiveTeamName = teamName ?? selectedTeamName; - const teamMembers = useStore((s) => - effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; @@ -68,7 +91,7 @@ export const MemberBadge = memo( const avatar = ( { + const effectiveAvatarTeamName = useStore((s) => props.teamName ?? s.selectedTeamName); + const teamMembers = useStore((s) => + effectiveAvatarTeamName + ? selectResolvedMembersForTeamName(s, effectiveAvatarTeamName) + : EMPTY_TEAM_MEMBERS + ); + const avatarMap = useMemo(() => getCachedMemberAvatarMap(teamMembers), [teamMembers]); + return ; +}); + +MemberBadgeWithResolvedAvatar.displayName = 'MemberBadgeWithResolvedAvatar'; + +const MemberBadgeContent = memo((props: MemberBadgeContentProps): React.JSX.Element => { + if (props.hideAvatar || props.avatarUrl != null) { + return ; + } + return ; +}); + +MemberBadgeContent.displayName = 'MemberBadgeContent'; + +const ThemedMemberBadge = memo(function ThemedMemberBadge({ + isLight: _isLight, + ...props +}: MemberBadgeProps): React.JSX.Element { + const { isLight } = useTheme(); + return ; +}); + +export const MemberBadge = memo(function MemberBadge(props: MemberBadgeProps): React.JSX.Element { + if (typeof props.isLight === 'boolean') { + return ; + } + return ; +}); + MemberBadge.displayName = 'MemberBadge'; diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index 5b287d88..ba887fc5 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; @@ -65,16 +65,36 @@ interface TaskTooltipProps { side?: 'top' | 'bottom' | 'left' | 'right'; } -/** - * Tooltip that shows task summary on hover over any #taskId link. - * Reads task data from the current team in the store. - */ export const TaskTooltip = memo(function TaskTooltip({ taskId, teamName, children, side = 'top', }: TaskTooltipProps): React.JSX.Element { + const [open, setOpen] = useState(false); + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + }, []); + + return ( + + {children} + {open ? : null} + + ); +}); + +interface TaskTooltipContentProps { + taskId: string; + teamName?: string; + side: 'top' | 'bottom' | 'left' | 'right'; +} + +const TaskTooltipContent = memo(function TaskTooltipContent({ + taskId, + teamName, + side, +}: TaskTooltipContentProps): React.JSX.Element | null { const { t } = useAppTranslation('team'); const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } = useStore( @@ -127,7 +147,7 @@ export const TaskTooltip = memo(function TaskTooltip({ ); // If task not found, render children without tooltip - if (!task) return children; + if (!task) return null; const column = getEffectiveColumn(task); const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending; @@ -142,63 +162,60 @@ export const TaskTooltip = memo(function TaskTooltip({ : null; return ( - - {children} - - {resolvedTeamName ? ( -
- {resolvedTeamDisplayName || resolvedTeamName} -
- ) : null} - {/* Subject */} -
- {formatTaskDisplayLabel(task)}{' '} - {task.subject} + + {resolvedTeamName ? ( +
+ {resolvedTeamDisplayName || resolvedTeamName}
+ ) : null} + {/* Subject */} +
+ {formatTaskDisplayLabel(task)}{' '} + {task.subject} +
- {/* Status badge */} -
+ {/* Status badge */} +
+ + {label} + + {isTeamTaskNeedsFixActionable(task) ? ( - {label} + {REVIEW_STATE_DISPLAY.needsFix.label} - {isTeamTaskNeedsFixActionable(task) ? ( - - {REVIEW_STATE_DISPLAY.needsFix.label} - - ) : null} - - {/* Owner */} - {task.owner && members.length > 0 ? ( - - ) : task.owner ? ( - {task.owner} - ) : ( - - {t('tasks.unassigned')} - - )} -
- - {/* Description — full markdown with scroll */} - {task.description ? ( -
- -
) : null} - - + + {/* Owner */} + {task.owner && members.length > 0 ? ( + + ) : task.owner ? ( + {task.owner} + ) : ( + + {t('tasks.unassigned')} + + )} +
+ + {/* Description — full markdown with scroll */} + {task.description ? ( +
+ +
+ ) : null} +
); }); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 76304b8b..49620a0a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,13 +1,15 @@ import { + forwardRef, lazy, memo, Suspense, useCallback, useEffect, - useId, + useImperativeHandle, useMemo, useRef, useState, + useSyncExternalStore, } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; @@ -50,6 +52,7 @@ import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; +import { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask'; import { buildTaskChangeRequestOptions, type TaskChangeRequestOptions, @@ -116,9 +119,25 @@ const TeamGraphOverlay = lazy(() => default: m.TeamGraphOverlay, })) ); -const TaskDetailDialog = lazy(() => - import('./dialogs/TaskDetailDialog').then((m) => ({ default: m.TaskDetailDialog })) -); +type TaskDetailDialogComponent = typeof import('./dialogs/TaskDetailDialog').TaskDetailDialog; +let loadedTaskDetailDialogComponent: TaskDetailDialogComponent | null = null; +let taskDetailDialogImportPromise: Promise<{ default: TaskDetailDialogComponent }> | null = null; +function loadTaskDetailDialog(): Promise<{ default: TaskDetailDialogComponent }> { + taskDetailDialogImportPromise ??= import('./dialogs/TaskDetailDialog') + .then((m) => { + loadedTaskDetailDialogComponent = m.TaskDetailDialog; + return { default: m.TaskDetailDialog }; + }) + .catch((error) => { + taskDetailDialogImportPromise = null; + throw error; + }); + return taskDetailDialogImportPromise; +} +function preloadTaskDetailDialog(): void { + void loadTaskDetailDialog().catch(() => undefined); +} +const LazyTaskDetailDialog = lazy(loadTaskDetailDialog); const SendMessageDialog = lazy(() => import('./dialogs/SendMessageDialog').then((m) => ({ default: m.SendMessageDialog })) ); @@ -158,6 +177,7 @@ import type { SessionInjection } from './session-injection-types'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { + KanbanTaskState, MemberSpawnStatusEntry, ResolvedTeamMember, TaskRef, @@ -170,6 +190,92 @@ import type { } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; +interface TaskDetailDialogHostHandle { + openTask: (task: TeamTaskWithKanban) => void; + close: () => void; +} + +interface TaskDetailDialogHostProps { + teamName: string; + kanbanTaskStateByTaskId: Record; + taskMap: Map; + members: ResolvedTeamMember[]; + onOwnerChange: (taskId: string, owner: string | null) => void; + onViewChanges: (taskId: string, filePath?: string) => void; + onOpenInEditor: (filePath: string) => void; + onDeleteTask: (taskId: string) => void; +} + +const TaskDetailDialogHost = memo( + forwardRef(function TaskDetailDialogHost( + { + teamName, + kanbanTaskStateByTaskId, + taskMap, + members, + onOwnerChange, + onViewChanges, + onOpenInEditor, + onDeleteTask, + }, + ref + ) { + const [selectedTask, setSelectedTask] = useState(null); + + useImperativeHandle( + ref, + () => ({ + openTask: setSelectedTask, + close: () => setSelectedTask(null), + }), + [] + ); + + const handleScrollToTask = useCallback((taskId: string) => { + setSelectedTask(null); + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener('animationend', () => el.classList.remove('kanban-card-focus-pulse'), { + once: true, + }); + } + }, []); + + if (selectedTask === null) { + return null; + } + + const DialogComponent = loadedTaskDetailDialogComponent ?? LazyTaskDetailDialog; + const dialog = ( + setSelectedTask(null)} + onScrollToTask={handleScrollToTask} + onOwnerChange={onOwnerChange} + onViewChanges={onViewChanges} + onOpenInEditor={onOpenInEditor} + onDeleteTask={onDeleteTask} + /> + ); + + if (loadedTaskDetailDialogComponent) { + return dialog; + } + + return {dialog}; + }) +); +TaskDetailDialogHost.displayName = 'TaskDetailDialogHost'; + interface TeamDetailViewProps { teamName: string; isActive?: boolean; @@ -789,12 +895,20 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({ type LeadUpdatedKey = `lead${'Con'}${'text'}UpdatedAt`; type TeamMessagesPanelBridgeProps = Omit< ComponentProps, - 'leadActivity' | LeadUpdatedKey + 'leadActivity' | LeadUpdatedKey | 'pendingRepliesByMember' | 'onPendingReplyChange' >; +type SendMessageDialogBridgeProps = Omit< + ComponentProps, + 'sending' | 'sendError' | 'sendWarning' | 'sendDebugDetails' | 'lastResult' | 'onSend' +>; +type SendMessageDialogOnSend = ComponentProps['onSend']; +type PendingRepliesUpdater = + | Record + | ((current: Record) => Record); type SharedTeamMessagesPanelProps = Omit; type TeamMemberListBridgeProps = Omit< ComponentProps, - 'leadActivity' | 'memberSpawnStatuses' + 'leadActivity' | 'memberSpawnStatuses' | 'pendingRepliesByMember' > & { teamName: string; }; @@ -802,6 +916,7 @@ type TeamMemberDetailDialogBridgeProps = Omit< ComponentProps, 'leadActivity' | 'spawnEntry' | 'runtimeEntry' >; +type TeamKanbanBoardBridgeProps = Omit, 'activeTaskLogActivity'>; type TeamSidebarRailBridgeProps = Omit< ComponentProps, 'messagesPanelProps' @@ -818,6 +933,54 @@ interface LeadLoadBridgeProps { isThisTabActive: boolean; } +const pendingRepliesCacheByTeam = new Map>(); +const pendingRepliesListenersByTeam = new Map void>>(); +let pendingReplyRefreshSourceSequence = 0; + +function getPendingRepliesSnapshot(teamName: string): Record { + let snapshot = pendingRepliesCacheByTeam.get(teamName); + if (!snapshot) { + snapshot = getTeamPendingRepliesState(teamName); + pendingRepliesCacheByTeam.set(teamName, snapshot); + } + return snapshot; +} + +function subscribePendingReplies(teamName: string, listener: () => void): () => void { + let listeners = pendingRepliesListenersByTeam.get(teamName); + if (!listeners) { + listeners = new Set(); + pendingRepliesListenersByTeam.set(teamName, listeners); + } + listeners.add(listener); + return () => { + listeners?.delete(listener); + if (listeners?.size === 0) { + pendingRepliesListenersByTeam.delete(teamName); + } + }; +} + +function setPendingRepliesForTeam(teamName: string, updater: PendingRepliesUpdater): void { + const current = getPendingRepliesSnapshot(teamName); + const next = typeof updater === 'function' ? updater(current) : updater; + if (next === current) { + return; + } + pendingRepliesCacheByTeam.set(teamName, next); + setTeamPendingRepliesState(teamName, next); + pendingRepliesListenersByTeam.get(teamName)?.forEach((listener) => listener()); +} + +function useTeamPendingReplies(teamName: string): Record { + const subscribe = useCallback( + (listener: () => void) => subscribePendingReplies(teamName, listener), + [teamName] + ); + const getSnapshot = useCallback(() => getPendingRepliesSnapshot(teamName), [teamName]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = []; function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string { @@ -1223,6 +1386,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ teamName, ...props }: TeamMemberListBridgeProps): React.JSX.Element { + const pendingRepliesByMember = useTeamPendingReplies(teamName); const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } = useStore( useShallow((s) => ({ @@ -1262,6 +1426,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ {...props} teamName={teamName} leadActivity={leadActivity} + pendingRepliesByMember={pendingRepliesByMember} memberSpawnStatuses={memberSpawnStatusMap} memberRuntimeEntries={memberRuntimeMap} runtimeRunId={runtimeRunId} @@ -1272,21 +1437,53 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({ teamName, + isTeamAlive, ...props }: TeamMessagesPanelBridgeProps): React.JSX.Element { - const { leadActivity, leadContextUpdatedAt } = useStore( + const pendingRepliesByMember = useTeamPendingReplies(teamName); + const pendingReplyRefreshSourceId = useRef(null); + if (pendingReplyRefreshSourceId.current === null) { + pendingReplyRefreshSourceSequence += 1; + pendingReplyRefreshSourceId.current = `team-messages:${pendingReplyRefreshSourceSequence}`; + } + const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, })) ); + useEffect(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId.current!, + Boolean(isTeamAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS + ); + + return () => { + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false); + }; + }, [isTeamAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]); + + const handlePendingReplyChange = useCallback( + (updater: PendingRepliesUpdater) => { + setPendingRepliesForTeam(teamName, updater); + }, + [teamName] + ); + return ( ); }); @@ -1295,24 +1492,136 @@ const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({ messagesPanelProps, ...props }: TeamSidebarRailBridgeProps): React.JSX.Element { - const { leadActivity, leadContextUpdatedAt } = useStore( + const teamName = messagesPanelProps.teamName; + const pendingRepliesByMember = useTeamPendingReplies(teamName); + const pendingReplyRefreshSourceId = useRef(null); + if (pendingReplyRefreshSourceId.current === null) { + pendingReplyRefreshSourceSequence += 1; + pendingReplyRefreshSourceId.current = `team-sidebar:${pendingReplyRefreshSourceSequence}`; + } + const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore( useShallow((s) => ({ - leadActivity: s.leadActivityByTeam[messagesPanelProps.teamName], - leadContextUpdatedAt: s.leadContextByTeam[messagesPanelProps.teamName]?.updatedAt, + leadActivity: s.leadActivityByTeam[teamName], + leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, })) ); + useEffect(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId.current!, + Boolean(messagesPanelProps.isTeamAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS + ); + + return () => { + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false); + }; + }, [ + messagesPanelProps.isTeamAlive, + pendingRepliesByMember, + syncTeamPendingReplyRefresh, + teamName, + ]); + + const handlePendingReplyChange = useCallback( + (updater: PendingRepliesUpdater) => { + setPendingRepliesForTeam(teamName, updater); + }, + [teamName] + ); const bridgedMessagesPanelProps = useMemo( () => ({ ...messagesPanelProps, leadActivity, leadContextUpdatedAt, + pendingRepliesByMember, + onPendingReplyChange: handlePendingReplyChange, }), - [leadActivity, leadContextUpdatedAt, messagesPanelProps] + [ + handlePendingReplyChange, + leadActivity, + leadContextUpdatedAt, + messagesPanelProps, + pendingRepliesByMember, + ] ); return ; }); +const SendMessageDialogBridge = memo(function SendMessageDialogBridge({ + teamName, + ...props +}: SendMessageDialogBridgeProps): React.JSX.Element { + const { + sendTeamMessage, + sendingMessage, + sendMessageError, + sendMessageWarning, + sendMessageDebugDetails, + lastSendMessageResult, + } = useStore( + useShallow((s) => ({ + sendTeamMessage: s.sendTeamMessage, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, + sendMessageDebugDetails: s.sendMessageDebugDetails, + lastSendMessageResult: s.lastSendMessageResult, + })) + ); + + const handleSend = useCallback( + async (member, text, summary, attachments, actionMode, taskRefs) => { + const sentAtMs = Date.now(); + setPendingRepliesForTeam(teamName, (prev) => ({ ...prev, [member]: sentAtMs })); + try { + const result = await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + if (shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)) { + setPendingRepliesForTeam(teamName, (prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + return result; + } catch (error) { + setPendingRepliesForTeam(teamName, (prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + throw error; + } + }, + [sendTeamMessage, teamName] + ); + + return ( + + ); +}); + const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge({ teamName, member, @@ -1374,6 +1683,17 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); +const TeamKanbanBoardBridge = memo(function TeamKanbanBoardBridge({ + teamName, + ...props +}: TeamKanbanBoardBridgeProps): React.JSX.Element { + const activeTaskLogActivity = useStore((s) => s.activeTaskLogActivityByTeam[teamName]); + + return ( + + ); +}); + export const TeamDetailView = memo(function TeamDetailView({ teamName, isActive = true, @@ -1382,15 +1702,11 @@ export const TeamDetailView = memo(function TeamDetailView({ const { t } = useAppTranslation('team'); const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const [selectedMemberView, setSelectedMemberView] = useState<{ initialTab?: MemberDetailTab; initialActivityFilter?: MemberActivityFilter; } | null>(null); - const [pendingRepliesByMember, setPendingRepliesByMember] = useState>(() => - getTeamPendingRepliesState(teamName) - ); const [createTaskDialog, setCreateTaskDialog] = useState({ open: false, defaultSubject: '', @@ -1413,6 +1729,8 @@ export const TeamDetailView = memo(function TeamDetailView({ const [editorOpen, setEditorOpen] = useState(false); const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); + const taskDetailDialogRef = useRef(null); + const taskDetailDialogPreloadScheduledRef = useRef(false); const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( null ); @@ -1527,18 +1845,12 @@ export const TeamDetailView = memo(function TeamDetailView({ updateKanbanColumnOrder, updateTaskStatus, updateTaskOwner, - sendTeamMessage, requestReview, createTeamTask, startTaskByUser, deleteTeam, openTeamsTab, closeTab, - sendingMessage, - sendMessageError, - sendMessageWarning, - sendMessageDebugDetails, - lastSendMessageResult, reviewActionError, addMember, restartMember, @@ -1553,14 +1865,12 @@ export const TeamDetailView = memo(function TeamDetailView({ refreshTeamData, refreshTeamMessagesHead, refreshMemberActivityMeta, - syncTeamPendingReplyRefresh, kanbanFilterQuery, clearKanbanFilter, softDeleteTask, restoreTask, fetchDeletedTasks, deletedTasks, - activeTaskLogActivity, launchParams, messagesPanelMode, messagesPanelWidth, @@ -1584,18 +1894,12 @@ export const TeamDetailView = memo(function TeamDetailView({ updateKanbanColumnOrder: s.updateKanbanColumnOrder, updateTaskStatus: s.updateTaskStatus, updateTaskOwner: s.updateTaskOwner, - sendTeamMessage: s.sendTeamMessage, requestReview: s.requestReview, createTeamTask: s.createTeamTask, startTaskByUser: s.startTaskByUser, deleteTeam: s.deleteTeam, openTeamsTab: s.openTeamsTab, closeTab: s.closeTab, - sendingMessage: s.sendingMessage, - sendMessageError: s.sendMessageError, - sendMessageWarning: s.sendMessageWarning, - sendMessageDebugDetails: s.sendMessageDebugDetails, - lastSendMessageResult: s.lastSendMessageResult, reviewActionError: s.reviewActionError, addMember: s.addMember, restartMember: s.restartMember, @@ -1619,14 +1923,12 @@ export const TeamDetailView = memo(function TeamDetailView({ refreshTeamData: s.refreshTeamData, refreshTeamMessagesHead: s.refreshTeamMessagesHead, refreshMemberActivityMeta: s.refreshMemberActivityMeta, - syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, softDeleteTask: s.softDeleteTask, restoreTask: s.restoreTask, fetchDeletedTasks: s.fetchDeletedTasks, deletedTasks: s.deletedTasks, - activeTaskLogActivity: teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined, launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, messagesPanelMode: s.messagesPanelMode, messagesPanelWidth: s.messagesPanelWidth, @@ -1682,14 +1984,6 @@ export const TeamDetailView = memo(function TeamDetailView({ } }, [tabId, initTabUIState]); - useEffect(() => { - setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); - }, [teamName]); - - useEffect(() => { - setTeamPendingRepliesState(teamName, pendingRepliesByMember); - }, [pendingRepliesByMember, teamName]); - useEffect(() => { const wasProvisioning = wasProvisioningRef.current; wasProvisioningRef.current = isTeamProvisioning; @@ -1794,35 +2088,11 @@ export const TeamDetailView = memo(function TeamDetailView({ ); const leadSessionId = data?.config.leadSessionId ?? null; - const pendingReplyRefreshSourceId = useId(); const sessionHistoryKey = useMemo( () => (data?.config.sessionHistory ?? []).join('|'), [data?.config.sessionHistory] ); - // Keep team message state fresh while we are explicitly waiting for a reply. - // This stays enabled even for hidden mounted tabs, because the waiting state - // is renderer-local and should keep its lightweight polling until resolved. - useEffect(() => { - const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; - syncTeamPendingReplyRefresh( - teamName, - pendingReplyRefreshSourceId, - Boolean(data?.isAlive) && hasPendingReplies, - TEAM_PENDING_REPLY_REFRESH_DELAY_MS - ); - - return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); - }; - }, [ - data?.isAlive, - pendingRepliesByMember, - pendingReplyRefreshSourceId, - syncTeamPendingReplyRefresh, - teamName, - ]); - useEffect(() => { if (!isThisTabActive || !projectId) return; @@ -2005,6 +2275,19 @@ export const TeamDetailView = memo(function TeamDetailView({ return filterKanbanTasks(filteredTasks, kanbanSearchQuery); }, [filteredTasks, kanbanSearchQuery]); + useEffect(() => { + if (taskDetailDialogPreloadScheduledRef.current) { + return; + } + + taskDetailDialogPreloadScheduledRef.current = true; + // Start this with the team page, before slow task data can delay the first task click. + scheduleStartupIdleTask(preloadTaskDetailDialog, { + minDelayMs: 250, + maxDelayMs: 2500, + }); + }, []); + const resolvedActiveTeammateCount = useMemo( () => activeMembers.filter((m) => !isLeadMember(m)).length, [activeMembers] @@ -2198,6 +2481,10 @@ export const TeamDetailView = memo(function TeamDetailView({ setSelectedMemberView(null); }, []); + const openTaskDetailDialog = useCallback((task: TeamTaskWithKanban) => { + taskDetailDialogRef.current?.openTask(task); + }, []); + const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { setSendDialogRecipient(member.name); setSendDialogDefaultText(undefined); @@ -2213,12 +2500,15 @@ export const TeamDetailView = memo(function TeamDetailView({ [openCreateTaskDialog] ); - const handleOpenTaskById = useCallback((taskId: string) => { - const task = taskMapRef.current.get(taskId); - if (task) { - setSelectedTask(task); - } - }, []); + const handleOpenTaskById = useCallback( + (taskId: string) => { + const task = taskMapRef.current.get(taskId); + if (task) { + openTaskDetailDialog(task); + } + }, + [openTaskDetailDialog] + ); const handleOpenMessagePanelTask = useCallback( (task: TeamTaskWithKanban) => { @@ -2231,11 +2521,29 @@ export const TeamDetailView = memo(function TeamDetailView({ (taskId: string) => { const task = taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); + if (task) openTaskDetailDialog(task); }, - [taskMap, data?.tasks] + [data?.tasks, openTaskDetailDialog, taskMap] ); + const handleTaskOwnerChange = useCallback( + (taskId: string, owner: string | null) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store + } + })(); + }, + [teamName, updateTaskOwner] + ); + + const handleOpenTaskFileInEditor = useCallback((filePath: string) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }, []); + const handleEditorAction = useCallback( (action: EditorSelectionAction) => { const chip = createChipFromSelection(action, []) ?? undefined; @@ -2396,6 +2704,176 @@ export const TeamDetailView = memo(function TeamDetailView({ [selectReviewFile, taskMap] ); + const handleRequestReview = useCallback( + (taskId: string) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }, + [requestReview, teamName] + ); + + const handleApproveTask = useCallback( + (taskId: string) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }, + [teamName, updateKanban] + ); + + const handleRequestChanges = useCallback((taskId: string) => { + setRequestChangesTaskId(taskId); + }, []); + + const handleMoveBackToDone = useCallback( + (taskId: string) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }, + [teamName, updateKanban, updateTaskStatus] + ); + + const handleStartTask = useCallback( + (taskId: string) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = taskMapRef.current.get(taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }, + [data?.isAlive, startTaskByUser, teamName] + ); + + const handleCompleteTask = useCallback( + (taskId: string) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }, + [teamName, updateTaskStatus] + ); + + const handleCancelTask = useCallback( + (taskId: string) => { + void (async () => { + try { + const task = taskMapRef.current.get(taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox - they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner ? ` ${task.owner} has been notified to stop.` : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }, + [data?.isAlive, teamName, updateTaskStatus] + ); + + const handleColumnOrderChange = useCallback( + (columnId: KanbanColumnId, orderedTaskIds: string[]) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }, + [teamName, updateKanbanColumnOrder] + ); + + const handleScrollToTask = useCallback((taskId: string) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener('animationend', () => el.classList.remove('kanban-card-focus-pulse'), { + once: true, + }); + }, []); + + const handleAddTask = useCallback( + (startImmediately: boolean) => { + openCreateTaskDialog('', '', '', startImmediately); + }, + [openCreateTaskDialog] + ); + + const handleOpenTrash = useCallback(() => { + setTrashOpen(true); + }, []); + const handleDeleteTeam = useCallback((): void => { setDeleteConfirmOpen(true); }, []); @@ -2469,8 +2947,6 @@ export const TeamDetailView = memo(function TeamDetailView({ isTeamAlive: data?.isAlive, timeWindow, currentLeadSessionId: data?.config.leadSessionId, - pendingRepliesByMember, - onPendingReplyChange: setPendingRepliesByMember, onMemberClick: handleSelectMember, onTaskClick: handleOpenMessagePanelTask, onCreateTaskFromMessage: handleCreateTaskFromMessage, @@ -2493,7 +2969,6 @@ export const TeamDetailView = memo(function TeamDetailView({ handleFloatingComposerHeightChange, messagesPanelTasks, messagesPanelMountPoint, - pendingRepliesByMember, teamName, timeWindow, changeMessagesPanelMode, @@ -2941,7 +3416,6 @@ export const TeamDetailView = memo(function TeamDetailView({ expectedTeammateCount={activeTeammateCount} memberTaskCounts={memberTaskCounts} taskMap={taskMap} - pendingRepliesByMember={pendingRepliesByMember} isRosterLoading={loading} isTeamAlive={data.isAlive} isTeamProvisioning={isTeamProvisioning} @@ -2997,7 +3471,7 @@ export const TeamDetailView = memo(function TeamDetailView({ } > - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` - ); - } - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); - - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } - - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { - void (async () => { - try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); - } catch { - // error via store - } - })(); - }} - onScrollToTask={(taskId) => { - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onTaskClick={(task) => setSelectedTask(task)} + onRequestReview={handleRequestReview} + onApprove={handleApproveTask} + onRequestChanges={handleRequestChanges} + onMoveBackToDone={handleMoveBackToDone} + onStartTask={handleStartTask} + onCompleteTask={handleCompleteTask} + onCancelTask={handleCancelTask} + onColumnOrderChange={handleColumnOrderChange} + onScrollToTask={handleScrollToTask} + onTaskClick={openTaskDetailDialog} onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => - openCreateTaskDialog('', '', '', startImmediately) - } + onAddTask={handleAddTask} onDeleteTask={handleDeleteTask} deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} + onOpenTrash={handleOpenTrash} /> @@ -3167,7 +3513,7 @@ export const TeamDetailView = memo(function TeamDetailView({ teamName={teamName} tasks={data.tasks} memberColorMap={resolvedMemberColorMap} - onOpenTask={(task) => setSelectedTask(task)} + onOpenTask={openTaskDetailDialog} onViewChanges={handleViewChangesForFile} /> @@ -3215,30 +3561,32 @@ export const TeamDetailView = memo(function TeamDetailView({ )} - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view + {requestChangesTaskId !== null && ( + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; } - })(); - }} - /> + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); + } catch { + // error state is handled in the store and shown in the view + } + })(); + }} + /> + )} { closeSelectedMemberDialog(); - setSelectedTask(task); + openTaskDetailDialog(task); }} onUpdateRole={async (memberName, role) => { setUpdatingRoleLoading(true); @@ -3323,108 +3671,120 @@ export const TeamDetailView = memo(function TeamDetailView({ )} - !isLeadMember(m))} - leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} - resolvedMemberColorMap={resolvedMemberColorMap} - isTeamAlive={data.isAlive && !isTeamProvisioning} - isTeamProvisioning={isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onChangeLeadRuntime={handleChangeLeadRuntime} - onSaved={() => void selectTeam(teamName)} - /> + {editDialogOpen && ( + !isLeadMember(m))} + leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} + resolvedMemberColorMap={resolvedMemberColorMap} + isTeamAlive={data.isAlive && !isTeamProvisioning} + isTeamProvisioning={isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} + onSaved={() => void selectTeam(teamName)} + /> + )} - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - isolation: entry.isolation, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - mcpPolicy: entry.mcpPolicy, - }); + {addMemberDialogOpen && ( + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + isolation: entry.isolation, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + mcpPolicy: entry.mcpPolicy, + }); + } + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + /> + )} - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - {t('detail.removeMember.title')} - - {t('detail.removeMember.description', { member: removeMemberConfirm })} - - - - - - - - + {removeMemberConfirm !== null && ( + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + {t('detail.removeMember.title')} + + {t('detail.removeMember.description', { member: removeMemberConfirm })} + + + + + + + + + )} - - - - {t('detail.deleteTeam.title')} - - {t('detail.deleteTeam.description', { team: data.config.name })} - - - - - - - - + {deleteConfirmOpen && ( + + + + {t('detail.deleteTeam.title')} + + {t('detail.deleteTeam.description', { team: data.config.name })} + + + + + + + + + )} {launchDialogOpen && ( - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - const result = await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - if ( - shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery) - ) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - return result; - } catch (error) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - throw error; - } - }} onClose={() => { setSendDialogOpen(false); setReplyQuote(undefined); @@ -3511,67 +3833,35 @@ export const TeamDetailView = memo(function TeamDetailView({ )} - {selectedTask !== null && ( - - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - )} - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} + + {trashOpen && ( + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> + )} + {reviewDialogState.open && ( import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) ); +const TEAM_SECTION_INITIAL_VISIBLE_COUNT = 24; +const TEAM_SECTION_PAGE_SIZE = 24; + interface CreateTeamDialogLoadingFallbackProps { readonly isCopy: boolean; readonly onClose: () => void; @@ -356,6 +359,12 @@ const ActiveTeamCard = ({ const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch'; const launchLabel = launchMode === 'relaunch' ? t('list.actions.relaunchTeam') : t('list.actions.launchTeam'); + const launchTitle = + launchingTeamName === team.teamName ? t('list.actions.launching') : launchLabel; + const stopTitle = + stoppingTeamName === team.teamName ? t('list.actions.stopping') : t('list.actions.stopTeam'); + const copyTitle = t('list.actions.copyTeam'); + const deleteTitle = t('list.actions.deleteTeam'); return (
{canLaunch ? ( - - - - - - {launchingTeamName === team.teamName - ? t('list.actions.launching') - : launchLabel} - - + ) : null} {status === 'active' || status === 'idle' ? ( - - - - - - {stoppingTeamName === team.teamName - ? t('list.actions.stopping') - : t('list.actions.stopTeam')} - - + ) : null} {!team.pendingCreate ? ( - - - - - {t('list.actions.copyTeam')} - + ) : null} - - - - - {t('list.actions.deleteTeam')} - +
@@ -536,12 +518,16 @@ const ActiveTeamCard = ({ export const TeamListView = memo(function TeamListView(): React.JSX.Element { const { isLight } = useTheme(); const { t } = useAppTranslation('team'); + const { t: tCommon } = useAppTranslation('common'); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); const [copyData, setCopyData] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [filter, setFilter] = useState(EMPTY_TEAM_FILTER); const [aliveTeams, setAliveTeams] = useState([]); + const [teamSectionVisibleCountByKey, setTeamSectionVisibleCountByKey] = useState< + Record + >({}); const { teams, teamsLoading, @@ -1228,10 +1214,17 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { const activeFiltered = filteredTeams.filter((t) => !t.deletedAt); const deletedFiltered = filteredTeams.filter((t) => t.deletedAt); + const shouldPageTeamSections = !searchQuery.trim() && !hasActiveFilters; + const selectedProjectSectionKey = currentProjectPath + ? `project:${normalizePath(currentProjectPath)}` + : 'project'; + const otherTeamsSectionKey = currentProjectPath + ? `other:${normalizePath(currentProjectPath)}` + : 'other'; const activeSections = currentProjectPath ? [ { - key: 'project', + key: selectedProjectSectionKey, title: t('list.sections.projectTeams', { project: folderName(currentProjectPath) || t('list.sections.selectedProject'), }), @@ -1240,7 +1233,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { ), }, { - key: 'other', + key: otherTeamsSectionKey, title: t('list.sections.otherTeams'), teams: activeFiltered.filter( (team) => !teamMatchesProjectSelection(team, currentProjectPath) @@ -1259,58 +1252,113 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { <> {activeSections.map((section, sectionIndex) => (
0 ? 'mt-6' : undefined}> - {section.title ? ( -
-

- {section.title} -

- - {section.teams.length} - -
- ) : null} -
- {section.teams.map((team) => { - const status = resolveTeamStatus( - team, - team.teamName, - aliveTeams, - getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), - leadActivityByTeam - ); - const teamColorSet = team.color - ? getTeamColorSet(team.color) - : nameColorSet(team.displayName); - const matchesCurrentProject = currentProjectPath - ? teamMatchesProjectSelection(team, currentProjectPath) - : false; - return ( - - ); - })} -
+ {(() => { + const paged = + shouldPageTeamSections && section.teams.length > TEAM_SECTION_INITIAL_VISIBLE_COUNT; + const requestedVisibleCount = + teamSectionVisibleCountByKey[section.key] ?? TEAM_SECTION_INITIAL_VISIBLE_COUNT; + const visibleCount = paged + ? Math.min(section.teams.length, requestedVisibleCount) + : section.teams.length; + const visibleTeams = section.teams.slice(0, visibleCount); + const canShowMore = paged && visibleCount < section.teams.length; + const canShowLess = paged && visibleCount > TEAM_SECTION_INITIAL_VISIBLE_COUNT; + + return ( + <> + {section.title ? ( +
+

+ {section.title} +

+ + {section.teams.length} + +
+ ) : null} +
+ {visibleTeams.map((team) => { + const status = resolveTeamStatus( + team, + team.teamName, + aliveTeams, + getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), + leadActivityByTeam + ); + const teamColorSet = team.color + ? getTeamColorSet(team.color) + : nameColorSet(team.displayName); + const matchesCurrentProject = currentProjectPath + ? teamMatchesProjectSelection(team, currentProjectPath) + : false; + return ( + + ); + })} +
+ {(canShowMore || canShowLess) && ( +
+ {canShowMore ? ( + + ) : null} + {canShowLess ? ( + + ) : null} +
+ )} + + ); + })()}
))} diff --git a/src/renderer/components/team/UnreadCommentsBadge.test.tsx b/src/renderer/components/team/UnreadCommentsBadge.test.tsx index 9559a621..671c7dc0 100644 --- a/src/renderer/components/team/UnreadCommentsBadge.test.tsx +++ b/src/renderer/components/team/UnreadCommentsBadge.test.tsx @@ -117,4 +117,24 @@ describe('UnreadCommentsBadge', () => { await flushReact(); }); }); + + it('does not mount tooltip content while closed', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(UnreadCommentsBadge, { unreadCount: 2, totalCount: 3 })); + await flushReact(); + }); + + expect(host.textContent).not.toContain('unread comments'); + expect(host.textContent).not.toContain('total'); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); }); diff --git a/src/renderer/components/team/UnreadCommentsBadge.tsx b/src/renderer/components/team/UnreadCommentsBadge.tsx index 363f54eb..da702cae 100644 --- a/src/renderer/components/team/UnreadCommentsBadge.tsx +++ b/src/renderer/components/team/UnreadCommentsBadge.tsx @@ -1,3 +1,5 @@ +import { type JSX, memo, useCallback, useState } from 'react'; + import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { MessageSquare } from 'lucide-react'; @@ -7,17 +9,22 @@ interface UnreadCommentsBadgeProps { pulseKey?: number; } -export const UnreadCommentsBadge = ({ +export const UnreadCommentsBadge = memo(function UnreadCommentsBadge({ unreadCount, totalCount, pulseKey, -}: UnreadCommentsBadgeProps): React.JSX.Element | null => { +}: UnreadCommentsBadgeProps): JSX.Element | null { + const [open, setOpen] = useState(false); + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + }, []); + if (totalCount === 0) return null; const shouldPulse = (pulseKey ?? 0) > 0; return ( - + - - {unreadCount > 0 - ? `${unreadCount} unread comments, ${totalCount} total` - : `${totalCount} comments`} - + {open ? ( + + {unreadCount > 0 + ? `${unreadCount} unread comments, ${totalCount} total` + : `${totalCount} comments`} + + ) : null} ); -}; +}); diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index 0d724de9..a52a37fc 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -1,4 +1,4 @@ -import { memo, type ReactNode, useState } from 'react'; +import { memo, type ReactNode, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; @@ -46,33 +46,45 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({ const { t } = useAppTranslation('team'); const { isLight } = useTheme(); const [collapsed, setCollapsed] = useState(defaultCollapsed); - const colorMap = buildMemberColorMap(members); - const avatarMap = buildMemberAvatarMap(members); - const taskMap = new Map(tasks.map((t) => [t.id, t])); - const entries: ActivityEntry[] = []; - - // Members working on tasks - const workingMemberNames = new Set(); - for (const m of members) { - if (!m.currentTaskId) continue; - const task = taskMap.get(m.currentTaskId); - // Defense-in-depth: hide stale currentTaskId until backend refresh clears it. - if (!isDisplayableCurrentTask(task)) continue; - workingMemberNames.add(m.name); - entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' }); - } - - // Members reviewing tasks (only if not already shown as working) - for (const m of members) { - if (workingMemberNames.has(m.name)) continue; - const reviewTask = tasks.find( - (t) => t.reviewer === m.name && getTeamTaskWorkflowColumn(t) === 'review' - ); - if (reviewTask) { - entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' }); + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]); + const entries = useMemo(() => { + const taskMap = new Map(tasks.map((task) => [task.id, task])); + const reviewTaskByReviewer = new Map(); + for (const task of tasks) { + if (!task.reviewer || getTeamTaskWorkflowColumn(task) !== 'review') continue; + if (!reviewTaskByReviewer.has(task.reviewer)) { + reviewTaskByReviewer.set(task.reviewer, task); + } } - } + + const nextEntries: ActivityEntry[] = []; + const workingMemberNames = new Set(); + for (const member of members) { + if (!member.currentTaskId) continue; + const task = taskMap.get(member.currentTaskId); + // Defense-in-depth: hide stale currentTaskId until backend refresh clears it. + if (!isDisplayableCurrentTask(task)) continue; + workingMemberNames.add(member.name); + nextEntries.push({ member, task, taskId: member.currentTaskId, kind: 'working' }); + } + + for (const member of members) { + if (workingMemberNames.has(member.name)) continue; + const reviewTask = reviewTaskByReviewer.get(member.name); + if (reviewTask) { + nextEntries.push({ + member, + task: reviewTask, + taskId: reviewTask.id, + kind: 'reviewing', + }); + } + } + + return nextEntries; + }, [members, tasks]); if (entries.length === 0) return null; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index fac70448..6c04f9bf 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -10,12 +10,7 @@ import { AttachmentDisplay } from '@renderer/components/team/attachments/Attachm import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@renderer/components/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, @@ -57,7 +52,6 @@ import { parseCrossTeamPrefix, stripCrossTeamPrefix, } from '@shared/constants/crossTeam'; -import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { buildStandaloneSlashCommandMeta, @@ -85,6 +79,14 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import { + encodeCacheParts, + extractMarkdownPlainTextCached, + getCachedString, + stringArrayCacheSignature, + stringMapCacheSignature, + taskRefsCacheSignature, +} from './activityRenderCache'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; import type { TeamColorSet } from '@renderer/constants/teamColors'; @@ -92,6 +94,30 @@ import type { InboxMessage } from '@shared/types'; type StructuredMessage = Record; +interface PermissionStatusIconProps { + requestId: string; +} + +const PermissionStatusIcon = memo(function PermissionStatusIcon({ + requestId, +}: PermissionStatusIconProps): React.JSX.Element { + const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); + const resolvedApprovals = useStore(useShallow((s) => s.resolvedApprovals)); + + const resolved = resolvedApprovals.get(requestId); + if (resolved === true) { + return ; + } + if (resolved === false) { + return ; + } + const isPending = pendingApprovals.some((a) => a.requestId === requestId); + if (isPending) { + return ; + } + return ; +}); + function parseQualifiedRecipient( value: string | undefined ): { teamName: string; memberName: string } | null { @@ -254,14 +280,87 @@ function getStringField(obj: StructuredMessage, key: string): string | null { return typeof value === 'string' && value.trim() !== '' ? value : null; } +const EMPTY_MEMBER_COLOR_MAP = new Map(); +const MAX_ACTIVITY_ITEM_CACHE_ENTRIES = 500; +const activityTimestampCache = new Map(); +const activityDisplayTextCache = new Map(); +const activityStructuredMessageCache = new Map(); +const activityIdleSemanticCache = new Map>(); +const activityNoiseMessageCache = new Map(); +const activityStrippedTextCache = new Map(); + +function getCachedActivityValue(cache: Map, key: string, buildValue: () => T): T { + if (cache.has(key)) return cache.get(key) as T; + + const value = buildValue(); + if (cache.size >= MAX_ACTIVITY_ITEM_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) cache.delete(oldestKey); + } + cache.set(key, value); + return value; +} + +function parseStructuredAgentMessageCached(text: string): StructuredMessage | null { + return getCachedActivityValue(activityStructuredMessageCache, text, () => + parseStructuredAgentMessage(text) + ); +} + +function classifyIdleNotificationCached( + message: InboxMessage +): ReturnType { + return getCachedActivityValue(activityIdleSemanticCache, message.text, () => + classifyIdleNotification(message) + ); +} + +function getStrippedActivityTextCached({ + message, + structured, + hasBootstrapDisplay, + isCrossTeamAny, +}: { + message: InboxMessage; + structured: StructuredMessage | null; + hasBootstrapDisplay: boolean; + isCrossTeamAny: boolean; +}): string | null { + if (structured) return null; + + const cacheKey = encodeCacheParts([ + message.text ?? '', + message.from ?? '', + message.to ?? '', + message.source ?? '', + hasBootstrapDisplay ? '1' : '0', + isCrossTeamAny ? '1' : '0', + ]); + + return getCachedActivityValue(activityStrippedTextCache, cacheKey, () => { + let stripped = getSanitizedInboxMessageText(message).trim(); + if (!hasBootstrapDisplay) { + stripped = stripAgentBlocks(stripped).trim(); + } + if (!stripped) return null; + if (isCrossTeamAny) { + stripped = stripCrossTeamPrefix(stripped); + } + return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); + }); +} + /** Check if a message renders as a compact noise row (idle, shutdown, etc.). */ export function isNoiseMessage(text: string): boolean { - return ( - getIdleNoiseLabel(text) !== null || - ((): boolean => { - const parsed = parseStructuredAgentMessage(text); - return parsed !== null && getNoiseLabel(parsed) !== null; - })() + return getCachedActivityValue( + activityNoiseMessageCache, + text, + () => + getIdleNoiseLabel(text) !== null || + (() => { + const parsed = parseStructuredAgentMessageCached(text); + return parsed !== null && getNoiseLabel(parsed) !== null; + })() ); } @@ -338,6 +437,7 @@ const PassiveIdlePeerSummaryRow = ({ teamName, senderName, senderColor, + isLight, summary, timestamp, onMemberNameClick, @@ -345,6 +445,7 @@ const PassiveIdlePeerSummaryRow = ({ teamName: string; senderName: string; senderColor?: string; + isLight: boolean; summary: string; timestamp: string; onMemberNameClick?: (memberName: string) => void; @@ -361,6 +462,7 @@ const PassiveIdlePeerSummaryRow = ({ name={senderName} color={senderColor} teamName={teamName} + isLight={isLight} hideAvatar={false} onClick={onMemberNameClick} /> @@ -392,6 +494,7 @@ const TaskStallRemediationRow = ({ teamName, recipientName, recipientColor, + isLight, taskRef, timestamp, onMemberNameClick, @@ -400,6 +503,7 @@ const TaskStallRemediationRow = ({ teamName: string; recipientName: string; recipientColor?: string; + isLight: boolean; taskRef?: NonNullable[number]; timestamp: string; onMemberNameClick?: (memberName: string) => void; @@ -426,6 +530,7 @@ const TaskStallRemediationRow = ({ name={recipientName} color={recipientColor} teamName={teamName} + isLight={isLight} hideAvatar onClick={onMemberNameClick} /> @@ -458,6 +563,7 @@ const MemberWorkSyncNudgeRow = ({ teamName, recipientName, recipientColor, + isLight, taskRefs, intent, timestamp, @@ -467,6 +573,7 @@ const MemberWorkSyncNudgeRow = ({ teamName: string; recipientName: string; recipientColor?: string; + isLight: boolean; taskRefs?: InboxMessage['taskRefs']; intent?: InboxMessage['workSyncIntent']; timestamp: string; @@ -500,6 +607,7 @@ const MemberWorkSyncNudgeRow = ({ name={recipientName} color={recipientColor} teamName={teamName} + isLight={isLight} hideAvatar onClick={onMemberNameClick} /> @@ -537,6 +645,7 @@ const BootstrapSystemRow = ({ runtime, senderColor, recipientColor, + isLight, timestamp, onMemberNameClick, }: { @@ -547,6 +656,7 @@ const BootstrapSystemRow = ({ runtime?: string; senderColor?: string; recipientColor?: string; + isLight: boolean; timestamp: string; onMemberNameClick?: (memberName: string) => void; }): React.JSX.Element => { @@ -565,6 +675,7 @@ const BootstrapSystemRow = ({ name={senderName} color={senderColor} teamName={teamName} + isLight={isLight} hideAvatar onClick={onMemberNameClick} /> @@ -573,6 +684,7 @@ const BootstrapSystemRow = ({ name={recipientName} color={recipientColor} teamName={teamName} + isLight={isLight} hideAvatar onClick={onMemberNameClick} /> @@ -593,6 +705,7 @@ const BootstrapAcknowledgementRow = ({ recipientName, senderColor, recipientColor, + isLight, timestamp, onMemberNameClick, }: { @@ -601,6 +714,7 @@ const BootstrapAcknowledgementRow = ({ recipientName: string; senderColor?: string; recipientColor?: string; + isLight: boolean; timestamp: string; onMemberNameClick?: (memberName: string) => void; }): React.JSX.Element => { @@ -614,6 +728,7 @@ const BootstrapAcknowledgementRow = ({ name={senderName} color={senderColor} teamName={teamName} + isLight={isLight} hideAvatar onClick={onMemberNameClick} /> @@ -622,6 +737,7 @@ const BootstrapAcknowledgementRow = ({ name={recipientName} color={recipientColor} teamName={teamName} + isLight={isLight} hideAvatar onClick={onMemberNameClick} /> @@ -691,6 +807,61 @@ const AUTH_ERROR_PATTERNS = [ /unauthorized/i, ]; +function getLocalDayCacheKey(date: Date): string { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function formatActivityTimestamp(timestamp: string): string { + const now = new Date(); + return getCachedString( + activityTimestampCache, + encodeCacheParts([timestamp, getLocalDayCacheKey(now)]), + () => { + const parsed = Date.parse(timestamp); + if (Number.isNaN(parsed)) return timestamp; + + const date = new Date(parsed); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + + return isToday + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleString(); + } + ); +} + +function buildActivityDisplayText( + strippedText: string, + isSystem: boolean, + taskRefs: InboxMessage['taskRefs'], + memberColorMap: Map | undefined, + teamNames: readonly string[] +): string { + const cacheKey = encodeCacheParts([ + strippedText, + isSystem ? '1' : '0', + taskRefsCacheSignature(taskRefs), + stringMapCacheSignature(memberColorMap), + stringArrayCacheSignature(teamNames), + ]); + + return getCachedString(activityDisplayTextCache, cacheKey, () => { + let result = highlightSystemLabels(strippedText, isSystem); + result = linkifyTaskIdsInMarkdown(result, taskRefs); + if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { + result = linkifyAllMentionsInMarkdown( + result, + memberColorMap ?? EMPTY_MEMBER_COLOR_MAP, + teamNames + ); + } + return result; + }); +} + // --------------------------------------------------------------------------- // Full message card — left colored border, name badge, collapsible content // --------------------------------------------------------------------------- @@ -829,20 +1000,12 @@ export const ActivityItem = memo( const formattedRole = memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null; - const timestamp = useMemo(() => { - if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp; - const date = new Date(message.timestamp); - const now = new Date(); - const isToday = - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate(); - return isToday - ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : date.toLocaleString(); - }, [message.timestamp]); + const timestamp = useMemo( + () => formatActivityTimestamp(message.timestamp), + [message.timestamp] + ); - const structured = parseStructuredAgentMessage(message.text); + const structured = parseStructuredAgentMessageCached(message.text); const bootstrapDisplay = getBootstrapPromptDisplay(message); const bootstrapAcknowledgement = getBootstrapAcknowledgementDisplay(message); // Only flag agent messages as rate-limited, not user's own quotes @@ -853,7 +1016,7 @@ export const ActivityItem = memo( const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text)); // Never collapse rate limit messages as noise — they must be visible const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; - const idleSemantic = classifyIdleNotification(message); + const idleSemantic = classifyIdleNotificationCached(message); const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const isManaged = collapseMode === 'managed'; @@ -896,21 +1059,16 @@ export const ActivityItem = memo( const isSystemMessage = message.from === 'system'; // Strip agent-only blocks + normalize escape sequences (before linkification) - const strippedText = useMemo(() => { - if (structured) return null; - let stripped = getSanitizedInboxMessageText(message).trim(); - if (!bootstrapDisplay) { - stripped = stripAgentBlocks(stripped).trim(); - } - if (!stripped) return null; // All content was agent-only blocks → show summary instead - // Strip cross-team metadata tag (e.g. `\n`) - // — kept in stored text for CLI agents / durable artifacts. - if (isCrossTeamAny) { - stripped = stripCrossTeamPrefix(stripped); - } - // Normalize literal \n from historical CLI-produced text to real newlines - return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); - }, [structured, message, bootstrapDisplay, isCrossTeamAny]); + const strippedText = useMemo( + () => + getStrippedActivityTextCached({ + message, + structured, + hasBootstrapDisplay: bootstrapDisplay !== null, + isCrossTeamAny, + }), + [structured, message, bootstrapDisplay, isCrossTeamAny] + ); const standaloneSlashCommand = useMemo( () => (strippedText ? parseStandaloneSlashCommand(strippedText) : null), [strippedText] @@ -944,12 +1102,14 @@ export const ActivityItem = memo( // Linkify task IDs (always, for TaskTooltip) + @mentions for display const displayText = useMemo(() => { if (!strippedText) return null; - let result = highlightSystemLabels(strippedText, !!systemLabel); - result = linkifyTaskIdsInMarkdown(result, message.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) - result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames); - return result; - }, [strippedText, memberColorMap, teamNames, systemLabel]); + return buildActivityDisplayText( + strippedText, + !!systemLabel, + message.taskRefs, + memberColorMap, + teamNames + ); + }, [strippedText, message.taskRefs, memberColorMap, teamNames, systemLabel]); const crossTeamPreview = useMemo(() => { if (!isCrossTeamAny || !strippedText) return ''; @@ -992,7 +1152,7 @@ export const ActivityItem = memo( slashCommandMeta, structured, ]); - const summaryText = extractMarkdownPlainText(rawSummary); + const summaryText = extractMarkdownPlainTextCached(rawSummary); const compactPreviewMarkdown = useMemo(() => { if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) { return idleSemantic.peerSummary; @@ -1028,7 +1188,7 @@ export const ActivityItem = memo( summaryText, ]); const compactPreviewTooltipText = useMemo(() => { - const normalized = extractMarkdownPlainText(compactPreviewMarkdown) + const normalized = extractMarkdownPlainTextCached(compactPreviewMarkdown) .replace(/\n+/g, ' ') .trim(); return normalized || compactPreviewMarkdown; @@ -1039,30 +1199,16 @@ export const ActivityItem = memo( commentTaskRef?.displayId ?? (commentTaskRef?.taskId ? `#${commentTaskRef.taskId.slice(0, 6)}` : null); - // Permission request status icon (check/x/clock) - const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); - const resolvedApprovals = useStore(useShallow((s) => s.resolvedApprovals)); - const permissionIcon = useMemo(() => { + const permissionRequestId = useMemo(() => { if (!structured) return null; const type = typeof structured.type === 'string' ? structured.type : null; if (type !== 'permission_request') return null; const requestId = typeof structured.request_id === 'string' ? structured.request_id : null; - if (!requestId) return null; - - const resolved = resolvedApprovals.get(requestId); - if (resolved === true) { - return ; - } - if (resolved === false) { - return ; - } - const isPending = pendingApprovals.some((a) => a.requestId === requestId); - if (isPending) { - return ; - } - // Not in pending and not resolved — already handled before we started tracking - return ; - }, [structured, pendingApprovals, resolvedApprovals]); + return requestId || null; + }, [structured]); + const permissionIcon = permissionRequestId ? ( + + ) : null; // Noise messages: minimal inline row if (noiseLabel) { @@ -1077,6 +1223,7 @@ export const ActivityItem = memo( teamName={teamName} senderName={senderName} senderColor={senderColor} + isLight={isLight} summary={idleSemantic.peerSummary} timestamp={timestamp} onMemberNameClick={onMemberNameClick} @@ -1090,6 +1237,7 @@ export const ActivityItem = memo( teamName={teamName} recipientName={message.to ?? 'teammate'} recipientColor={recipientColor} + isLight={isLight} taskRef={message.taskRefs?.[0]} timestamp={timestamp} onMemberNameClick={onMemberNameClick} @@ -1104,6 +1252,7 @@ export const ActivityItem = memo( teamName={teamName} recipientName={message.to ?? 'teammate'} recipientColor={recipientColor} + isLight={isLight} taskRefs={message.taskRefs} intent={message.workSyncIntent} timestamp={timestamp} @@ -1123,6 +1272,7 @@ export const ActivityItem = memo( runtime={bootstrapDisplay.runtime} senderColor={senderColor} recipientColor={recipientColor} + isLight={isLight} timestamp={timestamp} onMemberNameClick={onMemberNameClick} /> @@ -1137,6 +1287,7 @@ export const ActivityItem = memo( recipientName={message.to ?? 'lead'} senderColor={senderColor} recipientColor={recipientColor} + isLight={isLight} timestamp={timestamp} onMemberNameClick={onMemberNameClick} /> @@ -1176,6 +1327,7 @@ export const ActivityItem = memo( name={senderName} color={senderColor} teamName={teamName} + isLight={isLight} hideAvatar={senderHideAvatar || compactHeader} onClick={onMemberNameClick} disableHoverCard={crossTeamOrigin != null} @@ -1254,6 +1406,7 @@ export const ActivityItem = memo( name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to} color={crossTeamTarget ? undefined : recipientColor} teamName={crossTeamTarget ? undefined : teamName} + isLight={isLight} hideAvatar={ compactHeader || (crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) === 'user' @@ -1310,7 +1463,7 @@ export const ActivityItem = memo( return (
- - - -
- -
-
- - {compactPreviewTooltipText} - -
-
+
+ +
) : !isExpanded ? (
@@ -1506,27 +1646,14 @@ export const ActivityItem = memo( )}
- - - -
- -
-
- - {compactPreviewTooltipText} - -
-
+
+ +
) : ( <> diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index da99c9f3..1c2da89b 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -83,6 +83,8 @@ export interface TimelineViewport { scrollMargin?: number; /** Enable virtualization (wired in a follow-up; ignored for now). */ virtualizationEnabled?: boolean; + /** Optional row-count gate for compact hosts that need virtualization earlier. */ + virtualizationRowThreshold?: number; } interface ActivityTimelineProps { @@ -692,7 +694,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ const shouldVirtualize = viewport?.virtualizationEnabled === true && viewport.scrollElementRef != null && - renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD; + renderRows.length >= (viewport.virtualizationRowThreshold ?? VIRTUALIZATION_ROW_THRESHOLD); // DOM-measured distance from the scroll container's scroll origin to the // timeline root. We avoid re-measuring on every scroll: the offset only diff --git a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx index 8ba8ed41..86c86b02 100644 --- a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx +++ b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx @@ -1,6 +1,6 @@ -import { type JSX, useCallback, useEffect, useRef, useState } from 'react'; +import { Component, type JSX, useCallback, useEffect, useRef, useState } from 'react'; -import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react'; +import type { CSSProperties, PropsWithChildren, ReactNode, Ref } from 'react'; export const ENTRY_REVEAL_ANIMATION_MS = 700; export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; @@ -18,11 +18,69 @@ function assignRef(ref: Ref | undefined, value: T | null): void { ref(value); return; } - const mutableRef = ref as MutableRefObject; + const mutableRef = ref as { current: T | null }; mutableRef.current = value; } -export const AnimatedHeightReveal = ({ +function needsAnimatedWrapper(props: AnimatedHeightRevealProps): boolean { + return Boolean(props.animate || props.className || props.style || props.containerRef); +} + +const AnimatedHeightRevealPassthrough = ({ children }: PropsWithChildren): JSX.Element => ( + // eslint-disable-next-line react/jsx-no-useless-fragment -- preserves a DOM-free passthrough slot. + <>{children} +); + +class AnimatedHeightRevealSlot extends Component { + private hasRenderedInner = needsAnimatedWrapper(this.props); + + // eslint-disable-next-line sonarjs/function-return-type -- latch intentionally switches from passthrough to animated slot once. + render(): ReactNode { + const { animate, className, style, containerRef, children } = this.props; + const needsWrapper = needsAnimatedWrapper(this.props); + if (needsWrapper) { + this.hasRenderedInner = true; + } + + if (!this.hasRenderedInner) { + return {children}; + } + + return ( + + {children} + + ); + } +} + +export const AnimatedHeightReveal = (props: AnimatedHeightRevealProps): JSX.Element => { + // Latch the inner (hook-bearing, animating) variant for the lifetime of this slot. + // A call site that only passes `animate` (e.g. animate={isNewItem}) flips it true->false + // on the render right after the item appears. Without the latch the returned element type + // would switch from AnimatedHeightRevealInner to a bare Fragment on that flip, so React + // would unmount the inner subtree mid-reveal - aborting the entry animation and remounting + // the children (losing focus/internal state). Once the inner variant has rendered we keep + // rendering it so the element type stays stable; items that never need it keep the + // hook-free fast path. + return ( + + {props.children} + + ); +}; + +const AnimatedHeightRevealInner = ({ animate, className, style, diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 415729e9..40090fa3 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -13,20 +13,15 @@ import { import { useAppTranslation } from '@features/localization/renderer'; import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@renderer/components/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, CARD_BORDER_STYLE, CARD_ICON_MUTED, - CARD_TEXT_LIGHT, } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { areStringArraysEqual, @@ -37,12 +32,16 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; -import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; import { buildThoughtDisplayContent } from './activityMarkdown'; +import { + encodeCacheParts, + extractMarkdownPlainTextCached, + getCachedString, +} from './activityRenderCache'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, @@ -155,6 +154,7 @@ const LIVE_WINDOW_MS = 5_000; const COLLAPSED_THOUGHTS_HEIGHT = 200; const AUTO_SCROLL_THRESHOLD = 30; const THOUGHT_HEIGHT_ANIMATION_MS = ENTRY_REVEAL_ANIMATION_MS; +const leadThoughtTimeCache = new Map(); interface LeadThoughtsGroupRowProps { group: LeadThoughtGroup; @@ -206,15 +206,19 @@ interface LeadThoughtsGroupRowProps { } function formatTime(timestamp: string): string { - const d = new Date(timestamp); - if (Number.isNaN(d.getTime())) return timestamp; - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return getCachedString(leadThoughtTimeCache, encodeCacheParts(['minute', timestamp]), () => { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }); } export function formatTimeWithSec(timestamp: string): string { - const d = new Date(timestamp); - if (Number.isNaN(d.getTime())) return timestamp; - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + return getCachedString(leadThoughtTimeCache, encodeCacheParts(['second', timestamp]), () => { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + }); } function isRecentTimestamp(timestamp: string): boolean { @@ -363,8 +367,16 @@ const LeadThoughtItem = memo( useLayoutEffect(() => { const wrapper = wrapperRef.current; + if (!wrapper) return; + + if (!shouldAnimateOnMount) { + initialAnimationCompletedRef.current = true; + resetWrapperStyles(); + return; + } + const content = contentRef.current; - if (!wrapper || !content) return; + if (!content) return; const applyTransition = (targetHeight: number): void => { wrapper.style.transition = [ @@ -412,12 +424,6 @@ const LeadThoughtItem = memo( const previousHeight = previousHeightRef.current; previousHeightRef.current = nextHeight; - if (!shouldAnimateOnMount) { - initialAnimationCompletedRef.current = true; - resetWrapperStyles(); - return; - } - if (previousHeight === null) { if (nextHeight > 0 && animateFromZero) { animateHeight(nextHeight, 0, 0); @@ -562,6 +568,7 @@ const LeadThoughtsGroupRowComponent = ({ expandItemKey, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const { t } = useAppTranslation('team'); + const { isLight } = useTheme(); const ref = useRef(null); const scrollRef = useRef(null); const contentRef = useRef(null); @@ -576,9 +583,6 @@ const LeadThoughtsGroupRowComponent = ({ const oldest = thoughts[thoughts.length - 1]; const leadName = newest.from; - // Chronological order for rendering (oldest at top, newest at bottom) - const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]); - // Aggregate tool usage across all thoughts in this group const totalToolSummary = useMemo(() => { const merged: Record = {}; @@ -604,8 +608,22 @@ const LeadThoughtsGroupRowComponent = ({ return calls.length > 0 ? calls : undefined; }, [thoughts]); + const [expanded, setExpanded] = useState(false); + const [needsTruncation, setNeedsTruncation] = useState(false); + const isManaged = collapseMode === 'managed'; + const isBodyVisible = isManaged ? !isCollapsed : true; + const canToggleBodyVisibility = isManaged && canToggleCollapse; + const useCompactCollapsedHeader = compactHeader && !isBodyVisible; + + // Chronological order for rendering (oldest at top, newest at bottom) + const chronologicalThoughts = useMemo( + () => (isBodyVisible ? [...thoughts].reverse() : []), + [isBodyVisible, thoughts] + ); + // Reuse the same markdown preprocessing as the expanded thought body. const compactPreviewMarkdown = useMemo(() => { + if (isBodyVisible) return null; // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { @@ -621,9 +639,10 @@ const LeadThoughtsGroupRowComponent = ({ } } return totalToolSummary; - }, [memberColorMap, teamNames, thoughts, totalToolSummary]); + }, [isBodyVisible, memberColorMap, teamNames, thoughts, totalToolSummary]); const compactPreviewTooltipText = useMemo(() => { - const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '') + if (!compactPreviewMarkdown) return null; + const normalized = extractMarkdownPlainTextCached(compactPreviewMarkdown ?? '') .replace(/\n+/g, ' ') .trim(); return normalized || compactPreviewMarkdown; @@ -632,11 +651,6 @@ const LeadThoughtsGroupRowComponent = ({ // Detect if any thought in this group is an API error const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); - const [expanded, setExpanded] = useState(false); - const [needsTruncation, setNeedsTruncation] = useState(false); - const isManaged = collapseMode === 'managed'; - const isBodyVisible = isManaged ? !isCollapsed : true; - const canToggleBodyVisibility = isManaged && canToggleCollapse; const handleBodyToggle = useCallback(() => { if (canToggleBodyVisibility && collapseToggleKey) { onToggleCollapse?.(collapseToggleKey); @@ -788,16 +802,15 @@ const LeadThoughtsGroupRowComponent = ({ }); }, []); - const timestampLabel = - formatTime(oldest.timestamp) === formatTime(newest.timestamp) - ? formatTime(oldest.timestamp) - : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; - const useCompactCollapsedHeader = compactHeader && !isBodyVisible; - + const timestampLabel = useMemo(() => { + const oldestTime = formatTime(oldest.timestamp); + const newestTime = formatTime(newest.timestamp); + return oldestTime === newestTime ? oldestTime : `${oldestTime}–${newestTime}`; + }, [newest.timestamp, oldest.timestamp]); return (
- + {t('activity.thoughts.count', { count: thoughts.length })} @@ -866,27 +879,14 @@ const LeadThoughtsGroupRowComponent = ({
{compactPreviewMarkdown ? ( - - - -
- -
-
- - {compactPreviewTooltipText} - -
-
+
+ +
) : null} ) : !isBodyVisible ? ( @@ -918,7 +918,7 @@ const LeadThoughtsGroupRowComponent = ({ /> ) : null} - + {t('activity.thoughts.count', { count: thoughts.length })} @@ -951,27 +951,14 @@ const LeadThoughtsGroupRowComponent = ({ {compactPreviewMarkdown ? ( - - - -
- -
-
- - {compactPreviewTooltipText} - -
-
+
+ +
) : null} ) : ( @@ -1002,7 +989,7 @@ const LeadThoughtsGroupRowComponent = ({ /> ) : null} - + {t('activity.thoughts.count', { count: thoughts.length })} diff --git a/src/renderer/components/team/activity/activityMarkdown.ts b/src/renderer/components/team/activity/activityMarkdown.ts index 02776c70..50e1bfde 100644 --- a/src/renderer/components/team/activity/activityMarkdown.ts +++ b/src/renderer/components/team/activity/activityMarkdown.ts @@ -3,6 +3,14 @@ import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; +import { + encodeCacheParts, + getCachedString, + stringArrayCacheSignature, + stringMapCacheSignature, + taskRefsCacheSignature, +} from './activityRenderCache'; + import type { InboxMessage } from '@shared/types'; interface ThoughtDisplayContentOptions { @@ -10,6 +18,9 @@ interface ThoughtDisplayContentOptions { stripAgentOnlyBlocks?: boolean; } +const EMPTY_MEMBER_COLOR_MAP = new Map(); +const thoughtDisplayContentCache = new Map(); + export function buildThoughtDisplayContent( thought: Pick, memberColorMap?: ReadonlyMap, @@ -17,20 +28,31 @@ export function buildThoughtDisplayContent( options: ThoughtDisplayContentOptions = {} ): string { const { preserveLineBreaks = true, stripAgentOnlyBlocks = false } = options; - let text = stripTeammateMessageBlocks(thought.text); - if (stripAgentOnlyBlocks) { - text = stripAgentBlocks(text); - } - if (preserveLineBreaks) { - text = text.replace(/\n/g, ' \n'); - } - text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { - text = linkifyAllMentionsInMarkdown( - text, - (memberColorMap ?? new Map()) as Map, - teamNames - ); - } - return text; + const cacheKey = encodeCacheParts([ + thought.text, + taskRefsCacheSignature(thought.taskRefs), + stringMapCacheSignature(memberColorMap), + stringArrayCacheSignature(teamNames), + preserveLineBreaks ? '1' : '0', + stripAgentOnlyBlocks ? '1' : '0', + ]); + + return getCachedString(thoughtDisplayContentCache, cacheKey, () => { + let text = stripTeammateMessageBlocks(thought.text); + if (stripAgentOnlyBlocks) { + text = stripAgentBlocks(text); + } + if (preserveLineBreaks) { + text = text.replace(/\n/g, ' \n'); + } + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); + if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { + text = linkifyAllMentionsInMarkdown( + text, + (memberColorMap ?? EMPTY_MEMBER_COLOR_MAP) as Map, + teamNames + ); + } + return text; + }); } diff --git a/src/renderer/components/team/activity/activityRenderCache.ts b/src/renderer/components/team/activity/activityRenderCache.ts new file mode 100644 index 00000000..881d04cb --- /dev/null +++ b/src/renderer/components/team/activity/activityRenderCache.ts @@ -0,0 +1,90 @@ +import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; + +import type { TaskRef } from '@shared/types'; + +const MAX_ACTIVITY_RENDER_CACHE_ENTRIES = 500; + +type StringCache = Map; + +const taskRefsSignatureCache = new WeakMap(); +const stringArraySignatureCache = new WeakMap(); +const stringMapSignatureCache = new WeakMap, string>(); + +export function getCachedString(cache: StringCache, key: string, buildValue: () => string): string { + const cached = cache.get(key); + if (cached !== undefined || cache.has(key)) return cached ?? ''; + + const value = buildValue(); + if (cache.size >= MAX_ACTIVITY_RENDER_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) cache.delete(oldestKey); + } + cache.set(key, value); + return value; +} + +export function encodeCacheParts(parts: readonly string[]): string { + let encoded = ''; + for (let index = 0; index < parts.length; index += 1) { + if (index > 0) encoded += '|'; + const part = parts[index]; + encoded += `${part.length}:${part}`; + } + return encoded; +} + +export function taskRefsCacheSignature(taskRefs?: readonly TaskRef[]): string { + if (!taskRefs || taskRefs.length === 0) return ''; + const cached = taskRefsSignatureCache.get(taskRefs); + if (cached !== undefined) return cached; + + let encoded = ''; + let hasPart = false; + for (let index = 0; index < taskRefs.length; index += 1) { + const ref = taskRefs[index]; + const parts = [ref.taskId, ref.displayId, ref.teamName ?? '']; + for (const part of parts) { + if (hasPart) encoded += '|'; + encoded += `${part.length}:${part}`; + hasPart = true; + } + } + taskRefsSignatureCache.set(taskRefs, encoded); + return encoded; +} + +export function stringArrayCacheSignature(values?: readonly string[]): string { + if (!values || values.length === 0) return ''; + const cached = stringArraySignatureCache.get(values); + if (cached !== undefined) return cached; + const signature = encodeCacheParts(values); + stringArraySignatureCache.set(values, signature); + return signature; +} + +export function stringMapCacheSignature(map?: ReadonlyMap): string { + if (!map || map.size === 0) return ''; + const cached = stringMapSignatureCache.get(map); + if (cached !== undefined) return cached; + + const entries = [...map.entries()].sort(([a], [b]) => a.localeCompare(b)); + let encoded = ''; + let hasPart = false; + for (const [key, value] of entries) { + if (hasPart) encoded += '|'; + encoded += `${key.length}:${key}`; + hasPart = true; + encoded += `|${value.length}:${value}`; + } + stringMapSignatureCache.set(map, encoded); + return encoded; +} + +const markdownPlainTextCache: StringCache = new Map(); + +export function extractMarkdownPlainTextCached(markdown: string): string { + if (!markdown) return ''; + return getCachedString(markdownPlainTextCache, markdown, () => + extractMarkdownPlainText(markdown) + ); +} diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 2611df99..a267d132 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -107,7 +107,7 @@ import { isDeletedProjectPathSelection, isSelectableProjectPathProject, } from './projectPathOptions'; -import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; +import { loadProjectPathProjects, syntheticProjectFromPath } from './projectPathProjects'; import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { @@ -157,6 +157,7 @@ import { } from './WorktreeGitReadinessBanner'; import type { ActiveTeamRef } from './CreateTeamDialog'; +import type { ProjectPathProject } from './projectPathProjects'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { @@ -433,6 +434,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); + const [projectsLoadRequested, setProjectsLoadRequested] = useState(false); const [localError, setLocalError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -1847,9 +1849,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups)); const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; + const shouldDeferProjectListLoad = + isLaunchMode && + !projectsLoadRequested && + typeof defaultProjectPath === 'string' && + defaultProjectPath.length > 0 && + !isEphemeralProjectPath(defaultProjectPath); + const requestProjectListLoad = useCallback(() => { + setProjectsLoadRequested(true); + }, []); useEffect(() => { - if (!open) return; + if (!open) { + setProjectsLoadRequested(false); + return; + } + + if (shouldDeferProjectListLoad && defaultProjectPath) { + setProjects([syntheticProjectFromPath(defaultProjectPath)]); + setProjectsLoading(false); + setProjectsError(null); + return; + } setProjectsLoading(true); setProjectsError(null); @@ -1878,7 +1899,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, repositoryGroups, defaultProjectPath, t]); + }, [open, repositoryGroups, defaultProjectPath, shouldDeferProjectListLoad, t]); // Pre-select defaultProjectPath (launch mode) or first project @@ -2699,6 +2720,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen projects={projects} projectsLoading={projectsLoading} projectsError={projectsError} + onProjectsDropdownOpen={requestProjectListLoad} /> {/* ═══════════════════════════════════════════════════════════════════ diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index c979e1db..6e495148 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -132,6 +132,7 @@ interface ProjectPathSelectorProps { projectsLoading: boolean; projectsError: string | null; fieldError?: string | null; + onProjectsDropdownOpen?: () => void; } export const ProjectPathSelector = ({ @@ -145,6 +146,7 @@ export const ProjectPathSelector = ({ projectsLoading, projectsError, fieldError, + onProjectsDropdownOpen, }: ProjectPathSelectorProps): React.JSX.Element => { const { t } = useAppTranslation('team'); const projectOptions = React.useMemo( @@ -202,6 +204,11 @@ export const ProjectPathSelector = ({ searchPlaceholder={t('projectPath.searchPlaceholder')} emptyMessage={t('projectPath.empty')} disabled={projectsLoading || projectOptions.length === 0} + onOpenChange={(isOpen) => { + if (isOpen) { + onProjectsDropdownOpen?.(); + } + }} renderTriggerLabel={(option) => ( diff --git a/src/renderer/components/team/dialogs/projectPathProjects.ts b/src/renderer/components/team/dialogs/projectPathProjects.ts index b180a5bc..2239175e 100644 --- a/src/renderer/components/team/dialogs/projectPathProjects.ts +++ b/src/renderer/components/team/dialogs/projectPathProjects.ts @@ -99,7 +99,7 @@ function repositoryWorktreeToProject( }; } -function syntheticProjectFromPath(projectPath: string): Project { +export function syntheticProjectFromPath(projectPath: string): Project { return { id: projectPath.replace(/[/\\]/g, '-'), path: projectPath, diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index 765baf7d..d8a393df 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { Button } from '@renderer/components/ui/button'; @@ -46,6 +46,7 @@ export const KanbanFilterPopover = ({ onFilterChange, }: KanbanFilterPopoverProps): React.JSX.Element => { const { t } = useAppTranslation('team'); + const [open, setOpen] = useState(false); const activeCount = useMemo(() => { let count = 0; if (filter.sessionId !== null) count += 1; @@ -83,7 +84,7 @@ export const KanbanFilterPopover = ({ }; return ( - + @@ -104,111 +105,113 @@ export const KanbanFilterPopover = ({ {t('kanban.filter.title')} - - {/* Session section */} -
-

- {t('kanban.filter.session')} -

-
- - {sessions.map((session) => { - const isLead = session.id === leadSessionId; - const isSelected = filter.sessionId === session.id; - const label = formatSessionLabel(session.firstMessage) || session.id.slice(0, 8); - return ( - + {sessions.map((session) => { + const isLead = session.id === leadSessionId; + const isSelected = filter.sessionId === session.id; + const label = formatSessionLabel(session.firstMessage) || session.id.slice(0, 8); + return ( + + ); + })} +
+
+ + {/* Teammate section */} +
+

+ {t('kanban.filter.teammate')} +

+
+ {members.map((member) => ( +
-
- - {/* Teammate section */} -
-

- {t('kanban.filter.teammate')} -

-
- {members.map((member) => ( - + ))} + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */} + - ))} - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */} - +
- - {/* Column section */} -
-

- {t('kanban.filter.column')} -

-
- {KANBAN_COLUMNS.map((col) => ( - - ))} + {/* Column section */} +
+

+ {t('kanban.filter.column')} +

+
+ {KANBAN_COLUMNS.map((col) => ( + + ))} +
-
- {/* Footer */} -
- -
- + {/* Footer */} +
+ +
+ + ) : null} ); }; diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index 43640be9..db67011f 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -28,7 +28,7 @@ const DEFAULT_MIN_WIDTH = 3; const GRID_SCOPE_KEY = 'kanban-grid-layout:global:v2'; const SKELETON_HIDE_DELAY_MS = 500; const SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH = 750; -const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne']; +const RESIZE_HANDLES: ResizeHandleAxis[] = ['se']; const WidthAwareGridLayout = WidthProvider(ReactGridLayout); export interface KanbanGridColumn { diff --git a/src/renderer/components/team/kanban/KanbanSortPopover.tsx b/src/renderer/components/team/kanban/KanbanSortPopover.tsx index 2178a90e..f6a2f7ca 100644 --- a/src/renderer/components/team/kanban/KanbanSortPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanSortPopover.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { useAppTranslation } from '@features/localization/renderer'; import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; @@ -53,10 +55,11 @@ export const KanbanSortPopover = ({ onSortChange, }: KanbanSortPopoverProps): React.JSX.Element => { const { t } = useAppTranslation('team'); + const [open, setOpen] = useState(false); const isNonDefault = sort.field !== 'updatedAt'; return ( - + @@ -77,66 +80,68 @@ export const KanbanSortPopover = ({ {t('kanban.sort.title')} - -
-

- {t('kanban.sort.sortBy')} -

-
- {SORT_OPTIONS.map((option) => { - const isSelected = sort.field === option.field; - return ( - - ); - })} + {isSelected && ( + + )} + + ); + })} +
-
- {isNonDefault && ( -
- -
- )} -
+ {isNonDefault && ( +
+ +
+ )} + + ) : null}
); }; diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 72a3fc7c..7ceeb7b4 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -10,6 +10,7 @@ const unreadBadgeMock = vi.hoisted(() => ({ const unreadCommentCountMock = vi.hoisted(() => ({ value: 0, + calls: 0, })); vi.mock('@renderer/components/team/MemberBadge', () => ({ @@ -71,7 +72,10 @@ vi.mock('@renderer/hooks/useTheme', () => ({ })); vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({ - useUnreadCommentCount: () => unreadCommentCountMock.value, + useUnreadCommentCount: () => { + unreadCommentCountMock.calls += 1; + return unreadCommentCountMock.value; + }, })); /* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */ @@ -188,6 +192,7 @@ async function rerenderStrictTaskCard( afterEach(() => { unreadBadgeMock.props.length = 0; unreadCommentCountMock.value = 0; + unreadCommentCountMock.calls = 0; }); async function renderTaskCard( @@ -211,6 +216,105 @@ describe('KanbanTaskCard comment badge pulse', () => { document.body.innerHTML = ''; }); + it('skips rerender when refreshed task objects keep the same snapshot', async () => { + const taskMap = new Map(); + const memberColorMap = new Map([['alice', 'blue']]); + const { root } = await renderTaskCard({ + task: { ...baseTask, comments: [] }, + taskMap, + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBeGreaterThan(0); + unreadCommentCountMock.calls = 0; + + await rerenderTaskCard(root, { + task: { ...baseTask, comments: [] }, + taskMap, + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBe(0); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + + it('skips rerender when an unrelated taskMap entry changes', async () => { + const memberColorMap = new Map([['alice', 'blue']]); + const { root } = await renderTaskCard({ + task: { ...baseTask, blockedBy: [], blocks: [], comments: [] }, + taskMap: new Map([['other-task', { ...baseTask, id: 'other-task', subject: 'Other task' }]]), + memberColorMap, + }); + + unreadCommentCountMock.calls = 0; + await rerenderTaskCard(root, { + task: { ...baseTask, blockedBy: [], blocks: [], comments: [] }, + taskMap: new Map([ + ['other-task', { ...baseTask, id: 'other-task', subject: 'Updated unrelated task' }], + ]), + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBe(0); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + + it('rerenders when a displayed dependency task changes', async () => { + const memberColorMap = new Map([['alice', 'blue']]); + const blockedTask = { ...baseTask, id: 'dep-1', displayId: 'dep1', subject: 'Dependency A' }; + const { root } = await renderTaskCard({ + task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] }, + taskMap: new Map([['dep-1', blockedTask]]), + memberColorMap, + }); + + unreadCommentCountMock.calls = 0; + await rerenderTaskCard(root, { + task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] }, + taskMap: new Map([['dep-1', { ...blockedTask, subject: 'Dependency B', status: 'done' }]]), + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBeGreaterThan(0); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + + it('rerenders when a hidden task field changes so click handlers stay current', async () => { + const taskMap = new Map(); + const memberColorMap = new Map([['alice', 'blue']]); + const { root } = await renderTaskCard({ + task: { ...baseTask, comments: [] }, + taskMap, + memberColorMap, + }); + + unreadCommentCountMock.calls = 0; + await rerenderTaskCard(root, { + task: { ...baseTask, comments: [], description: 'Updated hidden details' }, + taskMap, + memberColorMap, + }); + + expect(unreadCommentCountMock.calls).toBeGreaterThan(0); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + it('does not pulse on initial render with existing comments', async () => { const { host, root } = await renderTaskCard({ task: { ...baseTask, comments: [createComment('comment-1')] }, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 273d2917..2dbc416f 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useReducer, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator'; @@ -83,6 +83,68 @@ interface CommentPulseSyncAction { } const EMPTY_TASK_COMMENTS: readonly TaskComment[] = []; +const taskCardSignatureCache = new WeakMap(); + +function getTaskCardSignature(task: TeamTaskWithKanban): string { + const cached = taskCardSignatureCache.get(task); + if (cached !== undefined) return cached; + + const signature = JSON.stringify(task); + taskCardSignatureCache.set(task, signature); + return signature; +} + +function areKanbanTaskStatesEqual( + prev: KanbanTaskState | undefined, + next: KanbanTaskState | undefined +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + return ( + prev.column === next.column && + prev.reviewer === next.reviewer && + prev.errorDescription === next.errorDescription && + prev.movedAt === next.movedAt + ); +} + +function getTaskDependencyIds(task: TeamTaskWithKanban): string[] { + return [...(task.blockedBy ?? []), ...(task.blocks ?? [])].filter((id) => id.length > 0); +} + +function getDependencyTaskSignature(task: TeamTask | undefined): string { + if (!task) return ''; + const kanbanTask = task as Partial; + return [ + task.id, + task.displayId ?? '', + task.subject, + task.status, + task.reviewState ?? '', + kanbanTask.kanbanColumn ?? '', + ].join('\u001f'); +} + +function areTaskMapDependenciesEqual( + prevTask: TeamTaskWithKanban, + nextTask: TeamTaskWithKanban, + prevTaskMap: Map, + nextTaskMap: Map +): boolean { + const dependencyIds = new Set([ + ...getTaskDependencyIds(prevTask), + ...getTaskDependencyIds(nextTask), + ]); + for (const taskId of dependencyIds) { + if ( + getDependencyTaskSignature(prevTaskMap.get(taskId)) !== + getDependencyTaskSignature(nextTaskMap.get(taskId)) + ) { + return false; + } + } + return true; +} function createCommentPulseState( taskKey: string, @@ -169,34 +231,14 @@ const TruncatedTitle = ({ }: { text: string; className?: string; -}): React.JSX.Element => { - const ref = useRef(null); - const [isTruncated, setIsTruncated] = useState(false); - - const checkTruncation = useCallback(() => { - const el = ref.current; - if (el) { - setIsTruncated(el.scrollHeight > el.clientHeight); - } - }, []); - - return ( - - -
- {text} -
-
- - {text} - -
- ); -}; +}): React.JSX.Element => ( +
+ {text} +
+); const CancelTaskButton = ({ taskId, @@ -226,32 +268,34 @@ const CancelTaskButton = ({ {t('kanban.taskCard.cancel')} - e.stopPropagation()} - > -

- {t('kanban.taskCard.moveBackToTodoConfirm')} -

-
- - -
-
+ {open ? ( + e.stopPropagation()} + > +

+ {t('kanban.taskCard.moveBackToTodoConfirm')} +

+
+ + +
+
+ ) : null}
); }; @@ -273,23 +317,221 @@ const TaskActionIconButton = ({ variant = 'outline', disabled = false, }: TaskActionIconButtonProps): React.JSX.Element => ( - - - - - {label} - + ); +interface TaskMetaActionsProps { + taskId: string; + unreadCount: number; + commentCount: number; + pulseKey: number; + canOpenChanges: boolean; + changesNeedAttention: boolean; + onViewChanges?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; +} + +const TaskMetaActions = memo(function TaskMetaActions({ + taskId, + unreadCount, + commentCount, + pulseKey, + canOpenChanges, + changesNeedAttention, + onViewChanges, + onDeleteTask, +}: TaskMetaActionsProps): React.JSX.Element { + const { t } = useAppTranslation('team'); + + return ( + <> + {canOpenChanges && onViewChanges ? ( + } + variant="ghost" + className={ + changesNeedAttention + ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300' + : 'text-sky-400 hover:bg-sky-500/10 hover:text-sky-300' + } + onClick={(e) => { + e.stopPropagation(); + onViewChanges(taskId); + }} + /> + ) : null} + + {onDeleteTask ? ( + } + variant="ghost" + className="text-red-400 hover:bg-red-500/10 hover:text-red-300" + onClick={(e) => { + e.stopPropagation(); + onDeleteTask(taskId); + }} + /> + ) : null} + + ); +}); + +interface TaskPrimaryActionsProps { + taskId: string; + columnId: KanbanColumnId; + isReviewManual: boolean; + onRequestReview: (taskId: string) => void; + onApprove: (taskId: string) => void; + onRequestChanges: (taskId: string) => void; + onMoveBackToDone: (taskId: string) => void; + onStartTask: (taskId: string) => void; + onCompleteTask: (taskId: string) => void; + onCancelTask: (taskId: string) => void; +} + +const TaskPrimaryActions = memo(function TaskPrimaryActions({ + taskId, + columnId, + isReviewManual, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, +}: TaskPrimaryActionsProps): React.JSX.Element { + const { t } = useAppTranslation('team'); + + return ( +
+ {columnId === 'todo' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onStartTask(taskId); + }} + /> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(taskId); + }} + /> + + ) : null} + + {columnId === 'in_progress' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(taskId); + }} + /> + + + ) : null} + + {columnId === 'done' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onApprove(taskId); + }} + /> + } + className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" + onClick={(e) => { + e.stopPropagation(); + onRequestReview(taskId); + }} + /> + + ) : null} + + {columnId === 'review' ? ( +
+ {isReviewManual ? ( +
+ {t('kanban.taskCard.manualReview')} +
+ ) : null} +
+ } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onApprove(taskId); + }} + /> + } + variant="destructive" + className="bg-red-500/90 text-white hover:bg-red-500" + onClick={(e) => { + e.stopPropagation(); + onRequestChanges(taskId); + }} + /> +
+
+ ) : null} + + {columnId === 'approved' ? ( + } + className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" + onClick={(e) => { + e.stopPropagation(); + onMoveBackToDone(taskId); + }} + /> + ) : null} +
+ ); +}); + export const KanbanTaskCard = memo( function KanbanTaskCard({ task, @@ -350,48 +592,6 @@ export const KanbanTaskCard = memo( syncCommentPulse({ taskKey: commentPulseTaskKey, comments }); }, [commentCount, commentPulseTaskKey, comments]); - const metaActions = ( - <> - {canOpenChanges ? ( - } - variant="ghost" - className={ - changesNeedAttention - ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300' - : 'text-sky-400 hover:bg-sky-500/10 hover:text-sky-300' - } - onClick={(e) => { - e.stopPropagation(); - onViewChanges!(task.id); - }} - /> - ) : null} - - {onDeleteTask ? ( - } - variant="ghost" - className="text-red-400 hover:bg-red-500/10 hover:text-red-300" - onClick={(e) => { - e.stopPropagation(); - onDeleteTask(task.id); - }} - /> - ) : null} - - ); - return (
-
- {columnId === 'todo' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onStartTask(task.id); - }} - /> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - ) : null} + - {columnId === 'in_progress' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - - ) : null} - - {columnId === 'done' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onApprove(task.id); - }} - /> - } - className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" - onClick={(e) => { - e.stopPropagation(); - onRequestReview(task.id); - }} - /> - - ) : null} - - {columnId === 'review' ? ( -
- {isReviewManual ? ( -
- {t('kanban.taskCard.manualReview')} -
- ) : null} -
- } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onApprove(task.id); - }} - /> - } - variant="destructive" - className="bg-red-500/90 text-white hover:bg-red-500" - onClick={(e) => { - e.stopPropagation(); - onRequestChanges(task.id); - }} - /> -
-
- ) : null} - - {columnId === 'approved' ? ( - } - className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" - onClick={(e) => { - e.stopPropagation(); - onMoveBackToDone(task.id); - }} - /> - ) : null} +
+
- -
{metaActions}
); }, (prev, next) => - prev.task === next.task && + getTaskCardSignature(prev.task) === getTaskCardSignature(next.task) && prev.teamName === next.teamName && prev.columnId === next.columnId && - prev.kanbanTaskState === next.kanbanTaskState && + areKanbanTaskStatesEqual(prev.kanbanTaskState, next.kanbanTaskState) && prev.hasReviewers === next.hasReviewers && prev.compact === next.compact && - prev.taskMap === next.taskMap && + areTaskMapDependenciesEqual(prev.task, next.task, prev.taskMap, next.taskMap) && prev.memberColorMap === next.memberColorMap && prev.hasLiveTaskLogs === next.hasLiveTaskLogs && prev.onRequestReview === next.onRequestReview && diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index 9fe5de3f..68bf3e21 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; @@ -22,15 +22,19 @@ interface CurrentTaskIndicatorProps { onOpenTask?: () => void; } +const ACTIVITY_TIMER_STORAGE_SYNC_INTERVAL_MS = 5_000; + function useActivityTimerLabel( activityTimer: MemberActivityTimerAnchor | null | undefined, isTimerRunning: boolean ): string | null { const [nowMs, setNowMs] = useState(() => Date.now()); + const lastStorageSyncAtRef = useRef(0); useEffect(() => { if (!activityTimer) return; const now = Date.now(); + lastStorageSyncAtRef.current = now; syncMemberActivityTimer({ timerId: activityTimer.timerId, startedAtMs: activityTimer.startedAtMs, @@ -56,14 +60,17 @@ function useActivityTimerLabel( if (!activityTimer || !isTimerRunning) return; const handle = window.setInterval(() => { const now = Date.now(); - syncMemberActivityTimer({ - timerId: activityTimer.timerId, - startedAtMs: activityTimer.startedAtMs, - baseElapsedMs: activityTimer.baseElapsedMs, - running: true, - runId: activityTimer.runId, - nowMs: now, - }); + if (now - lastStorageSyncAtRef.current >= ACTIVITY_TIMER_STORAGE_SYNC_INTERVAL_MS) { + lastStorageSyncAtRef.current = now; + syncMemberActivityTimer({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: true, + runId: activityTimer.runId, + nowMs: now, + }); + } setNowMs(now); }, 1000); return () => window.clearInterval(handle); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 034b0373..e62680c8 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -7,13 +7,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; -import { useStore } from '@renderer/store'; -import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { renderLinkifiedText } from '@renderer/utils/linkifiedText'; import { agentAvatarUrl, - buildMemberAvatarMap, buildMemberLaunchPresentation, displayMemberName, isOpenCodeRelaunchActionable, @@ -72,8 +69,10 @@ export interface RuntimeTelemetryScale { } interface MemberCardProps { + teamName?: string; member: ResolvedTeamMember; memberColor: string; + avatarUrl?: string; fullBleedSurface?: boolean; runtimeSummary?: string; runtimeEntry?: TeamAgentRuntimeEntry; @@ -98,6 +97,7 @@ interface MemberCardProps { spawnRuntimeAlive?: boolean; isLaunchSettling?: boolean; runtimeTelemetryScale?: RuntimeTelemetryScale; + renderRuntimeTelemetryStrip?: boolean; onOpenTask?: () => void; onOpenReviewTask?: () => void; onClick?: () => void; @@ -301,53 +301,8 @@ function normalizeRuntimeTelemetrySamples(history: unknown): TeamAgentRuntimeRes return (Array.isArray(history) ? history : []).filter(isRuntimeTelemetrySampleLike); } -function buildRuntimeTelemetryTitle( - runtimeEntry: TeamAgentRuntimeEntry | undefined -): string | undefined { - if (!runtimeEntry) { - return undefined; - } - if (normalizeRuntimeTelemetrySamples(runtimeEntry?.resourceHistory).length === 0) { - return undefined; - } - - const lines = [ - 'CPU includes parent + child processes.', - 'Local CPU excludes remote LLM inference.', - ]; - if (runtimeEntry.runtimeLoadScope === 'shared-host') { - lines.push('Shared OpenCode host metric; not exclusive to this member.'); - } - if (runtimeEntry.runtimeLoadTruncated) { - lines.push('Process tree was capped for this sample.'); - } - - const detailParts = [ - runtimeEntry.pid ? `root PID ${runtimeEntry.pid}` : undefined, - runtimeEntry.processCount ? `${runtimeEntry.processCount} processes` : undefined, - runtimeEntry.runtimeLoadScope ? `scope ${runtimeEntry.runtimeLoadScope}` : undefined, - 'sample 5s', - ].filter((part): part is string => Boolean(part)); - if (detailParts.length > 0) { - lines.push(detailParts.join(' · ')); - } - - const aggregateCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.cpuPercent); - const primaryCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.primaryCpuPercent); - const childCpuLabel = formatRuntimeTelemetryPercent(runtimeEntry.childCpuPercent); - const rssLabel = formatRuntimeTelemetryBytes(runtimeEntry.rssBytes); - const splitParts = [ - aggregateCpuLabel ? `CPU ${aggregateCpuLabel}` : undefined, - primaryCpuLabel ? `root ${primaryCpuLabel}` : undefined, - childCpuLabel ? `children ${childCpuLabel}` : undefined, - rssLabel ? `RSS ${rssLabel}` : undefined, - ].filter((part): part is string => Boolean(part)); - if (splitParts.length > 0) { - lines.push(splitParts.join(' · ')); - } - - lines.push('RSS is summed process RSS and can include shared pages.'); - return lines.join('\n'); +function hasRuntimeTelemetrySamples(history: unknown): boolean { + return Array.isArray(history) && history.some(isRuntimeTelemetrySampleLike); } const RuntimeTelemetryTooltipContent = ({ @@ -612,13 +567,128 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({ ); }); +interface MemberActionButtonProps { + label: string; + children: React.ReactNode; + onClick?: () => void; +} + +const MemberActionButton = memo(function MemberActionButton({ + label, + children, + onClick, +}: MemberActionButtonProps): React.JSX.Element { + const [tooltipOpen, setTooltipOpen] = useState(false); + + return ( + + + + + {tooltipOpen ? {label} : null} + + ); +}); + +interface MemberQuickActionsProps { + onSendMessage?: () => void; + onAssignTask?: () => void; +} + +const MemberQuickActions = memo(function MemberQuickActions({ + onSendMessage, + onAssignTask, +}: MemberQuickActionsProps): React.JSX.Element { + const { t } = useAppTranslation('team'); + + return ( +
+ + + + + + +
+ ); +}); + +interface MemberTaskProgressBadgeProps { + showStartingSkeleton: boolean; + memberTaskCount: number; + completed: number; + totalTasks: number; + progressPercent: number; +} + +const MemberTaskProgressBadge = memo(function MemberTaskProgressBadge({ + showStartingSkeleton, + memberTaskCount, + completed, + totalTasks, + progressPercent, +}: MemberTaskProgressBadgeProps): React.JSX.Element { + if (showStartingSkeleton) { + return ( +