From 297bd8f533ff5f3088a8bd72823c467e8c0ad07f Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 18 Apr 2026 17:44:39 +0500 Subject: [PATCH 1/4] fix(team): cap renderer IPC payloads to prevent OOM crashes Users with long-running teams (37+ tasks, 10+ agents for an hour) were hitting constant renderer crashes (issue #36). Two hot paths were serializing unbounded histories across IPC on every tick: - Provisioning progress: emitLogsProgress and updateProgress both joined the full provisioningOutputParts array (~20 event-driven call sites) plus the full CLI log tail, then fanned that out to the renderer. After an hour, each tick shipped multi-megabyte payloads and Zustand OOM'd on the immutable state clone. - Session detail cache: SessionDetail.messages (the raw parsed JSONL) was being cached and returned over IPC/HTTP even though the renderer only reads session/chunks/processes/metrics. This roughly doubled the per-entry cache footprint on large sessions. Fixes: - Add progressPayload helpers that cap the log tail to 200 lines and assistant output to the last 20 parts; empty/whitespace joins collapse to undefined so the noop guard is explicit rather than coincidental. - Apply the cap inside emitLogsProgress, updateProgress, and the two inline emission paths (stall warning, retry error). Throttle the log-progress tick 300ms -> 1000ms so Zustand can keep up. - Add stripSessionDetailMessages and call it at every SessionDetail production site that crosses IPC/HTTP (both sessions.ts routes, both cache stores). - Raise MAX_CACHE_SESSIONS 5 -> 20 now that the per-entry SessionDetail footprint is bounded. Previously 5 forced constant re-parsing on every session switch. Tests: 15 new unit tests covering the helpers (tail slicing, empty parts, whitespace-only parts, non-mutation of inputs). --- src/main/http/sessions.ts | 19 ++--- src/main/ipc/sessions.ts | 9 ++- .../services/analysis/sessionDetailPayload.ts | 24 ++++++ .../services/team/TeamProvisioningService.ts | 43 ++++++++--- src/main/services/team/progressPayload.ts | 52 +++++++++++++ src/shared/constants/cache.ts | 11 ++- .../analysis/sessionDetailPayload.test.ts | 68 ++++++++++++++++ .../services/team/progressPayload.test.ts | 77 +++++++++++++++++++ 8 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 src/main/services/analysis/sessionDetailPayload.ts create mode 100644 src/main/services/team/progressPayload.ts create mode 100644 test/main/services/analysis/sessionDetailPayload.test.ts create mode 100644 test/main/services/team/progressPayload.test.ts diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index c0244274..72f5262c 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -13,6 +13,7 @@ import { createLogger } from '@shared/utils/logger'; import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards'; +import { stripSessionDetailMessages } from '../services/analysis/sessionDetailPayload'; import { DataCache } from '../services/infrastructure/DataCache'; import type { SessionsByIdsOptions, SessionsPaginationOptions } from '../types'; @@ -188,11 +189,11 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic ); session.hasSubagents = subagents.length > 0; - // Build session detail with chunks - sessionDetail = services.chunkBuilder.buildSessionDetail( - session, - parsedSession.messages, - subagents + // Build session detail with chunks. Strip the raw `messages` array before + // caching/returning — the renderer only consumes chunks/processes/session, + // and including messages doubled IPC payload size and cache footprint. + sessionDetail = stripSessionDetailMessages( + services.chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents) ); // Cache the result @@ -318,10 +319,10 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic parsedSession.messages ); - detail = services.chunkBuilder.buildSessionDetail( - session, - parsedSession.messages, - subagents + // Strip the raw `messages` array before caching so the waterfall path + // cannot "poison" the cache entry reused by the session-detail route. + detail = stripSessionDetailMessages( + services.chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents) ); services.dataCache.set(cacheKey, detail); } diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index cea9d76e..1380a863 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -13,6 +13,7 @@ import { createLogger } from '@shared/utils/logger'; import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; import { DataCache } from '../services'; +import { stripSessionDetailMessages } from '../services/analysis/sessionDetailPayload'; import { type ConversationGroup, type PaginatedSessionsResult, @@ -247,8 +248,12 @@ async function handleGetSessionDetail( ); session.hasSubagents = subagents.length > 0; - // Build session detail with chunks - sessionDetail = chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents); + // Build session detail with chunks. Strip the raw `messages` array before + // caching/returning — the renderer only consumes chunks/processes/session, + // and including messages doubled IPC payload size and cache footprint. + sessionDetail = stripSessionDetailMessages( + chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents) + ); // Cache the result dataCache.set(cacheKey, sessionDetail); diff --git a/src/main/services/analysis/sessionDetailPayload.ts b/src/main/services/analysis/sessionDetailPayload.ts new file mode 100644 index 00000000..82f1a84d --- /dev/null +++ b/src/main/services/analysis/sessionDetailPayload.ts @@ -0,0 +1,24 @@ +import type { SessionDetail } from '../../types'; + +/** + * Strip the raw `messages` array from a `SessionDetail` before it crosses the + * IPC / HTTP boundary to the renderer. + * + * The renderer consumes `session`, `chunks`, `processes`, and `metrics` only — + * `messages` is an implementation detail that `ChunkBuilder` retains for + * internal callers. Including it in the serialized payload roughly doubled + * the IPC cost for sessions with large JSONL files (tens of MB per response) + * while also inflating the in-memory `DataCache` footprint. The field is + * preserved (as an empty array) so the shared `SessionDetail` type stays + * satisfied and downstream code can still observe `.messages.length === 0` + * without runtime type narrowing. + */ +export function stripSessionDetailMessages(detail: SessionDetail): SessionDetail { + if (detail.messages.length === 0) { + return detail; + } + return { + ...detail, + messages: [], + }; +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7204faa5..ec407a9e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -84,6 +84,7 @@ import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; import { withFileLock } from './fileLock'; +import { buildProgressAssistantOutput, buildProgressLogsTail } from './progressPayload'; import { type ClassifiedMainProcessIdle, classifyIdleNotificationForMainProcess, @@ -195,7 +196,13 @@ const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_POLL_MS = 500; const STDERR_RING_LIMIT = 64 * 1024; const STDOUT_RING_LIMIT = 64 * 1024; -const LOG_PROGRESS_THROTTLE_MS = 300; +// Progress emissions fan out the latest CLI tail + assistant output to the +// renderer over IPC. Under load the previous 300ms cadence combined with an +// unbounded payload (see `emitLogsProgress`) caused renderer OOM crashes +// (≈3 full-history serializations per second, each holding thousands of +// lines). The tail cap in `emitLogsProgress` bounds each payload; we also +// slow the cadence to ~1s so Zustand can keep up on large teams. +const LOG_PROGRESS_THROTTLE_MS = 1000; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PREFLIGHT_BINARY_TIMEOUT_MS = 8000; @@ -2025,10 +2032,12 @@ function updateProgress( 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity' > ): TeamProvisioningProgress { + // Cap assistant output on every progress tick. `updateProgress` is invoked + // from ~20 event-driven sites (auth retries, stall warnings, spawn events), + // and an unbounded `provisioningOutputParts.join` was part of the same OOM + // class that `emitLogsProgress` already guards against. const assistantOutput = - run.provisioningOutputParts.length > 0 - ? run.provisioningOutputParts.join('\n\n') - : run.progress.assistantOutput; + buildProgressAssistantOutput(run.provisioningOutputParts) ?? run.progress.assistantOutput; run.progress = { ...run.progress, state, @@ -2093,10 +2102,22 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined { return extractLogsTail(run.stdoutBuffer, run.stderrBuffer); } +/** + * Emit a throttled progress update for the renderer. Payloads are capped to a + * tail window so that the hot emission path (called every LOG_PROGRESS_THROTTLE_MS + * under streaming output) cannot accumulate into multi-megabyte IPC messages + * that would OOM the renderer's Zustand state. The full history stays in + * `run.claudeLogLines` / `run.provisioningOutputParts` for diagnostics and + * one-shot completion emissions that intentionally use `extractCliLogsFromRun`. + */ function emitLogsProgress(run: ProvisioningRun): void { - const logsTail = extractCliLogsFromRun(run); - const assistantOutput = - run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n\n') : undefined; + // Prefer the line-buffered history (already chronological with [stdout]/[stderr] + // markers) and fall back to the legacy ring-buffer tail only when no lines + // have been captured yet (early in provisioning). + const logsTail = + buildProgressLogsTail(run.claudeLogLines) ?? + extractLogsTail(run.stdoutBuffer, run.stderrBuffer); + const assistantOutput = buildProgressAssistantOutput(run.provisioningOutputParts); if (!logsTail && !assistantOutput) { return; @@ -4453,7 +4474,9 @@ export class TeamProvisioningService { message: this.buildStallProgressMessage(silenceSec, elapsed), messageSeverity: 'warning' as const, }), - assistantOutput: run.provisioningOutputParts.join('\n\n'), + assistantOutput: + buildProgressAssistantOutput(run.provisioningOutputParts) ?? + run.progress.assistantOutput, }; run.onProgress(run.progress); } catch (err) { @@ -8772,7 +8795,9 @@ export class TeamProvisioningService { updatedAt: nowIso(), message: retryText, messageSeverity: 'error' as const, - assistantOutput: run.provisioningOutputParts.join('\n\n'), + assistantOutput: + buildProgressAssistantOutput(run.provisioningOutputParts) ?? + run.progress.assistantOutput, }; run.onProgress(run.progress); } diff --git a/src/main/services/team/progressPayload.ts b/src/main/services/team/progressPayload.ts new file mode 100644 index 00000000..c2f4fce7 --- /dev/null +++ b/src/main/services/team/progressPayload.ts @@ -0,0 +1,52 @@ +/** + * Helpers that shape provisioning progress payloads before they are emitted + * to the renderer over IPC. + * + * Rationale: the renderer only renders a small "tail" preview of CLI logs + * and assistant output in ProvisioningProgressBlock / CliLogsRichView. Sending + * the full accumulated history on every throttled progress tick (≈ every + * second under load) serialized a multi-megabyte string over IPC and forced + * Zustand to produce a new immutable state object — which triggered renderer + * V8 OOM crashes for users with long-running teams. These helpers keep the + * hot emission path bounded while leaving the full history in-process for + * diagnostics and completion-time reports. + */ + +export const PROGRESS_LOG_TAIL_LINES = 200; +export const PROGRESS_OUTPUT_TAIL_PARTS = 20; + +/** + * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" + * and trimmed. Returns `undefined` when the tail is empty so callers can + * skip emitting a noop update. + */ +export function buildProgressLogsTail( + lines: readonly string[], + maxLines: number = PROGRESS_LOG_TAIL_LINES +): string | undefined { + if (lines.length === 0) { + return undefined; + } + const effectiveMax = Math.max(1, maxLines); + const tail = lines.length > effectiveMax ? lines.slice(-effectiveMax) : lines; + const joined = tail.join('\n').trim(); + return joined.length === 0 ? undefined : joined; +} + +/** + * Return the trailing `maxParts` of assistant output parts joined with a + * blank line, matching the renderer's rendering contract. Returns `undefined` + * when no parts are available. + */ +export function buildProgressAssistantOutput( + parts: readonly string[], + maxParts: number = PROGRESS_OUTPUT_TAIL_PARTS +): string | undefined { + if (parts.length === 0) { + return undefined; + } + const effectiveMax = Math.max(1, maxParts); + const tail = parts.length > effectiveMax ? parts.slice(-effectiveMax) : parts; + const joined = tail.join('\n\n'); + return joined.trim().length === 0 ? undefined : joined; +} diff --git a/src/shared/constants/cache.ts b/src/shared/constants/cache.ts index fff6edf0..3cfa3be1 100644 --- a/src/shared/constants/cache.ts +++ b/src/shared/constants/cache.ts @@ -2,8 +2,15 @@ * Cache-related constants. */ -/** Maximum number of sessions to cache */ -export const MAX_CACHE_SESSIONS = 5; +/** + * Maximum number of session details retained in the in-memory LRU cache. + * + * Users regularly juggle dozens of sessions — the previous cap of 5 caused + * constant re-parsing of large JSONL files on every session switch. Raised + * now that the per-entry footprint is bounded (the raw `messages` array is + * stripped before caching; see `stripSessionDetailMessages`). + */ +export const MAX_CACHE_SESSIONS = 20; /** Cache TTL in minutes */ export const CACHE_TTL_MINUTES = 5; diff --git a/test/main/services/analysis/sessionDetailPayload.test.ts b/test/main/services/analysis/sessionDetailPayload.test.ts new file mode 100644 index 00000000..0c094767 --- /dev/null +++ b/test/main/services/analysis/sessionDetailPayload.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { stripSessionDetailMessages } from '../../../../src/main/services/analysis/sessionDetailPayload'; +import type { ParsedMessage, SessionDetail } from '../../../../src/main/types'; + +function createDetail(overrides: Partial = {}): SessionDetail { + return { + session: { + id: 'session-1', + projectId: 'project-1', + projectPath: '/tmp/project', + isOngoing: false, + hasSubagents: false, + messageCount: 0, + createdAt: 0, + }, + messages: [], + chunks: [], + processes: [], + metrics: { + durationMs: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: 0, + }, + ...overrides, + }; +} + +describe('stripSessionDetailMessages', () => { + it('returns the same reference when messages is already empty', () => { + const detail = createDetail(); + const result = stripSessionDetailMessages(detail); + expect(result).toBe(detail); + }); + + it('drops the messages array when it is non-empty', () => { + const messages = [{ uuid: 'm-1' } as unknown as ParsedMessage]; + const detail = createDetail({ messages }); + const result = stripSessionDetailMessages(detail); + expect(result).not.toBe(detail); + expect(result.messages).toEqual([]); + }); + + it('preserves every other field (session, chunks, processes, metrics)', () => { + const messages = Array.from( + { length: 3 }, + (_, i) => ({ uuid: `m-${i}` }) as unknown as ParsedMessage + ); + const detail = createDetail({ messages }); + const result = stripSessionDetailMessages(detail); + expect(result.session).toBe(detail.session); + expect(result.chunks).toBe(detail.chunks); + expect(result.processes).toBe(detail.processes); + expect(result.metrics).toBe(detail.metrics); + }); + + it('does not mutate the input detail', () => { + const messages = [{ uuid: 'm-1' } as unknown as ParsedMessage]; + const detail = createDetail({ messages }); + stripSessionDetailMessages(detail); + expect(detail.messages).toBe(messages); + expect(detail.messages).toHaveLength(1); + }); +}); diff --git a/test/main/services/team/progressPayload.test.ts b/test/main/services/team/progressPayload.test.ts new file mode 100644 index 00000000..8265d24e --- /dev/null +++ b/test/main/services/team/progressPayload.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { + PROGRESS_LOG_TAIL_LINES, + PROGRESS_OUTPUT_TAIL_PARTS, + buildProgressAssistantOutput, + buildProgressLogsTail, +} from '../../../../src/main/services/team/progressPayload'; + +describe('buildProgressLogsTail', () => { + it('returns undefined for an empty buffer', () => { + expect(buildProgressLogsTail([])).toBeUndefined(); + }); + + it('returns undefined when all lines are whitespace', () => { + expect(buildProgressLogsTail(['', ' ', '\t'])).toBeUndefined(); + }); + + it('returns the full buffer joined when below the limit', () => { + const lines = ['alpha', 'beta', 'gamma']; + expect(buildProgressLogsTail(lines, 10)).toBe('alpha\nbeta\ngamma'); + }); + + it('caps the payload to the last N lines once the limit is exceeded', () => { + const lines = Array.from({ length: 1_000 }, (_, i) => `line-${i}`); + const result = buildProgressLogsTail(lines, 50); + expect(result).toBeDefined(); + const parts = result!.split('\n'); + expect(parts).toHaveLength(50); + expect(parts[0]).toBe('line-950'); + expect(parts[parts.length - 1]).toBe('line-999'); + }); + + it('uses the default tail size when the caller does not override it', () => { + const lines = Array.from({ length: PROGRESS_LOG_TAIL_LINES + 250 }, (_, i) => `l${i}`); + const result = buildProgressLogsTail(lines); + expect(result).toBeDefined(); + expect(result!.split('\n')).toHaveLength(PROGRESS_LOG_TAIL_LINES); + }); + + it('keeps payload size bounded for pathological inputs (50k lines)', () => { + const lines = Array.from({ length: 50_000 }, (_, i) => `line-${i}`); + const result = buildProgressLogsTail(lines); + expect(result).toBeDefined(); + // Regression guard: a full-buffer join of 50k synthetic lines would exceed + // 400k chars. The tail must stay well below that. + expect(result!.length).toBeLessThan(50_000); + }); + + it('coerces non-positive limits to at least one line', () => { + expect(buildProgressLogsTail(['a', 'b', 'c'], 0)).toBe('c'); + expect(buildProgressLogsTail(['a', 'b', 'c'], -5)).toBe('c'); + }); +}); + +describe('buildProgressAssistantOutput', () => { + it('returns undefined when there are no parts', () => { + expect(buildProgressAssistantOutput([])).toBeUndefined(); + }); + + it('joins parts with a blank-line separator when below the limit', () => { + expect(buildProgressAssistantOutput(['first', 'second'], 10)).toBe('first\n\nsecond'); + }); + + it('caps to the last N parts once the limit is exceeded', () => { + const parts = Array.from({ length: 200 }, (_, i) => `p${i}`); + const result = buildProgressAssistantOutput(parts, 5); + expect(result).toBe('p195\n\np196\n\np197\n\np198\n\np199'); + }); + + it('uses the default tail size when the caller does not override it', () => { + const parts = Array.from({ length: PROGRESS_OUTPUT_TAIL_PARTS + 10 }, (_, i) => `p${i}`); + const result = buildProgressAssistantOutput(parts); + expect(result).toBeDefined(); + expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS); + }); +}); From ca5791b801b55e90096a22eb1bf1b222bf619b2d Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 18 Apr 2026 17:57:55 +0500 Subject: [PATCH 2/4] chore: restore alphabetical import sort in TeamProvisioningService Autofix-only change. The OOM-fix commit inserted the progressPayload import into the wrong position relative to AutoResumeService / idleNotificationMainProcessSemantics, which failed the simple-import-sort ESLint rule enforced by CI. --- src/main/services/team/TeamProvisioningService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ec407a9e..c0546e1b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -82,15 +82,16 @@ import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; +import { peekAutoResumeService } from './AutoResumeService'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; import { withFileLock } from './fileLock'; -import { buildProgressAssistantOutput, buildProgressLogsTail } from './progressPayload'; import { type ClassifiedMainProcessIdle, classifyIdleNotificationForMainProcess, } from './idleNotificationMainProcessSemantics'; import { withInboxLock } from './inboxLock'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; +import { buildProgressAssistantOutput, buildProgressLogsTail } from './progressPayload'; import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode'; import { choosePreferredLaunchSnapshot, @@ -113,7 +114,6 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; -import { peekAutoResumeService } from './AutoResumeService'; /** * Kill a team CLI process using SIGKILL (uncatchable). From fb474af2a7e92d1228f2be8369bfec1929227f23 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 18 Apr 2026 22:19:09 +0500 Subject: [PATCH 3/4] refactor(team): narrow PR to progress payload cap only Remove the SessionDetail.messages stripping and related cache-size change per maintainer feedback. The session-detail optimization will follow separately after PR #58 lands with the right architectural pattern (lightweight snapshot + separate endpoints). This PR now contains only: - progressPayload helpers (buildProgressLogsTail, buildProgressAssistantOutput) - cap applied to emitLogsProgress, updateProgress, stall warning, retry error - throttle raised 300ms -> 1000ms - tests for the progress payload behavior --- src/main/http/sessions.ts | 19 +++--- src/main/ipc/sessions.ts | 9 +-- .../services/analysis/sessionDetailPayload.ts | 24 ------- src/shared/constants/cache.ts | 11 +-- .../analysis/sessionDetailPayload.test.ts | 68 ------------------- 5 files changed, 13 insertions(+), 118 deletions(-) delete mode 100644 src/main/services/analysis/sessionDetailPayload.ts delete mode 100644 test/main/services/analysis/sessionDetailPayload.test.ts diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index 72f5262c..c0244274 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -13,7 +13,6 @@ import { createLogger } from '@shared/utils/logger'; import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards'; -import { stripSessionDetailMessages } from '../services/analysis/sessionDetailPayload'; import { DataCache } from '../services/infrastructure/DataCache'; import type { SessionsByIdsOptions, SessionsPaginationOptions } from '../types'; @@ -189,11 +188,11 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic ); session.hasSubagents = subagents.length > 0; - // Build session detail with chunks. Strip the raw `messages` array before - // caching/returning — the renderer only consumes chunks/processes/session, - // and including messages doubled IPC payload size and cache footprint. - sessionDetail = stripSessionDetailMessages( - services.chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents) + // Build session detail with chunks + sessionDetail = services.chunkBuilder.buildSessionDetail( + session, + parsedSession.messages, + subagents ); // Cache the result @@ -319,10 +318,10 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic parsedSession.messages ); - // Strip the raw `messages` array before caching so the waterfall path - // cannot "poison" the cache entry reused by the session-detail route. - detail = stripSessionDetailMessages( - services.chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents) + detail = services.chunkBuilder.buildSessionDetail( + session, + parsedSession.messages, + subagents ); services.dataCache.set(cacheKey, detail); } diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index 1380a863..cea9d76e 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -13,7 +13,6 @@ import { createLogger } from '@shared/utils/logger'; import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; import { DataCache } from '../services'; -import { stripSessionDetailMessages } from '../services/analysis/sessionDetailPayload'; import { type ConversationGroup, type PaginatedSessionsResult, @@ -248,12 +247,8 @@ async function handleGetSessionDetail( ); session.hasSubagents = subagents.length > 0; - // Build session detail with chunks. Strip the raw `messages` array before - // caching/returning — the renderer only consumes chunks/processes/session, - // and including messages doubled IPC payload size and cache footprint. - sessionDetail = stripSessionDetailMessages( - chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents) - ); + // Build session detail with chunks + sessionDetail = chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents); // Cache the result dataCache.set(cacheKey, sessionDetail); diff --git a/src/main/services/analysis/sessionDetailPayload.ts b/src/main/services/analysis/sessionDetailPayload.ts deleted file mode 100644 index 82f1a84d..00000000 --- a/src/main/services/analysis/sessionDetailPayload.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SessionDetail } from '../../types'; - -/** - * Strip the raw `messages` array from a `SessionDetail` before it crosses the - * IPC / HTTP boundary to the renderer. - * - * The renderer consumes `session`, `chunks`, `processes`, and `metrics` only — - * `messages` is an implementation detail that `ChunkBuilder` retains for - * internal callers. Including it in the serialized payload roughly doubled - * the IPC cost for sessions with large JSONL files (tens of MB per response) - * while also inflating the in-memory `DataCache` footprint. The field is - * preserved (as an empty array) so the shared `SessionDetail` type stays - * satisfied and downstream code can still observe `.messages.length === 0` - * without runtime type narrowing. - */ -export function stripSessionDetailMessages(detail: SessionDetail): SessionDetail { - if (detail.messages.length === 0) { - return detail; - } - return { - ...detail, - messages: [], - }; -} diff --git a/src/shared/constants/cache.ts b/src/shared/constants/cache.ts index 3cfa3be1..fff6edf0 100644 --- a/src/shared/constants/cache.ts +++ b/src/shared/constants/cache.ts @@ -2,15 +2,8 @@ * Cache-related constants. */ -/** - * Maximum number of session details retained in the in-memory LRU cache. - * - * Users regularly juggle dozens of sessions — the previous cap of 5 caused - * constant re-parsing of large JSONL files on every session switch. Raised - * now that the per-entry footprint is bounded (the raw `messages` array is - * stripped before caching; see `stripSessionDetailMessages`). - */ -export const MAX_CACHE_SESSIONS = 20; +/** Maximum number of sessions to cache */ +export const MAX_CACHE_SESSIONS = 5; /** Cache TTL in minutes */ export const CACHE_TTL_MINUTES = 5; diff --git a/test/main/services/analysis/sessionDetailPayload.test.ts b/test/main/services/analysis/sessionDetailPayload.test.ts deleted file mode 100644 index 0c094767..00000000 --- a/test/main/services/analysis/sessionDetailPayload.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { stripSessionDetailMessages } from '../../../../src/main/services/analysis/sessionDetailPayload'; -import type { ParsedMessage, SessionDetail } from '../../../../src/main/types'; - -function createDetail(overrides: Partial = {}): SessionDetail { - return { - session: { - id: 'session-1', - projectId: 'project-1', - projectPath: '/tmp/project', - isOngoing: false, - hasSubagents: false, - messageCount: 0, - createdAt: 0, - }, - messages: [], - chunks: [], - processes: [], - metrics: { - durationMs: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - messageCount: 0, - }, - ...overrides, - }; -} - -describe('stripSessionDetailMessages', () => { - it('returns the same reference when messages is already empty', () => { - const detail = createDetail(); - const result = stripSessionDetailMessages(detail); - expect(result).toBe(detail); - }); - - it('drops the messages array when it is non-empty', () => { - const messages = [{ uuid: 'm-1' } as unknown as ParsedMessage]; - const detail = createDetail({ messages }); - const result = stripSessionDetailMessages(detail); - expect(result).not.toBe(detail); - expect(result.messages).toEqual([]); - }); - - it('preserves every other field (session, chunks, processes, metrics)', () => { - const messages = Array.from( - { length: 3 }, - (_, i) => ({ uuid: `m-${i}` }) as unknown as ParsedMessage - ); - const detail = createDetail({ messages }); - const result = stripSessionDetailMessages(detail); - expect(result.session).toBe(detail.session); - expect(result.chunks).toBe(detail.chunks); - expect(result.processes).toBe(detail.processes); - expect(result.metrics).toBe(detail.metrics); - }); - - it('does not mutate the input detail', () => { - const messages = [{ uuid: 'm-1' } as unknown as ParsedMessage]; - const detail = createDetail({ messages }); - stripSessionDetailMessages(detail); - expect(detail.messages).toBe(messages); - expect(detail.messages).toHaveLength(1); - }); -}); From d1c33cec64cf467ff073fb4fa5c3791a94167d7c Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 21:04:15 +0300 Subject: [PATCH 4/4] feat(team): add live task log stream count badge --- src/main/ipc/teams.ts | 22 ++++ .../stream/BoardTaskLogStreamService.ts | 122 ++++++++++++++---- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 9 ++ src/renderer/api/httpClient.ts | 5 + .../team/dialogs/TaskDetailDialog.tsx | 4 + .../team/taskLogs/TaskLogsPanel.tsx | 90 +++++++++++++ src/shared/types/api.ts | 2 + src/shared/types/team.ts | 4 + .../team/BoardTaskLogStreamService.test.ts | 57 ++++++++ .../team/taskLogs/TaskLogsPanel.test.ts | 56 ++++++++ 11 files changed, 347 insertions(+), 27 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 34be4f2c..f88bedce 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -35,6 +35,7 @@ import { TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_LOG_STREAM_SUMMARY, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -155,6 +156,7 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, CreateTaskRequest, EffortLevel, GlobalTask, @@ -536,6 +538,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity); ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail); + ipcMain.handle(TEAM_GET_TASK_LOG_STREAM_SUMMARY, handleGetTaskLogStreamSummary); ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail); @@ -611,6 +614,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL); + ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM_SUMMARY); ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); @@ -2645,6 +2649,24 @@ async function handleGetTaskLogStream( ); } +async function handleGetTaskLogStreamSummary( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vTask = validateTaskId(taskId); + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('getTaskLogStreamSummary', () => + getBoardTaskLogStreamService().getTaskLogStreamSummary(vTeam.value!, vTask.value!) + ); +} + async function handleGetTaskExactLogSummaries( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 9549c819..3e0a3cec 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -19,6 +19,7 @@ import type { BoardTaskLogParticipant, BoardTaskLogSegment, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, TeamTask, } from '@shared/types'; @@ -47,6 +48,11 @@ interface TimeWindow { endMs: number | null; } +interface StreamLayout { + participants: BoardTaskLogParticipant[]; + visibleSlices: StreamSlice[]; +} + const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; @@ -61,6 +67,12 @@ function emptyResponse(): BoardTaskLogStreamResponse { }; } +function emptySummary(): BoardTaskLogStreamSummary { + return { + segmentCount: 0, + }; +} + function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } @@ -1018,6 +1030,46 @@ function compareSlices(left: StreamSlice, right: StreamSlice): number { return left.id.localeCompare(right.id); } +function buildOrderedParticipants(visibleSlices: StreamSlice[]): BoardTaskLogParticipant[] { + const participantsByKey = new Map(); + const participantOrder: string[] = []; + + for (const slice of visibleSlices) { + if (participantsByKey.has(slice.participantKey)) { + continue; + } + participantsByKey.set( + slice.participantKey, + buildParticipant(slice.actor, slice.participantKey) + ); + participantOrder.push(slice.participantKey); + } + + return participantOrder + .map((key) => participantsByKey.get(key)) + .filter((participant): participant is BoardTaskLogParticipant => Boolean(participant)) + .sort((left, right) => { + if (left.isLead && !right.isLead) return 1; + if (!left.isLead && right.isLead) return -1; + return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key); + }); +} + +function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number { + if (visibleSlices.length === 0) { + return 0; + } + + let segmentCount = 1; + for (let index = 1; index < visibleSlices.length; index += 1) { + if (visibleSlices[index]?.participantKey !== visibleSlices[index - 1]?.participantKey) { + segmentCount += 1; + } + } + + return segmentCount; +} + export class BoardTaskLogStreamService { constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), @@ -1131,14 +1183,20 @@ export class BoardTaskLogStreamService { return inferredSlices.sort(compareSlices); } - async getTaskLogStream(teamName: string, taskId: string): Promise { + private async buildStreamLayout(teamName: string, taskId: string): Promise { if (!isBoardTaskExactLogsReadEnabled()) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } const records = await this.recordSource.getTaskRecords(teamName, taskId); if (records.length === 0) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } const fileVersionsByPath = await getBoardTaskExactLogFileVersions( @@ -1154,7 +1212,10 @@ export class BoardTaskLogStreamService { .sort(compareCandidates); if (candidates.length === 0) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } const parsedMessagesByFile = await this.strictParser.parseFiles( @@ -1202,7 +1263,10 @@ export class BoardTaskLogStreamService { } if (slices.length === 0) { - return emptyResponse(); + return { + participants: [], + visibleSlices: [], + }; } const inferredExecutionSlices = await this.buildInferredExecutionSlices( @@ -1220,27 +1284,31 @@ export class BoardTaskLogStreamService { const visibleSlices = namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices; - const participantsByKey = new Map(); - const participantOrder: string[] = []; - for (const slice of visibleSlices) { - if (participantsByKey.has(slice.participantKey)) { - continue; - } - participantsByKey.set( - slice.participantKey, - buildParticipant(slice.actor, slice.participantKey) - ); - participantOrder.push(slice.participantKey); + return { + participants: buildOrderedParticipants(visibleSlices), + visibleSlices, + }; + } + + async getTaskLogStreamSummary( + teamName: string, + taskId: string + ): Promise { + const layout = await this.buildStreamLayout(teamName, taskId); + if (layout.visibleSlices.length === 0) { + return emptySummary(); } - const orderedParticipants = participantOrder - .map((key) => participantsByKey.get(key)) - .filter((participant): participant is BoardTaskLogParticipant => Boolean(participant)) - .sort((left, right) => { - if (left.isLead && !right.isLead) return 1; - if (!left.isLead && right.isLead) return -1; - return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key); - }); + return { + segmentCount: countSegmentsFromSlices(layout.visibleSlices), + }; + } + + async getTaskLogStream(teamName: string, taskId: string): Promise { + const layout = await this.buildStreamLayout(teamName, taskId); + if (layout.visibleSlices.length === 0) { + return emptyResponse(); + } const segments: BoardTaskLogSegment[] = []; let currentSegmentSlices: StreamSlice[] = []; @@ -1274,7 +1342,7 @@ export class BoardTaskLogStreamService { currentSegmentSlices = []; }; - for (const slice of visibleSlices) { + for (const slice of layout.visibleSlices) { if ( currentSegmentSlices.length > 0 && currentSegmentSlices[0].participantKey !== slice.participantKey @@ -1285,11 +1353,11 @@ export class BoardTaskLogStreamService { } flushSegment(); - const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead); + const namedParticipants = layout.participants.filter((participant) => !participant.isLead); const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all'; return { - participants: orderedParticipants, + participants: layout.participants, defaultFilter, segments, }; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index f8de826d..8c9c7125 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -313,6 +313,9 @@ export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail'; /** Get one task-scoped log stream derived from explicit board-task activity */ export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; +/** Get lightweight task log stream summary for header badges/live counters */ +export const TEAM_GET_TASK_LOG_STREAM_SUMMARY = 'team:getTaskLogStreamSummary'; + /** Get exact task-log summaries derived from explicit board-task activity records */ export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'; diff --git a/src/preload/index.ts b/src/preload/index.ts index d7ed0c30..d2a593b1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -139,6 +139,7 @@ import { TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_LOG_STREAM_SUMMARY, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -243,6 +244,7 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, ChangeStats, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -993,6 +995,13 @@ const electronAPI: ElectronAPI = { activityId ); }, + getTaskLogStreamSummary: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_LOG_STREAM_SUMMARY, + teamName, + taskId + ); + }, getTaskLogStream: async (teamName: string, taskId: string) => { return invokeIpcWithResult( TEAM_GET_TASK_LOG_STREAM, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index fd111ba8..3d2afc3b 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -14,6 +14,7 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, ClaudeMdFileInfo, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -827,6 +828,10 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode'); return { status: 'missing' }; }, + getTaskLogStreamSummary: async (): Promise => { + console.warn('[HttpAPIClient] getTaskLogStreamSummary is not available in browser mode'); + return { segmentCount: 0 }; + }, getTaskLogStream: async (): Promise => { console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode'); return { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 3851faef..f89efcc5 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -159,6 +159,7 @@ export const TaskDetailDialog = ({ const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); const [logsSectionOpen, setLogsSectionOpen] = useState(false); const [taskLogActivityActive, setTaskLogActivityActive] = useState(false); + const [taskLogStreamCount, setTaskLogStreamCount] = useState(undefined); const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [taskChangesFiles, setTaskChangesFiles] = useState(null); const [taskChangesLoading, setTaskChangesLoading] = useState(false); @@ -236,6 +237,7 @@ export const TaskDetailDialog = ({ setExecutionPreviewOnline(false); setLogsSectionOpen(false); setTaskLogActivityActive(false); + setTaskLogStreamCount(undefined); }, [open, currentTask?.id]); const [replyTo, setReplyTo] = useState<{ @@ -1263,6 +1265,7 @@ export const TaskDetailDialog = ({ key={`task-logs:${currentTask.id}`} title="Task Logs" icon={} + badge={taskLogStreamCount} headerExtra={ taskLogActivityActive ? ( @@ -1288,6 +1291,7 @@ export const TaskDetailDialog = ({ showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask} onPreviewOnlineChange={setExecutionPreviewOnline} onTaskLogActivityChange={setTaskLogActivityActive} + onTaskLogCountChange={setTaskLogStreamCount} /> diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 71570643..4c0582ff 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -24,9 +24,11 @@ interface TaskLogsPanelProps { showLeadPreview?: boolean; onPreviewOnlineChange?: (isOnline: boolean) => void; onTaskLogActivityChange?: (isActive: boolean) => void; + onTaskLogCountChange?: (count: number | undefined) => void; } const TASK_LOG_ACTIVITY_PULSE_MS = 1800; +const TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS = 350; export const TaskLogsPanel = ({ teamName, @@ -40,6 +42,7 @@ export const TaskLogsPanel = ({ showLeadPreview = false, onPreviewOnlineChange, onTaskLogActivityChange, + onTaskLogCountChange, }: TaskLogsPanelProps): React.JSX.Element => { const availableTabs = useMemo(() => { const tabs: TaskLogsTab[] = []; @@ -56,9 +59,13 @@ export const TaskLogsPanel = ({ const defaultTab = availableTabs[0] ?? 'sessions'; const [activeTab, setActiveTab] = useState(defaultTab); const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false); + const [taskLogSegmentCount, setTaskLogSegmentCount] = useState(null); const [hasOpenedContent, setHasOpenedContent] = useState(isOpen); const pulseTimerRef = useRef | null>(null); + const countReloadTimerRef = useRef | null>(null); + const countRequestSeqRef = useRef(0); const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream'); + const taskLogSummaryEnabled = availableTabs.includes('stream'); useEffect(() => { setActiveTab(defaultTab); @@ -80,14 +87,50 @@ export const TaskLogsPanel = ({ onTaskLogActivityChange?.(isTaskLogActivityActive); }, [isTaskLogActivityActive, onTaskLogActivityChange]); + useEffect(() => { + onTaskLogCountChange?.( + taskLogSegmentCount != null && taskLogSegmentCount > 0 ? taskLogSegmentCount : undefined + ); + }, [onTaskLogCountChange, taskLogSegmentCount]); + useEffect(() => { if (pulseTimerRef.current) { clearTimeout(pulseTimerRef.current); pulseTimerRef.current = null; } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + countReloadTimerRef.current = null; + } + countRequestSeqRef.current += 1; setIsTaskLogActivityActive(false); + setTaskLogSegmentCount(null); }, [task.id]); + useEffect(() => { + if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) { + setTaskLogSegmentCount(null); + return; + } + + const requestSeq = countRequestSeqRef.current + 1; + countRequestSeqRef.current = requestSeq; + + void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id)) + .then((summary) => { + if (countRequestSeqRef.current !== requestSeq) { + return; + } + setTaskLogSegmentCount(summary.segmentCount); + }) + .catch(() => { + if (countRequestSeqRef.current !== requestSeq) { + return; + } + setTaskLogSegmentCount((prev) => prev); + }); + }, [task.id, taskLogSummaryEnabled, teamName]); + useEffect(() => { if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) { return; @@ -107,10 +150,39 @@ export const TaskLogsPanel = ({ clearTimeout(pulseTimerRef.current); pulseTimerRef.current = null; } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + countReloadTimerRef.current = null; + } setIsTaskLogActivityActive(false); return; } + const scheduleCountReload = (): void => { + if (!api.teams.getTaskLogStreamSummary) { + return; + } + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return; + } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + } + countReloadTimerRef.current = setTimeout(() => { + countReloadTimerRef.current = null; + const requestSeq = countRequestSeqRef.current + 1; + countRequestSeqRef.current = requestSeq; + void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id)) + .then((summary) => { + if (countRequestSeqRef.current !== requestSeq) { + return; + } + setTaskLogSegmentCount(summary.segmentCount); + }) + .catch(() => undefined); + }, TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS); + }; + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { if ( event.teamName !== teamName || @@ -128,13 +200,31 @@ export const TaskLogsPanel = ({ pulseTimerRef.current = null; setIsTaskLogActivityActive(false); }, TASK_LOG_ACTIVITY_PULSE_MS); + scheduleCountReload(); }); + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') { + scheduleCountReload(); + } + }; + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + return () => { if (pulseTimerRef.current) { clearTimeout(pulseTimerRef.current); pulseTimerRef.current = null; } + if (countReloadTimerRef.current) { + clearTimeout(countReloadTimerRef.current); + countReloadTimerRef.current = null; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } if (typeof unsubscribe === 'function') { unsubscribe(); } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 283f7ef0..eb09c4c7 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -45,6 +45,7 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, BoardTaskLogStreamResponse, + BoardTaskLogStreamSummary, CreateTaskRequest, CrossTeamMessage, CrossTeamSendRequest, @@ -493,6 +494,7 @@ export interface TeamsAPI { taskId: string, activityId: string ) => Promise; + getTaskLogStreamSummary: (teamName: string, taskId: string) => Promise; getTaskLogStream: (teamName: string, taskId: string) => Promise; getTaskExactLogSummaries: ( teamName: string, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 3a6d69d1..9dda365e 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -342,6 +342,10 @@ export interface BoardTaskLogStreamResponse { segments: BoardTaskLogSegment[]; } +export interface BoardTaskLogStreamSummary { + segmentCount: number; +} + export interface TaskComment { id: string; author: string; diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts index 7c1220ef..18295fa2 100644 --- a/test/main/services/team/BoardTaskLogStreamService.test.ts +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -181,6 +181,63 @@ describe('BoardTaskLogStreamService', () => { expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2); }); + it('returns lightweight segment count without building stream chunks', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const alice = { + memberName: 'alice', + role: 'member' as const, + sessionId: 'session-alice', + agentId: 'agent-alice', + isSidechain: true, + }; + const candidates = [ + makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), + makeCandidate('c3', '2026-04-12T16:02:00.000Z', alice, 'tool-3'), + makeCandidate('c4', '2026-04-12T16:03:00.000Z', tom, 'tool-4'), + ]; + + const recordSource = { + getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), + }; + const summarySelector = { + selectSummaries: vi.fn(() => candidates), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], + })), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + ); + + await expect(service.getTaskLogStreamSummary('demo', 'task-a')).resolves.toEqual({ + segmentCount: 3, + }); + expect(buildBundleChunks).not.toHaveBeenCalled(); + }); + it('merges duplicate message uuids inside one participant segment before chunk building', async () => { const tom = { memberName: 'tom', diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts index cedb1bb9..ea3734c7 100644 --- a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -9,6 +9,9 @@ import type { TeamTaskWithKanban } from '../../../../../src/shared/types'; const apiState = { onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(), + getTaskLogStreamSummary: vi.fn< + (teamName: string, taskId: string) => Promise<{ segmentCount: number }> + >(), setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise>(), }; @@ -17,6 +20,8 @@ vi.mock('@renderer/api', () => ({ teams: { onTeamChange: (...args: Parameters) => apiState.onTeamChange(...args), + getTaskLogStreamSummary: (...args: Parameters) => + apiState.getTaskLogStreamSummary(...args), setTaskLogStreamTracking: (...args: Parameters) => apiState.setTaskLogStreamTracking(...args), }, @@ -168,7 +173,9 @@ describe('TaskLogsPanel', () => { taskLogStreamProps.calls = []; executionSessionsProps.calls = []; apiState.onTeamChange.mockReset(); + apiState.getTaskLogStreamSummary.mockReset(); apiState.setTaskLogStreamTracking.mockReset(); + apiState.getTaskLogStreamSummary.mockResolvedValue({ segmentCount: 0 }); vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -484,4 +491,53 @@ describe('TaskLogsPanel', () => { await flushMicrotasks(); }); }); + + it('loads task log count for the header badge and refreshes it on matching live updates', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + + const counts: Array = []; + let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null; + apiState.onTeamChange.mockImplementation((callback) => { + handler = callback; + return () => { + handler = null; + }; + }); + apiState.getTaskLogStreamSummary + .mockResolvedValueOnce({ segmentCount: 4 }) + .mockResolvedValueOnce({ segmentCount: 5 }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskLogsPanel, { + teamName: 'demo', + task: makeTask(), + onTaskLogCountChange: (count) => counts.push(count), + }) + ); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledWith('demo', 'task-1'); + expect(counts).toEqual([undefined, 4]); + + await act(async () => { + handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + vi.advanceTimersByTime(350); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledTimes(2); + expect(counts).toEqual([undefined, 4, 5]); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); });