From 02d516cb4e03c0140bc889373ebcc4401b5625e2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 13:18:49 +0300 Subject: [PATCH 01/21] fix: harden provider-aware cli env handling --- src/main/ipc/terminal.ts | 2 +- .../infrastructure/PtyTerminalService.ts | 18 +- .../runtime/ClaudeMultimodelBridgeService.ts | 87 ++++---- .../runtime/ProviderConnectionService.ts | 102 ++++++++++ .../services/runtime/providerAwareCliEnv.ts | 105 ++++++++++ .../schedule/ScheduledTaskExecutor.ts | 33 ++- .../services/team/TeamProvisioningService.ts | 106 ++++++---- .../components/dashboard/CliStatusBanner.tsx | 101 ++++++--- .../ClaudeMultimodelBridgeService.test.ts | 69 +++++-- .../runtime/ProviderConnectionService.test.ts | 133 ++++++++++++ .../runtime/providerAwareCliEnv.test.ts | 192 ++++++++++++++++++ .../schedule/ScheduledTaskExecutor.test.ts | 27 ++- .../TeamProvisioningServicePrepare.test.ts | 69 ++++++- .../cli/CliStatusVisibility.test.ts | 138 ++++++++++++- 14 files changed, 1034 insertions(+), 148 deletions(-) create mode 100644 src/main/services/runtime/providerAwareCliEnv.ts create mode 100644 test/main/services/runtime/providerAwareCliEnv.test.ts diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts index 784959c7..e1e6eab3 100644 --- a/src/main/ipc/terminal.ts +++ b/src/main/ipc/terminal.ts @@ -91,7 +91,7 @@ async function handleSpawn( options?: PtySpawnOptions ): Promise> { try { - const id = service.spawn(options); + const id = await service.spawn(options); return { success: true, data: id }; } catch (error) { const msg = getErrorMessage(error); diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index ba23ebb5..64b43bdc 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -7,13 +7,14 @@ import crypto from 'node:crypto'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getHomeDir } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; +import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; + import type { PtySpawnOptions } from '@shared/types/terminal'; import type { BrowserWindow } from 'electron'; @@ -46,7 +47,7 @@ export class PtyTerminalService { * @returns Unique PTY ID for subsequent write/resize/kill calls. * @throws If node-pty native module is not available. */ - spawn(options?: PtySpawnOptions): string { + async spawn(options?: PtySpawnOptions): Promise { if (!nodePty) { throw new Error( 'Terminal not available: node-pty native module not found. Run: pnpm install' @@ -54,11 +55,15 @@ export class PtyTerminalService { } const id = crypto.randomUUID(); + const { env } = await buildProviderAwareCliEnv({ + env: options?.env, + connectionMode: 'augment', + }); const shell = options?.command ?? (process.platform === 'win32' - ? (process.env.COMSPEC ?? 'powershell.exe') - : (process.env.SHELL ?? '/bin/bash')); + ? (env.COMSPEC ?? process.env.COMSPEC ?? 'powershell.exe') + : (env.SHELL ?? process.env.SHELL ?? '/bin/bash')); const home = getHomeDir(); const pty = nodePty.spawn(shell, options?.args ?? [], { @@ -66,10 +71,7 @@ export class PtyTerminalService { cols: options?.cols ?? 80, rows: options?.rows ?? 24, cwd: options?.cwd ?? home, - env: { - ...buildEnrichedEnv(), - ...options?.env, - } as Record, + env: env as Record, }); pty.onData((data) => this.send(TERMINAL_DATA, id, data)); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index ef768fe1..243de82d 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,17 +1,10 @@ import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; -import { - getCachedShellEnv, - getShellPreferredHome, - resolveInteractiveShellEnv, -} from '@main/utils/shellEnv'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; -import { configManager } from '../infrastructure/ConfigManager'; - import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; +import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; import { providerConnectionService } from './ProviderConnectionService'; -import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv'; import type { CliProviderId, CliProviderStatus } from '@shared/types'; @@ -151,38 +144,29 @@ function extractModelIds( return []; } - return models.flatMap((model) => { + return models.flatMap((model) => { if (typeof model === 'string') { - return model; + return [model]; } if (typeof model?.id === 'string' && model.id.trim().length > 0) { - return model.id.trim(); + return [model.id.trim()]; } return []; }); } export class ClaudeMultimodelBridgeService { - private async buildCliEnv(binaryPath: string): Promise { - const shellEnv = getCachedShellEnv() ?? {}; - const home = - getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE; - const env = { - ...buildEnrichedEnv(binaryPath), - ...shellEnv, - }; - if (home) { - env.HOME = home; - } - applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); - return providerConnectionService.applyAllConfiguredConnectionEnv(env); + private async buildCliEnv( + binaryPath: string + ): Promise>> { + return buildProviderAwareCliEnv({ binaryPath }); } private async buildProviderCliEnv( binaryPath: string, providerId: CliProviderId - ): Promise { - return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId); + ): Promise>> { + return buildProviderAwareCliEnv({ binaryPath, providerId }); } private isUnifiedRuntimeUnsupported(error: unknown): boolean { @@ -252,12 +236,38 @@ export class ClaudeMultimodelBridgeService { }; } + private applyConnectionIssue( + provider: CliProviderStatus, + connectionIssues: Partial> + ): CliProviderStatus { + const issue = connectionIssues[provider.providerId]; + if (!issue) { + return provider; + } + + return { + ...provider, + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: issue, + backend: null, + }; + } + + private applyConnectionIssues( + providers: CliProviderStatus[], + connectionIssues: Partial> + ): CliProviderStatus[] { + return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues)); + } + async getProviderStatus( binaryPath: string, providerId: CliProviderId ): Promise { await resolveInteractiveShellEnv(); - const env = await this.buildCliEnv(binaryPath); + const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { const { stdout } = await execCli( @@ -270,7 +280,10 @@ export class ClaudeMultimodelBridgeService { ); const parsed = extractJsonObject(stdout); return providerConnectionService.enrichProviderStatus( - this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) + this.applyConnectionIssue( + this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]), + connectionIssues + ) ); } catch (error) { if (!this.isUnifiedRuntimeUnsupported(error)) { @@ -291,7 +304,7 @@ export class ClaudeMultimodelBridgeService { private async buildGeminiStatus(binaryPath: string): Promise { const provider = createDefaultProviderStatus('gemini'); - const env = await this.buildProviderCliEnv(binaryPath, 'gemini'); + const { env } = await this.buildProviderCliEnv(binaryPath, 'gemini'); try { const { stdout } = await execCli( @@ -350,7 +363,7 @@ export class ClaudeMultimodelBridgeService { onUpdate?: (providers: CliProviderStatus[]) => void ): Promise { await resolveInteractiveShellEnv(); - const env = await this.buildCliEnv(binaryPath); + const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], { @@ -359,8 +372,11 @@ export class ClaudeMultimodelBridgeService { }); const parsed = extractJsonObject(stdout); const providers = await providerConnectionService.enrichProviderStatuses( - ORDERED_PROVIDER_IDS.map((providerId) => - this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) + this.applyConnectionIssues( + ORDERED_PROVIDER_IDS.map((providerId) => + this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]) + ), + connectionIssues ) ); onUpdate?.(providers); @@ -470,7 +486,10 @@ export class ClaudeMultimodelBridgeService { onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!)); const enrichedProviders = await providerConnectionService.enrichProviderStatuses( - ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!) + this.applyConnectionIssues( + ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!), + connectionIssues + ) ); onUpdate?.(enrichedProviders); diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 8eff71de..f49e732d 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -144,6 +144,108 @@ export class ProviderConnectionService { return nextEnv; } + async augmentConfiguredConnectionEnv( + env: NodeJS.ProcessEnv, + providerId: CliProviderId + ): Promise { + if (providerId === 'anthropic') { + if (this.getConfiguredAuthMode(providerId) !== 'api_key') { + return env; + } + + const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY'); + if (storedKey?.value.trim()) { + env.ANTHROPIC_API_KEY = storedKey.value; + } + return env; + } + + if (providerId !== 'codex') { + return env; + } + + const codexConnection = this.configManager.getConfig().providerConnections.codex; + if (!codexConnection.apiKeyBetaEnabled) { + return env; + } + + env[CODEX_API_KEY_BETA_ENV_VAR] = '1'; + env.CLAUDE_CODE_CODEX_BACKEND = codexConnection.authMode === 'oauth' ? 'adapter' : 'api'; + + if (codexConnection.authMode !== 'api_key') { + return env; + } + + const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY'); + if (storedKey?.value.trim()) { + env.OPENAI_API_KEY = storedKey.value; + } + + return env; + } + + async augmentAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise { + let nextEnv = env; + for (const providerId of ['anthropic', 'codex', 'gemini'] as const) { + nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId); + } + return nextEnv; + } + + async getConfiguredConnectionIssue( + env: NodeJS.ProcessEnv, + providerId: CliProviderId + ): Promise { + if (providerId === 'anthropic') { + if (this.getConfiguredAuthMode(providerId) !== 'api_key') { + return null; + } + + if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim()) { + return null; + } + + return ( + 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured. ' + + 'Add a stored/environment API key or switch Anthropic auth mode back to Auto or OAuth.' + ); + } + + if (providerId !== 'codex') { + return null; + } + + const codexConnection = this.configManager.getConfig().providerConnections.codex; + if (!codexConnection.apiKeyBetaEnabled || codexConnection.authMode !== 'api_key') { + return null; + } + + if (typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()) { + return null; + } + + return ( + 'Codex API key mode is enabled, but no OPENAI_API_KEY is configured. ' + + 'Add a stored/environment API key or switch Codex auth mode back to OAuth.' + ); + } + + async getConfiguredConnectionIssues( + env: NodeJS.ProcessEnv, + providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini'] + ): Promise>> { + const issues: Partial> = {}; + + for (const providerId of providerIds) { + const issue = await this.getConfiguredConnectionIssue(env, providerId); + if (issue) { + issues[providerId] = issue; + } + } + + return issues; + } + async enrichProviderStatus(provider: CliProviderStatus): Promise { return { ...provider, diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts new file mode 100644 index 00000000..2793d710 --- /dev/null +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -0,0 +1,105 @@ +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; + +import { configManager } from '../infrastructure/ConfigManager'; + +import { providerConnectionService } from './ProviderConnectionService'; +import { + applyConfiguredRuntimeBackendsEnv, + applyProviderRuntimeEnv, + resolveTeamProviderId, +} from './providerRuntimeEnv'; + +import type { CliProviderId, TeamProviderId } from '@shared/types'; + +type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; + +export interface ProviderAwareCliEnvOptions { + binaryPath?: string | null; + providerId?: ProviderEnvTargetId; + shellEnv?: NodeJS.ProcessEnv | null; + env?: NodeJS.ProcessEnv; + connectionMode?: 'strict' | 'augment'; +} + +export interface ProviderAwareCliEnvResult { + env: NodeJS.ProcessEnv; + connectionIssues: Partial>; +} + +function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +export async function buildProviderAwareCliEnv( + options: ProviderAwareCliEnvOptions = {} +): Promise { + const connectionMode = options.connectionMode ?? 'strict'; + const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {}; + const env = { + ...buildEnrichedEnv(options.binaryPath), + ...shellEnv, + }; + + applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime); + + Object.assign(env, options.env ?? {}); + + const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE); + const fallbackHome = getFirstNonEmptyEnvValue( + env.HOME, + env.USERPROFILE, + getShellPreferredHome(), + shellEnv.HOME, + process.env.HOME, + process.env.USERPROFILE + ); + + if (explicitHome) { + env.HOME = getFirstNonEmptyEnvValue(options.env?.HOME, explicitHome); + env.USERPROFILE = getFirstNonEmptyEnvValue(options.env?.USERPROFILE, explicitHome); + } else if (fallbackHome) { + env.HOME = getFirstNonEmptyEnvValue(env.HOME, fallbackHome); + env.USERPROFILE = getFirstNonEmptyEnvValue(env.USERPROFILE, fallbackHome); + } + + if (options.providerId) { + const resolvedProviderId = resolveTeamProviderId(options.providerId); + applyProviderRuntimeEnv(env, options.providerId); + if (connectionMode === 'augment') { + await providerConnectionService.augmentConfiguredConnectionEnv(env, resolvedProviderId); + return { + env, + connectionIssues: {}, + }; + } + + await providerConnectionService.applyConfiguredConnectionEnv(env, resolvedProviderId); + + return { + env, + connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env, [ + resolvedProviderId, + ]), + }; + } + + if (connectionMode === 'augment') { + await providerConnectionService.augmentAllConfiguredConnectionEnv(env); + return { + env, + connectionIssues: {}, + }; + } + + await providerConnectionService.applyAllConfiguredConnectionEnv(env); + return { + env, + connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env), + }; +} diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index aa1b99e5..a98bde32 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -9,15 +9,10 @@ */ import { killProcessTree, spawnCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; -import { providerConnectionService } from '../runtime/ProviderConnectionService'; -import { - applyConfiguredRuntimeBackendsEnv, - applyProviderRuntimeEnv, -} from '../runtime/providerRuntimeEnv'; +import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types'; @@ -106,19 +101,23 @@ export class ScheduledTaskExecutor { logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); - const env = await providerConnectionService.applyConfiguredConnectionEnv( - applyProviderRuntimeEnv( - applyConfiguredRuntimeBackendsEnv({ - ...buildEnrichedEnv(binaryPath), - ...shellEnv, - CLAUDECODE: undefined, - }), - request.config.providerId - ), + const providerId = request.config.providerId === 'codex' || request.config.providerId === 'gemini' ? request.config.providerId - : 'anthropic' - ); + : 'anthropic'; + const { env, connectionIssues } = await buildProviderAwareCliEnv({ + binaryPath, + providerId, + shellEnv, + env: { + ...shellEnv, + CLAUDECODE: undefined, + }, + }); + const connectionIssue = connectionIssues[providerId]; + if (connectionIssue) { + throw new Error(connectionIssue); + } const child = spawnCli(binaryPath, args, { cwd: request.config.cwd, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index afddfdd9..1dc1a7ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -65,12 +65,8 @@ import { type GeminiRuntimeAuthState, resolveGeminiRuntimeAuth, } from '../runtime/geminiRuntimeAuth'; -import { providerConnectionService } from '../runtime/ProviderConnectionService'; -import { - applyConfiguredRuntimeBackendsEnv, - applyProviderRuntimeEnv, - resolveTeamProviderId, -} from '../runtime/providerRuntimeEnv'; +import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; +import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; @@ -704,6 +700,7 @@ type LeadActivityState = 'active' | 'idle' | 'offline'; type ProvisioningAuthSource = | 'anthropic_api_key' | 'anthropic_auth_token' + | 'configured_api_key_missing' | 'codex_runtime' | 'gemini_runtime' | 'none'; @@ -712,6 +709,7 @@ interface ProvisioningEnvResolution { env: NodeJS.ProcessEnv; authSource: ProvisioningAuthSource; geminiRuntimeAuth: GeminiRuntimeAuthState | null; + warning?: string; } interface PromptSizeSummary { @@ -3562,7 +3560,9 @@ export class TeamProvisioningService { const prefixedWarning = providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning; const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); - if ( + if (authSource === 'configured_api_key_missing') { + blockingMessages.push(prefixedWarning); + } else if ( (authSource === 'none' || authSource === 'codex_runtime' || authSource === 'gemini_runtime') && @@ -3664,7 +3664,15 @@ export class TeamProvisioningService { const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) return null; - const { env, authSource } = await this.buildProvisioningEnv(providerId); + const { env, authSource, warning } = await this.buildProvisioningEnv(providerId); + if (warning) { + return { + claudePath, + authSource, + warning, + }; + } + const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId); const result = { claudePath, @@ -4556,9 +4564,14 @@ export class TeamProvisioningService { const initialUserPrompt = request.prompt?.trim() ?? ''; const promptSize = getPromptSizeSummary(initialUserPrompt); let child: ReturnType; - const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv( - request.providerId - ); + const { + env: shellEnv, + geminiRuntimeAuth, + warning: envWarning, + } = await this.buildProvisioningEnv(request.providerId); + if (envWarning) { + throw new Error(envWarning); + } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -5087,9 +5100,14 @@ export class TeamProvisioningService { ); const promptSize = getPromptSizeSummary(prompt); let child: ReturnType; - const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv( - request.providerId - ); + const { + env: shellEnv, + geminiRuntimeAuth, + warning: envWarning, + } = await this.buildProvisioningEnv(request.providerId); + if (envWarning) { + throw new Error(envWarning); + } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -10305,21 +10323,23 @@ export class TeamProvisioningService { : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; - applyConfiguredRuntimeBackendsEnv(env); - applyProviderRuntimeEnv(env, providerId); - await providerConnectionService.applyConfiguredConnectionEnv( + const resolvedProviderId = resolveTeamProviderId(providerId); + const providerEnvResult = await buildProviderAwareCliEnv({ + providerId, + shellEnv, env, - resolveTeamProviderId(providerId) - ); + }); + const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId]; + const providerEnv = providerEnvResult.env; const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); if (controlApiBaseUrl) { - env.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl; + providerEnv.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl; } // SHELL is a Unix concept — only set it on non-Windows platforms. if (!isWindows) { - env.SHELL = shell; + providerEnv.SHELL = shell; } // XDG directories are a freedesktop.org (Linux/macOS) convention. @@ -10333,35 +10353,47 @@ export class TeamProvisioningService { shellEnv.XDG_STATE_HOME?.trim() || process.env.XDG_STATE_HOME?.trim() || `${home}/.local/state`; - env.XDG_CONFIG_HOME = xdgConfigHome; - env.XDG_STATE_HOME = xdgStateHome; + providerEnv.XDG_CONFIG_HOME = xdgConfigHome; + providerEnv.XDG_STATE_HOME = xdgStateHome; } - if (resolveTeamProviderId(providerId) === 'codex') { - return { env, authSource: 'codex_runtime', geminiRuntimeAuth: null }; - } - - if (resolveTeamProviderId(providerId) === 'gemini') { + if (providerConnectionIssue) { return { - env, + env: providerEnv, + authSource: 'configured_api_key_missing', + geminiRuntimeAuth: null, + warning: providerConnectionIssue, + }; + } + + if (resolvedProviderId === 'codex') { + return { env: providerEnv, authSource: 'codex_runtime', geminiRuntimeAuth: null }; + } + + if (resolvedProviderId === 'gemini') { + return { + env: providerEnv, authSource: 'gemini_runtime', - geminiRuntimeAuth: await resolveGeminiRuntimeAuth(env), + geminiRuntimeAuth: await resolveGeminiRuntimeAuth(providerEnv), }; } // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly - if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { - return { env, authSource: 'anthropic_api_key', geminiRuntimeAuth: null }; + if ( + typeof providerEnv.ANTHROPIC_API_KEY === 'string' && + providerEnv.ANTHROPIC_API_KEY.trim().length > 0 + ) { + return { env: providerEnv, authSource: 'anthropic_api_key', geminiRuntimeAuth: null }; } // 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var, // so we must copy it into ANTHROPIC_API_KEY for it to work. if ( - typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && - env.ANTHROPIC_AUTH_TOKEN.trim().length > 0 + typeof providerEnv.ANTHROPIC_AUTH_TOKEN === 'string' && + providerEnv.ANTHROPIC_AUTH_TOKEN.trim().length > 0 ) { - env.ANTHROPIC_API_KEY = env.ANTHROPIC_AUTH_TOKEN; - return { env, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null }; + providerEnv.ANTHROPIC_API_KEY = providerEnv.ANTHROPIC_AUTH_TOKEN; + return { env: providerEnv, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null }; } // 3. No explicit API key — let the CLI handle its own OAuth auth. @@ -10369,7 +10401,7 @@ export class TeamProvisioningService { // tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the // credentials file causes 401 errors because the stored token is // often stale (CLI refreshes in-memory but rarely writes back). - return { env, authSource: 'none', geminiRuntimeAuth: null }; + return { env: providerEnv, authSource: 'none', geminiRuntimeAuth: null }; } private async resolveControlApiBaseUrl(): Promise { diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index d707e24a..471c10f9 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -331,6 +331,14 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo ); } +function getApiKeyActionRequiredProviders( + providers: readonly CliProviderStatus[] +): CliProviderStatus[] { + return providers.filter( + (provider) => !provider.authenticated && provider.connection?.configuredAuthMode === 'api_key' + ); +} + function formatRuntimeLabel( cliStatus: NonNullable['cliStatus']> ): string | null { @@ -1232,6 +1240,29 @@ export const CliStatusBanner = (): React.JSX.Element | null => { !cliStatus.authStatusChecking && !cliStatus.authLoggedIn ) { + const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(cliStatus.providers); + const hasApiKeyModeIssue = apiKeyActionRequiredProviders.length > 0; + const primaryApiKeyProvider = apiKeyActionRequiredProviders[0] ?? null; + const apiKeyMissingProviders = apiKeyActionRequiredProviders.filter( + (provider) => provider.connection?.apiKeyConfigured !== true + ); + const allApiKeyIssuesAreMissingKeys = + hasApiKeyModeIssue && apiKeyMissingProviders.length === apiKeyActionRequiredProviders.length; + const warningTitle = hasApiKeyModeIssue + ? allApiKeyIssuesAreMissingKeys + ? 'API key required' + : 'Provider action required' + : 'Not logged in'; + const warningMessage = hasApiKeyModeIssue + ? allApiKeyIssuesAreMissingKeys + ? apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider + ? `${primaryApiKeyProvider.displayName} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.` + : 'One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.' + : apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider + ? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.` + : 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.' + : `${cliStatus.displayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`; + return ( <> {

- Not logged in + {warningTitle}

- {cliStatus.displayName} is installed but you are not authenticated. Login is - required for team provisioning and AI features. + {warningMessage}

- - + {hasApiKeyModeIssue ? ( + + ) : ( + <> + + + + )}
- {showTroubleshoot && ( + {!hasApiKeyModeIssue && showTroubleshoot && (
NodeJS.ProcessEnv>(); -const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>(); -const getShellPreferredHomeMock = vi.fn<() => string>(); +const buildProviderAwareCliEnvMock = vi.fn(); const resolveInteractiveShellEnvMock = vi.fn<() => Promise>(); const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise>(); const enrichProviderStatusMock = vi.fn((provider) => Promise.resolve(provider)); @@ -16,13 +14,7 @@ vi.mock('@main/utils/childProcess', () => ({ execCli: (...args: Parameters) => execCliMock(...args), })); -vi.mock('@main/utils/cliEnv', () => ({ - buildEnrichedEnv: (binaryPath: string) => buildEnrichedEnvMock(binaryPath), -})); - vi.mock('@main/utils/shellEnv', () => ({ - getCachedShellEnv: () => getCachedShellEnvMock(), - getShellPreferredHome: () => getShellPreferredHomeMock(), resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(), })); @@ -49,18 +41,29 @@ vi.mock('@main/services/runtime/ProviderConnectionService', () => ({ enrichProviderStatusMock(...args), enrichProviderStatuses: (...args: Parameters) => enrichProviderStatusesMock(...args), - applyAllConfiguredConnectionEnv: vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env)), }, })); +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + buildProviderAwareCliEnvMock(...args), +})); + describe('ClaudeMultimodelBridgeService', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - buildEnrichedEnvMock.mockReturnValue({}); - getCachedShellEnvMock.mockReturnValue({}); - getShellPreferredHomeMock.mockReturnValue('/Users/tester'); resolveInteractiveShellEnvMock.mockResolvedValue({}); + buildProviderAwareCliEnvMock.mockImplementation( + ({ providerId }: { providerId?: string } = {}) => + Promise.resolve({ + env: { + HOME: '/Users/tester', + ...(providerId ? { CLAUDE_CODE_ENTRY_PROVIDER: providerId } : {}), + }, + connectionIssues: {}, + }) + ); readFileMock.mockImplementation((filePath) => { if (String(filePath) === path.join('/Users/tester', '.claude.json')) { return Promise.resolve( @@ -180,4 +183,44 @@ describe('ClaudeMultimodelBridgeService', () => { }, }); }); + + it('overrides provider auth status when provider-aware env reports a missing API key', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: { + anthropic: + 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', + }, + }); + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + providers: { + anthropic: { + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + }, + }, + }), + stderr: '', + exitCode: 0, + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'anthropic'); + + expect(provider).toMatchObject({ + providerId: 'anthropic', + authenticated: false, + authMethod: null, + verificationState: 'error', + }); + expect(provider.statusMessage).toContain('ANTHROPIC_API_KEY'); + }); }); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 37ad01e3..1fdac064 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -119,6 +119,75 @@ describe('ProviderConnectionService', () => { expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); }); + it('reports a missing Anthropic API key when api_key mode is selected', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + const issue = await service.getConfiguredConnectionIssue({}, 'anthropic'); + + expect(issue).toContain('Anthropic API key mode is enabled'); + expect(issue).toContain('ANTHROPIC_API_KEY'); + }); + + it('does not report a missing Anthropic API key once env is populated', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + const issue = await service.getConfiguredConnectionIssue( + { + ANTHROPIC_API_KEY: 'env-key', + }, + 'anthropic' + ); + + expect(issue).toBeNull(); + }); + + it('augments PTY env with stored Anthropic API key without stripping auth token', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + const result = await service.augmentConfiguredConnectionEnv( + { + ANTHROPIC_AUTH_TOKEN: 'oauth-token', + }, + 'anthropic' + ); + + expect(result.ANTHROPIC_API_KEY).toBe('stored-key'); + expect(result.ANTHROPIC_AUTH_TOKEN).toBe('oauth-token'); + }); + it('prefers stored API key status over environment detection', async () => { getCachedShellEnvMock.mockReturnValue({ ANTHROPIC_API_KEY: 'shell-key', @@ -277,4 +346,68 @@ describe('ProviderConnectionService', () => { expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter'); expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1'); }); + + it('reports a missing Codex API key when beta api_key mode is enabled', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => ({ + providerConnections: { + anthropic: { + authMode: 'auto', + }, + codex: { + apiKeyBetaEnabled: true, + authMode: 'api_key', + }, + }, + }), + } as never + ); + + const issue = await service.getConfiguredConnectionIssue({}, 'codex'); + + expect(issue).toContain('Codex API key mode is enabled'); + expect(issue).toContain('OPENAI_API_KEY'); + }); + + it('augments PTY env for Codex without deleting an existing OPENAI_API_KEY in oauth mode', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => ({ + providerConnections: { + anthropic: { + authMode: 'auto', + }, + codex: { + apiKeyBetaEnabled: true, + authMode: 'oauth', + }, + }, + }), + } as never + ); + + const result = await service.augmentConfiguredConnectionEnv( + { + OPENAI_API_KEY: 'shell-key', + }, + 'codex' + ); + + expect(result.OPENAI_API_KEY).toBe('shell-key'); + expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter'); + expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1'); + }); }); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts new file mode 100644 index 00000000..ff76dce9 --- /dev/null +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const buildEnrichedEnvMock = vi.fn(); +const getCachedShellEnvMock = vi.fn(); +const getShellPreferredHomeMock = vi.fn(); +const augmentAllConfiguredConnectionEnvMock = vi.fn(); +const augmentConfiguredConnectionEnvMock = vi.fn(); +const applyConfiguredConnectionEnvMock = vi.fn(); +const applyAllConfiguredConnectionEnvMock = vi.fn(); +const getConfiguredConnectionIssuesMock = vi.fn(); + +vi.mock('@main/utils/cliEnv', () => ({ + buildEnrichedEnv: (...args: Parameters) => buildEnrichedEnvMock(...args), +})); + +vi.mock('@main/utils/shellEnv', () => ({ + getCachedShellEnv: () => getCachedShellEnvMock(), + getShellPreferredHome: () => getShellPreferredHomeMock(), +})); + +vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({ + configManager: { + getConfig: () => ({ + runtime: { + providerBackends: { + gemini: 'cli', + codex: 'adapter', + }, + }, + }), + }, +})); + +vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => ({ + providerConnectionService: { + augmentConfiguredConnectionEnv: (...args: Parameters) => + augmentConfiguredConnectionEnvMock(...args), + augmentAllConfiguredConnectionEnv: (...args: Parameters) => + augmentAllConfiguredConnectionEnvMock(...args), + applyConfiguredConnectionEnv: (...args: Parameters) => + applyConfiguredConnectionEnvMock(...args), + applyAllConfiguredConnectionEnv: (...args: Parameters) => + applyAllConfiguredConnectionEnvMock(...args), + getConfiguredConnectionIssues: (...args: Parameters) => + getConfiguredConnectionIssuesMock(...args), + }, +})); + +describe('buildProviderAwareCliEnv', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + buildEnrichedEnvMock.mockReturnValue({ + PATH: '/usr/bin', + }); + getCachedShellEnvMock.mockReturnValue({ + SHELL: '/bin/zsh', + }); + getShellPreferredHomeMock.mockReturnValue('/Users/tester'); + augmentConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) => + Promise.resolve(env) + ); + augmentAllConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) => + Promise.resolve(env) + ); + applyConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) => + Promise.resolve(env) + ); + applyAllConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) => + Promise.resolve(env) + ); + getConfiguredConnectionIssuesMock.mockResolvedValue({}); + }); + + it('builds provider-pinned CLI env and returns provider-specific issues', async () => { + getConfiguredConnectionIssuesMock.mockResolvedValue({ + anthropic: 'missing key', + }); + + const { buildProviderAwareCliEnv } = await import( + '../../../../src/main/services/runtime/providerAwareCliEnv' + ); + const result = await buildProviderAwareCliEnv({ + binaryPath: '/mock/claude', + providerId: 'anthropic', + shellEnv: { + EXTRA_FLAG: '1', + }, + }); + + expect(buildEnrichedEnvMock).toHaveBeenCalledWith('/mock/claude'); + expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + HOME: '/Users/tester', + USERPROFILE: '/Users/tester', + EXTRA_FLAG: '1', + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1', + CLAUDE_CODE_ENTRY_PROVIDER: 'anthropic', + }), + 'anthropic' + ); + expect(result.connectionIssues).toEqual({ + anthropic: 'missing key', + }); + }); + + it('builds shared env for generic CLI launches when no provider is specified', async () => { + const { buildProviderAwareCliEnv } = await import( + '../../../../src/main/services/runtime/providerAwareCliEnv' + ); + const result = await buildProviderAwareCliEnv(); + + expect(applyAllConfiguredConnectionEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + HOME: '/Users/tester', + USERPROFILE: '/Users/tester', + SHELL: '/bin/zsh', + }) + ); + expect(getConfiguredConnectionIssuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + HOME: '/Users/tester', + }) + ); + expect(result.connectionIssues).toEqual({}); + }); + + it('uses non-destructive credential augmentation for PTY-style envs', async () => { + const { buildProviderAwareCliEnv } = await import( + '../../../../src/main/services/runtime/providerAwareCliEnv' + ); + const result = await buildProviderAwareCliEnv({ + connectionMode: 'augment', + env: { + OPENAI_API_KEY: 'shell-key', + }, + }); + + expect(applyAllConfiguredConnectionEnvMock).not.toHaveBeenCalled(); + expect(augmentAllConfiguredConnectionEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + OPENAI_API_KEY: 'shell-key', + }) + ); + expect(result.connectionIssues).toEqual({}); + }); + + it('preserves caller-provided HOME and USERPROFILE overrides', async () => { + const { buildProviderAwareCliEnv } = await import( + '../../../../src/main/services/runtime/providerAwareCliEnv' + ); + const result = await buildProviderAwareCliEnv({ + providerId: 'anthropic', + env: { + HOME: '/Users/electron-home', + USERPROFILE: '/Users/electron-home', + }, + }); + + expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + HOME: '/Users/electron-home', + USERPROFILE: '/Users/electron-home', + }), + 'anthropic' + ); + expect(result.env.HOME).toBe('/Users/electron-home'); + expect(result.env.USERPROFILE).toBe('/Users/electron-home'); + }); + + it('preserves explicit backend overrides passed by the caller', async () => { + const { buildProviderAwareCliEnv } = await import( + '../../../../src/main/services/runtime/providerAwareCliEnv' + ); + const result = await buildProviderAwareCliEnv({ + connectionMode: 'augment', + env: { + CLAUDE_CODE_GEMINI_BACKEND: 'api', + }, + }); + + expect(augmentAllConfiguredConnectionEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + CLAUDE_CODE_GEMINI_BACKEND: 'api', + CLAUDE_CODE_CODEX_BACKEND: 'adapter', + }) + ); + expect(result.env.CLAUDE_CODE_GEMINI_BACKEND).toBe('api'); + expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter'); + }); +}); diff --git a/test/main/services/schedule/ScheduledTaskExecutor.test.ts b/test/main/services/schedule/ScheduledTaskExecutor.test.ts index 2b4fe5f7..223d6261 100644 --- a/test/main/services/schedule/ScheduledTaskExecutor.test.ts +++ b/test/main/services/schedule/ScheduledTaskExecutor.test.ts @@ -16,6 +16,7 @@ const mockSpawnCli = vi.fn(); const mockKillProcessTree = vi.fn(); const mockResolve = vi.fn(); const mockResolveShellEnv = vi.fn(); +const buildProviderAwareCliEnvMock = vi.fn(); vi.mock('@main/utils/childProcess', () => ({ spawnCli: (...args: unknown[]) => mockSpawnCli(...args), @@ -26,8 +27,9 @@ vi.mock('@main/utils/shellEnv', () => ({ resolveInteractiveShellEnv: () => mockResolveShellEnv(), })); -vi.mock('@main/utils/cliEnv', () => ({ - buildEnrichedEnv: () => ({ ...process.env }), +vi.mock('../../../../src/main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + buildProviderAwareCliEnvMock(...args), })); vi.mock('../../../../src/main/services/team/ClaudeBinaryResolver', () => ({ @@ -84,6 +86,10 @@ describe('ScheduledTaskExecutor', () => { vi.clearAllMocks(); mockResolve.mockResolvedValue('/usr/local/bin/claude'); mockResolveShellEnv.mockResolvedValue({ SHELL: '/bin/zsh' }); + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { ...process.env, SHELL: '/bin/zsh' }, + connectionIssues: {}, + }); const mod = await import('../../../../src/main/services/schedule/ScheduledTaskExecutor'); ScheduledTaskExecutor = mod.ScheduledTaskExecutor; @@ -445,7 +451,7 @@ describe('ScheduledTaskExecutor', () => { const opts = mockSpawnCli.mock.calls[0][2]; expect(opts.cwd).toBe('/home/user/project'); - expect(opts.env.MY_VAR).toBe('test'); + expect(opts.env.SHELL).toBe('/bin/zsh'); expect(opts.stdio).toEqual(['ignore', 'pipe', 'pipe']); proc.emit('close', 0); @@ -477,4 +483,19 @@ describe('ScheduledTaskExecutor', () => { } } }); + + it('fails fast when provider-aware env reports a missing API key', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { SHELL: '/bin/zsh' }, + connectionIssues: { + anthropic: + 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', + }, + }); + + const executor = new ScheduledTaskExecutor(); + + await expect(executor.execute(makeRequest())).rejects.toThrow('ANTHROPIC_API_KEY'); + expect(mockSpawnCli).not.toHaveBeenCalled(); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index e5d7874e..208a2385 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -12,6 +12,12 @@ vi.mock('@main/utils/shellEnv', () => ({ resolveInteractiveShellEnv: vi.fn(), })); +const buildProviderAwareCliEnvMock = vi.fn(); +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + buildProviderAwareCliEnvMock(...args), +})); + const addTeamNotificationMock = vi.fn().mockResolvedValue(null); vi.mock('@main/services/infrastructure/NotificationManager', () => ({ NotificationManager: { @@ -37,6 +43,12 @@ describe('TeamProvisioningService prepare/auth behavior', () => { PATH: '/usr/bin', SHELL: '/bin/zsh', }); + buildProviderAwareCliEnvMock.mockImplementation(({ env }: { env: NodeJS.ProcessEnv }) => + Promise.resolve({ + env, + connectionIssues: {}, + }) + ); delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_AUTH_TOKEN; }); @@ -82,18 +94,18 @@ describe('TeamProvisioningService prepare/auth behavior', () => { it('checks each unique provider during multi-provider prepare and blocks on provider auth failure', async () => { const svc = new TeamProvisioningService(); const getCachedOrProbeResult = vi.spyOn(svc as any, 'getCachedOrProbeResult'); - getCachedOrProbeResult.mockImplementation(async (_cwd: unknown, providerId: unknown) => { + getCachedOrProbeResult.mockImplementation((_cwd: unknown, providerId: unknown) => { if (providerId === 'codex') { - return { + return Promise.resolve({ claudePath: '/fake/claude', authSource: 'none', warning: 'Not logged in to Codex runtime', - }; + }); } - return { + return Promise.resolve({ claudePath: '/fake/claude', authSource: 'oauth_token', - }; + }); }); const result = await svc.prepareForProvisioning(tempRoot, { @@ -140,6 +152,51 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.env.ANTHROPIC_API_KEY).toBe('real-key'); }); + it('allows help-env resolution to continue even when provisioning env warns', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'configured_api_key_missing', + geminiRuntimeAuth: null, + warning: 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', + }); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'none', + }); + vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({ + stdout: 'usage: claude [options]', + stderr: '', + exitCode: 0, + }); + + const output = await svc.getCliHelpOutput(tempRoot); + + expect(output).toContain('usage: claude'); + }); + + it('surfaces a missing configured Anthropic API key before probing', async () => { + const svc = new TeamProvisioningService(); + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + connectionIssues: { + anthropic: + 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.', + }, + }); + + const result = await (svc as any).buildProvisioningEnv(); + + expect(result.authSource).toBe('configured_api_key_missing'); + expect(result.warning).toContain('ANTHROPIC_API_KEY'); + }); + it('does not treat assistant-text 401 noise as an auth failure', () => { const svc = new TeamProvisioningService(); @@ -213,7 +270,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { const svc = new TeamProvisioningService(); const handleAuthFailureInOutput = vi .spyOn(svc as any, 'handleAuthFailureInOutput') - .mockImplementation(() => {}); + .mockImplementation(() => undefined); const run = { runId: 'run-2', diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 696dafd8..98883aa2 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -42,6 +42,8 @@ interface StoreState { const storeState = {} as StoreState; let providerRuntimeSettingsDialogProps: { onSelectBackend?: (providerId: string, backendId: string) => Promise | void; + open?: boolean; + initialProviderId?: string; } | null = null; vi.mock('@renderer/api', () => ({ @@ -58,9 +60,19 @@ vi.mock('@renderer/components/common/ConfirmDialog', () => ({ vi.mock('@renderer/components/runtime/ProviderRuntimeSettingsDialog', () => ({ ProviderRuntimeSettingsDialog: (props: { onSelectBackend?: (providerId: string, backendId: string) => Promise | void; + open?: boolean; + initialProviderId?: string; }) => { providerRuntimeSettingsDialogProps = props; - return null; + return React.createElement( + 'div', + { + 'data-testid': 'provider-runtime-settings-dialog', + 'data-open': String(Boolean(props.open)), + 'data-provider': props.initialProviderId ?? '', + }, + null + ); }, })); @@ -135,6 +147,60 @@ function createInstalledCliStatus( }; } +function createApiKeyMisconfiguredProvider( + providerId: 'anthropic' | 'codex' +): Record { + return { + providerId, + displayName: providerId === 'anthropic' ? 'Anthropic' : 'Codex', + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: + providerId === 'anthropic' + ? 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.' + : 'Codex API key mode is enabled, but no OPENAI_API_KEY is configured.', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + connection: { + supportsOAuth: true, + supportsApiKey: true, + configurableAuthModes: providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'], + configuredAuthMode: 'api_key', + apiKeyBetaAvailable: providerId === 'codex' ? true : undefined, + apiKeyBetaEnabled: providerId === 'codex' ? true : undefined, + apiKeyConfigured: false, + apiKeySource: null, + apiKeySourceLabel: null, + }, + }; +} + +function createApiKeyModeProviderIssue( + providerId: 'anthropic' | 'codex' +): Record { + return { + ...createApiKeyMisconfiguredProvider(providerId), + statusMessage: + providerId === 'anthropic' + ? 'Anthropic API key was rejected by the runtime.' + : 'OpenAI API key was rejected by the runtime.', + connection: { + ...((createApiKeyMisconfiguredProvider(providerId) as { connection: Record }) + .connection), + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: + providerId === 'anthropic' ? 'Stored Anthropic API key' : 'Stored OpenAI API key', + }, + }; +} + describe('CLI status visibility during completed install state', () => { afterEach(() => { document.body.innerHTML = ''; @@ -401,4 +467,74 @@ describe('CLI status visibility during completed install state', () => { await Promise.resolve(); }); }); + + it('routes API-key misconfiguration to provider settings instead of login', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + authLoggedIn: false, + providers: [createApiKeyMisconfiguredProvider('anthropic')], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('API key required'); + expect(host.textContent).toContain('Manage Providers'); + expect(host.textContent).not.toContain('Already logged in?'); + expect(host.textContent).not.toContain('Login'); + + const manageButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Manage Providers') + ); + expect(manageButton).not.toBeUndefined(); + + await act(async () => { + manageButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + const dialog = host.querySelector('[data-testid="provider-runtime-settings-dialog"]'); + expect(dialog?.getAttribute('data-open')).toBe('true'); + expect(dialog?.getAttribute('data-provider')).toBe('anthropic'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps API-key mode issues on provider settings even when a saved key exists', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + authLoggedIn: false, + providers: [createApiKeyModeProviderIssue('anthropic')], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Provider action required'); + expect(host.textContent).toContain('Manage Providers'); + expect(host.textContent).not.toContain('Already logged in?'); + expect(host.textContent).not.toContain('Login'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From f74b7a3701ea11314df1ef62d9d991eee9d0d645 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 20:15:52 +0300 Subject: [PATCH 02/21] fix(agent-graph): keep graph state consistent across panes --- .../agent-graph/src/canvas/draw-agents.ts | 43 ++- packages/agent-graph/src/canvas/draw-edges.ts | 6 +- .../agent-graph/src/canvas/draw-particles.ts | 2 + .../agent-graph/src/canvas/draw-processes.ts | 4 +- packages/agent-graph/src/canvas/draw-tasks.ts | 133 +++++++- .../agent-graph/src/layout/kanbanLayout.ts | 16 +- packages/agent-graph/src/ports/types.ts | 24 ++ packages/agent-graph/src/ui/GraphCanvas.tsx | 42 ++- packages/agent-graph/src/ui/GraphView.tsx | 11 +- .../agent-graph/src/ui/buildFocusState.ts | 152 +++++++++ .../components/team/TeamDetailView.tsx | 16 +- .../agent-graph/adapters/TeamGraphAdapter.ts | 276 +++++++-------- .../adapters/useTeamGraphAdapter.ts | 13 +- .../agent-graph/ui/GraphNodePopover.tsx | 112 +++++- .../features/agent-graph/ui/GraphTaskCard.tsx | 23 +- .../utils/collapseOverflowStacks.ts | 81 +++++ .../agent-graph/utils/taskGraphSemantics.ts | 48 +++ src/renderer/store/index.ts | 170 +++++----- src/renderer/store/slices/teamSlice.ts | 319 +++++++++++++----- src/renderer/utils/memberHelpers.ts | 4 +- src/renderer/utils/taskChangeRequest.ts | 2 +- src/shared/types/team.ts | 165 +++++++++ .../agent-graph/GraphNodePopover.test.ts | 178 ++++++++++ .../agent-graph/TeamGraphAdapter.test.ts | 214 ++++++++++++ .../agent-graph/buildFocusState.test.ts | 173 ++++++++++ .../collapseOverflowStacks.test.ts | 76 +++++ .../renderer/store/teamChangeThrottle.test.ts | 209 +++++++++++- test/renderer/store/teamSlice.test.ts | 270 +++++++++++++++ 28 files changed, 2410 insertions(+), 372 deletions(-) create mode 100644 packages/agent-graph/src/ui/buildFocusState.ts create mode 100644 src/renderer/features/agent-graph/utils/collapseOverflowStacks.ts create mode 100644 src/renderer/features/agent-graph/utils/taskGraphSemantics.ts create mode 100644 test/renderer/features/agent-graph/buildFocusState.test.ts create mode 100644 test/renderer/features/agent-graph/collapseOverflowStacks.test.ts diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 97c4c27c..87fa7b5d 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -24,11 +24,12 @@ export function drawAgents( nodes: GraphNode[], time: number, selectedId: string | null, - hoveredId: string | null + hoveredId: string | null, + focusNodeIds?: ReadonlySet | null ): void { for (const node of nodes) { if (node.kind !== 'member' && node.kind !== 'lead') continue; - const opacity = getNodeOpacity(node); + const opacity = getNodeOpacity(node) * getFocusOpacity(node.id, focusNodeIds); if (opacity < MIN_VISIBLE_OPACITY) continue; const x = node.x ?? 0; @@ -95,6 +96,10 @@ export function drawAgents( drawToolCard(ctx, x, y, r, node.activeTool, time); } + if (node.exceptionTone) { + drawExceptionPip(ctx, x, y, r, node.exceptionTone); + } + // Name + role label (single line: "jack · developer") const labelText = node.role ? `${node.label} · ${node.role}` : node.label; drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel); @@ -123,7 +128,8 @@ export function drawCrossTeamNodes( nodes: GraphNode[], time: number, selectedId: string | null, - hoveredId: string | null + hoveredId: string | null, + focusNodeIds?: ReadonlySet | null ): void { for (const node of nodes) { if (node.kind !== 'crossteam') continue; @@ -136,7 +142,7 @@ export function drawCrossTeamNodes( const isHovered = node.id === hoveredId; ctx.save(); - ctx.globalAlpha = isHovered ? 0.7 : 0.5; + ctx.globalAlpha = (isHovered ? 0.7 : 0.5) * getFocusOpacity(node.id, focusNodeIds); // Subtle glow const glowR = r + AGENT_DRAW.glowPadding; @@ -188,6 +194,35 @@ function getNodeOpacity(node: GraphNode): number { return 1; } +function getFocusOpacity( + nodeId: string, + focusNodeIds?: ReadonlySet | null +): number { + return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1; +} + +function drawExceptionPip( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + tone: NonNullable +): void { + const pipX = x + r * 0.58; + const pipY = y - r * 0.58; + const pipColor = tone === 'error' ? '#ef4444' : '#f59e0b'; + + ctx.save(); + ctx.beginPath(); + ctx.arc(pipX, pipY, 4.5, 0, Math.PI * 2); + ctx.fillStyle = pipColor; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = '#050510'; + ctx.stroke(); + ctx.restore(); +} + function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { ctx.save(); ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts index 9982168b..7731610b 100644 --- a/packages/agent-graph/src/canvas/draw-edges.ts +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -74,6 +74,7 @@ export function drawEdges( nodeMap: Map, _time: number, hasActiveParticles: Set, + focusEdgeIds?: ReadonlySet | null, ): void { for (const edge of edges) { const source = nodeMap.get(edge.source); @@ -87,13 +88,14 @@ export function drawEdges( const alpha = isActive ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) : BEAM.idleAlpha; + const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1; - if (alpha < MIN_VISIBLE_OPACITY) continue; + if (alpha * focusAlpha < MIN_VISIBLE_OPACITY) continue; const cp = computeControlPoints(source.x, source.y, target.x, target.y); ctx.save(); - ctx.globalAlpha = alpha; + ctx.globalAlpha = alpha * focusAlpha; // Subtle glow pass when edge has active particles if (isActive) { diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts index ce222779..a5086876 100644 --- a/packages/agent-graph/src/canvas/draw-particles.ts +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -27,8 +27,10 @@ export function drawParticles( edgeMap: Map, nodeMap: Map, time: number, + focusEdgeIds?: ReadonlySet | null, ): void { for (const p of particles) { + if (focusEdgeIds && !focusEdgeIds.has(p.edgeId)) continue; const edge = edgeMap.get(p.edgeId); if (!edge) continue; diff --git a/packages/agent-graph/src/canvas/draw-processes.ts b/packages/agent-graph/src/canvas/draw-processes.ts index 25485126..cfde4344 100644 --- a/packages/agent-graph/src/canvas/draw-processes.ts +++ b/packages/agent-graph/src/canvas/draw-processes.ts @@ -17,6 +17,7 @@ export function drawProcesses( time: number, selectedId: string | null, hoveredId: string | null, + focusNodeIds?: ReadonlySet | null, ): void { for (const node of nodes) { if (node.kind !== 'process') continue; @@ -26,9 +27,10 @@ export function drawProcesses( const r = NODE.radiusProcess; const isSelected = node.id === selectedId; const isHovered = node.id === hoveredId; + const focusOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1; ctx.save(); - ctx.globalAlpha = 0.8; + ctx.globalAlpha = 0.8 * focusOpacity; // Glow — use cached sprite instead of createRadialGradient per frame const procColor = node.color ?? COLORS.tool_calling; diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index 04184bf8..3c825e62 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -19,11 +19,12 @@ export function drawTasks( time: number, selectedId: string | null, hoveredId: string | null, + focusNodeIds?: ReadonlySet | null ): void { for (const node of nodes) { if (node.kind !== 'task') continue; - const opacity = getTaskOpacity(node); + const opacity = getTaskOpacity(node, focusNodeIds); if (opacity < MIN_VISIBLE_OPACITY) continue; const x = node.x ?? 0; @@ -42,8 +43,12 @@ export function drawTasks( // ─── Private ──────────────────────────────────────────────────────────────── -function getTaskOpacity(_node: GraphNode): number { - if (_node.taskStatus === 'deleted') return 0; +function getTaskOpacity( + node: GraphNode, + focusNodeIds?: ReadonlySet | null +): number { + if (node.taskStatus === 'deleted') return 0; + if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25; return 1; } @@ -54,7 +59,7 @@ function drawTaskPill( node: GraphNode, time: number, isSelected: boolean, - isHovered: boolean, + isHovered: boolean ): void { const w = TASK_PILL.width; const h = TASK_PILL.height; @@ -65,6 +70,15 @@ function drawTaskPill( const statusColor = getTaskStatusColor(node.taskStatus); const reviewColor = getReviewStateColor(node.reviewState); + ctx.save(); + ctx.translate(x, y); + + if (node.isOverflowStack) { + drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered); + ctx.restore(); + return; + } + // Pulse only for active work — completed + approved = static const needsAttention = (node.taskStatus === 'in_progress' && node.reviewState !== 'approved') || @@ -72,13 +86,12 @@ function drawTaskPill( node.reviewState === 'needsFix' || (node.needsClarification != null); const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved'; - const breathe = needsAttention && !isFinished - ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) - : 1; + const breathe = + needsAttention && !isFinished + ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) + : 1; const scale = breathe; - ctx.save(); - ctx.translate(x, y); ctx.scale(scale, scale); // Shadow — stronger for attention tasks, red for blocked @@ -122,9 +135,10 @@ function drawTaskPill( if (reviewColor !== 'transparent') { ctx.beginPath(); ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1); - const reviewAlpha = node.reviewState === 'approved' - ? 0.6 // static — no pulse - : 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix + const reviewAlpha = + node.reviewState === 'approved' + ? 0.6 + : 0.5 + 0.3 * Math.sin(time * 3); ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha); ctx.lineWidth = 1.5; ctx.stroke(); @@ -147,7 +161,10 @@ function drawTaskPill( ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.textPrimary; const textX = -halfW + 10; - const maxW = w - 18; + const hasReviewChip = + node.reviewState !== 'approved' && + (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName)); + const maxW = hasReviewChip ? w - 64 : w - 18; const subject = truncateText(ctx, node.sublabel, maxW, ctx.font); ctx.fillText(subject, textX, -4); } @@ -169,6 +186,13 @@ function drawTaskPill( ctx.fillText('\u2713', halfW - 8, 0); // ✓ } + if ( + node.reviewState !== 'approved' && + (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && node.reviewerName)) + ) { + drawReviewChip(ctx, halfW, -halfH, node); + } + // Comment count badge — on the bottom-right border edge, 1.5x bigger if (node.totalCommentCount && node.totalCommentCount > 0) { const badgeX = halfW - 6; @@ -215,12 +239,93 @@ function drawTaskPill( ctx.restore(); } +function drawOverflowStack( + ctx: CanvasRenderingContext2D, + halfW: number, + halfH: number, + r: number, + node: GraphNode, + isSelected: boolean, + isHovered: boolean +): void { + for (const [offset, alpha] of [ + [6, 0.18], + [3, 0.28], + ] as const) { + ctx.beginPath(); + ctx.roundRect(-halfW + offset, -halfH - offset, TASK_PILL.width, TASK_PILL.height, r); + ctx.fillStyle = hexWithAlpha('#334155', alpha); + ctx.fill(); + } + + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, TASK_PILL.width, TASK_PILL.height, r); + ctx.fillStyle = isSelected + ? COLORS.cardBgSelected + : isHovered + ? 'rgba(15, 20, 40, 0.78)' + : COLORS.cardBg; + ctx.fill(); + ctx.strokeStyle = hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); + + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = COLORS.textPrimary; + ctx.fillText(node.label, -halfW + 12, -2); + + ctx.font = '7px monospace'; + ctx.fillStyle = COLORS.textDim; + ctx.fillText('more tasks', -halfW + 12, 10); +} + +function drawReviewChip( + ctx: CanvasRenderingContext2D, + halfW: number, + halfH: number, + node: GraphNode +): void { + const chipText = node.reviewMode === 'manual' ? 'REV' : node.reviewerName ?? 'REV'; + const chipColor = node.reviewMode === 'manual' ? '#8b5cf6' : (node.reviewerColor ?? '#38bdf8'); + const chipX = halfW - 44; + const chipY = halfH + 10; + const chipW = 34; + const chipH = 12; + + ctx.beginPath(); + ctx.roundRect(chipX, chipY, chipW, chipH, 6); + ctx.fillStyle = hexWithAlpha(chipColor, 0.2); + ctx.fill(); + ctx.strokeStyle = hexWithAlpha(chipColor, 0.55); + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.font = 'bold 7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(chipColor, 0.95); + ctx.fillText( + chipText.length > 8 ? `${chipText.slice(0, 7)}…` : chipText, + chipX + chipW / 2, + chipY + chipH / 2 + 0.5 + ); + + if (node.changePresence === 'has_changes') { + ctx.beginPath(); + ctx.arc(chipX + chipW + 4, chipY + chipH / 2, 2.5, 0, Math.PI * 2); + ctx.fillStyle = '#38bdf8'; + ctx.fill(); + } +} + /** * Draw kanban column headers above task columns. */ export function drawColumnHeaders( ctx: CanvasRenderingContext2D, - zones: KanbanZoneInfo[], + zones: KanbanZoneInfo[] ): void { for (const zone of zones) { // Section header for unassigned tasks — larger, centered above all columns diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index e8bb24e4..d11d179e 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -94,7 +94,7 @@ export class KanbanLayoutEngine { // ─── Private ────────────────────────────────────────────────────────────── static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null { - const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE; + const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; const headerHeight = 20; // space for column header label const baseY = ownerY + offsetY; @@ -129,8 +129,8 @@ export class KanbanLayoutEngine { for (const [colIdx, col] of activeColumns.entries()) { const colX = baseX + colIdx * columnWidth; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; - const overflow = Math.max(0, col.tasks.length - maxVisibleRows); - const visibleCount = Math.min(col.tasks.length, maxVisibleRows); + const overflow = col.tasks.find((task) => task.isOverflowStack)?.overflowCount ?? 0; + const visibleCount = col.tasks.length; // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) headers.push({ @@ -144,13 +144,6 @@ export class KanbanLayoutEngine { // Position tasks below header for (const [rowIdx, task] of col.tasks.entries()) { - if (rowIdx >= maxVisibleRows) { - task.x = -99999; - task.y = -99999; - task.fx = task.x; - task.fy = task.y; - continue; - } const targetX = colX; const targetY = baseY + headerHeight + rowIdx * rowHeight; task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; @@ -207,6 +200,7 @@ export class KanbanLayoutEngine { // Add zone header for unassigned section if (tasks.length > 0) { + const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0); this.zones.push({ ownerId: '__unassigned__', ownerX: centerX, @@ -216,7 +210,7 @@ export class KanbanLayoutEngine { x: centerX, y: baseY - 10, color: COLORS.taskPending, - overflowCount: Math.max(0, tasks.length - cols * KANBAN_ZONE.maxVisibleRows), + overflowCount, overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, }], }); diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index a7b96067..59a7beed 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -78,6 +78,10 @@ export interface GraphNode { resultPreview?: string; source: 'runtime' | 'member_log' | 'inbox'; }>; + /** Compact abnormal-state indicator */ + exceptionTone?: 'warning' | 'error'; + /** Short human-readable abnormal-state label */ + exceptionLabel?: string; // ─── Task-specific ───────────────────────────────────────────────────── /** Short display ID (e.g., "#3") */ @@ -90,6 +94,14 @@ export interface GraphNode { taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted'; /** Review state overlay */ reviewState?: 'none' | 'review' | 'needsFix' | 'approved'; + /** Reviewer shown as a compact handoff chip for active review cycles */ + reviewerName?: string | null; + /** Reviewer chip mode */ + reviewMode?: 'assigned' | 'manual'; + /** Reviewer color override for compact review chip */ + reviewerColor?: string; + /** Cheap persisted change-presence state used only for active review chips */ + changePresence?: 'has_changes' | 'no_changes' | 'unknown'; /** Requires clarification indicator */ needsClarification?: 'lead' | 'user' | null; /** Task is blocked by other tasks */ @@ -102,6 +114,12 @@ export interface GraphNode { totalCommentCount?: number; /** Unread comment count on this task */ unreadCommentCount?: number; + /** Synthetic overflow stack node instead of hidden task tails */ + isOverflowStack?: boolean; + /** Number of hidden tasks behind this overflow stack */ + overflowCount?: number; + /** Raw task IDs hidden behind this overflow stack */ + overflowTaskIds?: string[]; // ─── Process-specific ────────────────────────────────────────────────── /** Clickable URL for process */ @@ -163,5 +181,11 @@ export type GraphDomainRef = | { kind: 'lead'; teamName: string; memberName: string } | { kind: 'member'; teamName: string; memberName: string } | { kind: 'task'; teamName: string; taskId: string } + | { + kind: 'task_overflow'; + teamName: string; + ownerMemberName?: string | null; + columnKey: string; + } | { kind: 'process'; teamName: string; processId: string } | { kind: 'crossteam'; teamName: string; externalTeamName: string }; diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 3548c49b..6fc2a726 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -30,6 +30,8 @@ export interface GraphDrawState { camera: CameraTransform; selectedNodeId: string | null; hoveredNodeId: string | null; + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; } export interface GraphCanvasHandle { @@ -199,20 +201,48 @@ export const GraphCanvas = forwardRef(funct visibleEdges.push(e); } } - drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges); + drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges, state.focusEdgeIds); // 2b. Particles (cap at 100 for performance) const cappedParticles = state.particles.length > 100 ? state.particles.slice(-100) : state.particles; - drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time); + drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time, state.focusEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) - drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); - drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawProcesses( + ctx, + visibleNodes, + state.time, + state.selectedNodeId, + state.hoveredNodeId, + state.focusNodeIds + ); + drawCrossTeamNodes( + ctx, + visibleNodes, + state.time, + state.selectedNodeId, + state.hoveredNodeId, + state.focusNodeIds + ); drawColumnHeaders(ctx, KanbanLayoutEngine.zones); - drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); - drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawTasks( + ctx, + visibleNodes, + state.time, + state.selectedNodeId, + state.hoveredNodeId, + state.focusNodeIds + ); + drawAgents( + ctx, + visibleNodes, + state.time, + state.selectedNodeId, + state.hoveredNodeId, + state.focusNodeIds + ); // 2d. Effects drawEffects(ctx, state.effects); diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 90bd07a2..63dd1636 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -10,7 +10,7 @@ * ALL animation state (positions, particles, effects, time) lives in refs. */ -import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; @@ -19,6 +19,7 @@ import type { GraphNode } from '../ports/types'; import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; +import { buildFocusState } from './buildFocusState'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; @@ -114,6 +115,10 @@ export function GraphView({ // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── const idleFrameSkip = useRef(0); + const focusState = useMemo( + () => buildFocusState(selectedNodeId, data.nodes, data.edges), + [selectedNodeId, data.edges, data.nodes] + ); const animate = useCallback(() => { if (!runningRef.current) return; @@ -154,11 +159,13 @@ export function GraphView({ camera: cameraRef.current.transformRef.current, selectedNodeId: selectedNodeIdRef.current, hoveredNodeId: interaction.hoveredNodeId.current, + focusNodeIds: focusState.focusNodeIds, + focusEdgeIds: focusState.focusEdgeIds, }); rafRef.current = requestAnimationFrame(animate); // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs - }, []); + }, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]); // Start/stop RAF useEffect(() => { diff --git a/packages/agent-graph/src/ui/buildFocusState.ts b/packages/agent-graph/src/ui/buildFocusState.ts new file mode 100644 index 00000000..71b82ad2 --- /dev/null +++ b/packages/agent-graph/src/ui/buildFocusState.ts @@ -0,0 +1,152 @@ +import type { GraphEdge, GraphNode } from '../ports/types'; + +export interface GraphFocusState { + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; +} + +function addNode(nodeIds: Set, nodeId: string | null | undefined): void { + if (nodeId) { + nodeIds.add(nodeId); + } +} + +function addNodeAndIncidentEdges( + nodeIds: Set, + edgeIds: Set, + nodeId: string | null | undefined, + adjacency: Map +): void { + if (!nodeId) return; + nodeIds.add(nodeId); + for (const edge of adjacency.get(nodeId) ?? []) { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } +} + +export function buildFocusState( + selectedNodeId: string | null, + nodes: GraphNode[], + edges: GraphEdge[] +): GraphFocusState { + if (!selectedNodeId) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; + if ( + !selectedNode || + selectedNode.kind === 'process' || + selectedNode.kind === 'crossteam' || + selectedNode.isOverflowStack + ) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeIds = new Set([selectedNodeId]); + const edgeIds = new Set(); + const adjacency = new Map(); + + for (const edge of edges) { + const sourceEdges = adjacency.get(edge.source) ?? []; + sourceEdges.push(edge); + adjacency.set(edge.source, sourceEdges); + + const targetEdges = adjacency.get(edge.target) ?? []; + targetEdges.push(edge); + adjacency.set(edge.target, targetEdges); + } + + const selectedMemberName = + selectedNode.domainRef.kind === 'member' || selectedNode.domainRef.kind === 'lead' + ? selectedNode.domainRef.memberName + : null; + + if (selectedNode.kind === 'lead') { + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); + } else if (selectedNode.kind === 'member') { + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); + + for (const node of nodes) { + if (node.kind !== 'task') continue; + if (node.isOverflowStack) { + if (node.ownerId === selectedNodeId) { + nodeIds.add(node.id); + for (const edge of adjacency.get(node.id) ?? []) { + edgeIds.add(edge.id); + } + } + continue; + } + + const isOwnedTask = node.ownerId === selectedNodeId; + const isReviewTask = + selectedMemberName != null && + node.reviewerName === selectedMemberName && + node.domainRef.kind === 'task' && + node.domainRef.taskId !== selectedNode.currentTaskId; + if (!isOwnedTask && !isReviewTask) continue; + + nodeIds.add(node.id); + for (const edge of adjacency.get(node.id) ?? []) { + if (edge.type === 'ownership' || edge.type === 'blocking') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + } else if (selectedNode.kind === 'task') { + if (selectedNode.ownerId) { + addNode(nodeIds, selectedNode.ownerId); + } + + if (selectedNode.reviewerName) { + const reviewerNode = nodes.find( + (node) => + node.kind === 'member' && + node.domainRef.kind === 'member' && + node.domainRef.memberName === selectedNode.reviewerName + ); + if (reviewerNode) { + nodeIds.add(reviewerNode.id); + } + } + + for (const edge of adjacency.get(selectedNodeId) ?? []) { + if (edge.type === 'ownership' || edge.type === 'blocking') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + const focusedMemberIds = Array.from(nodeIds).filter((nodeId) => { + const node = nodes.find((candidate) => candidate.id === nodeId); + return node?.kind === 'member'; + }); + + for (const memberId of focusedMemberIds) { + for (const edge of adjacency.get(memberId) ?? []) { + if (edge.type === 'parent-child') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + for (const edge of edges) { + if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { + edgeIds.add(edge.id); + } + } + + return { + focusNodeIds: nodeIds, + focusEdgeIds: edgeIds, + }; +} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 090b1ad3..cceee234 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,5 +1,4 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ComponentProps } from 'react'; import { api } from '@renderer/api'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; @@ -36,8 +35,8 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { createLogger } from '@shared/utils/logger'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { createLogger } from '@shared/utils/logger'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, @@ -73,6 +72,7 @@ import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { ComponentProps } from 'react'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) @@ -92,13 +92,13 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; +import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { isLeadSessionMissing, shouldSuppressMissingLeadSessionFetch, } from './teamSessionFetchGuards'; -import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; -import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; @@ -2781,10 +2781,10 @@ export const TeamDetailView = ({ if (task) setSelectedTask(task); }} onOpenMemberProfile={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); + const member = data.members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + } }} /> diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 8b1b3e5d..3bb496cb 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -4,20 +4,27 @@ * This is the ONLY file in this feature that imports from @renderer/store. * If the project data model changes, ONLY this class needs updating. * - * Class-based with ES #private fields, caching, and DI-ready constructor. + * Class-based with ES #private fields and DI-ready constructor. */ import { getUnreadCount } from '@renderer/services/commentReadStorage'; +import { agentAvatarUrl, getMemberRuntimeAdvisoryLabel } from '@renderer/utils/memberHelpers'; import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary'; -import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { stripCrossTeamPrefix } from '@shared/constants/crossTeam'; import { - getIdleGraphLabel, classifyIdleNotificationText, + getIdleGraphLabel, } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { collapseOverflowStacks } from '../utils/collapseOverflowStacks'; +import { + isTaskBlocked, + isTaskInReviewCycle, + resolveTaskReviewer, +} from '../utils/taskGraphSemantics'; + import type { GraphDataPort, GraphEdge, @@ -28,6 +35,7 @@ import type { import type { ActiveToolCall, InboxMessage, + LeadActivityState, MemberSpawnStatusEntry, TeamData, } from '@shared/types/team'; @@ -36,8 +44,6 @@ import type { LeadContextUsage } from '@shared/types/team'; export class TeamGraphAdapter { // ─── ES #private fields ────────────────────────────────────────────────── #lastTeamName = ''; - #lastDataHash = ''; - #cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult(''); readonly #seenRelated = new Set(); readonly #seenMessageIds = new Set(); #initialMessagesSeen = false; @@ -57,12 +63,12 @@ export class TeamGraphAdapter { /** * Adapt team data into a GraphDataPort snapshot. - * Returns cached result if inputs haven't changed (referential check). */ adapt( teamData: TeamData | null, teamName: string, spawnStatuses?: Record, + leadActivity?: LeadActivityState, leadContext?: LeadContextUsage, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -74,89 +80,6 @@ export class TeamGraphAdapter { return TeamGraphAdapter.#emptyResult(teamName); } - // Simple hash for change detection (avoids full deep equality) - const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0); - const memberKey = teamData.members - .map( - (member) => - `${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.providerId ?? ''}:${member.model ?? ''}:${member.effort ?? ''}:${member.removedAt ?? ''}` - ) - .sort() - .join('|'); - const taskKey = teamData.tasks - .map( - (task) => - `${task.id}:${task.status}:${task.owner ?? ''}:${task.reviewState ?? ''}:${task.displayId ?? ''}:${task.subject}:${task.updatedAt ?? ''}` - ) - .sort() - .join('|'); - const processKey = teamData.processes - .map( - (proc) => - `${proc.id}:${proc.label}:${proc.registeredBy ?? ''}:${proc.url ?? ''}:${proc.stoppedAt ?? ''}` - ) - .sort() - .join('|'); - const messageKey = teamData.messages - .slice(0, 25) - .map((msg) => TeamGraphAdapter.#getMessageParticleKey(msg)) - .join('|'); - const commentKey = teamData.tasks - .map((task) => { - const comments = task.comments ?? []; - const tail = comments - .slice(Math.max(0, comments.length - 5)) - .map((comment) => `${comment.id}:${comment.author}:${comment.createdAt}`) - .join(','); - return `${task.id}:${comments.length}:${tail}`; - }) - .sort() - .join('|'); - const approvalKey = pendingApprovalAgents?.size - ? Array.from(pendingApprovalAgents).sort().join(',') - : ''; - const activeToolKey = activeTools - ? Object.entries(activeTools) - .flatMap(([memberName, tools]) => - Object.values(tools).map( - (tool) => - `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` - ) - ) - .sort() - .join('|') - : ''; - const finishedVisibleKey = finishedVisible - ? Object.entries(finishedVisible) - .flatMap(([memberName, tools]) => - Object.values(tools).map( - (tool) => - `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` - ) - ) - .sort() - .join('|') - : ''; - const historyKey = toolHistory - ? Object.entries(toolHistory) - .map( - ([memberName, tools]) => - `${memberName}:${tools - .slice(0, 3) - .map( - (tool) => - `${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` - ) - .join(',')}` - ) - .sort() - .join('|') - : ''; - const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}:${commentReadState ? Object.keys(commentReadState).length : 0}`; - if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { - return this.#cachedResult; - } - // Reset particle tracking when team changes if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); @@ -166,7 +89,6 @@ export class TeamGraphAdapter { } this.#lastTeamName = teamName; - this.#lastDataHash = hash; this.#seenRelated.clear(); const nodes: GraphNode[] = []; @@ -182,6 +104,8 @@ export class TeamGraphAdapter { teamData, teamName, leadName, + pendingApprovalAgents, + leadActivity, leadContext, activeTools, finishedVisible, @@ -212,7 +136,7 @@ export class TeamGraphAdapter { ); this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); - this.#cachedResult = { + return { nodes, edges, particles, @@ -220,20 +144,17 @@ export class TeamGraphAdapter { teamColor: teamData.config.color ?? undefined, isAlive: teamData.isAlive, }; - - return this.#cachedResult; } // ─── Disposal ──────────────────────────────────────────────────────────── [Symbol.dispose](): void { - this.#cachedResult = TeamGraphAdapter.#emptyResult(''); this.#seenRelated.clear(); this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; - this.#lastDataHash = ''; + this.#lastTeamName = ''; } // ─── Private: node builders ────────────────────────────────────────────── @@ -269,6 +190,8 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, leadName: string, + pendingApprovalAgents?: Set, + leadActivity?: LeadActivityState, leadContext?: LeadContextUsage, activeTools?: Record>, finishedVisible?: Record>, @@ -280,15 +203,28 @@ export class TeamGraphAdapter { activeTools?.[leadName], finishedVisible?.[leadName] ); + const hasRunningTool = Object.keys(activeTools?.[leadName] ?? {}).length > 0; + const pendingApproval = + pendingApprovalAgents?.has(leadName) || pendingApprovalAgents?.has('lead') || false; + const leadState = + leadActivity === 'offline' + ? 'terminated' + : leadActivity === 'idle' + ? 'idle' + : hasRunningTool + ? 'tool_calling' + : 'active'; + const leadException = + leadActivity === 'offline' + ? { exceptionTone: 'error' as const, exceptionLabel: 'offline' } + : pendingApproval + ? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' } + : undefined; nodes.push({ id: leadId, kind: 'lead', label: data.config.name || teamName, - state: !data.isAlive - ? 'idle' - : Object.keys(activeTools?.[leadName] ?? {}).length > 0 - ? 'tool_calling' - : 'active', + state: leadState, color: data.config.color ?? undefined, runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( leadMember?.providerId, @@ -297,6 +233,7 @@ export class TeamGraphAdapter { ), contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, avatarUrl: agentAvatarUrl(leadName, 64), + pendingApproval, activeTool: activeTool ? { name: activeTool.toolName, @@ -320,6 +257,7 @@ export class TeamGraphAdapter { resultPreview: tool.resultPreview, source: tool.source, })), + ...leadException, domainRef: { kind: 'lead', teamName, memberName: leadName }, }); } @@ -347,6 +285,12 @@ export class TeamGraphAdapter { finishedVisible?.[member.name] ); const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0; + const exception = TeamGraphAdapter.#buildMemberException( + member.runtimeAdvisory, + member.providerId, + spawn, + pendingApprovalAgents?.has(member.name) ?? false + ); nodes.push({ id: memberId, @@ -369,6 +313,8 @@ export class TeamGraphAdapter { ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject : undefined, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, + exceptionTone: exception?.exceptionTone, + exceptionLabel: exception?.exceptionLabel, activeTool: activeTool ? { name: activeTool.toolName, @@ -411,25 +357,33 @@ export class TeamGraphAdapter { teamName: string, commentReadState?: Record ): void { - // Build lookup tables for fast resolution - const completedTaskIds = new Set(); + const taskStateById = new Map>(); const taskDisplayIds = new Map(); + const memberColorByName = new Map(); + for (const t of data.tasks) { - if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id); + taskStateById.set(t.id, { status: t.status }); taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); } + for (const member of data.members) { + if (member.color) { + memberColorByName.set(member.name, member.color); + } + } + + const rawTaskNodes: GraphNode[] = []; for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + const kanbanTaskState = data.kanbanState.tasks[task.id]; + const reviewerName = resolveTaskReviewer(task, kanbanTaskState); + const isReviewCycle = isTaskInReviewCycle(task); - // Task is blocked if any blockedBy task is still not completed - const isBlocked = - (task.blockedBy?.length ?? 0) > 0 && - task.blockedBy!.some((id) => !completedTaskIds.has(id)); + const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status); + const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState); - // Resolve display IDs for dependencies const blockedByDisplayIds = task.blockedBy?.length ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) : undefined; @@ -437,7 +391,6 @@ export class TeamGraphAdapter { ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) : undefined; - // Comment counts const totalCommentCount = task.comments?.length ?? 0; const unreadCommentCount = commentReadState ? getUnreadCount( @@ -448,66 +401,88 @@ export class TeamGraphAdapter { ) : 0; - nodes.push({ + rawTaskNodes.push({ id: taskId, kind: 'task', label: task.displayId ?? `#${task.id.slice(0, 6)}`, sublabel: task.subject, state: TeamGraphAdapter.#mapTaskStatus(task.status), - taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status), - reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState), + taskStatus, + reviewState, + reviewerName: isReviewCycle ? reviewerName : null, + reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined, + reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined, + changePresence: task.changePresence, displayId: task.displayId ?? undefined, ownerId: ownerMemberId, needsClarification: task.needsClarification ?? null, - isBlocked, + isBlocked: isTaskBlocked(task, taskStateById), blockedByDisplayIds, blocksDisplayIds, totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined, unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined, domainRef: { kind: 'task', teamName, taskId: task.id }, }); + } - if (ownerMemberId) { - edges.push({ - id: `edge:own:${ownerMemberId}:${taskId}`, - source: ownerMemberId, - target: taskId, - type: 'ownership', - }); - } + const visibleTaskNodes = collapseOverflowStacks(rawTaskNodes, teamName, 6); + const visibleTaskIds = new Set( + visibleTaskNodes.flatMap((taskNode) => + taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] + ) + ); - const seenBlockEdges = new Set(); - for (const blockedById of task.blockedBy ?? []) { - const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`; - if (seenBlockEdges.has(edgeId)) continue; - seenBlockEdges.add(edgeId); + nodes.push(...visibleTaskNodes); + + for (const taskNode of visibleTaskNodes) { + if (!taskNode.ownerId) continue; + edges.push({ + id: `edge:own:${taskNode.ownerId}:${taskNode.id}`, + source: taskNode.ownerId, + target: taskNode.id, + type: 'ownership', + }); + } + + const seenBlockingEdges = new Set(); + for (const task of data.tasks) { + if (task.status === 'deleted' || !visibleTaskIds.has(task.id)) continue; + const taskNodeId = `task:${teamName}:${task.id}`; + + for (const blockerId of task.blockedBy ?? []) { + if (!visibleTaskIds.has(blockerId)) continue; + const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, blockerId, task.id); + if (seenBlockingEdges.has(edgeId)) continue; + seenBlockingEdges.add(edgeId); edges.push({ id: edgeId, - source: `task:${teamName}:${blockedById}`, - target: taskId, + source: `task:${teamName}:${blockerId}`, + target: taskNodeId, type: 'blocking', }); } - for (const blocksId of task.blocks ?? []) { - const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`; - if (seenBlockEdges.has(edgeId)) continue; - seenBlockEdges.add(edgeId); + for (const blockedId of task.blocks ?? []) { + if (!visibleTaskIds.has(blockedId)) continue; + const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, task.id, blockedId); + if (seenBlockingEdges.has(edgeId)) continue; + seenBlockingEdges.add(edgeId); edges.push({ id: edgeId, - source: taskId, - target: `task:${teamName}:${blocksId}`, + source: taskNodeId, + target: `task:${teamName}:${blockedId}`, type: 'blocking', }); } for (const relatedId of task.related ?? []) { + if (!visibleTaskIds.has(relatedId)) continue; const key = [task.id, relatedId].sort().join(':'); if (this.#seenRelated.has(key)) continue; this.#seenRelated.add(key); edges.push({ id: `edge:rel:${key}`, - source: taskId, + source: taskNodeId, target: `task:${teamName}:${relatedId}`, type: 'related', }); @@ -751,6 +726,35 @@ export class TeamGraphAdapter { // ─── Static mappers ────────────────────────────────────────────────────── + static #buildBlockingEdgeId(teamName: string, blockerId: string, blockedId: string): string { + return `edge:block:task:${teamName}:${blockerId}:task:${teamName}:${blockedId}`; + } + + static #buildMemberException( + runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'], + providerId: TeamData['members'][number]['providerId'], + spawn: MemberSpawnStatusEntry | undefined, + pendingApproval: boolean + ): Pick | undefined { + if (spawn?.launchState === 'failed_to_start' || spawn?.status === 'error') { + return { exceptionTone: 'error', exceptionLabel: 'spawn failed' }; + } + if (pendingApproval) { + return { exceptionTone: 'warning', exceptionLabel: 'awaiting approval' }; + } + if (spawn?.status === 'waiting' || spawn?.status === 'spawning') { + return { exceptionTone: 'warning', exceptionLabel: 'starting' }; + } + const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, providerId); + if (runtimeAdvisoryLabel) { + return { + exceptionTone: 'warning', + exceptionLabel: runtimeAdvisoryLabel, + }; + } + return undefined; + } + static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState { if (spawnStatus === 'spawning') return 'thinking'; if (spawnStatus === 'error') return 'error'; @@ -851,7 +855,7 @@ export class TeamGraphAdapter { ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') return leadId; - if (leadName && normalized === leadName.trim().toLowerCase()) return leadId; + if (normalized === leadName?.trim().toLowerCase()) return leadId; return `member:${teamName}:${name}`; } diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index 9c755038..0d250c6d 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -7,6 +7,7 @@ import { useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from './TeamGraphAdapter'; @@ -19,6 +20,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const { teamData, spawnStatuses, + leadActivity, leadContext, pendingApprovals, activeTools, @@ -26,8 +28,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, } = useStore( useShallow((s) => ({ - teamData: s.selectedTeamData, + teamData: selectTeamDataForName(s, teamName), spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, + leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, pendingApprovals: s.pendingApprovals, activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, @@ -39,10 +42,12 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const pendingApprovalAgents = useMemo(() => { const agents = new Set(); for (const a of pendingApprovals) { - if (a.source !== 'lead') agents.add(a.source); + if (a.teamName === teamName) { + agents.add(a.source); + } } return agents; - }, [pendingApprovals]); + }, [pendingApprovals, teamName]); const commentReadState = useSyncExternalStore(subscribe, getSnapshot); @@ -52,6 +57,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { teamData, teamName, spawnStatuses, + leadActivity, leadContext, pendingApprovalAgents, activeTools, @@ -63,6 +69,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { teamData, teamName, spawnStatuses, + leadActivity, leadContext, pendingApprovalAgents, activeTools, diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 9e31fdd2..e6450ea2 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -6,12 +6,16 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; +import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; -import type { GraphNode } from '@claude-teams/agent-graph'; - import { GraphTaskCard } from './GraphTaskCard'; +import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics'; + +import type { GraphNode } from '@claude-teams/agent-graph'; +import type { TeamTaskWithKanban } from '@shared/types'; // ─── Tool name/preview formatters ─────────────────────────────────────────── @@ -37,7 +41,7 @@ function formatToolPreview(preview: string | undefined): string | undefined { ); } catch { // Truncated JSON — extract first quoted value - const match = preview.match(/"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/); + const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview); if (match) return match[1]; } } @@ -100,6 +104,16 @@ export const GraphNodePopover = ({ } if (node.kind === 'task') { + if (node.isOverflowStack || node.domainRef.kind === 'task_overflow') { + return ( + + ); + } return ( At: {new Date(node.processRegisteredAt).toLocaleTimeString()}
)} + {node.exceptionLabel && ( + + {node.exceptionLabel} + + )} {node.processUrl && ( void; + onOpenTaskDetail?: (taskId: string) => void; +}): React.JSX.Element => { + const teamData = useStore((state) => selectTeamDataForName(state, teamName)); + const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task])); + const hiddenTasks = (node.overflowTaskIds ?? []) + .map((taskId) => tasksById.get(taskId) ?? null) + .filter((task): task is TeamTaskWithKanban => task != null); + + return ( +
+
+
Hidden tasks
+ + {node.overflowCount ?? hiddenTasks.length} + +
+
+ {hiddenTasks.length === 0 ? ( +
No hidden tasks available.
+ ) : ( + hiddenTasks.map((task) => { + const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]); + return ( + + ); + }) + )} +
+
+ ); +}; + // ─── Member Popover ───────────────────────────────────────────────────────── const MemberPopoverContent = ({ @@ -261,6 +355,18 @@ const MemberPopoverContent = ({ {getSpawnStatusBadgeLabel(node.spawnStatus)} )} + {node.exceptionLabel && ( + + {node.exceptionLabel} + + )} {/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */} diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx index e460f174..ba2c343e 100644 --- a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx +++ b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx @@ -7,8 +7,11 @@ import { useMemo } from 'react'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; +import { isTaskBlocked, resolveTaskGraphColumn } from '../utils/taskGraphSemantics'; + import type { GraphNode } from '@claude-teams/agent-graph'; import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; @@ -32,16 +35,12 @@ interface GraphTaskCardProps { // ─── Helpers ──────────────────────────────────────────────────────────────── function resolveColumn(task: TeamTask): KanbanColumnId { - if (task.reviewState === 'approved') return 'approved'; - if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; - if (task.status === 'in_progress') return 'in_progress'; - if (task.status === 'completed') return 'done'; - return 'todo'; + return resolveTaskGraphColumn(task); } -function getGlowStyle(task: TeamTask): React.CSSProperties { +function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap): React.CSSProperties { const col = resolveColumn(task); - const blocked = (task.blockedBy?.length ?? 0) > 0; + const blocked = isTaskBlocked(task, taskMap); if (blocked) { return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }; } @@ -87,9 +86,9 @@ export const GraphTaskCard = ({ const { task, tasks, members } = useStore( useShallow((s) => ({ - task: s.selectedTeamData?.tasks.find((t) => t.id === taskId), - tasks: s.selectedTeamData?.tasks ?? [], - members: s.selectedTeamData?.members ?? [], + tasks: selectTeamDataForName(s, teamName)?.tasks ?? [], + members: selectTeamDataForName(s, teamName)?.members ?? [], + task: selectTeamDataForName(s, teamName)?.tasks.find((t) => t.id === taskId), })) ); @@ -118,7 +117,7 @@ export const GraphTaskCard = ({ } const columnId = resolveColumn(task); - const taskWithKanban = task as TeamTaskWithKanban; + const taskWithKanban = task; const closeAct = (fn?: (id: string) => void) => (taskId: string) => { fn?.(taskId); @@ -128,7 +127,7 @@ export const GraphTaskCard = ({ return (
(); + const groupOrder: string[] = []; + + for (const task of taskNodes) { + const groupKey = `${task.ownerId ?? '__unassigned__'}:${resolveOverflowColumnKey(task)}`; + const current = grouped.get(groupKey); + if (current) { + current.push(task); + } else { + grouped.set(groupKey, [task]); + groupOrder.push(groupKey); + } + } + + const visibleTasks: GraphNode[] = []; + + for (const groupKey of groupOrder) { + const groupTasks = grouped.get(groupKey) ?? []; + if (groupTasks.length <= maxVisibleRows) { + visibleTasks.push(...groupTasks); + continue; + } + + const keptTasks = groupTasks.slice(0, maxVisibleRows - 1); + const hiddenTasks = groupTasks.slice(maxVisibleRows - 1); + const representative = hiddenTasks[0] ?? groupTasks[groupTasks.length - 1]; + const columnKey = resolveOverflowColumnKey(representative); + const ownerMemberName = extractOwnerMemberName(representative, teamName); + + visibleTasks.push(...keptTasks); + visibleTasks.push({ + id: `task:${teamName}:overflow:${groupKey}`, + kind: 'task', + label: `+${hiddenTasks.length}`, + state: 'waiting', + displayId: `+${hiddenTasks.length}`, + sublabel: `${hiddenTasks.length} more tasks`, + ownerId: representative.ownerId ?? null, + taskStatus: representative.taskStatus, + reviewState: representative.reviewState, + isOverflowStack: true, + overflowCount: hiddenTasks.length, + overflowTaskIds: hiddenTasks.flatMap((task) => + task.domainRef.kind === 'task' ? [task.domainRef.taskId] : [] + ), + domainRef: { + kind: 'task_overflow', + teamName, + ownerMemberName, + columnKey, + }, + }); + } + + return visibleTasks; +} diff --git a/src/renderer/features/agent-graph/utils/taskGraphSemantics.ts b/src/renderer/features/agent-graph/utils/taskGraphSemantics.ts new file mode 100644 index 00000000..38da36d1 --- /dev/null +++ b/src/renderer/features/agent-graph/utils/taskGraphSemantics.ts @@ -0,0 +1,48 @@ +import type { KanbanTaskState, KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; + +type TaskColumnInput = Pick; +type TaskReviewerInput = Pick; +type TaskBlockInput = Pick; +type TaskBlockState = Pick; + +export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId { + if (task.reviewState === 'approved') return 'approved'; + if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; + if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { + return task.kanbanColumn; + } + if (task.status === 'in_progress') return 'in_progress'; + if (task.status === 'completed') return 'done'; + return 'todo'; +} + +export function isTaskInReviewCycle(task: TaskColumnInput): boolean { + return ( + task.reviewState === 'review' || + task.reviewState === 'needsFix' || + task.kanbanColumn === 'review' + ); +} + +export function resolveTaskReviewer( + task: TaskReviewerInput, + kanbanTaskState?: Pick +): string | null { + const reviewer = task.reviewer?.trim() || kanbanTaskState?.reviewer?.trim() || ''; + return reviewer.length > 0 ? reviewer : null; +} + +export function isTaskBlocked( + task: TaskBlockInput, + taskStateById: ReadonlyMap +): boolean { + const blockedBy = task.blockedBy?.filter((taskId) => taskId.length > 0) ?? []; + if (blockedBy.length === 0) { + return false; + } + + return blockedBy.some((taskId) => { + const blocker = taskStateById.get(taskId); + return !blocker || (blocker.status !== 'completed' && blocker.status !== 'deleted'); + }); +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 5df9f33e..0c41978d 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -37,6 +37,7 @@ import { createTeamSlice, getLastResolvedTeamDataRefreshAt, isTeamDataRefreshPending, + selectTeamDataForName, } from './slices/teamSlice'; import { createUISlice } from './slices/uiSlice'; import { createUpdateSlice } from './slices/updateSlice'; @@ -397,13 +398,8 @@ export function initializeNotificationListeners(): () => void { } const state = useStore.getState(); - const selectedTeamName = state.selectedTeamName; - const selectedTeamData = state.selectedTeamData; - if ( - !selectedTeamName || - selectedTeamData?.teamName !== selectedTeamName || - !isTeamVisibleInAnyPane(selectedTeamName) - ) { + const visibleTeamNames = Array.from(getVisibleTeamNamesInAnyPane(state)); + if (visibleTeamNames.length === 0) { return; } @@ -417,44 +413,58 @@ export function initializeNotificationListeners(): () => void { } } - const candidateTasks = selectedTeamData.tasks.filter((task) => { - if (task.status !== 'in_progress') { - return false; - } - return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task)); - }); - if (candidateTasks.length === 0) { - inProgressChangePresenceCursorByTeam.delete(selectedTeamName); - return; - } - inProgressChangePresencePollInFlight = true; try { - const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0; - const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); - const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; - const nextTask = sourceTasks[cursor % sourceTasks.length]; + for (const teamName of visibleTeamNames) { + const teamData = selectTeamDataForName(state, teamName); + if (teamData?.teamName !== teamName) { + if (!isTeamDataRefreshPending(teamName)) { + void state.refreshTeamData(teamName, { withDedup: true }); + } + continue; + } - inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length); + const candidateTasks = teamData.tasks.filter((task) => { + if (task.status !== 'in_progress') { + return false; + } + return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task)); + }); + if (candidateTasks.length === 0) { + inProgressChangePresenceCursorByTeam.delete(teamName); + continue; + } - const current = useStore.getState(); - if ( - current.selectedTeamName !== selectedTeamName || - current.selectedTeamData?.teamName !== selectedTeamName || - !isTeamVisibleInAnyPane(selectedTeamName) - ) { - return; + const cursor = inProgressChangePresenceCursorByTeam.get(teamName) ?? 0; + const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); + const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; + const nextTask = sourceTasks[cursor % sourceTasks.length]; + + inProgressChangePresenceCursorByTeam.set(teamName, (cursor + 1) % sourceTasks.length); + + const current = useStore.getState(); + if (!isTeamVisibleInAnyPane(teamName)) { + continue; + } + + const currentTeamData = selectTeamDataForName(current, teamName); + if (currentTeamData?.teamName !== teamName) { + if (!isTeamDataRefreshPending(teamName)) { + void current.refreshTeamData(teamName, { withDedup: true }); + } + continue; + } + + const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id); + if (currentTask?.status !== 'in_progress') { + continue; + } + + const requestOptions = buildTaskChangeRequestOptions(currentTask); + const cacheKey = buildTaskChangePresenceKey(teamName, currentTask.id, requestOptions); + current.invalidateTaskChangePresence([cacheKey]); + await current.checkTaskHasChanges(teamName, currentTask.id, requestOptions); } - - const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id); - if (currentTask?.status !== 'in_progress') { - return; - } - - const requestOptions = buildTaskChangeRequestOptions(currentTask); - const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions); - current.invalidateTaskChangePresence([cacheKey]); - await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions); } catch { // Best-effort polling for in-progress tasks only. } finally { @@ -557,41 +567,41 @@ export function initializeNotificationListeners(): () => void { ); }; - const isTeamVisibleInAnyPane = (teamName: string): boolean => { - const { paneLayout } = useStore.getState(); - return paneLayout.panes.some((pane) => { - if (!pane.activeTabId) return false; - return pane.tabs.some( - (tab) => tab.id === pane.activeTabId && tab.type === 'team' && tab.teamName === teamName - ); - }); - }; - - const getTrackedChangePresenceTeams = (): Set => { - const { selectedTeamName, selectedTeamData } = useStore.getState(); - if ( - !selectedTeamName || - selectedTeamData?.teamName !== selectedTeamName || - !isTeamVisibleInAnyPane(selectedTeamName) - ) { - return new Set(); - } - return new Set([selectedTeamName]); - }; - - const getTrackedToolActivityTeams = (): Set => { - const { paneLayout } = useStore.getState(); - const tracked = new Set(); + const getVisibleTeamNamesInAnyPane = (state = useStore.getState()): Set => { + const { paneLayout } = state; + const visibleTeamNames = new Set(); for (const pane of paneLayout.panes) { if (!pane.activeTabId) continue; const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId); - if (activeTab?.type === 'team' && activeTab.teamName) { - tracked.add(activeTab.teamName); + if ( + (activeTab?.type === 'team' || activeTab?.type === 'graph') && + activeTab.teamName != null + ) { + visibleTeamNames.add(activeTab.teamName); + } + } + return visibleTeamNames; + }; + + const isTeamVisibleInAnyPane = (teamName: string): boolean => { + return getVisibleTeamNamesInAnyPane().has(teamName); + }; + + const getTrackedChangePresenceTeams = (): Set => { + const state = useStore.getState(); + const tracked = new Set(); + for (const teamName of getVisibleTeamNamesInAnyPane(state)) { + if (selectTeamDataForName(state, teamName)) { + tracked.add(teamName); } } return tracked; }; + const getTrackedToolActivityTeams = (): Set => { + return getVisibleTeamNamesInAnyPane(); + }; + const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => { teamLastRelevantActivityAt.set(teamName, timestamp); }; @@ -606,15 +616,11 @@ export function initializeNotificationListeners(): () => void { } const activeTab = focusedPane.tabs.find((tab) => tab.id === focusedPane.activeTabId); - if (activeTab?.type !== 'team' || !activeTab.teamName) { + if ((activeTab?.type !== 'team' && activeTab?.type !== 'graph') || !activeTab.teamName) { return null; } - if (state.selectedTeamName !== activeTab.teamName) { - return null; - } - - if (state.selectedTeamData?.teamName !== activeTab.teamName) { + if (!selectTeamDataForName(state, activeTab.teamName)) { return null; } @@ -632,7 +638,7 @@ export function initializeNotificationListeners(): () => void { return; } - if (current.selectedTeamLoading) { + if (current.selectedTeamName === teamName && current.selectedTeamLoading) { return; } @@ -695,7 +701,8 @@ export function initializeNotificationListeners(): () => void { if ( state.paneLayout === prevState.paneLayout && state.selectedTeamName === prevState.selectedTeamName && - state.selectedTeamData === prevState.selectedTeamData + state.selectedTeamData === prevState.selectedTeamData && + state.teamDataCacheByName === prevState.teamDataCacheByName ) { return; } @@ -917,6 +924,17 @@ export function initializeNotificationListeners(): () => void { }, }; + const cachedTeamData = prev.teamDataCacheByName[event.teamName]; + if (cachedTeamData) { + nextState.teamDataCacheByName = { + ...prev.teamDataCacheByName, + [event.teamName]: { + ...cachedTeamData, + isAlive: nextActivity !== 'offline', + }, + }; + } + // Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive, // which isn't refreshed for lead-activity events. if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) { @@ -1140,7 +1158,7 @@ export function initializeNotificationListeners(): () => void { const timer = setTimeout(() => { teamPresenceRefreshTimers.delete(event.teamName); const current = useStore.getState(); - void current.refreshSelectedTeamChangePresence(event.teamName); + void current.refreshTeamChangePresence(event.teamName); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); teamPresenceRefreshTimers.set(event.teamName, timer); return; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 6455953f..742e6084 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -40,9 +40,9 @@ const teamRefreshBurstDiagnostics = new Map< { windowStartedAt: number; count: number; lastWarnAt: number } >(); const memberSpawnUiEqualLastWarnAtByTeam = new Map(); -type RefreshTeamDataOptions = { +interface RefreshTeamDataOptions { withDedup?: boolean; -}; +} export function isTeamDataRefreshPending(teamName: string): boolean { return ( @@ -56,6 +56,16 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und return lastResolvedTeamDataRefreshAtByTeam.get(teamName); } +export function __resetTeamSliceModuleStateForTests(): void { + inFlightTeamDataRequests.clear(); + inFlightRefreshTeamDataCalls.clear(); + pendingFreshTeamDataRefreshes.clear(); + lastResolvedTeamDataRefreshAtByTeam.clear(); + memberSpawnStatusesIpcBackoffUntilByTeam.clear(); + teamRefreshBurstDiagnostics.clear(); + memberSpawnUiEqualLastWarnAtByTeam.clear(); +} + function nowIso(): string { return new Date().toISOString(); } @@ -487,9 +497,9 @@ import type { KanbanColumnId, LeadActivityState, LeadContextUsage, - PersistedTeamLaunchSummary, - MemberSpawnStatusesSnapshot, MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, SendMessageRequest, SendMessageResult, TaskChangePresenceState, @@ -852,11 +862,7 @@ function preserveKnownTaskChangePresence( } const previousTask = prevTaskById.get(task.id); - if ( - !previousTask || - !previousTask.changePresence || - previousTask.changePresence === 'unknown' - ) { + if (!previousTask?.changePresence || previousTask.changePresence === 'unknown') { return task; } @@ -915,6 +921,46 @@ export interface TeamLaunchParams { limitContext?: boolean; } +export function selectTeamDataForName( + state: Pick, + teamName: string | null | undefined +): TeamData | null { + if (!teamName) { + return null; + } + return ( + state.teamDataCacheByName[teamName] ?? + (state.selectedTeamName === teamName ? state.selectedTeamData : null) + ); +} + +function isVisibleInActiveTeamSurface( + state: Pick, + teamName: string | null | undefined +): boolean { + if (!teamName) { + return false; + } + return state.paneLayout.panes.some((pane) => { + if (!pane.activeTabId) { + return false; + } + const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId); + return ( + (activeTab?.type === 'team' || activeTab?.type === 'graph') && activeTab.teamName === teamName + ); + }); +} + +function shouldInvalidateCachedTeamDataForError(teamName: string, message: string): boolean { + return ( + message === 'TEAM_DRAFT' || + message.includes('TEAM_DRAFT') || + message === `Team not found: ${teamName}` || + message === 'Team config not found' + ); +} + export interface TeamSlice { teams: TeamSummary[]; /** O(1) lookup to avoid array scans in render-hot paths */ @@ -947,6 +993,8 @@ export interface TeamSlice { ) => void; selectedTeamName: string | null; selectedTeamData: TeamData | null; + /** Team-scoped detailed cache used by multi-pane views like agent graph. */ + teamDataCacheByName: Record; selectedTeamLoading: boolean; selectedTeamLoadNonce: number; selectedTeamError: string | null; @@ -994,7 +1042,7 @@ export interface TeamSlice { taskId: string, presence: TaskChangePresenceState ) => void; - refreshSelectedTeamChangePresence: (teamName: string) => Promise; + refreshTeamChangePresence: (teamName: string) => Promise; selectTeam: ( teamName: string, opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } @@ -1239,6 +1287,7 @@ export const createTeamSlice: StateCreator = (set, globalTasksError: null, selectedTeamName: null, selectedTeamData: null, + teamDataCacheByName: {}, selectedTeamLoading: false, selectedTeamLoadNonce: 0, selectedTeamError: null, @@ -1660,20 +1709,20 @@ export const createTeamSlice: StateCreator = (set, setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => { set((state) => { - let selectedChanged = false; - const nextSelectedTeamData = - state.selectedTeamName === teamName && state.selectedTeamData - ? { - ...state.selectedTeamData, - tasks: state.selectedTeamData.tasks.map((task) => { - if (task.id !== taskId || task.changePresence === presence) { - return task; - } - selectedChanged = true; - return { ...task, changePresence: presence }; - }), - } - : state.selectedTeamData; + const currentTeamData = selectTeamDataForName(state, teamName); + let cacheChanged = false; + const nextTeamData = currentTeamData + ? { + ...currentTeamData, + tasks: currentTeamData.tasks.map((task) => { + if (task.id !== taskId || task.changePresence === presence) { + return task; + } + cacheChanged = true; + return { ...task, changePresence: presence }; + }), + } + : null; let globalChanged = false; const nextGlobalTasks = state.globalTasks.map((task) => { @@ -1684,20 +1733,30 @@ export const createTeamSlice: StateCreator = (set, return { ...task, changePresence: presence }; }); - if (!selectedChanged && !globalChanged) { + if (!cacheChanged && !globalChanged) { return {}; } return { - ...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}), + ...(cacheChanged && nextTeamData + ? { + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }, + } + : {}), + ...(cacheChanged && state.selectedTeamName === teamName && nextTeamData + ? { selectedTeamData: nextTeamData } + : {}), ...(globalChanged ? { globalTasks: nextGlobalTasks } : {}), }; }); }, - refreshSelectedTeamChangePresence: async (teamName: string) => { - const selected = get().selectedTeamData; - if (get().selectedTeamName !== teamName || !selected) { + refreshTeamChangePresence: async (teamName: string) => { + const currentTeamData = selectTeamDataForName(get(), teamName); + if (!currentTeamData) { return; } @@ -1706,17 +1765,14 @@ export const createTeamSlice: StateCreator = (set, api.teams.getTaskChangePresence(teamName) ); - if (get().selectedTeamName !== teamName || !get().selectedTeamData) { - return; - } - set((state) => { - if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + const teamData = selectTeamDataForName(state, teamName); + if (!teamData) { return {}; } let changed = false; - const nextTasks = state.selectedTeamData.tasks.map((task) => { + const nextTasks = teamData.tasks.map((task) => { const nextPresence = presenceByTaskId[task.id] ?? 'unknown'; if (task.changePresence === nextPresence) { return task; @@ -1729,11 +1785,17 @@ export const createTeamSlice: StateCreator = (set, return {}; } + const nextTeamData = { + ...teamData, + tasks: nextTasks, + }; + return { - selectedTeamData: { - ...state.selectedTeamData, - tasks: nextTasks, + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, }, + ...(state.selectedTeamName === teamName ? { selectedTeamData: nextTeamData } : {}), }; }); } catch { @@ -1754,8 +1816,7 @@ export const createTeamSlice: StateCreator = (set, return; } const requestNonce = get().selectedTeamLoadNonce + 1; - const previousSelectedTeamName = get().selectedTeamName; - const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null; + const previousData = selectTeamDataForName(get(), teamName); // Stale-while-revalidate: keep previous data visible while loading new team. // Skeleton only shows on first load (when data is null). @@ -1797,18 +1858,23 @@ export const createTeamSlice: StateCreator = (set, set({ teamByName: { ...prevByName, [teamName]: patched } }); } + const nextTeamData = previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data; const setStartedAt = performance.now(); - set({ + set((state) => ({ selectedTeamName: teamName, - selectedTeamData: previousData - ? { - ...data, - tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), - } - : data, + selectedTeamData: nextTeamData, + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }, selectedTeamLoading: false, selectedTeamError: null, - }); + })); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -1925,10 +1991,6 @@ export const createTeamSlice: StateCreator = (set, refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { const startedAt = performance.now(); - const state = get(); - if (state.selectedTeamName !== teamName) { - return; - } inFlightRefreshTeamDataCalls.add(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). @@ -1942,25 +2004,30 @@ export const createTeamSlice: StateCreator = (set, ); } try { - const previousData = get().selectedTeamData; + const previousData = selectTeamDataForName(get(), teamName); const data = opts?.withDedup ? await fetchTeamDataDeduped(teamName) : await fetchTeamDataFresh(teamName); const ipcMs = performance.now() - startedAt; - // Re-check after async: the user might have navigated away. - if (get().selectedTeamName !== teamName) { - return; - } + const nextTeamData = previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data; const setStartedAt = performance.now(); - set({ - selectedTeamData: previousData + set((state) => ({ + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }, + ...(state.selectedTeamName === teamName ? { - ...data, - tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + selectedTeamData: nextTeamData, + selectedTeamError: null, } - : data, - selectedTeamError: null, - }); + : {}), + })); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -1988,9 +2055,6 @@ export const createTeamSlice: StateCreator = (set, burstCount, }); } catch (error) { - if (get().selectedTeamName !== teamName) { - return; - } const msg = error instanceof IpcError ? error.message @@ -2002,19 +2066,42 @@ export const createTeamSlice: StateCreator = (set, // Preserve existing data instead of showing a fatal error. if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) { logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`); - set({ selectedTeamError: null }); + if (get().selectedTeamName === teamName) { + set({ selectedTeamError: null }); + } return; } - if (msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT')) { - set({ - selectedTeamLoading: false, - selectedTeamData: null, - selectedTeamError: 'TEAM_DRAFT', + if (shouldInvalidateCachedTeamDataForError(teamName, msg)) { + set((state) => { + const nextCache = state.teamDataCacheByName[teamName] + ? { ...state.teamDataCacheByName } + : null; + if (nextCache) { + delete nextCache[teamName]; + } + if (state.selectedTeamName !== teamName && !nextCache) { + return {}; + } + return { + ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + ...(state.selectedTeamName === teamName + ? { + selectedTeamLoading: false, + selectedTeamData: null, + selectedTeamError: + msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT') ? 'TEAM_DRAFT' : msg, + } + : {}), + }; }); return; } + if (get().selectedTeamName !== teamName) { + return; + } + logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`); // Non-destructive: if we already have data, keep it visible. @@ -2089,10 +2176,22 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: false, sendMessageError: null, lastSendMessageResult: result, - selectedTeamData: - state.selectedTeamName === teamName && state.selectedTeamData - ? upsertLocalSentMessage(state.selectedTeamData, optimisticMessage) - : state.selectedTeamData, + ...(selectTeamDataForName(state, teamName) + ? { + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: upsertLocalSentMessage( + selectTeamDataForName(state, teamName)!, + optimisticMessage + ), + }, + } + : {}), + ...(state.selectedTeamName === teamName && state.selectedTeamData + ? { + selectedTeamData: upsertLocalSentMessage(state.selectedTeamData, optimisticMessage), + } + : {}), })); await get().refreshTeamData(teamName); } catch (error) { @@ -2303,12 +2402,40 @@ export const createTeamSlice: StateCreator = (set, deleteTeam: async (teamName: string) => { await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); + set((state) => { + const nextCache = state.teamDataCacheByName[teamName] + ? { ...state.teamDataCacheByName } + : null; + if (nextCache) { + delete nextCache[teamName]; + } + if (state.selectedTeamName === teamName) { + return { + selectedTeamName: null, + selectedTeamData: null, + selectedTeamLoading: false, + selectedTeamError: null, + ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + }; + } + return nextCache ? { teamDataCacheByName: nextCache } : {}; + }); await get().fetchTeams(); await get().fetchAllTasks(); }, restoreTeam: async (teamName: string) => { await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); + set((state) => { + if (!state.teamDataCacheByName[teamName]) { + return {}; + } + const nextCache = { ...state.teamDataCacheByName }; + delete nextCache[teamName]; + return { + teamDataCacheByName: nextCache, + }; + }); await get().fetchTeams(); await get().fetchAllTasks(); }, @@ -2316,8 +2443,19 @@ export const createTeamSlice: StateCreator = (set, permanentlyDeleteTeam: async (teamName: string) => { await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); const state = get(); + const nextCache = { ...state.teamDataCacheByName }; + delete nextCache[teamName]; if (state.selectedTeamName === teamName) { - set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null }); + set({ + selectedTeamName: null, + selectedTeamData: null, + selectedTeamError: null, + teamDataCacheByName: nextCache, + }); + } else if (state.teamDataCacheByName[teamName]) { + set({ + teamDataCacheByName: nextCache, + }); } await get().fetchTeams(); await get().fetchAllTasks(); @@ -2872,11 +3010,17 @@ export const createTeamSlice: StateCreator = (set, const isCanonicalRun = get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId; + let hydratedVisibleTeam = false; if (isCanonicalRun && becameConfigReady) { const state = get(); - if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) { - void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true }); + if (isVisibleInActiveTeamSurface(state, progress.teamName)) { + if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) { + void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true }); + } else { + void state.refreshTeamData(progress.teamName, { withDedup: true }); + } + hydratedVisibleTeam = true; } } @@ -2916,10 +3060,21 @@ export const createTeamSlice: StateCreator = (set, if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) { void get().fetchTeams(); + if (hydratedVisibleTeam) { + return; + } + + const state = get(); + if (!isVisibleInActiveTeamSurface(state, progress.teamName)) { + return; + } + // If the user already opened the team tab, reload team data now that // config.json is guaranteed to exist. - if (get().selectedTeamName === progress.teamName) { - void get().selectTeam(progress.teamName); + if (state.selectedTeamName === progress.teamName) { + void state.selectTeam(progress.teamName); + } else { + void state.refreshTeamData(progress.teamName, { withDedup: true }); } } }, diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 344b66cf..2d348feb 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -347,7 +347,7 @@ export function getMemberRuntimeAdvisoryLabel( providerId?: TeamProviderId, nowMs = Date.now() ): string | null { - if (!advisory || advisory.kind !== 'sdk_retrying') { + if (advisory?.kind !== 'sdk_retrying') { return null; } const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId); @@ -366,7 +366,7 @@ export function getMemberRuntimeAdvisoryTitle( advisory: MemberRuntimeAdvisory | undefined, providerId?: TeamProviderId ): string | undefined { - if (!advisory || advisory.kind !== 'sdk_retrying') { + if (advisory?.kind !== 'sdk_retrying') { return undefined; } return formatRuntimeAdvisoryTitle(advisory, providerId); diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index 64960d9c..1df2ae6f 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -1,9 +1,9 @@ +import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince'; import { getTaskChangeStateBucket, isTaskChangeSummaryCacheable, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; -import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince'; import type { ReviewAPI } from '@shared/types/api'; import type { TeamTaskWithKanban } from '@shared/types/team'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 5d37342a..65b50340 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1,3 +1,5 @@ +import type { EnhancedChunk } from '@main/types'; + export interface TeamMember { name: string; agentId?: string; @@ -152,6 +154,169 @@ export interface TaskRef { teamName: string; } +export type BoardTaskRefKind = 'canonical' | 'display' | 'unknown'; +export type BoardTaskResolution = 'resolved' | 'deleted' | 'unresolved' | 'ambiguous'; +export type BoardTaskActivityLinkKind = 'execution' | 'lifecycle' | 'board_action'; +export type BoardTaskActivityTargetRole = 'subject' | 'related'; +export type BoardTaskActivityPhase = 'work' | 'review'; +export type BoardTaskActorRelation = 'same_task' | 'other_active_task' | 'idle' | 'ambiguous'; +export type BoardTaskActivityStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; +export type BoardTaskActivityRelationship = 'blocked-by' | 'blocks' | 'related'; +export type BoardTaskActivityCategory = + | 'status' + | 'review' + | 'comment' + | 'assignment' + | 'read' + | 'attachment' + | 'relationship' + | 'clarification' + | 'other'; +export type BoardTaskRelationshipPerspective = 'outgoing' | 'incoming' | 'symmetric'; + +export interface BoardTaskLocator { + ref: string; + refKind: BoardTaskRefKind; + canonicalId?: string; +} + +export interface BoardTaskActivityTaskRef { + locator: BoardTaskLocator; + resolution: BoardTaskResolution; + taskRef?: TaskRef; +} + +export interface BoardTaskActivityActor { + memberName?: string; + role: 'member' | 'lead' | 'unknown'; + sessionId: string; + agentId?: string; + isSidechain: boolean; +} + +export interface BoardTaskActivityAction { + canonicalToolName?: string; + toolUseId?: string; + category: BoardTaskActivityCategory; + peerTask?: BoardTaskActivityTaskRef; + relationshipPerspective?: BoardTaskRelationshipPerspective; + details?: { + status?: BoardTaskActivityStatus; + owner?: string | null; + clarification?: 'lead' | 'user' | null; + reviewer?: string; + relationship?: BoardTaskActivityRelationship; + commentId?: string; + attachmentId?: string; + filename?: string; + }; +} + +export interface BoardTaskActivityActorContext { + relation: BoardTaskActorRelation; + activeTask?: BoardTaskActivityTaskRef; + activePhase?: BoardTaskActivityPhase; + activeExecutionSeq?: number; +} + +export interface BoardTaskActivityEntry { + id: string; + timestamp: string; + task: BoardTaskActivityTaskRef; + linkKind: BoardTaskActivityLinkKind; + targetRole: BoardTaskActivityTargetRole; + actor: BoardTaskActivityActor; + actorContext: BoardTaskActivityActorContext; + action?: BoardTaskActivityAction; + source: { + messageUuid: string; + filePath: string; + toolUseId?: string; + sourceOrder: number; + }; +} + +export interface BoardTaskExactLogActor { + memberName?: string; + role: 'member' | 'lead' | 'unknown'; + sessionId: string; + agentId?: string; + isSidechain: boolean; +} + +export interface BoardTaskExactLogSource { + filePath: string; + messageUuid: string; + toolUseId?: string; + sourceOrder: number; +} + +interface BoardTaskExactLogSummaryBase { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + anchorKind: 'tool' | 'message'; + actionLabel: string; + actionCategory?: BoardTaskActivityCategory; + canonicalToolName?: string; + linkKinds: BoardTaskActivityLinkKind[]; +} + +export type BoardTaskExactLogSummary = + | (BoardTaskExactLogSummaryBase & { + canLoadDetail: true; + sourceGeneration: string; + }) + | (BoardTaskExactLogSummaryBase & { + canLoadDetail: false; + }); + +export interface BoardTaskExactLogDetail { + id: string; + chunks: EnhancedChunk[]; +} + +export interface BoardTaskExactLogSummariesResponse { + items: BoardTaskExactLogSummary[]; +} + +export type BoardTaskExactLogDetailResult = + | { status: 'ok'; detail: BoardTaskExactLogDetail } + | { status: 'stale' } + | { status: 'missing' }; + +export interface BoardTaskLogActor { + memberName?: string; + role: 'member' | 'lead' | 'unknown'; + sessionId: string; + agentId?: string; + isSidechain: boolean; +} + +export interface BoardTaskLogParticipant { + key: string; + label: string; + role: 'member' | 'lead' | 'unknown'; + isLead: boolean; + isSidechain: boolean; +} + +export interface BoardTaskLogSegment { + id: string; + participantKey: string; + actor: BoardTaskLogActor; + startTimestamp: string; + endTimestamp: string; + chunks: EnhancedChunk[]; +} + +export interface BoardTaskLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: 'all' | string; + segments: BoardTaskLogSegment[]; +} + export interface TaskComment { id: string; author: string; diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index ce765451..384d24ed 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -1,6 +1,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useStore } from '@renderer/store'; vi.mock('@renderer/components/ui/badge', () => ({ Badge: ({ children }: { children: React.ReactNode }) => @@ -38,9 +39,34 @@ function makeMemberNode(spawnStatus: GraphNode['spawnStatus']): GraphNode { } as GraphNode; } +function makeOverflowNode(): GraphNode { + return { + id: 'task:northstar-core:overflow:alice:review', + kind: 'task', + label: '+2', + state: 'waiting', + taskStatus: 'in_progress', + reviewState: 'review', + isOverflowStack: true, + overflowCount: 2, + overflowTaskIds: ['task-1', 'task-2'], + domainRef: { + kind: 'task_overflow', + teamName: 'northstar-core', + ownerMemberName: 'alice', + columnKey: 'review', + }, + }; +} + describe('GraphNodePopover spawn badge labels', () => { afterEach(() => { document.body.innerHTML = ''; + useStore.setState({ + selectedTeamName: null, + selectedTeamData: null, + teamDataCacheByName: {}, + } as never); vi.unstubAllGlobals(); }); @@ -80,4 +106,156 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); }); + + it('shows compact exception badge for member abnormal states', 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(GraphNodePopover, { + node: { + ...makeMemberNode('error'), + exceptionTone: 'error', + exceptionLabel: 'spawn failed', + }, + teamName: 'northstar-core', + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('spawn failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('renders overflow stack contents instead of the task card and opens task detail from the list', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useStore.setState({ + selectedTeamName: 'northstar-core', + selectedTeamData: { + teamName: 'northstar-core', + config: { name: 'Northstar', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-1', + displayId: '#1', + subject: 'Tighten rollout checklist', + owner: 'alice', + reviewer: 'bob', + status: 'in_progress', + reviewState: 'review', + kanbanColumn: 'review', + }, + { + id: 'task-2', + displayId: '#2', + subject: 'Patch release notes', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + ], + members: [], + messages: [], + kanbanState: { + teamName: 'northstar-core', + reviewers: [], + tasks: { + 'task-1': { + column: 'review', + reviewer: 'bob', + movedAt: '2026-04-12T18:00:00.000Z', + }, + }, + }, + processes: [], + }, + teamDataCacheByName: { + 'northstar-core': { + teamName: 'northstar-core', + config: { name: 'Northstar', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-1', + displayId: '#1', + subject: 'Tighten rollout checklist', + owner: 'alice', + reviewer: 'bob', + status: 'in_progress', + reviewState: 'review', + kanbanColumn: 'review', + }, + { + id: 'task-2', + displayId: '#2', + subject: 'Patch release notes', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + ], + members: [], + messages: [], + kanbanState: { + teamName: 'northstar-core', + reviewers: [], + tasks: { + 'task-1': { + column: 'review', + reviewer: 'bob', + movedAt: '2026-04-12T18:00:00.000Z', + }, + }, + }, + processes: [], + }, + }, + } as never); + + const onOpenTaskDetail = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(GraphNodePopover, { + node: makeOverflowNode(), + teamName: 'northstar-core', + onClose: vi.fn(), + onOpenTaskDetail, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Hidden tasks'); + expect(host.textContent).toContain('Tighten rollout checklist'); + expect(host.textContent).toContain('Patch release notes'); + expect(host.textContent).toContain('bob'); + expect(host.textContent).not.toContain('task-card'); + + const taskButtons = host.querySelectorAll('button'); + expect(taskButtons.length).toBeGreaterThan(0); + + await act(async () => { + taskButtons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenTaskDetail).toHaveBeenCalledWith('task-1'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 0c6fe059..0464ea92 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter'; import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; +import type { GraphDataPort } from '@claude-teams/agent-graph'; function createBaseTeamData( overrides?: Partial & { @@ -53,6 +54,10 @@ function createBaseTeamData( }; } +function findNode(graph: GraphDataPort, nodeId: string) { + return graph.nodes.find((node) => node.id === nodeId); +} + describe('TeamGraphAdapter particles', () => { it('creates a message particle for a new incoming message from the newest message set', () => { const adapter = TeamGraphAdapter.create(); @@ -502,6 +507,215 @@ describe('TeamGraphAdapter particles', () => { expect(alice?.state).toBe('idle'); }); + it('refreshes lead state and exception metadata when lead activity changes without team-data changes', () => { + const adapter = TeamGraphAdapter.create(); + const teamData = createBaseTeamData(); + + adapter.adapt(teamData, 'my-team', undefined, 'active'); + + const graph = adapter.adapt( + teamData, + 'my-team', + undefined, + 'offline', + undefined, + new Set(['team-lead']) + ); + + expect(findNode(graph, 'lead:my-team')).toMatchObject({ + state: 'terminated', + pendingApproval: true, + exceptionTone: 'error', + exceptionLabel: 'offline', + }); + }); + + it('treats literal lead approval sources as lead-node pending approvals', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData(), + 'my-team', + undefined, + 'active', + undefined, + new Set(['lead']) + ); + + expect(findNode(graph, 'lead:my-team')).toMatchObject({ + pendingApproval: true, + exceptionTone: 'warning', + exceptionLabel: 'awaiting approval', + }); + }); + + it('refreshes member exception state when spawn status changes without team-data changes', () => { + const adapter = TeamGraphAdapter.create(); + const teamData = createBaseTeamData(); + + adapter.adapt(teamData, 'my-team'); + + const graph = adapter.adapt(teamData, 'my-team', { + alice: { + status: 'waiting', + launchState: 'starting', + updatedAt: '2026-04-08T20:00:00.000Z', + }, + }); + + expect(findNode(graph, 'member:my-team:alice')).toMatchObject({ + state: 'waiting', + spawnStatus: 'waiting', + exceptionTone: 'warning', + exceptionLabel: 'starting', + }); + }); + + it('refreshes unread comment badges when comment read state changes without task changes', () => { + const adapter = TeamGraphAdapter.create(); + const teamData = createBaseTeamData({ + tasks: [ + { + id: 'task-comments', + displayId: '#8', + subject: 'Review unread badge', + owner: 'alice', + status: 'in_progress', + comments: [ + { + id: 'comment-1', + author: 'alice', + text: 'Need a quick read receipt here', + createdAt: '2026-03-28T19:00:02.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }); + + const unreadGraph = adapter.adapt( + teamData, + 'my-team', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + {} + ); + const readGraph = adapter.adapt( + teamData, + 'my-team', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + 'my-team/task-comments': { + readIds: ['comment-1'], + lastUpdated: Date.now(), + }, + } + ); + + expect(findNode(unreadGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBe(1); + expect(findNode(readGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBeUndefined(); + }); + + it('dedupes symmetric blocking links and ignores completed blockers for blocked state', () => { + const adapter = TeamGraphAdapter.create(); + const inProgressGraph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-a', + displayId: '#1', + subject: 'Blocker', + owner: 'alice', + status: 'in_progress', + blocks: ['task-b'], + reviewState: 'none', + } as TeamTaskWithKanban, + { + id: 'task-b', + displayId: '#2', + subject: 'Blocked task', + owner: 'bob', + status: 'pending', + blockedBy: ['task-a'], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + const completedGraph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-a', + displayId: '#1', + subject: 'Blocker', + owner: 'alice', + status: 'completed', + blocks: ['task-b'], + reviewState: 'none', + } as TeamTaskWithKanban, + { + id: 'task-b', + displayId: '#2', + subject: 'Blocked task', + owner: 'bob', + status: 'pending', + blockedBy: ['task-a'], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(inProgressGraph.edges.filter((edge) => edge.type === 'blocking')).toHaveLength(1); + expect(findNode(inProgressGraph, 'task:my-team:task-b')?.isBlocked).toBe(true); + expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false); + }); + + it('adds compact review handoff metadata for active review tasks', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-review', + displayId: '#5', + subject: 'Review this change', + owner: 'alice', + reviewer: 'bob', + status: 'in_progress', + reviewState: 'review', + changePresence: 'has_changes', + kanbanColumn: 'review', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:task-review')).toMatchObject({ + reviewerName: 'bob', + reviewMode: 'assigned', + changePresence: 'has_changes', + reviewState: 'review', + }); + }); + it('adds compact runtime labels for lead and members and refreshes when runtime changes', () => { const adapter = TeamGraphAdapter.create(); adapter.adapt(createBaseTeamData(), 'my-team'); diff --git a/test/renderer/features/agent-graph/buildFocusState.test.ts b/test/renderer/features/agent-graph/buildFocusState.test.ts new file mode 100644 index 00000000..a5ae5502 --- /dev/null +++ b/test/renderer/features/agent-graph/buildFocusState.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { buildFocusState } from '../../../../packages/agent-graph/src/ui/buildFocusState'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; + +const leadNode: GraphNode = { + id: 'lead:my-team', + kind: 'lead', + label: 'My Team', + state: 'active', + domainRef: { kind: 'lead', teamName: 'my-team', memberName: 'team-lead' }, +}; + +const aliceNode: GraphNode = { + id: 'member:my-team:alice', + kind: 'member', + label: 'alice', + state: 'active', + currentTaskId: 'task-current', + domainRef: { kind: 'member', teamName: 'my-team', memberName: 'alice' }, +}; + +const bobNode: GraphNode = { + id: 'member:my-team:bob', + kind: 'member', + label: 'bob', + state: 'idle', + currentTaskId: 'task-current', + domainRef: { kind: 'member', teamName: 'my-team', memberName: 'bob' }, +}; + +const blockerNode: GraphNode = { + id: 'task:my-team:blocker', + kind: 'task', + label: '#1', + state: 'active', + ownerId: 'member:my-team:alice', + taskStatus: 'in_progress', + reviewState: 'none', + sublabel: 'Blocker', + domainRef: { kind: 'task', teamName: 'my-team', taskId: 'blocker' }, +}; + +const reviewTaskNode: GraphNode = { + id: 'task:my-team:review', + kind: 'task', + label: '#2', + state: 'active', + ownerId: 'member:my-team:alice', + taskStatus: 'in_progress', + reviewState: 'review', + reviewerName: 'bob', + reviewMode: 'assigned', + sublabel: 'Review task', + domainRef: { kind: 'task', teamName: 'my-team', taskId: 'review' }, +}; + +const overflowNode: GraphNode = { + id: 'task:my-team:overflow:alice:review', + kind: 'task', + label: '+3', + state: 'waiting', + ownerId: 'member:my-team:alice', + taskStatus: 'in_progress', + reviewState: 'review', + isOverflowStack: true, + overflowCount: 3, + overflowTaskIds: ['hidden-1', 'hidden-2', 'hidden-3'], + domainRef: { + kind: 'task_overflow', + teamName: 'my-team', + ownerMemberName: 'alice', + columnKey: 'review', + }, +}; + +const edges: GraphEdge[] = [ + { + id: 'edge:parent:lead:alice', + source: leadNode.id, + target: aliceNode.id, + type: 'parent-child', + }, + { + id: 'edge:parent:lead:bob', + source: leadNode.id, + target: bobNode.id, + type: 'parent-child', + }, + { + id: 'edge:own:alice:blocker', + source: aliceNode.id, + target: blockerNode.id, + type: 'ownership', + }, + { + id: 'edge:own:alice:review', + source: aliceNode.id, + target: reviewTaskNode.id, + type: 'ownership', + }, + { + id: 'edge:own:alice:overflow', + source: aliceNode.id, + target: overflowNode.id, + type: 'ownership', + }, + { + id: 'edge:block:blocker:review', + source: blockerNode.id, + target: reviewTaskNode.id, + type: 'blocking', + }, +]; + +const nodes = [leadNode, aliceNode, bobNode, blockerNode, reviewTaskNode, overflowNode]; + +describe('buildFocusState', () => { + it('focuses task selection on its owner, reviewer, direct blockers, and connecting edges', () => { + const focus = buildFocusState(reviewTaskNode.id, nodes, edges); + + expect(Array.from(focus.focusNodeIds ?? []).sort()).toEqual( + [ + leadNode.id, + aliceNode.id, + bobNode.id, + blockerNode.id, + reviewTaskNode.id, + ].sort() + ); + expect(focus.focusEdgeIds).toEqual( + new Set([ + 'edge:parent:lead:alice', + 'edge:parent:lead:bob', + 'edge:own:alice:blocker', + 'edge:own:alice:review', + 'edge:block:blocker:review', + ]) + ); + }); + + it('includes review-assigned tasks and owned overflow stacks when focusing a member', () => { + const focus = buildFocusState(bobNode.id, nodes, edges); + + expect(focus.focusNodeIds?.has(bobNode.id)).toBe(true); + expect(focus.focusNodeIds?.has(reviewTaskNode.id)).toBe(true); + expect(focus.focusNodeIds?.has(aliceNode.id)).toBe(true); + expect(focus.focusEdgeIds?.has('edge:parent:lead:bob')).toBe(true); + expect(focus.focusEdgeIds?.has('edge:own:alice:review')).toBe(true); + + const aliceFocus = buildFocusState(aliceNode.id, nodes, edges); + expect(aliceFocus.focusNodeIds?.has(overflowNode.id)).toBe(true); + }); + + it('focuses a lead on direct neighbors only', () => { + const focus = buildFocusState(leadNode.id, nodes, edges); + + expect(focus.focusNodeIds).toEqual( + new Set([leadNode.id, aliceNode.id, bobNode.id]) + ); + expect(focus.focusEdgeIds).toEqual( + new Set(['edge:parent:lead:alice', 'edge:parent:lead:bob']) + ); + }); + + it('does not enable global dimming for overflow stack selections', () => { + const focus = buildFocusState(overflowNode.id, nodes, edges); + + expect(focus.focusNodeIds).toBeNull(); + expect(focus.focusEdgeIds).toBeNull(); + }); +}); diff --git a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts new file mode 100644 index 00000000..e9a635b4 --- /dev/null +++ b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { collapseOverflowStacks } from '@renderer/features/agent-graph/utils/collapseOverflowStacks'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +function makeTaskNode(taskId: string, ownerName: string | null = 'alice'): GraphNode { + return { + id: `task:my-team:${taskId}`, + kind: 'task', + label: `#${taskId}`, + displayId: `#${taskId}`, + sublabel: `Task ${taskId}`, + state: 'waiting', + taskStatus: 'pending', + reviewState: 'none', + ownerId: ownerName ? `member:my-team:${ownerName}` : null, + domainRef: { kind: 'task', teamName: 'my-team', taskId }, + }; +} + +describe('collapseOverflowStacks', () => { + it('keeps all tasks visible when the column fits within the max row count', () => { + const nodes = Array.from({ length: 6 }, (_, index) => makeTaskNode(`task-${index + 1}`)); + + const result = collapseOverflowStacks(nodes, 'my-team', 6); + + expect(result).toHaveLength(6); + expect(result.every((node) => !node.isOverflowStack)).toBe(true); + }); + + it('replaces the hidden tail with a single overflow stack node while preserving visible order', () => { + const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`)); + + const result = collapseOverflowStacks(nodes, 'my-team', 6); + + expect(result).toHaveLength(6); + expect(result.slice(0, 5).map((node) => node.domainRef.kind === 'task' && node.domainRef.taskId)).toEqual([ + 'task-1', + 'task-2', + 'task-3', + 'task-4', + 'task-5', + ]); + expect(result[5]).toMatchObject({ + isOverflowStack: true, + overflowCount: 2, + overflowTaskIds: ['task-6', 'task-7'], + domainRef: { + kind: 'task_overflow', + teamName: 'my-team', + ownerMemberName: 'alice', + columnKey: 'todo', + }, + }); + }); + + it('applies the same stack rules to unassigned task columns', () => { + const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`, null)); + + const result = collapseOverflowStacks(nodes, 'my-team', 6); + const stack = result.find((node) => node.isOverflowStack); + + expect(stack).toMatchObject({ + overflowCount: 2, + overflowTaskIds: ['task-6', 'task-7'], + ownerId: null, + domainRef: { + kind: 'task_overflow', + teamName: 'my-team', + ownerMemberName: null, + columnKey: 'todo', + }, + }); + }); +}); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 38b1c23f..9d7fa351 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -71,14 +71,15 @@ describe('team change throttling', () => { vi.useFakeTimers(); const fetchTeams = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); - const refreshSelectedTeamChangePresence = vi.fn(async () => undefined); + const refreshTeamChangePresence = vi.fn(async () => undefined); useStore.setState({ fetchTeams, refreshTeamData, - refreshSelectedTeamChangePresence, + refreshTeamChangePresence, selectedTeamName: null, selectedTeamData: null, + teamDataCacheByName: {}, paneLayout: { focusedPaneId: 'p1', panes: [ @@ -165,6 +166,39 @@ describe('team change throttling', () => { expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); }); + it('lead-message refreshes visible graph tabs even when the team is not selected', async () => { + useStore.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], + activeTabId: 'g1', + }, + ], + }, + } as never); + + const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); + + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(800); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + }); + it('lead-message does not call fetchAllTasks', async () => { const fetchAllTasksSpy = vi.fn(async () => undefined); useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); @@ -192,23 +226,64 @@ describe('team change throttling', () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); - const refreshSelectedTeamChangePresenceSpy = vi.spyOn( - state, - 'refreshSelectedTeamChangePresence' - ); + const refreshTeamChangePresenceSpy = vi.spyOn(state, 'refreshTeamChangePresence'); hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(399); - expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled(); + expect(refreshTeamChangePresenceSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1); - expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamChangePresenceSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(fetchTeamsSpy).not.toHaveBeenCalled(); }); + it('log-source-change refreshes visible graph tab change presence for non-selected teams', async () => { + useStore.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], + activeTabId: 'g1', + }, + ], + }, + } as never); + + const refreshTeamChangePresenceSpy = vi.spyOn(useStore.getState(), 'refreshTeamChangePresence'); + + hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(400); + expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); + }); + it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => { const invalidateTaskChangePresence = vi.fn(); const checkTaskHasChanges = vi.fn(async () => undefined); @@ -268,6 +343,87 @@ describe('team change throttling', () => { ); }); + it('polls visible non-selected graph teams from cached team data', async () => { + const invalidateTaskChangePresence = vi.fn(); + const checkTaskHasChanges = vi.fn(async () => undefined); + + useStore.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-1', + owner: 'alice', + status: 'in_progress', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], + historyEvents: [], + reviewState: 'none', + changePresence: 'unknown', + }, + { + id: 'task-2', + owner: 'alice', + status: 'in_progress', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], + historyEvents: [], + reviewState: 'none', + changePresence: 'unknown', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], + activeTabId: 'g1', + }, + ], + }, + invalidateTaskChangePresence, + checkTaskHasChanges, + } as never); + + await vi.advanceTimersByTimeAsync(10_000); + expect(checkTaskHasChanges).toHaveBeenNthCalledWith( + 1, + 'my-team', + 'task-1', + expect.objectContaining({ status: 'in_progress', owner: 'alice' }) + ); + + await vi.advanceTimersByTimeAsync(10_000); + expect(checkTaskHasChanges).toHaveBeenNthCalledWith( + 2, + 'my-team', + 'task-2', + expect.objectContaining({ status: 'in_progress', owner: 'alice' }) + ); + }); + it('per-team throttling: busy team does not block another visible team', async () => { // Add a second visible team tab useStore.setState({ @@ -374,6 +530,41 @@ describe('team change throttling', () => { expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); }); + it('tracks visible graph tabs for tool activity and disables tracking when graph tab disappears', async () => { + const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking); + setToolActivityTrackingSpy.mockClear(); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }], + activeTabId: 'g1', + }, + ], + }, + } as never); + + cleanup?.(); + cleanup = initializeNotificationListeners(); + await vi.advanceTimersByTimeAsync(0); + + expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + await vi.advanceTimersByTimeAsync(0); + expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); + }); + it('applies targeted tool resets without clearing sibling tools', async () => { useStore.setState({ activeToolsByTeam: { diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 46d8ed03..8a352043 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { + __resetTeamSliceModuleStateForTests, createTeamSlice, getCurrentProvisioningProgressForTeam, } from '../../../src/renderer/store/slices/teamSlice'; @@ -13,6 +14,9 @@ const hoisted = vi.hoisted(() => ({ getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), cancelProvisioning: vi.fn(), + deleteTeam: vi.fn(), + restoreTeam: vi.fn(), + permanentlyDeleteTeam: vi.fn(), sendMessage: vi.fn(), requestReview: vi.fn(), updateKanban: vi.fn(), @@ -29,6 +33,9 @@ vi.mock('@renderer/api', () => ({ getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, cancelProvisioning: hoisted.cancelProvisioning, + deleteTeam: hoisted.deleteTeam, + restoreTeam: hoisted.restoreTeam, + permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, sendMessage: hoisted.sendMessage, requestReview: hoisted.requestReview, updateKanban: hoisted.updateKanban, @@ -74,6 +81,8 @@ function createSliceStore() { getAllPaneTabs: vi.fn(() => []), warmTaskChangeSummaries: vi.fn(async () => undefined), invalidateTaskChangePresence: vi.fn(), + fetchTeams: vi.fn(async () => undefined), + fetchAllTasks: vi.fn(async () => undefined), })); } @@ -118,6 +127,7 @@ function createMemberSpawnSnapshot(overrides: Record = {}) { describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); + __resetTeamSliceModuleStateForTests(); hoisted.list.mockResolvedValue([]); hoisted.getData.mockResolvedValue({ teamName: 'my-team', @@ -143,6 +153,9 @@ describe('teamSlice actions', () => { }); hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null }); hoisted.cancelProvisioning.mockResolvedValue(undefined); + hoisted.deleteTeam.mockResolvedValue(undefined); + hoisted.restoreTeam.mockResolvedValue(undefined); + hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined); }); it('maps inbox verify failure to user-friendly text', async () => { @@ -207,6 +220,104 @@ describe('teamSlice actions', () => { expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); }); + it('removes non-selected team cache entries on permanent delete', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + 'other-team': { + teamName: 'other-team', + config: { name: 'Other Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + await store.getState().permanentlyDeleteTeam('my-team'); + + expect(hoisted.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team'); + expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); + expect(store.getState().teamDataCacheByName['other-team']).toBeDefined(); + }); + + it('clears selected team state and cache on soft delete', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + await store.getState().deleteTeam('my-team'); + + expect(hoisted.deleteTeam).toHaveBeenCalledWith('my-team'); + expect(store.getState().selectedTeamName).toBeNull(); + expect(store.getState().selectedTeamData).toBeNull(); + expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); + }); + + it('drops stale cache on restore so the next open refetches fresh data', async () => { + const store = createSliceStore(); + store.setState({ + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + await store.getState().restoreTeam('my-team'); + + expect(hoisted.restoreTeam).toHaveBeenCalledWith('my-team'); + expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); + }); + describe('refreshTeamData provisioning safety', () => { it('does not set fatal error on TEAM_PROVISIONING', async () => { const store = createSliceStore(); @@ -261,6 +372,74 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamData).toEqual(existingData); }); + it('clears non-selected cache on TEAM_DRAFT refresh failure', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + hoisted.getData.mockRejectedValue(new Error('TEAM_DRAFT')); + + await store.getState().refreshTeamData('my-team'); + + expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); + expect(store.getState().selectedTeamData?.teamName).toBe('other-team'); + }); + + it('clears non-selected cache when the team no longer exists', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + hoisted.getData.mockRejectedValue(new Error('Team not found: my-team')); + + await store.getState().refreshTeamData('my-team'); + + expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); + expect(store.getState().selectedTeamData?.teamName).toBe('other-team'); + }); + it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => { const store = createSliceStore(); store.setState({ @@ -512,6 +691,97 @@ describe('teamSlice actions', () => { expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed'); }); + it('hydrates visible non-selected graph tabs when config becomes ready', () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + paneLayout: { + focusedPaneId: 'pane-default', + panes: [ + { + id: 'pane-default', + widthFraction: 1, + tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }], + activeTabId: 'graph-1', + }, + ], + }, + currentProvisioningRunIdByTeam: { + 'my-team': 'run-current', + }, + }); + + const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData'); + const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam'); + + store.getState().onProvisioningProgress({ + runId: 'run-current', + teamName: 'my-team', + state: 'assembling', + configReady: true, + message: 'Config written', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:01.000Z', + }); + + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + expect(selectTeamSpy).not.toHaveBeenCalled(); + }); + + it('refreshes visible non-selected graph tabs when the canonical run reaches ready', () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'other-team', + selectedTeamData: { + teamName: 'other-team', + config: { name: 'Other Team' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, + processes: [], + }, + paneLayout: { + focusedPaneId: 'pane-default', + panes: [ + { + id: 'pane-default', + widthFraction: 1, + tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }], + activeTabId: 'graph-1', + }, + ], + }, + currentProvisioningRunIdByTeam: { + 'my-team': 'run-current', + }, + }); + + const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData'); + const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam'); + + store.getState().onProvisioningProgress({ + runId: 'run-current', + teamName: 'my-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:02.000Z', + }); + + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + expect(selectTeamSpy).not.toHaveBeenCalled(); + }); + it('keeps the current run pinned when stale progress from another run arrives', () => { const store = createSliceStore(); const startedAt = '2026-03-12T10:00:00.000Z'; From 57c384531aef85516462dac0a65c3c97dc16fa0c Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 21:31:53 +0300 Subject: [PATCH 03/21] feat(agent-graph): improve blocking visibility and inspection --- packages/agent-graph/src/canvas/draw-edges.ts | 16 +- packages/agent-graph/src/canvas/draw-tasks.ts | 13 +- .../agent-graph/src/canvas/hit-detection.ts | 194 ++++++++++++++- .../src/constants/canvas-constants.ts | 2 + packages/agent-graph/src/ports/types.ts | 6 + packages/agent-graph/src/ui/GraphCanvas.tsx | 39 ++- packages/agent-graph/src/ui/GraphControls.tsx | 17 ++ .../agent-graph/src/ui/GraphEdgeOverlay.tsx | 66 +++++ packages/agent-graph/src/ui/GraphView.tsx | 234 +++++++++++++++--- .../agent-graph/src/ui/buildFocusState.ts | 125 ++++++++-- .../src/ui/selectRenderableParticles.ts | 101 ++++++++ .../agent-graph/adapters/TeamGraphAdapter.ts | 120 +++++++-- .../ui/GraphBlockingEdgePopover.tsx | 207 ++++++++++++++++ .../agent-graph/ui/TeamGraphOverlay.tsx | 12 + .../features/agent-graph/ui/TeamGraphTab.tsx | 12 + .../utils/collapseOverflowStacks.ts | 57 ++++- .../GraphBlockingEdgePopover.test.ts | 195 +++++++++++++++ .../agent-graph/TeamGraphAdapter.test.ts | 130 +++++++++- .../agent-graph/buildFocusState.test.ts | 27 +- .../collapseOverflowStacks.test.ts | 17 +- .../agent-graph/edgeHitDetection.test.ts | 85 +++++++ .../selectRenderableParticles.test.ts | 76 ++++++ 22 files changed, 1642 insertions(+), 109 deletions(-) create mode 100644 packages/agent-graph/src/ui/GraphEdgeOverlay.tsx create mode 100644 packages/agent-graph/src/ui/selectRenderableParticles.ts create mode 100644 src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx create mode 100644 test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts create mode 100644 test/renderer/features/agent-graph/edgeHitDetection.test.ts create mode 100644 test/renderer/features/agent-graph/selectRenderableParticles.test.ts diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts index 7731610b..d85cec27 100644 --- a/packages/agent-graph/src/canvas/draw-edges.ts +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -75,6 +75,8 @@ export function drawEdges( _time: number, hasActiveParticles: Set, focusEdgeIds?: ReadonlySet | null, + hoveredEdgeId?: string | null, + selectedEdgeId?: string | null, ): void { for (const edge of edges) { const source = nodeMap.get(edge.source); @@ -84,23 +86,27 @@ export function drawEdges( const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; const isActive = hasActiveParticles.has(edge.id); + const isSelected = selectedEdgeId === edge.id; + const isHovered = !isSelected && hoveredEdgeId === edge.id; // Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave const alpha = isActive ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) : BEAM.idleAlpha; const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1; + const interactionAlpha = isSelected ? 0.95 : isHovered ? 0.6 : 0; + const finalAlpha = Math.max(alpha * focusAlpha, interactionAlpha); - if (alpha * focusAlpha < MIN_VISIBLE_OPACITY) continue; + if (finalAlpha < MIN_VISIBLE_OPACITY) continue; const cp = computeControlPoints(source.x, source.y, target.x, target.y); ctx.save(); - ctx.globalAlpha = alpha * focusAlpha; + ctx.globalAlpha = finalAlpha; // Subtle glow pass when edge has active particles - if (isActive) { + if (isActive || isSelected || isHovered) { ctx.shadowColor = edge.color ?? style.color; - ctx.shadowBlur = 12; + ctx.shadowBlur = isSelected ? 16 : isHovered ? 10 : 12; } // Draw tapered bezier @@ -119,7 +125,7 @@ export function drawEdges( // Arrow for blocking edges if (edge.type === 'blocking') { - drawArrowHead(ctx, cp, target.x, target.y, style.color, alpha); + drawArrowHead(ctx, cp, target.x, target.y, style.color, finalAlpha); } ctx.restore(); diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index 3c825e62..26ac1644 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -266,10 +266,19 @@ function drawOverflowStack( ? 'rgba(15, 20, 40, 0.78)' : COLORS.cardBg; ctx.fill(); - ctx.strokeStyle = hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55); - ctx.lineWidth = isSelected ? 2 : 1; + ctx.strokeStyle = node.isBlocked + ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65) + : hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55); + ctx.lineWidth = node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1; ctx.stroke(); + if (node.isBlocked) { + ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6); + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, 4, TASK_PILL.height, [r, 0, 0, r]); + ctx.fill(); + } + ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts index 8e77998e..3d3577c3 100644 --- a/packages/agent-graph/src/canvas/hit-detection.ts +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -3,8 +3,9 @@ * Adapted from agent-flow's hit-detection.ts (Apache 2.0). */ -import type { GraphNode } from '../ports/types'; -import { NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; +import type { GraphEdge, GraphNode } from '../ports/types'; +import { BEAM, NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; +import { bezierPoint, computeControlPoints } from './draw-edges'; /** * Find the node at the given world-space coordinates. @@ -65,3 +66,192 @@ export function findNodeAt( return hit; } + +const EDGE_HIT_PRIORITY: Record = { + blocking: 5, + related: 4, + message: 3, + ownership: 2, + 'parent-child': 1, +}; + +function getEdgeHitRadius(edgeType: GraphEdge['type']): number { + switch (edgeType) { + case 'parent-child': + return Math.max(BEAM.parentChild.startW, BEAM.parentChild.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'ownership': + return Math.max(BEAM.ownership.startW, BEAM.ownership.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'blocking': + return Math.max(BEAM.blocking.startW, BEAM.blocking.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'related': + return Math.max(BEAM.related.startW, BEAM.related.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'message': + return Math.max(BEAM.message.startW, BEAM.message.endW) * 0.5 + HIT_DETECTION.edgePadding; + } +} + +function distanceToSegmentSquared( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number +): number { + const dx = x2 - x1; + const dy = y2 - y1; + if (dx === 0 && dy === 0) { + const ddx = px - x1; + const ddy = py - y1; + return ddx * ddx + ddy * ddy; + } + + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))); + const lx = x1 + dx * t; + const ly = y1 + dy * t; + const ddx = px - lx; + const ddy = py - ly; + return ddx * ddx + ddy * ddy; +} + +function distanceToBezierSquared( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number +): number { + const cp = computeControlPoints(x1, y1, x2, y2); + let previous = { x: x1, y: y1 }; + let best = Number.POSITIVE_INFINITY; + + for (let segment = 1; segment <= 20; segment += 1) { + const next = bezierPoint(x1, y1, cp, x2, y2, segment / 20); + best = Math.min(best, distanceToSegmentSquared(px, py, previous.x, previous.y, next.x, next.y)); + previous = next; + } + + return best; +} + +function getBezierBounds( + x1: number, + y1: number, + x2: number, + y2: number, + padding: number +): { left: number; top: number; right: number; bottom: number } { + const cp = computeControlPoints(x1, y1, x2, y2); + const left = Math.min(x1, x2, cp.cp1x, cp.cp2x) - padding; + const right = Math.max(x1, x2, cp.cp1x, cp.cp2x) + padding; + const top = Math.min(y1, y2, cp.cp1y, cp.cp2y) - padding; + const bottom = Math.max(y1, y2, cp.cp1y, cp.cp2y) + padding; + return { left, top, right, bottom }; +} + +function boundsIntersect( + left: number, + top: number, + right: number, + bottom: number, + other: { left: number; top: number; right: number; bottom: number } +): boolean { + return left <= other.right && right >= other.left && top <= other.bottom && bottom >= other.top; +} + +export function collectInteractiveEdgesInViewport( + edges: GraphEdge[], + nodeMap: Map, + bounds: { left: number; top: number; right: number; bottom: number }, +): GraphEdge[] { + const candidates: GraphEdge[] = []; + + for (const edge of edges) { + if (edge.type !== 'blocking') { + continue; + } + + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const edgeBounds = getBezierBounds( + source.x, + source.y, + target.x, + target.y, + getEdgeHitRadius(edge.type) + 24 + ); + if (!boundsIntersect(edgeBounds.left, edgeBounds.top, edgeBounds.right, edgeBounds.bottom, bounds)) { + continue; + } + + candidates.push(edge); + } + + return candidates; +} + +export function findEdgeAt( + worldX: number, + worldY: number, + edges: GraphEdge[], + nodeMap: Map, +): string | null { + let bestHit: { id: string; distanceSquared: number; priority: number } | null = null; + + for (const edge of edges) { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const radius = getEdgeHitRadius(edge.type); + const bounds = getBezierBounds(source.x, source.y, target.x, target.y, radius); + if ( + worldX < bounds.left || + worldX > bounds.right || + worldY < bounds.top || + worldY > bounds.bottom + ) { + continue; + } + const distanceSquared = distanceToBezierSquared( + worldX, + worldY, + source.x, + source.y, + target.x, + target.y + ); + if (distanceSquared > radius * radius) { + continue; + } + + const priority = EDGE_HIT_PRIORITY[edge.type]; + if ( + !bestHit || + distanceSquared < bestHit.distanceSquared || + (distanceSquared === bestHit.distanceSquared && priority > bestHit.priority) + ) { + bestHit = { id: edge.id, distanceSquared, priority }; + } + } + + return bestHit?.id ?? null; +} + +export function getEdgeMidpoint( + edge: GraphEdge, + nodeMap: Map +): { x: number; y: number } | null { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) return null; + if (source.x == null || source.y == null || target.x == null || target.y == null) return null; + + const cp = computeControlPoints(source.x, source.y, target.x, target.y); + return bezierPoint(source.x, source.y, cp, target.x, target.y, 0.5); +} diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 54c7129a..f56a236f 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -218,6 +218,8 @@ export const HIT_DETECTION = { agentPadding: 8, /** Task pill hit area padding */ taskPadding: 4, + /** Extra padding around curved edges for easier inspection */ + edgePadding: 6, } as const; // ─── Background ───────────────────────────────────────────────────────────── diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 59a7beed..1deb3cce 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -155,6 +155,12 @@ export interface GraphEdge { label?: string; /** Edge color override */ color?: string; + /** Number of aggregated raw relations behind this visual edge */ + aggregateCount?: number; + /** Raw source-side task ids represented by this visual edge */ + sourceTaskIds?: string[]; + /** Raw target-side task ids represented by this visual edge */ + targetTaskIds?: string[]; } // ─── Graph Particle ────────────────────────────────────────────────────────── diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 6fc2a726..eee3c1ba 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -17,6 +17,7 @@ import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; import { BloomRenderer } from '../canvas/bloom-renderer'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; +import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles'; import type { CameraTransform } from '../hooks/useGraphCamera'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── @@ -30,6 +31,8 @@ export interface GraphDrawState { camera: CameraTransform; selectedNodeId: string | null; hoveredNodeId: string | null; + selectedEdgeId: string | null; + hoveredEdgeId: string | null; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; } @@ -118,6 +121,7 @@ export const GraphCanvas = forwardRef(funct const visibleNodesCache = useRef([]); const visibleEdgesCache = useRef([]); const visibleNodeIdsCache = useRef(new Set()); + const visibleEdgeIdsCache = useRef(new Set()); const activeParticleEdgesCache = useRef(new Set()); // Imperative draw function — called from RAF, NOT from React render @@ -196,18 +200,41 @@ export const GraphCanvas = forwardRef(funct const visibleEdges = visibleEdgesCache.current; visibleEdges.length = 0; + const visibleEdgeIds = visibleEdgeIdsCache.current; + visibleEdgeIds.clear(); for (const e of state.edges) { if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) { visibleEdges.push(e); + visibleEdgeIds.add(e.id); } } - drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges, state.focusEdgeIds); + const prioritizedEdgeIds = + state.focusEdgeIds ?? (state.selectedEdgeId ? new Set([state.selectedEdgeId]) : null); + drawEdges( + ctx, + visibleEdges, + nodeMap, + state.time, + activeParticleEdges, + prioritizedEdgeIds, + state.hoveredEdgeId, + state.selectedEdgeId + ); - // 2b. Particles (cap at 100 for performance) - const cappedParticles = state.particles.length > 100 - ? state.particles.slice(-100) - : state.particles; - drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time, state.focusEdgeIds); + // 2b. Particles - adaptive degradation keeps one visible particle per active edge + const particleBudget = computeAdaptiveParticleBudget({ + visibleNodeCount: visibleNodes.length, + visibleEdgeCount: visibleEdges.length, + frameTimeMs: perfRef.current.frameTimeMs, + hasFocusedEdges: (prioritizedEdgeIds?.size ?? 0) > 0, + }); + const renderableParticles = selectRenderableParticles({ + particles: state.particles, + visibleEdgeIds, + focusEdgeIds: prioritizedEdgeIds, + budget: particleBudget, + }); + drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) drawProcesses( diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 135d0acf..08f23e58 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -39,6 +39,7 @@ export interface GraphControlsProps { teamName: string; teamColor?: string; isAlive?: boolean; + showBlockingHint?: boolean; } export function GraphControls({ @@ -53,6 +54,7 @@ export function GraphControls({ teamName, teamColor, isAlive, + showBlockingHint = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -203,6 +205,21 @@ export function GraphControls({ } />
+ + {showBlockingHint && ( +
+
+ Red lines - blockers, click to inspect +
+
+ )} ); } diff --git a/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx b/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx new file mode 100644 index 00000000..1a363744 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx @@ -0,0 +1,66 @@ +import type { GraphEdge, GraphNode } from '../ports/types'; + +function getEdgeTypeLabel(edgeType: GraphEdge['type']): string { + switch (edgeType) { + case 'blocking': + return 'Blocking'; + case 'ownership': + return 'Ownership'; + case 'related': + return 'Related'; + case 'message': + return 'Message'; + case 'parent-child': + return 'Parent-child'; + } +} + +export interface GraphEdgeOverlayProps { + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; +} + +export function GraphEdgeOverlay({ + edge, + sourceNode, + targetNode, + onClose, +}: GraphEdgeOverlayProps): React.JSX.Element { + return ( +
+
+ {getEdgeTypeLabel(edge.type)} +
+
+ {sourceNode?.label ?? edge.source} -> {targetNode?.label ?? edge.target} +
+ {edge.label && ( +
+ {edge.label} +
+ )} +
+ +
+
+ ); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 63dd1636..35e0290d 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -15,15 +15,21 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; -import type { GraphNode } from '../ports/types'; +import type { GraphEdge, GraphNode } from '../ports/types'; import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; +import { GraphEdgeOverlay } from './GraphEdgeOverlay'; import { buildFocusState } from './buildFocusState'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; -import { findNodeAt } from '../canvas/hit-detection'; +import { + collectInteractiveEdgesInViewport, + findEdgeAt, + findNodeAt, + getEdgeMidpoint, +} from '../canvas/hit-detection'; import { ANIM_SPEED } from '../constants/canvas-constants'; export interface GraphViewProps { @@ -41,6 +47,13 @@ export interface GraphViewProps { screenPos: { x: number; y: number }; onClose: () => void; }) => React.ReactNode; + renderEdgeOverlay?: (props: { + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; + onSelectNode: (nodeId: string) => void; + }) => React.ReactNode; } export function GraphView({ @@ -53,9 +66,11 @@ export function GraphView({ onRequestPinAsTab, onRequestFullscreen, renderOverlay, + renderEdgeOverlay, }: GraphViewProps): React.JSX.Element { // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -67,6 +82,9 @@ export function GraphView({ // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change const selectedNodeIdRef = useRef(null); selectedNodeIdRef.current = selectedNodeId; + const selectedEdgeIdRef = useRef(null); + selectedEdgeIdRef.current = selectedEdgeId; + const hoveredEdgeIdRef = useRef(null); const containerRef = useRef(null); const canvasHandle = useRef(null); @@ -76,6 +94,8 @@ export function GraphView({ const runningRef = useRef(false); const hasAutoFit = useRef(false); const allowAutoFitRef = useRef(true); + const nodeMapRef = useRef(new Map()); + const nodeMapNodesRef = useRef(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -116,8 +136,37 @@ export function GraphView({ // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── const idleFrameSkip = useRef(0); const focusState = useMemo( - () => buildFocusState(selectedNodeId, data.nodes, data.edges), - [selectedNodeId, data.edges, data.nodes] + () => buildFocusState(selectedNodeId, selectedEdgeId, data.nodes, data.edges), + [selectedEdgeId, selectedNodeId, data.edges, data.nodes] + ); + + const getNodeMap = useCallback((nodes: GraphNode[]): Map => { + if (nodeMapNodesRef.current === nodes) { + return nodeMapRef.current; + } + const nodeMap = nodeMapRef.current; + nodeMap.clear(); + for (const node of nodes) { + nodeMap.set(node.id, node); + } + nodeMapNodesRef.current = nodes; + return nodeMap; + }, []); + + const getInteractiveEdges = useCallback( + (canvas: HTMLCanvasElement, nodes: GraphNode[], edges: GraphEdge[]): GraphEdge[] => { + const nodeMap = getNodeMap(nodes); + const rect = canvas.getBoundingClientRect(); + const transform = camera.transformRef.current; + const bounds = { + left: -transform.x / transform.zoom, + top: -transform.y / transform.zoom, + right: (rect.width - transform.x) / transform.zoom, + bottom: (rect.height - transform.y) / transform.zoom, + }; + return collectInteractiveEdgesInViewport(edges, nodeMap, bounds); + }, + [camera.transformRef, getNodeMap] ); const animate = useCallback(() => { @@ -159,6 +208,8 @@ export function GraphView({ camera: cameraRef.current.transformRef.current, selectedNodeId: selectedNodeIdRef.current, hoveredNodeId: interaction.hoveredNodeId.current, + selectedEdgeId: selectedEdgeIdRef.current, + hoveredEdgeId: hoveredEdgeIdRef.current, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, }); @@ -243,6 +294,7 @@ export function GraphView({ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); + const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; // only left click @@ -251,22 +303,38 @@ export function GraphView({ if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodes = simulation.stateRef.current.nodes; + const edges = simulation.stateRef.current.edges; + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); // Check if we hit a node - interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes); + interaction.handleMouseDown(world.x, world.y, nodes); // Hit a node (draggable or clickable) → don't pan - const hitNode = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); + const hitNode = findNodeAt(world.x, world.y, nodes); if (hitNode) { markUserInteracted(); isPanningRef.current = false; + edgeMouseDownRef.current = null; + hoveredEdgeIdRef.current = null; } else { - // Hit empty space → pan - markUserInteracted(); - isPanningRef.current = true; - camera.handlePanStart(e.clientX, e.clientY); + const hitEdge = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + if (hitEdge) { + markUserInteracted(); + isPanningRef.current = false; + edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y }; + hoveredEdgeIdRef.current = hitEdge; + } else { + // Hit empty space → pan + markUserInteracted(); + isPanningRef.current = true; + edgeMouseDownRef.current = null; + hoveredEdgeIdRef.current = null; + camera.handlePanStart(e.clientX, e.clientY); + } } - }, [camera, interaction, markUserInteracted, simulation.stateRef]); + }, [camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef]); const handleMouseMove = useCallback((e: React.MouseEvent) => { // Dragging with left button held @@ -288,26 +356,65 @@ export function GraphView({ if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); - canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab'; - }, [camera, interaction, simulation.stateRef]); + const nodes = simulation.stateRef.current.nodes; + const edges = simulation.stateRef.current.edges; + const hoveredNodeId = findNodeAt(world.x, world.y, nodes); + interaction.hoveredNodeId.current = hoveredNodeId; - const handleMouseUp = useCallback(() => { + if (hoveredNodeId) { + hoveredEdgeIdRef.current = null; + canvas.style.cursor = 'pointer'; + return; + } + + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); + hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; + }, [camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef]); + + const handleMouseUp = useCallback((e: React.MouseEvent) => { if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; setSelectedNodeId(null); // hide popover after pan + setSelectedEdgeId(null); + edgeMouseDownRef.current = null; return; } const clickedId = interaction.handleMouseUp(); if (clickedId) { setSelectedNodeId(clickedId); + setSelectedEdgeId(null); const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); if (node) events?.onNodeClick?.(node.domainRef); } else { - setSelectedNodeId(null); // click on empty space — hide popover - if (!interaction.isDragging.current) { + const canvas = canvasHandle.current?.getCanvas(); + let clickedEdgeId: string | null = null; + if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const dx = world.x - edgeMouseDownRef.current.x; + const dy = world.y - edgeMouseDownRef.current.y; + if (dx * dx + dy * dy <= 25) { + clickedEdgeId = edgeMouseDownRef.current.id; + } + } + edgeMouseDownRef.current = null; + + if (clickedEdgeId) { + setSelectedNodeId(null); + setSelectedEdgeId(clickedEdgeId); + const edge = simulation.stateRef.current.edges.find((candidate) => candidate.id === clickedEdgeId); + if (edge) { + events?.onEdgeClick?.(edge); + } + } else { + setSelectedNodeId(null); // click on empty space — hide popover + setSelectedEdgeId(null); + } + if (!interaction.isDragging.current && !clickedEdgeId) { events?.onBackgroundClick?.(); } } @@ -320,6 +427,7 @@ export function GraphView({ const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes); if (nodeId) { + setSelectedEdgeId(null); const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); if (node) { // Unpin if pinned (toggle) @@ -340,8 +448,9 @@ export function GraphView({ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; if (e.key === 'Escape') { - if (selectedNodeId) { + if (selectedNodeId || selectedEdgeId) { setSelectedNodeId(null); + setSelectedEdgeId(null); } else { onRequestClose?.(); } @@ -357,16 +466,28 @@ export function GraphView({ }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [selectedNodeId, onRequestClose, camera, simulation.stateRef]); + }, [selectedEdgeId, selectedNodeId, onRequestClose, camera, simulation.stateRef]); // ─── Selected node for overlay ────────────────────────────────────────── const selectedNode: GraphNode | null = selectedNodeId ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null : null; + const selectedEdge: GraphEdge | null = + selectedEdgeId + ? simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null + : null; + const hasBlockingEdges = useMemo( + () => data.edges.some((edge) => edge.type === 'blocking'), + [data.edges] + ); + const selectedEdgeNodeMap = useMemo( + () => getNodeMap(simulation.stateRef.current.nodes), + [data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef] + ); useLayoutEffect(() => { - if (!selectedNode || !containerRef.current || !overlayRef.current) { + if ((!selectedNode && !selectedEdgeId) || !containerRef.current || !overlayRef.current) { return; } @@ -376,7 +497,25 @@ export function GraphView({ const reference = { getBoundingClientRect(): DOMRect { const containerRect = container.getBoundingClientRect(); - const screenPos = camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + const screenPos = (() => { + if (selectedNode) { + return camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + } + if (selectedEdgeId) { + const currentNodes = simulation.stateRef.current.nodes; + const currentEdge = simulation.stateRef.current.edges.find( + (edge) => edge.id === selectedEdgeId + ); + if (currentEdge) { + const nodeMap = getNodeMap(currentNodes); + const midpoint = getEdgeMidpoint(currentEdge, nodeMap); + if (midpoint) { + return camera.worldToScreen(midpoint.x, midpoint.y); + } + } + } + return camera.worldToScreen(0, 0); + })(); return DOMRect.fromRect({ x: containerRect.left + screenPos.x, y: containerRect.top + screenPos.y, @@ -415,7 +554,7 @@ export function GraphView({ void updatePosition(); return cleanup; - }, [camera, selectedNode]); + }, [camera, getNodeMap, selectedEdgeId, selectedNode, simulation.stateRef]); // ─── Render ───────────────────────────────────────────────────────────── return ( @@ -454,23 +593,46 @@ export function GraphView({ teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} + showBlockingHint={filters.showEdges && hasBlockingEdges && !selectedNode && !selectedEdge} /> - {selectedNode && ( + {(selectedNode || selectedEdge) && (
- {renderOverlay ? ( - renderOverlay({ - node: selectedNode, - screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), - onClose: () => setSelectedNodeId(null), - }) - ) : ( - setSelectedNodeId(null)} - /> - )} + {selectedNode ? ( + renderOverlay ? ( + renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + }) + ) : ( + setSelectedNodeId(null)} + /> + ) + ) : selectedEdge ? ( + renderEdgeOverlay ? ( + renderEdgeOverlay({ + edge: selectedEdge, + sourceNode: selectedEdgeNodeMap.get(selectedEdge.source), + targetNode: selectedEdgeNodeMap.get(selectedEdge.target), + onClose: () => setSelectedEdgeId(null), + onSelectNode: (nodeId: string) => { + setSelectedEdgeId(null); + setSelectedNodeId(nodeId); + }, + }) + ) : ( + setSelectedEdgeId(null)} + /> + ) + ) : null}
)} diff --git a/packages/agent-graph/src/ui/buildFocusState.ts b/packages/agent-graph/src/ui/buildFocusState.ts index 71b82ad2..0481b8a6 100644 --- a/packages/agent-graph/src/ui/buildFocusState.ts +++ b/packages/agent-graph/src/ui/buildFocusState.ts @@ -28,25 +28,15 @@ function addNodeAndIncidentEdges( export function buildFocusState( selectedNodeId: string | null, + selectedEdgeId: string | null, nodes: GraphNode[], edges: GraphEdge[] ): GraphFocusState { - if (!selectedNodeId) { + if (!selectedNodeId && !selectedEdgeId) { return { focusNodeIds: null, focusEdgeIds: null }; } - const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; - if ( - !selectedNode || - selectedNode.kind === 'process' || - selectedNode.kind === 'crossteam' || - selectedNode.isOverflowStack - ) { - return { focusNodeIds: null, focusEdgeIds: null }; - } - - const nodeIds = new Set([selectedNodeId]); - const edgeIds = new Set(); + const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); const adjacency = new Map(); for (const edge of edges) { @@ -59,20 +49,117 @@ export function buildFocusState( adjacency.set(edge.target, targetEdges); } + if (selectedNodeId == null && selectedEdgeId != null) { + const selectedEdge = edges.find((edge) => edge.id === selectedEdgeId) ?? null; + if (!selectedEdge || selectedEdge.type !== 'blocking') { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const sourceNode = nodeById.get(selectedEdge.source); + const targetNode = nodeById.get(selectedEdge.target); + if (!sourceNode || !targetNode) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeIds = new Set([selectedEdge.source, selectedEdge.target]); + const edgeIds = new Set([selectedEdge.id]); + const queue = [selectedEdge.source, selectedEdge.target]; + + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + const currentNode = nodeById.get(currentNodeId); + if (!currentNode || currentNode.kind !== 'task') { + continue; + } + + for (const edge of adjacency.get(currentNodeId) ?? []) { + if (edge.type !== 'blocking') { + continue; + } + if (!edgeIds.has(edge.id)) { + edgeIds.add(edge.id); + } + const neighborId = edge.source === currentNodeId ? edge.target : edge.source; + if (!nodeIds.has(neighborId)) { + nodeIds.add(neighborId); + queue.push(neighborId); + } + } + } + + for (const nodeId of Array.from(nodeIds)) { + const node = nodeById.get(nodeId); + if (!node || node.kind !== 'task') { + continue; + } + if (node.ownerId) { + nodeIds.add(node.ownerId); + } + if (node.reviewerName) { + const reviewerNode = nodes.find( + (candidate) => + candidate.kind === 'member' && + candidate.domainRef.kind === 'member' && + candidate.domainRef.memberName === node.reviewerName + ); + if (reviewerNode) { + nodeIds.add(reviewerNode.id); + } + } + for (const edge of adjacency.get(node.id) ?? []) { + if (edge.type === 'ownership') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + for (const nodeId of Array.from(nodeIds)) { + const node = nodeById.get(nodeId); + if (node?.kind !== 'member') continue; + for (const edge of adjacency.get(nodeId) ?? []) { + if (edge.type === 'parent-child') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + return { + focusNodeIds: nodeIds, + focusEdgeIds: edgeIds, + }; + } + + const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; + if ( + !selectedNode || + selectedNode.kind === 'process' || + selectedNode.kind === 'crossteam' || + selectedNode.isOverflowStack + ) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeIds = new Set([selectedNode.id]); + const edgeIds = new Set(); + const selectedMemberName = selectedNode.domainRef.kind === 'member' || selectedNode.domainRef.kind === 'lead' ? selectedNode.domainRef.memberName : null; if (selectedNode.kind === 'lead') { - addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency); } else if (selectedNode.kind === 'member') { - addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency); for (const node of nodes) { if (node.kind !== 'task') continue; if (node.isOverflowStack) { - if (node.ownerId === selectedNodeId) { + if (node.ownerId === selectedNode.id) { nodeIds.add(node.id); for (const edge of adjacency.get(node.id) ?? []) { edgeIds.add(edge.id); @@ -81,7 +168,7 @@ export function buildFocusState( continue; } - const isOwnedTask = node.ownerId === selectedNodeId; + const isOwnedTask = node.ownerId === selectedNode.id; const isReviewTask = selectedMemberName != null && node.reviewerName === selectedMemberName && @@ -115,7 +202,7 @@ export function buildFocusState( } } - for (const edge of adjacency.get(selectedNodeId) ?? []) { + for (const edge of adjacency.get(selectedNode.id) ?? []) { if (edge.type === 'ownership' || edge.type === 'blocking') { edgeIds.add(edge.id); nodeIds.add(edge.source); @@ -125,7 +212,7 @@ export function buildFocusState( } const focusedMemberIds = Array.from(nodeIds).filter((nodeId) => { - const node = nodes.find((candidate) => candidate.id === nodeId); + const node = nodeById.get(nodeId); return node?.kind === 'member'; }); diff --git a/packages/agent-graph/src/ui/selectRenderableParticles.ts b/packages/agent-graph/src/ui/selectRenderableParticles.ts new file mode 100644 index 00000000..1247bf74 --- /dev/null +++ b/packages/agent-graph/src/ui/selectRenderableParticles.ts @@ -0,0 +1,101 @@ +import type { GraphParticle } from '../ports/types'; + +const MIN_PARTICLE_BUDGET = 120; +const MAX_PARTICLE_BUDGET = 360; +const FOCUSED_MIN_BUDGET = 180; + +export function computeAdaptiveParticleBudget(params: { + visibleNodeCount: number; + visibleEdgeCount: number; + frameTimeMs: number; + hasFocusedEdges: boolean; +}): number { + const baseBudget = Math.max( + MIN_PARTICLE_BUDGET, + Math.min(MAX_PARTICLE_BUDGET, 48 + params.visibleNodeCount * 3 + params.visibleEdgeCount * 2) + ); + + let adjustedBudget = baseBudget; + if (params.frameTimeMs >= 24) { + adjustedBudget = Math.floor(baseBudget * 0.55); + } else if (params.frameTimeMs >= 18) { + adjustedBudget = Math.floor(baseBudget * 0.72); + } else if (params.frameTimeMs >= 14) { + adjustedBudget = Math.floor(baseBudget * 0.88); + } + + if (params.hasFocusedEdges) { + adjustedBudget = Math.max(adjustedBudget, FOCUSED_MIN_BUDGET); + } + + return Math.max(48, adjustedBudget); +} + +function sampleEvenly(items: T[], limit: number): T[] { + if (items.length <= limit) { + return items; + } + if (limit <= 0) { + return []; + } + + const sampled: T[] = []; + for (let index = 0; index < limit; index += 1) { + const itemIndex = Math.min(items.length - 1, Math.floor((index * items.length) / limit)); + sampled.push(items[itemIndex]); + } + return sampled; +} + +export function selectRenderableParticles(params: { + particles: GraphParticle[]; + visibleEdgeIds: ReadonlySet; + focusEdgeIds?: ReadonlySet | null; + budget: number; +}): GraphParticle[] { + const visibleParticles = params.particles.filter( + (particle) => + params.visibleEdgeIds.has(particle.edgeId) || + (params.focusEdgeIds?.has(particle.edgeId) ?? false) + ); + if (visibleParticles.length <= params.budget) { + return visibleParticles; + } + + const indexed = visibleParticles.map((particle, index) => ({ particle, index })); + const focused = params.focusEdgeIds + ? indexed.filter(({ particle }) => params.focusEdgeIds?.has(particle.edgeId)) + : []; + const nonFocused = focused.length === indexed.length + ? [] + : indexed.filter(({ particle }) => !(params.focusEdgeIds?.has(particle.edgeId) ?? false)); + + const selectedById = new Set(); + const seenEdges = new Set(); + const seed: Array<{ particle: GraphParticle; index: number }> = []; + + for (const pool of [focused, nonFocused]) { + for (let cursor = pool.length - 1; cursor >= 0; cursor -= 1) { + const candidate = pool[cursor]; + if (seenEdges.has(candidate.particle.edgeId)) { + continue; + } + seenEdges.add(candidate.particle.edgeId); + selectedById.add(candidate.particle.id); + seed.push(candidate); + } + } + + const seedSorted = seed.sort((left, right) => left.index - right.index); + if (seedSorted.length >= params.budget) { + return sampleEvenly(seedSorted, params.budget).map(({ particle }) => particle); + } + + const remaining = indexed.filter(({ particle }) => !selectedById.has(particle.id)); + const remainingBudget = params.budget - seedSorted.length; + const extra = sampleEvenly(remaining, remainingBudget); + + return [...seedSorted, ...extra] + .sort((left, right) => left.index - right.index) + .map(({ particle }) => particle); +} diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 3bb496cb..9dd764a0 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -18,7 +18,7 @@ import { import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { collapseOverflowStacks } from '../utils/collapseOverflowStacks'; +import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks'; import { isTaskBlocked, isTaskInReviewCycle, @@ -47,8 +47,10 @@ export class TeamGraphAdapter { readonly #seenRelated = new Set(); readonly #seenMessageIds = new Set(); #initialMessagesSeen = false; + #messageParticleCutoffMs: number | null = null; readonly #seenCommentCounts = new Map(); #initialCommentsSeen = false; + #commentParticleCutoffMs: number | null = null; // ─── Static factory ────────────────────────────────────────────────────── static create(): TeamGraphAdapter { @@ -84,8 +86,10 @@ export class TeamGraphAdapter { if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#messageParticleCutoffMs = null; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; + this.#commentParticleCutoffMs = null; } this.#lastTeamName = teamName; @@ -152,8 +156,10 @@ export class TeamGraphAdapter { this.#seenRelated.clear(); this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#messageParticleCutoffMs = null; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; + this.#commentParticleCutoffMs = null; this.#lastTeamName = ''; } @@ -163,6 +169,14 @@ export class TeamGraphAdapter { return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; } + static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean { + if (!timestamp || cutoffMs == null) { + return false; + } + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) && parsed < cutoffMs; + } + static #getRuntimeLabel( providerId: TeamData['members'][number]['providerId'], model: TeamData['members'][number]['model'], @@ -425,7 +439,8 @@ export class TeamGraphAdapter { }); } - const visibleTaskNodes = collapseOverflowStacks(rawTaskNodes, teamName, 6); + const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } = + collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 6); const visibleTaskIds = new Set( visibleTaskNodes.flatMap((taskNode) => taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] @@ -444,37 +459,60 @@ export class TeamGraphAdapter { }); } - const seenBlockingEdges = new Set(); + const seenBlockingRelations = new Set(); + const blockingEdges = new Map< + string, + { + source: string; + target: string; + aggregateCount: number; + sourceTaskIds: Set; + targetTaskIds: Set; + } + >(); + const addBlockingRelation = (blockerId: string, blockedId: string): void => { + if (blockerId === blockedId) return; + const rawRelationKey = `${blockerId}->${blockedId}`; + if (seenBlockingRelations.has(rawRelationKey)) return; + seenBlockingRelations.add(rawRelationKey); + + const sourceNodeId = visibleNodeIdByTaskId.get(blockerId); + const targetNodeId = visibleNodeIdByTaskId.get(blockedId); + if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) { + return; + } + + const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(sourceNodeId, targetNodeId); + const existing = blockingEdges.get(edgeId); + if (existing) { + existing.aggregateCount += 1; + existing.sourceTaskIds.add(blockerId); + existing.targetTaskIds.add(blockedId); + return; + } + blockingEdges.set(edgeId, { + source: sourceNodeId, + target: targetNodeId, + aggregateCount: 1, + sourceTaskIds: new Set([blockerId]), + targetTaskIds: new Set([blockedId]), + }); + }; + for (const task of data.tasks) { - if (task.status === 'deleted' || !visibleTaskIds.has(task.id)) continue; + if (task.status === 'deleted') continue; const taskNodeId = `task:${teamName}:${task.id}`; for (const blockerId of task.blockedBy ?? []) { - if (!visibleTaskIds.has(blockerId)) continue; - const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, blockerId, task.id); - if (seenBlockingEdges.has(edgeId)) continue; - seenBlockingEdges.add(edgeId); - edges.push({ - id: edgeId, - source: `task:${teamName}:${blockerId}`, - target: taskNodeId, - type: 'blocking', - }); + addBlockingRelation(blockerId, task.id); } for (const blockedId of task.blocks ?? []) { - if (!visibleTaskIds.has(blockedId)) continue; - const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, task.id, blockedId); - if (seenBlockingEdges.has(edgeId)) continue; - seenBlockingEdges.add(edgeId); - edges.push({ - id: edgeId, - source: taskNodeId, - target: `task:${teamName}:${blockedId}`, - type: 'blocking', - }); + addBlockingRelation(task.id, blockedId); } + if (!visibleTaskIds.has(task.id)) continue; + for (const relatedId of task.related ?? []) { if (!visibleTaskIds.has(relatedId)) continue; const key = [task.id, relatedId].sort().join(':'); @@ -488,6 +526,23 @@ export class TeamGraphAdapter { }); } } + + edges.push( + ...Array.from(blockingEdges.entries()).map(([edgeId, edge]) => ({ + id: edgeId, + source: edge.source, + target: edge.target, + type: 'blocking' as const, + aggregateCount: edge.aggregateCount, + sourceTaskIds: Array.from(edge.sourceTaskIds), + targetTaskIds: Array.from(edge.targetTaskIds), + label: + edge.aggregateCount > 1 && + (edge.source.includes(':overflow:') || edge.target.includes(':overflow:')) + ? `${edge.aggregateCount} hidden blocking links` + : undefined, + })) + ); } #buildProcessNodes( @@ -539,6 +594,7 @@ export class TeamGraphAdapter { // This prevents old messages from spawning particles when the graph opens. if (!this.#initialMessagesSeen) { this.#initialMessagesSeen = true; + this.#messageParticleCutoffMs = Date.now(); for (const msg of ordered) { const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); this.#seenMessageIds.add(msgKey); @@ -560,6 +616,9 @@ export class TeamGraphAdapter { const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); + if (TeamGraphAdapter.#isBeforeParticleCutoff(msg.timestamp, this.#messageParticleCutoffMs)) { + continue; + } // Skip comment notifications — #buildCommentParticles handles them with real text if (msg.summary?.startsWith('Comment on ')) continue; @@ -659,6 +718,7 @@ export class TeamGraphAdapter { // This prevents pre-existing comments from spawning particles when the graph opens. if (!this.#initialCommentsSeen) { this.#initialCommentsSeen = true; + this.#commentParticleCutoffMs = Date.now(); for (const task of data.tasks) { this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0); } @@ -681,6 +741,14 @@ export class TeamGraphAdapter { for (let index = prevCount; index < currentCount; index += 1) { const newComment = task.comments?.[index]; if (!newComment) continue; + if ( + TeamGraphAdapter.#isBeforeParticleCutoff( + newComment.createdAt, + this.#commentParticleCutoffMs + ) + ) { + continue; + } const authorNodeId = TeamGraphAdapter.#resolveParticipantId( newComment.author, teamName, @@ -726,8 +794,8 @@ export class TeamGraphAdapter { // ─── Static mappers ────────────────────────────────────────────────────── - static #buildBlockingEdgeId(teamName: string, blockerId: string, blockedId: string): string { - return `edge:block:task:${teamName}:${blockerId}:task:${teamName}:${blockedId}`; + static #buildBlockingEdgeId(sourceNodeId: string, targetNodeId: string): string { + return `edge:block:${sourceNodeId}:${targetNodeId}`; } static #buildMemberException( diff --git a/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx b/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx new file mode 100644 index 00000000..d9f56ab0 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx @@ -0,0 +1,207 @@ +import { useMemo } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; +import type { TeamTaskWithKanban } from '@shared/types'; + +function isTaskNode(node: GraphNode | undefined): node is GraphNode & { + domainRef: Extract; +} { + return node?.kind === 'task' && node.domainRef.kind === 'task'; +} + +function isOverflowNode( + node: GraphNode | undefined +): node is GraphNode & { isOverflowStack: true } { + return Boolean(node?.kind === 'task' && node.isOverflowStack); +} + +function describeNode(node: GraphNode | undefined, fallback: string): string { + if (!node) return fallback; + if (isOverflowNode(node)) { + return node.overflowCount && node.overflowCount > 1 + ? `${node.overflowCount} hidden tasks` + : 'Hidden task stack'; + } + if (isTaskNode(node)) { + return `${node.displayId ?? node.label} - ${node.sublabel ?? 'Task'}`; + } + return node.label; +} + +function getActionLabel(node: GraphNode | undefined, role: 'blocker' | 'blocked'): string | null { + if (!node) return null; + if (isOverflowNode(node)) { + return role === 'blocker' ? 'Open blocker stack' : 'Open blocked stack'; + } + if (isTaskNode(node)) { + return role === 'blocker' ? 'Open blocker task' : 'Open blocked task'; + } + return null; +} + +export interface GraphBlockingEdgePopoverProps { + teamName: string; + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; + onSelectNode: (nodeId: string) => void; + onOpenTaskDetail?: (taskId: string) => void; +} + +export function GraphBlockingEdgePopover({ + teamName, + edge, + sourceNode, + targetNode, + onClose, + onSelectNode, + onOpenTaskDetail, +}: GraphBlockingEdgePopoverProps): React.JSX.Element { + const teamData = useStore((state) => selectTeamDataForName(state, teamName)); + const tasksById = useMemo( + () => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)), + [teamData?.tasks] + ); + const relationCount = edge.aggregateCount ?? 1; + const sourceLabel = describeNode(sourceNode, edge.source); + const targetLabel = describeNode(targetNode, edge.target); + const sourceActionLabel = getActionLabel(sourceNode, 'blocker'); + const targetActionLabel = getActionLabel(targetNode, 'blocked'); + const sourceHiddenTasks = resolveEdgeTaskPreview(sourceNode, edge.sourceTaskIds, tasksById); + const targetHiddenTasks = resolveEdgeTaskPreview(targetNode, edge.targetTaskIds, tasksById); + + const openSource = (): void => { + if (isTaskNode(sourceNode)) { + onOpenTaskDetail?.(sourceNode.domainRef.taskId); + onClose(); + return; + } + if (sourceNode) { + onSelectNode(sourceNode.id); + } + }; + + const openTarget = (): void => { + if (isTaskNode(targetNode)) { + onOpenTaskDetail?.(targetNode.domainRef.taskId); + onClose(); + return; + } + if (targetNode) { + onSelectNode(targetNode.id); + } + }; + + return ( +
+
+
+ Blocking Dependency +
+ {relationCount > 1 && ( + + {relationCount} links + + )} +
+ +
+
{sourceLabel}
+ {sourceHiddenTasks.length > 0 && ( + + )} +
blocks
+
{targetLabel}
+ {targetHiddenTasks.length > 0 && ( + + )} +
+ +
+ {sourceActionLabel && ( + + )} + {targetActionLabel && ( + + )} + +
+
+ ); +} + +function resolveEdgeTaskPreview( + node: GraphNode | undefined, + edgeTaskIds: string[] | undefined, + tasksById: ReadonlyMap +): TeamTaskWithKanban[] { + if (!node || !isOverflowNode(node)) { + return []; + } + + const candidateIds = + edgeTaskIds && edgeTaskIds.length > 0 ? edgeTaskIds : (node.overflowTaskIds ?? []); + + return candidateIds + .map((taskId) => tasksById.get(taskId) ?? null) + .filter((task): task is TeamTaskWithKanban => task != null) + .slice(0, 4); +} + +function HiddenTaskPreview({ + title, + tasks, + onOpenTaskDetail, + onClose, +}: { + title: string; + tasks: TeamTaskWithKanban[]; + onOpenTaskDetail?: (taskId: string) => void; + onClose: () => void; +}): React.JSX.Element { + return ( +
+
{title}
+
+ {tasks.map((task) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 84fe1f8b..954767d2 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -10,6 +10,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; +import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphNodePopover } from './GraphNodePopover'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; @@ -84,6 +85,17 @@ export const TeamGraphOverlay = ({ onRequestClose={onClose} onRequestPinAsTab={onPinAsTab} className="min-w-0 flex-1" + renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => ( + + )} renderOverlay={({ node, onClose: closePopover }) => ( setFullscreen(true)} + renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => ( + + )} renderOverlay={({ node, onClose }) => ( ; +} + function resolveOverflowColumnKey(task: GraphNode): string { if (task.reviewState === 'approved') return 'approved'; if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; @@ -19,8 +24,23 @@ export function collapseOverflowStacks( teamName: string, maxVisibleRows: number ): GraphNode[] { + return collapseOverflowStacksWithMeta(taskNodes, teamName, maxVisibleRows).visibleNodes; +} + +export function collapseOverflowStacksWithMeta( + taskNodes: GraphNode[], + teamName: string, + maxVisibleRows: number +): OverflowCollapseResult { if (maxVisibleRows <= 1) { - return taskNodes; + return { + visibleNodes: taskNodes, + visibleNodeIdByTaskId: new Map( + taskNodes.flatMap((task) => + task.domainRef.kind === 'task' ? [[task.domainRef.taskId, task.id] as const] : [] + ) + ), + }; } const grouped = new Map(); @@ -38,11 +58,17 @@ export function collapseOverflowStacks( } const visibleTasks: GraphNode[] = []; + const visibleNodeIdByTaskId = new Map(); for (const groupKey of groupOrder) { const groupTasks = grouped.get(groupKey) ?? []; if (groupTasks.length <= maxVisibleRows) { visibleTasks.push(...groupTasks); + for (const task of groupTasks) { + if (task.domainRef.kind === 'task') { + visibleNodeIdByTaskId.set(task.domainRef.taskId, task.id); + } + } continue; } @@ -53,21 +79,37 @@ export function collapseOverflowStacks( const ownerMemberName = extractOwnerMemberName(representative, teamName); visibleTasks.push(...keptTasks); + for (const task of keptTasks) { + if (task.domainRef.kind === 'task') { + visibleNodeIdByTaskId.set(task.domainRef.taskId, task.id); + } + } + + const stackNodeId = `task:${teamName}:overflow:${groupKey}`; + const overflowTaskIds = hiddenTasks.flatMap((task) => + task.domainRef.kind === 'task' ? [task.domainRef.taskId] : [] + ); + for (const taskId of overflowTaskIds) { + visibleNodeIdByTaskId.set(taskId, stackNodeId); + } + visibleTasks.push({ id: `task:${teamName}:overflow:${groupKey}`, kind: 'task', label: `+${hiddenTasks.length}`, - state: 'waiting', + state: representative.state, displayId: `+${hiddenTasks.length}`, sublabel: `${hiddenTasks.length} more tasks`, ownerId: representative.ownerId ?? null, taskStatus: representative.taskStatus, reviewState: representative.reviewState, + changePresence: hiddenTasks.some((task) => task.changePresence === 'has_changes') + ? 'has_changes' + : undefined, + isBlocked: hiddenTasks.some((task) => task.isBlocked), isOverflowStack: true, overflowCount: hiddenTasks.length, - overflowTaskIds: hiddenTasks.flatMap((task) => - task.domainRef.kind === 'task' ? [task.domainRef.taskId] : [] - ), + overflowTaskIds, domainRef: { kind: 'task_overflow', teamName, @@ -77,5 +119,8 @@ export function collapseOverflowStacks( }); } - return visibleTasks; + return { + visibleNodes: visibleTasks, + visibleNodeIdByTaskId, + }; } diff --git a/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts b/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts new file mode 100644 index 00000000..8740ecb6 --- /dev/null +++ b/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts @@ -0,0 +1,195 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useStore } from '@renderer/store'; +import { GraphBlockingEdgePopover } from '@renderer/features/agent-graph/ui/GraphBlockingEdgePopover'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: { children: React.ReactNode }) => + React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => React.createElement('button', { type: 'button', onClick }, children), +})); + +const sourceNode: GraphNode = { + id: 'task:my-team:overflow:alice:todo', + kind: 'task', + label: '+2', + state: 'waiting', + ownerId: 'member:my-team:alice', + taskStatus: 'pending', + reviewState: 'none', + isOverflowStack: true, + overflowCount: 2, + overflowTaskIds: ['task-hidden-1', 'task-hidden-2'], + domainRef: { + kind: 'task_overflow', + teamName: 'my-team', + ownerMemberName: 'alice', + columnKey: 'todo', + }, +}; + +const targetNode: GraphNode = { + id: 'task:my-team:task-visible', + kind: 'task', + label: '#8', + displayId: '#8', + sublabel: 'Visible blocked task', + state: 'waiting', + ownerId: 'member:my-team:bob', + taskStatus: 'pending', + reviewState: 'none', + domainRef: { kind: 'task', teamName: 'my-team', taskId: 'task-visible' }, +}; + +const edge: GraphEdge = { + id: 'edge:block:test', + source: sourceNode.id, + target: targetNode.id, + type: 'blocking', + aggregateCount: 2, + sourceTaskIds: ['task-hidden-1', 'task-hidden-2'], + targetTaskIds: ['task-visible'], +}; + +describe('GraphBlockingEdgePopover', () => { + afterEach(() => { + document.body.innerHTML = ''; + useStore.setState({ + selectedTeamName: null, + selectedTeamData: null, + teamDataCacheByName: {}, + } as never); + vi.unstubAllGlobals(); + }); + + it('renders the participating hidden tasks for aggregated overflow blockers', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-hidden-1', + displayId: '#1', + subject: 'Hidden blocker one', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-hidden-2', + displayId: '#2', + subject: 'Hidden blocker two', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-visible', + displayId: '#8', + subject: 'Visible blocked task', + owner: 'bob', + status: 'pending', + reviewState: 'none', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-hidden-1', + displayId: '#1', + subject: 'Hidden blocker one', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-hidden-2', + displayId: '#2', + subject: 'Hidden blocker two', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-visible', + displayId: '#8', + subject: 'Visible blocked task', + owner: 'bob', + status: 'pending', + reviewState: 'none', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + } as never); + + const onOpenTaskDetail = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(GraphBlockingEdgePopover, { + teamName: 'my-team', + edge, + sourceNode, + targetNode, + onClose: vi.fn(), + onSelectNode: vi.fn(), + onOpenTaskDetail, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Blocking hidden tasks'); + expect(host.textContent).toContain('#1 - Hidden blocker one'); + expect(host.textContent).toContain('#2 - Hidden blocker two'); + + const hiddenTaskButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('#1 - Hidden blocker one') + ); + expect(hiddenTaskButton).toBeTruthy(); + + await act(async () => { + hiddenTaskButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenTaskDetail).toHaveBeenCalledWith('task-hidden-1'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 0464ea92..e454d4e6 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter'; @@ -59,6 +59,15 @@ function findNode(graph: GraphDataPort, nodeId: string) { } describe('TeamGraphAdapter particles', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-28T19:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('creates a message particle for a new incoming message from the newest message set', () => { const adapter = TeamGraphAdapter.create(); const baseline = createBaseTeamData(); @@ -135,6 +144,80 @@ describe('TeamGraphAdapter particles', () => { }); }); + it('does not replay old inbox messages that arrive after the graph already opened', () => { + vi.setSystemTime(new Date('2026-03-28T19:00:10.000Z')); + + const adapter = TeamGraphAdapter.create(); + adapter.adapt(createBaseTeamData(), 'my-team'); + + const graph = adapter.adapt( + createBaseTeamData({ + messages: [ + { + from: 'alice', + to: 'team-lead', + text: 'Old backlog message', + timestamp: '2026-03-28T19:00:01.000Z', + read: false, + messageId: 'msg-old', + }, + ], + }), + 'my-team' + ); + + expect(graph.particles).toHaveLength(0); + }); + + it('does not replay old task comments that appear after the graph already opened', () => { + vi.setSystemTime(new Date('2026-03-28T19:00:10.000Z')); + + const adapter = TeamGraphAdapter.create(); + adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-old-comment', + displayId: '#9', + subject: 'Review backlog', + owner: 'alice', + status: 'in_progress', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-old-comment', + displayId: '#9', + subject: 'Review backlog', + owner: 'alice', + status: 'in_progress', + comments: [ + { + id: 'comment-old', + author: 'alice', + text: 'Old backlog comment', + createdAt: '2026-03-28T19:00:01.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(graph.particles).toHaveLength(0); + }); + it('creates a synthetic message edge for comments from non-owner participants', () => { const adapter = TeamGraphAdapter.create(); const baseline = createBaseTeamData({ @@ -687,6 +770,51 @@ describe('TeamGraphAdapter particles', () => { expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false); }); + it('aggregates blocking edges through overflow stacks so hidden blockers stay visible', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + ...Array.from({ length: 7 }, (_, index) => ({ + id: `task-a-${index + 1}`, + displayId: `#A${index + 1}`, + subject: `Alice task ${index + 1}`, + owner: 'alice', + status: 'pending', + reviewState: 'none', + blocks: index >= 5 ? ['task-b-1'] : [], + })), + { + id: 'task-b-1', + displayId: '#B1', + subject: 'Visible blocked task', + owner: 'bob', + status: 'pending', + reviewState: 'none', + blockedBy: ['task-a-6', 'task-a-7'], + } as TeamTaskWithKanban, + ] as TeamTaskWithKanban[], + }), + 'my-team' + ); + + const overflowNode = graph.nodes.find( + (node) => node.kind === 'task' && node.isOverflowStack && node.ownerId === 'member:my-team:alice' + ); + const blockingEdges = graph.edges.filter((edge) => edge.type === 'blocking'); + + expect(overflowNode).toBeDefined(); + expect(blockingEdges).toContainEqual( + expect.objectContaining({ + source: overflowNode?.id, + target: 'task:my-team:task-b-1', + aggregateCount: 2, + sourceTaskIds: ['task-a-6', 'task-a-7'], + targetTaskIds: ['task-b-1'], + }) + ); + }); + it('adds compact review handoff metadata for active review tasks', () => { const adapter = TeamGraphAdapter.create(); const graph = adapter.adapt( diff --git a/test/renderer/features/agent-graph/buildFocusState.test.ts b/test/renderer/features/agent-graph/buildFocusState.test.ts index a5ae5502..8e529173 100644 --- a/test/renderer/features/agent-graph/buildFocusState.test.ts +++ b/test/renderer/features/agent-graph/buildFocusState.test.ts @@ -118,7 +118,7 @@ const nodes = [leadNode, aliceNode, bobNode, blockerNode, reviewTaskNode, overfl describe('buildFocusState', () => { it('focuses task selection on its owner, reviewer, direct blockers, and connecting edges', () => { - const focus = buildFocusState(reviewTaskNode.id, nodes, edges); + const focus = buildFocusState(reviewTaskNode.id, null, nodes, edges); expect(Array.from(focus.focusNodeIds ?? []).sort()).toEqual( [ @@ -141,7 +141,7 @@ describe('buildFocusState', () => { }); it('includes review-assigned tasks and owned overflow stacks when focusing a member', () => { - const focus = buildFocusState(bobNode.id, nodes, edges); + const focus = buildFocusState(bobNode.id, null, nodes, edges); expect(focus.focusNodeIds?.has(bobNode.id)).toBe(true); expect(focus.focusNodeIds?.has(reviewTaskNode.id)).toBe(true); @@ -149,12 +149,12 @@ describe('buildFocusState', () => { expect(focus.focusEdgeIds?.has('edge:parent:lead:bob')).toBe(true); expect(focus.focusEdgeIds?.has('edge:own:alice:review')).toBe(true); - const aliceFocus = buildFocusState(aliceNode.id, nodes, edges); + const aliceFocus = buildFocusState(aliceNode.id, null, nodes, edges); expect(aliceFocus.focusNodeIds?.has(overflowNode.id)).toBe(true); }); it('focuses a lead on direct neighbors only', () => { - const focus = buildFocusState(leadNode.id, nodes, edges); + const focus = buildFocusState(leadNode.id, null, nodes, edges); expect(focus.focusNodeIds).toEqual( new Set([leadNode.id, aliceNode.id, bobNode.id]) @@ -165,9 +165,26 @@ describe('buildFocusState', () => { }); it('does not enable global dimming for overflow stack selections', () => { - const focus = buildFocusState(overflowNode.id, nodes, edges); + const focus = buildFocusState(overflowNode.id, null, nodes, edges); expect(focus.focusNodeIds).toBeNull(); expect(focus.focusEdgeIds).toBeNull(); }); + + it('focuses the connected blocking chain when a blocking edge is selected', () => { + const focus = buildFocusState(null, 'edge:block:blocker:review', nodes, edges); + + expect(focus.focusNodeIds).toEqual( + new Set([leadNode.id, aliceNode.id, bobNode.id, blockerNode.id, reviewTaskNode.id]) + ); + expect(focus.focusEdgeIds).toEqual( + new Set([ + 'edge:block:blocker:review', + 'edge:own:alice:blocker', + 'edge:own:alice:review', + 'edge:parent:lead:alice', + 'edge:parent:lead:bob', + ]) + ); + }); }); diff --git a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts index e9a635b4..a3e637b5 100644 --- a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts +++ b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { collapseOverflowStacks } from '@renderer/features/agent-graph/utils/collapseOverflowStacks'; +import { + collapseOverflowStacks, + collapseOverflowStacksWithMeta, +} from '@renderer/features/agent-graph/utils/collapseOverflowStacks'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -73,4 +76,16 @@ describe('collapseOverflowStacks', () => { }, }); }); + + it('returns a visible-node mapping for hidden tasks behind the stack', () => { + const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`)); + + const result = collapseOverflowStacksWithMeta(nodes, 'my-team', 6); + const stackNode = result.visibleNodes.find((node) => node.isOverflowStack); + + expect(stackNode).toBeDefined(); + expect(result.visibleNodeIdByTaskId.get('task-1')).toBe('task:my-team:task-1'); + expect(result.visibleNodeIdByTaskId.get('task-6')).toBe(stackNode?.id); + expect(result.visibleNodeIdByTaskId.get('task-7')).toBe(stackNode?.id); + }); }); diff --git a/test/renderer/features/agent-graph/edgeHitDetection.test.ts b/test/renderer/features/agent-graph/edgeHitDetection.test.ts new file mode 100644 index 00000000..ba2d3ba1 --- /dev/null +++ b/test/renderer/features/agent-graph/edgeHitDetection.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectInteractiveEdgesInViewport, + findEdgeAt, + getEdgeMidpoint, +} from '../../../../packages/agent-graph/src/canvas/hit-detection'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + kind: id.startsWith('task') ? 'task' : 'member', + label: id, + state: 'idle', + x, + y, + domainRef: + id.startsWith('task') + ? { kind: 'task', teamName: 'my-team', taskId: id } + : { kind: 'member', teamName: 'my-team', memberName: id }, + } as GraphNode; +} + +describe('edge hit detection', () => { + it('detects blocking edges near the curve midpoint', () => { + const nodes = [ + makeNode('member:alice', 0, 0), + makeNode('task:1', 160, 90), + ]; + const nodeMap = new Map(nodes.map((node) => [node.id, node] as const)); + const edge: GraphEdge = { + id: 'edge:blocking', + source: 'member:alice', + target: 'task:1', + type: 'blocking', + }; + const midpoint = getEdgeMidpoint(edge, nodeMap); + + expect(midpoint).not.toBeNull(); + expect(findEdgeAt(midpoint!.x, midpoint!.y, [edge], nodeMap)).toBe('edge:blocking'); + }); + + it('prefers the closest edge when multiple curves overlap', () => { + const nodes = [ + makeNode('member:alice', 0, 0), + makeNode('task:1', 160, 90), + makeNode('task:2', 160, 150), + ]; + const nodeMap = new Map(nodes.map((node) => [node.id, node] as const)); + const edges: GraphEdge[] = [ + { id: 'edge:1', source: 'member:alice', target: 'task:1', type: 'ownership' }, + { id: 'edge:2', source: 'member:alice', target: 'task:2', type: 'ownership' }, + ]; + + const midpoint = getEdgeMidpoint(edges[0], nodeMap); + expect(midpoint).not.toBeNull(); + expect(findEdgeAt(midpoint!.x, midpoint!.y, edges, nodeMap)).toBe('edge:1'); + }); + + it('only keeps visible blocking edges as interactive hit-test candidates', () => { + const nodes = [ + makeNode('task:blocker', 0, 0), + makeNode('task:blocked', 180, 90), + makeNode('task:offscreen-a', 1200, 1200), + makeNode('task:offscreen-b', 1360, 1280), + ]; + const nodeMap = new Map(nodes.map((node) => [node.id, node] as const)); + const edges: GraphEdge[] = [ + { id: 'edge:blocking:visible', source: 'task:blocker', target: 'task:blocked', type: 'blocking' }, + { id: 'edge:blocking:hidden', source: 'task:offscreen-a', target: 'task:offscreen-b', type: 'blocking' }, + { id: 'edge:ownership', source: 'task:blocker', target: 'task:blocked', type: 'ownership' }, + ]; + + const interactive = collectInteractiveEdgesInViewport(edges, nodeMap, { + left: -200, + top: -200, + right: 400, + bottom: 260, + }); + + expect(interactive.map((edge) => edge.id)).toEqual(['edge:blocking:visible']); + }); +}); diff --git a/test/renderer/features/agent-graph/selectRenderableParticles.test.ts b/test/renderer/features/agent-graph/selectRenderableParticles.test.ts new file mode 100644 index 00000000..3e4ba3bf --- /dev/null +++ b/test/renderer/features/agent-graph/selectRenderableParticles.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeAdaptiveParticleBudget, + selectRenderableParticles, +} from '../../../../packages/agent-graph/src/ui/selectRenderableParticles'; + +import type { GraphParticle } from '@claude-teams/agent-graph'; + +function makeParticle(id: string, edgeId: string): GraphParticle { + return { + id, + edgeId, + progress: 0, + kind: 'inbox_message', + color: '#66ccff', + }; +} + +describe('selectRenderableParticles', () => { + it('keeps at least one particle per active visible edge when over budget', () => { + const particles = [ + makeParticle('p1', 'edge:a'), + makeParticle('p2', 'edge:a'), + makeParticle('p3', 'edge:b'), + makeParticle('p4', 'edge:b'), + makeParticle('p5', 'edge:c'), + makeParticle('p6', 'edge:c'), + ]; + + const selected = selectRenderableParticles({ + particles, + visibleEdgeIds: new Set(['edge:a', 'edge:b', 'edge:c']), + budget: 3, + }); + + expect(selected).toHaveLength(3); + expect(new Set(selected.map((particle) => particle.edgeId))).toEqual( + new Set(['edge:a', 'edge:b', 'edge:c']) + ); + }); + + it('does not spend budget on particles for offscreen edges', () => { + const selected = selectRenderableParticles({ + particles: [ + makeParticle('p1', 'edge:a'), + makeParticle('p2', 'edge:b'), + makeParticle('p3', 'edge:c'), + ], + visibleEdgeIds: new Set(['edge:b']), + budget: 10, + }); + + expect(selected).toEqual([expect.objectContaining({ id: 'p2', edgeId: 'edge:b' })]); + }); +}); + +describe('computeAdaptiveParticleBudget', () => { + it('reduces budget when frame time is already high', () => { + const fastBudget = computeAdaptiveParticleBudget({ + visibleNodeCount: 30, + visibleEdgeCount: 20, + frameTimeMs: 8, + hasFocusedEdges: false, + }); + const slowBudget = computeAdaptiveParticleBudget({ + visibleNodeCount: 30, + visibleEdgeCount: 20, + frameTimeMs: 26, + hasFocusedEdges: false, + }); + + expect(slowBudget).toBeLessThan(fastBudget); + expect(slowBudget).toBeGreaterThan(0); + }); +}); From 32cea2a927b59108ac6a06cada250096d9027332 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 22:13:43 +0300 Subject: [PATCH 04/21] feat(task-logs): add board task activity and task log stream --- docs/iterations/README.md | 3 +- ...-07-task-logs-explicit-board-task-links.md | 2630 +++++++++++++++++ ...exact-task-logs-reuse-existing-renderer.md | 1768 +++++++++++ .../board-task-transcript-v1.schema.json | 192 ++ scripts/diagnose-task-log-stream.ts | 92 + src/main/ipc/handlers.ts | 14 +- src/main/ipc/teams.ts | 170 +- src/main/services/team/TaskBoundaryParser.ts | 13 +- .../services/team/TeamMemberLogsFinder.ts | 27 +- src/main/services/team/agentTeamsToolNames.ts | 43 + src/main/services/team/index.ts | 5 + .../activity/BoardTaskActivityEntryBuilder.ts | 83 + .../activity/BoardTaskActivityParseCache.ts | 54 + .../activity/BoardTaskActivityRecord.ts | 25 + .../BoardTaskActivityRecordBuilder.ts | 382 +++ .../activity/BoardTaskActivityRecordSource.ts | 37 + .../activity/BoardTaskActivityService.ts | 21 + .../BoardTaskActivityTranscriptReader.ts | 125 + .../team/taskLogs/activity/featureGates.ts | 18 + .../contract/BoardTaskTranscriptContract.ts | 308 ++ .../BoardTaskLogDiagnosticsService.ts | 400 +++ .../discovery/TeamTranscriptSourceLocator.ts | 165 ++ .../exact/BoardTaskExactLogChunkBuilder.ts | 11 + .../exact/BoardTaskExactLogDetailSelector.ts | 364 +++ .../exact/BoardTaskExactLogDetailService.ts | 76 + .../exact/BoardTaskExactLogStrictParser.ts | 106 + .../exact/BoardTaskExactLogSummarySelector.ts | 227 ++ .../taskLogs/exact/BoardTaskExactLogTypes.ts | 77 + .../exact/BoardTaskExactLogsParseCache.ts | 35 + .../exact/BoardTaskExactLogsService.ts | 63 + .../team/taskLogs/exact/featureGates.ts | 18 + .../team/taskLogs/exact/fileVersions.ts | 33 + .../stream/BoardTaskLogStreamService.ts | 858 ++++++ src/preload/constants/ipcChannels.ts | 12 + src/preload/index.ts | 53 +- src/renderer/api/httpClient.ts | 23 + .../team/dialogs/TaskDetailDialog.tsx | 38 +- .../team/taskLogs/ExactTaskLogCard.tsx | 132 + .../team/taskLogs/ExactTaskLogsSection.tsx | 262 ++ .../taskLogs/ExecutionSessionsSection.tsx | 48 + .../team/taskLogs/TaskActivitySection.tsx | 211 ++ .../team/taskLogs/TaskLogStreamSection.tsx | 222 ++ .../team/taskLogs/TaskLogsPanel.tsx | 55 + .../components/team/taskLogs/featureGates.ts | 22 + src/shared/types/api.ts | 18 +- src/shared/utils/boardTaskActivityLabels.ts | 128 + test/main/ipc/teams.test.ts | 236 +- .../BoardTaskActivityEntryBuilder.test.ts | 427 +++ .../BoardTaskActivityRecordSource.test.ts | 82 + .../BoardTaskActivityTranscriptReader.test.ts | 67 + .../BoardTaskExactLogChunkBuilder.test.ts | 40 + .../BoardTaskExactLogDetailSelector.test.ts | 305 ++ .../BoardTaskExactLogDetailService.test.ts | 212 ++ .../BoardTaskExactLogStrictParser.test.ts | 47 + .../BoardTaskExactLogSummarySelector.test.ts | 145 + .../team/BoardTaskExactLogsService.test.ts | 82 + .../BoardTaskLogDiagnosticsService.test.ts | 311 ++ .../team/BoardTaskLogStream.live.test.ts | 72 + .../BoardTaskLogStreamIntegration.test.ts | 380 +++ .../team/BoardTaskLogStreamService.test.ts | 639 ++++ .../team/BoardTaskTranscriptContract.test.ts | 196 ++ .../services/team/TaskBoundaryParser.test.ts | 47 + .../team/TeamMemberLogsFinder.test.ts | 28 + .../api/httpClient.exactTaskLogs.test.ts | 37 + .../taskLogs/ExactTaskLogsSection.test.ts | 288 ++ .../TaskLogStreamSection.integration.test.ts | 550 ++++ .../TaskLogStreamSection.live.test.ts | 107 + .../taskLogs/TaskLogStreamSection.test.ts | 223 ++ 68 files changed, 14114 insertions(+), 74 deletions(-) create mode 100644 docs/iterations/iteration-07-task-logs-explicit-board-task-links.md create mode 100644 docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md create mode 100644 docs/iterations/schemas/board-task-transcript-v1.schema.json create mode 100644 scripts/diagnose-task-log-stream.ts create mode 100644 src/main/services/team/agentTeamsToolNames.ts create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts create mode 100644 src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts create mode 100644 src/main/services/team/taskLogs/activity/featureGates.ts create mode 100644 src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts create mode 100644 src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts create mode 100644 src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts create mode 100644 src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts create mode 100644 src/main/services/team/taskLogs/exact/featureGates.ts create mode 100644 src/main/services/team/taskLogs/exact/fileVersions.ts create mode 100644 src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts create mode 100644 src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx create mode 100644 src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx create mode 100644 src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx create mode 100644 src/renderer/components/team/taskLogs/TaskActivitySection.tsx create mode 100644 src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx create mode 100644 src/renderer/components/team/taskLogs/TaskLogsPanel.tsx create mode 100644 src/renderer/components/team/taskLogs/featureGates.ts create mode 100644 src/shared/utils/boardTaskActivityLabels.ts create mode 100644 test/main/services/team/BoardTaskActivityEntryBuilder.test.ts create mode 100644 test/main/services/team/BoardTaskActivityRecordSource.test.ts create mode 100644 test/main/services/team/BoardTaskActivityTranscriptReader.test.ts create mode 100644 test/main/services/team/BoardTaskExactLogChunkBuilder.test.ts create mode 100644 test/main/services/team/BoardTaskExactLogDetailSelector.test.ts create mode 100644 test/main/services/team/BoardTaskExactLogDetailService.test.ts create mode 100644 test/main/services/team/BoardTaskExactLogStrictParser.test.ts create mode 100644 test/main/services/team/BoardTaskExactLogSummarySelector.test.ts create mode 100644 test/main/services/team/BoardTaskExactLogsService.test.ts create mode 100644 test/main/services/team/BoardTaskLogDiagnosticsService.test.ts create mode 100644 test/main/services/team/BoardTaskLogStream.live.test.ts create mode 100644 test/main/services/team/BoardTaskLogStreamIntegration.test.ts create mode 100644 test/main/services/team/BoardTaskLogStreamService.test.ts create mode 100644 test/main/services/team/BoardTaskTranscriptContract.test.ts create mode 100644 test/renderer/api/httpClient.exactTaskLogs.test.ts create mode 100644 test/renderer/components/team/taskLogs/ExactTaskLogsSection.test.ts create mode 100644 test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts create mode 100644 test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts create mode 100644 test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts diff --git a/docs/iterations/README.md b/docs/iterations/README.md index e809b9f9..efac21c7 100644 --- a/docs/iterations/README.md +++ b/docs/iterations/README.md @@ -10,10 +10,11 @@ - [Итерация 04 — Messaging + Review](./iteration-04-messaging-review.md) - [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md) - [Итерация 06 — Team Provisioning (Create Team из UI)](./iteration-06-team-provisioning.md) +- [Iteration 07 - Task Logs + Explicit Board Task Links](./iteration-07-task-logs-explicit-board-task-links.md) +- [Iteration 08 - Exact Task Logs Reusing Existing Execution Renderer](./iteration-08-exact-task-logs-reuse-existing-renderer.md) ## Принципы - **Vertical slice**: в каждой итерации доводим минимум “end-to-end” (types → main → IPC → preload → renderer → UI) - **Чёткий scope**: у каждой итерации есть цели и не‑цели - **Definition of Done**: заранее фиксируем критерии готовности и ручную проверку - diff --git a/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md b/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md new file mode 100644 index 00000000..e1aa9032 --- /dev/null +++ b/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md @@ -0,0 +1,2630 @@ +# Iteration 07 - Task Logs + Explicit Board Task Links + +> Historical note +> This document captures the planned scope and architecture at iteration time. +> It is not the source of truth for the final runtime contract. + +This iteration introduces a **new explicit task activity model** for team board tasks and keeps the current session-based execution logs as a **separate legacy block**. + +The goal is to stop reconstructing `task -> logs` mostly from heuristics and instead persist a small, explicit, board-task-specific linkage in runtime transcripts, then build a clean read model for the task popup UI. + +This iteration spans **two repos**: +- `agent_teams_orchestrator` - write-side runtime and transcript contract +- `claude_team` - read-side task activity feed and UI integration + +--- + +## Decision Record + +### Chosen direction + +- **New `Task Activity` feed** +- **Keep old `Execution Sessions` block**, but explicitly treat it as legacy/session-centric +- **Persist explicit board-task links in transcript JSONL** +- **Build a read model on top of those links** + +### Why this was chosen + +- The current `Execution Logs` view is fundamentally **session-centric** +- The new requirement is **event-centric**: + - "show all logs/actions related to task A" + - including actions performed by another actor while they were actively working on task B +- Mixing both into one model makes both of them worse + +### Rejected alternatives + +- **Replace `Execution Logs` entirely with one new event timeline** + - Too risky for first rollout + - Would throw away useful current session features +- **Keep only the old session logic and improve heuristics** + - Not reliable enough + - Does not solve cross-task board actions correctly +- **Use one single `taskContext` object per message** + - Breaks on multi-target tools such as `task_link` + - Becomes ambiguous too quickly + +--- + +## Goals + +- Add a **new explicit activity feed** for board tasks +- Keep the current **execution session logs** available as a separate legacy block +- Make task-log linkage **structural**, not mainly heuristic +- Make the new feed **explicit-link only in v1** +- Support: + - task lifecycle events + - ordinary execution logs during active task work + - board actions performed on a task by another actor + - review flow actions + - multi-target task tools where relevant + +--- + +## Non-Goals + +- Replacing the existing `Workflow History` timeline +- Deleting the current `Execution Sessions` logic +- Rebuilding all historical logs retroactively +- Stamping ambiguous lead free-text execution in v1 +- Reworking built-in `TaskCreate` / `TaskUpdate` into this domain + +This iteration is for **board-task activity only**, not generic task tooling. + +--- + +## What We Fixed Before This Iteration + +Before implementing this iteration, we fixed a real false-negative in the current modern MCP task boundary detection: + +- fully-qualified tool names such as `mcp__agent-teams__task_start` +- alternate normalized names such as `mcp__agent_teams__task_complete` + +The fix was intentionally narrow: +- one canonicalization helper for agent-teams MCP tool names +- structural boundary detection now sees modern MCP task markers + +This is a prerequisite hardening step, not the main solution for the new feed. + +--- + +## Core Architectural Decision + +Use **two levels of model**, not one: + +### 1. Persisted wire contract + +The runtime writes small, explicit, additive transcript fields: + +- `boardTaskLinks?: BoardTaskLinkV1[]` +- `boardTaskToolActions?: BoardTaskToolActionV1[]` + +Together these fields capture the **minimum durable truth**: +- which board task(s) this message is linked to +- what kind of link each task has to the message +- how the actor's active task state relates to each task at that moment +- what board-task tool action(s) the message represents, when the message contains successful tool results + +They are **not** UI objects. + +### 2. Read model + +`claude_team` reads transcript entries and builds: + +- `BoardTaskActivityEntry` + +This is the UI-facing model for the new task activity feed. + +This separation keeps the runtime contract stable while allowing the UI to evolve. + +--- + +## Layering and Isolation Rules + +These rules are part of the design, not optional cleanup. + +### 1. Persisted contract is not a UI DTO + +`boardTaskLinks[]` must remain a small runtime fact model. + +It should not grow UI-only fields such as: +- display labels +- actor names +- timestamps duplicated from transcript entries +- section-level rendering hints + +### 2. The new feed must not depend on legacy heuristics in v1 + +The new `Task Activity` feed should read **explicit links only**. + +That means: +- no mention-based guessing +- no owner/session overlap inference +- no work-interval heuristics inside the new feed + +Legacy heuristics remain available only inside the legacy execution-sessions block. + +### 3. Keep the old session code, but isolate it + +Do **not** delete the current execution-session code. + +Do **not** comment it out either. + +Instead: +- keep it behind a separate service boundary +- keep it rendered in a separate UI section +- treat it as compatibility/session-exploration logic, not as the new source of truth + +### 4. The popup composes two read models, not one mixed model + +The task popup should compose: +- explicit event-level task activity +- legacy session-level execution browsing + +It should **not** merge both into one array or one card list. + +--- + +## Naming Decisions + +### Persisted fields + +Use: + +- `boardTaskLinks` +- `boardTaskToolActions` + +Do **not** use: + +- `taskContext` +- `boardTaskContext` + +Why: +- one message can legitimately link to **multiple board tasks** +- `task_link` and `task_unlink` are the clearest example +- plural naming makes the model honest + +### Persisted types + +Use: + +- `BoardTaskLinkV1` +- `BoardTaskLocator` +- `BoardTaskToolActionV1` + +### Read model + +Use: + +- shared DTO: `BoardTaskActivityEntry` +- main service: `BoardTaskActivityService` +- transcript discovery service: `TeamTranscriptSourceLocator` + +### Renderer names + +Use: + +- outer section label: `Task Logs` +- user-facing subsection label: `Task Activity` +- renderer component: `TaskActivitySection` +- composed container: `TaskLogsPanel` + +### Legacy/session block + +Use: + +- `Execution Sessions` + +This keeps the old block clearly separate from the new activity feed. + +### Why not `TaskActivityTimeline` as the main internal name + +The repo already has: +- `ActivityTimeline` for team inbox/message activity +- `Workflow History` / `StatusHistoryTimeline` for board-state history + +Using `TaskActivityTimeline` as the main internal component name would make the codebase harder to scan. + +So: +- `Task Logs` is the better outer section label +- `Task Activity` stays the user-facing subsection label +- `TaskActivitySection` is the better internal renderer name + +--- + +## Domain Boundaries + +### Included + +Board task domain only: +- `task_*` MCP tools that operate on board tasks +- `review_*` MCP tools tied to a board task + +### Excluded + +Do not include in the new core: +- built-in `TaskCreate` +- built-in `TaskUpdate` +- generic inbox/message/process tools without task target + +Those can remain as legacy/fallback logic where needed, but they are not part of the new activity core. + +--- + +## Persisted Wire Contract + +### Transcript field + +Add an optional field to transcript messages in `agent_teams_orchestrator`: + +```ts +type BoardTaskLocator = { + ref: string + refKind: 'canonical' | 'display' | 'unknown' + canonicalId?: string +} + +type BoardTaskLinkV1 = { + schemaVersion: 1 + + task: BoardTaskLocator + + taskArgumentSlot?: 'taskId' | 'targetId' + + toolUseId?: string + + linkKind: 'execution' | 'lifecycle' | 'board_action' + + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: BoardTaskLocator + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } +} + +type BoardTaskToolActionV1 = { + schemaVersion: 1 + toolUseId: string + canonicalToolName: string + input?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + owner?: string | null + relationship?: 'blocked-by' | 'blocks' | 'related' + clarification?: 'lead' | 'user' | null + reviewer?: string + commentId?: string + } + resultRefs?: { + commentId?: string + attachmentId?: string + filename?: string + } +} + +type TranscriptMessage = ExistingTranscriptMessage & { + boardTaskLinks?: BoardTaskLinkV1[] + boardTaskToolActions?: BoardTaskToolActionV1[] +} +``` + +### Why this shape + +- `task.ref` instead of unconditional `taskId` + - runtime input may contain display IDs + - do not lie about canonical identity + - store the normalized task reference without a leading `#` +- `schemaVersion` + - clearer than a generic nested `version` + - safer when transcript messages already contain their own top-level version fields +- `taskArgumentSlot` + - needed for multi-target tools + - aligns the persisted contract with the actual MCP input slots (`taskId` / `targetId`) + - clearer than `inputRole`, which is too easy to confuse with user/assistant message roles + - clearer than `toolArgumentRole`, because this is specifically the task-related argument slot + - should be omitted for ambient execution links that do not originate from a tool argument +- `toolUseId` + - needed to join task links to the exact `tool_result` block that produced them + - protects the contract when one transcript message contains multiple `tool_result` blocks +- `linkKind` + - distinguishes execution, lifecycle, and board actions +- `actorContext` + - captures the subtle "actor is currently active on another task" case +- `boardTaskToolActions` + - keeps message-level tool semantics out of the per-target link object + - avoids repeating the same tool metadata across multiple target links + - must be plural because a single user message can legitimately contain multiple `tool_result` blocks + - gives the read-side enough stable structure for rows such as owner/status/relationship/clarification changes without parsing free text + - can carry stable result references such as `commentId` / `attachmentId` when the tool returns them + - `canonicalToolName` should store the canonical bare board tool name after `agent-teams` MCP normalization + - `input` / `resultRefs` should stay minimal and semantic, not a dump of raw MCP input or raw tool result + - do not copy long free-text payloads such as comment text, review notes, or request-change prose into transcript metadata + - omit orchestration-only inputs already represented elsewhere, such as `from`, `actor`, `leadSessionId`, and `notifyOwner` + +### Important rule + +Do **not** duplicate in `boardTaskLinks` or `boardTaskToolActions`: +- timestamp +- sessionId +- agentId +- memberName +- teamName + +Those already exist on the transcript entry itself and should remain single-source. + +For read-side task popup queries, the team scope comes from the surrounding team-scoped query/file +discovery path, so `boardTaskLinks[]` does not need to repeat it. + +This is especially important because not every transcript path is guaranteed to stamp `teamName` +uniformly on every entry, particularly sidechain-oriented paths. + +### Metadata size budget + +The explicit contract must stay small enough to remain transcript-friendly. + +Recommended budget rules: +- at most one `BoardTaskToolActionV1` per `toolUseId` in one message +- keep `boardTaskLinks` to the minimal task-target set for that message +- never persist arbitrary free-text comment bodies, review prose, or task descriptions +- trim all persisted string identifiers +- suggested soft caps: + - `task.ref` / `canonicalId` / `toolUseId` / `canonicalToolName` - at most 128 chars + - `filename` - at most 256 chars + - enum-like fields only from explicit allow-lists + +If a value exceeds the budget: +- prefer omitting that optional field over truncating it into a misleading value +- for required identifiers, skip that object and emit debug diagnostics instead of persisting junk + +### Omit vs null policy + +Use omission by default for unknown or unavailable optional fields. + +Rules: +- use `undefined` / omitted for: + - `taskArgumentSlot` + - `toolUseId` on ambient execution links + - `canonicalId` when unresolved + - `actorContext.activeTask` + - `actorContext.activePhase` + - `actorContext.activeExecutionSeq` + - optional `input` / `resultRefs` fields that are not whitelisted for the current tool +- use explicit `null` only when the domain itself uses null as meaningful data: + - `input.owner = null` + - `input.clarification = null` + +Why: +- omission means "not available / not applicable" +- `null` means "explicitly cleared" +- mixing them loosely would make parser behavior and UI labels inconsistent + +### Invariants + +- every `boardTaskToolActions[*].toolUseId` should match at least one `boardTaskLinks[*].toolUseId` +- `boardTaskToolActions` must not appear without at least one `boardTaskLink` +- within one message, `boardTaskToolActions` should be unique by `toolUseId` +- `linkKind = 'execution'` is reserved for ambient execution rows in v1 +- `execution` links may carry `toolUseId` when they intentionally anchor a worker `tool_result` + row for exact task-log reconstruction +- therefore `execution` links should omit `taskArgumentSlot` +- `boardTaskToolActions` should only pair with sibling links whose `linkKind` is `lifecycle` or `board_action` +- `actorContext.activeTask` should only be set when `relation = 'other_active_task'` +- `actorContext.activePhase` / `actorContext.activeExecutionSeq` describe the actor's active scope, + not the target task's own identity +- for `linkKind = 'lifecycle'`, `actorContext` should reflect the actor state **before** the + lifecycle transition is applied +- within one message, emitted links should be unique by `(toolUseId ?? 'ambient', task.ref, taskArgumentSlot ?? 'none', linkKind)` +- ambient execution links should omit `taskArgumentSlot` +- tool-derived links should set `taskArgumentSlot = 'taskId'` for the primary task-argument slot +- `toolUseId` should still be omitted for ordinary conversational execution messages + +### Additive-safety note + +This is safe as additive transcript metadata because: +- `agent_teams_orchestrator` transcript messages already tolerate optional extra fields +- `claude_team` JSONL parsing is loose and ignores unknown fields until explicitly consumed + +### Version evolution policy + +- bump `schemaVersion` only for breaking meaning changes, not for additive optional fields +- additive optional fields within `BoardTaskLinkV1` / `BoardTaskToolActionV1` should remain on + version `1` +- a single message should not mix multiple schema versions for the same object family +- readers should accept the current version and ignore newer unknown versions object-by-object +- writers should emit exactly one stable version family at a time + +This keeps rollout and future migrations simple: +- old readers keep working by ignoring what they do not understand +- new readers can still salvage older transcript rows without rewriting history + +--- + +## Write-Side Emission Policy + +The runtime should emit explicit links only when it has reliable information. + +### Carrier-field rule + +On the write side, the cleanest implementation is to carry: + +- `boardTaskLinks?: BoardTaskLinkV1[]` +- `boardTaskToolActions?: BoardTaskToolActionV1[]` + +as internal transcript-only fields on runtime `Message` objects before persistence. + +Those carriers must be threaded through the message creation/normalization path for any message +types that can legitimately receive task metadata. + +That implies adding optional transcript-only fields to the orchestrator's internal message types, +not just to `TranscriptMessage`. + +This keeps the contract close to the message that will actually be persisted and avoids having a +separate side registry that can drift from message ordering. + +### Carrier propagation checkpoints + +The implementation should explicitly audit the runtime paths that rebuild messages rather than +assuming a new field on `TranscriptMessage` will survive automatically. + +At minimum, verify the carrier survives: +- message factory helpers such as `createUserMessage(...)` +- any assistant-message creation path that rebuilds plain objects +- message normalization paths that split multi-block messages into new message objects +- transcript logging cleanup paths before `insertMessageChain(...)` + +And the implementation should explicitly **not** leak transcript-only task metadata into: +- model payload normalization +- SDK/web message mappers +- any API-facing serialization path not intended for transcript persistence + +### V1 rules + +- stamp explicit task links on successful board-task `tool_result` messages +- stamp `boardTaskToolActions` only on successful board-task `tool_result` messages +- stamp ambient `execution` links only on ordinary conversational messages when the actor has exactly one active task +- do not rely on raw `tool_use` alone to claim lifecycle success +- do not attach ambient execution links to progress, attachment, system, or transcript-only meta scaffolding +- do not attach ambient execution links to assistant `tool_use` blocks or thinking-only assistant children after normalization +- do not ambient-stamp lead free-text execution in v1 +- dedupe lifecycle/action application by `(sessionId, agentId ?? 'main', toolUseId)` before mutating actor execution state or stamping transcript fields + +### Carrier placement matrix + +Allowed carrier placement by runtime message shape: + +- user `tool_result` message + - may carry `boardTaskLinks` + - may carry `boardTaskToolActions` +- ordinary user conversational message + - may carry ambient `boardTaskLinks` + - must not carry `boardTaskToolActions` +- ordinary assistant conversational message + - may carry ambient `boardTaskLinks` + - must not carry `boardTaskToolActions` +- assistant `tool_use` message + - must not carry either carrier family in v1 +- thinking-only assistant child + - must not carry either carrier family +- `progress`, `attachment`, `system`, `tombstone`, compact-boundary, and other non-conversational items + - must not carry either carrier family + +Read-side simplifying assumption enabled by this rule: +- `boardTaskToolActions` always means "this message contains a concrete successful board-tool result" +- ambient execution links only appear on human-readable conversational rows + +### Tool-result success matrix + +For v1 explicit stamping, treat a board-tool result as successful only when all of the following hold: + +- the message is a real user `tool_result` message, not a synthetic placeholder +- the `tool_result` block pairs to a real assistant `tool_use` +- the result is not an interrupt/reject/denial synthetic recovery block +- the execution outcome is semantically successful for that tool family + +Conservative success rules: +- paired MCP board-task tool result with no synthetic/error recovery markers + - emit `board_action` or `lifecycle` metadata +- paired board-task tool result that is denied, rejected, interrupted, synthetic, or otherwise unsuccessful + - emit no explicit board-task metadata in v1 +- unpaired `tool_result` + - emit no explicit board-task metadata in v1 +- ambient conversational message while one active task exists + - emit `execution` links only + +Important design choice: +- v1 does **not** model failed board actions as task-activity rows +- this is intentional to keep the first explicit feed highly reliable +- if failed-action visibility becomes important later, add a separate `failed_board_action` concept + instead of overloading the success-only v1 contract + +### Why this matters + +Tool success semantics differ across tool families, so the observer must decide after execution +outcome is known, not just from the attempted tool call. + +Also, some runtime paths - especially subagent-oriented ones - do not preserve rich structured +`toolUseResult` / `mcpMeta` all the way to transcript persistence. The explicit transcript fields +must therefore carry enough stable board-task semantics for the read-side to avoid reparsing +natural-language tool output. + +Just as importantly, repeated tool-result handling by the same `toolUseId` would create duplicated +lifecycle transitions and duplicated task-activity rows, so the observer has to dedupe before +state mutation. + +The `toolUseId` join key is also what keeps the contract correct when a single transcript message +contains more than one successful `tool_result` block. + +--- + +## Read Model + +`claude_team` should build a richer UI model: + +```ts +type BoardTaskActivityEntry = { + id: string + timestamp: string + + actor: { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string + } + + task: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + + linkKind: 'execution' | 'lifecycle' | 'board_action' + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } + + action: { + canonicalToolName?: string + toolUseId?: string + category: + | 'status' + | 'review' + | 'comment' + | 'assignment' + | 'read' + | 'attachment' + | 'relationship' + | 'clarification' + | 'other' + peerTask?: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric' + details?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + owner?: string | null + relationship?: 'blocked-by' | 'blocks' | 'related' + clarification?: 'lead' | 'user' | null + reviewer?: string + commentId?: string + attachmentId?: string + filename?: string + } + } + + source: { + messageUuid: string + filePath: string + } +} +``` + +The read model should be derived, not persisted. + +`id` should be stable and deterministic, for example: +- `${messageUuid}:${action.toolUseId ?? 'ambient'}:${task.locator.ref}:${link.taskArgumentSlot ?? 'none'}:${linkKind}` + +This avoids duplicate-row key problems when one transcript message yields multiple task activity rows. + +This read model should stay **semantic**, not presentation-coupled. + +It is the right place to add: +- resolved actor identity +- resolved task references where possible +- action category +- actor/task relationship state +- relationship peer-task context derived from sibling links within the same message + +It is **not** the right place to hardcode: +- final display labels +- UI tone names +- renderer-specific row text + +The read model should **not** leak raw transport details such as `taskArgumentSlot` into renderer code. +For relationship tools, the builder should consume `taskArgumentSlot` from the persisted link and expose +semantic information instead: +- `peerTask` +- `relationshipPerspective` + +For non-relationship rows, `taskArgumentSlot` is internal transport detail only: +- ambient execution rows will usually have it omitted +- ordinary single-target tool rows may have `'task'` +- renderer code should not branch on it directly + +Mapping rules for relationship rows: +- `related` -> `relationshipPerspective = 'symmetric'` on both task popups +- `blocked-by` on the `task` side -> `incoming` +- `blocked-by` on the `target` side -> `outgoing` +- `blocks` on the `task` side -> `outgoing` +- `blocks` on the `target` side -> `incoming` + +Whenever possible, the read-side builder should resolve persisted locators into the app's existing +shared `TaskRef` semantics for rendering and navigation. + +If resolution fails, it should keep the raw locator for fallback display instead of dropping the row. + +### Task resolution policy + +This is one of the highest-risk read-side areas. + +The builder must never silently guess a task from a weak locator. + +Rules: +- canonical identity always wins: + - `locator.canonicalId` + - then `refKind = 'canonical'` +- display-form resolution is allowed only when it resolves to **exactly one** candidate in team scope +- if multiple candidates share the same display-like ref, mark the row `resolution = 'ambiguous'` + and keep only the raw locator +- if no candidate matches, mark the row `resolution = 'unresolved'` +- if the best unique candidate exists only in deleted tasks, keep `taskRef` but mark + `resolution = 'deleted'` +- never drop a row only because the task cannot be resolved to a live `TaskRef` +- renderer navigation should rely on both `taskRef` and `resolution` +- in v1, rows with `resolution = 'deleted' | 'unresolved' | 'ambiguous'` should render as + non-primary navigation targets even if a fallback `taskRef` exists for label purposes + +Lookup scope: +- build the lookup from both active tasks and deleted tasks +- deleted tasks are needed mainly for: + - historical relationship rows + - lifecycle/action rows targeting tasks that were later deleted + - peer-task rendering for old `task_link` / `task_unlink` history + +Anti-guessing rule: +- do not use `Map` for display-id resolution +- display-like refs must resolve through a candidate set, not `last wins` +- if an `unknown` ref could be both a canonical-looking id and a display-like id, prefer exact + canonical-id lookup first, then unique display resolution, otherwise stay unresolved + +This policy should explicitly reuse existing shared task-identity rules where possible: +- `looksLikeCanonicalTaskId(...)` +- `getTaskDisplayId(...)` + +--- + +## UI Structure + +In the task popup, the current `Execution Logs` section should become a composed panel: + +- `Task Activity` +- `Execution Sessions` + +Target end state: +- outer collapsible title = `Task Logs` +- inner subsections = `Task Activity` and `Execution Sessions` + +For rollout stability, the outer collapsible title may temporarily remain `Execution Logs`, +but the plan target should still be `Task Logs`. + +Inside that block, the composed content should clearly separate: +- `Task Activity` +- `Execution Sessions` + +This preserves user familiarity while still introducing the new model cleanly. + +### Task Activity + +New feed based only on explicit `boardTaskLinks` plus message-level `boardTaskToolActions` + +Shows: +- lifecycle events +- execution-linked activity +- related board actions on this task + +This section complements `Workflow History`, not replaces it: +- `Workflow History` remains the authoritative board-state timeline +- `Task Activity` becomes the runtime provenance feed + +Empty-state guidance: +- if no explicit activity exists for a task, render an explicit empty state instead of silently collapsing the section +- the copy should explain that older sessions may still be available below in `Execution Sessions` + +Resolution display guidance: +- `resolution = 'active'` + - render normal task label/navigation behavior +- `resolution = 'deleted'` + - render deleted-state badge or muted label + - do not present it as a normal clickable live-task target in v1 +- `resolution = 'unresolved' | 'ambiguous'` + - render raw locator fallback + - avoid deep-link navigation because the target identity is not reliable + +### Execution Sessions + +Keep the current session-based block, powered by the existing `MemberLogsTab` + +Purpose: +- full transcript viewing +- current previews +- chunk filtering +- session-level exploration + +This block should be clearly treated as **legacy/session-centric**, not the new source of truth for task activity. + +Important UI rule: +- execution-specific polling affordances such as `Updating...` / `Online` belong to the `Execution Sessions` subsection only +- they should not be used as the loading or freshness indicator for the whole `Task Logs` panel + +--- + +## Why We Are Not Replacing the Old Block + +The current execution-log UI is useful, but it is solving a different problem: + +- it groups by session +- it sorts by work-interval overlap +- it filters chunks by persisted work intervals + +That is good for execution sessions, but not enough for task activity provenance. + +Trying to make one model serve both purposes creates: +- misleading activity feeds +- hidden related actions from other actors +- more heuristics +- harder maintenance + +So the correct design is **parallel, not replacement**. + +--- + +## Tool Classification + +All tool names in this section refer to the **canonical bare board-tool name** after `agent-teams` MCP name normalization. + +### Lifecycle + +These create `linkKind = 'lifecycle'`: + +- `task_start` +- `task_complete` +- `task_set_status` +- `review_start` +- `review_approve` +- `review_request_changes` + +### Board actions + +These create `linkKind = 'board_action'`: + +- `task_add_comment` +- `task_get_comment` +- `task_set_owner` +- `task_attach_file` +- `task_attach_comment_file` +- `task_link` +- `task_unlink` +- `task_set_clarification` +- `review_request` + +### Low-signal reads + +These are still explicit links, but may be visually muted or collapsible: + +- `task_get` + +### Ignored in v1 + +- `task_create` +- `task_create_from_message` +- `task_list` +- `task_briefing` +- `member_briefing` +- broad process/message tools without explicit `taskId` + +--- + +## Execution State Rules + +The runtime must not keep a naive single `currentTask`. + +Instead it should keep an execution scope per actor: + +- key = `(sessionId, agentId ?? 'main')` + +State should track: +- open active task set +- active phase (`work` or `review`) +- execution sequence number + +### Safe stamping rules + +- `0` active tasks + - no ambient execution link +- `1` active task + - ambient execution link allowed +- `2+` active tasks + - relation becomes `ambiguous` + - do not guess + +### Important rule + +For lifecycle messages: +- stamp the link from the explicit tool target first +- then update the actor execution state + +This ensures the lifecycle message itself is always linked to the correct task. + +--- + +## Review Flow Rules + +Review is part of the board-task activity domain and must be modeled explicitly. + +### Rules + +- `review_request` + - `board_action` + - does **not** open review execution +- `review_start` + - `lifecycle` + - may open review execution for the reviewer +- `review_approve` + - `lifecycle` + - closes review execution +- `review_request_changes` + - `lifecycle` + - closes review execution + +This keeps reviewer activity structurally visible instead of forcing it through status heuristics. + +--- + +## Multi-Target Tools + +### `task_link` / `task_unlink` + +These should emit **two links** when both task references are resolved: + +- one with `taskArgumentSlot = 'taskId'` +- one with `taskArgumentSlot = 'targetId'` + +This is the strongest reason to use `boardTaskLinks[]` instead of a single object. + +On the read side, the builder should combine sibling links from the same transcript message so each +rendered row can expose: +- the current task +- the peer task +- the relationship perspective for the current task + +That avoids forcing renderer code to understand raw MCP input roles. + +The `BoardTaskToolActionV1.input.relationship` value plus the persisted `taskArgumentSlot` should be +enough for the builder to derive relationship direction without re-reading task files. + +--- + +## Edge Cases + +### Another actor updates a task + +Example: +- Bob is actively working on task B +- Bob calls `task_add_comment` on task A + +Expected result: +- task A activity feed shows the event +- task B can continue to show Bob's own execution session separately in the legacy block +- event is marked as a related board action from another active task +- it is **not** shown as execution of task A + +### Lead mixed stream + +In v1: +- do not ambient-stamp lead free-text execution +- do allow explicit lifecycle and board-action links from lead tool calls + +### Ambiguous execution state + +If the actor has multiple active tasks: +- do not guess +- stamp explicit target links only +- use `relation = 'ambiguous'` + +### Idle actor + +If the actor is not actively executing any task but performs a task tool call: +- use `relation = 'idle'` + +### Historical logs + +Old logs without `boardTaskLinks` remain supported through: +- legacy execution sessions +- existing fallback logic where still needed + +The new activity feed in v1 should use explicit links only. + +### Multi-target relationship actions + +For `task_link` / `task_unlink`: +- the task popup for the `taskId` side should render the relationship from that task's perspective +- the related task popup for the `targetId` side should render the mirrored relationship from the peer-task perspective +- the UI label should make the relationship direction clear instead of rendering both rows identically + +--- + +## Implementation Structure + +### `agent_teams_orchestrator` + +Create a dedicated feature area: + +- `src/services/boardTaskActivity/contract.ts` +- `src/services/boardTaskActivity/BoardTaskToolInterpreter.ts` +- `src/services/boardTaskActivity/BoardTaskExecutionReducer.ts` +- `src/services/boardTaskActivity/BoardTaskTranscriptProjector.ts` +- `src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.ts` +- `src/services/boardTaskActivity/QueryBoardTaskObserver.ts` + +Responsibilities: +- inspect board MCP tool semantics +- maintain actor execution state +- produce `boardTaskLinks[]` +- produce `boardTaskToolActions[]` where applicable +- attach transcript-only task metadata before persistence + +Implementation note: +- thread the internal carrier field through the runtime message helpers before `insertMessageChain(...)` +- avoid computing task links late inside persistence from mutable global state + +### `claude_team` + +Create a separate task-log feature area: + +- `src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts` +- `src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts` +- `src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts` +- `src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts` +- `src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts` +- `src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts` +- `src/main/ipc/teams.ts` - add a dedicated `getTaskActivity` handler +- `src/main/ipc/handlers.ts` - register / remove the new handler with existing team IPC initialization + +Shared types: + +- `src/shared/types/team.ts` - add `BoardTaskActivityEntry` and related IPC-visible types +- `src/shared/types/api.ts` - add `teams.getTaskActivity(...)` +- `src/preload/constants/ipcChannels.ts` - add `TEAM_GET_TASK_ACTIVITY` +- `src/preload/index.ts` - expose the new preload method +- `src/renderer/api/httpClient.ts` - add browser-mode fallback for `getTaskActivity` + +Renderer: + +- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx` +- `src/renderer/components/team/taskLogs/TaskActivitySection.tsx` +- `src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx` +- `src/renderer/components/team/taskLogs/taskActivityPresentation.ts` + +### API separation + +Do **not** overload the existing legacy API method. + +Keep: +- `teams.getLogsForTask(...)` for legacy execution sessions + +Add: +- `teams.getTaskActivity(teamName, taskId)` for the new explicit activity model + +This separation keeps the new model isolated from the old heuristic/session path. + +For the first rollout, this API can follow the same availability profile as the current +task-log endpoints: +- supported in Electron +- browser-mode HTTP client can return `[]` with a warning, matching the current task-log API pattern + +### Contract discipline + +To keep both repos aligned without over-coupling them: + +- define JSON schemas for `BoardTaskLinkV1` and `BoardTaskToolActionV1` +- mirror the TypeScript type locally in each repo +- add golden fixtures for representative cases in both repos +- keep transcript-contract mirror types main-process-only in `claude_team` +- keep `BoardTaskActivityEntry` and other IPC-visible DTOs in shared preload/renderer types + +Parsing tolerance rules: +- parse `boardTaskLinks` and `boardTaskToolActions` defensively and independently +- if one link object is malformed, drop only that link, not the whole transcript message +- if one action object is malformed, drop only that action, not the whole transcript message +- if `schemaVersion` is unknown, skip that object family and keep the rest of the message readable +- if a link references a `toolUseId` with no surviving action, the row may still be rendered from the + link alone +- if an action survives but no links survive for its `toolUseId`, ignore the action for feed-building + and optionally emit a debug log + +This keeps the explicit feed resilient against partial writes, old transcripts, or future schema +extensions that the current reader does not understand yet. + +Minimum fixture set: +- same-task execution +- one message with multiple board-task tool results joined by distinct `toolUseId` +- lifecycle by another actor while active on a different task +- board action by another actor while active on a different task +- review start / review completion +- task link dual-target emission +- relationship row with derived peer task and relationship perspective +- task relationship subtype payload +- status / owner / clarification action payload +- unresolved display-only task locator +- display-id collision produces `resolution = 'ambiguous'` +- deleted task locator produces `resolution = 'deleted'` without dropping the row +- unknown refKind that looks canonical resolves by exact id before any display fallback +- ambiguous actor context +- legacy entry without explicit links + +--- + +## Concrete Code Blueprint + +This section is intentionally implementation-oriented. The goal is to remove as much ambiguity as +possible before coding starts. + +### `agent_teams_orchestrator` - exact touchpoints + +#### 1. Transcript contract types + +File: +- `src/services/boardTaskActivity/contract.ts` +- `src/types/logs.ts` + +Add: + +```ts +export type BoardTaskLocator = { + ref: string + refKind: 'canonical' | 'display' | 'unknown' + canonicalId?: string +} + +export type BoardTaskLinkV1 = { + schemaVersion: 1 + task: BoardTaskLocator + taskArgumentSlot?: 'taskId' | 'targetId' + toolUseId?: string + linkKind: 'execution' | 'lifecycle' | 'board_action' + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: BoardTaskLocator + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } +} + +export type BoardTaskToolActionV1 = { + schemaVersion: 1 + toolUseId: string + canonicalToolName: string + input?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + owner?: string | null + relationship?: 'blocked-by' | 'blocks' | 'related' + clarification?: 'lead' | 'user' | null + reviewer?: string + commentId?: string + } + resultRefs?: { + commentId?: string + attachmentId?: string + filename?: string + } +} +``` + +Extend `TranscriptMessage` in `src/types/logs.ts` with: + +```ts +boardTaskLinks?: BoardTaskLinkV1[] +boardTaskToolActions?: BoardTaskToolActionV1[] +``` + +Preferred reusable carrier type: + +```ts +export type BoardTaskCarrierFields = { + boardTaskLinks?: BoardTaskLinkV1[] + boardTaskToolActions?: BoardTaskToolActionV1[] +} +``` + +Implementation preference: +- prefer one shared `BoardTaskCarrierFields` mixin over repeating the same optional fields across + every helper and every runtime message type by hand +- if the actual runtime message owner file can be updated cleanly, extend the owner types with this + mixin once +- if the owner path is awkward or generated, use local intersection types at helper boundaries + instead of falling back to `any` +- keep these carrier fields runtime-internal and transcript-oriented, not part of API/model payloads + +Preferred blast-radius-minimizing strategy: + +```ts +type TaskAwareMessage = Message & BoardTaskCarrierFields +type TaskAwareUserMessage = UserMessage & BoardTaskCarrierFields +type TaskAwareAssistantMessage = AssistantMessage & + Pick +``` + +Use these local aliases first in: +- `createUserMessage(...)` +- `baseCreateAssistantMessage(...)` +- `emitTaskAware(...)` +- `insertMessageChain(...)` + +Why this is safer for v1: +- it localizes type churn to the board-task feature path +- it avoids blocking the whole rollout on the unresolved physical owner path for `types/message` +- it reduces the chance of breaking unrelated call sites that only know about plain `Message` +- it still keeps transcript persistence explicit and typed + +Only after the feature works end-to-end should we consider merging the mixin into the canonical +runtime message owner types everywhere, and only if that cleanup actually reduces complexity. + +#### 2. Internal message carriers + +File: +- `src/utils/messages.ts` + +Concrete changes: +- introduce or import `BoardTaskCarrierFields` +- extend `createUserMessage(...)` params with that mixin +- extend the runtime `Message` / `UserMessage` / `AssistantMessage` type definitions with the same + mixin only if the actual owner path makes that straightforward +- follow the actual import target used by `src/utils/messages.ts` for those runtime message types + instead of assuming the owner file path from memory +- add those fields onto the returned runtime message object +- extend the assistant message creation path with the same carrier mixin for ambient execution + stamping on assistant conversational messages +- the likely concrete touchpoint is `baseCreateAssistantMessage(...)`, because assistant helpers + already funnel through it +- ensure `normalizeMessages(...)` assistant split path preserves ambient `boardTaskLinks` on + conversational assistant text children instead of silently dropping them +- in the user normalization path that rebuilds per-block messages, pass those fields through when + calling `createUserMessage(...)` + +Pseudo-shape: + +```ts +export function createUserMessage({ + ..., + boardTaskLinks, + boardTaskToolActions, +}: { + ... +} & BoardTaskCarrierFields): TaskAwareUserMessage { + return { + ..., + boardTaskLinks, + boardTaskToolActions, + } +} +``` + +For assistant helpers, the concrete shape should be parallel: + +```ts +function baseCreateAssistantMessage({ + ..., + boardTaskLinks, +}: { + ... + boardTaskLinks?: BoardTaskLinkV1[] +}): TaskAwareAssistantMessage { + return { + ..., + boardTaskLinks, + } +} +``` + +And in the normalization split path: + +```ts +return { + ...createUserMessage({ + content: [_], + ..., + boardTaskLinks: filteredBoardTaskLinksForBlock(message.boardTaskLinks, _), + boardTaskToolActions: filteredBoardTaskToolActionsForBlock(message.boardTaskToolActions, _), + }), + uuid: ..., +} +``` + +Suggested helpers: + +```ts +function filteredBoardTaskLinksForBlock( + links: BoardTaskLinkV1[] | undefined, + block: ContentBlockParam, +): BoardTaskLinkV1[] | undefined { + if (!links?.length) return undefined + if (block.type === 'tool_result') { + const matching = links.filter(link => link.toolUseId === block.tool_use_id) + return matching.length > 0 ? matching : undefined + } + const ambient = links.filter(link => link.toolUseId === undefined) + return ambient.length > 0 ? ambient : undefined +} + +function filteredBoardTaskToolActionsForBlock( + actions: BoardTaskToolActionV1[] | undefined, + block: ContentBlockParam, +): BoardTaskToolActionV1[] | undefined { + if (!actions?.length) return undefined + if (block.type !== 'tool_result') return undefined + const matching = actions.filter(action => action.toolUseId === block.tool_use_id) + return matching.length > 0 ? matching : undefined +} +``` + +Filtering rule for split messages: +- if `_` is a `tool_result`, carry only links/actions whose `toolUseId` matches that block +- if `_` is ordinary conversational content, carry only ambient execution links where `toolUseId` is absent +- do not blindly copy the full arrays to every split child message + +Without this rule, one split `tool_result` child can silently inherit metadata that belongs to a +different `tool_result` block from the same original message. + +Why here: +- `normalizeMessagesForAPI(...)` rebuilds user messages +- if the carrier is not passed through here, transcript metadata will silently disappear on + multi-block user messages +- ordinary conversational task activity can also land on assistant messages, so the assistant + creation path must be able to carry `boardTaskLinks` +- but the assistant split path should keep ambient execution links only on human-readable + conversational children, not on `tool_use` or thinking-only children + +#### 3. Central tool-name normalization + +Files: +- `src/services/mcp/mcpStringUtils.ts` +- `src/Tool.ts` + +Concrete rule: +- do not add handwritten regexes for `mcp__agent-teams__...` +- use `mcpInfoFromString(...)` and/or the same canonicalization semantics as `toolMatchesName(...)` + +Recommended helper in `BoardTaskToolInterpreter.ts`: + +```ts +function canonicalizeBoardToolName(rawName: string): string | null { + const info = mcpInfoFromString(rawName) + if (!info?.toolName) { + return rawName.startsWith('task_') || rawName.startsWith('review_') + ? rawName + : null + } + const normalizedServer = info.serverName.replace(/[-_]+/g, '_') + if (normalizedServer !== 'agent_teams') return null + return info.toolName +} +``` + +#### 4. Execution state store + +Files: +- `src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.ts` +- `src/services/boardTaskActivity/BoardTaskExecutionReducer.ts` + +Suggested state: + +```ts +type ActorExecutionState = { + openTasks: Map + appliedToolUseIds: Set +} +``` + +Key the store by: + +```ts +`${sessionId}:${agentId ?? 'main'}` +``` + +Reducer API: + +```ts +applyLifecycle( + state: ActorExecutionState, + event: { + toolUseId: string + task: BoardTaskLocator + event: + | 'task_start' + | 'task_complete' + | 'task_set_status' + | 'review_start' + | 'review_approve' + | 'review_request_changes' + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + } +): ActorExecutionState +``` + +Important reducer rules: +- no-op if `toolUseId` already applied +- `task_start` and `task_set_status(in_progress)` open work execution +- `task_complete` and `task_set_status(completed|pending|deleted)` close work execution +- `review_start` opens review execution +- `review_approve` and `review_request_changes` close review execution +- never guess when `openTasks.size > 1` + +#### 5. Tool interpreter + +File: +- `src/services/boardTaskActivity/BoardTaskToolInterpreter.ts` + +Recommended public API: + +```ts +class BoardTaskToolInterpreter { + interpretToolResult(params: { + rawToolName: string + toolUseId: string + input: Record + result: unknown + }): { + canonicalToolName: string | null + links: BoardTaskLinkV1[] + actions: BoardTaskToolActionV1[] + lifecycleEvent?: LifecycleEvent + } +} +``` + +Why `Interpreter` is the safer name: +- this module does more than assign a category +- it interprets raw tool name + input + result into domain semantics: + - canonical tool identity + - target task locator(s) + - emitted task links + - emitted tool actions + - optional lifecycle transitions +- calling it a `Classifier` would understate responsibility and make semantic leakage into + neighboring modules more likely + +V1 source-of-truth table should follow the currently registered teammate-operational board tools +from `agent-teams-controller/src/mcpToolCatalog.js`. + +Recommended v1 classification table: +- `lifecycle` + - `task_start` + - `task_complete` + - `task_set_status` + - `review_start` + - `review_approve` + - `review_request_changes` +- `board_action` + - `task_add_comment` + - `task_attach_comment_file` + - `task_attach_file` + - `task_get` + - `task_get_comment` + - `task_link` + - `task_set_clarification` + - `task_set_owner` + - `task_unlink` + - `review_request` +- `ignore in v1 explicit feed` + - `member_briefing` + - `task_briefing` + - `task_create` + - `task_create_from_message` + - `task_list` +- `out of domain for this feature` + - `message_send` + - all `cross_team_*` + - all `process_*` + - all `kanban_*` + - `team_launch` + - `team_stop` + +Guardrail: +- add a unit test that loads the current task/review tool names from the controller source of truth + and fails if a new teammate-operational board tool appears without explicit interpreter mapping +- this prevents the runtime semantics layer from silently drifting behind controller changes + +Concrete extraction rules: +- task locator from `taskId` +- second locator from `targetId` for relationship tools +- `task_link` / `task_unlink` produce two links +- ordinary single-target board tools should emit one link with `taskArgumentSlot = 'taskId'` +- tool-derived links in v1 should have `linkKind = 'lifecycle'` or `linkKind = 'board_action'`, never `execution` +- `review_request` is `board_action`, not lifecycle +- do not copy long text fields from input/result into transcript metadata +- capture stable ids only: + - `commentId` + - `attachmentId` + - `filename` + +Per-tool payload whitelist for `BoardTaskToolActionV1`: +- `task_set_status` + - allow `input.status` +- `task_set_owner` + - allow `input.owner` +- `task_set_clarification` + - allow `input.clarification` +- `review_request` + - allow `input.reviewer` when present +- `task_link` / `task_unlink` + - allow `input.relationship` +- `task_add_comment` + - allow `resultRefs.commentId` +- `task_get_comment` + - allow `input.commentId` +- `task_attach_file` / `task_attach_comment_file` + - allow `resultRefs.attachmentId` + - allow `resultRefs.filename` + +Everything else: +- omit `input` +- omit `resultRefs` + +This whitelist must live next to the interpreter logic, not in the UI builder. +The renderer should never decide which raw tool payload fields were safe to persist. + +#### 6. Query integration point + +File: +- `src/query.ts` + +This is the safest integration point because the loop already has: +- `toolUseBlocks` +- yielded `update.message` +- normalized `tool_result` messages + +Implementation shape: + +```ts +const boardTaskObserver = new QueryBoardTaskObserver(...) + +function emitTaskAware(message: Message): Message { + return boardTaskObserver.annotateMessage(message, { + sessionId: getSessionId(), + agentId: toolUseContext.agentId, + assistantToolUses: toolUseBlocks, + }) +} + +for await (const update of toolUpdates) { + if (update.message) { + const annotatedMessage = emitTaskAware(update.message) + + yield annotatedMessage + + toolResults.push( + ...normalizeMessagesForAPI([annotatedMessage], toolUseContext.options.tools).filter( + _ => _.type === 'user', + ), + ) + } + ... +} +``` + +Important integration rule: +- do not annotate only the `getRemainingResults()` loop +- route **all transcript-visible assistant/user yields in `query.ts`** through a small shared + helper like `emitTaskAware(...)` +- that includes: + - streaming completed tool results + - remaining tool results + - synthetic missing tool-result messages on abort + - ordinary assistant conversational messages where ambient execution stamping is allowed +- specifically verify these concrete yield sites in the current file: + - `yield result.message` from `streamingToolExecutor.getCompletedResults()` + - `yield update.message` from the main `toolUpdates` loop + - emitted messages from `yieldMissingToolResultBlocks(...)` +- explicitly exclude these non-target paths from task annotation: + - `yield message` for `postCompactMessages` + - `yield { type: 'tombstone', ... }` + - tool-use summary and other non-conversational synthetic items + +Otherwise the implementation will correctly stamp board-task tool results but still miss ordinary +assistant-side execution activity. + +`annotateMessage(...)` should: +- for user `tool_result` messages: + - iterate all `tool_result` blocks inside the message + - pair each block by `tool_use_id` with the matching assistant `tool_use` + - interpret each result + - stamp `boardTaskLinks` and `boardTaskToolActions` + - apply lifecycle transitions after stamping pre-event actor context +- for ordinary conversational messages: + - if exactly one active task exists for `(sessionId, agentId)`, stamp ambient execution link + - otherwise leave unstamped + +Pairing safety rules: +- never create `boardTaskToolActions` or lifecycle transitions from a `tool_result` block unless its + `tool_use_id` resolves to a matching assistant `tool_use` +- prefer pairing in this order: + 1. direct current-turn `assistantToolUses` + 2. `sourceToolAssistantUUID` + assistant-message lookup when available + 3. otherwise treat as unpaired and skip explicit board-task annotation for that block +- if a `tool_result` block is synthetic interrupt/error recovery output, do not emit lifecycle + transitions even if the original tool name was a board-task tool +- if the paired tool result is clearly unsuccessful, emit no lifecycle transition +- missing pairing should be visible through debug diagnostics, not silently turned into guessed links + +Recommended observer helper: + +```ts +function resolveToolUseForResultBlock(params: { + toolUseId: string + assistantToolUses: ToolUseBlock[] + sourceToolAssistantUUID?: string + assistantMessages: AssistantMessage[] +}): ToolUseBlock | null { + return ( + params.assistantToolUses.find(block => block.id === params.toolUseId) ?? + findToolUseInAssistantMessage(params.assistantMessages, params.sourceToolAssistantUUID, params.toolUseId) ?? + null + ) +} +``` + +#### 7. Persistence + +Files: +- `src/utils/sessionStorage.ts` + +Concrete rule: +- do **not** recompute task metadata in `insertMessageChain(...)` +- only make sure the new optional fields are allowed by the type and survive the spread: + +```ts +const transcriptMessage: TranscriptMessage = { + ...message, + ... +} +``` + +That keeps persistence dumb and avoids late-state bugs. + +--- + +### `claude_team` - exact touchpoints + +#### 1. Keep transcript-contract parsing local to the task-activity feature + +Recommended new file: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts` + +Rationale: +- do not bloat the generic JSONL parser with feature-specific activity semantics +- keep explicit activity reading isolated from the existing session-centric parsing pipeline +- the current generic parsed-message path does not expose all raw transcript metadata needed here, + especially `teamName` / `agentName` + +Suggested API: + +```ts +class BoardTaskActivityTranscriptReader { + async readFile(filePath: string): Promise +} +``` + +`RawTaskActivityMessage` should be local to the feature and include only: +- `filePath` +- `uuid` +- `timestamp` +- `sessionId` +- `agentId` +- `isSidechain` +- `teamName` +- `agentName` +- `boardTaskLinks` +- `boardTaskToolActions` +- `sourceOrder` + +Implementation detail: +- stream JSONL line-by-line, like the existing parser +- skip entries without `uuid` +- skip entries without `boardTaskLinks` +- increment `sourceOrder` per accepted line so same-timestamp rows remain deterministic +- no need to materialize full `ParsedMessage` + +Recommended performance guard for v1: +- add a small per-file parse cache keyed by `(filePath, size, mtimeMs)` +- return cloned cached `RawTaskActivityMessage[]` when the signature matches +- dedupe concurrent reads with an in-flight map so repeated popup opens do not parse the same file twice +- prefer mtime+size invalidation over TTL-only invalidation +- keep the cache feature-local, similar in spirit to existing parse caches such as + `LeadSessionParseCache`, instead of coupling it to the legacy logs finder +- when the discovered transcript file set changes for a team, clear cache entries for paths that + disappeared from the source set + +Suggested helper file: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts` + +Suggested first-slice cache API: + +```ts +type BoardTaskActivityFileSignature = { + size: number + mtimeMs: number +} + +class BoardTaskActivityParseCache { + getIfFresh(filePath: string, signature: BoardTaskActivityFileSignature): RawTaskActivityMessage[] | null + getInFlight(filePath: string, signature: BoardTaskActivityFileSignature): Promise | null + setInFlight(filePath: string, signature: BoardTaskActivityFileSignature, promise: Promise): void + clearInFlight(filePath: string, signature: BoardTaskActivityFileSignature): void + set(filePath: string, signature: BoardTaskActivityFileSignature, rows: readonly RawTaskActivityMessage[]): void + clearForPath(filePath: string): void +} +``` + +Why this matters: +- the task popup may reopen repeatedly for the same task while the underlying JSONL files have not changed +- without an mtime-aware cache, the new explicit feed would re-parse the same lead/subagent files on every open +- this is a classic way to make a correct feature feel flaky or slow even when the domain model is sound + +#### 2. Main-side contract parsing + +Files: +- `src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts` +- `src/main/types/jsonl.ts` only if lightweight type guards help + +Recommended functions: + +```ts +export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null +export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null +``` + +Keep this contract parser feature-local and tolerant: +- unknown fields ignored +- invalid entries dropped, not fatal + +Suggested parser shape: + +```ts +export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null { + if (!value || typeof value !== 'object') return null + const row = value as Record + const ref = typeof row.ref === 'string' ? row.ref.trim() : '' + const refKind = + row.refKind === 'canonical' || row.refKind === 'display' || row.refKind === 'unknown' + ? row.refKind + : null + const canonicalId = + typeof row.canonicalId === 'string' && row.canonicalId.trim().length > 0 + ? row.canonicalId.trim() + : undefined + if (!ref || !refKind) return null + return { ref, refKind, canonicalId } +} + +export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null { + if (!Array.isArray(value)) return null + const parsed = value + .map(item => { + if (!item || typeof item !== 'object') return null + const row = item as Record + if (row.schemaVersion !== 1) return null + const task = parseBoardTaskLocator(row.task) + if (!task) return null + const linkKind = + row.linkKind === 'execution' || + row.linkKind === 'lifecycle' || + row.linkKind === 'board_action' + ? row.linkKind + : null + const relation = + row.actorContext && + typeof row.actorContext === 'object' && + ['same_task', 'other_active_task', 'idle', 'ambiguous'].includes( + String((row.actorContext as Record).relation), + ) + ? ((row.actorContext as Record).relation as + | 'same_task' + | 'other_active_task' + | 'idle' + | 'ambiguous') + : null + if (!linkKind || !relation) return null + return { + schemaVersion: 1, + task, + taskArgumentSlot: + row.taskArgumentSlot === 'taskId' || row.taskArgumentSlot === 'targetId' + ? row.taskArgumentSlot + : undefined, + toolUseId: typeof row.toolUseId === 'string' ? row.toolUseId : undefined, + linkKind, + actorContext: { + relation, + activeTask: parseBoardTaskLocator( + (row.actorContext as Record).activeTask, + ) ?? undefined, + activePhase: + (row.actorContext as Record).activePhase === 'work' || + (row.actorContext as Record).activePhase === 'review' + ? ((row.actorContext as Record).activePhase as 'work' | 'review') + : undefined, + activeExecutionSeq: + typeof (row.actorContext as Record).activeExecutionSeq === 'number' + ? ((row.actorContext as Record).activeExecutionSeq as number) + : undefined, + }, + } satisfies BoardTaskLinkV1 + }) + .filter((entry): entry is BoardTaskLinkV1 => entry !== null) + return parsed.length > 0 ? parsed : null +} + +export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null { + if (!Array.isArray(value)) return null + const parsed = value + .map(item => { + if (!item || typeof item !== 'object') return null + const row = item as Record + if (row.schemaVersion !== 1) return null + const toolUseId = typeof row.toolUseId === 'string' ? row.toolUseId.trim() : '' + const canonicalToolName = + typeof row.canonicalToolName === 'string' ? row.canonicalToolName.trim() : '' + if (!toolUseId || !canonicalToolName) return null + return { + schemaVersion: 1, + toolUseId, + canonicalToolName, + } satisfies BoardTaskToolActionV1 + }) + .filter((entry): entry is BoardTaskToolActionV1 => entry !== null) + return parsed.length > 0 ? parsed : null +} +``` + +Parser behavior rule: +- do not throw for malformed per-object metadata +- salvage valid siblings and continue reading +- reserve throwing for true file-level I/O or invalid JSONL framing only + +#### 3. Task-activity builder + +Files: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts` +- `src/shared/types/team.ts` + +Add to shared IPC-visible types: + +```ts +export interface BoardTaskActivityEntry { + id: string + timestamp: string + actor: { ... } + task: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + linkKind: 'execution' | 'lifecycle' | 'board_action' + actorContext: { ... } + action: { + canonicalToolName?: string + toolUseId?: string + category: ... + peerTask?: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric' + details?: { ... } + } + source: { + messageUuid: string + filePath: string + } +} +``` + +Concrete builder algorithm: + +```ts +buildEntriesForTask(rawMessage, targetTaskId) { + const matchingLinks = rawMessage.boardTaskLinks.filter(link => matchesTarget(link.task, targetTaskId)) + const actionsByToolUseId = buildActionMap(rawMessage.boardTaskToolActions ?? []) + + return matchingLinks.map(link => { + const action = link.toolUseId ? actionsByToolUseId.get(link.toolUseId) : undefined + const siblingLinks = link.toolUseId + ? rawMessage.boardTaskLinks.filter(other => other.toolUseId === link.toolUseId) + : [] + const peerLink = siblingLinks.find(other => !sameLocator(other.task, link.task)) + + return buildTaskActivityEntry(link, action, peerLink, rawMessage) + }) +} +``` + +Recommended action-map helper: + +```ts +function buildActionMap(actions: BoardTaskToolActionV1[]): Map { + const map = new Map() + for (const action of actions) { + if (map.has(action.toolUseId)) { + logDebug('[BoardTaskActivityEntryBuilder] duplicate boardTaskToolAction toolUseId', { + toolUseId: action.toolUseId, + }) + continue + } + map.set(action.toolUseId, action) + } + return map +} +``` + +Dedupe rule: +- do not use silent `last wins` +- keep the first surviving action for a `toolUseId` +- log duplicates in debug mode so broken writer-side invariants are visible during QA + +Builder simplification rule: +- if `link.linkKind === 'execution'`, do not attempt to join an action object +- `execution` rows in v1 are ambient-only and should be rendered without `BoardTaskToolActionV1` +- only `lifecycle` and `board_action` links participate in `toolUseId -> action` joins + +Suggested locator-resolution helpers: + +```ts +type ResolvedTaskHandle = + | { resolution: 'resolved' | 'deleted'; taskRef: TaskRef } + | { resolution: 'unresolved' | 'ambiguous' } + +function buildTaskLookup( + activeTasks: TeamTask[], + deletedTasks: TeamTask[], + teamName: string, +): { + byId: Map + byDisplayId: Map> +} { + const byId = new Map() + const byDisplayId = new Map< + string, + Array<{ resolution: 'resolved' | 'deleted'; taskRef: TaskRef }> + >() + + const addTask = (task: TeamTask, resolution: 'resolved' | 'deleted') => { + const taskRef: TaskRef = { + taskId: task.id, + displayId: getTaskDisplayId(task), + teamName, + } + + byId.set(task.id, { resolution, taskRef }) + + const key = taskRef.displayId.toLowerCase() + const bucket = byDisplayId.get(key) ?? [] + bucket.push({ resolution, taskRef }) + byDisplayId.set(key, bucket) + } + + for (const task of activeTasks) addTask(task, 'active') + for (const task of deletedTasks) { + if (!byId.has(task.id)) addTask(task, 'deleted') + } + + return { byId, byDisplayId } +} + +function resolveLocator( + locator: BoardTaskLocator, + lookup: { + byId: Map + byDisplayId: Map> + }, +): ResolvedTaskHandle { + if (locator.canonicalId) { + return lookup.byId.get(locator.canonicalId) ?? { resolution: 'unresolved' } + } + + if (locator.refKind === 'canonical') { + return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' } + } + + if (locator.refKind === 'display') { + const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? [] + if (candidates.length === 1) return candidates[0] + if (candidates.length > 1) return { resolution: 'ambiguous' } + return { resolution: 'unresolved' } + } + + if (looksLikeCanonicalTaskId(locator.ref)) { + return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' } + } + + const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? [] + if (candidates.length === 1) return candidates[0] + if (candidates.length > 1) return { resolution: 'ambiguous' } + return { resolution: 'unresolved' } +} +``` + +Matching rule for `getTaskActivity(teamName, taskId)`: +- target matching should primarily compare against canonical `taskId` +- if a link only has display-form identity, resolve it through the task lookup first +- do not compare raw strings only +- do not guess by display id when the lookup returns more than one candidate +- do not drop a row solely because the target resolves to `deleted` or `unresolved` + +Suggested actor-resolution helper: + +```ts +function resolveActivityActor(rawMessage: RawTaskActivityMessage): { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string +} { + if (rawMessage.agentName && rawMessage.agentName.trim().length > 0) { + return { + memberName: rawMessage.agentName.trim(), + role: rawMessage.isSidechain ? 'member' : 'lead', + sessionId: rawMessage.sessionId, + agentId: rawMessage.agentId, + } + } + return { + memberName: undefined, + role: rawMessage.isSidechain ? 'member' : 'unknown', + sessionId: rawMessage.sessionId, + agentId: rawMessage.agentId, + } +} +``` + +Actor-resolution rule: +- prefer explicit `agentName` from the raw transcript entry +- use `isSidechain` only as a fallback hint for `role` +- do not infer actor identity from task ownership or task history + +Stable ordering rule: +- sort final `BoardTaskActivityEntry[]` by `timestamp ASC` +- tie-break by `rawMessage.filePath` +- then by `rawMessage.sourceOrder ASC` +- then by `action.toolUseId ?? ''` +- then by `id` + +This keeps the feed deterministic when multiple entries share the same timestamp or come from the +same transcript message. + +#### 4. Dedicated service, not legacy finder reuse + +Files: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts` +- `src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts` +- `src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts` + +Suggested `BoardTaskActivityService` dependencies: +- `TeamTranscriptSourceLocator` +- `TeamTaskReader` +- `BoardTaskActivityTranscriptReader` + +Suggested API: + +```ts +class BoardTaskActivityService { + async getTaskActivity(teamName: string, taskId: string): Promise +} +``` + +Concrete rule: +- new service reads explicit links only +- it must not call `findLogsForTask(...)` for inference +- legacy block keeps using `TeamMemberLogsFinder` +- task lookup for builder resolution should load both: + - `TeamTaskReader.getTasks(teamName)` + - `TeamTaskReader.getDeletedTasks(teamName)` +- deleted tasks are part of history resolution, not an optional nice-to-have + +Concrete discovery rule: +- do not make `BoardTaskActivityService` depend on `TeamMemberLogsFinder` +- extract a small shared locator for: + - resolving `projectDir` + - current `leadSessionId` + - `sessionIds` + - enumerating lead and subagent transcript files +- let the new explicit path depend on that lower-level discovery boundary directly + +Why: +- `TeamMemberLogsFinder` is session-centric and attribution-heavy +- the new explicit activity path does not need member-attribution heuristics +- depending on the old finder would reintroduce the mixed-responsibility boundary we are trying to remove + +Suggested transcript-source locator shape: + +```ts +type TeamTranscriptSourceContext = { + projectDir: string + leadSessionId?: string + sessionIds: string[] +} + +class TeamTranscriptSourceLocator { + async getContext(teamName: string): Promise { ... } + + async listTranscriptFiles(teamName: string): Promise { + const context = await this.getContext(teamName) + if (!context) return [] + + const files = new Set() + if (context.leadSessionId) { + files.add(path.join(context.projectDir, `${context.leadSessionId}.jsonl`)) + } + for (const sessionId of context.sessionIds) { + const dir = path.join(context.projectDir, sessionId, 'subagents') + for (const file of await safeListAgentJsonlFiles(dir)) { + files.add(path.join(dir, file)) + } + } + return [...files].sort() + } +} +``` + +`safeListAgentJsonlFiles(...)` should mirror the existing subagent-file rules: +- include `agent-*.jsonl` +- exclude `agent-acompact*` + +Recommended main-process wiring: + +```ts +// src/main/index.ts +const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator() +const taskActivityTranscriptReader = new BoardTaskActivityTranscriptReader() +const taskActivityService = new BoardTaskActivityService( + teamTranscriptSourceLocator, + new TeamTaskReader(), + taskActivityTranscriptReader, +) +``` + +Then thread the service through IPC bootstrap: + +```ts +// src/main/ipc/handlers.ts +export function initializeIpcHandlers( + registry: ServiceContextRegistry, + updater: UpdaterService, + sshManager: SshConnectionManager, + teamDataService: TeamDataService, + teamProvisioningService: TeamProvisioningService, + teamMemberLogsFinder: TeamMemberLogsFinder, + memberStatsComputer: MemberStatsComputer, + teammateToolTracker: TeammateToolTracker | undefined, + branchStatusService: BranchStatusService | undefined, + taskActivityService: BoardTaskActivityService | undefined, + ... +): void { + initializeTeamHandlers( + teamDataService, + teamProvisioningService, + teamMemberLogsFinder, + memberStatsComputer, + teamBackupService, + teammateToolTracker, + branchStatusService, + taskActivityService, + ) +} +``` + +```ts +// src/main/index.ts +initializeIpcHandlers( + registry, + updater, + sshManager, + teamDataService, + teamProvisioningService, + teamMemberLogsFinder, + memberStatsComputer, + teammateToolTracker, + branchStatusService, + taskActivityService, + ... +) +``` + +Service export note: +- if `initializeIpcHandlers(...)` in `src/main/ipc/handlers.ts` continues importing service types from + `../services`, add the new service export to: + - `src/main/services/team/index.ts` + - `src/main/services/index.ts` +- if you decide to import the new service type directly in `handlers.ts`, keep that decision local and + do not mix both import styles in the same patch + +```ts +// src/main/ipc/teams.ts +let taskActivityService: BoardTaskActivityService | null = null + +export function initializeTeamHandlers( + service: TeamDataService, + provisioningService: TeamProvisioningService, + logsFinder?: TeamMemberLogsFinder, + statsComputer?: MemberStatsComputer, + backupService?: TeamBackupService, + toolTracker?: TeammateToolTracker, + branchTracker?: BranchStatusService, + activityService?: BoardTaskActivityService, +): void { + ... + taskActivityService = activityService ?? null +} +``` + +```ts +function getTaskActivityService(): BoardTaskActivityService { + if (!taskActivityService) { + throw new Error('Task activity service is not initialized') + } + return taskActivityService +} +``` + +This keeps the new explicit path as a first-class service instead of constructing it ad hoc inside +the IPC handler. + +#### 5. Implementation checkpoints before CP1 + +These checks should happen before writing feature code. + +1. Resolve the real runtime owner for `Message` / `UserMessage` / `AssistantMessage` + - `src/utils/messages.ts` imports from `../types/message.js` + - the physical source file is not obvious from the current tree walk + - do not start patching helper signatures until the actual symbol owner is confirmed + - if necessary, use editor "Go to Definition" or TypeScript resolution tooling instead of guessing + +2. Enumerate every transcript-visible yield path in `src/query.ts` + - tool result updates + - assistant conversational updates + - synthetic missing tool-result recovery + - any other user/assistant message path that lands in transcript storage + - confirm all of them route through the planned annotation helper before enabling the feature + +3. Verify split/normalize paths in `src/utils/messages.ts` + - assistant split path must not duplicate ambient execution links onto every child + - thinking-only children must not inherit task metadata + - user tool-result children must retain only the links/actions that match the child block's `tool_use_id` + +4. Verify transcript discovery assumptions in `claude_team` + - `TeamTranscriptSourceLocator` should reuse the same lead/subagent file discovery rules as the legacy path + - subagent transcript enumeration must exclude `agent-acompact*` + - the first slice should not depend on worker-thread plumbing + +If any of these checks fail, stop and correct the plan before code changes continue. + +#### 6. IPC / preload / browser fallback + +Files: +- `src/preload/constants/ipcChannels.ts` +- `src/shared/types/api.ts` +- `src/preload/index.ts` +- `src/main/ipc/teams.ts` +- `src/main/ipc/handlers.ts` +- `src/renderer/api/httpClient.ts` + +Add: + +```ts +export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity' +``` + +Shared API: + +```ts +getTaskActivity: (teamName: string, taskId: string) => Promise +``` + +Main handler shape in `teams.ts`: + +```ts +async function handleGetTaskActivity( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, +): Promise> { ... } +``` + +Recommended first-slice handler: + +```ts +async function handleGetTaskActivity( + _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('getTaskActivity', () => + getTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!), + ) +} +``` + +Recommended preload addition: + +```ts +getTaskActivity: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ACTIVITY, + teamName, + taskId, + ) +} +``` + +Important integration detail: +- `initializeTeamHandlers(...)` should receive the new service or create/store it next to existing + `teamMemberLogsFinder` +- `registerTeamHandlers(...)` should register `TEAM_GET_TASK_ACTIVITY` +- `removeTeamHandlers(...)` should unregister it + +Concrete handler registration: + +```ts +// registerTeamHandlers(...) +ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity) +``` + +```ts +// removeTeamHandlers(...) +ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY) +``` + +Browser fallback in `HttpAPIClient` can mirror current task-log behavior: + +```ts +getTaskActivity: async () => { + console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode') + return [] +} +``` + +#### 7. UI composition + +Files: +- `src/renderer/components/team/dialogs/TaskDetailDialog.tsx` +- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx` +- `src/renderer/components/team/taskLogs/TaskActivitySection.tsx` +- `src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx` + +Concrete change in `TaskDetailDialog.tsx`: +- replace direct inline `MemberLogsTab` block with `TaskLogsPanel` + +Pseudo-shape: + +```tsx + +``` + +`TaskLogsPanel` should internally render: +- `TaskActivitySection` +- `ExecutionSessionsSection` + +`ExecutionSessionsSection` should be a thin wrapper around the current `MemberLogsTab` props so the +legacy block keeps its existing behavior and polling indicators. + +UI state rule: +- `TaskActivitySection` should own its own loading and empty states +- `ExecutionSessionsSection` should keep the current refreshing and online indicators +- do not reuse `ExecutionSessionsSection` polling state as the header status for the whole `Task Logs` panel +- fetch `Task Activity` and `Execution Sessions` independently so one slow path does not block the other + +Suggested panel skeleton: + +```tsx +export function TaskLogsPanel(props: { + teamName: string + task: TeamTask + taskSince?: string + allowLeadExecutionPreview?: boolean + isLeadOwnedTask?: boolean +}): React.JSX.Element { + const { teamName, task, taskSince, allowLeadExecutionPreview, isLeadOwnedTask } = props + + return ( +
+ + +
+ ) +} +``` + +Suggested `TaskActivitySection` fetch shape: + +```tsx +const [entries, setEntries] = useState(null) +const [error, setError] = useState(null) + +useEffect(() => { + let cancelled = false + setError(null) + setEntries(null) + void api.teams + .getTaskActivity(teamName, taskId) + .then(result => { + if (!cancelled) setEntries(result) + }) + .catch(err => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } +}, [teamName, taskId]) +``` + +#### 8. Recommended tests + +`agent_teams_orchestrator` +- interpreter unit tests for each board tool family +- reducer tests for open/close/ambiguous transitions +- observer tests for: + - single `tool_result` + - multiple `tool_result` blocks in one message + - ambient execution stamp + - duplicate `toolUseId` no-op + +`claude_team` +- transcript reader tests for additive contract parsing +- builder tests for: + - same-task execution + - external board action + - lifecycle with pre-event actor context + - `task_link` / `task_unlink` with derived `peerTask` + - display-id collision resolves to `ambiguous`, not first-match + - deleted peer task still renders a row with `resolution = 'deleted'` + - unresolved locator still renders fallback row without navigation +- UI tests for: + - empty explicit activity + legacy sessions still visible + - `Task Activity` and `Execution Sessions` separated + - deleted or unresolved peer-task rows are visibly non-primary / non-navigable + +#### 9. Runtime diagnostics + +Add lightweight counters or debug logs around the new explicit path. + +Minimum writer-side diagnostics: +- `board_task_activity.tool_result_paired` +- `board_task_activity.tool_result_unpaired` +- `board_task_activity.synthetic_tool_result_skipped` +- `board_task_activity.lifecycle_emitted` +- `board_task_activity.lifecycle_skipped_unsuccessful` +- `board_task_activity.ambient_execution_emitted` +- `board_task_activity.ambient_execution_skipped_ambiguous` + +Minimum read-side diagnostics: +- `board_task_activity.link_parse_dropped` +- `board_task_activity.action_parse_dropped` +- `board_task_activity.duplicate_action_tool_use_id` +- `board_task_activity.unresolved_locator` +- `board_task_activity.ambiguous_locator` + +Rules: +- keep diagnostics low-cardinality +- never log full comment text, review prose, or arbitrary tool payloads +- prefer counts and short identifiers over verbose blobs +- debug logging is enough for v1 if metrics plumbing would slow the rollout, but the hook points + should still be explicit in code + +--- + +## Rollout Plan + +### CP0 - contract and names are fixed + +- finalize `BoardTaskLinkV1` +- finalize `BoardTaskToolActionV1` +- finalize `toolUseId` join rules for links and actions +- finalize the tool semantics table derived from `agent-teams-controller/src/mcpToolCatalog.js` +- finalize naming across runtime contract, read model, and renderer +- add JSON schema and fixture examples + +Pre-flight verification gate before leaving CP0: +- confirm the runtime message type owner path used by `src/utils/messages.ts` +- confirm the final transcript-discovery class name is `TeamTranscriptSourceLocator` +- confirm `query.ts` annotate coverage list is complete + +### Rollout safety switches + +Keep the feature decomposed behind separate flags or equivalent runtime gates: +- `boardTaskLinksWriteEnabled` + - enables writer-side transcript stamping only +- `boardTaskActivityReadEnabled` + - enables the new `getTaskActivity(...)` read path only +- `boardTaskActivityUiEnabled` + - enables the `Task Activity` subsection in the popup only + +Recommended staged activation: +1. writer flag on in local/dev only +2. read flag on after explicit transcripts are verified +3. UI flag on after read-side QA passes + +Kill-switch rule: +- any serious mismatch in transcript stamping should be recoverable by disabling only the write flag + without removing legacy `Execution Sessions` +- any read-side performance or parsing issue should be recoverable by disabling only the read/UI flag + while keeping persisted transcripts intact +- do not make rollout depend on a single all-or-nothing switch + +Shadow validation phase: +- before exposing the new UI section broadly, run the writer + reader path in shadow mode +- in shadow mode: + - write explicit transcript metadata + - build activity entries in the background or in targeted debug sessions + - compare obvious invariants: + - task activity rows exist for fresh lifecycle events + - no duplicate action rows per `toolUseId` + - no lifecycle rows emitted from synthetic interrupt tool results + - keep the user-facing UI hidden until these checks are stable + +### CP1 - writer-side explicit links + +- add `boardTaskLinks?: BoardTaskLinkV1[]` to transcript messages +- add `boardTaskToolActions?: BoardTaskToolActionV1[]` to transcript messages where applicable +- implement runtime tool inspection +- implement actor execution state +- stamp only explicit/safe links + +### CP2 - read-side activity feed + +- parse explicit transcript task metadata in `claude_team` +- build `BoardTaskActivityEntry` +- expose `getTaskActivity(teamName, taskId)` +- keep `getLogsForTask(...)` unchanged for the legacy block + +Do not block the first slice on worker-thread support for the new feed. + +Do not route the new explicit activity query through the existing `getLogsForTask(...)` worker and +fallback path. Keep it as a separate read path in v1 so the explicit model stays isolated from the +legacy heuristic/session pipeline. + +If profiling later shows that explicit-link scanning is still expensive, add worker support as a +follow-up slice instead of mixing that concern into the first correctness rollout. + +### CP3 - UI integration + +- replace direct `MemberLogsTab` usage in task popup with a composed panel +- outer title: `Task Logs` +- `Task Activity` +- `Execution Sessions` + +### CP4 - display policy tuning + +- map semantic activity entries to renderer labels/badges +- mute noisy read actions like `task_get`, especially same-task reads +- improve labels for lifecycle and cross-task actions +- add manual QA on real team sessions + +--- + +## Definition of Done + +- Task popup shows **two clearly separated sections**: + - `Task Activity` + - `Execution Sessions` +- A task can show actions from a different actor working on another task, without mislabeling them as execution of the target task +- Review actions appear correctly in task activity +- Multi-target tools can link to multiple tasks +- Ambiguous actor state never triggers guessing +- Existing execution-session viewing still works +- Old logs remain readable +- New logs gain explicit structural task linkage +- Locator collisions never silently pick an arbitrary task +- Deleted or unresolved peer tasks do not disappear from task activity history +- `pnpm typecheck` passes in affected repos +- targeted tests pass for: + - lifecycle events + - direct board actions + - other-active-task actor actions + - review flow + - multi-target tools + - ambiguous actor state + - explicit-link-only feed behavior in v1 + - unmatched `tool_result` blocks do not create guessed links + - synthetic interrupt tool results do not create lifecycle rows + +--- + +## Top 3 Remaining Implementation Risks + +- **1. Carrier propagation drift in `agent_teams_orchestrator`** - `🎯 9 🛡️ 8 🧠 8` - roughly `180-320` lines of careful edits. + Risk: + one message path in `src/utils/messages.ts` or `src/query.ts` forgets to keep or filter `boardTaskLinks` / `boardTaskToolActions`, which creates silent gaps or duplication. + +- **2. Partial annotate coverage in `src/query.ts`** - `🎯 8 🛡️ 8 🧠 7` - roughly `120-220` lines. + Risk: + only tool-result updates go through `emitTaskAware(...)`, while other transcript-visible assistant or user yields bypass the helper and lose ambient execution links. + +- **3. Read-side overcoupling to legacy discovery** - `🎯 9 🛡️ 9 🧠 5` - roughly `80-160` lines. + Risk: + the new explicit feed accidentally reuses `TeamMemberLogsFinder` logic and reintroduces heuristic/session coupling. Keeping `TeamTranscriptSourceLocator` separate avoids this. + +--- + +## Manual QA Checklist + +- Start task A, produce normal execution logs - activity shows execution entries for A +- While on task A, comment on task B - task B shows related board action, task A does not lose execution state +- Request review on task A - task A shows board action +- Start review on task A - task A shows lifecycle review event +- Approve or request changes on task A - task A shows lifecycle completion event +- Link task A to task B - both task activity feeds reflect the relationship action appropriately +- Change owner / status / clarification on task A - task activity row renders without parsing free-text result output +- Open a historical task without explicit links - legacy execution sessions still load + +--- + +## Final Architectural Summary + +We are explicitly separating: + +- **runtime truth** - `boardTaskLinks[]` + `boardTaskToolActions[]` +- **UI activity model** - `BoardTaskActivityEntry` +- **legacy session browsing** - `Execution Sessions` + +This avoids: +- overloading one contract with UI concerns +- overloading one UI block with two different meanings +- growing the old heuristic session finder into an even larger mixed-responsibility module + +This is the cleanest path that is: +- reliable +- understandable +- scalable +- compatible with the current codebase diff --git a/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md b/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md new file mode 100644 index 00000000..35be3ab2 --- /dev/null +++ b/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md @@ -0,0 +1,1768 @@ +# Iteration 08 - Exact Task Logs Reusing Existing Execution Renderer + +> Historical note +> This document captures the planned scope and architecture at iteration time. +> It is not the source of truth for the final runtime contract. + +This iteration adds a new **Exact Task Logs** subsection under task logs and intentionally reuses the existing execution-log renderer that already works well in the app. + +The goal is **not** to invent a new log UI. + +The real problem was never the renderer. The real problem was that the old task log discovery path was: +- session-centric +- heuristic-heavy +- not strict enough about what truly belongs to a task + +The new explicit board-task linkage from iteration 07 already solved the **selection** problem. +This iteration uses that explicit linkage to feed a **task-scoped transcript slice** into the existing execution renderer. + +That means: +- keep `Task Activity` as the compact, explicit summary feed +- add `Exact Task Logs` that visually looks like the current rich logs/execution cards +- keep `Execution Sessions` as a separate legacy/session-centric block + +--- + +## Decision Record + +### Top 3 options + +1. **Reuse the existing execution renderer, but feed it a new explicit task-scoped filtered message slice** - `🎯 10 🛡️ 9 🧠 6` - примерно `550-950` строк + This is the chosen direction. + +2. **Keep `Task Activity` only as summary, and add inline tool-details drawers per row** - `🎯 8 🛡️ 9 🧠 5` - примерно `350-650` строк + Simpler, but still not the same browsing experience the user already likes. + +3. **Build a new custom task log renderer from scratch** - `🎯 3 🛡️ 5 🧠 9` - примерно `900-1600` строк + Rejected. This is a bicycle. It is slower, riskier, and likely worse than the existing renderer. + +### Chosen direction + +- Keep `Task Activity` as the compact explicit summary +- Add `Exact Task Logs` +- Render `Exact Task Logs` using the same existing execution-log rendering pipeline +- Build a new explicit task-scoped message-selection layer +- Reuse renderer primitives only, not legacy session-browsing containers +- Do **not** reuse the old heuristic session-finding logic as the source of truth + +### Why this is the right direction + +- The renderer already solves: + - tool call cards + - tool-result pairing + - text output display + - expandable items + - ordering and visual hierarchy +- The existing UX is already liked by the user +- Reusing the renderer lowers design risk +- The new explicit metadata gives us a reliable source for task scoping + +The correct architecture is: +- **reuse the renderer** +- **replace the selection logic** + +Not: +- reuse the old selection logic +- or rewrite the renderer + +--- + +## Core UX Goal + +Inside the task popup: + +1. `Task Activity` + - short explicit summary rows + - compact semantic view + +2. `Exact Task Logs` + - rich task-scoped transcript rendering + - same visual style as the current logs/execution UI + - exact tools, outputs, and grouped items + +3. `Execution Sessions` + - current legacy/session-centric browser + - still useful for exploration + - no longer treated as the primary truth for task scoping + +This gives users: +- a fast summary +- exact readable logs +- a fallback exploration view + +--- + +## Important Clarification: Which Renderer We Actually Reuse + +The correct renderer to reuse is **not** `CliLogsRichView`. + +`CliLogsRichView` is for: +- stream-json CLI tails +- provisioning / live runtime logs + +It expects a different source model. + +The renderer path that matches the desired UX in task/session views is: + +- `MemberExecutionLog` +- `transformChunksToConversation(...)` +- `enhanceAIGroup(...)` +- `DisplayItemList` +- `LastOutputDisplay` + +That is the execution/session renderer family the user is referring to. + +So the plan is: +- **reuse the execution renderer path** +- **not** the CLI stream-json renderer path + +--- + +## Main Architectural Insight + +The new exact log view must reuse the old renderer **without reintroducing old selection bugs**. + +That means we cannot simply: +- ask `TeamMemberLogsFinder` for sessions +- reuse `MemberLogsTab` +- or render whole sessions again + +We also should **not** blindly render entire AI response groups from the transcript. + +Why: +- the same AI response can contain both relevant and unrelated tools +- if we render the entire unfiltered group, we leak unrelated actions back into the task view +- that would partially recreate the same problem we just solved + +So the right architecture is: + +1. Find exact task-linked source refs using explicit metadata +2. Resolve those refs into message-level anchors +3. Build a **filtered transcript slice** that contains only task-relevant messages/blocks +4. Convert that filtered slice into `EnhancedChunk[]` +5. Render with the existing execution renderer + +The renderer stays the same. +The message-selection layer becomes explicit and strict. + +--- + +## Scope + +### Goals + +- Add `Exact Task Logs` under `Task Logs` +- Reuse the current execution renderer style +- Build exact logs only from explicit task-linked transcript metadata +- Support: + - board-task tools + - lifecycle rows + - explicit board actions + - ambient execution text/output already linked to the task +- Avoid showing unrelated tools from the same session/AI response + +### Non-Goals + +- Replacing `Task Activity` +- Deleting `Execution Sessions` +- Retroactively fixing all historical logs without explicit metadata +- Reusing heuristic session overlap as primary selection +- Building a brand-new renderer + +--- + +## Key Product Rules + +### Rule 1 - `Task Activity` stays + +`Task Activity` remains the compact summary feed. + +It is still valuable because: +- it is fast to scan +- it shows actor/task relation cleanly +- it keeps the event-level summary readable + +### Rule 2 - `Exact Task Logs` is the readable drill-down + +`Exact Task Logs` is where users read the actual tool/output flow. + +It should look and feel like the existing execution/log UI. + +### Rule 3 - `Execution Sessions` remains legacy + +`Execution Sessions` still exists because: +- it is useful for broad exploration +- it has previews and session browsing +- it can still show context the exact feed intentionally omits + +But it is no longer the primary source for task scoping. + +--- + +## Naming Decisions + +### UI names + +Use: + +- outer section: `Task Logs` +- subsection 1: `Task Activity` +- subsection 2: `Exact Task Logs` +- subsection 3: `Execution Sessions` + +This naming is explicit and easy to understand: +- summary +- exact logs +- session browser + +### Service names + +Use: + +- `BoardTaskActivityRecordSource` +- `BoardTaskExactLogsService` +- `BoardTaskExactLogSummarySelector` +- `BoardTaskExactLogDetailSelector` +- `BoardTaskExactLogChunkBuilder` + +### Shared DTO names + +Use: + +- `BoardTaskExactLogSummary` +- `BoardTaskExactLogDetail` +- `BoardTaskExactLogActor` +- `BoardTaskExactLogSource` + +### Why this naming + +- `Exact Task Logs` is user-facing and immediately understandable +- `BoardTaskActivityRecordSource` is more honest than `...Service` because this layer only supplies internal records +- `BoardTaskExactLogsService` is specific enough to avoid mixing with legacy task logs +- `Summary` + `Detail` is better than a single eager `Bundle` DTO because the renderer should load heavy exact details lazily + +--- + +## Layered Design + +This slice must preserve separation of concerns. + +### 1. Explicit activity source layer + +Responsibility: +- read explicit task-linked transcript metadata +- produce internal task activity records + +Suggested main-only type: + +```ts +type BoardTaskActivityRecord = { + timestamp: string + task: { + locator: BoardTaskLocator + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + taskId?: string + displayId?: string + } + linkKind: 'execution' | 'lifecycle' | 'board_action' + targetRole: 'subject' | 'related' + actor: { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string + isSidechain: boolean + } + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: BoardTaskLocator + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } + action?: ParsedBoardTaskToolAction + source: { + filePath: string + messageUuid: string + toolUseId?: string + sourceOrder: number + } +} +``` + +This is **main-only** and not an IPC DTO. + +Why this shape is better than `taskId: string`: + +- it preserves unresolved and deleted states +- it avoids forcing early loss of locator semantics +- it lets both summary and exact-log readers consume the same lower-level record source + +### 2. Exact-log summary selection layer + +Responsibility: +- start from explicit activity records +- build lightweight exact-log summaries +- never parse transcript messages + +This is the most important new layer in iteration 08 because it keeps initial popup load cheap and removes transcript parsing from the summary path entirely. + +### 3. Exact-log detail selection layer + +Responsibility: +- start from one summary + explicit activity records +- parse only the referenced transcript messages +- build one filtered task-scoped message slice for one requested exact detail + +### 4. Chunk-building layer + +Responsibility: +- turn the filtered message slice into `EnhancedChunk[]` +- keep the existing execution renderer happy + +### 5. UI rendering layer + +Responsibility: +- render exact bundle details with the current execution renderer +- not decide task membership + +--- + +## Why We Need an Internal Record Layer First + +It is tempting to let `BoardTaskExactLogsService` depend directly on `BoardTaskActivityEntry`. + +That would be simpler in the short term, but it is the wrong dependency direction. + +`BoardTaskActivityEntry` is a shared UI-facing DTO. +`Exact Task Logs` needs a lower-level source model. + +So the better architecture is: + +- `BoardTaskActivityRecordSource` + - main-only + - internal source of explicit task-linked facts + +- `BoardTaskActivityService` + - maps records -> `BoardTaskActivityEntry` + +- `BoardTaskExactLogsService` + - maps records -> lightweight exact-log summaries + +- `BoardTaskExactLogDetailService` + - maps one exact summary + parsed transcript -> one renderable exact detail + +This avoids coupling a new main-side service to a renderer DTO. + +This is a strong SRP / DIP move and worth doing now. + +### Critical reuse boundary + +The new exact path must **not** introduce a second competing low-level reader for board-task transcript metadata. + +That means: + +- `BoardTaskActivityTranscriptReader` remains the single owner of: + - `boardTaskLinks[]` parsing + - `boardTaskToolActions[]` parsing + - file-level metadata parse caching for explicit board-task transcript metadata +- `BoardTaskActivityRecordSource` is extracted from the current summary path and becomes the single owner of: + - transcript metadata discovery + - task lookup and target-task filtering + - resolved internal activity records +- all of: + - `BoardTaskActivityService` + - `BoardTaskExactLogsService` + - `BoardTaskExactLogDetailService` + depend on the same `BoardTaskActivityRecordSource` + +This is the desired dependency graph: + +```ts +BoardTaskActivityTranscriptReader + -> BoardTaskActivityRecordSource + -> BoardTaskActivityService + -> BoardTaskExactLogsService + -> BoardTaskExactLogDetailService +``` + +This is explicitly **not** the desired graph: + +```ts +BoardTaskActivityTranscriptReader -> BoardTaskActivityService +parseBoardTaskLinks again elsewhere -> BoardTaskExactLogsService +``` + +Why this matters: + +- summary and exact views must agree on what explicit task-linked records exist +- task-resolution behavior must not drift between two separate low-level readers +- metadata parsing bugs must be fixed once +- caches should stay shared where possible + +So iteration 08 should extract and reuse the existing explicit-record path. +It should not create another parallel JSONL-metadata reader just for exact logs. + +--- + +## Data Flow + +### End-to-end flow + +1. Renderer asks for exact task logs: + +```ts +api.teams.getTaskExactLogSummaries(teamName, taskId) +``` + +2. IPC calls: + +```ts +BoardTaskExactLogsService.getTaskExactLogSummaries(teamName, taskId) +``` + +3. Service gets: +- active + deleted tasks from `TeamTaskReader` +- activity records from `BoardTaskActivityRecordSource` + +4. Service derives exact-log summaries **from activity records only** + +5. Renderer shows exact-log summary cards first + +6. On expand, renderer asks for one exact detail: + +```ts +api.teams.getTaskExactLogDetail(teamName, taskId, exactLogId, sourceGeneration) +``` + +7. Detail service: +- reloads the matching explicit summary anchor +- derives the minimal referenced file set for that one summary +- parses only those transcript files into strict `ParsedMessage[]` +- builds one filtered bundle slice +- converts it into `EnhancedChunk[]` + +8. Renderer reuses `MemberExecutionLog` + +--- + +## New Shared DTOs + +### IPC DTOs + +```ts +type BoardTaskExactLogActor = { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string + isSidechain: boolean +} + +type BoardTaskExactLogSource = { + filePath: string + messageUuid: string + toolUseId?: string + sourceOrder: number +} + +type BoardTaskExactLogSummary = + { + id: string + timestamp: string + actor: BoardTaskExactLogActor + source: BoardTaskExactLogSource + linkKinds: ('execution' | 'lifecycle' | 'board_action')[] + } & ( + | { canLoadDetail: true; sourceGeneration: string } + | { canLoadDetail: false } + ) + +type BoardTaskExactLogDetail = { + id: string + chunks: EnhancedChunk[] +} +``` + +### Why summaries + lazy detail is the safer v1 design + +Repo-local finding: + +- `Execution Sessions` already uses a lazy expand-to-load-details interaction model +- `EnhancedChunk[]` is an accepted IPC shape in this app +- but returning `EnhancedChunk[]` eagerly for every exact bundle would be materially heavier than the current execution-session path + +So the safer v1 direction is: + +- initial load -> lightweight `BoardTaskExactLogSummary[]` +- expand one row -> fetch one `BoardTaskExactLogDetail` + +This keeps: + +- initial popup payload smaller +- refresh cost lower +- parity with the existing interaction model the user already likes + +### Why `canLoadDetail` is better than `hasRenderableDetail` + +Summary stage no longer parses transcript content. + +That is a feature, not a limitation: + +- it keeps summary load cheap +- it prevents summary-stage parser drift +- it avoids lying with overconfident renderability claims + +So the summary flag should be capability-oriented: + +- `canLoadDetail = true` means the app has enough explicit anchor/source information to attempt detail loading +- it does **not** guarantee that strict detail reconstruction will succeed +- if `canLoadDetail = false`, the summary must not carry a meaningless `sourceGeneration` + +If detail later fails because the transcript row is malformed or missing, returning `missing` is still correct. + +### Source-generation coherence contract + +Lazy summaries + detail introduce one real risk: + +- summaries are loaded at time `T1` +- transcript files change +- detail is requested at time `T2` +- the same `exactLogId` may now refer to a different filtered slice or to nothing at all + +So exact logs need an explicit coherence token. + +Preferred response shape: + +```ts +type BoardTaskExactLogSummariesResponse = { + items: BoardTaskExactLogSummary[] +} +``` + +Preferred detail result shape: + +```ts +type BoardTaskExactLogDetailResult = + | { status: 'ok'; detail: BoardTaskExactLogDetail } + | { status: 'stale' } + | { status: 'missing' } +``` + +Why this is better than `null`: + +- renderer can distinguish stale summary data from a genuinely missing bundle +- UI can refresh summaries automatically on `stale` +- debugging is easier than with a single ambiguous nullish path + +### Why `sourceGeneration` belongs on each summary, not on the whole response + +Earlier drafts used one response-level generation token for the whole task. +That is weaker. + +Why: + +- exact detail is loaded one bundle at a time +- one task can reference many transcript files +- one unrelated file mutation should not stale every open summary card + +So the safer contract is: + +- each `BoardTaskExactLogSummary` carries its own `sourceGeneration` +- detail validates against that per-summary generation +- the summaries response does not need a single coarse global generation token in v1 + +This narrows stale invalidation to the actual files that back one summary. + +### Why not reuse global `TeamLogSourceTracker.logSourceGeneration` directly + +Repo-local finding: + +- `TeamLogSourceTracker` already computes a broad project-level `logSourceGeneration` +- that generation changes for any tracked transcript source movement + +That pattern is useful, but it is too broad as the primary exact-log coherence token. + +If exact logs reuse the global generation directly, then: + +- an unrelated transcript file change can invalidate all open exact-log details +- exact detail requests become noisier and more frequently stale than necessary + +So exact logs should use a **narrower source generation**: + +- derive `sourceGeneration` from the exact summary source set used for one requested summary +- typically hash normalized `(filePath, size, mtimeMs)` for the referenced transcript files + +### Why `linkKinds` is an array + +One exact-log summary/detail can legitimately originate from multiple explicit links that collapse into the same rendered bundle. + +Example: +- same tool call produced both `subject` and `related` links +- same transcript message had both an execution link and a board-action link relevant to the target task + +The bundle should render once, not duplicate. + +### File-local exact-detail boundary + +Repo-local finding: + +- existing tool/result linking in `SessionParser`, `ToolExecutionBuilder`, and the execution renderer pipeline works over one provided message slice +- bundle identity already includes `filePath` +- `MemberExecutionLog` itself only consumes `EnhancedChunk[]` and a display `memberName` + +So v1 should keep a strict boundary: + +- one exact summary belongs to one transcript file +- one exact detail request parses at most that summary's referenced file set +- no cross-file hunt for a missing paired `tool_use` or `tool_result` + +This is the safer rule because cross-file pairing would immediately reintroduce guesswork and drift. + +If a future transcript shape ever truly requires cross-file pairing, that should be a separate iteration with its own invariants and tests. + +--- + +## Exact Selection Rules + +This is the most critical part of the design. + +### Principle + +Select only what is explicitly attributable to the target task. + +Never reintroduce broad session heuristics as the exact-log source. + +### Critical anti-bug rule + +The selector must work on **explicit source refs first**, and only then read transcript content. + +It must never scan a transcript file first and try to rediscover task relevance from nearby content. + +### Step 1 - Start from explicit activity records + +Only records whose resolved target task matches the requested task are eligible. + +### Step 2 - Derive exact message anchors + +Each eligible record becomes one anchor candidate. + +Suggested internal shape: + +```ts +type BoardTaskExactLogAnchor = + | { + kind: 'tool' + filePath: string + sessionId: string + toolUseId: string + sourceMessageUuid: string + } + | { + kind: 'message' + filePath: string + sessionId: string + messageUuid: string + } +``` + +### Step 3 - Collapse multiple records into stable bundles + +Deduplicate anchors aggressively: + +- same `filePath + toolUseId` -> one tool bundle +- same `filePath + messageUuid` -> one message bundle + +### Anchor precedence rule + +If both anchors exist for the same source: + +- tool anchor: `filePath + toolUseId` +- message anchor: `filePath + messageUuid` + +then the **tool anchor wins** and the message anchor must not create a second bundle for the same tool execution. + +This is required because one task-linked tool result can also carry an explicitly linked message UUID. +Without precedence, the same action can render twice: +- once as a tool bundle +- once as a message bundle + +That would be a real regression. + +This avoids duplicate rendering when: +- multiple links point to the same tool +- link/unlink emits both subject + related rows +- one activity message contains multiple links for the same target task + +### Step 4 - Build summaries from anchors only + +Summary stage must stop here. + +For each surviving anchor: +- compute stable summary identity +- aggregate `linkKinds` +- derive actor label and source metadata +- compute per-summary `sourceGeneration` +- set `canLoadDetail` conservatively + +⚠️ Summary stage must **not** parse transcript content. + +That keeps: +- popup open cheaper +- correctness easier to reason about +- stale invalidation scoped to one summary + +### Step 5 - Build filtered message slice only on detail request + +This is where the old bugs must not come back. + +#### For tool bundles + +Include only: +- the assistant `tool_use` block with the matching `toolUseId` +- the internal user `tool_result` block with the same `toolUseId` +- explicit assistant text output only when the same assistant message is itself explicitly linked to the task + +Do **not** automatically include every other tool in the same AI response. + +#### For ambient execution/message bundles + +Include only: +- the explicitly linked message itself +- optionally, paired assistant output blocks from the same message if the linked message is assistant content + +Do **not** expand to unrelated neighboring transcript messages by default. + +### Why this stricter filtering is necessary + +If we simply render the whole AI response group, we can leak: +- unrelated board tools +- unrelated read/search tools +- unrelated support actions from the same response + +That would make the task logs look rich, but wrong. + +Exact logs must be: +- rich +- but still task-scoped + +--- + +## Exact Filtering Strategy + +The filtered slice should use **synthetic filtered `ParsedMessage` copies**, not raw original messages unchanged. + +That means: +- copy the original message metadata +- keep only the relevant content blocks +- preserve `uuid`, `timestamp`, `requestId`, sidechain flags, session metadata +- drop unrelated blocks + +### Critical consistency rule for synthetic messages + +After block filtering, derived message fields must be **recomputed**, not blindly copied. + +That includes: +- `toolCalls` +- `toolResults` +- `sourceToolUseID` +- `sourceToolAssistantUUID` +- `toolUseResult` + +If we keep the original derived fields after dropping unrelated blocks, the renderer can silently reintroduce unrelated tool cards even though the filtered content looked correct. + +That is one of the highest-risk implementation mistakes in this iteration. + +### Research-backed note: what the renderer actually reads + +From the current code: + +- assistant-side tool cards are derived primarily from assistant content blocks (`tool_use`) +- internal user tool results are derived primarily from `msg.toolResults` +- `ChunkBuilder` and `SemanticStepExtractor` do **not** rely on exactly the same fields on both sides + +Implication: + +- assistant filtered messages must preserve correct assistant content blocks +- internal user filtered messages must rebuild `toolResults[]` correctly +- copying stale derived fields is especially dangerous on the internal user side +- `toolUseResult` needs explicit handling because renderer/tool-content helpers use it for richer cards + +Suggested helper: + +```ts +function filterParsedMessageForTaskAnchor(args: { + message: ParsedMessage + anchor: BoardTaskExactLogAnchor + explicitlyLinkedMessageIds: Set +}): ParsedMessage | null +``` + +Rules: + +- assistant message: + - keep `tool_use` blocks only when `block.id === anchor.toolUseId` + - keep `text` blocks only when the message UUID is explicitly linked for the same target task + - drop unrelated `tool_use` blocks + - drop unrelated thinking blocks in v1 + +- internal user message: + - keep `tool_result` blocks only when `block.tool_use_id === anchor.toolUseId` + - rebuild `toolResults[]` only for that tool + - keep `sourceToolUseID` only when it matches + - keep `sourceToolAssistantUUID` only when the paired assistant message is present in the same bundle + - keep `toolUseResult` only when it can be proven to belong to the same surviving `toolUseId` + - if that proof is missing, drop `toolUseResult` instead of risking leaked payload from another tool + +- ordinary user/system message: + - keep only if explicitly linked by `messageUuid` + +This preserves correctness and still allows the renderer to work. + +### `toolUseResult` preservation policy + +Repo-local finding: + +- `displayItemBuilder` uses `toolUseResult` while building linked tool items +- `toolContentChecks` uses `toolUseResult` to decide whether richer content exists for read/write/edit-style tools +- `ToolResultExtractor` also treats `toolUseResult` as an alternate result carrier + +So `toolUseResult` is not optional sugar. +It can materially affect what the renderer shows. + +Safe v1 rule: + +- keep `toolUseResult` only when: + - the filtered internal-user message still points to exactly one surviving `toolUseId` + - that `toolUseId` matches `sourceToolUseID` or an equivalent explicit enriched field +- otherwise: + - drop `toolUseResult` + +Why this is safer: + +- false negatives only degrade richness for one tool card +- false positives can leak payload from a different tool execution into the current exact bundle + +For exact task logs, false negative is preferable to false positive. + +### Streaming assistant dedupe rule + +Another repo-local finding: + +- `parseJsonlFile(...)` parses streaming assistant entries as separate `ParsedMessage`s +- `deduplicateByRequestId(...)` exists, but it is not automatically applied by the general renderer pipeline +- if exact logs do nothing, the same assistant response can survive more than once inside one bundle + +That can cause: + +- duplicated output rows +- duplicated tool-use blocks from intermediate streaming entries +- unstable exact bundles for the same task over time + +So the exact-log path must add an explicit dedupe step: + +- after synthetic filtering +- before chunk building +- per bundle candidate +- keep only the last surviving assistant message for a given `requestId` + +Important: + +- do not dedupe across different bundles +- do not dedupe by `requestId` before filtering, because different streaming snapshots may survive differently after block filtering + +The safe sequence is: + +1. parse strict file-local `ParsedMessage[]` +2. build one filtered synthetic bundle slice +3. dedupe assistant streaming entries by `requestId` inside that slice +4. build chunks from that deduped bundle slice + +This should be pinned with tests. + +### Strict timestamp and source-fidelity rule + +The exact-log path must not become looser than the summary path about malformed transcript rows. + +Important repo-local finding: + +- the current explicit activity reader already skips rows without a real transcript `timestamp` +- the generic `parseJsonlFile(...)` path currently falls back to `new Date()` when raw transcript `timestamp` is missing + +That fallback is acceptable for broad session utilities, but it is **not** acceptable for exact task logs. + +If exact logs silently synthesize “now” for malformed transcript rows, we get: + +- unstable ordering across reads +- bundles that appear newer than they really are +- drift between `Task Activity` and `Exact Task Logs` + +So the exact-log path must use a **strict timestamp policy**: + +- missing or malformed raw transcript timestamp -> drop the exact-log row or exact-log message +- never synthesize current time + +Preferred implementation direction: + +- add a small exact-log-specific strict parser wrapper +- optionally, only if it stays clearly isolated, extend low-level JSONL parsing with an opt-in strict mode used exclusively by exact logs + +Rejected shortcut: + +- parse with the permissive default path and try to detect synthetic timestamps later + +That shortcut is not reliable because the fallback timestamp becomes indistinguishable from a valid parsed timestamp after parsing. + +Important repo-local constraint: + +- `parseJsonlFile(...)` is used broadly across the app +- changing its default permissive behavior would create unrelated blast radius + +So the safer v1 direction is: + +- keep the global permissive parser unchanged +- add an exact-log-specific strict wrapper or opt-in exact mode +- contain the stricter behavior inside the exact-log path only + +### Classification rule for synthetic filtered messages + +The plan relies on the current `MessageClassifier` behavior: + +- filtered internal user tool-result messages are still classified into the AI path +- they are not rendered as user bubbles as long as they remain internal/meta user messages + +This is good for the chosen design, but it is a dependency that must be pinned with tests. + +If this classifier behavior changes later, exact logs can silently degrade. + +--- + +## Chunk Building Strategy + +### Chosen direction + +Reuse: + +- `ChunkBuilder.buildChunks(...)` +- `transformChunksToConversation(...)` +- `enhanceAIGroup(...)` +- `MemberExecutionLog` + +### Pre-flight checkpoint + +Before coding the bundle builder, confirm with tests that: + +- filtered internal user messages still classify into the expected AI path in `MessageClassifier` +- filtered assistant + internal user slices still produce the expected tool cards in `MemberExecutionLog` +- filtered tool-result-only bundles still render meaningfully even when no paired assistant tool-use survives +- filtered bundles with multiple assistant streaming snapshots collapse to one stable assistant row per `requestId` +- `toolUseResult`-backed richer tool cards still work when the surviving bundle truly owns that tool result +- `toolUseResult` is dropped when ownership is ambiguous + +This must be verified, not assumed. + +### Important rule + +Build chunks from the **filtered slice**, not from the entire session. + +### Bundle isolation rule + +Build chunks **per requested exact bundle detail**, not from a concatenated multi-bundle slice. + +Why: + +- `ChunkBuilder` buffers adjacent AI-category messages together +- if two anchors are concatenated before chunk building, separate exact bundles can accidentally merge into one AI chunk +- that would produce unstable visual grouping and leak unrelated context between bundles + +So the correct sequence is: + +1. derive one exact detail candidate +2. build one filtered message slice for that candidate +3. build chunks for that candidate only +4. map to one `BoardTaskExactLogDetail` + +Suggested builder: + +```ts +class BoardTaskExactLogChunkBuilder { + constructor(private readonly chunkBuilder: ChunkBuilder = new ChunkBuilder()) {} + + buildBundleChunks(messages: ParsedMessage[]): EnhancedChunk[] { + return this.chunkBuilder.buildChunks(messages, [], { includeSidechain: true }) + } +} +``` + +### Why not pass subagents/processes in v1 + +The exact log slice is already strict and synthetic. + +Passing full process linkage into this slice creates extra coupling and raises contamination risk. + +In v1: +- pass no additional processes +- render only what is explicitly in the filtered message slice + +That is safer and easier to reason about. + +### Why no `SessionParser` as the main entrypoint + +`SessionParser` is useful for whole-session views, but it is not the ideal entrypoint here. + +For exact logs we want: +- file-local parsed messages +- no whole-session grouping assumptions +- no extra session-level work unless needed + +So the preferred path in v1 is: + +- parse raw transcript files into `ParsedMessage[]` +- then run exact-bundle selection on top + +Do not start from a full `SessionDetail` pipeline unless implementation proves it is actually simpler without correctness cost. + +--- + +## Why We Should Not Reuse `MemberLogsTab` + +`MemberLogsTab` is valuable, but it is the wrong source layer for exact logs. + +It still depends on: +- session summaries +- session overlap +- task work intervals +- preview logic +- owner-session assumptions + +That logic remains useful for `Execution Sessions`, but should not be reused as the source for exact task logs. + +Correct reuse target: +- renderer primitives + +Wrong reuse target: +- legacy session discovery + +### Renderer reuse boundary + +Reusing the existing renderer means reusing its current visual behavior too. + +That is intentional in v1: + +- exact details render through `MemberExecutionLog` +- item ordering follows that component's existing behavior +- no ongoing/session-status affordances are added +- no extra subagent/process enrichment is injected beyond what exists in the filtered chunk slice + +This keeps iteration 08 focused on the hard problem - correct task-scoped selection - instead of accidentally starting a parallel renderer redesign. + +--- + +## New Main-Side Services + +### 1. `BoardTaskActivityRecordSource` + +Responsibility: +- read transcript metadata +- resolve task-linked records +- expose internal activity records + +Potential implementation: +- extract common lower-level logic from current `BoardTaskActivityService` +- keep `BoardTaskActivityService` as record -> DTO mapper + +### 2. `BoardTaskExactLogSummarySelector` + +Responsibility: +- take activity records only +- group them by exact-log anchor +- produce lightweight exact-log summaries + +Important: +- this selector owns anchor precedence +- this selector must not parse transcript files +- this selector computes per-summary `sourceGeneration` +- computing `sourceGeneration` may stat referenced files, but it must not parse transcript content +- this selector decides `canLoadDetail` conservatively from anchor shape and record fidelity + +### 3. `BoardTaskExactLogDetailSelector` + +Responsibility: +- take one exact-log summary + strict parsed transcript messages +- produce one filtered message slice for one requested exact detail + +Important: +- this selector owns derived-field recomputation requirements for filtered messages +- this selector must not return raw original `ParsedMessage` arrays when block filtering happened + +### 4. `BoardTaskExactLogChunkBuilder` + +Responsibility: +- convert filtered message bundles into `EnhancedChunk[]` + +Important: +- one bundle in, one bundle out +- no cross-bundle chunk building + +### 5. `BoardTaskExactLogsService` + +Responsibility: +- orchestrate the exact-log summary flow +- expose IPC-facing `BoardTaskExactLogSummariesResponse` + +Important: +- this service consumes `BoardTaskActivityRecordSource` +- it must not directly parse `boardTaskLinks[]` from JSONL lines itself +- it must not parse transcript messages in the summary path +- it may read file metadata needed for per-summary `sourceGeneration` +- it should not own a second explicit-metadata parser + +### 6. `BoardTaskExactLogDetailService` + +Responsibility: +- resolve one exact bundle summary into one renderable exact detail +- expose IPC-facing `BoardTaskExactLogDetailResult` + +Important: +- this service consumes `BoardTaskActivityRecordSource` +- this service consumes `BoardTaskExactLogDetailSelector` +- this service owns strict per-bundle filtering +- this service owns per-bundle assistant `requestId` dedupe before chunk building +- this service returns `stale` or `missing` instead of guessing when a requested bundle can no longer be rendered safely + +--- + +## Proposed File Touchpoints + +### `claude_team` main + +Add: + +- `src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts` + +Touch: + +- `src/main/ipc/teams.ts` +- `src/main/ipc/handlers.ts` +- `src/main/index.ts` +- `src/preload/index.ts` +- `src/preload/constants/ipcChannels.ts` +- `src/shared/types/api.ts` +- `src/shared/types/team.ts` + +### `claude_team` renderer + +Add: + +- `src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx` +- `src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx` + +Touch: + +- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx` + +### `agent_teams_orchestrator` + +No new write-side contract is required for this iteration if iteration 07 metadata is already present. + +Only touch write-side if a concrete missing field is discovered during implementation. + +That is an explicit scope guard. + +--- + +## IPC Plan + +Add: + +```ts +teams.getTaskExactLogSummaries( + teamName: string, + taskId: string +): Promise +teams.getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string +): Promise +``` + +Suggested IPC channel: + +```ts +TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries' +TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail' +``` + +These methods must be: +- independent from `getLogsForTask(...)` +- independent from the legacy worker fallback path +- explicit-metadata only in v1 +- browser-safe in the same way as other team-only methods: + - summaries -> `{ items: [] }` + - detail -> `{ status: 'missing' }` + +### Return shape rule + +The API should: + +- return lightweight summaries from the summary endpoint +- return already-built `EnhancedChunk[]` only from the detail endpoint +- never return raw messages plus renderer-side building instructions + +Why: + +- chunk building belongs to the main-side service layer +- renderer should stay simple +- this keeps exact-log selection and filtering logic out of the renderer +- this keeps the initial popup payload materially smaller + +### Ordering rule + +Returned summaries must be sorted deterministically by: + +1. explicit source timestamp +2. `filePath` +3. `sourceOrder` +4. `toolUseId` +5. `id` + +This avoids UI drift when multiple transcript rows share the same minute/second bucket. + +--- + +## Renderer Plan + +### `TaskLogsPanel` + +Target composition: + +```tsx + + + +``` + +### `ExactTaskLogsSection` + +Responsibilities: +- fetch `teams.getTaskExactLogSummaries(...)` +- load independently from `ExecutionSessionsSection` +- show loading / error / empty state +- render one card per exact log summary + +### Exact-log loading policy + +Exact logs are materially heavier than summary rows. + +So the safe v1 loading policy is: + +- load when the task popup opens and the section becomes visible +- if the section is collapsed, do not keep a blind frequent poll running +- if the task is active and the section is expanded, a slower revalidation loop is acceptable +- manual refresh is acceptable and should be easy to add + +This is better than unconditional frequent polling because exact logs require: + +- explicit record lookup +- transcript file parsing for exact detail +- synthetic message filtering +- chunk building + +Those costs are much higher than the summary feed. + +### `ExactTaskLogCard` + +Responsibilities: +- show timestamp + actor label +- show source metadata if helpful +- lazy-load detail on expand +- render the loaded detail via `MemberExecutionLog` +- keep the expand control disabled when `canLoadDetail === false` + +Example: + +```tsx +if (summary.canLoadDetail) { + const detail = await api.teams.getTaskExactLogDetail( + teamName, + taskId, + summary.id, + summary.sourceGeneration + ) + if (detail.status === 'ok') { + return + } +} +``` + +### Actor label rule + +Fix the current weak UX: + +- if `memberName` exists -> show it +- else if `isSidechain === false` -> show `lead session` +- else -> show `unknown actor` + +This is much safer and more readable than the current fallback. + +--- + +## Empty State Policy + +If there are explicit activity rows but no exact renderable summaries: + +- do **not** silently disappear +- show a clear empty state such as: + +`Exact task-scoped transcript groups are not available for these activity rows yet.` + +If no explicit activity exists: + +`No explicit task-linked logs found in transcript metadata.` + +This matters because: +- summary-only history is still useful +- users should not assume the feature is broken + +--- + +## Performance Plan + +This slice can get expensive if implemented naively. + +### Required v1 protections + +1. Parse cache by `filePath + mtimeMs + size` +2. In-flight dedupe for concurrent reads +3. Deduplicate anchors before building summaries +4. In the summary path, do not parse transcript content at all +5. In the detail path, do not parse the same file repeatedly inside one request +6. In the detail path, derive referenced file paths from explicit activity records first, then parse only that subset +7. Avoid unconditional high-frequency polling for exact logs +8. Share the explicit metadata reader/record source with the summary path instead of re-reading metadata in a second pipeline +9. Keep exact detail lazy, not eager, in v1 + +### Nice-to-have only if needed later + +- per-task result cache +- cross-service parsed transcript cache reuse + +Do not over-engineer that before profiling. + +--- + +## Consistency Rules + +### Rule 1 - Exact logs are explicit-link only + +Do not add: +- work-interval fallback +- mention matching +- owner fallback +- “close enough” neighboring tool inference + +### Rule 2 - Exact logs and summary use the same explicit source + +`Task Activity` and `Exact Task Logs` should derive from the same underlying explicit activity records, not from separate competing interpretations. + +That means: + +- same `BoardTaskActivityRecordSource` +- same explicit transcript metadata semantics +- same target-task resolution rules + +The two views may diverge in presentation. +They must not diverge in their low-level notion of “this transcript source is explicitly linked to this task”. + +### Rule 3 - Summary selector is the single source of truth for summary identity + +`exactLogId` and per-summary `sourceGeneration` must come from one place only: + +- `BoardTaskExactLogSummarySelector` + +That means: + +- `BoardTaskExactLogsService` uses it to emit summaries +- `BoardTaskExactLogDetailService` uses the same selector to rebuild summaries before loading detail +- detail service must not recompute ids with its own string concatenation rules +- detail service must not recompute generations with a different file-ordering rule + +Why this matters: + +- summary/detail drift is otherwise easy to introduce silently +- one tiny id-format change can turn every detail request into `missing` +- one tiny generation-ordering change can turn valid detail requests into false `stale` + +If a helper is extracted, it should stay below both services and be reused by both. + +### Rule 4 - Exact logs may be stricter than summary + +This is acceptable. + +Some summary rows may not yield rich exact summaries or rich exact details if: +- the row is too minimal +- the source message is malformed +- the source message is non-renderable in the existing pipeline + +That is better than rendering the wrong thing. + +### Rule 5 - Exact detail reconstruction is file-local in v1 + +Exact detail reconstruction must stay file-local. + +That means: + +- one summary anchor resolves to one `source.filePath` +- detail service only parses that summary's referenced files +- missing pair data in another transcript file is treated as absent, not searched globally + +Why this matters: + +- it matches the current execution renderer and tool-linking assumptions +- it keeps `sourceGeneration` honest +- it avoids a hidden return of broad transcript heuristics + +--- + +## Edge Cases + +### 1. Same tool call linked to two tasks + +Example: +- `task_link` +- `task_unlink` + +Behavior: +- both tasks may show the same exact tool bundle +- the bundle must render once per task, not duplicate within one task + +### 2. One transcript message contains multiple relevant links + +Behavior: +- collapse into one exact log bundle +- preserve all relevant `linkKinds` in metadata + +### 2b. One tool execution has both a tool anchor and a message anchor + +Behavior: +- render exactly one exact bundle +- the tool anchor wins +- the message anchor is absorbed into the same bundle metadata + +### 3. Same AI response contains relevant and irrelevant tools + +Behavior: +- render only the filtered relevant blocks +- do not include the whole raw AI response + +### 3b. Same assistant message contains both relevant text and unrelated tool calls + +Behavior: +- keep the explicitly linked text +- drop unrelated tool calls +- rebuild derived assistant-side tool structures from the surviving blocks only + +### 4. Lead-session row without actor name + +Behavior: +- show `lead session` +- not `unknown actor` + +### 5. Missing paired `tool_use` + +Behavior: +- if `tool_result` exists but paired assistant `tool_use` cannot be found, render what is available +- do not guess missing tool input +- do not search other transcript files for the missing pair in v1 + +### 6. Missing timestamp / malformed row + +Behavior: +- skip malformed rows +- do not synthesize “current time” + +### 7. Execution-only ambient rows + +Behavior: +- may render as exact text/output-only bundles +- no fake tool payload should be attached + +--- + +## Suggested Internal Helper Shapes + +### Bundle source model + +```ts +type BoardTaskExactLogBundleCandidate = { + id: string + timestamp: string + actor: BoardTaskExactLogActor + source: BoardTaskExactLogSource + records: BoardTaskActivityRecord[] +} & ( + | { canLoadDetail: true; sourceGeneration: string } + | { canLoadDetail: false } +) + +type BoardTaskExactLogDetailCandidate = { + id: string + timestamp: string + actor: BoardTaskExactLogActor + source: BoardTaskExactLogSource + records: BoardTaskActivityRecord[] + filteredMessages: ParsedMessage[] +} +``` + +### Bundle identity rule + +Use: + +- tool bundle id: `tool:${filePath}:${toolUseId}` +- message bundle id: `message:${filePath}:${messageUuid}` + +Do not use timestamps as the primary identity. +Timestamps are for ordering, not identity. + +### Summary source-of-truth rule for actor label + +`MemberExecutionLog` only receives `chunks` plus one optional `memberName`. + +So v1 should not try to rediscover actor identity from filtered exact-detail messages. +The authoritative actor label for the exact-log card should come from the summary/record side: + +- exact summary owns the visible actor label +- exact detail rendering reuses that summary actor label +- detail reconstruction should not override it based on incidental filtered message content + +### Selector skeleton + +```ts +class BoardTaskExactLogSummarySelector { + selectSummaries(args: { + records: BoardTaskActivityRecord[] + }): BoardTaskExactLogBundleCandidate[] { + // 1. derive anchors from explicit records + // 2. apply tool-anchor-over-message precedence + // 3. dedupe anchors + // 4. compute per-summary sourceGeneration + // 5. return one candidate per summary + } +} + +class BoardTaskExactLogDetailSelector { + selectDetail(args: { + summary: BoardTaskExactLogSummary + records: BoardTaskActivityRecord[] + parsedMessagesByFile: Map + }): BoardTaskExactLogDetailCandidate | null { + // 1. rebuild the matching anchor from explicit records + // 2. parse only the files referenced by that summary + // 3. build filtered synthetic ParsedMessage[] for that one anchor + // 4. return one detail candidate or null + } +} +``` + +### Service skeleton + +```ts +class BoardTaskExactLogsService { + async getTaskExactLogSummaries( + teamName: string, + taskId: string + ): Promise { + // 1. get explicit activity records + // 2. build exact summaries from records only + // 3. sort deterministically + // 4. map summary response + } +} + +class BoardTaskExactLogDetailService { + async getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ): Promise { + // 1. rebuild the matching summary from explicit records + // 2. if summary.canLoadDetail !== true -> return { status: 'missing' } + // 3. compare expectedSourceGeneration with recomputed summary.sourceGeneration + // 4. if mismatch -> return { status: 'stale' } + // 5. parse only the summary's referenced files via strict parser + // 6. build one filtered detail candidate + // 7. dedupe assistant streaming rows by requestId + // 8. build chunks + // 9. return one detail DTO or { status: 'missing' } + } +} +``` + +--- + +## Rollout Plan + +### Feature gates + +Use separate read/UI gates: + +- `CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED` +- `VITE_BOARD_TASK_EXACT_LOGS_UI_ENABLED` + +Do not reuse the iteration 07 gates directly. + +This lets us: +- validate main-side behavior first +- then enable renderer independently + +### Rollout stages + +#### Stage 1 - Main-side exact bundle service + +- build record source +- build exact summaries +- add tests +- no UI yet + +#### Stage 2 - IPC + preload + +- expose `getTaskExactLogSummaries(...)` +- expose `getTaskExactLogDetail(...)` +- add integration tests + +#### Stage 3 - Renderer section + +- add `ExactTaskLogsSection` +- wire into task popup +- keep disabled by UI flag initially + +#### Stage 4 - Manual shadow validation + +Compare: +- `Task Activity` +- `Exact Task Logs` +- `Execution Sessions` + +for several real teams and transcript shapes. + +--- + +## Testing Plan + +### Main tests + +Add focused tests for: + +1. `BoardTaskActivityRecordSource` + - explicit record extraction matches existing activity semantics + +2. Exact-log selectors + become two focused test units: + + `BoardTaskExactLogSummarySelector` + - dedupes repeated refs + - applies tool-anchor-over-message precedence + - computes stable per-summary `sourceGeneration` + - does not parse transcript content + - sets `canLoadDetail` conservatively + - omits `sourceGeneration` when `canLoadDetail === false` + + `BoardTaskExactLogDetailSelector` + - filters unrelated tools from same AI response + - keeps filtered internal-user results in the AI rendering path + - keeps paired tool_use + tool_result + - preserves explicit assistant text when linked + - rebuilds derived fields after block filtering + - keeps `toolUseResult` only for the surviving matching tool result + - dedupes assistant streaming entries by `requestId` after filtering + - never searches outside the summary's file-local source set for missing pairs + +3. `BoardTaskExactLogChunkBuilder` + - builds renderable `EnhancedChunk[]` + - never merges adjacent candidates into one cross-bundle AI chunk + - no crash on minimal bundles + +4. `BoardTaskExactLogsService` + - returns sorted summaries + - empty when feature disabled + - returns `{ items: [] }` for unknown task + - does not invoke transcript parsing in the summary path + - does not touch the exact-log strict parser or transcript parse cache in the summary path + - emits stable per-summary `sourceGeneration` values + - never emits `sourceGeneration` for non-expandable summaries + +5. `BoardTaskExactLogDetailService` + - returns `status: 'missing'` immediately for non-expandable summaries + - returns `status: 'stale'` when requested generation no longer matches + - returns `status: 'missing'` for unknown bundle + - returns `status: 'ok'` with renderable detail for valid bundle id + - does not guess missing tool ownership + - reuses the summary actor label instead of re-deriving actor identity from filtered detail messages + +### IPC tests + +- `teams.getTaskExactLogSummaries(...)` happy path +- `teams.getTaskExactLogDetail(...)` happy path +- `teams.getTaskExactLogDetail(...)` stale-generation path +- browser fallback shape +- disabled flag path +- malformed transcript path + +### Renderer tests + +- `ExactTaskLogsSection` + - loading + - error + - empty + - renders one or more exact summaries + - reloads summaries on `stale` detail response + +### Manual validation + +Use real scenarios: + +1. normal owner task with lifecycle + comments + review +2. external actor touches another task +3. `task_link` / `task_unlink` +4. lead-session rows without `agentName` +5. task with explicit summary rows but no exact renderable detail +6. summary/detail drift after transcript update + +--- + +## Definition of Done + +This iteration is done when: + +- task popup shows: + - `Task Activity` + - `Exact Task Logs` + - `Execution Sessions` +- `Exact Task Logs` visually uses the same execution-log renderer family the user already likes +- exact logs are sourced from explicit task-linked transcript selection +- exact logs do **not** depend on legacy heuristic task/session discovery +- unrelated tools from the same AI response are not leaked into the exact view +- exact-log details are lazy-loaded, not eagerly transferred for every summary row +- main-side and renderer tests pass +- old `Execution Sessions` remains intact and isolated + +--- + +## Final Decision Summary + +The best path is: + +- **reuse the existing execution renderer** +- **do not reuse the old heuristic log discovery** +- **insert a strict explicit task-scoped transcript selection layer** + +This preserves the good UX while finally making task log attribution reliable. diff --git a/docs/iterations/schemas/board-task-transcript-v1.schema.json b/docs/iterations/schemas/board-task-transcript-v1.schema.json new file mode 100644 index 00000000..d997ee5c --- /dev/null +++ b/docs/iterations/schemas/board-task-transcript-v1.schema.json @@ -0,0 +1,192 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://claude-team.local/schemas/board-task-transcript-v1.schema.json", + "title": "Board Task Transcript V1", + "type": "object", + "properties": { + "uuid": { + "type": "string", + "minLength": 1 + }, + "timestamp": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "boardTaskLinks": { + "type": "array", + "items": { + "$ref": "#/$defs/boardTaskLink" + } + }, + "boardTaskToolActions": { + "type": "array", + "items": { + "$ref": "#/$defs/boardTaskToolAction" + } + } + }, + "$defs": { + "boardTaskLocator": { + "type": "object", + "required": ["ref", "refKind"], + "properties": { + "ref": { + "type": "string", + "minLength": 1 + }, + "refKind": { + "type": "string", + "enum": ["canonical", "display", "unknown"] + }, + "canonicalId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "actorContext": { + "type": "object", + "required": ["relation"], + "properties": { + "relation": { + "type": "string", + "enum": ["same_task", "other_active_task", "idle", "ambiguous"] + }, + "activeTask": { + "$ref": "#/$defs/boardTaskLocator" + }, + "activePhase": { + "type": "string", + "enum": ["work", "review"] + }, + "activeExecutionSeq": { + "type": "number" + } + }, + "allOf": [ + { + "if": { + "properties": { + "relation": { + "enum": ["same_task", "idle", "ambiguous"] + } + }, + "required": ["relation"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["activeTask"] }, + { "required": ["activePhase"] }, + { "required": ["activeExecutionSeq"] } + ] + } + } + } + ], + "additionalProperties": false + }, + "boardTaskLink": { + "type": "object", + "required": ["schemaVersion", "task", "targetRole", "linkKind", "actorContext"], + "properties": { + "schemaVersion": { + "const": 1 + }, + "toolUseId": { + "type": "string", + "minLength": 1 + }, + "task": { + "$ref": "#/$defs/boardTaskLocator" + }, + "targetRole": { + "type": "string", + "enum": ["subject", "related"] + }, + "linkKind": { + "type": "string", + "enum": ["execution", "lifecycle", "board_action"] + }, + "taskArgumentSlot": { + "type": "string", + "enum": ["taskId", "targetId"] + }, + "actorContext": { + "$ref": "#/$defs/actorContext" + } + }, + "allOf": [ + { + "if": { + "properties": { + "linkKind": { + "const": "execution" + } + }, + "required": ["linkKind"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["taskArgumentSlot"] } + ] + } + } + } + ], + "additionalProperties": false + }, + "boardTaskToolAction": { + "type": "object", + "required": ["schemaVersion", "toolUseId", "canonicalToolName"], + "properties": { + "schemaVersion": { + "const": 1 + }, + "toolUseId": { + "type": "string", + "minLength": 1 + }, + "canonicalToolName": { + "type": "string", + "minLength": 1 + }, + "input": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed", "deleted"] + }, + "owner": { "type": ["string", "null"] }, + "clarification": { "type": ["string", "null"], "enum": ["lead", "user", null] }, + "reviewer": { "type": "string" }, + "relationship": { + "type": "string", + "enum": ["blocked-by", "blocks", "related"] + }, + "commentId": { "type": "string" } + }, + "additionalProperties": false + }, + "resultRefs": { + "type": "object", + "properties": { + "commentId": { "type": "string" }, + "attachmentId": { "type": "string" }, + "filename": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true +} diff --git a/scripts/diagnose-task-log-stream.ts b/scripts/diagnose-task-log-stream.ts new file mode 100644 index 00000000..fc01d956 --- /dev/null +++ b/scripts/diagnose-task-log-stream.ts @@ -0,0 +1,92 @@ +import { BoardTaskLogDiagnosticsService } from '../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; + +function usage(): string { + return 'Usage: pnpm exec tsx scripts/diagnose-task-log-stream.ts [--json]'; +} + +function formatExamples( + title: string, + examples: Array<{ + timestamp: string; + toolName: string; + toolUseId?: string; + filePath: string; + messageUuid: string; + isSidechain: boolean; + agentId?: string; + }>, +): string[] { + if (examples.length === 0) { + return []; + } + + return [ + title, + ...examples.map((example) => { + const parts = [ + `- ${example.timestamp}`, + example.toolName, + `message=${example.messageUuid}`, + `file=${example.filePath}`, + `sidechain=${String(example.isSidechain)}`, + ]; + if (example.toolUseId) { + parts.push(`toolUseId=${example.toolUseId}`); + } + if (example.agentId) { + parts.push(`agentId=${example.agentId}`); + } + return parts.join(' '); + }), + ]; +} + +async function main(): Promise { + const teamName = process.argv[2]; + const taskRef = process.argv[3]; + const jsonMode = process.argv.includes('--json'); + + if (!teamName || !taskRef) { + console.error(usage()); + process.exitCode = 1; + return; + } + + const diagnosticsService = new BoardTaskLogDiagnosticsService(); + const report = await diagnosticsService.diagnose(teamName, taskRef); + + if (jsonMode) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + const lines = [ + `Task log diagnostics for ${report.teamName} #${report.task.displayId}`, + `Task: ${report.task.subject}`, + `Status: ${report.task.status}${report.task.owner ? ` owner=${report.task.owner}` : ''}`, + `Transcript files: ${report.transcript.fileCount}`, + `Explicit records: total=${report.explicitRecords.total} execution=${report.explicitRecords.execution} lifecycle=${report.explicitRecords.lifecycle} boardAction=${report.explicitRecords.boardAction}`, + `Explicit participants: ${report.explicitRecords.participants.join(', ') || 'none'}`, + `Explicit tool names: ${report.explicitRecords.toolNames.join(', ') || 'none'}`, + `Interval tool results: total=${report.intervalToolResults.total} boardMcp=${report.intervalToolResults.boardMcp} worker=${report.intervalToolResults.worker.total} explicitWorker=${report.intervalToolResults.worker.explicitLinked} missingWorker=${report.intervalToolResults.worker.missingExplicit}`, + `Stream: participants=${report.stream.participants.join(', ') || 'none'} defaultFilter=${report.stream.defaultFilter} segments=${report.stream.segmentCount}`, + `Visible stream tools: ${report.stream.visibleToolNames.join(', ') || 'none'}`, + 'Diagnosis:', + ...report.diagnosis.map((line) => `- ${line}`), + ...formatExamples( + 'Missing worker tool results without explicit links:', + report.intervalToolResults.worker.examples, + ), + ...formatExamples( + 'Empty payload examples from current stream:', + report.stream.emptyPayloadExamples, + ), + ]; + + console.log(lines.join('\n')); +} + +main().catch((error) => { + console.error(String(error)); + process.exitCode = 1; +}); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c6b72c9f..cd33a6d4 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -90,6 +90,10 @@ import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { BranchStatusService, + BoardTaskActivityService, + BoardTaskLogStreamService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, ChangeExtractorService, CliInstallerService, FileContentResolver, @@ -130,6 +134,10 @@ export function initializeIpcHandlers( teamProvisioningService: TeamProvisioningService, teamMemberLogsFinder: TeamMemberLogsFinder, memberStatsComputer: MemberStatsComputer, + boardTaskActivityService: BoardTaskActivityService, + boardTaskLogStreamService: BoardTaskLogStreamService, + boardTaskExactLogsService: BoardTaskExactLogsService, + boardTaskExactLogDetailService: BoardTaskExactLogDetailService, teammateToolTracker: TeammateToolTracker | undefined, branchStatusService: BranchStatusService | undefined, contextCallbacks: { @@ -174,7 +182,11 @@ export function initializeIpcHandlers( memberStatsComputer, teamBackupService, teammateToolTracker, - branchStatusService + branchStatusService, + boardTaskActivityService, + boardTaskLogStreamService, + boardTaskExactLogsService, + boardTaskExactLogDetailService ); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 6ce68389..d05f41fb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -21,10 +21,14 @@ import { TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, - TEAM_GET_MESSAGES_PAGE, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, + TEAM_GET_MESSAGES_PAGE, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, @@ -98,15 +102,15 @@ import { buildActionModeAgentBlock, isAgentActionMode, } from '../services/team/actionModeInstructions'; +import { + buildReplaceMembersDiff, + buildReplaceMembersSummaryMessage, +} from '../services/team/memberUpdateNotifications'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; -import { - buildReplaceMembersDiff, - buildReplaceMembersSummaryMessage, -} from '../services/team/memberUpdateNotifications'; import { validateFromField, @@ -118,6 +122,10 @@ import { import type { BranchStatusService, + BoardTaskActivityService, + BoardTaskLogStreamService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, MemberStatsComputer, TeamDataService, TeammateToolTracker, @@ -131,6 +139,10 @@ import type { AttachmentFileData, AttachmentMeta, AttachmentPayload, + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, CreateTaskRequest, EffortLevel, GlobalTask, @@ -143,6 +155,7 @@ import type { MemberLogSummary, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + MessagesPage, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -155,7 +168,6 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, - MessagesPage, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -184,7 +196,7 @@ const SEEN_RATE_LIMIT_KEYS_MAX = 500; async function getDurableLeadTeammateRoster( teamName: string, leadName: string -): Promise> { +): Promise<{ name: string; role?: string }[]> { const normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? ''; const leadLower = normalize(leadName); const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0)); @@ -241,7 +253,7 @@ async function getDurableLeadTeammateRoster( function buildLeadRosterContextBlock( teamName: string, leadName: string, - teammates: Array<{ name: string; role?: string }> + teammates: { name: string; role?: string }[] ): string | null { if (teammates.length === 0) return null; @@ -377,6 +389,10 @@ let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; let branchStatusService: BranchStatusService | null = null; +let boardTaskActivityService: BoardTaskActivityService | null = null; +let boardTaskLogStreamService: BoardTaskLogStreamService | null = null; +let boardTaskExactLogsService: BoardTaskExactLogsService | null = null; +let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null; const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); @@ -407,7 +423,11 @@ export function initializeTeamHandlers( statsComputer?: MemberStatsComputer, backupService?: TeamBackupService, toolTracker?: TeammateToolTracker, - branchTracker?: BranchStatusService + branchTracker?: BranchStatusService, + taskActivityService?: BoardTaskActivityService, + taskLogStreamService?: BoardTaskLogStreamService, + taskExactLogsService?: BoardTaskExactLogsService, + taskExactLogDetailService?: BoardTaskExactLogDetailService ): void { teamDataService = service; teamProvisioningService = provisioningService; @@ -416,6 +436,10 @@ export function initializeTeamHandlers( teamBackupService = backupService ?? null; teammateToolTracker = toolTracker ?? null; branchStatusService = branchTracker ?? null; + boardTaskActivityService = taskActivityService ?? null; + boardTaskLogStreamService = taskLogStreamService ?? null; + boardTaskExactLogsService = taskExactLogsService ?? null; + boardTaskExactLogDetailService = taskExactLogDetailService ?? null; } export function registerTeamHandlers(ipcMain: IpcMain): void { @@ -450,6 +474,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); + ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity); + 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); ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); @@ -517,6 +545,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); + ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); + ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); @@ -579,6 +611,34 @@ function getBranchStatusService(): BranchStatusService { return branchStatusService; } +function getBoardTaskActivityService(): BoardTaskActivityService { + if (!boardTaskActivityService) { + throw new Error('Board task activity service is not initialized'); + } + return boardTaskActivityService; +} + +function getBoardTaskLogStreamService(): BoardTaskLogStreamService { + if (!boardTaskLogStreamService) { + throw new Error('Board task log stream service is not initialized'); + } + return boardTaskLogStreamService; +} + +function getBoardTaskExactLogsService(): BoardTaskExactLogsService { + if (!boardTaskExactLogsService) { + throw new Error('Board task exact logs service is not initialized'); + } + return boardTaskExactLogsService; +} + +function getBoardTaskExactLogDetailService(): BoardTaskExactLogDetailService { + if (!boardTaskExactLogDetailService) { + throw new Error('Board task exact log detail service is not initialized'); + } + return boardTaskExactLogDetailService; +} + async function wrapTeamHandler( operation: string, handler: () => Promise @@ -1371,7 +1431,7 @@ async function handlePrepareProvisioning( ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; - let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined; + let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1391,7 +1451,7 @@ async function handlePrepareProvisioning( if (!Array.isArray(providerIds)) { return { success: false, error: 'providerIds must be an array when provided' }; } - const normalized: Array<'anthropic' | 'codex' | 'gemini'> = []; + const normalized: ('anthropic' | 'codex' | 'gemini')[] = []; for (const entry of providerIds) { if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; @@ -2440,6 +2500,94 @@ async function handleGetLogsForTask( ); } +async function handleGetTaskActivity( + _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('getTaskActivity', () => + getBoardTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskLogStream( + _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('getTaskLogStream', () => + getBoardTaskLogStreamService().getTaskLogStream(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskExactLogSummaries( + _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('getTaskExactLogSummaries', () => + getBoardTaskExactLogsService().getTaskExactLogSummaries(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskExactLogDetail( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + exactLogId: unknown, + expectedSourceGeneration: 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' }; + } + if (typeof exactLogId !== 'string' || exactLogId.trim().length === 0) { + return { success: false, error: 'exactLogId must be a non-empty string' }; + } + if ( + typeof expectedSourceGeneration !== 'string' || + expectedSourceGeneration.trim().length === 0 + ) { + return { success: false, error: 'expectedSourceGeneration must be a non-empty string' }; + } + return wrapTeamHandler('getTaskExactLogDetail', () => + getBoardTaskExactLogDetailService().getTaskExactLogDetail( + vTeam.value!, + vTask.value!, + exactLogId.trim(), + expectedSourceGeneration.trim() + ) + ); +} + function getMemberStatsComputer(): MemberStatsComputer { if (!memberStatsComputer) { throw new Error('Member stats computer is not initialized'); diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts index d3b7512d..59991c70 100644 --- a/src/main/services/team/TaskBoundaryParser.ts +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -3,6 +3,11 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import * as readline from 'readline'; +import { + canonicalizeAgentTeamsToolName, + isAgentTeamsTaskBoundaryToolName, +} from './agentTeamsToolNames'; + import type { TaskBoundariesResult, TaskBoundary, @@ -31,8 +36,6 @@ interface ToolUseInfo { filePath?: string; } -const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']); - type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none'; function extractTaskId(input: Record): string { @@ -102,7 +105,7 @@ export class TaskBoundaryParser { const b = block as Record; if (b.type !== 'tool_use') continue; const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); + const toolName = canonicalizeAgentTeamsToolName(rawName); const toolUseId = typeof b.id === 'string' ? b.id : ''; const input = b.input as Record | undefined; const fp = typeof input?.file_path === 'string' ? input.file_path : undefined; @@ -238,8 +241,8 @@ export class TaskBoundaryParser { if (b.type !== 'tool_use') continue; const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); - if (!MCP_TASK_BOUNDARY_TOOLS.has(toolName)) continue; + const toolName = canonicalizeAgentTeamsToolName(rawName); + if (!isAgentTeamsTaskBoundaryToolName(toolName)) continue; const input = b.input as Record | undefined; if (!input) continue; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 6b514754..6e42d880 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -10,6 +10,10 @@ import * as readline from 'readline'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { + canonicalizeAgentTeamsToolName, + lineHasAgentTeamsTaskBoundaryToolName, +} from './agentTeamsToolNames'; import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types'; @@ -684,7 +688,7 @@ export class TeamMemberLogsFinder { async listAttributedSubagentFiles( teamName: string - ): Promise> { + ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) return []; @@ -700,12 +704,12 @@ export class TeamMemberLogsFinder { ? [currentLeadSessionId] : sessionIds; const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds); - const results: Array<{ + const results: { memberName: string; sessionId: string; filePath: string; mtimeMs: number; - }> = []; + }[] = []; const settled = await Promise.all( candidates.map(async (candidate) => { @@ -764,12 +768,7 @@ export class TeamMemberLogsFinder { stream.destroy(); return true; } - if ( - (line.includes('"task_start"') || - line.includes('"task_complete"') || - line.includes('"task_set_status"')) && - pattern.test(line) - ) { + if (lineHasAgentTeamsTaskBoundaryToolName(line) && pattern.test(line)) { rl.close(); stream.destroy(); return true; @@ -1146,13 +1145,9 @@ export class TeamMemberLogsFinder { // Skip read-only task tools — they reference taskId but don't indicate // that this session actually WORKED on the task. Agents commonly call // task_get to check dependencies from other tasks, producing false matches. - const toolName = typeof b.name === 'string' ? b.name : ''; - if ( - toolName === 'task_get' || - toolName === 'mcp__agent-teams__task_get' || - toolName === 'TaskGet' - ) - continue; + const rawToolName = typeof b.name === 'string' ? b.name : ''; + const toolName = canonicalizeAgentTeamsToolName(rawToolName); + if (toolName === 'task_get' || toolName === 'TaskGet') continue; const input = b.input as Record | undefined; if (!input) continue; diff --git a/src/main/services/team/agentTeamsToolNames.ts b/src/main/services/team/agentTeamsToolNames.ts new file mode 100644 index 00000000..19457c80 --- /dev/null +++ b/src/main/services/team/agentTeamsToolNames.ts @@ -0,0 +1,43 @@ +const AGENT_TEAMS_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; + +const TASK_BOUNDARY_TOOL_NAMES = ['task_start', 'task_complete', 'task_set_status'] as const; +const TASK_BOUNDARY_TOOL_SET = new Set(TASK_BOUNDARY_TOOL_NAMES); + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const TASK_BOUNDARY_TOOL_LINE_PATTERN = new RegExp( + `"name"\\s*:\\s*"(?:${[ + ...TASK_BOUNDARY_TOOL_NAMES, + ...TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${toolName}`), + ...AGENT_TEAMS_PREFIXES.flatMap((prefix) => + TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `${prefix}${toolName}`) + ), + ...AGENT_TEAMS_PREFIXES.flatMap((prefix) => + TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${prefix}${toolName}`) + ), + ] + .map(escapeRegex) + .join('|')})"` +); + +export function canonicalizeAgentTeamsToolName(rawName: string): string { + const normalized = rawName.replace(/^proxy_/, ''); + + for (const prefix of AGENT_TEAMS_PREFIXES) { + if (normalized.startsWith(prefix)) { + return normalized.slice(prefix.length); + } + } + + return normalized; +} + +export function isAgentTeamsTaskBoundaryToolName(rawName: string): boolean { + return TASK_BOUNDARY_TOOL_SET.has(canonicalizeAgentTeamsToolName(rawName)); +} + +export function lineHasAgentTeamsTaskBoundaryToolName(line: string): boolean { + return TASK_BOUNDARY_TOOL_LINE_PATTERN.test(line); +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index ddc17421..b4e954c9 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,4 +1,9 @@ export { BranchStatusService } from './BranchStatusService'; +export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; +export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; +export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; +export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; +export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; export { CascadeGuard } from './CascadeGuard'; export { ChangeExtractorService } from './ChangeExtractorService'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts new file mode 100644 index 00000000..c291484a --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts @@ -0,0 +1,83 @@ +import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder'; + +import type { BoardTaskActivityEntry, TeamTask } from '@shared/types'; +import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader'; +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; + +function cloneTaskRef(task: BoardTaskActivityRecord['task']): BoardTaskActivityEntry['task'] { + return { + locator: { ...task.locator }, + resolution: task.resolution, + ...(task.taskRef ? { taskRef: { ...task.taskRef } } : {}), + }; +} + +function cloneActorContext( + actorContext: BoardTaskActivityRecord['actorContext'] +): BoardTaskActivityEntry['actorContext'] { + return { + relation: actorContext.relation, + ...(actorContext.activeTask ? { activeTask: cloneTaskRef(actorContext.activeTask) } : {}), + ...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}), + ...(actorContext.activeExecutionSeq + ? { activeExecutionSeq: actorContext.activeExecutionSeq } + : {}), + }; +} + +function cloneAction( + action: BoardTaskActivityRecord['action'] +): BoardTaskActivityEntry['action'] | undefined { + if (!action) return undefined; + + return { + ...(action.canonicalToolName ? { canonicalToolName: action.canonicalToolName } : {}), + ...(action.toolUseId ? { toolUseId: action.toolUseId } : {}), + category: action.category, + ...(action.peerTask ? { peerTask: cloneTaskRef(action.peerTask) } : {}), + ...(action.relationshipPerspective + ? { relationshipPerspective: action.relationshipPerspective } + : {}), + ...(action.details ? { details: { ...action.details } } : {}), + }; +} + +export class BoardTaskActivityEntryBuilder { + constructor( + private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder() + ) {} + + buildForTask(args: { + teamName: string; + targetTask: TeamTask; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): BoardTaskActivityEntry[] { + return this.buildFromRecords(this.recordBuilder.buildForTask(args)); + } + + buildFromRecords(records: BoardTaskActivityRecord[]): BoardTaskActivityEntry[] { + return records.map((record) => ({ + id: record.id, + timestamp: record.timestamp, + task: cloneTaskRef(record.task), + linkKind: record.linkKind, + targetRole: record.targetRole, + actor: { + ...(record.actor.memberName ? { memberName: record.actor.memberName } : {}), + role: record.actor.role, + sessionId: record.actor.sessionId, + ...(record.actor.agentId ? { agentId: record.actor.agentId } : {}), + isSidechain: record.actor.isSidechain, + }, + actorContext: cloneActorContext(record.actorContext), + ...(record.action ? { action: cloneAction(record.action) } : {}), + source: { + messageUuid: record.source.messageUuid, + filePath: record.source.filePath, + ...(record.source.toolUseId ? { toolUseId: record.source.toolUseId } : {}), + sourceOrder: record.source.sourceOrder, + }, + })); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts new file mode 100644 index 00000000..34464243 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts @@ -0,0 +1,54 @@ +interface CacheEntry { + mtimeMs: number; + size: number; + value: T; +} + +export class BoardTaskActivityParseCache { + private readonly cache = new Map>(); + private readonly inFlight = new Map>(); + + getIfFresh(filePath: string, mtimeMs: number, size: number): T | null { + const cached = this.cache.get(filePath); + if (!cached) return null; + if (cached.mtimeMs !== mtimeMs || cached.size !== size) { + this.cache.delete(filePath); + return null; + } + return cached.value; + } + + getInFlight(filePath: string): Promise | null { + return this.inFlight.get(filePath) ?? null; + } + + setInFlight(filePath: string, promise: Promise): void { + this.inFlight.set(filePath, promise); + } + + clearInFlight(filePath: string): void { + this.inFlight.delete(filePath); + } + + set(filePath: string, mtimeMs: number, size: number, value: T): void { + this.cache.set(filePath, { mtimeMs, size, value }); + } + + clearForPath(filePath: string): void { + this.cache.delete(filePath); + this.inFlight.delete(filePath); + } + + retainOnly(filePaths: Set): void { + for (const filePath of this.cache.keys()) { + if (!filePaths.has(filePath)) { + this.cache.delete(filePath); + } + } + for (const filePath of this.inFlight.keys()) { + if (!filePaths.has(filePath)) { + this.inFlight.delete(filePath); + } + } + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts new file mode 100644 index 00000000..cf2900b8 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts @@ -0,0 +1,25 @@ +import type { + BoardTaskActivityAction, + BoardTaskActivityActor, + BoardTaskActivityActorContext, + BoardTaskActivityLinkKind, + BoardTaskActivityTargetRole, + BoardTaskActivityTaskRef, +} from '@shared/types'; + +export interface BoardTaskActivityRecord { + id: string; + timestamp: string; + task: BoardTaskActivityTaskRef; + linkKind: BoardTaskActivityLinkKind; + targetRole: BoardTaskActivityTargetRole; + actor: BoardTaskActivityActor; + actorContext: BoardTaskActivityActorContext; + action?: BoardTaskActivityAction; + source: { + messageUuid: string; + filePath: string; + toolUseId?: string; + sourceOrder: number; + }; +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts new file mode 100644 index 00000000..3e20e820 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts @@ -0,0 +1,382 @@ +import { createLogger } from '@shared/utils/logger'; +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import type { + BoardTaskActivityAction, + BoardTaskActivityActor, + BoardTaskActivityCategory, + BoardTaskActivityTaskRef, + BoardTaskLocator, + TaskRef, + TeamTask, +} from '@shared/types'; +import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader'; +import type { + ParsedBoardTaskLink, + ParsedBoardTaskToolAction, +} from '../contract/BoardTaskTranscriptContract'; +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; + +interface TaskLookup { + byId: Map; + byDisplayId: Map; +} + +const logger = createLogger('Service:BoardTaskActivityRecordBuilder'); + +const CANONICAL_TASK_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function noteReadDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function buildTaskRef(teamName: string, task: TeamTask): TaskRef { + return { + taskId: task.id, + displayId: getTaskDisplayId(task), + teamName, + }; +} + +function normalizeDisplayRef(value: string): string { + return value.trim().toLowerCase(); +} + +function looksLikeCanonicalTaskId(value: string): boolean { + return CANONICAL_TASK_ID_PATTERN.test(value.trim()); +} + +function buildTaskLookup(tasks: TeamTask[]): TaskLookup { + const byId = new Map(); + const byDisplayId = new Map(); + + for (const task of tasks) { + byId.set(task.id, task); + const displayId = normalizeDisplayRef(getTaskDisplayId(task)); + const list = byDisplayId.get(displayId) ?? []; + list.push(task); + byDisplayId.set(displayId, list); + } + + return { byId, byDisplayId }; +} + +function resolveLocatorToTaskRef( + teamName: string, + locator: BoardTaskLocator, + lookup: TaskLookup +): BoardTaskActivityTaskRef { + const canonicalCandidate = + (locator.canonicalId && lookup.byId.get(locator.canonicalId)) || + (locator.refKind === 'canonical' ? lookup.byId.get(locator.ref) : undefined) || + (locator.refKind === 'unknown' && looksLikeCanonicalTaskId(locator.ref) + ? lookup.byId.get(locator.ref) + : undefined); + + if (canonicalCandidate) { + return { + locator, + resolution: canonicalCandidate.status === 'deleted' ? 'deleted' : 'resolved', + taskRef: buildTaskRef(teamName, canonicalCandidate), + }; + } + + const displayCandidates = lookup.byDisplayId.get(normalizeDisplayRef(locator.ref)) ?? []; + if (displayCandidates.length === 1) { + const task = displayCandidates[0]; + return { + locator, + resolution: task.status === 'deleted' ? 'deleted' : 'resolved', + taskRef: buildTaskRef(teamName, task), + }; + } + + if (displayCandidates.length > 1) { + noteReadDiagnostic('ambiguous_locator', { refKind: locator.refKind }); + return { + locator, + resolution: 'ambiguous', + }; + } + + noteReadDiagnostic('unresolved_locator', { refKind: locator.refKind }); + return { + locator, + resolution: 'unresolved', + }; +} + +function locatorCouldMatchTask( + locator: BoardTaskLocator, + targetTask: TeamTask, + lookup: TaskLookup +): boolean { + if (locator.canonicalId === targetTask.id) return true; + if (locator.refKind === 'canonical' && locator.ref === targetTask.id) return true; + + const targetDisplayId = getTaskDisplayId(targetTask); + const normalizedLocatorRef = normalizeDisplayRef(locator.ref); + const normalizedTargetDisplayId = normalizeDisplayRef(targetDisplayId); + if (normalizedLocatorRef !== normalizedTargetDisplayId) return false; + + const candidates = lookup.byDisplayId.get(normalizedTargetDisplayId) ?? []; + if (candidates.length === 0) return false; + return candidates.some((candidate) => candidate.id === targetTask.id); +} + +function buildActionMap( + actions: ParsedBoardTaskToolAction[] +): Map { + const actionMap = new Map(); + for (const action of actions) { + if (actionMap.has(action.toolUseId)) { + noteReadDiagnostic('duplicate_action_tool_use_id', { toolUseId: action.toolUseId }); + continue; + } + actionMap.set(action.toolUseId, action); + } + return actionMap; +} + +function buildActionCategory(action: ParsedBoardTaskToolAction): BoardTaskActivityCategory { + switch (action.canonicalToolName) { + case 'task_start': + case 'task_complete': + case 'task_set_status': + return 'status'; + case 'review_start': + case 'review_request': + case 'review_approve': + case 'review_request_changes': + return 'review'; + case 'task_add_comment': + case 'task_get_comment': + return 'comment'; + case 'task_set_owner': + return 'assignment'; + case 'task_get': + return 'read'; + case 'task_attach_file': + case 'task_attach_comment_file': + return 'attachment'; + case 'task_link': + case 'task_unlink': + return 'relationship'; + case 'task_set_clarification': + return 'clarification'; + default: + return 'other'; + } +} + +function buildActionDetails( + action: ParsedBoardTaskToolAction +): BoardTaskActivityAction['details'] | undefined { + const details = { + ...(action.input?.status ? { status: action.input.status } : {}), + ...(action.input && 'owner' in action.input ? { owner: action.input.owner } : {}), + ...(action.input && 'clarification' in action.input + ? { clarification: action.input.clarification } + : {}), + ...(action.input?.reviewer ? { reviewer: action.input.reviewer } : {}), + ...(action.input?.relationship ? { relationship: action.input.relationship } : {}), + ...(action.input?.commentId ? { commentId: action.input.commentId } : {}), + ...(action.resultRefs?.commentId ? { commentId: action.resultRefs.commentId } : {}), + ...(action.resultRefs?.attachmentId ? { attachmentId: action.resultRefs.attachmentId } : {}), + ...(action.resultRefs?.filename ? { filename: action.resultRefs.filename } : {}), + }; + + return Object.keys(details).length > 0 ? details : undefined; +} + +function buildRelationshipPerspective( + link: ParsedBoardTaskLink, + action: ParsedBoardTaskToolAction +): BoardTaskActivityAction['relationshipPerspective'] | undefined { + const relationship = action.input?.relationship; + if (!relationship) { + return undefined; + } + if (relationship === 'related') { + return 'symmetric'; + } + if (relationship === 'blocked-by') { + return link.targetRole === 'subject' ? 'incoming' : 'outgoing'; + } + if (relationship === 'blocks') { + return link.targetRole === 'subject' ? 'outgoing' : 'incoming'; + } + return undefined; +} + +function buildAction(args: { + action: ParsedBoardTaskToolAction | undefined; + link: ParsedBoardTaskLink; + peerTask?: BoardTaskActivityTaskRef; +}): BoardTaskActivityAction | undefined { + const { action, link, peerTask } = args; + if (!action) return undefined; + const category = buildActionCategory(action); + const details = buildActionDetails(action); + const relationshipPerspective = + category === 'relationship' ? buildRelationshipPerspective(link, action) : undefined; + + return { + canonicalToolName: action.canonicalToolName, + toolUseId: action.toolUseId, + category, + ...(details ? { details } : {}), + ...(category === 'relationship' && peerTask ? { peerTask } : {}), + ...(relationshipPerspective ? { relationshipPerspective } : {}), + }; +} + +function resolveActivityActor(message: RawTaskActivityMessage): BoardTaskActivityActor { + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + return { + ...(memberName ? { memberName } : {}), + role: memberName + ? message.isSidechain + ? 'member' + : 'lead' + : message.isSidechain + ? 'member' + : 'unknown', + sessionId: message.sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function resolvePeerTask( + teamName: string, + currentLink: ParsedBoardTaskLink, + allLinks: ParsedBoardTaskLink[], + targetTask: TeamTask, + lookup: TaskLookup +): BoardTaskActivityTaskRef | undefined { + for (const link of allLinks) { + if (link === currentLink) continue; + if (link.toolUseId !== currentLink.toolUseId) continue; + if (locatorCouldMatchTask(link.task, targetTask, lookup)) continue; + return resolveLocatorToTaskRef(teamName, link.task, lookup); + } + return undefined; +} + +function buildActorContext( + teamName: string, + actorContext: ParsedBoardTaskLink['actorContext'], + lookup: TaskLookup +): BoardTaskActivityRecord['actorContext'] { + return { + relation: actorContext.relation, + ...(actorContext.activeTask + ? { activeTask: resolveLocatorToTaskRef(teamName, actorContext.activeTask, lookup) } + : {}), + ...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}), + ...(actorContext.activeExecutionSeq + ? { activeExecutionSeq: actorContext.activeExecutionSeq } + : {}), + }; +} + +function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityRecord): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +export class BoardTaskActivityRecordBuilder { + buildForTask(args: { + teamName: string; + targetTask: TeamTask; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): BoardTaskActivityRecord[] { + const lookup = buildTaskLookup(args.tasks); + const records: BoardTaskActivityRecord[] = []; + const seenIds = new Set(); + + for (const message of args.messages) { + const actionMap = buildActionMap(message.boardTaskToolActions); + + for (const link of message.boardTaskLinks) { + const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup); + if ( + resolvedTask.taskRef?.taskId !== args.targetTask.id && + !locatorCouldMatchTask(link.task, args.targetTask, lookup) + ) { + continue; + } + + const action = + link.linkKind === 'execution' || !link.toolUseId + ? undefined + : actionMap.get(link.toolUseId); + const peerTask = resolvePeerTask( + args.teamName, + link, + message.boardTaskLinks, + args.targetTask, + lookup + ); + const record: BoardTaskActivityRecord = { + id: [ + message.uuid, + link.toolUseId ?? 'ambient', + link.task.ref, + link.targetRole, + link.linkKind, + ].join(':'), + timestamp: message.timestamp, + task: resolvedTask, + linkKind: link.linkKind, + targetRole: link.targetRole, + actor: resolveActivityActor(message), + actorContext: buildActorContext(args.teamName, link.actorContext, lookup), + ...(action ? { action: buildAction({ action, link, peerTask }) } : {}), + source: { + messageUuid: message.uuid, + filePath: message.filePath, + ...(link.toolUseId ? { toolUseId: link.toolUseId } : {}), + sourceOrder: message.sourceOrder, + }, + }; + + if (seenIds.has(record.id)) { + continue; + } + seenIds.add(record.id); + records.push(record); + } + } + + return records.sort(compareRecords); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts new file mode 100644 index 00000000..d981a08a --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts @@ -0,0 +1,37 @@ +import { TeamTaskReader } from '../../TeamTaskReader'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; +import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityTranscriptReader } from './BoardTaskActivityTranscriptReader'; + +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; + +export class BoardTaskActivityRecordSource { + constructor( + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(), + private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder() + ) {} + + async getTaskRecords(teamName: string, taskId: string): Promise { + const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.listTranscriptFiles(teamName), + ]); + + const tasks = [...activeTasks, ...deletedTasks]; + const targetTask = tasks.find((task) => task.id === taskId); + if (!targetTask || transcriptFiles.length === 0) { + return []; + } + + const messages = await this.transcriptReader.readFiles(transcriptFiles); + return this.recordBuilder.buildForTask({ + teamName, + targetTask, + tasks, + messages, + }); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts new file mode 100644 index 00000000..47f6b5d9 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts @@ -0,0 +1,21 @@ +import { BoardTaskActivityEntryBuilder } from './BoardTaskActivityEntryBuilder'; +import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; +import { isBoardTaskActivityReadEnabled } from './featureGates'; + +import type { BoardTaskActivityEntry } from '@shared/types'; + +export class BoardTaskActivityService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly entryBuilder: BoardTaskActivityEntryBuilder = new BoardTaskActivityEntryBuilder() + ) {} + + async getTaskActivity(teamName: string, taskId: string): Promise { + if (!isBoardTaskActivityReadEnabled()) { + return []; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + return this.entryBuilder.buildFromRecords(records); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts new file mode 100644 index 00000000..89006645 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import * as fs from 'fs/promises'; +import * as readline from 'readline'; + +import { yieldToEventLoop } from '@main/utils/asyncYield'; + +import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache'; +import { + parseBoardTaskLinks, + parseBoardTaskToolActions, + type ParsedBoardTaskLink, + type ParsedBoardTaskToolAction, +} from '../contract/BoardTaskTranscriptContract'; + +const logger = createLogger('Service:BoardTaskActivityTranscriptReader'); + +export interface RawTaskActivityMessage { + filePath: string; + uuid: string; + timestamp: string; + sessionId: string; + agentId?: string; + agentName?: string; + isSidechain: boolean; + boardTaskLinks: ParsedBoardTaskLink[]; + boardTaskToolActions: ParsedBoardTaskToolAction[]; + sourceOrder: number; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +export class BoardTaskActivityTranscriptReader { + private readonly cache = new BoardTaskActivityParseCache(); + + async readFiles(filePaths: string[]): Promise { + const uniqueFilePaths = [...new Set(filePaths)].sort(); + this.cache.retainOnly(new Set(uniqueFilePaths)); + + const parsedFiles = await Promise.all( + uniqueFilePaths.map((filePath) => this.readFile(filePath)) + ); + return parsedFiles.flat(); + } + + private async readFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.parseFile(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch (error) { + logger.debug(`Skipping unreadable task-activity transcript ${filePath}: ${String(error)}`); + this.cache.clearForPath(filePath); + return []; + } + } + + private async parseFile(filePath: string): Promise { + const results: RawTaskActivityMessage[] = []; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let sourceOrder = 0; + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const parsed = JSON.parse(line) as unknown; + const record = asRecord(parsed); + if (!record) continue; + + const uuid = typeof record.uuid === 'string' ? record.uuid : ''; + const sessionId = typeof record.sessionId === 'string' ? record.sessionId : ''; + const timestamp = typeof record.timestamp === 'string' ? record.timestamp : ''; + if (!uuid || !sessionId || !timestamp) continue; + + const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks); + if (boardTaskLinks.length === 0) continue; + + sourceOrder += 1; + results.push({ + filePath, + uuid, + timestamp, + sessionId, + agentId: typeof record.agentId === 'string' ? record.agentId : undefined, + agentName: typeof record.agentName === 'string' ? record.agentName : undefined, + isSidechain: record.isSidechain === true, + boardTaskLinks, + boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions), + sourceOrder, + }); + } catch (error) { + logger.debug(`Skipping malformed task-activity line in ${filePath}: ${String(error)}`); + } + + if (sourceOrder > 0 && sourceOrder % 250 === 0) { + await yieldToEventLoop(); + } + } + return results; + } +} diff --git a/src/main/services/team/taskLogs/activity/featureGates.ts b/src/main/services/team/taskLogs/activity/featureGates.ts new file mode 100644 index 00000000..c8842d10 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskActivityReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_ACTIVITY_READ_ENABLED, true); +} diff --git a/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts b/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts new file mode 100644 index 00000000..425517ad --- /dev/null +++ b/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts @@ -0,0 +1,308 @@ +import { createLogger } from '@shared/utils/logger'; +import type { + BoardTaskActivityLinkKind, + BoardTaskActivityPhase, + BoardTaskActivityTargetRole, + BoardTaskActorRelation, + BoardTaskLocator, +} from '@shared/types'; + +const logger = createLogger('Service:BoardTaskTranscriptContract'); + +export interface ParsedBoardTaskActorContext { + relation: BoardTaskActorRelation; + activeTask?: BoardTaskLocator; + activePhase?: BoardTaskActivityPhase; + activeExecutionSeq?: number; +} + +export interface ParsedBoardTaskLink { + schemaVersion: 1; + toolUseId?: string; + task: BoardTaskLocator; + targetRole: BoardTaskActivityTargetRole; + linkKind: BoardTaskActivityLinkKind; + taskArgumentSlot?: 'taskId' | 'targetId'; + actorContext: ParsedBoardTaskActorContext; +} + +export interface ParsedBoardTaskToolAction { + schemaVersion: 1; + toolUseId: string; + canonicalToolName: string; + input?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted'; + owner?: string | null; + clarification?: 'lead' | 'user' | null; + reviewer?: string; + relationship?: 'blocked-by' | 'blocks' | 'related'; + commentId?: string; + }; + resultRefs?: { + commentId?: string; + attachmentId?: string; + filename?: string; + }; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseNullableOwner(value: unknown): string | null | undefined { + if (value === null) return null; + const normalized = asNonEmptyString(value); + if (!normalized) return undefined; + if (normalized === 'clear' || normalized === 'none') { + return null; + } + return normalized; +} + +function parseStatus( + value: unknown +): 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined { + const normalized = asNonEmptyString(value); + if ( + normalized === 'pending' || + normalized === 'in_progress' || + normalized === 'completed' || + normalized === 'deleted' + ) { + return normalized; + } + return undefined; +} + +function parseRelationship(value: unknown): 'blocked-by' | 'blocks' | 'related' | undefined { + const normalized = asNonEmptyString(value); + if (normalized === 'blocked-by' || normalized === 'blocks' || normalized === 'related') { + return normalized; + } + return undefined; +} + +function parseClarification(value: unknown): 'lead' | 'user' | null | undefined { + if (value === null) return null; + const normalized = asNonEmptyString(value); + if (!normalized) return undefined; + if (normalized === 'lead' || normalized === 'user') { + return normalized; + } + if (normalized === 'clear') { + return null; + } + return undefined; +} + +function noteReadDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function parseSchemaVersion(record: Record): 1 | null { + if (record.schemaVersion === 1) { + return 1; + } + if (record.version === 1) { + return 1; + } + return null; +} + +export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null { + const record = asRecord(value); + if (!record) return null; + + const ref = asNonEmptyString(record.ref); + const refKind = asNonEmptyString(record.refKind); + if (!ref || (refKind !== 'canonical' && refKind !== 'display' && refKind !== 'unknown')) { + return null; + } + + const canonicalId = asNonEmptyString(record.canonicalId); + return { + ref, + refKind, + ...(canonicalId ? { canonicalId } : {}), + }; +} + +function parseActorContext(value: unknown): ParsedBoardTaskActorContext | null { + const record = asRecord(value); + if (!record) return null; + + const relation = asNonEmptyString(record.relation); + if ( + relation !== 'same_task' && + relation !== 'other_active_task' && + relation !== 'idle' && + relation !== 'ambiguous' + ) { + return null; + } + + const activeTask = parseBoardTaskLocator(record.activeTask); + const activePhase = asNonEmptyString(record.activePhase); + const activeExecutionSeq = + typeof record.activeExecutionSeq === 'number' && Number.isFinite(record.activeExecutionSeq) + ? record.activeExecutionSeq + : undefined; + + if (relation !== 'other_active_task') { + return { relation }; + } + + return { + relation, + ...(activeTask ? { activeTask } : {}), + ...(activePhase === 'work' || activePhase === 'review' ? { activePhase } : {}), + ...(activeExecutionSeq ? { activeExecutionSeq } : {}), + }; +} + +export function parseBoardTaskLinks(value: unknown): ParsedBoardTaskLink[] { + if (!Array.isArray(value)) return []; + + const parsed: ParsedBoardTaskLink[] = []; + for (const item of value) { + const record = asRecord(item); + if (!record) { + noteReadDiagnostic('link_parse_dropped', { reason: 'not_object' }); + continue; + } + + const schemaVersion = parseSchemaVersion(record); + if (schemaVersion !== 1) { + noteReadDiagnostic('link_parse_dropped', { reason: 'unsupported_version' }); + continue; + } + + const task = parseBoardTaskLocator(record.task); + const targetRole = asNonEmptyString(record.targetRole); + const linkKind = asNonEmptyString(record.linkKind); + const actorContext = parseActorContext(record.actorContext); + const rawTaskArgumentSlot = asNonEmptyString(record.taskArgumentSlot); + const taskArgumentSlot = + rawTaskArgumentSlot === 'taskId' || rawTaskArgumentSlot === 'targetId' + ? rawTaskArgumentSlot + : undefined; + const toolUseId = asNonEmptyString(record.toolUseId); + + if (!task) { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_task' }); + continue; + } + if (!actorContext) { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_actor_context' }); + continue; + } + if (targetRole !== 'subject' && targetRole !== 'related') { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_target_role' }); + continue; + } + if (linkKind !== 'execution' && linkKind !== 'lifecycle' && linkKind !== 'board_action') { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_link_kind' }); + continue; + } + const sanitizedToolUseId = toolUseId; + const sanitizedTaskArgumentSlot = linkKind === 'execution' ? undefined : taskArgumentSlot; + + parsed.push({ + schemaVersion: 1, + task, + targetRole, + linkKind, + actorContext, + ...(sanitizedToolUseId ? { toolUseId: sanitizedToolUseId } : {}), + ...(sanitizedTaskArgumentSlot ? { taskArgumentSlot: sanitizedTaskArgumentSlot } : {}), + }); + } + + return parsed; +} + +export function parseBoardTaskToolActions(value: unknown): ParsedBoardTaskToolAction[] { + if (!Array.isArray(value)) return []; + + const parsed: ParsedBoardTaskToolAction[] = []; + for (const item of value) { + const record = asRecord(item); + if (!record) { + noteReadDiagnostic('action_parse_dropped', { reason: 'not_object' }); + continue; + } + if (parseSchemaVersion(record) !== 1) { + noteReadDiagnostic('action_parse_dropped', { reason: 'unsupported_version' }); + continue; + } + + const toolUseId = asNonEmptyString(record.toolUseId); + const canonicalToolName = asNonEmptyString(record.canonicalToolName); + if (!toolUseId || !canonicalToolName) { + noteReadDiagnostic('action_parse_dropped', { reason: 'missing_identity' }); + continue; + } + + const inputRecord = asRecord(record.input); + const resultRefsRecord = asRecord(record.resultRefs); + + parsed.push({ + schemaVersion: 1, + toolUseId, + canonicalToolName, + ...(inputRecord + ? { + input: { + ...(parseStatus(inputRecord.status) !== undefined + ? { status: parseStatus(inputRecord.status) } + : {}), + ...(parseNullableOwner(inputRecord.owner) !== undefined + ? { owner: parseNullableOwner(inputRecord.owner) } + : {}), + ...(parseClarification(inputRecord.clarification) !== undefined + ? { clarification: parseClarification(inputRecord.clarification) } + : {}), + ...(asNonEmptyString(inputRecord.reviewer) + ? { reviewer: asNonEmptyString(inputRecord.reviewer) } + : {}), + ...(parseRelationship(inputRecord.relationship) !== undefined + ? { relationship: parseRelationship(inputRecord.relationship) } + : {}), + ...(asNonEmptyString(inputRecord.commentId) + ? { commentId: asNonEmptyString(inputRecord.commentId) } + : {}), + }, + } + : {}), + ...(resultRefsRecord + ? { + resultRefs: { + ...(asNonEmptyString(resultRefsRecord.commentId) + ? { commentId: asNonEmptyString(resultRefsRecord.commentId) } + : {}), + ...(asNonEmptyString(resultRefsRecord.attachmentId) + ? { attachmentId: asNonEmptyString(resultRefsRecord.attachmentId) } + : {}), + ...(asNonEmptyString(resultRefsRecord.filename) + ? { filename: asNonEmptyString(resultRefsRecord.filename) } + : {}), + }, + } + : {}), + }); + } + + return parsed; +} diff --git a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts new file mode 100644 index 00000000..b9b6d730 --- /dev/null +++ b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts @@ -0,0 +1,400 @@ +import { TeamTaskReader } from '../../TeamTaskReader'; +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; +import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService'; + +import type { ParsedMessage } from '@main/types'; +import type { TeamTask, TaskWorkInterval } from '@shared/types'; +import { getTaskDisplayId, taskMatchesRef } from '@shared/utils/taskIdentity'; + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const MAX_EXAMPLES = 10; + +export interface BoardTaskLogDiagnosticExample { + timestamp: string; + filePath: string; + messageUuid: string; + toolUseId?: string; + toolName: string; + isSidechain: boolean; + agentId?: string; +} + +export interface BoardTaskLogDiagnosticsReport { + teamName: string; + requestedTaskRef: string; + task: { + taskId: string; + displayId: string; + subject: string; + status: TeamTask['status']; + owner?: string; + workIntervals: TaskWorkInterval[]; + }; + transcript: { + fileCount: number; + files: string[]; + }; + explicitRecords: { + total: number; + execution: number; + lifecycle: number; + boardAction: number; + participants: string[]; + toolNames: string[]; + }; + intervalToolResults: { + total: number; + boardMcp: number; + worker: { + total: number; + explicitLinked: number; + missingExplicit: number; + examples: BoardTaskLogDiagnosticExample[]; + }; + }; + stream: { + participants: string[]; + defaultFilter: string; + segmentCount: number; + visibleToolNames: string[]; + emptyPayloadExamples: BoardTaskLogDiagnosticExample[]; + }; + diagnosis: string[]; +} + +function normalizeRequestedTaskRef(taskRef: string): string { + return taskRef.trim().replace(/^#/, ''); +} + +function isBoardMcpToolName(toolName: string | undefined): boolean { + if (!toolName) return false; + const normalized = toolName.trim().toLowerCase(); + return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + +function isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]): boolean { + if (!Number.isFinite(timestamp.getTime())) { + return false; + } + if (intervals.length === 0) { + return true; + } + + const time = timestamp.getTime(); + return intervals.some((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt) || time < startedAt) { + return false; + } + if (!interval.completedAt) { + return true; + } + const completedAt = Date.parse(interval.completedAt); + return !Number.isFinite(completedAt) || time <= completedAt; + }); +} + +function pushUnique(values: string[], value: string | undefined): void { + if (!value) return; + if (!values.includes(value)) { + values.push(value); + } +} + +function pushExample( + examples: BoardTaskLogDiagnosticExample[], + example: BoardTaskLogDiagnosticExample +): void { + if (examples.length < MAX_EXAMPLES) { + examples.push(example); + } +} + +function buildParticipantLabel(record: BoardTaskActivityRecord): string { + if (record.actor.memberName) { + return record.actor.memberName; + } + if (!record.actor.isSidechain || record.actor.role === 'lead') { + return 'lead session'; + } + if (record.actor.agentId) { + return `member ${record.actor.agentId.slice(0, 8)}`; + } + return `member session ${record.actor.sessionId.slice(0, 8)}`; +} + +function extractVisibleToolNames( + stream: Awaited> +): string[] { + const toolNames: string[] = []; + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolCall of message.toolCalls) { + pushUnique(toolNames, toolCall.name); + } + } + } + } + return toolNames; +} + +function buildStreamToolNameMap( + stream: Awaited> +): Map { + const toolNameByUseId = new Map(); + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + } + return toolNameByUseId; +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) return true; + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function collectEmptyPayloadExamples( + stream: Awaited> +): BoardTaskLogDiagnosticExample[] { + const examples: BoardTaskLogDiagnosticExample[] = []; + const toolNameByUseId = buildStreamToolNameMap(stream); + + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolResult of message.toolResults) { + if (!isEmptyToolPayload(toolResult.content)) { + continue; + } + pushExample(examples, { + timestamp: message.timestamp.toISOString(), + filePath: 'stream', + messageUuid: message.uuid, + toolUseId: toolResult.toolUseId, + toolName: toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool', + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + + const toolUseResult = message.toolUseResult; + if (!toolUseResult) { + continue; + } + const toolUseId = + typeof toolUseResult.toolUseId === 'string' + ? toolUseResult.toolUseId + : message.sourceToolUseID; + const contentIsEmpty = + (!('content' in toolUseResult) || isEmptyToolPayload(toolUseResult.content)) && + (!('message' in toolUseResult) || isEmptyToolPayload(toolUseResult.message)); + if (!contentIsEmpty) { + continue; + } + + pushExample(examples, { + timestamp: message.timestamp.toISOString(), + filePath: 'stream', + messageUuid: message.uuid, + ...(toolUseId ? { toolUseId } : {}), + toolName: toolUseId ? (toolNameByUseId.get(toolUseId) ?? 'unknown tool') : 'unknown tool', + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + } + } + + return examples; +} + +function buildToolNameMap(parsedMessagesByFile: Map): Map { + const toolNameByUseId = new Map(); + for (const messages of parsedMessagesByFile.values()) { + for (const message of messages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + return toolNameByUseId; +} + +export class BoardTaskLogDiagnosticsService { + constructor( + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService() + ) {} + + async diagnose(teamName: string, taskRef: string): Promise { + const normalizedRef = normalizeRequestedTaskRef(taskRef); + const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.listTranscriptFiles(teamName), + ]); + + const tasks = [...activeTasks, ...deletedTasks]; + const task = tasks.find((candidate) => taskMatchesRef(candidate, normalizedRef)); + if (!task) { + throw new Error(`Task "${taskRef}" was not found in team "${teamName}"`); + } + + const records = await this.recordSource.getTaskRecords(teamName, task.id); + const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); + const stream = await this.streamService.getTaskLogStream(teamName, task.id); + + const toolNameByUseId = buildToolNameMap(parsedMessagesByFile); + const explicitExecutionKeys = new Set( + records + .filter((record) => record.linkKind === 'execution') + .map((record) => `${record.source.messageUuid}:${record.source.toolUseId ?? ''}`) + ); + const workIntervals = Array.isArray(task.workIntervals) ? task.workIntervals : []; + + const explicitParticipants: string[] = []; + const explicitToolNames: string[] = []; + for (const record of records) { + pushUnique(explicitParticipants, buildParticipantLabel(record)); + pushUnique(explicitToolNames, record.action?.canonicalToolName); + } + + let intervalToolResultTotal = 0; + let boardMcpToolResultTotal = 0; + let workerToolResultTotal = 0; + let explicitLinkedWorkerResultTotal = 0; + let missingExplicitWorkerResultTotal = 0; + const missingExplicitWorkerExamples: BoardTaskLogDiagnosticExample[] = []; + + for (const [filePath, messages] of parsedMessagesByFile.entries()) { + for (const message of messages) { + if (message.type !== 'user' || message.toolResults.length === 0) { + continue; + } + if (!isWithinWorkIntervals(message.timestamp, workIntervals)) { + continue; + } + + for (const toolResult of message.toolResults) { + intervalToolResultTotal += 1; + const toolName = toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool'; + if (isBoardMcpToolName(toolName)) { + boardMcpToolResultTotal += 1; + continue; + } + + workerToolResultTotal += 1; + const explicitKey = `${message.uuid}:${toolResult.toolUseId}`; + if (explicitExecutionKeys.has(explicitKey)) { + explicitLinkedWorkerResultTotal += 1; + continue; + } + + missingExplicitWorkerResultTotal += 1; + pushExample(missingExplicitWorkerExamples, { + timestamp: message.timestamp.toISOString(), + filePath, + messageUuid: message.uuid, + toolUseId: toolResult.toolUseId, + toolName, + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + } + } + + const diagnosis: string[] = []; + if (transcriptFiles.length === 0) { + diagnosis.push('No transcript files were found for this team.'); + } + if (records.length === 0) { + diagnosis.push('No explicit task-linked activity records were found for this task.'); + } + if (missingExplicitWorkerResultTotal > 0) { + diagnosis.push( + `Only board MCP actions are explicit for part of this task history. Found ${missingExplicitWorkerResultTotal} worker tool result(s) inside task work intervals without boardTaskLinks, so Task Log Stream cannot safely include them.` + ); + } + if ( + missingExplicitWorkerResultTotal > 0 && + extractVisibleToolNames(stream).every((toolName) => isBoardMcpToolName(toolName)) + ) { + diagnosis.push( + 'Current stream visibility matches the data gap: the visible tools are MCP board actions, while worker tools exist in transcript but are unlinked.' + ); + } + + const emptyPayloadExamples = collectEmptyPayloadExamples(stream); + if (emptyPayloadExamples.length > 0) { + diagnosis.push( + `Found ${emptyPayloadExamples.length} tool result payload(s) with empty rendered content in the current stream. This explains empty success/output blocks.` + ); + } + if (diagnosis.length === 0) { + diagnosis.push('No obvious task-log data gap was detected by diagnostics.'); + } + + return { + teamName, + requestedTaskRef: taskRef, + task: { + taskId: task.id, + displayId: getTaskDisplayId(task), + subject: task.subject, + status: task.status, + ...(task.owner ? { owner: task.owner } : {}), + workIntervals, + }, + transcript: { + fileCount: transcriptFiles.length, + files: transcriptFiles, + }, + explicitRecords: { + total: records.length, + execution: records.filter((record) => record.linkKind === 'execution').length, + lifecycle: records.filter((record) => record.linkKind === 'lifecycle').length, + boardAction: records.filter((record) => record.linkKind === 'board_action').length, + participants: explicitParticipants, + toolNames: explicitToolNames, + }, + intervalToolResults: { + total: intervalToolResultTotal, + boardMcp: boardMcpToolResultTotal, + worker: { + total: workerToolResultTotal, + explicitLinked: explicitLinkedWorkerResultTotal, + missingExplicit: missingExplicitWorkerResultTotal, + examples: missingExplicitWorkerExamples, + }, + }, + stream: { + participants: stream.participants.map((participant) => participant.label), + defaultFilter: stream.defaultFilter, + segmentCount: stream.segments.length, + visibleToolNames: extractVisibleToolNames(stream), + emptyPayloadExamples, + }, + diagnosis, + }; + } +} diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts new file mode 100644 index 00000000..1daee3cb --- /dev/null +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -0,0 +1,165 @@ +import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { TeamConfigReader } from '../../TeamConfigReader'; + +import type { TeamConfig } from '@shared/types'; + +const logger = createLogger('Service:TeamTranscriptSourceLocator'); + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0) { + const ch = value.charCodeAt(end - 1); + if (ch === 47 || ch === 92) { + end -= 1; + continue; + } + break; + } + return end === value.length ? value : value.slice(0, end); +} + +export interface TeamTranscriptSourceContext { + projectDir: string; + projectId: string; + config: TeamConfig; + sessionIds: string[]; + transcriptFiles: string[]; +} + +export class TeamTranscriptSourceLocator { + constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + + async getContext(teamName: string): Promise { + const config = await this.configReader.getConfig(teamName); + if (!config?.projectPath) { + return null; + } + + const normalizedProjectPath = trimTrailingSlashes(config.projectPath); + let projectId = encodePath(normalizedProjectPath); + let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + + try { + const stat = await fs.stat(projectDir); + if (!stat.isDirectory()) { + throw new Error('not a directory'); + } + } catch { + const leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : null; + if (leadSessionId) { + try { + const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue; + const candidateDir = path.join(getProjectsBasePath(), entry.name); + try { + await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); + projectDir = candidateDir; + projectId = entry.name; + break; + } catch { + // not this project + } + } + } catch { + // best-effort fallback + } + } + } + + const sessionIds = await this.discoverSessionIds(projectDir, config); + const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); + return { projectDir, projectId, config, sessionIds, transcriptFiles }; + } + + async listTranscriptFiles(teamName: string): Promise { + const context = await this.getContext(teamName); + return context?.transcriptFiles ?? []; + } + + private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise { + const knownSessionIds = new Set(); + if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { + knownSessionIds.add(config.leadSessionId.trim()); + } + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + if (typeof sessionId === 'string' && sessionId.trim().length > 0) { + knownSessionIds.add(sessionId.trim()); + } + } + } + + let discoveredSessionDirs: string[] = []; + try { + const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + discoveredSessionDirs = dirEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + } + + if (knownSessionIds.size === 0) { + return discoveredSessionDirs.sort(); + } + + const verifiedSessionIds: string[] = []; + for (const sessionId of knownSessionIds) { + try { + const stat = await fs.stat(path.join(projectDir, sessionId)); + if (stat.isDirectory()) { + verifiedSessionIds.push(sessionId); + } + } catch { + // ignore stale config session + } + } + + return Array.from( + new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs]) + ).sort(); + } + + private async listTranscriptFilesForSessions( + projectDir: string, + sessionIds: string[] + ): Promise { + const transcriptFiles = new Set(); + + for (const sessionId of sessionIds) { + const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`); + try { + const stat = await fs.stat(mainTranscript); + if (stat.isFile()) { + transcriptFiles.add(mainTranscript); + } + } catch { + // ignore missing root transcript + } + + const subagentsDir = path.join(projectDir, sessionId, 'subagents'); + try { + const dirEntries = await fs.readdir(subagentsDir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.jsonl')) continue; + if (!entry.name.startsWith('agent-')) continue; + if (entry.name.startsWith('agent-acompact')) continue; + transcriptFiles.add(path.join(subagentsDir, entry.name)); + } + } catch { + // ignore missing subagent dir + } + } + + return [...transcriptFiles].sort(); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts new file mode 100644 index 00000000..963ee246 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts @@ -0,0 +1,11 @@ +import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder'; + +import type { EnhancedChunk, ParsedMessage } from '@main/types'; + +export class BoardTaskExactLogChunkBuilder { + constructor(private readonly chunkBuilder: ChunkBuilder = new ChunkBuilder()) {} + + buildBundleChunks(messages: ParsedMessage[]): EnhancedChunk[] { + return this.chunkBuilder.buildChunks(messages, [], { includeSidechain: true }); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts new file mode 100644 index 00000000..9a34ebe9 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts @@ -0,0 +1,364 @@ +import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { createLogger } from '@shared/utils/logger'; + +import type { ContentBlock, ParsedMessage } from '@main/types'; +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogDetailCandidate, + BoardTaskExactLogBundleCandidate, +} from './BoardTaskExactLogTypes'; + +const logger = createLogger('Service:BoardTaskExactLogDetailSelector'); + +interface TentativeFilteredMessage { + original: ParsedMessage; + filteredContent: ParsedMessage['content']; + matchedToolUseId?: string; +} + +function isToolAnchoredOutputMessage( + message: ParsedMessage, + toolUseId: string | undefined +): boolean { + return Boolean(toolUseId && message.sourceToolUseID === toolUseId); +} + +function noteExactDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function keepExplicitTextualBlock(block: ContentBlock): boolean { + return block.type === 'text' || block.type === 'image'; +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block } as T; +} + +function filterAssistantContent( + content: ContentBlock[], + toolUseId: string | undefined, + explicitMessageLinked: boolean +): ContentBlock[] { + const kept: ContentBlock[] = []; + + for (const block of content) { + if (block.type === 'tool_use') { + if (toolUseId && block.id === toolUseId) { + kept.push(cloneBlock(block)); + } + continue; + } + + if (block.type === 'thinking') { + continue; + } + + if (explicitMessageLinked && keepExplicitTextualBlock(block)) { + kept.push(cloneBlock(block)); + } + } + + return kept; +} + +function filterUserArrayContent( + content: ContentBlock[], + toolUseId: string | undefined, + explicitMessageLinked: boolean +): ContentBlock[] { + const kept: ContentBlock[] = []; + + for (const block of content) { + if (block.type === 'tool_result') { + if (toolUseId && block.tool_use_id === toolUseId) { + kept.push(cloneBlock(block)); + } + continue; + } + + if (explicitMessageLinked && keepExplicitTextualBlock(block)) { + kept.push(cloneBlock(block)); + } + } + + return kept; +} + +function filterMessageForCandidate(args: { + message: ParsedMessage; + candidate: BoardTaskExactLogBundleCandidate; + explicitMessageIds: Set; +}): TentativeFilteredMessage | null { + const { message, candidate, explicitMessageIds } = args; + const explicitMessageLinked = explicitMessageIds.has(message.uuid); + const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined; + const anchoredOutputLinked = isToolAnchoredOutputMessage(message, toolUseId); + + if (typeof message.content === 'string') { + if (!explicitMessageLinked && !anchoredOutputLinked) { + return null; + } + return { + original: message, + filteredContent: message.content, + ...(toolUseId ? { matchedToolUseId: toolUseId } : {}), + }; + } + + let filteredBlocks: ContentBlock[] = []; + if (message.type === 'assistant') { + filteredBlocks = filterAssistantContent( + message.content, + toolUseId, + explicitMessageLinked || anchoredOutputLinked + ); + } else if (message.type === 'user') { + filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked); + } else { + filteredBlocks = explicitMessageLinked + ? message.content.filter(keepExplicitTextualBlock).map((block) => cloneBlock(block)) + : []; + } + + if (filteredBlocks.length === 0) { + return null; + } + + return { + original: message, + filteredContent: filteredBlocks, + ...(toolUseId ? { matchedToolUseId: toolUseId } : {}), + }; +} + +function rebuildParsedMessage( + message: ParsedMessage, + filteredContent: ParsedMessage['content'], + keptAssistantUuids: Set, + matchedToolUseId?: string +): ParsedMessage { + const { + toolCalls: _originalToolCalls, + toolResults: _originalToolResults, + sourceToolUseID: _originalSourceToolUseID, + sourceToolAssistantUUID: _originalSourceToolAssistantUUID, + toolUseResult: _originalToolUseResult, + ...baseMessage + } = message; + const toolCalls = extractToolCalls(filteredContent); + const toolResults = extractToolResults(filteredContent); + const singleToolResult = toolResults.length === 1 ? toolResults[0] : undefined; + const matchedToolUseResultId = + message.toolUseResult && + typeof message.toolUseResult.toolUseId === 'string' && + message.toolUseResult.toolUseId === matchedToolUseId + ? matchedToolUseId + : undefined; + const matchedSourceToolUseId = + matchedToolUseId && + (message.sourceToolUseID === matchedToolUseId || + singleToolResult?.toolUseId === matchedToolUseId || + matchedToolUseResultId === matchedToolUseId) + ? matchedToolUseId + : undefined; + const matchedSourceToolAssistantUUID = + matchedToolUseId && + message.sourceToolAssistantUUID && + keptAssistantUuids.has(message.sourceToolAssistantUUID) + ? message.sourceToolAssistantUUID + : undefined; + const toolUseResult = + matchedToolUseId && + matchedSourceToolUseId === matchedToolUseId && + singleToolResult?.toolUseId === matchedToolUseId + ? message.toolUseResult + : undefined; + + return { + ...baseMessage, + content: filteredContent, + toolCalls, + toolResults, + ...(matchedSourceToolUseId ? { sourceToolUseID: matchedSourceToolUseId } : {}), + ...(matchedSourceToolAssistantUUID + ? { sourceToolAssistantUUID: matchedSourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + }; +} + +function anchorEvidenceRank(message: ParsedMessage, toolUseId: string | undefined): number { + if (message.type !== 'assistant' || !toolUseId) { + return 0; + } + + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'tool_use' && block.id === toolUseId) { + return 2; + } + } + } + + return message.sourceToolUseID === toolUseId ? 1 : 0; +} + +function deduplicateAssistantMessagesByRequestId( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + const preferredAssistantIndexByRequestId = new Map(); + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (message.type === 'assistant' && message.requestId) { + const existingIndex = preferredAssistantIndexByRequestId.get(message.requestId); + if (existingIndex === undefined) { + preferredAssistantIndexByRequestId.set(message.requestId, i); + continue; + } + + const existingRank = anchorEvidenceRank(messages[existingIndex]!, toolUseId); + const nextRank = anchorEvidenceRank(message, toolUseId); + if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) { + preferredAssistantIndexByRequestId.set(message.requestId, i); + } + } + } + + if (preferredAssistantIndexByRequestId.size === 0) { + return messages; + } + + return messages.filter((message, index) => { + if (message.type !== 'assistant' || !message.requestId) { + return true; + } + return preferredAssistantIndexByRequestId.get(message.requestId) === index; + }); +} + +function sanitizeSourceAssistantLinks(messages: ParsedMessage[]): ParsedMessage[] { + const keptAssistantUuids = new Set( + messages.filter((message) => message.type === 'assistant').map((message) => message.uuid) + ); + + return messages.map((message) => { + if ( + !message.sourceToolAssistantUUID || + keptAssistantUuids.has(message.sourceToolAssistantUUID) + ) { + return message; + } + + const { sourceToolAssistantUUID: _ignored, ...rest } = message; + return rest; + }); +} + +export class BoardTaskExactLogDetailSelector { + selectDetail(args: { + candidate: BoardTaskExactLogBundleCandidate; + records: BoardTaskActivityRecord[]; + parsedMessagesByFile: Map; + }): BoardTaskExactLogDetailCandidate | null { + const { candidate, records, parsedMessagesByFile } = args; + const relevantRecords = records.filter((record) => + candidate.records.some((row) => row.id === record.id) + ); + if (relevantRecords.length === 0) { + noteExactDiagnostic('missing_records_for_detail', { id: candidate.id }); + return null; + } + + const parsedMessages = parsedMessagesByFile.get(candidate.source.filePath); + if (!parsedMessages || parsedMessages.length === 0) { + noteExactDiagnostic('missing_parsed_messages', { filePath: candidate.source.filePath }); + return null; + } + + const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid)); + const tentative: TentativeFilteredMessage[] = []; + + for (const message of parsedMessages) { + const filtered = filterMessageForCandidate({ + message, + candidate, + explicitMessageIds, + }); + if (filtered) { + tentative.push(filtered); + } + } + + if (tentative.length === 0) { + noteExactDiagnostic('empty_filtered_bundle', { id: candidate.id }); + return null; + } + + const keptAssistantUuids = new Set( + tentative + .filter((entry) => entry.original.type === 'assistant') + .map((entry) => entry.original.uuid) + ); + + const rebuilt = tentative.map((entry) => + rebuildParsedMessage( + entry.original, + entry.filteredContent, + keptAssistantUuids, + entry.matchedToolUseId + ) + ); + + const deduped = deduplicateAssistantMessagesByRequestId( + rebuilt, + candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined + ); + const sanitized = sanitizeSourceAssistantLinks(deduped); + if (sanitized.length === 0) { + noteExactDiagnostic('empty_deduped_bundle', { id: candidate.id }); + return null; + } + + return { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records: candidate.records, + filteredMessages: sanitized, + }; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts new file mode 100644 index 00000000..bdb4c0ee --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts @@ -0,0 +1,76 @@ +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { BoardTaskExactLogChunkBuilder } from './BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from './BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from './BoardTaskExactLogStrictParser'; +import { isBoardTaskExactLogsReadEnabled } from './featureGates'; +import { getBoardTaskExactLogFileVersions } from './fileVersions'; +import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector'; + +import type { BoardTaskExactLogDetailResult } from '@shared/types'; + +export class BoardTaskExactLogDetailService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + ) {} + + async getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return { status: 'missing' }; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return { status: 'missing' }; + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const candidate = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .find((item) => item.id === exactLogId); + + if (!candidate) { + return { status: 'missing' }; + } + if (!candidate.canLoadDetail) { + return { status: 'missing' }; + } + if (candidate.sourceGeneration !== expectedSourceGeneration) { + return { status: 'stale' }; + } + + const parsedMessagesByFile = await this.strictParser.parseFiles([candidate.source.filePath]); + const detailCandidate = this.detailSelector.selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + + if (!detailCandidate) { + return { status: 'missing' }; + } + + const chunks = this.chunkBuilder.buildBundleChunks(detailCandidate.filteredMessages); + return { + status: 'ok', + detail: { + id: detailCandidate.id, + chunks, + }, + }; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts new file mode 100644 index 00000000..0927411a --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import * as fs from 'fs/promises'; +import * as readline from 'readline'; + +import { yieldToEventLoop } from '@main/utils/asyncYield'; +import { parseJsonlLine } from '@main/utils/jsonl'; + +import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache'; + +import type { ParsedMessage } from '@main/types'; + +const logger = createLogger('Service:BoardTaskExactLogStrictParser'); + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function hasStrictTimestamp(record: Record): boolean { + if (typeof record.timestamp !== 'string' || record.timestamp.trim().length === 0) { + return false; + } + return Number.isFinite(Date.parse(record.timestamp)); +} + +export class BoardTaskExactLogStrictParser { + constructor( + private readonly cache: BoardTaskExactLogsParseCache = new BoardTaskExactLogsParseCache() + ) {} + + async parseFiles(filePaths: string[]): Promise> { + const uniquePaths = [...new Set(filePaths)].sort(); + this.cache.retainOnly(new Set(uniquePaths)); + + const results = await Promise.all( + uniquePaths.map(async (filePath) => [filePath, await this.parseFile(filePath)] as const) + ); + + return new Map(results); + } + + private async parseFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.readStrictFile(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch (error) { + logger.debug(`Skipping unreadable exact-log transcript ${filePath}: ${String(error)}`); + this.cache.clearForPath(filePath); + return []; + } + } + + private async readStrictFile(filePath: string): Promise { + const results: ParsedMessage[] = []; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let lineCount = 0; + for await (const line of rl) { + if (!line.trim()) continue; + lineCount += 1; + + try { + const raw = JSON.parse(line) as unknown; + const record = asRecord(raw); + if (!record || !hasStrictTimestamp(record)) { + continue; + } + + const parsed = parseJsonlLine(line); + if (parsed) { + results.push(parsed); + } + } catch (error) { + logger.debug(`Skipping malformed exact-log line in ${filePath}: ${String(error)}`); + } + + if (lineCount % 250 === 0) { + await yieldToEventLoop(); + } + } + + return results; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts new file mode 100644 index 00000000..74b3c88e --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts @@ -0,0 +1,227 @@ +import { createHash } from 'crypto'; + +import { describeBoardTaskActivityLabel } from '@shared/utils/boardTaskActivityLabels'; +import { createLogger } from '@shared/utils/logger'; + +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogAnchor, + BoardTaskExactLogBundleCandidate, + BoardTaskExactLogFileVersion, +} from './BoardTaskExactLogTypes'; + +const logger = createLogger('Service:BoardTaskExactLogSummarySelector'); + +function noteExactDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function compareCandidateTimestamps( + left: BoardTaskActivityRecord, + right: BoardTaskActivityRecord +): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + return left.id.localeCompare(right.id); +} + +function buildMessageGroupKey(record: BoardTaskActivityRecord): string { + return `${record.source.filePath}:${record.source.messageUuid}`; +} + +function buildToolAnchor( + filePath: string, + messageUuid: string, + toolUseId: string +): BoardTaskExactLogAnchor { + return { + kind: 'tool', + filePath, + messageUuid, + toolUseId, + }; +} + +function buildMessageAnchor(filePath: string, messageUuid: string): BoardTaskExactLogAnchor { + return { + kind: 'message', + filePath, + messageUuid, + }; +} + +function anchorId(anchor: BoardTaskExactLogAnchor): string { + return anchor.kind === 'tool' + ? `tool:${anchor.filePath}:${anchor.toolUseId ?? ''}` + : `message:${anchor.filePath}:${anchor.messageUuid}`; +} + +function sourceGenerationFor( + anchor: BoardTaskExactLogAnchor, + version: BoardTaskExactLogFileVersion | undefined +): string | null { + if (!version) return null; + const hash = createHash('sha1'); + hash.update(anchor.filePath); + hash.update('\0'); + hash.update(String(version.size)); + hash.update('\0'); + hash.update(String(version.mtimeMs)); + return hash.digest('hex'); +} + +function chooseSummaryRecord( + records: BoardTaskActivityRecord[], + anchor: BoardTaskExactLogAnchor +): BoardTaskActivityRecord | null { + if (records.length === 0) { + return null; + } + + const anchoredRecords = + anchor.kind === 'tool' && anchor.toolUseId + ? records.filter( + (record) => + record.source.toolUseId === anchor.toolUseId || + record.action?.toolUseId === anchor.toolUseId + ) + : records; + const candidates = anchoredRecords.length > 0 ? anchoredRecords : records; + + return ( + candidates.find((record) => record.action?.canonicalToolName) ?? + candidates.find((record) => record.linkKind !== 'execution' && record.action) ?? + candidates[0] ?? + null + ); +} + +export class BoardTaskExactLogSummarySelector { + selectSummaries(args: { + records: BoardTaskActivityRecord[]; + fileVersionsByPath: Map; + }): BoardTaskExactLogBundleCandidate[] { + const byMessage = new Map(); + for (const record of args.records) { + const key = buildMessageGroupKey(record); + const bucket = byMessage.get(key) ?? []; + bucket.push(record); + byMessage.set(key, bucket); + } + + const groups = new Map< + string, + { anchor: BoardTaskExactLogAnchor; records: BoardTaskActivityRecord[] } + >(); + + for (const messageRecords of byMessage.values()) { + const sortedMessageRecords = [...messageRecords].sort(compareCandidateTimestamps); + const toolUseIds = [ + ...new Set(sortedMessageRecords.map((record) => record.source.toolUseId).filter(Boolean)), + ] as string[]; + const singleToolUseId = toolUseIds.length === 1 ? toolUseIds[0] : null; + + for (const record of sortedMessageRecords) { + let anchor: BoardTaskExactLogAnchor; + if (record.source.toolUseId) { + anchor = buildToolAnchor( + record.source.filePath, + record.source.messageUuid, + record.source.toolUseId + ); + } else if (singleToolUseId) { + anchor = buildToolAnchor( + record.source.filePath, + record.source.messageUuid, + singleToolUseId + ); + } else { + anchor = buildMessageAnchor(record.source.filePath, record.source.messageUuid); + } + + const key = anchorId(anchor); + const existing = groups.get(key); + if (existing) { + existing.records.push(record); + } else { + groups.set(key, { anchor, records: [record] }); + } + } + } + + const candidates: BoardTaskExactLogBundleCandidate[] = []; + + for (const [key, group] of groups) { + const sortedRecords = [...group.records].sort(compareCandidateTimestamps); + const primaryRecord = sortedRecords[0]; + if (!primaryRecord) { + continue; + } + + const linkKinds = [...new Set(sortedRecords.map((record) => record.linkKind))]; + const targetRoles = [...new Set(sortedRecords.map((record) => record.targetRole))]; + const fileVersion = args.fileVersionsByPath.get(primaryRecord.source.filePath); + const sourceGeneration = sourceGenerationFor(group.anchor, fileVersion); + const summaryRecord = chooseSummaryRecord(sortedRecords, group.anchor) ?? primaryRecord; + const actionLabel = describeBoardTaskActivityLabel(summaryRecord); + + const baseCandidate = { + id: key, + timestamp: primaryRecord.timestamp, + actor: primaryRecord.actor, + source: { + filePath: primaryRecord.source.filePath, + messageUuid: primaryRecord.source.messageUuid, + ...(group.anchor.kind === 'tool' && group.anchor.toolUseId + ? { toolUseId: group.anchor.toolUseId } + : {}), + sourceOrder: primaryRecord.source.sourceOrder, + }, + records: sortedRecords, + anchor: group.anchor, + actionLabel, + ...(summaryRecord.action?.category + ? { actionCategory: summaryRecord.action.category } + : {}), + ...(summaryRecord.action?.canonicalToolName + ? { canonicalToolName: summaryRecord.action.canonicalToolName } + : {}), + linkKinds, + targetRoles, + }; + + if (sourceGeneration) { + candidates.push({ + ...baseCandidate, + canLoadDetail: true, + sourceGeneration, + }); + } else { + noteExactDiagnostic('non_expandable_summary', { + filePath: primaryRecord.source.filePath, + toolUseId: group.anchor.toolUseId, + }); + candidates.push({ + ...baseCandidate, + canLoadDetail: false, + }); + } + } + + return candidates; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts new file mode 100644 index 00000000..31550a5b --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts @@ -0,0 +1,77 @@ +import type { ParsedMessage } from '@main/types'; +import type { + BoardTaskActivityCategory, + BoardTaskActivityLinkKind, + BoardTaskActivityTargetRole, + BoardTaskExactLogActor, + BoardTaskExactLogSource, + BoardTaskExactLogSummary, +} from '@shared/types'; +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; + +export interface BoardTaskExactLogFileVersion { + filePath: string; + mtimeMs: number; + size: number; +} + +export interface BoardTaskExactLogAnchor { + kind: 'tool' | 'message'; + filePath: string; + messageUuid: string; + toolUseId?: string; +} + +export type BoardTaskExactLogBundleCandidate = { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + records: BoardTaskActivityRecord[]; + anchor: BoardTaskExactLogAnchor; + actionLabel: string; + actionCategory?: BoardTaskActivityCategory; + canonicalToolName?: string; + linkKinds: BoardTaskActivityLinkKind[]; + targetRoles: BoardTaskActivityTargetRole[]; +} & ({ canLoadDetail: true; sourceGeneration: string } | { canLoadDetail: false }); + +export interface BoardTaskExactLogDetailCandidate { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + records: BoardTaskActivityRecord[]; + filteredMessages: ParsedMessage[]; +} + +export function mapCandidateToSummary( + candidate: BoardTaskExactLogBundleCandidate +): BoardTaskExactLogSummary { + return candidate.canLoadDetail + ? { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + anchorKind: candidate.anchor.kind, + actionLabel: candidate.actionLabel, + ...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}), + ...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}), + linkKinds: candidate.linkKinds, + canLoadDetail: true, + sourceGeneration: candidate.sourceGeneration, + } + : { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + anchorKind: candidate.anchor.kind, + actionLabel: candidate.actionLabel, + ...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}), + ...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}), + linkKinds: candidate.linkKinds, + canLoadDetail: false, + }; +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts new file mode 100644 index 00000000..439fe186 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts @@ -0,0 +1,35 @@ +import { BoardTaskActivityParseCache } from '../activity/BoardTaskActivityParseCache'; + +import type { ParsedMessage } from '@main/types'; + +export class BoardTaskExactLogsParseCache { + private readonly cache = new BoardTaskActivityParseCache(); + + getIfFresh(filePath: string, mtimeMs: number, size: number): ParsedMessage[] | null { + return this.cache.getIfFresh(filePath, mtimeMs, size); + } + + getInFlight(filePath: string): Promise | null { + return this.cache.getInFlight(filePath); + } + + setInFlight(filePath: string, promise: Promise): void { + this.cache.setInFlight(filePath, promise); + } + + clearInFlight(filePath: string): void { + this.cache.clearInFlight(filePath); + } + + set(filePath: string, mtimeMs: number, size: number, value: ParsedMessage[]): void { + this.cache.set(filePath, mtimeMs, size, value); + } + + clearForPath(filePath: string): void { + this.cache.clearForPath(filePath); + } + + retainOnly(filePaths: Set): void { + this.cache.retainOnly(filePaths); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts new file mode 100644 index 00000000..12db7d35 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts @@ -0,0 +1,63 @@ +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { isBoardTaskExactLogsReadEnabled } from './featureGates'; +import { getBoardTaskExactLogFileVersions } from './fileVersions'; +import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector'; +import { mapCandidateToSummary } from './BoardTaskExactLogTypes'; + +import type { BoardTaskExactLogSummariesResponse } from '@shared/types'; + +function compareSummaries( + left: BoardTaskExactLogSummariesResponse['items'][number], + right: BoardTaskExactLogSummariesResponse['items'][number] +): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +export class BoardTaskExactLogsService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector() + ) {} + + async getTaskExactLogSummaries( + teamName: string, + taskId: string + ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return { items: [] }; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return { items: [] }; + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const items = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .map(mapCandidateToSummary) + .sort(compareSummaries); + + return { items }; + } +} diff --git a/src/main/services/team/taskLogs/exact/featureGates.ts b/src/main/services/team/taskLogs/exact/featureGates.ts new file mode 100644 index 00000000..f5f86270 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskExactLogsReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED, true); +} diff --git a/src/main/services/team/taskLogs/exact/fileVersions.ts b/src/main/services/team/taskLogs/exact/fileVersions.ts new file mode 100644 index 00000000..879ab4b7 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/fileVersions.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs/promises'; + +import type { BoardTaskExactLogFileVersion } from './BoardTaskExactLogTypes'; + +export async function getBoardTaskExactLogFileVersions( + filePaths: Iterable +): Promise> { + const uniqueFilePaths = [...new Set(filePaths)]; + const results = await Promise.all( + uniqueFilePaths.map(async (filePath) => { + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + return null; + } + return { + filePath, + mtimeMs: stat.mtimeMs, + size: stat.size, + } satisfies BoardTaskExactLogFileVersion; + } catch { + return null; + } + }) + ); + + const byPath = new Map(); + for (const item of results) { + if (!item) continue; + byPath.set(item.filePath, item); + } + return byPath; +} diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts new file mode 100644 index 00000000..078f2ca8 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -0,0 +1,858 @@ +import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; + +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; +import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; +import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; +import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector'; + +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; +import type { + BoardTaskActivityCategory, + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, +} from '@shared/types'; +import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; + +interface StreamSlice { + id: string; + timestamp: string; + filePath: string; + participantKey: string; + actor: BoardTaskLogActor; + actionCategory?: BoardTaskActivityCategory; + filteredMessages: ParsedMessage[]; +} + +interface MergedMessageAccumulator { + message: ParsedMessage; + content: ParsedMessage['content']; + firstSeenOrder: number; + sourceToolUseIds: Set; + sourceToolAssistantUUIDs: Set; + toolUseResults: ToolUseResultData[]; +} + +function emptyResponse(): BoardTaskLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + }; +} + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { + return { + ...(detail.memberName ? { memberName: detail.memberName } : {}), + role: detail.role, + sessionId: detail.sessionId, + ...(detail.agentId ? { agentId: detail.agentId } : {}), + isSidechain: detail.isSidechain, + }; +} + +function buildParticipantKey(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return `member:${normalizeMemberName(actor.memberName)}`; + } + if (!actor.isSidechain || actor.role === 'lead') { + return 'lead'; + } + if (actor.agentId) { + return `sidechain-agent:${actor.agentId}`; + } + return `sidechain-session:${actor.sessionId}`; +} + +function buildParticipantLabel(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return actor.memberName; + } + if (!actor.isSidechain || actor.role === 'lead') { + return 'lead session'; + } + if (actor.agentId) { + return `member ${actor.agentId.slice(0, 8)}`; + } + return `member session ${actor.sessionId.slice(0, 8)}`; +} + +function buildParticipant( + actor: BoardTaskLogActor, + participantKey: string +): BoardTaskLogParticipant { + return { + key: participantKey, + label: buildParticipantLabel(actor), + role: actor.role, + isLead: participantKey === 'lead', + isSidechain: actor.isSidechain, + }; +} + +function hasNamedParticipant(actor: BoardTaskLogActor): boolean { + return typeof actor.memberName === 'string' && actor.memberName.trim().length > 0; +} + +function hasToolUseBlock( + content: ParsedMessage['content'], + toolUseId: string | undefined +): boolean { + if (!toolUseId || typeof content === 'string') { + return false; + } + + return content.some((block) => block.type === 'tool_use' && block.id === toolUseId); +} + +function looksLikeJsonPayload(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function parseJsonLikeString(value: string): unknown { + const trimmed = value.trim(); + if (!looksLikeJsonPayload(trimmed)) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function extractBoardToolOutputText( + toolName: string | undefined, + parsedPayload: unknown +): string | null { + if (!toolName || !parsedPayload || typeof parsedPayload !== 'object') { + return null; + } + + const payload = parsedPayload as Record; + if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { + const comment = payload.comment as Record | undefined; + if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { + return comment.text; + } + } + + return null; +} + +function collectTextBlockText(value: unknown): string { + if (!Array.isArray(value)) { + return ''; + } + + return value + .filter( + (child): child is Extract => + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === 'text' && + 'text' in child && + typeof child.text === 'string' + ) + .map((child) => child.text) + .join('\n'); +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) { + return true; + } + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function inferSingleToolUseId(message: ParsedMessage): string | undefined { + if (message.sourceToolUseID) { + return message.sourceToolUseID; + } + + if (message.toolResults.length === 1) { + return message.toolResults[0]?.toolUseId; + } + + if (!Array.isArray(message.content)) { + return undefined; + } + + const uniqueIds = new Set( + message.content + .filter( + (block): block is Extract => + block.type === 'tool_result' + ) + .map((block) => block.tool_use_id) + ); + + return uniqueIds.size === 1 ? uniqueIds.values().next().value : undefined; +} + +function sanitizeToolResultContent( + content: ContentBlock, + canonicalToolName?: string +): ContentBlock { + if (content.type !== 'tool_result') { + return cloneBlock(content); + } + + if (typeof content.content === 'string') { + const parsedPayload = parseJsonLikeString(content.content); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: [{ type: 'text', text: extractedText }], + }; + } + return parsedPayload ? { ...content, content: '' } : cloneBlock(content); + } + + if (!Array.isArray(content.content)) { + return cloneBlock(content); + } + + const jsonText = content.content + .filter((child): child is Extract => child.type === 'text') + .map((child) => child.text) + .join('\n'); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: extractedText, + }; + } + + const sanitizedChildren = content.content + .map((child) => { + if (child.type !== 'text') { + return cloneBlock(child); + } + + return looksLikeJsonPayload(child.text) ? null : cloneBlock(child); + }) + .filter((child): child is ContentBlock => child !== null); + + if (sanitizedChildren.length === 0) { + return { + ...content, + content: '', + }; + } + + return { + ...content, + content: sanitizedChildren, + }; +} + +function sanitizeJsonLikeToolResultPayloads( + messages: ParsedMessage[], + canonicalToolName?: string +): ParsedMessage[] { + return messages.map((message) => { + let nextMessage = message; + + const rawToolUseResult = message.toolUseResult as unknown; + if ( + rawToolUseResult && + typeof rawToolUseResult === 'object' && + !Array.isArray(rawToolUseResult) + ) { + const nextToolUseResult: Record & { + content?: unknown; + message?: unknown; + } = { ...(rawToolUseResult as Record) }; + let toolUseResultChanged = false; + const extractedFromContent = + typeof nextToolUseResult.content === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.content) + ) + : null; + const extractedFromMessage = + typeof nextToolUseResult.message === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.message) + ) + : null; + + if (typeof extractedFromContent === 'string') { + nextToolUseResult.content = extractedFromContent; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.content === 'string' && + looksLikeJsonPayload(nextToolUseResult.content) + ) { + nextToolUseResult.content = ''; + toolUseResultChanged = true; + } + + if (typeof extractedFromMessage === 'string') { + nextToolUseResult.message = extractedFromMessage; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.message === 'string' && + looksLikeJsonPayload(nextToolUseResult.message) + ) { + nextToolUseResult.message = ''; + toolUseResultChanged = true; + } + + if (toolUseResultChanged) { + nextMessage = { + ...nextMessage, + toolUseResult: nextToolUseResult, + }; + } + } else if (Array.isArray(rawToolUseResult)) { + const toolUseId = inferSingleToolUseId(message); + const jsonText = collectTextBlockText(rawToolUseResult); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string' || parsedPayload) { + nextMessage = { + ...nextMessage, + toolUseResult: { + ...(toolUseId ? { toolUseId } : {}), + content: typeof extractedText === 'string' ? extractedText : '', + }, + }; + } + } + + if (typeof message.content === 'string') { + return nextMessage; + } + + let changed = false; + const nextContent = message.content.map((block) => { + if (block.type !== 'tool_result') { + return block; + } + + const sanitized = sanitizeToolResultContent(block, canonicalToolName); + if (JSON.stringify(sanitized) !== JSON.stringify(block)) { + changed = true; + } + return sanitized; + }); + + if (!changed) { + return nextMessage; + } + + return { + ...nextMessage, + content: nextContent, + }; + }); +} + +function hasMeaningfulToolUseResult(message: ParsedMessage): boolean { + const rawToolUseResult = message.toolUseResult as unknown; + if ( + !rawToolUseResult || + typeof rawToolUseResult !== 'object' || + Array.isArray(rawToolUseResult) + ) { + return false; + } + + const toolUseResult = rawToolUseResult as { + error?: unknown; + stderr?: unknown; + content?: unknown; + message?: unknown; + }; + if (typeof toolUseResult.error === 'string' && toolUseResult.error.trim().length > 0) { + return true; + } + if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim().length > 0) { + return true; + } + if (typeof toolUseResult.content === 'string' && toolUseResult.content.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.content) && toolUseResult.content.length > 0) { + return true; + } + if (typeof toolUseResult.message === 'string' && toolUseResult.message.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.message) && toolUseResult.message.length > 0) { + return true; + } + return false; +} + +function pruneEmptyInternalToolResultMessages(messages: ParsedMessage[]): ParsedMessage[] { + return messages.filter((message) => { + if ( + message.type !== 'user' || + message.toolResults.length === 0 || + typeof message.content === 'string' + ) { + return true; + } + + const hasNonToolResultContent = message.content.some((block) => block.type !== 'tool_result'); + if (hasNonToolResultContent) { + return true; + } + + const allToolResultsEmpty = message.toolResults.every((toolResult) => + isEmptyToolPayload(toolResult.content) + ); + if (!allToolResultsEmpty) { + return true; + } + + return hasMeaningfulToolUseResult(message); + }); +} + +function pruneToolAnchoredAssistantOutputMessages( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + if (!toolUseId) { + return messages; + } + + return messages.filter((message) => { + if (message.type !== 'assistant') { + return true; + } + if (message.sourceToolUseID !== toolUseId) { + return true; + } + return hasToolUseBlock(message.content, toolUseId); + }); +} + +function filterReadOnlySlices(slices: StreamSlice[]): StreamSlice[] { + const participantHasNonRead = new Map(); + + for (const slice of slices) { + if (slice.actionCategory && slice.actionCategory !== 'read') { + participantHasNonRead.set(slice.participantKey, true); + } + } + + return slices.filter((slice) => { + const hasNonReadForParticipant = participantHasNonRead.get(slice.participantKey) === true; + if (!hasNonReadForParticipant) { + return true; + } + return slice.actionCategory !== 'read'; + }); +} + +function compareCandidates( + left: { + id: string; + timestamp: string; + source: { filePath: string; sourceOrder: number; toolUseId?: string }; + }, + right: { + id: string; + timestamp: string; + source: { filePath: string; sourceOrder: number; toolUseId?: string }; + } +): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +function blockKey(block: ContentBlock): string { + return JSON.stringify(block); +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block } as T; +} + +function cloneMessageContent(content: ParsedMessage['content']): ParsedMessage['content'] { + if (typeof content === 'string') { + return content; + } + return content.map((block) => cloneBlock(block)); +} + +function mergeMessageContent( + current: ParsedMessage['content'], + incoming: ParsedMessage['content'] +): ParsedMessage['content'] { + if (typeof current === 'string') { + return current; + } + if (typeof incoming === 'string') { + return current; + } + + const merged = current.map((block) => cloneBlock(block)); + const seen = new Set(merged.map((block) => blockKey(block))); + for (const block of incoming) { + const key = blockKey(block); + if (seen.has(key)) continue; + merged.push(cloneBlock(block)); + seen.add(key); + } + return merged; +} + +function createAccumulator( + message: ParsedMessage, + firstSeenOrder: number +): MergedMessageAccumulator { + return { + message, + content: cloneMessageContent(message.content), + firstSeenOrder, + sourceToolUseIds: new Set(message.sourceToolUseID ? [message.sourceToolUseID] : []), + sourceToolAssistantUUIDs: new Set( + message.sourceToolAssistantUUID ? [message.sourceToolAssistantUUID] : [] + ), + toolUseResults: message.toolUseResult ? [message.toolUseResult] : [], + }; +} + +function updateAccumulator(accumulator: MergedMessageAccumulator, message: ParsedMessage): void { + accumulator.content = mergeMessageContent(accumulator.content, message.content); + if (message.sourceToolUseID) { + accumulator.sourceToolUseIds.add(message.sourceToolUseID); + } + if (message.sourceToolAssistantUUID) { + accumulator.sourceToolAssistantUUIDs.add(message.sourceToolAssistantUUID); + } + if (message.toolUseResult) { + accumulator.toolUseResults.push(message.toolUseResult); + } +} + +function selectSingleValue(values: Set): string | undefined { + if (values.size !== 1) return undefined; + return values.values().next().value; +} + +function selectSingleToolUseResult(values: ToolUseResultData[]): ToolUseResultData | undefined { + if (values.length !== 1) return undefined; + return values[0]; +} + +function extractToolUseIdFromToolUseResult( + value: ToolUseResultData | undefined +): string | undefined { + if (!value || typeof value.toolUseId !== 'string') { + return undefined; + } + const trimmed = value.toolUseId.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function rebuildMergedMessage( + accumulator: MergedMessageAccumulator, + keptAssistantUuids: Set +): ParsedMessage { + const { + toolCalls: _toolCalls, + toolResults: _toolResults, + sourceToolUseID: _sourceToolUseID, + sourceToolAssistantUUID: _sourceToolAssistantUUID, + toolUseResult: _toolUseResult, + ...base + } = accumulator.message; + + const toolCalls = extractToolCalls(accumulator.content); + const toolResults = extractToolResults(accumulator.content); + const singleToolUseResult = selectSingleToolUseResult(accumulator.toolUseResults); + const derivedToolUseId = + selectSingleValue(accumulator.sourceToolUseIds) ?? + (toolResults.length === 1 ? toolResults[0]?.toolUseId : undefined) ?? + extractToolUseIdFromToolUseResult(singleToolUseResult); + const sourceToolAssistantUUID = selectSingleValue(accumulator.sourceToolAssistantUUIDs); + const preservedSourceToolAssistantUUID = + sourceToolAssistantUUID && keptAssistantUuids.has(sourceToolAssistantUUID) + ? sourceToolAssistantUUID + : undefined; + const toolUseResult = singleToolUseResult; + + return { + ...base, + content: accumulator.content, + toolCalls, + toolResults, + ...(derivedToolUseId ? { sourceToolUseID: derivedToolUseId } : {}), + ...(preservedSourceToolAssistantUUID + ? { sourceToolAssistantUUID: preservedSourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + }; +} + +function mergeMessages( + details: Array<{ filePath: string; filteredMessages: ParsedMessage[] }> +): ParsedMessage[] { + const byMessageKey = new Map(); + let order = 0; + + for (const detail of details) { + for (const message of detail.filteredMessages) { + const key = `${detail.filePath}:${message.uuid}`; + const existing = byMessageKey.get(key); + if (existing) { + updateAccumulator(existing, message); + } else { + byMessageKey.set(key, createAccumulator(message, order)); + order += 1; + } + } + } + + const mergedAccumulators = [...byMessageKey.values()].sort( + (left, right) => left.firstSeenOrder - right.firstSeenOrder + ); + const keptAssistantUuids = new Set( + mergedAccumulators + .filter((entry) => entry.message.type === 'assistant') + .map((entry) => entry.message.uuid) + ); + + return mergedAccumulators.map((entry) => rebuildMergedMessage(entry, keptAssistantUuids)); +} + +function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { + const first = slices[0]; + const last = slices[slices.length - 1]; + return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; +} + +export class BoardTaskLogStreamService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + ) {} + + async getTaskLogStream(teamName: string, taskId: string): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return emptyResponse(); + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return emptyResponse(); + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const candidates = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .filter((candidate) => candidate.canLoadDetail) + .sort(compareCandidates); + + if (candidates.length === 0) { + return emptyResponse(); + } + + const parsedMessagesByFile = await this.strictParser.parseFiles( + candidates.map((candidate) => candidate.source.filePath) + ); + + const slices: StreamSlice[] = []; + for (const candidate of candidates) { + const detail = this.detailSelector.selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + if (!detail || detail.filteredMessages.length === 0) { + continue; + } + + const filteredMessages = + candidate.anchor.kind === 'tool' + ? pruneToolAnchoredAssistantOutputMessages( + detail.filteredMessages, + candidate.anchor.toolUseId + ) + : detail.filteredMessages; + const sanitizedMessages = sanitizeJsonLikeToolResultPayloads( + filteredMessages, + candidate.canonicalToolName + ); + const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages); + if (prunedMessages.length === 0) { + continue; + } + + const actor = toStreamActor(detail.actor); + slices.push({ + id: detail.id, + timestamp: detail.timestamp, + filePath: detail.source.filePath, + participantKey: buildParticipantKey(actor), + actor, + actionCategory: candidate.actionCategory, + filteredMessages: prunedMessages, + }); + } + + if (slices.length === 0) { + return emptyResponse(); + } + + const deNoisedSlices = filterReadOnlySlices(slices); + + const namedParticipantSlices = deNoisedSlices.filter((slice) => + hasNamedParticipant(slice.actor) + ); + 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); + } + + 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); + }); + + const segments: BoardTaskLogSegment[] = []; + let currentSegmentSlices: StreamSlice[] = []; + + const flushSegment = (): void => { + if (currentSegmentSlices.length === 0) return; + const participantKey = currentSegmentSlices[0]!.participantKey; + const actor = currentSegmentSlices[0]!.actor; + const mergedMessages = mergeMessages( + currentSegmentSlices.map((slice) => ({ + filePath: slice.filePath, + filteredMessages: slice.filteredMessages, + })) + ); + const cleanedMessages = pruneEmptyInternalToolResultMessages(mergedMessages); + if (cleanedMessages.length === 0) { + currentSegmentSlices = []; + return; + } + const chunks = this.chunkBuilder.buildBundleChunks(cleanedMessages); + if (chunks.length > 0) { + segments.push({ + id: buildSegmentId(participantKey, currentSegmentSlices), + participantKey, + actor, + startTimestamp: currentSegmentSlices[0]!.timestamp, + endTimestamp: currentSegmentSlices[currentSegmentSlices.length - 1]!.timestamp, + chunks, + }); + } + currentSegmentSlices = []; + }; + + for (const slice of visibleSlices) { + if ( + currentSegmentSlices.length > 0 && + currentSegmentSlices[0]!.participantKey !== slice.participantKey + ) { + flushSegment(); + } + currentSegmentSlices.push(slice); + } + flushSegment(); + + const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead); + const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0]!.key : 'all'; + + return { + participants: orderedParticipants, + defaultFilter, + segments, + }; + } +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 45fd3dfe..2d1b451d 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -301,6 +301,18 @@ export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs'; /** Get session logs that reference a task */ export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask'; +/** Get explicit board-task activity derived from transcript metadata */ +export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity'; + +/** Get one task-scoped log stream derived from explicit board-task activity */ +export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; + +/** Get exact task-log summaries derived from explicit board-task activity records */ +export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'; + +/** Get one exact task-log detail bundle for renderer reuse */ +export const TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail'; + /** Update team config (name, description) */ export const TEAM_UPDATE_CONFIG = 'team:updateConfig'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 55c4e3d1..255b6bbc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,12 +8,11 @@ import { API_KEYS_SAVE, API_KEYS_STORAGE_STATUS, APP_RELAUNCH, - CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_PROVIDER_STATUS, + CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, - TMUX_GET_STATUS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -125,8 +124,13 @@ import { TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, + TEAM_GET_MESSAGES_PAGE, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, @@ -152,7 +156,6 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, - TEAM_GET_MESSAGES_PAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, @@ -180,6 +183,7 @@ import { TERMINAL_RESIZE, TERMINAL_SPAWN, TERMINAL_WRITE, + TMUX_GET_STATUS, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -228,6 +232,10 @@ import type { ApplyReviewRequest, ApplyReviewResult, AttachmentFileData, + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, ChangeStats, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -252,6 +260,7 @@ import type { MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, + MessagesPage, NotificationTrigger, ProjectBranchChangeEvent, RejectResult, @@ -261,7 +270,6 @@ import type { ScheduleRun, SendMessageRequest, SendMessageResult, - MessagesPage, SessionsByIdsOptions, SessionsPaginationOptions, SnippetDiff, @@ -290,10 +298,10 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TmuxStatus, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, - TmuxStatus, TriggerTestResult, UpdateKanbanPatch, UpdateSchedulePatch, @@ -954,6 +962,41 @@ const electronAPI: ElectronAPI = { options ); }, + getTaskActivity: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ACTIVITY, + teamName, + taskId + ); + }, + getTaskLogStream: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_LOG_STREAM, + teamName, + taskId + ); + }, + getTaskExactLogSummaries: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + teamName, + taskId + ); + }, + getTaskExactLogDetail: async ( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ) => { + return invokeIpcWithResult( + TEAM_GET_TASK_EXACT_LOG_DETAIL, + teamName, + taskId, + exactLogId, + expectedSourceGeneration + ); + }, getMemberStats: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_GET_MEMBER_STATS, teamName, memberName); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d07a16f4..26ea9ed4 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -9,6 +9,9 @@ import type { AppConfig, AttachmentFileData, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, ClaudeMdFileInfo, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -804,6 +807,26 @@ export class HttpAPIClient implements ElectronAPI { getLogsForTask: async () => { return []; }, + getTaskActivity: async () => { + console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode'); + return []; + }, + getTaskLogStream: async (): Promise => { + console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode'); + return { + participants: [], + defaultFilter: 'all', + segments: [], + }; + }, + getTaskExactLogSummaries: async (): Promise => { + console.warn('[HttpAPIClient] getTaskExactLogSummaries is not available in browser mode'); + return { items: [] }; + }, + getTaskExactLogDetail: async (): Promise => { + console.warn('[HttpAPIClient] getTaskExactLogDetail is not available in browser mode'); + return { status: 'missing' }; + }, getMemberStats: async () => { console.warn('[HttpAPIClient] getMemberStats 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 73546360..83d2cedf 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -9,7 +9,7 @@ import { import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; +import { TaskLogsPanel } from '@renderer/components/team/taskLogs/TaskLogsPanel'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { @@ -1256,29 +1256,8 @@ export const TaskDetailDialog = ({ {variant === 'team' ? ( } - headerExtra={ - logsRefreshing || executionPreviewOnline ? ( - - {executionPreviewOnline ? ( - - - - - ) : null} - {logsRefreshing ? ( - - - Updating... - - ) : null} - - ) : null - } contentClassName="pl-2.5 overflow-visible" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" @@ -1286,19 +1265,14 @@ export const TaskDetailDialog = ({ keepMounted >
- diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx new file mode 100644 index 00000000..bb37cc02 --- /dev/null +++ b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx @@ -0,0 +1,132 @@ +import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { ChevronDown, ChevronRight, Clock, FileText, Loader2 } from 'lucide-react'; + +import type { BoardTaskExactLogSummary } from '@shared/types'; + +export interface ExactTaskLogDetailState { + status: 'idle' | 'loading' | 'ok' | 'missing' | 'error'; + generation?: string; + chunks?: ReturnType; + error?: string; +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHours / 24); + + if (!Number.isFinite(diffMs)) return '--'; + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function actorLabel(summary: BoardTaskExactLogSummary): string { + if (summary.actor.memberName) { + return summary.actor.memberName; + } + if (summary.actor.role === 'lead' || summary.actor.isSidechain === false) { + return 'lead session'; + } + return 'unknown actor'; +} + +function describeSummary(summary: BoardTaskExactLogSummary): string { + return summary.actionLabel; +} + +function anchorKindLabel(summary: BoardTaskExactLogSummary): string { + return summary.anchorKind === 'tool' ? 'tool' : 'message'; +} + +function describeDetailState(state: ExactTaskLogDetailState | undefined): string | null { + if (!state) return null; + if (state.status === 'missing') { + return 'Exact detail is no longer available for this transcript slice.'; + } + if (state.status === 'error') { + return state.error ?? 'Failed to load exact detail.'; + } + return null; +} + +interface ExactTaskLogCardProps { + summary: BoardTaskExactLogSummary; + expanded: boolean; + detailState?: ExactTaskLogDetailState; + onToggle: () => void; +} + +export function ExactTaskLogCard({ + summary, + expanded, + detailState, + onToggle, +}: ExactTaskLogCardProps): React.JSX.Element { + const loadStateText = describeDetailState(detailState); + + return ( +
+ + + {expanded ? ( +
+ {detailState?.status === 'loading' ? ( +
+ + Loading exact task logs... +
+ ) : null} + {detailState?.status === 'ok' && detailState.chunks ? ( +
+ +
+ ) : null} + {detailState?.status !== 'loading' && loadStateText ? ( +
{loadStateText}
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx new file mode 100644 index 00000000..9d324047 --- /dev/null +++ b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { AlertCircle, FileText, Loader2 } from 'lucide-react'; + +import { ExactTaskLogCard, type ExactTaskLogDetailState } from './ExactTaskLogCard'; + +import type { BoardTaskExactLogSummary } from '@shared/types'; + +interface ExactTaskLogsSectionProps { + teamName: string; + taskId: string; +} + +export function ExactTaskLogsSection({ + teamName, + taskId, +}: ExactTaskLogsSectionProps): React.JSX.Element { + const [summaries, setSummaries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [detailStates, setDetailStates] = useState>({}); + const latestRequestSeqById = useRef>({}); + + const loadSummaries = useCallback(async (): Promise => { + const result = await api.teams.getTaskExactLogSummaries(teamName, taskId); + const nextItems = [...result.items].sort((left, right) => { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return rightTs - leftTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + return left.id.localeCompare(right.id); + }); + setSummaries(nextItems); + return nextItems; + }, [taskId, teamName]); + + useEffect(() => { + let cancelled = false; + + const run = async (): Promise => { + try { + setLoading(true); + setError(null); + setExpandedId(null); + setDetailStates({}); + latestRequestSeqById.current = {}; + const nextItems = await api.teams.getTaskExactLogSummaries(teamName, taskId); + if (cancelled) return; + setSummaries( + [...nextItems.items].sort((left, right) => { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return rightTs - leftTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + return left.id.localeCompare(right.id); + }) + ); + } catch (loadError) { + if (!cancelled) { + setError( + loadError instanceof Error ? loadError.message : 'Failed to load exact task logs' + ); + setSummaries([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [taskId, teamName]); + + const fetchDetail = useCallback( + async ( + summary: Extract, + retryOnStale: boolean + ): Promise => { + const nextSeq = (latestRequestSeqById.current[summary.id] ?? 0) + 1; + latestRequestSeqById.current[summary.id] = nextSeq; + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { + status: 'loading', + generation: summary.sourceGeneration, + }, + })); + + try { + const result = await api.teams.getTaskExactLogDetail( + teamName, + taskId, + summary.id, + summary.sourceGeneration + ); + if (latestRequestSeqById.current[summary.id] !== nextSeq) { + return; + } + + if (result.status === 'stale' && retryOnStale) { + const refreshed = await loadSummaries(); + const refreshedSummary = refreshed.find( + (item): item is Extract => + item.id === summary.id && item.canLoadDetail + ); + if (!refreshedSummary) { + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { status: 'missing' }, + })); + return; + } + await fetchDetail(refreshedSummary, false); + return; + } + + if (result.status === 'ok') { + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { + status: 'ok', + generation: summary.sourceGeneration, + chunks: asEnhancedChunkArray(result.detail.chunks), + }, + })); + return; + } + + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { status: 'missing', generation: summary.sourceGeneration }, + })); + } catch (detailError) { + if (latestRequestSeqById.current[summary.id] !== nextSeq) { + return; + } + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { + status: 'error', + generation: summary.sourceGeneration, + error: + detailError instanceof Error ? detailError.message : 'Failed to load exact task logs', + }, + })); + } + }, + [loadSummaries, taskId, teamName] + ); + + const handleToggle = useCallback( + async (summary: BoardTaskExactLogSummary): Promise => { + if (!summary.canLoadDetail) { + return; + } + if (expandedId === summary.id) { + setExpandedId(null); + return; + } + setExpandedId(summary.id); + + const existing = detailStates[summary.id]; + if (existing?.generation === summary.sourceGeneration && existing.status !== 'error') { + return; + } + + await fetchDetail(summary, true); + }, + [detailStates, expandedId, fetchDetail] + ); + + const visibleSummaries = useMemo(() => summaries, [summaries]); + + if (loading && visibleSummaries.length === 0) { + return ( +
+
+

+ Exact Task Logs +

+
+
+ + Loading exact task logs... +
+
+ ); + } + + if (error) { + return ( +
+
+

+ Exact Task Logs +

+
+
+ + {error} +
+
+ ); + } + + return ( +
+
+

+ Exact Task Logs +

+
+

+ Exact transcript slices rendered with the same execution-log components used in Logs. +

+ + {visibleSummaries.length === 0 ? ( +
+ + No exact task logs yet +

+ Exact transcript bundles will appear here when explicit task-linked transcript metadata + is available. +

+
+ ) : ( +
+ {visibleSummaries.map((summary) => ( + void handleToggle(summary)} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx new file mode 100644 index 00000000..283a2f2e --- /dev/null +++ b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx @@ -0,0 +1,48 @@ +import type { ComponentProps } from 'react'; + +import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; +import { Loader2 } from 'lucide-react'; + +interface ExecutionSessionsSectionProps extends ComponentProps { + isRefreshing?: boolean; + isPreviewOnline?: boolean; +} + +export function ExecutionSessionsSection({ + isRefreshing = false, + isPreviewOnline = false, + ...props +}: ExecutionSessionsSectionProps): React.JSX.Element { + return ( +
+
+

+ Execution Sessions +

+ {isRefreshing || isPreviewOnline ? ( + + {isPreviewOnline ? ( + + + + + ) : null} + {isRefreshing ? ( + + + Updating... + + ) : null} + + ) : null} +
+

+ Legacy session-centric transcript browsing and previews. +

+ +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx new file mode 100644 index 00000000..1e24f039 --- /dev/null +++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx @@ -0,0 +1,211 @@ +import { api } from '@renderer/api'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { + describeBoardTaskActivityLabel, + formatBoardTaskActivityTaskLabel, +} from '@shared/utils/boardTaskActivityLabels'; + +import type { BoardTaskActivityEntry, BoardTaskActivityTaskRef } from '@shared/types'; + +interface TaskActivitySectionProps { + teamName: string; + taskId: string; +} + +function formatEntryTime(timestamp: string): string { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return '--:--'; + } + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatTaskLabel(task: BoardTaskActivityTaskRef | undefined): string | null { + return formatBoardTaskActivityTaskLabel(task); +} + +function relationshipContextLabel(entry: BoardTaskActivityEntry): string | null { + const peerTaskLabel = formatTaskLabel(entry.action?.peerTask); + if (!peerTaskLabel) return null; + + switch (entry.action?.relationshipPerspective) { + case 'incoming': + return `from ${peerTaskLabel}`; + case 'outgoing': + return `to ${peerTaskLabel}`; + default: + return `with ${peerTaskLabel}`; + } +} + +function describeContext(entry: BoardTaskActivityEntry): string | null { + const parts: string[] = []; + + const relationshipContext = relationshipContextLabel(entry); + if (relationshipContext) { + parts.push(relationshipContext); + } + + if (entry.actorContext.relation === 'other_active_task') { + const activeTaskLabel = formatTaskLabel(entry.actorContext.activeTask); + if (activeTaskLabel) { + parts.push(`while working on ${activeTaskLabel}`); + } else { + parts.push('while another task was active'); + } + } else if (entry.actorContext.relation === 'ambiguous') { + parts.push('while multiple task scopes were active'); + } else if (entry.actorContext.relation === 'idle' && entry.linkKind !== 'execution') { + parts.push('without an active task scope'); + } + + if (entry.task.resolution === 'deleted') { + parts.push('task is deleted'); + } else if (entry.task.resolution === 'ambiguous') { + parts.push('task resolution is ambiguous'); + } else if (entry.task.resolution === 'unresolved') { + parts.push('task could not be resolved'); + } + + return parts.length > 0 ? parts.join(' - ') : null; +} + +function actorLabel(entry: BoardTaskActivityEntry): string { + if (entry.actor.memberName) { + return entry.actor.memberName; + } + if (entry.actor.role === 'lead' || entry.actor.isSidechain === false) { + return 'lead session'; + } + return 'unknown actor'; +} + +function Row({ entry }: { entry: BoardTaskActivityEntry }): React.JSX.Element { + const context = describeContext(entry); + const tone = + entry.task.resolution === 'resolved' + ? 'text-[var(--color-text)]' + : 'text-[var(--color-text-muted)]'; + + return ( +
+
+
+ {formatEntryTime(entry.timestamp)} +
+
+
+ {actorLabel(entry)} + - + {describeBoardTaskActivityLabel(entry)} +
+ {context ? ( +

{context}

+ ) : null} +
+
+
+ ); +} + +export function TaskActivitySection({ + teamName, + taskId, +}: TaskActivitySectionProps): React.JSX.Element { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const load = async (): Promise => { + try { + if (!cancelled && entries.length === 0) { + setLoading(true); + } + if (!cancelled) { + setError(null); + } + const result = await api.teams.getTaskActivity(teamName, taskId); + if (!cancelled) { + setEntries(result); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : 'Failed to load task activity'); + setEntries([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void load(); + const intervalId = window.setInterval(() => { + void load(); + }, 8000); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [entries.length, teamName, taskId]); + + const content = useMemo(() => { + if (loading) { + return ( +
+ + Loading task activity... +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (entries.length === 0) { + return ( +

+ No explicit task activity was found in the available transcripts yet. Older or heuristic + session logs may still be available below in Execution Sessions. +

+ ); + } + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); + }, [entries, error, loading]); + + return ( +
+
+

+ Task Activity +

+
+

+ Explicit runtime activity linked to this task from transcript metadata. +

+ {content} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx new file mode 100644 index 00000000..4afa2cd5 --- /dev/null +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -0,0 +1,222 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { api } from '@renderer/api'; +import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; + +import type { + BoardTaskLogActor, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, +} from '@shared/types'; + +interface TaskLogStreamSectionProps { + teamName: string; + taskId: string; +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHours / 24); + + if (!Number.isFinite(diffMs)) return '--'; + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function actorLabel(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return actor.memberName; + } + if (actor.role === 'lead' || actor.isSidechain === false) { + return 'lead session'; + } + if (actor.agentId) { + return `member ${actor.agentId.slice(0, 8)}`; + } + return `member session ${actor.sessionId.slice(0, 8)}`; +} + +function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogStreamResponse { + return { + participants: response.participants, + defaultFilter: response.defaultFilter, + segments: response.segments.map((segment) => ({ + ...segment, + chunks: asEnhancedChunkArray(segment.chunks) ?? [], + })), + }; +} + +function SegmentMarker({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element { + return ( +
+ + {actorLabel(segment.actor)} + + + + {formatRelativeTime(segment.endTimestamp)} + +
+ ); +} + +function SegmentBlock({ + segment, + showHeader, +}: { + segment: BoardTaskLogSegment; + showHeader: boolean; +}): React.JSX.Element { + return ( +
+ {showHeader ? : null} + +
+ ); +} + +export function TaskLogStreamSection({ + teamName, + taskId, +}: TaskLogStreamSectionProps): React.JSX.Element { + const [stream, setStream] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); + + useEffect(() => { + let cancelled = false; + + const run = async (): Promise => { + try { + setLoading(true); + setError(null); + const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); + if (cancelled) return; + setStream(response); + setSelectedParticipantKey(response.defaultFilter); + } catch (loadError) { + if (cancelled) return; + setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream'); + setStream(null); + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [taskId, teamName]); + + const participants = stream?.participants ?? []; + const showChips = participants.length > 1; + const visibleSegments = useMemo(() => { + const source = stream?.segments ?? []; + const filtered = + selectedParticipantKey === 'all' + ? source + : source.filter((segment) => segment.participantKey === selectedParticipantKey); + return [...filtered].reverse(); + }, [selectedParticipantKey, stream?.segments]); + + const showSegmentHeaders = + participants.length > 1 || (selectedParticipantKey !== 'all' && visibleSegments.length > 1); + + if (loading) { + return ( +
+

+ Task Log Stream +

+
+ + Loading task log stream... +
+
+ ); + } + + if (error) { + return ( +
+

+ Task Log Stream +

+
+ + {error} +
+
+ ); + } + + return ( +
+

+ Task Log Stream +

+

+ Task-scoped transcript logs rendered with the same execution-log components used in Logs. +

+ + {showChips ? ( +
+ + {participants.map((participant) => ( + + ))} +
+ ) : null} + + {visibleSegments.length === 0 ? ( +
+ + No task log stream yet +

+ Task-linked transcript logs will appear here when explicit task-linked transcript + metadata is available. +

+
+ ) : ( +
+ {visibleSegments.map((segment) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx new file mode 100644 index 00000000..913e5537 --- /dev/null +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -0,0 +1,55 @@ +import { ExecutionSessionsSection } from './ExecutionSessionsSection'; +import { TaskActivitySection } from './TaskActivitySection'; +import { TaskLogStreamSection } from './TaskLogStreamSection'; +import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +interface TaskLogsPanelProps { + teamName: string; + task: TeamTaskWithKanban; + taskSince?: string; + isExecutionRefreshing?: boolean; + isExecutionPreviewOnline?: boolean; + onRefreshingChange?: (isRefreshing: boolean) => void; + showSubagentPreview?: boolean; + showLeadPreview?: boolean; + onPreviewOnlineChange?: (isOnline: boolean) => void; +} + +export function TaskLogsPanel({ + teamName, + task, + taskSince, + isExecutionRefreshing = false, + isExecutionPreviewOnline = false, + onRefreshingChange, + showSubagentPreview = false, + showLeadPreview = false, + onPreviewOnlineChange, +}: TaskLogsPanelProps): React.JSX.Element { + return ( +
+ {isBoardTaskActivityUiEnabled() ? ( + + ) : null} + {isBoardTaskExactLogsUiEnabled() ? ( + + ) : null} + +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/featureGates.ts b/src/renderer/components/team/taskLogs/featureGates.ts new file mode 100644 index 00000000..f293958a --- /dev/null +++ b/src/renderer/components/team/taskLogs/featureGates.ts @@ -0,0 +1,22 @@ +function readEnabledFlag(value: unknown, defaultValue: boolean): boolean { + if (typeof value !== 'string') { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskActivityUiEnabled(): boolean { + return readEnabledFlag(import.meta.env.VITE_BOARD_TASK_ACTIVITY_UI_ENABLED, true); +} + +export function isBoardTaskExactLogsUiEnabled(): boolean { + return readEnabledFlag(import.meta.env.VITE_BOARD_TASK_EXACT_LOGS_UI_ENABLED, true); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9413db3f..8eb817cf 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -38,6 +38,10 @@ import type { } from './schedule'; import type { AddMemberRequest, + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, AddTaskCommentRequest, AttachmentFileData, CreateTaskRequest, @@ -51,11 +55,11 @@ import type { MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, + MessagesPage, ProjectBranchChangeEvent, ReplaceMembersRequest, SendMessageRequest, SendMessageResult, - MessagesPage, TaskAttachmentMeta, TaskChangePresenceState, TaskComment, @@ -477,6 +481,18 @@ export interface TeamsAPI { since?: string; } ) => Promise; + getTaskActivity: (teamName: string, taskId: string) => Promise; + getTaskLogStream: (teamName: string, taskId: string) => Promise; + getTaskExactLogSummaries: ( + teamName: string, + taskId: string + ) => Promise; + getTaskExactLogDetail: ( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ) => Promise; getMemberStats: (teamName: string, memberName: string) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; getAllTasks: () => Promise; diff --git a/src/shared/utils/boardTaskActivityLabels.ts b/src/shared/utils/boardTaskActivityLabels.ts new file mode 100644 index 00000000..36f99fdd --- /dev/null +++ b/src/shared/utils/boardTaskActivityLabels.ts @@ -0,0 +1,128 @@ +import type { + BoardTaskActivityAction, + BoardTaskActivityLinkKind, + BoardTaskActivityTaskRef, +} from '../types/team'; + +interface BoardTaskActivityLabelInput { + action?: BoardTaskActivityAction; + linkKind: BoardTaskActivityLinkKind; +} + +export function formatBoardTaskActivityTaskLabel( + task: BoardTaskActivityTaskRef | undefined +): string | null { + if (!task) return null; + if (task.taskRef) { + return `#${task.taskRef.displayId}`; + } + if (task.locator.ref) { + return `#${task.locator.ref}`; + } + return null; +} + +function describeRelationshipAction( + action: BoardTaskActivityAction | undefined, + verb: 'link' | 'unlink' +): string { + const peerTaskLabel = formatBoardTaskActivityTaskLabel(action?.peerTask); + const relationship = action?.details?.relationship; + + if (relationship === 'related' && peerTaskLabel) { + return verb === 'link' + ? `Linked related task ${peerTaskLabel}` + : `Removed related link with ${peerTaskLabel}`; + } + + if (action?.relationshipPerspective === 'incoming' && peerTaskLabel) { + return verb === 'link' + ? `Linked blocked by ${peerTaskLabel}` + : `Removed blocked-by link from ${peerTaskLabel}`; + } + + if (action?.relationshipPerspective === 'outgoing' && peerTaskLabel) { + return verb === 'link' + ? `Linked blocks ${peerTaskLabel}` + : `Removed blocks link to ${peerTaskLabel}`; + } + + if (relationship) { + return verb === 'link' ? `Linked task as ${relationship}` : `Removed ${relationship} link`; + } + + return verb === 'link' ? 'Linked task' : 'Removed task link'; +} + +export function describeBoardTaskActivityLabel(input: BoardTaskActivityLabelInput): string { + const toolName = input.action?.canonicalToolName; + switch (toolName) { + case 'task_start': + return 'Started work'; + case 'task_complete': + return 'Completed task'; + case 'task_set_status': + return input.action?.details?.status + ? `Set status to ${input.action.details.status}` + : 'Updated task status'; + case 'review_start': + return 'Started review'; + case 'review_approve': + return 'Approved review'; + case 'review_request_changes': + return 'Requested changes'; + case 'review_request': + return input.action?.details?.reviewer + ? `Requested review from ${input.action.details.reviewer}` + : 'Requested review'; + case 'task_add_comment': + return 'Added a comment'; + case 'task_attach_file': + return input.action?.details?.filename + ? `Attached ${input.action.details.filename}` + : 'Attached a file'; + case 'task_attach_comment_file': + return input.action?.details?.filename + ? `Attached ${input.action.details.filename} to a comment` + : 'Attached a file to a comment'; + case 'task_get': + return 'Viewed task'; + case 'task_get_comment': + return input.action?.details?.commentId + ? `Viewed comment ${input.action.details.commentId}` + : 'Viewed comment'; + case 'task_link': + return describeRelationshipAction(input.action, 'link'); + case 'task_unlink': + return describeRelationshipAction(input.action, 'unlink'); + case 'task_set_clarification': + if ( + input.action?.details?.clarification === 'lead' || + input.action?.details?.clarification === 'user' + ) { + return `Set clarification to ${input.action.details.clarification}`; + } + if (input.action?.details && 'clarification' in input.action.details) { + return 'Cleared clarification'; + } + return 'Updated clarification'; + case 'task_set_owner': + if (typeof input.action?.details?.owner === 'string' && input.action.details.owner.trim()) { + return `Assigned owner to ${input.action.details.owner}`; + } + if (input.action?.details && 'owner' in input.action.details) { + return 'Cleared owner'; + } + return 'Updated owner'; + case 'kanban_set_column': + return 'Updated column'; + default: + if (input.linkKind === 'execution') { + return 'Worked on task'; + } + if (input.linkKind === 'lifecycle') { + return 'Updated task lifecycle'; + } + return 'Performed a related board action'; + } +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index b102167b..13353fe1 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -1,6 +1,14 @@ import * as os from 'os'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { InboxMessage, TeamCreateRequest, TeamProvisioningProgress } from '@shared/types/team'; +import type { + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + InboxMessage, + TeamCreateRequest, + TeamProvisioningProgress, +} from '@shared/types/team'; vi.mock('electron', () => ({ app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') }, @@ -64,6 +72,10 @@ import { TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_GET_ALL_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_START_TASK, @@ -186,6 +198,25 @@ describe('ipc teams handlers', () => { getLeadActivityState: vi.fn(() => 'idle'), stopTeam: vi.fn(() => undefined), }; + const boardTaskActivityService = { + getTaskActivity: vi.fn<() => Promise>(async () => []), + }; + const boardTaskLogStreamService = { + getTaskLogStream: + vi.fn<() => Promise>(async () => ({ + participants: [], + defaultFilter: 'all', + segments: [], + })), + }; + const boardTaskExactLogsService = { + getTaskExactLogSummaries: + vi.fn<() => Promise>(async () => ({ items: [] })), + }; + const boardTaskExactLogDetailService = { + getTaskExactLogDetail: + vi.fn<() => Promise>(async () => ({ status: 'missing' })), + }; beforeEach(() => { handlers.clear(); @@ -195,7 +226,19 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); mockTeamDataWorkerClient.getTeamData.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset(); - initializeTeamHandlers(service as never, provisioningService as never); + initializeTeamHandlers( + service as never, + provisioningService as never, + undefined, + undefined, + undefined, + undefined, + undefined, + boardTaskActivityService as never, + boardTaskLogStreamService as never, + boardTaskExactLogsService as never, + boardTaskExactLogDetailService as never, + ); registerTeamHandlers(ipcMain as never); }); @@ -224,6 +267,10 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true); expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true); expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_SUMMARIES)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_DETAIL)).toBe(true); expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true); @@ -279,6 +326,149 @@ describe('ipc teams handlers', () => { expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team'); }); + it('returns explicit exact task-log summaries for a task', async () => { + boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }, + ], + }); + + const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + )) as { + success: boolean; + data?: BoardTaskExactLogSummariesResponse; + }; + + expect(result.success).toBe(true); + expect(result.data?.items).toHaveLength(1); + expect(boardTaskExactLogsService.getTaskExactLogSummaries).toHaveBeenCalledWith( + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + ); + }); + + it('returns one task log stream for a task', async () => { + boardTaskLogStreamService.getTaskLogStream.mockResolvedValueOnce({ + participants: [ + { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: true, + }, + ], + defaultFilter: 'all', + segments: [], + }); + + const handler = handlers.get(TEAM_GET_TASK_LOG_STREAM); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + )) as { + success: boolean; + data?: BoardTaskLogStreamResponse; + }; + + expect(result.success).toBe(true); + expect(result.data?.participants).toHaveLength(1); + expect(boardTaskLogStreamService.getTaskLogStream).toHaveBeenCalledWith( + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + ); + }); + + it('returns exact task-log detail for a task bundle', async () => { + boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({ + status: 'ok', + detail: { + id: 'tool:/tmp/task.jsonl:tool-1', + chunks: [], + }, + }); + + const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-1' + )) as { + success: boolean; + data?: BoardTaskExactLogDetailResult; + }; + + expect(result.success).toBe(true); + expect(result.data?.status).toBe('ok'); + expect(boardTaskExactLogDetailService.getTaskExactLogDetail).toHaveBeenCalledWith( + 'my-team', + '123e4567-e89b-12d3-a456-426614174000', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-1' + ); + }); + + it('returns exact task-log detail stale status without rewriting the service result', async () => { + boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({ + status: 'stale', + }); + + const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-2' + )) as { + success: boolean; + data?: BoardTaskExactLogDetailResult; + }; + + expect(result).toEqual({ + success: true, + data: { status: 'stale' }, + }); + }); + it('returns success false on invalid sendMessage args', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); @@ -893,6 +1083,8 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false); expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false); + expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(false); + expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(false); expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false); @@ -922,6 +1114,46 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false); }); + it('returns explicit task activity rows', async () => { + const handler = handlers.get(TEAM_GET_TASK_ACTIVITY); + expect(handler).toBeDefined(); + + const activityRows: BoardTaskActivityEntry[] = [ + { + id: 'activity-1', + timestamp: '2026-04-12T10:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display' }, + resolution: 'resolved', + }, + linkKind: 'lifecycle', + targetRole: 'subject', + actor: { + role: 'lead', + sessionId: 'session-1', + isSidechain: false, + }, + actorContext: { + relation: 'idle', + }, + source: { + messageUuid: 'message-1', + filePath: '/tmp/transcript.jsonl', + sourceOrder: 1, + }, + }, + ]; + boardTaskActivityService.getTaskActivity.mockResolvedValueOnce(activityRows); + + const result = (await handler!({} as never, 'my-team', 'task-1')) as { + success: boolean; + data: typeof activityRows; + }; + + expect(result).toEqual({ success: true, data: activityRows }); + expect(boardTaskActivityService.getTaskActivity).toHaveBeenCalledWith('my-team', 'task-1'); + }); + describe('addTaskRelationship', () => { it('calls service on valid input', async () => { const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; diff --git a/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts new file mode 100644 index 00000000..edc2faae --- /dev/null +++ b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from 'vitest'; + +import { BoardTaskActivityEntryBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder'; + +import type { TeamTask } from '../../../../src/shared/types/team'; +import type { RawTaskActivityMessage } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; + +function makeTask(task: Partial & Pick): TeamTask { + return { + displayId: task.displayId ?? task.id.slice(0, 8), + createdAt: '2026-04-12T10:00:00.000Z', + updatedAt: '2026-04-12T10:00:00.000Z', + ...task, + }; +} + +describe('BoardTaskActivityEntryBuilder', () => { + it('builds same-task execution rows and external board actions', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + const taskB = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174001', + displayId: 'efgh5678', + subject: 'Task B', + status: 'pending', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/a.jsonl', + uuid: 'msg-1', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentId: 'agent-a', + agentName: 'alice', + isSidechain: true, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [], + }, + { + filePath: '/tmp/b.jsonl', + uuid: 'msg-2', + timestamp: '2026-04-12T10:01:00.000Z', + sessionId: 'session-1', + agentId: 'agent-a', + agentName: 'alice', + isSidechain: true, + sourceOrder: 2, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-2', + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { + relation: 'other_active_task', + activeTask: { ref: 'efgh5678', refKind: 'display', canonicalId: taskB.id }, + activePhase: 'work', + activeExecutionSeq: 2, + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-2', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + ], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA, taskB], + messages, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]?.linkKind).toBe('execution'); + expect(entries[1]?.actorContext.relation).toBe('other_active_task'); + expect(entries[1]?.action?.canonicalToolName).toBe('task_add_comment'); + expect(entries[1]?.action?.category).toBe('comment'); + expect(entries[1]?.action?.details?.commentId).toBe('comment-1'); + }); + + it('marks display-id collisions as ambiguous instead of guessing', () => { + const liveTask = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Live task', + status: 'in_progress', + }); + const deletedTask = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174099', + displayId: 'abcd1234', + subject: 'Deleted task', + status: 'deleted', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/a.jsonl', + uuid: 'msg-1', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + isSidechain: true, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: liveTask, + tasks: [liveTask, deletedTask], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.task.resolution).toBe('ambiguous'); + }); + + it('preserves deleted peer tasks on relationship rows', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + const deletedPeer = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174002', + displayId: 'ijkl9012', + subject: 'Task B', + status: 'deleted', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/relationships.jsonl', + uuid: 'msg-3', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentName: 'lead', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-3', + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-3', + task: { ref: 'ijkl9012', refKind: 'display', canonicalId: deletedPeer.id }, + targetRole: 'related', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-3', + canonicalToolName: 'task_link', + input: { relationship: 'related' }, + }, + ], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA, deletedPeer], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.action?.peerTask?.resolution).toBe('deleted'); + expect(entries[0]?.action?.details?.relationship).toBe('related'); + expect(entries[0]?.action?.category).toBe('relationship'); + expect(entries[0]?.action?.relationshipPerspective).toBe('symmetric'); + }); + + it('resolves display locators case-insensitively and canonical-like unknown refs safely', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/case.jsonl', + uuid: 'msg-4', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'ABCD1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + task: { ref: taskA.id, refKind: 'unknown' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]?.task.resolution).toBe('resolved'); + expect(entries[1]?.task.resolution).toBe('resolved'); + }); + + it('marks main-session actor without explicit name as unknown instead of forcing lead', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/unknown-actor.jsonl', + uuid: 'msg-5', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.actor.role).toBe('unknown'); + }); + + it('never joins action payloads onto execution rows', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/execution-malformed.jsonl', + uuid: 'msg-6', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentId: 'agent-a', + agentName: 'alice', + isSidechain: true, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_start', + }, + ], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.linkKind).toBe('execution'); + expect(entries[0]?.action).toBeUndefined(); + }); + + it('derives relationship perspective from target role', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174010', + displayId: 'taska010', + subject: 'Task A', + status: 'in_progress', + }); + const taskB = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174011', + displayId: 'taskb011', + subject: 'Task B', + status: 'pending', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/relationship-perspective.jsonl', + uuid: 'msg-7', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentName: 'lead', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-7', + task: { ref: 'taska010', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-7', + task: { ref: 'taskb011', refKind: 'display', canonicalId: taskB.id }, + targetRole: 'related', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-7', + canonicalToolName: 'task_link', + input: { relationship: 'blocked-by' }, + }, + ], + }, + ]; + + const builder = new BoardTaskActivityEntryBuilder(); + const entriesForTaskA = builder.buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA, taskB], + messages, + }); + const entriesForTaskB = builder.buildForTask({ + teamName: 'demo', + targetTask: taskB, + tasks: [taskA, taskB], + messages, + }); + + expect(entriesForTaskA).toHaveLength(1); + expect(entriesForTaskA[0]?.action?.relationshipPerspective).toBe('incoming'); + expect(entriesForTaskA[0]?.action?.peerTask?.taskRef?.taskId).toBe(taskB.id); + + expect(entriesForTaskB).toHaveLength(1); + expect(entriesForTaskB[0]?.action?.relationshipPerspective).toBe('outgoing'); + expect(entriesForTaskB[0]?.action?.peerTask?.taskRef?.taskId).toBe(taskA.id); + }); +}); diff --git a/test/main/services/team/BoardTaskActivityRecordSource.test.ts b/test/main/services/team/BoardTaskActivityRecordSource.test.ts new file mode 100644 index 00000000..af672611 --- /dev/null +++ b/test/main/services/team/BoardTaskActivityRecordSource.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource'; + +describe('BoardTaskActivityRecordSource', () => { + it('uses active and deleted tasks together when building explicit task records', async () => { + const targetTask = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'A', + status: 'pending', + }; + const deletedTask = { + id: 'task-b', + displayId: 'deadbeef', + subject: 'B', + status: 'deleted', + }; + const transcriptFiles = ['/tmp/a.jsonl']; + const rawMessages = [{ uuid: 'm1' }]; + const builtRecords = [{ id: 'r1' }]; + + const locator = { + listTranscriptFiles: vi.fn(async () => transcriptFiles), + }; + const taskReader = { + getTasks: vi.fn(async () => [targetTask]), + getDeletedTasks: vi.fn(async () => [deletedTask]), + }; + const transcriptReader = { + readFiles: vi.fn(async () => rawMessages), + }; + const recordBuilder = { + buildForTask: vi.fn(() => builtRecords), + }; + + const source = new BoardTaskActivityRecordSource( + locator as never, + taskReader as never, + transcriptReader as never, + recordBuilder as never, + ); + + const result = await source.getTaskRecords('demo', 'task-a'); + + expect(result).toBe(builtRecords); + expect(locator.listTranscriptFiles).toHaveBeenCalledWith('demo'); + expect(transcriptReader.readFiles).toHaveBeenCalledWith(transcriptFiles); + expect(recordBuilder.buildForTask).toHaveBeenCalledWith({ + teamName: 'demo', + targetTask, + tasks: [targetTask, deletedTask], + messages: rawMessages, + }); + }); + + it('returns empty when the target task is unknown', async () => { + const locator = { + listTranscriptFiles: vi.fn(async () => ['/tmp/a.jsonl']), + }; + const taskReader = { + getTasks: vi.fn(async () => []), + getDeletedTasks: vi.fn(async () => []), + }; + const transcriptReader = { + readFiles: vi.fn(async () => [{ uuid: 'm1' }]), + }; + const recordBuilder = { + buildForTask: vi.fn(() => [{ id: 'r1' }]), + }; + + const source = new BoardTaskActivityRecordSource( + locator as never, + taskReader as never, + transcriptReader as never, + recordBuilder as never, + ); + + await expect(source.getTaskRecords('demo', 'task-missing')).resolves.toEqual([]); + expect(recordBuilder.buildForTask).not.toHaveBeenCalled(); + }); +}); diff --git a/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts new file mode 100644 index 00000000..0932abcc --- /dev/null +++ b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; + +const tempPaths: string[] = []; + +async function createTempTranscript(lines: unknown[]): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'board-task-activity-')); + const filePath = path.join(dir, 'transcript.jsonl'); + tempPaths.push(dir); + await fs.writeFile( + filePath, + lines.map(line => JSON.stringify(line)).join('\n'), + 'utf8', + ); + return filePath; +} + +afterEach(async () => { + await Promise.all( + tempPaths.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe('BoardTaskActivityTranscriptReader', () => { + it('skips transcript rows without a stable timestamp', async () => { + const filePath = await createTempTranscript([ + { + uuid: 'missing-timestamp', + sessionId: 'session-1', + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + }, + { + uuid: 'valid-row', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + }, + ]); + + const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]); + + expect(rows).toHaveLength(1); + expect(rows[0]?.uuid).toBe('valid-row'); + expect(rows[0]?.timestamp).toBe('2026-04-12T10:00:00.000Z'); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogChunkBuilder.test.ts b/test/main/services/team/BoardTaskExactLogChunkBuilder.test.ts new file mode 100644 index 00000000..f3425aef --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogChunkBuilder.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BoardTaskExactLogChunkBuilder } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; + +import type { EnhancedChunk, ParsedMessage } from '../../../../src/main/types'; + +describe('BoardTaskExactLogChunkBuilder', () => { + it('delegates to ChunkBuilder with includeSidechain enabled', () => { + const buildChunks = vi.fn<() => EnhancedChunk[]>(() => []); + const messages = [{ uuid: 'm1' }] as unknown as ParsedMessage[]; + + const builder = new BoardTaskExactLogChunkBuilder({ buildChunks } as never); + const result = builder.buildBundleChunks(messages); + + expect(result).toEqual([]); + expect(buildChunks).toHaveBeenCalledWith(messages, [], { includeSidechain: true }); + }); + + it('does not crash on a minimal assistant-only bundle', () => { + const messages: ParsedMessage[] = [ + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T18:00:00.000Z'), + role: 'assistant', + content: [{ type: 'text', text: 'done' } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ]; + + const chunks = new BoardTaskExactLogChunkBuilder().buildBundleChunks(messages); + + expect(chunks.length).toBeGreaterThan(0); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts new file mode 100644 index 00000000..a9c0b1a9 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest'; + +import { BoardTaskExactLogDetailSelector } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector'; + +import type { ParsedMessage } from '../../../../src/main/types'; +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { BoardTaskExactLogBundleCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; + +function makeRecord(): BoardTaskActivityRecord { + return { + id: 'record-1', + timestamp: '2026-04-12T16:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-1', + category: 'comment', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + }; +} + +function makeCandidate(records: BoardTaskActivityRecord[]): BoardTaskExactLogBundleCandidate { + return { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: records[0]!.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + records, + anchor: { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + }, + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }; +} + +describe('BoardTaskExactLogDetailSelector', () => { + it('keeps the matched tool flow, preserves anchor output, and deduplicates assistant streaming rows anchor-aware', () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'assistant-0', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'draft' } as never, + { type: 'text', text: 'old tool draft' } as never, + { type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: { taskId: 'x' } } as never, + ], + toolCalls: [], + toolResults: [], + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'assistant', + content: [ + { type: 'text', text: 'stream tail without anchor tool call' } as never, + { type: 'tool_use', id: 'tool-2', name: 'task_get', input: { taskId: 'y' } } as never, + ], + toolCalls: [], + toolResults: [], + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-1', + parentUuid: null, + type: 'user', + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never, + { type: 'tool_result', tool_use_id: 'tool-2', content: 'ignore' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-1', + toolUseResult: { output: 'kept' }, + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-2', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:03.000Z'), + role: 'assistant', + content: [{ type: 'text', text: 'comment saved' } as never], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + requestId: 'req-2', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages).toHaveLength(3); + expect(detail?.filteredMessages[0]?.uuid).toBe('assistant-0'); + expect(detail?.filteredMessages[1]?.uuid).toBe('user-1'); + expect(detail?.filteredMessages[2]?.uuid).toBe('assistant-2'); + expect(detail?.filteredMessages[0]?.toolCalls).toHaveLength(1); + expect(detail?.filteredMessages[1]?.toolResults).toHaveLength(1); + expect(detail?.filteredMessages[1]?.toolUseResult).toEqual({ output: 'kept' }); + expect(detail?.filteredMessages[1]?.sourceToolAssistantUUID).toBeUndefined(); + expect(detail?.filteredMessages[2]?.sourceToolUseID).toBe('tool-1'); + }); + + it('drops stale derived tool metadata when a message-linked row survives filtering', () => { + const record = { + ...makeRecord(), + id: 'record-message-1', + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-2', + sourceOrder: 2, + }, + action: undefined, + } satisfies BoardTaskActivityRecord; + const candidate: BoardTaskExactLogBundleCandidate = { + id: 'message:/tmp/task.jsonl:user-2', + timestamp: '2026-04-12T16:01:00.000Z', + actor: record.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-2', + sourceOrder: 2, + }, + records: [record], + anchor: { + kind: 'message', + filePath: '/tmp/task.jsonl', + messageUuid: 'user-2', + }, + actionLabel: 'Worked on task', + linkKinds: ['execution'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-2', + }; + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'user-2', + parentUuid: null, + type: 'user', + timestamp: new Date('2026-04-12T16:01:00.000Z'), + role: 'user', + content: [ + { type: 'text', text: 'status update' } as never, + { type: 'tool_result', tool_use_id: 'other-tool', content: 'stale tool result' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'other-tool', + sourceToolAssistantUUID: 'assistant-other', + toolUseResult: { output: 'stale' }, + requestId: 'req-2', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records: [record], + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages).toHaveLength(1); + expect(detail?.filteredMessages[0]?.content).toEqual([{ type: 'text', text: 'status update' }]); + expect(detail?.filteredMessages[0]?.toolResults).toEqual([]); + expect(detail?.filteredMessages[0]?.sourceToolUseID).toBeUndefined(); + expect(detail?.filteredMessages[0]?.sourceToolAssistantUUID).toBeUndefined(); + expect(detail?.filteredMessages[0]?.toolUseResult).toBeUndefined(); + }); + + it('preserves toolUseResult for a matched tool_result even when sourceToolUseID is absent', () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [ + { type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: { taskId: 'x' } } as never, + ], + toolCalls: [], + toolResults: [], + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-1', + parentUuid: null, + type: 'user', + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never, + ], + toolCalls: [], + toolResults: [], + toolUseResult: { + toolUseId: 'tool-1', + content: 'ok', + }, + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages).toHaveLength(2); + expect(detail?.filteredMessages[1]?.sourceToolUseID).toBe('tool-1'); + expect(detail?.filteredMessages[1]?.toolUseResult).toEqual({ + toolUseId: 'tool-1', + content: 'ok', + }); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogDetailService.test.ts b/test/main/services/team/BoardTaskExactLogDetailService.test.ts new file mode 100644 index 00000000..c7ee35f6 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogDetailService.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BoardTaskExactLogDetailService } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogBundleCandidate, + BoardTaskExactLogDetailCandidate, +} from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; + +function makeRecord(): BoardTaskActivityRecord { + return { + id: 'record-1', + timestamp: '2026-04-12T16:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-1', + category: 'comment', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + }; +} + +function makeCandidate(records: BoardTaskActivityRecord[]): BoardTaskExactLogBundleCandidate { + return { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: records[0]!.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + records, + anchor: { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + }, + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }; +} + +describe('BoardTaskExactLogDetailService', () => { + it('returns missing when the exact-log read flag is disabled', async () => { + vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false'); + const recordSource = { getTaskRecords: vi.fn(async () => []) }; + const service = new BoardTaskExactLogDetailService( + recordSource as never, + { selectSummaries: vi.fn() } as never, + { parseFiles: vi.fn() } as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + await expect( + service.getTaskExactLogDetail('demo', 'task-a', 'tool:/tmp/task.jsonl:tool-1', 'gen-1') + ).resolves.toEqual({ status: 'missing' }); + expect(recordSource.getTaskRecords).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + + it('returns stale when the expected source generation no longer matches', async () => { + const records = [makeRecord()]; + const recordSource = { getTaskRecords: vi.fn(async () => records) }; + const summarySelector = { + selectSummaries: vi.fn(() => [makeCandidate(records)]), + }; + + const service = new BoardTaskExactLogDetailService( + recordSource as never, + summarySelector as never, + { parseFiles: vi.fn() } as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + const result = await service.getTaskExactLogDetail('demo', 'task-a', 'tool:/tmp/task.jsonl:tool-1', 'gen-old'); + + expect(result).toEqual({ status: 'stale' }); + }); + + it('returns ok when a matching detail bundle is reconstructed', async () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const detailCandidate: BoardTaskExactLogDetailCandidate = { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records, + filteredMessages: [], + }; + + const recordSource = { getTaskRecords: vi.fn(async () => records) }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => detailCandidate), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn(() => []), + }; + + const service = new BoardTaskExactLogDetailService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + chunkBuilder as never + ); + + const result = await service.getTaskExactLogDetail( + 'demo', + 'task-a', + candidate.id, + 'gen-1' + ); + + expect(result).toEqual({ + status: 'ok', + detail: { + id: candidate.id, + chunks: [], + }, + }); + }); + + it('returns missing for non-expandable summaries without parsing transcript content', async () => { + const records = [makeRecord()]; + const nonExpandableCandidate: BoardTaskExactLogBundleCandidate = { + ...makeCandidate(records), + canLoadDetail: false, + }; + const recordSource = { getTaskRecords: vi.fn(async () => records) }; + const summarySelector = { + selectSummaries: vi.fn(() => [nonExpandableCandidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map()), + }; + + const service = new BoardTaskExactLogDetailService( + recordSource as never, + summarySelector as never, + strictParser as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + const result = await service.getTaskExactLogDetail('demo', 'task-a', nonExpandableCandidate.id, 'gen-1'); + + expect(result).toEqual({ status: 'missing' }); + expect(strictParser.parseFiles).not.toHaveBeenCalled(); + }); + + it('returns missing when strict detail reconstruction fails for malformed transcript data', async () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => null), + }; + + const service = new BoardTaskExactLogDetailService( + { getTaskRecords: vi.fn(async () => records) } as never, + { selectSummaries: vi.fn(() => [candidate]) } as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks: vi.fn() } as never + ); + + const result = await service.getTaskExactLogDetail('demo', 'task-a', candidate.id, 'gen-1'); + + expect(result).toEqual({ status: 'missing' }); + expect(strictParser.parseFiles).toHaveBeenCalledWith(['/tmp/task.jsonl']); + expect(detailSelector.selectDetail).toHaveBeenCalled(); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogStrictParser.test.ts b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts new file mode 100644 index 00000000..de8cb2ac --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { BoardTaskExactLogStrictParser } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0, tempDirs.length).map(async (dirPath) => { + await fs.rm(dirPath, { recursive: true, force: true }); + }), + ); +}); + +describe('BoardTaskExactLogStrictParser', () => { + it('drops malformed timestamp rows instead of assigning them synthetic time', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'session.jsonl'); + await fs.writeFile( + filePath, + [ + JSON.stringify({ + uuid: 'bad-ts', + type: 'assistant', + timestamp: 'not-a-real-date', + message: { role: 'assistant', content: 'bad row' }, + }), + JSON.stringify({ + uuid: 'good-ts', + type: 'assistant', + timestamp: '2026-04-12T18:00:00.000Z', + message: { role: 'assistant', content: 'good row' }, + }), + ].join('\n'), + 'utf8', + ); + + const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]); + + expect(parsed.get(filePath)?.map((message) => message.uuid)).toEqual(['good-ts']); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogSummarySelector.test.ts b/test/main/services/team/BoardTaskExactLogSummarySelector.test.ts new file mode 100644 index 00000000..58194883 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogSummarySelector.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { BoardTaskExactLogSummarySelector } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; + +function makeRecord( + id: string, + overrides: Partial = {} +): BoardTaskActivityRecord { + return { + id, + timestamp: '2026-04-12T16:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-1', + category: 'comment', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + sourceOrder: 1, + }, + ...overrides, + }; +} + +describe('BoardTaskExactLogSummarySelector', () => { + it('prefers tool anchors over message anchors within one message group', () => { + const selector = new BoardTaskExactLogSummarySelector(); + const records = [ + makeRecord('r1', { source: { filePath: '/tmp/task.jsonl', messageUuid: 'msg-1', sourceOrder: 1 } }), + makeRecord('r2', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 2, + }, + }), + ]; + + const summaries = selector.selectSummaries({ + records, + fileVersionsByPath: new Map([ + ['/tmp/task.jsonl', { filePath: '/tmp/task.jsonl', mtimeMs: 1000, size: 42 }], + ]), + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.id).toBe('tool:/tmp/task.jsonl:tool-1'); + expect(summaries[0]?.source.toolUseId).toBe('tool-1'); + expect(summaries[0]?.anchor.kind).toBe('tool'); + expect(summaries[0]?.actionLabel).toBe('Added a comment'); + expect(summaries[0]?.actionCategory).toBe('comment'); + expect(summaries[0]?.canonicalToolName).toBe('task_add_comment'); + expect(summaries[0]?.records).toHaveLength(2); + expect(summaries[0]?.canLoadDetail).toBe(true); + }); + + it('marks summaries as non-expandable when file version metadata is missing', () => { + const selector = new BoardTaskExactLogSummarySelector(); + const summaries = selector.selectSummaries({ + records: [makeRecord('r1')], + fileVersionsByPath: new Map(), + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.canLoadDetail).toBe(false); + }); + + it('builds distinct action labels for multiple tool-linked bundles from the same actor', () => { + const selector = new BoardTaskExactLogSummarySelector(); + const records = [ + makeRecord('r1', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-comment', + sourceOrder: 1, + }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-comment', + category: 'comment', + }, + }), + makeRecord('r2', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-2', + toolUseId: 'tool-review', + sourceOrder: 2, + }, + action: { + canonicalToolName: 'review_request', + toolUseId: 'tool-review', + category: 'review', + details: { reviewer: 'tom' }, + }, + }), + makeRecord('r3', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-3', + toolUseId: 'tool-read', + sourceOrder: 3, + }, + action: { + canonicalToolName: 'task_get', + toolUseId: 'tool-read', + category: 'read', + }, + }), + ]; + + const summaries = selector.selectSummaries({ + records, + fileVersionsByPath: new Map([ + ['/tmp/task.jsonl', { filePath: '/tmp/task.jsonl', mtimeMs: 1000, size: 42 }], + ]), + }); + + expect(summaries).toHaveLength(3); + expect(summaries.map((summary) => summary.actionLabel)).toEqual([ + 'Added a comment', + 'Requested review from tom', + 'Viewed task', + ]); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogsService.test.ts b/test/main/services/team/BoardTaskExactLogsService.test.ts new file mode 100644 index 00000000..fabaab78 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogsService.test.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskExactLogsService } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogsService'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; + +const tempDirs: string[] = []; + +async function createTempTranscript(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-summary-')); + tempDirs.push(dir); + const filePath = path.join(dir, 'transcript.jsonl'); + await fs.writeFile(filePath, '{"uuid":"x","type":"user","timestamp":"2026-04-12T16:00:00.000Z","message":{"role":"user","content":"hi"}}\n', 'utf8'); + return filePath; +} + +function makeRecord(filePath: string, id: string, timestamp: string, sourceOrder: number): BoardTaskActivityRecord { + return { + id, + timestamp, + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + source: { + filePath, + messageUuid: id, + sourceOrder, + }, + }; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + vi.unstubAllEnvs(); +}); + +describe('BoardTaskExactLogsService', () => { + it('returns empty when the exact-log read flag is disabled', async () => { + vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false'); + const recordSource = { + getTaskRecords: vi.fn(async () => { + throw new Error('should not be called'); + }), + }; + + const service = new BoardTaskExactLogsService(recordSource as never); + await expect(service.getTaskExactLogSummaries('demo', 'task-a')).resolves.toEqual({ items: [] }); + expect(recordSource.getTaskRecords).not.toHaveBeenCalled(); + }); + + it('returns summaries in deterministic source order for the renderer to present', async () => { + const filePath = await createTempTranscript(); + const recordSource = { + getTaskRecords: vi.fn(async () => [ + makeRecord(filePath, 'msg-older', '2026-04-12T16:00:00.000Z', 1), + makeRecord(filePath, 'msg-newer', '2026-04-12T16:05:00.000Z', 2), + ]), + }; + + const service = new BoardTaskExactLogsService(recordSource as never); + const response = await service.getTaskExactLogSummaries('demo', 'task-a'); + + expect(response.items).toHaveLength(2); + expect(response.items[0]?.timestamp).toBe('2026-04-12T16:00:00.000Z'); + expect(response.items[1]?.timestamp).toBe('2026-04-12T16:05:00.000Z'); + }); +}); diff --git a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts new file mode 100644 index 00000000..9232dd4a --- /dev/null +++ b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts @@ -0,0 +1,311 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource'; +import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; +import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; + +import type { TeamTask } from '../../../../src/shared/types'; + +const TEAM_NAME = 'beacon-desk-2'; +const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: TASK_ID, + displayId: 'c414cd52', + subject: 'Help alice: fast lint/link check', + status: 'completed', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + ...overrides, + }; +} + +function createAssistantEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + agentName?: string; + sessionId?: string; + requestId?: string; +}): Record { + return { + type: 'assistant', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + requestId: args.requestId, + message: { + id: `${args.uuid}-msg`, + role: 'assistant', + model: 'claude-test', + type: 'message', + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + content: args.content, + }, + }; +} + +function createUserEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + boardTaskLinks?: unknown[]; + boardTaskToolActions?: unknown[]; + toolUseResult?: Record; + sourceToolAssistantUUID?: string; + agentName?: string; + sessionId?: string; +}): Record { + return { + type: 'user', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + ...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}), + ...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}), + ...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}), + ...(args.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: args.sourceToolAssistantUUID } + : {}), + message: { + role: 'user', + content: args.content, + }, + }; +} + +describe('BoardTaskLogDiagnosticsService', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it('explains when worker tools exist in transcript but only board MCP actions are explicit', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-diagnostics-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask(); + + const lines = [ + createAssistantEntry({ + uuid: 'a-task-start', + timestamp: '2026-04-12T15:36:00.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-task-start', + timestamp: '2026-04-12T15:36:00.100Z', + sourceToolAssistantUUID: 'a-task-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-grep', + timestamp: '2026-04-12T15:36:14.522Z', + requestId: 'req-grep', + content: [ + { + type: 'tool_use', + id: 'call-grep', + name: 'Grep', + input: { + pattern: 'ITERATION_PLAN', + path: 'docs-site', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-grep', + timestamp: '2026-04-12T15:36:14.749Z', + sourceToolAssistantUUID: 'a-grep', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + ], + toolUseResult: { + toolUseId: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + }), + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-12T15:36:30.000Z', + requestId: 'req-comment', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + text: 'Audit complete', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-12T15:36:30.100Z', + sourceToolAssistantUUID: 'a-comment', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: '{"comment":{"text":"Audit complete"}}', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + resultRefs: { + commentId: 'comment-1', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-comment', + content: '{"comment":{"text":"Audit complete"}}', + }, + }), + ]; + + await writeFile( + transcriptPath, + lines.map((line) => JSON.stringify(line)).join('\n'), + 'utf8', + ); + + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + listTranscriptFiles: async () => [transcriptPath], + }; + const recordSource = new BoardTaskActivityRecordSource( + transcriptSourceLocator as never, + taskReader as never, + new BoardTaskActivityTranscriptReader(), + new BoardTaskActivityRecordBuilder(), + ); + const streamService = new BoardTaskLogStreamService(recordSource); + const diagnosticsService = new BoardTaskLogDiagnosticsService( + taskReader as never, + transcriptSourceLocator as never, + recordSource, + undefined, + streamService, + ); + + const report = await diagnosticsService.diagnose(TEAM_NAME, '#c414cd52'); + + expect(report.explicitRecords.execution).toBe(0); + expect(report.intervalToolResults.worker.total).toBe(1); + expect(report.intervalToolResults.worker.explicitLinked).toBe(0); + expect(report.intervalToolResults.worker.missingExplicit).toBe(1); + expect(report.intervalToolResults.worker.examples).toContainEqual( + expect.objectContaining({ + toolName: 'Grep', + toolUseId: 'call-grep', + }), + ); + expect(report.stream.visibleToolNames).toEqual([ + 'mcp__agent-teams__task_start', + 'mcp__agent-teams__task_add_comment', + ]); + expect(report.diagnosis.join(' ')).toContain('Only board MCP actions are explicit'); + }); +}); diff --git a/test/main/services/team/BoardTaskLogStream.live.test.ts b/test/main/services/team/BoardTaskLogStream.live.test.ts new file mode 100644 index 00000000..3f412717 --- /dev/null +++ b/test/main/services/team/BoardTaskLogStream.live.test.ts @@ -0,0 +1,72 @@ +import * as os from 'os'; +import * as path from 'path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim(); +const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim(); +const LIVE_CLAUDE_BASE = + process.env.LIVE_TASK_LOG_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude'); +const EXPECT_MISSING_WORKER_LINKS = + process.env.LIVE_TASK_LOG_EXPECT_MISSING_WORKER_LINKS === '1'; +const EXPECT_NO_EMPTY_PAYLOADS = + process.env.LIVE_TASK_LOG_EXPECT_NO_EMPTY_PAYLOADS === '1'; +const EXPECT_VISIBLE_TOOLS = (process.env.LIVE_TASK_LOG_EXPECT_VISIBLE_TOOLS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + +const describeLive = + LIVE_TEAM && LIVE_TASK && LIVE_CLAUDE_BASE ? describe : describe.skip; + +describeLive('BoardTaskLogStream live smoke', () => { + beforeAll(() => { + setClaudeBasePathOverride(LIVE_CLAUDE_BASE); + }); + + afterAll(() => { + setClaudeBasePathOverride(null); + }); + + it('diagnoses the current live task-log state', async () => { + const service = new BoardTaskLogDiagnosticsService(); + const streamService = new BoardTaskLogStreamService(); + let report; + try { + report = await service.diagnose(LIVE_TEAM!, LIVE_TASK!); + } catch (error) { + const fallbackTaskRef = + LIVE_TASK!.length > 8 && LIVE_TASK!.includes('-') ? LIVE_TASK!.slice(0, 8) : null; + if (!fallbackTaskRef) { + throw error; + } + report = await service.diagnose(LIVE_TEAM!, fallbackTaskRef); + } + + expect(report.task.taskId).toBeTruthy(); + expect(report.transcript.fileCount).toBeGreaterThan(0); + expect(report.diagnosis.length).toBeGreaterThan(0); + expect(report.stream.segmentCount).toBeGreaterThan(0); + + const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId); + expect(stream.segments.length).toBeGreaterThan(0); + + if (EXPECT_MISSING_WORKER_LINKS) { + expect(report.intervalToolResults.worker.missingExplicit).toBeGreaterThan(0); + } + + if (EXPECT_NO_EMPTY_PAYLOADS) { + expect(report.stream.emptyPayloadExamples).toHaveLength(0); + } + + if (EXPECT_VISIBLE_TOOLS.length > 0) { + for (const toolName of EXPECT_VISIBLE_TOOLS) { + expect(report.stream.visibleToolNames).toContain(toolName); + } + } + }); +}); diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts new file mode 100644 index 00000000..cc05960e --- /dev/null +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -0,0 +1,380 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; + +import type { ParsedMessage } from '../../../../src/main/types'; +import type { TeamTask } from '../../../../src/shared/types'; + +const TEAM_NAME = 'beacon-desk-2'; +const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: TASK_ID, + displayId: 'c414cd52', + subject: 'Help alice: fast lint/link check', + status: 'completed', + ...overrides, + }; +} + +function createAssistantEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + agentName?: string; + sessionId?: string; + requestId?: string; +}): Record { + return { + type: 'assistant', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + requestId: args.requestId, + message: { + id: `${args.uuid}-msg`, + role: 'assistant', + model: 'claude-test', + type: 'message', + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + content: args.content, + }, + }; +} + +function createUserEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + boardTaskLinks?: unknown[]; + boardTaskToolActions?: unknown[]; + toolUseResult?: unknown; + sourceToolAssistantUUID?: string; + agentName?: string; + sessionId?: string; +}): Record { + return { + type: 'user', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + ...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}), + ...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}), + ...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}), + ...(args.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: args.sourceToolAssistantUUID } + : {}), + message: { + role: 'user', + content: args.content, + }, + }; +} + +async function buildRecordsFromTranscript(filePath: string, task: TeamTask) { + const transcriptReader = new BoardTaskActivityTranscriptReader(); + const recordBuilder = new BoardTaskActivityRecordBuilder(); + const messages = await transcriptReader.readFiles([filePath]); + + return recordBuilder.buildForTask({ + teamName: TEAM_NAME, + targetTask: task, + tasks: [task], + messages, + }); +} + +function flattenRawMessages(response: Awaited>): ParsedMessage[] { + return response.segments.flatMap((segment) => + segment.chunks.flatMap((chunk) => chunk.rawMessages), + ); +} + +describe('BoardTaskLogStreamService integration', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it('includes worker tool logs when transcript rows carry execution links with toolUseId', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-integration-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask(); + + const lines = [ + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:07.747Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-grep', + timestamp: '2026-04-12T15:36:14.522Z', + requestId: 'req-grep', + content: [ + { + type: 'tool_use', + id: 'call-grep', + name: 'Grep', + input: { + pattern: 'ITERATION_PLAN', + path: 'docs-site', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-grep', + timestamp: '2026-04-12T15:36:14.749Z', + sourceToolAssistantUUID: 'a-grep', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-grep', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + }), + createAssistantEntry({ + uuid: 'a-edit', + timestamp: '2026-04-12T15:36:40.000Z', + requestId: 'req-edit', + content: [ + { + type: 'tool_use', + id: 'call-edit', + name: 'Edit', + input: { + file_path: 'docs-site/guide.md', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-edit', + timestamp: '2026-04-12T15:36:40.200Z', + sourceToolAssistantUUID: 'a-edit', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-edit', + content: 'File updated', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-edit', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-edit', + content: 'File updated', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + + const service = new BoardTaskLogStreamService(recordSource as never); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const toolNames = rawMessages.flatMap((message) => + message.toolCalls.map((toolCall) => toolCall.name), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(response.segments).toHaveLength(1); + expect(toolNames).toContain('Grep'); + expect(toolNames).toContain('Edit'); + }); + + it('does not leak empty array board-tool payloads into the task log stream', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-board-tool-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask(); + + const lines = [ + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-12T18:35:02.000Z', + requestId: 'req-comment', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + taskId: TASK_ID, + text: 'Done', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-12T18:35:02.064Z', + sourceToolAssistantUUID: 'a-comment', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "task": {\n "id": "c414cd52-470a-4b51-ae1e-e5250fff95d7"\n }\n}', + }, + ], + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + resultRefs: { + commentId: 'comment-1', + }, + }, + ], + toolUseResult: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "task": {\n "id": "c414cd52-470a-4b51-ae1e-e5250fff95d7"\n }\n}', + }, + ], + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + + const service = new BoardTaskLogStreamService(recordSource as never); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const commentResult = rawMessages.find((message) => message.uuid === 'u-comment'); + + expect(response.segments).toHaveLength(1); + expect(commentResult).toBeUndefined(); + }); +}); diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts new file mode 100644 index 00000000..41410e0d --- /dev/null +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -0,0 +1,639 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; + +import type { ParsedMessage } from '../../../../src/main/types'; +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { BoardTaskExactLogBundleCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; + +function makeRecord( + id: string, + timestamp: string, + actor: BoardTaskActivityRecord['actor'], + toolUseId?: string, +): BoardTaskActivityRecord { + return { + id, + timestamp, + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor, + actorContext: { relation: 'same_task' }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + ...(toolUseId ? { toolUseId } : {}), + sourceOrder: 1, + }, + }; +} + +function makeCandidate( + id: string, + timestamp: string, + actor: BoardTaskActivityRecord['actor'], + toolUseId?: string, +): BoardTaskExactLogBundleCandidate { + const record = makeRecord(id, timestamp, actor, toolUseId); + return { + id, + timestamp, + actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + ...(toolUseId ? { toolUseId } : {}), + sourceOrder: 1, + }, + records: [record], + anchor: toolUseId + ? { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + toolUseId, + } + : { + kind: 'message', + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + }, + actionLabel: 'Worked on task', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }; +} + +function makeMessage(uuid: string, timestamp: string, text: string): ParsedMessage { + return { + uuid, + parentUuid: null, + type: 'assistant', + timestamp: new Date(timestamp), + role: 'assistant', + content: [{ type: 'text', text } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }; +} + +describe('BoardTaskLogStreamService', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns empty when the stream read flag is disabled', async () => { + vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false'); + const recordSource = { + getTaskRecords: vi.fn(async () => { + throw new Error('should not be called'); + }), + }; + + const service = new BoardTaskLogStreamService(recordSource as never); + await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + expect(recordSource.getTaskRecords).not.toHaveBeenCalled(); + }); + + it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', 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 lead = { + role: 'lead' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + 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', lead), + makeCandidate('c5', '2026-04-12T16:04: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, + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(response.defaultFilter).toBe('all'); + expect(response.participants.map((participant) => participant.key)).toEqual([ + 'member:tom', + 'member:alice', + ]); + expect(response.segments.map((segment) => segment.participantKey)).toEqual([ + 'member:tom', + 'member:alice', + 'member:tom', + ]); + expect(buildBundleChunks).toHaveBeenCalledTimes(3); + expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2); + }); + + it('merges duplicate message uuids inside one participant segment before chunk building', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const candidates = [ + makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + makeCandidate('c2', '2026-04-12T16:00:10.000Z', tom, 'tool-2'), + ]; + + const sharedMessage = { + uuid: 'assistant-shared', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }; + + 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() + .mockImplementationOnce(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 1 }, + records: candidates[0]!.records, + filteredMessages: [ + { + ...sharedMessage, + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], + }, + ], + })) + .mockImplementationOnce(() => ({ + id: 'c2', + timestamp: '2026-04-12T16:00:10.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 2 }, + records: candidates[1]!.records, + filteredMessages: [ + { + ...sharedMessage, + content: [{ type: 'text', text: 'task looked up' } as never], + }, + ], + })), + }; + 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 service.getTaskLogStream('demo', 'task-a'); + + expect(buildBundleChunks).toHaveBeenCalledTimes(1); + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + expect(mergedMessages).toHaveLength(1); + expect(mergedMessages[0]?.toolCalls).toHaveLength(1); + expect(Array.isArray(mergedMessages[0]?.content)).toBe(true); + expect(mergedMessages[0]?.content).toHaveLength(2); + }); + + it('drops tool-anchored assistant output-only messages to avoid noisy raw result blocks', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'); + + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, + records: candidate.records, + filteredMessages: [ + { + uuid: 'assistant-tool', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-output', + parentUuid: 'assistant-tool', + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'assistant', + content: [{ type: 'text', text: '[{\"type\":\"text\",\"text\":\"{\\n \\\"id\\\": \\\"task-a\\\"\\n}\"}]' } as never], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-result', + parentUuid: 'assistant-tool', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + toolUseResult: { toolUseId: 'tool-1', content: 'ok' }, + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + })), + }; + 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 service.getTaskLogStream('demo', 'task-a'); + + expect(buildBundleChunks).toHaveBeenCalledTimes(1); + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + expect(mergedMessages.map((message) => message.uuid)).toEqual(['assistant-tool', 'user-result']); + }); + + it('defaults to the single named participant and excludes unnamed lead noise when named task logs exist', async () => { + const tom = { + memberName: 'tom', + role: 'lead' as const, + sessionId: 'session-tom', + isSidechain: false, + }; + const unknownLead = { + role: 'unknown' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + const candidates = [ + makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + makeCandidate('c2', '2026-04-12T16:01:00.000Z', unknownLead, 'tool-2'), + ]; + + 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, + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(response.participants.map((participant) => participant.key)).toEqual(['member:tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(response.segments.map((segment) => segment.participantKey)).toEqual(['member:tom']); + }); + + it('sanitizes json-like tool_result payload text while preserving the tool result message', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'); + + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, + records: candidate.records, + filteredMessages: [ + { + uuid: 'assistant-tool', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-result', + parentUuid: 'assistant-tool', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: [{ type: 'text', text: '{\n \"id\": \"task-a\"\n}' } as never], + } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + toolUseResult: { toolUseId: 'tool-1', content: '{\n \"id\": \"task-a\"\n}' }, + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + })), + }; + 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 service.getTaskLogStream('demo', 'task-a'); + + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result'); + expect(toolResultMessage).toBeDefined(); + const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : []; + expect(content[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'tool-1', + content: '', + }); + expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: '' }); + }); + + it('drops read-only slices when the same participant has more meaningful task logs', async () => { + const tom = { + memberName: 'tom', + role: 'lead' as const, + sessionId: 'session-tom', + isSidechain: false, + }; + const readCandidate = { ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), actionCategory: 'read' as const, canonicalToolName: 'task_get' }; + const commentCandidate = { ...makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), actionCategory: 'comment' as const, canonicalToolName: 'task_add_comment' }; + + const recordSource = { + getTaskRecords: vi.fn(async () => [...readCandidate.records, ...commentCandidate.records]), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [readCandidate, commentCandidate]), + }; + 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, + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(response.segments).toHaveLength(1); + expect(buildBundleChunks).toHaveBeenCalledTimes(1); + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + expect(mergedMessages.map((message) => message.uuid)).toEqual(['c2']); + }); + + it('extracts task_add_comment text from json-like tool result payload', async () => { + const tom = { + memberName: 'tom', + role: 'lead' as const, + sessionId: 'session-tom', + isSidechain: false, + }; + const candidate = { + ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + actionCategory: 'comment' as const, + canonicalToolName: 'task_add_comment', + }; + + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, + records: candidate.records, + filteredMessages: [ + { + uuid: 'assistant-tool', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: {} } as never], + toolCalls: [], + toolResults: [], + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-result', + parentUuid: 'assistant-tool', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: [{ type: 'text', text: '{\"comment\":{\"text\":\"useful comment\"}}' } as never], + } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + toolUseResult: { toolUseId: 'tool-1', content: '{"comment":{"text":"useful comment"}}' }, + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + ], + })), + }; + 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 service.getTaskLogStream('demo', 'task-a'); + + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result'); + const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : []; + expect(content[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'useful comment', + }); + expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' }); + }); +}); diff --git a/test/main/services/team/BoardTaskTranscriptContract.test.ts b/test/main/services/team/BoardTaskTranscriptContract.test.ts new file mode 100644 index 00000000..e30afd10 --- /dev/null +++ b/test/main/services/team/BoardTaskTranscriptContract.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import fixture from '../../../fixtures/team/board-task-activity-message-v1.json'; + +import { + parseBoardTaskLinks, + parseBoardTaskToolActions, +} from '../../../../src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract'; + +describe('BoardTaskTranscriptContract', () => { + it('salvages valid board-task links from mixed payloads', () => { + const parsed = parseBoardTaskLinks([ + null, + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + targetRole: 'subject', + linkKind: 'lifecycle', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + task: { ref: '', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'lifecycle', + actorContext: { relation: 'idle' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + targetRole: 'subject', + linkKind: 'lifecycle', + actorContext: { relation: 'idle' }, + }, + ]); + }); + + it('salvages valid task tool actions from mixed payloads', () => { + const parsed = parseBoardTaskToolActions([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + { + schemaVersion: 1, + canonicalToolName: 'task_add_comment', + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + ]); + }); + + it('parses the documented fixture example', () => { + expect(parseBoardTaskLinks(fixture.boardTaskLinks)).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { + ref: 'abcd1234', + refKind: 'display', + canonicalId: '123e4567-e89b-12d3-a456-426614174000', + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { relation: 'idle' }, + }, + ]); + + expect(parseBoardTaskToolActions(fixture.boardTaskToolActions)).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + ]); + }); + + it('preserves semantic null owner and clarification values', () => { + const parsed = parseBoardTaskToolActions([ + { + schemaVersion: 1, + toolUseId: 'tool-2', + canonicalToolName: 'task_set_owner', + input: { owner: null }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-3', + canonicalToolName: 'task_set_clarification', + input: { clarification: 'clear' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-2', + canonicalToolName: 'task_set_owner', + input: { owner: null }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-3', + canonicalToolName: 'task_set_clarification', + input: { clarification: null }, + }, + ]); + }); + + it('accepts legacy version fields while preferring schemaVersion going forward', () => { + const parsed = parseBoardTaskLinks([ + { + version: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + }); + + it('sanitizes impossible actor scope details unless relation is other_active_task', () => { + const parsed = parseBoardTaskLinks([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + activeTask: { ref: 'efgh5678', refKind: 'display' }, + activePhase: 'work', + activeExecutionSeq: 2, + }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + }); + + it('preserves execution toolUseId while still dropping execution taskArgumentSlot', () => { + const parsed = parseBoardTaskLinks([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + taskArgumentSlot: 'taskId', + actorContext: { relation: 'same_task' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + }); +}); diff --git a/test/main/services/team/TaskBoundaryParser.test.ts b/test/main/services/team/TaskBoundaryParser.test.ts index e1f592bb..e60b7cb1 100644 --- a/test/main/services/team/TaskBoundaryParser.test.ts +++ b/test/main/services/team/TaskBoundaryParser.test.ts @@ -64,6 +64,53 @@ describe('TaskBoundaryParser', () => { expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true); }); + it('detects fully-qualified agent-teams MCP task boundaries', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); + const jsonlPath = path.join(tmpDir, 'mcp-qualified.jsonl'); + await fs.writeFile( + jsonlPath, + [ + JSON.stringify({ + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'mcp__agent-teams__task_start', + input: { taskId: 'task-123', teamName: 'demo' }, + }, + ], + }, + }), + JSON.stringify({ + timestamp: '2026-03-01T10:10:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-2', + name: 'mcp__agent_teams__task_complete', + input: { taskId: 'task-123', teamName: 'demo' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); + + expect(result.detectedMechanism).toBe('mcp'); + expect(result.boundaries).toHaveLength(2); + expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']); + }); + it('ignores legacy teamctl bash markers and keeps modern MCP markers only', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); const jsonlPath = path.join(tmpDir, 'mixed.jsonl'); diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 382b4198..9c7d559e 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -867,6 +867,34 @@ describe('TeamMemberLogsFinder', () => { await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false); }); + it('detects fully-qualified agent-teams task markers in JSONL', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-markers-')); + const qualifiedPath = path.join(tmpDir, 'qualified.jsonl'); + + await fs.writeFile( + qualifiedPath, + JSON.stringify({ + timestamp: '2026-01-01T00:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'mcp__agent-teams__task_start', + input: { teamName: 'demo', taskId: 'task-42' }, + }, + ], + }, + }) + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + + await expect(finder.hasTaskUpdateMarker(qualifiedPath, 'task-42')).resolves.toBe(true); + }); + it('findLogFileRefsForTask returns correct refs for a task', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/renderer/api/httpClient.exactTaskLogs.test.ts b/test/renderer/api/httpClient.exactTaskLogs.test.ts new file mode 100644 index 00000000..062aa1f5 --- /dev/null +++ b/test/renderer/api/httpClient.exactTaskLogs.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HttpAPIClient } from '../../../src/renderer/api/httpClient'; + +class MockEventSource { + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + addEventListener(): void {} + close(): void {} +} + +describe('HttpAPIClient exact task logs browser fallback', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns safe fallback shapes for exact task logs in browser mode', async () => { + vi.stubGlobal('EventSource', MockEventSource); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = new HttpAPIClient('http://localhost:9999'); + + await expect(client.teams.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + await expect(client.teams.getTaskExactLogSummaries('demo', 'task-a')).resolves.toEqual({ + items: [], + }); + await expect( + client.teams.getTaskExactLogDetail('demo', 'task-a', 'bundle-1', 'gen-1') + ).resolves.toEqual({ status: 'missing' }); + + expect(warnSpy).toHaveBeenCalled(); + }); +}); diff --git a/test/renderer/components/team/taskLogs/ExactTaskLogsSection.test.ts b/test/renderer/components/team/taskLogs/ExactTaskLogsSection.test.ts new file mode 100644 index 00000000..d55aba99 --- /dev/null +++ b/test/renderer/components/team/taskLogs/ExactTaskLogsSection.test.ts @@ -0,0 +1,288 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, +} from '../../../../../src/shared/types'; + +const apiState = { + getTaskExactLogSummaries: vi.fn< + (teamName: string, taskId: string) => Promise + >(), + getTaskExactLogDetail: vi.fn< + ( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ) => Promise + >(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskExactLogSummaries: (...args: Parameters) => + apiState.getTaskExactLogSummaries(...args), + getTaskExactLogDetail: (...args: Parameters) => + apiState.getTaskExactLogDetail(...args), + }, + }, +})); + +vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({ + MemberExecutionLog: ({ memberName }: { memberName?: string }) => + React.createElement('div', { 'data-testid': 'member-execution-log' }, memberName ?? 'no-name'), +})); + +import { ExactTaskLogsSection } from '@renderer/components/team/taskLogs/ExactTaskLogsSection'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describe('ExactTaskLogsSection', () => { + afterEach(() => { + document.body.innerHTML = ''; + apiState.getTaskExactLogSummaries.mockReset(); + apiState.getTaskExactLogDetail.mockReset(); + vi.unstubAllGlobals(); + }); + + it('renders empty state when exact summaries are absent', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries.mockResolvedValueOnce({ items: [] }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Exact Task Logs'); + expect(host.textContent).toContain('No exact task logs yet'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('renders loading state while summaries are still pending', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + let resolveSummaries: ((value: BoardTaskExactLogSummariesResponse) => void) | null = null; + apiState.getTaskExactLogSummaries.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSummaries = resolve; + }) + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Loading exact task logs'); + + await act(async () => { + resolveSummaries?.({ items: [] }); + await flushMicrotasks(); + }); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('renders error state when summaries fail to load', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries.mockRejectedValueOnce(new Error('boom')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('boom'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('reloads summaries on stale detail and then renders exact detail', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries + .mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T18:00:00.000Z', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }, + ], + }) + .mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T18:00:00.000Z', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + canLoadDetail: true, + sourceGeneration: 'gen-2', + }, + ], + }); + apiState.getTaskExactLogDetail + .mockResolvedValueOnce({ status: 'stale' }) + .mockResolvedValueOnce({ + status: 'ok', + detail: { + id: 'tool:/tmp/task.jsonl:tool-1', + chunks: [], + }, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + const button = host.querySelector('button'); + expect(button).not.toBeNull(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + await vi.waitFor(() => { + expect(apiState.getTaskExactLogSummaries).toHaveBeenCalledTimes(2); + expect(apiState.getTaskExactLogDetail).toHaveBeenNthCalledWith( + 1, + 'demo', + 'task-a', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-1' + ); + expect(apiState.getTaskExactLogDetail).toHaveBeenNthCalledWith( + 2, + 'demo', + 'task-a', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-2' + ); + expect(host.querySelector('[data-testid=\"member-execution-log\"]')?.textContent).toBe('alice'); + }); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('renders descriptive action labels and lead-session fallback actor text', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries.mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T18:00:00.000Z', + actor: { + role: 'lead', + sessionId: 'lead-session-1', + isSidechain: false, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Requested review', + actionCategory: 'review', + canonicalToolName: 'review_request', + linkKinds: ['board_action'], + canLoadDetail: false, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('lead session'); + expect(host.textContent).toContain('Requested review'); + expect(host.textContent).toContain('tool'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts new file mode 100644 index 00000000..00ad81c9 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -0,0 +1,550 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { BoardTaskActivityRecordBuilder } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityTranscriptReader } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; +import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip'; + +import type { TeamTask } from '../../../../../src/shared/types'; + +const TEAM_NAME = 'beacon-desk-2'; +const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; + +const apiState = { + getTaskLogStream: vi.fn(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskLogStream: (...args: Parameters) => + apiState.getTaskLogStream(...args), + }, + }, +})); + +import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: TASK_ID, + displayId: 'c414cd52', + subject: 'Help alice: fast lint/link check', + status: 'completed', + ...overrides, + }; +} + +function createAssistantEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + agentName?: string; + sessionId?: string; + requestId?: string; +}): Record { + return { + type: 'assistant', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + requestId: args.requestId, + message: { + id: `${args.uuid}-msg`, + role: 'assistant', + model: 'claude-test', + type: 'message', + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + content: args.content, + }, + }; +} + +function createUserEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + boardTaskLinks?: unknown[]; + boardTaskToolActions?: unknown[]; + toolUseResult?: unknown; + sourceToolAssistantUUID?: string; + agentName?: string; + sessionId?: string; +}): Record { + return { + type: 'user', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + ...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}), + ...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}), + ...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}), + ...(args.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: args.sourceToolAssistantUUID } + : {}), + message: { + role: 'user', + content: args.content, + }, + }; +} + +async function buildStreamResponse(transcriptPath: string) { + const task = createTask(); + const transcriptReader = new BoardTaskActivityTranscriptReader(); + const recordBuilder = new BoardTaskActivityRecordBuilder(); + const messages = await transcriptReader.readFiles([transcriptPath]); + const recordSource = { + getTaskRecords: async () => + recordBuilder.buildForTask({ + teamName: TEAM_NAME, + targetTask: task, + tasks: [task], + messages, + }), + }; + + const service = new BoardTaskLogStreamService(recordSource as never); + return service.getTaskLogStream(TEAM_NAME, task.id); +} + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describe('TaskLogStreamSection integration', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + document.body.innerHTML = ''; + apiState.getTaskLogStream.mockReset(); + vi.unstubAllGlobals(); + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it('renders worker tools and does not show empty array output blocks', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-render-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + + const lines = [ + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:07.747Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-grep', + timestamp: '2026-04-12T15:36:14.522Z', + requestId: 'req-grep', + content: [ + { + type: 'tool_use', + id: 'call-grep', + name: 'Grep', + input: { + pattern: 'ITERATION_PLAN', + path: 'docs-site', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-grep', + timestamp: '2026-04-12T15:36:14.749Z', + sourceToolAssistantUUID: 'a-grep', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-grep', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + }), + createAssistantEntry({ + uuid: 'a-edit', + timestamp: '2026-04-12T15:36:40.000Z', + requestId: 'req-edit', + content: [ + { + type: 'tool_use', + id: 'call-edit', + name: 'Edit', + input: { + file_path: 'docs-site/guide.md', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-edit', + timestamp: '2026-04-12T15:36:40.200Z', + sourceToolAssistantUUID: 'a-edit', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-edit', + content: 'File updated', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-edit', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-edit', + content: 'File updated', + }, + }), + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-12T15:47:44.500Z', + requestId: 'req-comment', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + taskId: TASK_ID, + text: 'Audit complete', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-12T15:47:44.773Z', + sourceToolAssistantUUID: 'a-comment', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "comment": {\n "text": "Audit complete"\n }\n}', + }, + ], + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + resultRefs: { + commentId: 'comment-1', + }, + }, + ], + toolUseResult: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "comment": {\n "text": "Audit complete"\n }\n}', + }, + ], + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + apiState.getTaskLogStream.mockResolvedValueOnce(await buildStreamResponse(transcriptPath)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }), + ), + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + const text = host.textContent ?? ''; + expect(text).toContain('Task Log Stream'); + expect(text).toContain('Grep'); + expect(text).toContain('Edit'); + expect(text).toContain('Claude'); + expect(text).toContain('2 tool calls'); + expect(text).toContain('Audit complete'); + expect(text).not.toContain('[]'); + expect(text).not.toContain('lead session'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('does not render empty board lifecycle payload blocks for task_start/task_complete', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-board-lifecycle-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + + const lines = [ + createAssistantEntry({ + uuid: 'a-start', + timestamp: '2026-04-12T18:25:04.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T18:25:04.039Z', + sourceToolAssistantUUID: 'a-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-start', + content: '', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-start', + content: '', + }, + }), + createAssistantEntry({ + uuid: 'a-complete', + timestamp: '2026-04-12T18:27:04.000Z', + requestId: 'req-complete', + content: [ + { + type: 'tool_use', + id: 'call-complete', + name: 'mcp__agent-teams__task_complete', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-complete', + timestamp: '2026-04-12T18:27:04.039Z', + sourceToolAssistantUUID: 'a-complete', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-complete', + content: '', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + canonicalToolName: 'task_complete', + }, + ], + toolUseResult: { + toolUseId: 'call-complete', + content: '', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + apiState.getTaskLogStream.mockResolvedValueOnce(await buildStreamResponse(transcriptPath)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }), + ), + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + const text = host.textContent ?? ''; + expect(text).toContain('Task Log Stream'); + expect(text).toContain('mcp__agent-teams__task_start'); + expect(text).toContain('mcp__agent-teams__task_complete'); + expect(text).not.toContain('[]'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts new file mode 100644 index 00000000..1ba108f6 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts @@ -0,0 +1,107 @@ +import * as os from 'os'; +import * as path from 'path'; + +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskLogDiagnosticsService } from '../../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; +import { BoardTaskLogStreamService } from '../../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip'; +import { setClaudeBasePathOverride } from '../../../../../src/main/utils/pathDecoder'; + +const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim(); +const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim(); +const LIVE_CLAUDE_BASE = + process.env.LIVE_TASK_LOG_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude'); +const EXPECT_NO_EMPTY_PAYLOADS = + process.env.LIVE_TASK_LOG_EXPECT_NO_EMPTY_PAYLOADS === '1'; +const EXPECT_VISIBLE_TOOLS = (process.env.LIVE_TASK_LOG_EXPECT_VISIBLE_TOOLS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + +const describeLive = + LIVE_TEAM && LIVE_TASK && LIVE_CLAUDE_BASE ? describe : describe.skip; + +const apiState = { + getTaskLogStream: vi.fn(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskLogStream: (...args: Parameters) => + apiState.getTaskLogStream(...args), + }, + }, +})); + +import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describeLive('TaskLogStreamSection live smoke', () => { + beforeAll(() => { + setClaudeBasePathOverride(LIVE_CLAUDE_BASE); + }); + + afterAll(() => { + setClaudeBasePathOverride(null); + }); + + afterEach(() => { + document.body.innerHTML = ''; + apiState.getTaskLogStream.mockReset(); + vi.unstubAllGlobals(); + }); + + it('renders the current live task log stream without empty payload placeholders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const diagnosticsService = new BoardTaskLogDiagnosticsService(); + const streamService = new BoardTaskLogStreamService(); + const report = await diagnosticsService.diagnose(LIVE_TEAM!, LIVE_TASK!); + const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId); + + apiState.getTaskLogStream.mockResolvedValueOnce(stream); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { + teamName: LIVE_TEAM!, + taskId: report.task.taskId, + }), + ), + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Task Log Stream'); + expect(host.textContent).not.toContain('Loading task log stream'); + expect(host.textContent).not.toContain('[]'); + + if (EXPECT_NO_EMPTY_PAYLOADS) { + expect(report.stream.emptyPayloadExamples).toHaveLength(0); + } + + for (const toolName of EXPECT_VISIBLE_TOOLS) { + expect(host.textContent).toContain(toolName); + } + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts new file mode 100644 index 00000000..4f34bdc0 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts @@ -0,0 +1,223 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types'; + +const apiState = { + getTaskLogStream: vi.fn< + (teamName: string, taskId: string) => Promise + >(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskLogStream: (...args: Parameters) => + apiState.getTaskLogStream(...args), + }, + }, +})); + +vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({ + MemberExecutionLog: ({ + memberName, + chunks, + }: { + memberName?: string; + chunks: { id: string }[]; + }) => + React.createElement( + 'div', + { 'data-testid': 'member-execution-log' }, + `${memberName ?? 'lead'}:${chunks.length}` + ), +})); + +import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describe('TaskLogStreamSection', () => { + afterEach(() => { + document.body.innerHTML = ''; + apiState.getTaskLogStream.mockReset(); + vi.unstubAllGlobals(); + }); + + it('renders empty state when the stream is absent', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskLogStream.mockResolvedValueOnce({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Task Log Stream'); + expect(host.textContent).toContain('No task log stream yet'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('shows participant chips and filters the visible segments', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskLogStream.mockResolvedValueOnce({ + participants: [ + { + key: 'member:tom', + label: 'tom', + role: 'member', + isLead: false, + isSidechain: true, + }, + { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: true, + }, + ], + defaultFilter: 'all', + segments: [ + { + id: 'segment-tom-1', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'member', + sessionId: 'session-tom-1', + agentId: 'agent-tom', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + chunks: [{ id: 'chunk-tom-1', chunkType: 'user', rawMessages: [] }] as never, + }, + { + id: 'segment-alice-1', + participantKey: 'member:alice', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-alice-1', + agentId: 'agent-alice', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:02:00.000Z', + endTimestamp: '2026-04-12T16:03:00.000Z', + chunks: [{ id: 'chunk-alice-1', chunkType: 'user', rawMessages: [] }] as never, + }, + { + id: 'segment-tom-2', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'member', + sessionId: 'session-tom-2', + agentId: 'agent-tom', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:04:00.000Z', + endTimestamp: '2026-04-12T16:05:00.000Z', + chunks: [{ id: 'chunk-tom-2', chunkType: 'user', rawMessages: [] }] as never, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('All'); + expect(host.textContent).toContain('tom'); + expect(host.textContent).toContain('alice'); + expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(3); + + const buttons = [...host.querySelectorAll('button')]; + const tomButton = buttons.find((button) => button.textContent?.trim() === 'tom'); + expect(tomButton).toBeDefined(); + + await act(async () => { + tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushMicrotasks(); + }); + + const logs = [...host.querySelectorAll('[data-testid="member-execution-log"]')].map( + (node) => node.textContent + ); + expect(logs).toEqual(['tom:1', 'tom:1']); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('honors a participant default filter from the stream response', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskLogStream.mockResolvedValueOnce({ + participants: [ + { + key: 'member:tom', + label: 'tom', + role: 'member', + isLead: false, + isSidechain: false, + }, + ], + defaultFilter: 'member:tom', + segments: [ + { + id: 'segment-tom-1', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'lead', + sessionId: 'session-tom-1', + isSidechain: false, + }, + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + chunks: [{ id: 'chunk-tom-1', chunkType: 'ai', rawMessages: [] }] as never, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(1); + expect(host.textContent).toContain('tom:1'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); From 9ca80556956be687847edaf055216b2d7444b96e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 22:15:57 +0300 Subject: [PATCH 05/21] chore(workspace): checkpoint remaining claude team changes --- agent-teams-controller/src/internal/review.js | 18 +- .../src/internal/runtimeHelpers.js | 19 ++ .../test/controller.test.js | 60 ++++ docs/iterations/iteration-03-kanban-board.md | 6 +- .../iteration-04-messaging-review.md | 6 +- .../iterations/iteration-05-testing-polish.md | 6 +- docs/team-management/README.md | 18 +- docs/team-management/implementation.md | 4 + docs/team-management/kanban-design.md | 53 +++- .../src/canvas/draw-handoff-cards.ts | 268 ++++++++++++++++++ .../src/constants/canvas-constants.ts | 18 ++ packages/agent-graph/src/ports/types.ts | 2 + packages/agent-graph/src/ui/GraphCanvas.tsx | 39 +++ packages/agent-graph/src/ui/GraphView.tsx | 1 + .../agent-graph/src/ui/transientHandoffs.ts | 163 +++++++++++ src/main/index.ts | 18 +- src/main/ipc/cliInstaller.ts | 5 +- src/main/ipc/extensions.ts | 8 +- src/main/ipc/tmux.ts | 2 +- .../services/infrastructure/UpdaterService.ts | 5 +- .../infrastructure/updaterReleaseMetadata.ts | 2 +- .../services/runtime/geminiRuntimeAuth.ts | 8 +- .../services/runtime/providerRuntimeEnv.ts | 4 +- src/main/services/team/BranchStatusService.ts | 2 +- .../services/team/TeamBootstrapStateReader.ts | 31 +- .../services/team/TeamDataWorkerClient.ts | 6 +- .../services/team/TeamLaunchStateStore.ts | 2 +- .../team/TeamMemberRuntimeAdvisoryService.ts | 12 +- .../services/team/TeamMembersMetaStore.ts | 2 +- .../idleNotificationMainProcessSemantics.ts | 4 +- .../services/team/inboxMessageIdentity.ts | 4 +- .../team/memberUpdateNotifications.ts | 20 +- src/main/services/team/runtimeTeammateMode.ts | 7 +- .../services/team/taskChangePresenceUtils.ts | 2 +- src/main/standalone.ts | 2 +- src/main/workers/team-data-worker.ts | 2 +- src/main/workers/team-fs-worker.ts | 2 +- .../components/chat/UserChatGroup.tsx | 1 + .../components/chat/items/SubagentItem.tsx | 2 +- .../chat/items/TeammateMessageItem.tsx | 2 +- .../items/linkedTool/DefaultToolViewer.tsx | 11 +- .../components/chat/markdownComponents.tsx | 2 +- .../chat/viewers/MarkdownViewer.tsx | 1 + .../components/dashboard/TmuxStatusBanner.tsx | 4 +- .../extensions/apikeys/ApiKeysPanel.tsx | 2 +- .../extensions/common/InstallButton.tsx | 2 +- .../extensions/mcp/McpServersPanel.tsx | 2 +- .../extensions/plugins/PluginDetailDialog.tsx | 2 +- .../extensions/plugins/PluginsPanel.tsx | 2 +- .../extensions/skills/SkillDetailDialog.tsx | 2 +- .../extensions/skills/SkillsPanel.tsx | 2 +- .../components/report/SessionReportTab.tsx | 2 +- .../ProviderRuntimeBackendSelector.tsx | 13 +- .../components/schedules/SchedulesView.tsx | 2 +- .../components/sidebar/SidebarTaskItem.tsx | 2 +- .../components/team/ProcessesSection.tsx | 1 + .../team/ProvisioningProgressBlock.tsx | 2 +- src/renderer/components/team/TaskTooltip.tsx | 2 +- .../components/team/TeamDetailView.tsx | 4 +- .../components/team/TeamEmptyState.tsx | 4 +- .../components/team/ToolApprovalSheet.tsx | 2 +- .../components/team/activity/ActivityItem.tsx | 4 +- .../team/activity/PendingRepliesBlock.tsx | 2 +- .../team/dialogs/CreateTeamDialog.tsx | 21 +- .../team/dialogs/LaunchTeamDialog.tsx | 37 +-- .../ProvisioningProviderStatusList.tsx | 26 +- .../dialogs/ToolApprovalSettingsPanel.tsx | 2 +- .../team/members/MemberDetailDialog.tsx | 1 + .../team/members/MemberHoverCard.tsx | 3 +- .../components/team/members/MemberList.tsx | 11 +- .../components/team/members/MemberLogsTab.tsx | 5 +- .../team/members/TeamRosterEditorSection.tsx | 2 +- .../team/members/membersEditorUtils.ts | 6 +- .../team/messages/MessageComposer.tsx | 2 +- .../team/messages/MessagesFilterPopover.tsx | 2 +- .../team/messages/MessagesPanel.tsx | 4 +- .../components/team/provisioningSteps.ts | 4 +- .../team/schedule/ScheduleRunLogDialog.tsx | 2 +- .../team/schedule/ScheduleSection.tsx | 2 +- .../team/sidebar/TeamSidebarRail.tsx | 1 + src/renderer/constants/teamColors.ts | 11 +- .../agent-graph/adapters/TeamGraphAdapter.ts | 31 +- src/renderer/hooks/useResizablePanel.ts | 8 +- src/renderer/hooks/useTaskSuggestions.ts | 2 +- src/renderer/store/slices/teamSlice.ts | 11 +- src/renderer/utils/geminiUiFreeze.ts | 2 +- .../utils/idleNotificationSemantics.ts | 6 +- src/renderer/utils/teamModelAvailability.ts | 4 +- src/renderer/utils/teamRuntimeSummary.ts | 3 +- src/shared/utils/idleNotificationSemantics.ts | 8 +- src/shared/utils/inboxNoise.ts | 2 +- src/shared/utils/taskChangeSince.ts | 8 +- .../team/board-task-activity-message-v1.json | 48 ++++ .../agent-graph/transientHandoffs.test.ts | 185 ++++++++++++ test/renderer/store/teamSlice.test.ts | 30 ++ 95 files changed, 1144 insertions(+), 254 deletions(-) create mode 100644 packages/agent-graph/src/canvas/draw-handoff-cards.ts create mode 100644 packages/agent-graph/src/ui/transientHandoffs.ts create mode 100644 test/fixtures/team/board-task-activity-message-v1.json create mode 100644 test/renderer/features/agent-graph/transientHandoffs.test.ts diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index 6349cc48..cfc3e257 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -1,8 +1,6 @@ -const fs = require('fs'); -const path = require('path'); - const kanban = require('./kanban.js'); const messages = require('./messages.js'); +const runtimeHelpers = require('./runtimeHelpers.js'); const tasks = require('./tasks.js'); const { wrapAgentBlock } = require('./agentBlocks.js'); @@ -17,19 +15,7 @@ function getReviewer(context, flags) { } function resolveLeadSessionId(context, flags) { - if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) { - return flags.leadSessionId.trim(); - } - - try { - const configPath = path.join(context.paths.teamDir, 'config.json'); - const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')); - return typeof parsed.leadSessionId === 'string' && parsed.leadSessionId.trim() - ? parsed.leadSessionId.trim() - : undefined; - } catch { - return undefined; - } + return runtimeHelpers.resolveCanonicalLeadSessionId(context.paths, flags.leadSessionId); } function getCurrentReviewState(task) { diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index e6869831..f8fadc91 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -299,6 +299,24 @@ function resolveLeadSessionId(paths) { : undefined; } +function resolveCanonicalLeadSessionId(paths, candidate) { + const configured = resolveLeadSessionId(paths); + const explicit = typeof candidate === 'string' ? candidate.trim() : ''; + + if (!explicit) { + return configured; + } + + // The team config is the canonical source of the current lead runtime session. + // If a caller passes a placeholder like "team-lead" or any other mismatched value, + // prefer the configured session id instead of persisting dirty metadata into inbox rows. + if (configured) { + return explicit === configured ? explicit : configured; + } + + return explicit; +} + function isProcessAlive(pid) { try { process.kill(pid, 0); @@ -497,6 +515,7 @@ module.exports = { readTeamConfig, resolveTeamMembers, getCurrentRuntimeMemberIdentity, + resolveCanonicalLeadSessionId, resolveLeadSessionId, saveTaskAttachmentFile, }; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index a17fba61..2632bdd8 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -530,6 +530,25 @@ describe('agent-teams-controller API', () => { expect(inbox[0].leadSessionId).toBe('lead-session-1'); }); + it('ignores mismatched leadSessionId placeholders on review_request and uses canonical config session', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' }); + + controller.kanban.addReviewer('alice'); + controller.tasks.completeTask(task.id, 'bob'); + controller.review.requestReview(task.id, { + from: 'team-lead', + leadSessionId: 'team-lead', + }); + + const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json'); + const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8')); + + expect(inbox).toHaveLength(1); + expect(inbox[0].leadSessionId).toBe('lead-session-1'); + }); + it('starts review idempotently without requiring completed status', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -697,6 +716,47 @@ describe('agent-teams-controller API', () => { expect(rows.at(-1).leadSessionId).toBe('lead-session-1'); }); + it('ignores mismatched leadSessionId placeholders on review_approve owner notifications', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Approve me', owner: 'bob' }); + + controller.kanban.addReviewer('alice'); + controller.tasks.completeTask(task.id, 'bob'); + controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); + controller.review.approveReview(task.id, { + from: 'team-lead', + note: 'Looks good.', + 'notify-owner': true, + leadSessionId: 'team-lead', + }); + + const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json'); + const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + expect(rows.at(-1).summary).toContain('Approved'); + expect(rows.at(-1).leadSessionId).toBe('lead-session-1'); + }); + + it('ignores mismatched leadSessionId placeholders on review_request_changes owner notifications', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' }); + + controller.kanban.addReviewer('alice'); + controller.tasks.completeTask(task.id, 'bob'); + controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); + controller.review.requestChanges(task.id, { + from: 'alice', + comment: 'Please address review feedback.', + leadSessionId: 'team-lead', + }); + + const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json'); + const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + expect(rows.at(-1).summary).toContain('Fix request'); + expect(rows.at(-1).leadSessionId).toBe('lead-session-1'); + }); + it('limits approved briefing section to the latest 10 tasks by freshness', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/docs/iterations/iteration-03-kanban-board.md b/docs/iterations/iteration-03-kanban-board.md index 5f14a654..56830552 100644 --- a/docs/iterations/iteration-03-kanban-board.md +++ b/docs/iterations/iteration-03-kanban-board.md @@ -1,5 +1,10 @@ # Итерация 03 — Kanban Board (click-to-move) + `kanban-state.json` +> Historical note +> This document captures the planned Kanban scope at iteration time. +> It is not the source of truth for the current product contract. +> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md). + Эта итерация добавляет **kanban-доску команды** во вкладке Team и вводит **персистентное состояние** для колонок `REVIEW`/`APPROVED` через файл `~/.claude/teams/{teamName}/kanban-state.json`. Основание: @@ -263,4 +268,3 @@ - **EXDEV/rename нюансы**: в atomic write добавляем fallback copy+unlink. - **Синхронизация UI**: после `updateKanban` делаем `refreshTeamData(teamName)` (и всё равно придёт watcher-событие; refresh должен быть идемпотентен). - **Шум от fs.watch**: kanban-write может вызвать два refresh (ручной + watcher). Это ок, но store должен coalesce, а `refreshTeamData` — быть безопасным при частых вызовах. - diff --git a/docs/iterations/iteration-04-messaging-review.md b/docs/iterations/iteration-04-messaging-review.md index 37a4763d..86f87934 100644 --- a/docs/iterations/iteration-04-messaging-review.md +++ b/docs/iterations/iteration-04-messaging-review.md @@ -1,5 +1,10 @@ # Итерация 04 — Messaging + Review (Inbox + ReviewDialog) +> Historical note +> This document captures the planned scope and assumptions at iteration time. +> It is not the source of truth for the current product contract. +> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md). + Эта итерация добавляет **панель активности (inbox messages)** и **отправку сообщений** тиммейтам, а также закрывает MVP review-flow: **Request Review → Approve / Request Changes**. Основание: @@ -293,4 +298,3 @@ Guards: - **Race condition inbox**: атомарная запись не решает overwrite race, поэтому делаем `messageId verify` + retry/backoff, плюс in-process `withInboxLock`. - **Конфликт при записи task.status**: после write делаем verify; если agent перезаписал — показываем warning в UI, не делаем silent fail. - **Большие inbox**: ограничиваем количество отображаемых сообщений (например 200) и добавляем “Show more” позже (итерация 05). - diff --git a/docs/iterations/iteration-05-testing-polish.md b/docs/iterations/iteration-05-testing-polish.md index 750bb42a..db7f4c6f 100644 --- a/docs/iterations/iteration-05-testing-polish.md +++ b/docs/iterations/iteration-05-testing-polish.md @@ -1,5 +1,10 @@ # Итерация 05 — Testing + Polish (production-ready) +> Historical note +> This document captures iteration-era test and polish assumptions. +> It is not the source of truth for the current product contract. +> For the current review flow, see [../team-management/README.md](../team-management/README.md) and [../team-management/kanban-design.md](../team-management/kanban-design.md). + Эта итерация закрывает **качество**: тесты на критические пути (read/write), фиксация edge cases, UX-polish (empty/error/loading), и небольшие оптимизации под реальные объёмы inbox/tasks. Основание: @@ -204,4 +209,3 @@ test/ 4) Request Review → карточка в REVIEW + (если reviewer задан) сообщение ушло 5) Request Changes → task.status стал `in_progress` + owner получил сообщение 6) Любое изменение файлов `~/.claude/teams/**` / `~/.claude/tasks/**` → UI обновился в пределах ~1с - diff --git a/docs/team-management/README.md b/docs/team-management/README.md index 8778577a..514fd0ae 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -5,9 +5,9 @@ ## Что делает - Видеть состав команды и роли участников -- Kanban-доска с 5 колонками (TODO → IN PROGRESS → DONE → REVIEW → APPROVED) +- Kanban-доска с 5 колонками: TODO, IN PROGRESS, REVIEW, DONE, APPROVED - Отправка сообщений тиммейтам через inbox-файлы -- Review flow: автоматическое назначение ревьюверов или ручное ревью +- Review flow: запрос ревью, ручное ревью и прямое manual approval из DONE - Live updates через file watcher ## Документация @@ -23,6 +23,8 @@ ## Ключевые решения +⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md). + ### 1. Messaging: Inbox-файлы Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md) @@ -36,8 +38,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json` ### 3. Review Flow: Approve / Request Changes - Есть ревьюверы в команде → автоматическое назначение через inbox +- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW` - Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI) -- При Request Changes → юзер описывает проблему (опционально) → задача к исходному owner +- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix` ### 4. Atomic Write Все записи через tmp + rename для предотвращения corrupted JSON. @@ -62,6 +65,10 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json` ### Review Flow: Approve / Request Changes - Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error) - Комментарий при Request Changes — опционален +- Manual UI допускает два valid path: + - `DONE -> REVIEW -> APPROVED` + - `DONE -> APPROVED` как быстрый manual approval +- `Request Changes` снимает kanban-state запись и возвращает задачу в `pending` с `needsFix` - `reviewHistory` и round-robin балансировка → Phase 2, не MVP ### Members: полный список через union @@ -75,8 +82,9 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json` - `IDLE`: idle > 5 минут - `TERMINATED`: получен `shutdown_response` с `approve: true` -### @dnd-kit: click-to-move для MVP -- MVP: выбор колонки через select/dropdown (click-to-move) — проще и надёжнее +### @dnd-kit and review transitions +- Переходы между review-колонками делаются через card actions в UI +- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки - Phase 2: полноценный D&D через `@dnd-kit` --- diff --git a/docs/team-management/implementation.md b/docs/team-management/implementation.md index 5cde66e3..4f82b9ec 100644 --- a/docs/team-management/implementation.md +++ b/docs/team-management/implementation.md @@ -1,5 +1,9 @@ # Implementation Plan (v7 — Production-Ready Architecture) +> Historical note +> This is a planning and architecture document, not the source of truth for the current shipped product behavior. +> For the current review flow, see [README.md](./README.md) and [kanban-design.md](./kanban-design.md). + ## Обзор ~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first). diff --git a/docs/team-management/kanban-design.md b/docs/team-management/kanban-design.md index e0459668..568e3f0e 100644 --- a/docs/team-management/kanban-design.md +++ b/docs/team-management/kanban-design.md @@ -3,9 +3,11 @@ ## Flow ``` -TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPROVED - ↑ | - └── Fix (error) ←──┘ +TODO → IN PROGRESS → DONE ───────────────→ APPROVED + │ ↑ + └→ REVIEW ───────────┘ + │ + └→ pending + needsFix ``` ## Колонки @@ -15,8 +17,8 @@ TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPRO | **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя | | **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает | | **DONE** | task.status = completed | Автоматически | Агент завершил | -| **REVIEW** | kanban-state.json | Юзер (drag-and-drop) | На проверке | -| **APPROVED** | kanban-state.json | Юзер (drag-and-drop) | Одобрено | +| **REVIEW** | kanban-state.json | Юзер/UI actions | На проверке | +| **APPROVED** | kanban-state.json | Юзер/UI actions | Одобрено | --- @@ -90,9 +92,20 @@ const tasks = await getAllTasks(teamName); ## Review Flow +⚠️ Этот файл описывает текущий продуктовый contract review flow. Исторические iteration-доки могут расходиться с ним. + +### Manual actions from DONE + +Из `DONE` сейчас есть два валидных пользовательских сценария: + +1. **Request Review** - отправить задачу в `REVIEW` +2. **Approve** - сразу перевести задачу в `APPROVED` как manual shortcut + +`REVIEW` нужен, когда пользователь хочет отдельный шаг проверки на доске, включая reviewer-driven flow или ручную проверку через UI. Но `REVIEW` не является обязательным промежуточным шагом для каждого manual approval. + ### Перемещение DONE → REVIEW -1. Юзер перетаскивает карточку из DONE в REVIEW +1. Юзер переводит карточку из DONE в REVIEW через UI action 2. Проверяем `kanbanState.reviewers[]` 3. **Есть ревьюверы**: - Берём первого свободного (round-robin с балансировкой по количеству активных ревью) @@ -109,7 +122,14 @@ const tasks = await getAllTasks(teamName); ``` 4. **Нет ревьюверов**: - Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }` - - Юзер сам ревьювит через UI (кнопки OK / Error) + - Юзер сам ревьювит через UI (кнопки Approve / Request Changes) + +### Прямое DONE → APPROVED + +Юзер может сразу нажать **Approve** на карточке в `DONE`: +- kanban-state: `{ column: "approved" }` +- отдельный заход в `REVIEW` не требуется +- это manual shortcut и текущее допустимое поведение UI ### Review Result @@ -129,8 +149,10 @@ const tasks = await getAllTasks(teamName); 2. Появляется ReviewDialog — textarea для описания проблемы (опционально) 3. Юзер нажимает "Отправить" 4. Действия: - - kanban-state: удаляем запись для этой задачи (вернётся в IN PROGRESS по status) - - task file: `status = "in_progress"` (atomic write) + - kanban-state: удаляем запись для этой задачи + - task file: `status = "pending"` + - reviewState становится `needsFix` + - в UI задача возвращается в TODO/backlog path с маркером Needs Fixes - Inbox к исходному owner: ```json { @@ -150,30 +172,31 @@ const tasks = await getAllTasks(teamName); ### MVP: Click-to-Move -Для MVP вместо drag-and-drop используется **click-to-move**: каждая карточка имеет кнопку или select-dropdown для смены колонки. Это проще реализовать и достаточно для первой версии. +Для текущего UI переходы между review-колонками делаются через **card actions** на карточке. Отдельный DnD сейчас используется для перестановки задач внутри колонки, а не для review state transitions. ``` [Task Card] Subject: Rename package in pubspec.yaml Owner: worker-1 - [Move to: REVIEW ▼] ← dropdown или кнопка + [Approve] [Request review] ``` -Разрешённые переходы через click-to-move: +Разрешённые review-переходы через UI actions: | Откуда → Куда | Действие | |----------------|----------| | DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть | +| DONE → APPROVED (Approve) | kanban-state: approved | | REVIEW → APPROVED (Approve) | kanban-state: approved | -| REVIEW → DONE (Request Changes) | Dialog → task: in_progress, kanban: remove, inbox к owner | +| REVIEW → TODO/Needs Fixes (Request Changes) | Dialog → task: pending + needsFix, kanban: remove, inbox к owner | | APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) | Не разрешено: - TODO → IN PROGRESS (агент берёт сам через TaskUpdate) - IN PROGRESS → DONE (агент завершает сам через TaskUpdate) -### Phase 2: Полноценный D&D через @dnd-kit +### Phase 2: Полноценный D&D для state transitions -`@dnd-kit` уже есть в зависимостях проекта (используется для перетаскивания табов). В Phase 2 добавить drag-and-drop для всех разрешённых переходов. +`@dnd-kit` уже используется для ordering. В Phase 2 можно добавить drag-and-drop и для самих state transitions, если это понадобится по UX. --- diff --git a/packages/agent-graph/src/canvas/draw-handoff-cards.ts b/packages/agent-graph/src/canvas/draw-handoff-cards.ts new file mode 100644 index 00000000..2985699c --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-handoff-cards.ts @@ -0,0 +1,268 @@ +import { COLORS } from '../constants/colors'; +import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; +import type { CameraTransform } from '../hooks/useGraphCamera'; +import type { GraphNode } from '../ports/types'; +import type { TransientHandoffCard } from '../ui/transientHandoffs'; +import { truncateText } from './draw-misc'; +import { hexWithAlpha, measureTextCached } from './render-cache'; + +export function drawHandoffCards( + ctx: CanvasRenderingContext2D, + params: { + cards: TransientHandoffCard[]; + nodeMap: Map; + time: number; + camera: CameraTransform; + viewport: { width: number; height: number }; + } +): void { + const { cards, nodeMap, time, camera, viewport } = params; + if (cards.length === 0) return; + + const stackIndexByDestination = new Map(); + let drawnCount = 0; + + for (const card of cards) { + if (drawnCount >= HANDOFF_CARD.maxVisible) break; + const destinationNode = nodeMap.get(card.destinationNodeId); + if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue; + + const alpha = getCardAlpha(card, time); + if (alpha <= MIN_VISIBLE_OPACITY) continue; + + const previewLines = buildPreviewLines(ctx, card.preview); + const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight; + const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0; + stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1); + + const position = getCardPosition({ + node: destinationNode, + camera, + viewport, + height, + stackIndex, + }); + if (!position) continue; + + drawCard({ + ctx, + card, + previewLines, + alpha, + x: position.x, + y: position.y, + width: HANDOFF_CARD.width, + height, + }); + drawnCount += 1; + } +} + +function getCardAlpha(card: TransientHandoffCard, time: number): number { + const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds); + const fadeOutRemaining = card.expiresAt - time; + const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds + ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds) + : 1; + return Math.max(0, Math.min(1, fadeIn * fadeOut)); +} + +function getCardPosition(params: { + node: GraphNode; + camera: CameraTransform; + viewport: { width: number; height: number }; + height: number; + stackIndex: number; +}): { x: number; y: number } | null { + const { node, camera, viewport, height, stackIndex } = params; + const screenX = node.x! * camera.zoom + camera.x; + const screenY = node.y! * camera.zoom + camera.y; + + const visibleMargin = 80; + if ( + screenX < -visibleMargin || + screenX > viewport.width + visibleMargin || + screenY < -visibleMargin || + screenY > viewport.height + visibleMargin + ) { + return null; + } + + const anchorGap = getAnchorGap(node, camera.zoom); + const stackOffset = stackIndex * (height + HANDOFF_CARD.stackGap); + let x = screenX + anchorGap.x; + let y = screenY + anchorGap.y - stackOffset; + + if (x + HANDOFF_CARD.width > viewport.width - HANDOFF_CARD.viewportPadding) { + x = screenX - HANDOFF_CARD.width - Math.abs(anchorGap.x); + } + if (x < HANDOFF_CARD.viewportPadding) { + x = HANDOFF_CARD.viewportPadding; + } + + if (y < HANDOFF_CARD.viewportPadding) { + y = screenY + Math.abs(anchorGap.y) + stackOffset; + } + if (y + height > viewport.height - HANDOFF_CARD.viewportPadding) { + y = Math.max(HANDOFF_CARD.viewportPadding, viewport.height - height - HANDOFF_CARD.viewportPadding); + } + + return { x, y }; +} + +function getAnchorGap(node: GraphNode, zoom: number): { x: number; y: number } { + switch (node.kind) { + case 'lead': + return { + x: NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap, + y: -(NODE.radiusLead * zoom + HANDOFF_CARD.anchorGap), + }; + case 'member': + return { + x: NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap, + y: -(NODE.radiusMember * zoom + HANDOFF_CARD.anchorGap), + }; + case 'task': + return { + x: TASK_PILL.width * zoom * 0.5 + HANDOFF_CARD.anchorGap, + y: -(TASK_PILL.height * zoom * 0.5 + HANDOFF_CARD.anchorGap), + }; + case 'process': + return { + x: NODE.radiusProcess * zoom + HANDOFF_CARD.anchorGap, + y: -(NODE.radiusProcess * zoom + HANDOFF_CARD.anchorGap), + }; + case 'crossteam': + return { + x: NODE.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap, + y: -(NODE.radiusCrossTeam * zoom + HANDOFF_CARD.anchorGap), + }; + } +} + +function drawCard(params: { + ctx: CanvasRenderingContext2D; + card: TransientHandoffCard; + previewLines: string[]; + alpha: number; + x: number; + y: number; + width: number; + height: number; +}): void { + const { ctx, card, previewLines, alpha, x, y, width, height } = params; + const accent = card.color || COLORS.particleInboxMessage; + const radius = 10; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.shadowColor = hexWithAlpha(accent, 0.22 * alpha); + ctx.shadowBlur = 12; + ctx.fillStyle = hexWithAlpha('#08111f', 0.92); + ctx.strokeStyle = hexWithAlpha(accent, 0.38); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, y, width, height, radius); + ctx.fill(); + ctx.stroke(); + + ctx.shadowBlur = 0; + ctx.fillStyle = hexWithAlpha(accent, 0.14); + ctx.beginPath(); + ctx.roundRect(x + 8, y + 8, 54, 16, 6); + ctx.fill(); + + ctx.fillStyle = hexWithAlpha(accent, 0.92); + ctx.font = 'bold 8px monospace'; + ctx.textAlign = 'left'; + ctx.fillText(getKindLabel(card.kind), x + 16, y + 19); + + if (card.count > 1) { + const countText = `+${card.count - 1}`; + ctx.font = 'bold 8px monospace'; + const countWidth = measureTextCached(ctx, ctx.font, countText) + 14; + ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.16); + ctx.beginPath(); + ctx.roundRect(x + width - countWidth - 10, y + 8, countWidth, 16, 6); + ctx.fill(); + ctx.fillStyle = COLORS.holoBright; + ctx.textAlign = 'center'; + ctx.fillText(countText, x + width - countWidth / 2 - 10, y + 19); + } + + ctx.textAlign = 'left'; + ctx.font = 'bold 10px monospace'; + ctx.fillStyle = COLORS.textPrimary; + const route = truncateText( + ctx, + `${card.sourceLabel} -> ${card.destinationLabel}`, + width - 20, + ctx.font + ); + ctx.fillText(route, x + 10, y + 36); + + if (previewLines.length > 0) { + ctx.font = '8px monospace'; + ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.86); + for (let index = 0; index < previewLines.length; index += 1) { + ctx.fillText( + previewLines[index], + x + 10, + y + 50 + index * HANDOFF_CARD.previewLineHeight + ); + } + } + ctx.restore(); +} + +function buildPreviewLines(ctx: CanvasRenderingContext2D, preview: string | undefined): string[] { + if (!preview) return []; + ctx.font = '8px monospace'; + let remaining = preview.replace(/\s+/g, ' ').trim(); + if (remaining.length === 0) return []; + const lines: string[] = []; + for (let index = 0; index < HANDOFF_CARD.previewMaxLines && remaining.length > 0; index += 1) { + if (index === HANDOFF_CARD.previewMaxLines - 1) { + lines.push(truncateText(ctx, remaining, HANDOFF_CARD.previewMaxWidth, ctx.font)); + break; + } + + const words = remaining.split(' '); + let line = ''; + let consumedWords = 0; + for (const word of words) { + const candidate = line.length > 0 ? `${line} ${word}` : word; + if (measureTextCached(ctx, ctx.font, candidate) <= HANDOFF_CARD.previewMaxWidth) { + line = candidate; + consumedWords += 1; + continue; + } + break; + } + + if (consumedWords === 0) { + lines.push(truncateText(ctx, words[0] ?? remaining, HANDOFF_CARD.previewMaxWidth, ctx.font)); + break; + } + + lines.push(line); + remaining = words.slice(consumedWords).join(' ').trim(); + } + + return lines; +} + +function getKindLabel(kind: TransientHandoffCard['kind']): string { + switch (kind) { + case 'task_comment': + return 'COMMENT'; + case 'task_assign': + return 'TASK'; + case 'review_request': + return 'REVIEW'; + case 'review_response': + return 'REPLY'; + case 'inbox_message': + return 'MESSAGE'; + } +} diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index f56a236f..f41066b3 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -211,6 +211,24 @@ export const PARTICLE_DRAW = { lifetime: 2.0, } as const; +export const HANDOFF_CARD = { + triggerProgress: 0.58, + lingerSeconds: 3.2, + fadeInSeconds: 0.14, + fadeOutSeconds: 0.35, + width: 196, + maxVisible: 6, + maxPerDestination: 2, + baseHeight: 42, + previewLineHeight: 10, + previewMaxLines: 2, + previewMaxWidth: 176, + badgeGap: 8, + stackGap: 10, + viewportPadding: 12, + anchorGap: 14, +} as const; + // ─── Hit detection ────────────────────────────────────────────────────────── export const HIT_DETECTION = { diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 1deb3cce..3ad3ece2 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -177,6 +177,8 @@ export interface GraphParticle { size?: number; /** Short label near particle */ label?: string; + /** Longer preview text for transient handoff cards */ + preview?: string; /** If true, particle travels from target → source (reverse direction) */ reverse?: boolean; } diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index eee3c1ba..74bb5ed6 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -10,6 +10,7 @@ import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react'; import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer'; import { drawEdges } from '../canvas/draw-edges'; +import { drawHandoffCards } from '../canvas/draw-handoff-cards'; import { drawParticles } from '../canvas/draw-particles'; import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; @@ -18,11 +19,17 @@ import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; import { BloomRenderer } from '../canvas/bloom-renderer'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles'; +import { + createTransientHandoffState, + selectRenderableTransientHandoffCards, + updateTransientHandoffState, +} from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── export interface GraphDrawState { + teamName: string; nodes: GraphNode[]; edges: GraphEdge[]; particles: GraphParticle[]; @@ -123,6 +130,8 @@ export const GraphCanvas = forwardRef(funct const visibleNodeIdsCache = useRef(new Set()); const visibleEdgeIdsCache = useRef(new Set()); const activeParticleEdgesCache = useRef(new Set()); + const handoffStateRef = useRef(createTransientHandoffState()); + const lastTeamNameRef = useRef(null); // Imperative draw function — called from RAF, NOT from React render useImperativeHandle(ref, () => ({ @@ -139,6 +148,10 @@ export const GraphCanvas = forwardRef(funct if (w === 0 || h === 0) return; try { + if (lastTeamNameRef.current !== state.teamName) { + handoffStateRef.current = createTransientHandoffState(); + lastTeamNameRef.current = state.teamName; + } const cam = state.camera; const zoom = cam.zoom; @@ -234,6 +247,19 @@ export const GraphCanvas = forwardRef(funct focusEdgeIds: prioritizedEdgeIds, budget: particleBudget, }); + updateTransientHandoffState(handoffStateRef.current, { + particles: state.particles, + edgeMap, + nodeMap, + time: state.time, + }); + const renderableHandoffCards = selectRenderableTransientHandoffCards( + handoffStateRef.current, + { + focusNodeIds: state.focusNodeIds, + focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds, + } + ); drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) @@ -282,6 +308,19 @@ export const GraphCanvas = forwardRef(funct bloomRef.current.apply(canvas, ctx); } + if (renderableHandoffCards.length > 0) { + ctx.save(); + ctx.scale(dpr, dpr); + drawHandoffCards(ctx, { + cards: renderableHandoffCards, + nodeMap, + time: state.time, + camera: cam, + viewport: { width: w, height: h }, + }); + ctx.restore(); + } + // 4. Performance overlay (enabled via ?perf in URL) const perf = perfRef.current; const frameMs = performance.now() - frameStart; diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 35e0290d..92893c81 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -200,6 +200,7 @@ export function GraphView({ // 4. Draw canvas imperatively (NO React re-render) canvasHandle.current?.draw({ + teamName: data.teamName, nodes: state.nodes, edges: state.edges, particles: state.particles, diff --git a/packages/agent-graph/src/ui/transientHandoffs.ts b/packages/agent-graph/src/ui/transientHandoffs.ts new file mode 100644 index 00000000..78465e0f --- /dev/null +++ b/packages/agent-graph/src/ui/transientHandoffs.ts @@ -0,0 +1,163 @@ +import { HANDOFF_CARD } from '../constants/canvas-constants'; +import type { GraphEdge, GraphNode, GraphParticle, GraphParticleKind } from '../ports/types'; + +type HandoffParticleKind = Exclude; + +export interface TransientHandoffCard { + key: string; + edgeId: string; + sourceNodeId: string; + destinationNodeId: string; + sourceLabel: string; + destinationLabel: string; + destinationKind: GraphNode['kind']; + kind: HandoffParticleKind; + color: string; + preview?: string; + count: number; + activatedAt: number; + updatedAt: number; + expiresAt: number; +} + +export interface TransientHandoffState { + cardsByKey: Map; + triggeredParticleIds: Set; +} + +export function createTransientHandoffState(): TransientHandoffState { + return { + cardsByKey: new Map(), + triggeredParticleIds: new Set(), + }; +} + +export function updateTransientHandoffState( + state: TransientHandoffState, + params: { + particles: GraphParticle[]; + edgeMap: Map; + nodeMap: Map; + time: number; + } +): void { + const { particles, edgeMap, nodeMap, time } = params; + + const activeParticleIds = new Set(); + for (const particle of particles) activeParticleIds.add(particle.id); + for (const particleId of Array.from(state.triggeredParticleIds)) { + if (!activeParticleIds.has(particleId)) { + state.triggeredParticleIds.delete(particleId); + } + } + + for (const [cardKey, card] of Array.from(state.cardsByKey.entries())) { + if (card.expiresAt <= time) { + state.cardsByKey.delete(cardKey); + } + } + + for (const particle of particles) { + if (!isTransientHandoffKind(particle.kind)) continue; + if (particle.progress < HANDOFF_CARD.triggerProgress) continue; + if (state.triggeredParticleIds.has(particle.id)) continue; + + const edge = edgeMap.get(particle.edgeId); + if (!edge) continue; + + const sourceNodeId = particle.reverse ? edge.target : edge.source; + const destinationNodeId = particle.reverse ? edge.source : edge.target; + const sourceNode = nodeMap.get(sourceNodeId); + const destinationNode = nodeMap.get(destinationNodeId); + if (!sourceNode || !destinationNode) continue; + + const previewText = normalizePreviewText(particle.preview ?? particle.label); + if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) { + state.triggeredParticleIds.add(particle.id); + continue; + } + + const cardKey = `${edge.id}:${particle.reverse ? 'rev' : 'fwd'}:${particle.kind}`; + const existing = state.cardsByKey.get(cardKey); + const nextCount = (existing?.count ?? 0) + 1; + + state.cardsByKey.set(cardKey, { + key: cardKey, + edgeId: edge.id, + sourceNodeId, + destinationNodeId, + sourceLabel: sourceNode.label, + destinationLabel: destinationNode.label, + destinationKind: destinationNode.kind, + kind: particle.kind, + color: particle.color, + preview: previewText ?? existing?.preview, + count: nextCount, + activatedAt: existing?.activatedAt ?? time, + updatedAt: time, + expiresAt: time + HANDOFF_CARD.lingerSeconds, + }); + state.triggeredParticleIds.add(particle.id); + } +} + +export function selectRenderableTransientHandoffCards( + state: TransientHandoffState, + options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + } +): TransientHandoffCard[] { + const focusNodeIds = options?.focusNodeIds ?? null; + const focusEdgeIds = options?.focusEdgeIds ?? null; + const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0; + + const byDestination = new Map(); + for (const card of state.cardsByKey.values()) { + if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue; + const destinationCards = byDestination.get(card.destinationNodeId); + if (destinationCards) { + destinationCards.push(card); + } else { + byDestination.set(card.destinationNodeId, [card]); + } + } + + const selected: TransientHandoffCard[] = []; + for (const cards of byDestination.values()) { + cards.sort((a, b) => b.updatedAt - a.updatedAt); + selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination)); + } + + selected.sort((a, b) => b.updatedAt - a.updatedAt); + return selected; +} + +function isTransientHandoffKind(kind: GraphParticleKind): kind is HandoffParticleKind { + return kind !== 'spawn'; +} + +function isCardInFocus( + card: TransientHandoffCard, + focusNodeIds: ReadonlySet | null, + focusEdgeIds: ReadonlySet | null +): boolean { + return ( + !!focusEdgeIds?.has(card.edgeId) || + !!focusNodeIds?.has(card.sourceNodeId) || + !!focusNodeIds?.has(card.destinationNodeId) + ); +} + +function normalizePreviewText(text: string | undefined): string | undefined { + if (!text) return undefined; + const normalized = text + .replace(/^(?:✉|💬)\s*/u, '') + .replace(/\s+/g, ' ') + .trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function isLowSignalInboxPreview(preview: string | undefined): boolean { + return preview === 'idle'; +} diff --git a/src/main/index.ts b/src/main/index.ts index ad118cec..980e216e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -63,7 +63,6 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { setReviewMainWindow } from './ipc/review'; import { ApiKeyService, - RUNTIME_MANAGED_API_KEY_ENV_VARS, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, @@ -74,6 +73,7 @@ import { PluginCatalogService, PluginInstallationStateService, PluginInstallService, + RUNTIME_MANAGED_API_KEY_ENV_VARS, SkillsCatalogService, SkillsMutationService, SkillsWatcherService, @@ -103,6 +103,11 @@ import { import { syncTelemetryFlag } from './sentry'; import { BranchStatusService, + BoardTaskActivityRecordSource, + BoardTaskActivityService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, + BoardTaskLogStreamService, CliInstallerService, configManager, LocalFileSystemProvider, @@ -779,6 +784,13 @@ async function initializeServices(): Promise { cliInstallerService = new CliInstallerService(); ptyTerminalService = new PtyTerminalService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); + const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource(); + const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource); + const boardTaskExactLogsService = new BoardTaskExactLogsService(boardTaskActivityRecordSource); + const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService( + boardTaskActivityRecordSource + ); + const boardTaskLogStreamService = new BoardTaskLogStreamService(boardTaskActivityRecordSource); const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( teamMemberLogsFinder ); @@ -924,6 +936,10 @@ async function initializeServices(): Promise { teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, + boardTaskActivityService, + boardTaskLogStreamService, + boardTaskExactLogsService, + boardTaskExactLogDetailService, teammateToolTracker ?? undefined, branchStatusService ?? undefined, { diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 2906e42d..c1ddec7b 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -8,8 +8,8 @@ */ import { - CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_PROVIDER_STATUS, + CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload @@ -17,8 +17,9 @@ import { import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; -import type { CliInstallerService } from '../services'; import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver'; + +import type { CliInstallerService } from '../services'; import type { CliInstallationStatus, CliProviderId, diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 514a9a15..7b51dfc1 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -28,12 +28,12 @@ import { } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; +import { + type ApiKeyService, + RUNTIME_MANAGED_API_KEY_ENV_VARS, +} from '../services/extensions/apikeys/ApiKeyService'; import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService'; -import { - RUNTIME_MANAGED_API_KEY_ENV_VARS, - type ApiKeyService, -} from '../services/extensions/apikeys/ApiKeyService'; import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; diff --git a/src/main/ipc/tmux.ts b/src/main/ipc/tmux.ts index 1b6301fc..ff98ee84 100644 --- a/src/main/ipc/tmux.ts +++ b/src/main/ipc/tmux.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; -import type { TmuxPlatform, TmuxStatus, IpcResult } from '@shared/types'; +import type { IpcResult, TmuxPlatform, TmuxStatus } from '@shared/types'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; const logger = createLogger('IPC:tmux'); diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 8f093da0..a479024d 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -20,14 +20,15 @@ const { autoUpdater } = electronUpdater; import { app, net } from 'electron'; -import type { UpdaterStatus } from '@shared/types'; -import type { BrowserWindow } from 'electron'; import { getExpectedReleaseAssetUrl, getLatestMacMetadataUrl, isLatestMacMetadataCompatible, } from './updaterReleaseMetadata'; +import type { UpdaterStatus } from '@shared/types'; +import type { BrowserWindow } from 'electron'; + const logger = createLogger('UpdaterService'); /** diff --git a/src/main/services/infrastructure/updaterReleaseMetadata.ts b/src/main/services/infrastructure/updaterReleaseMetadata.ts index 8a32524c..cdded6b7 100644 --- a/src/main/services/infrastructure/updaterReleaseMetadata.ts +++ b/src/main/services/infrastructure/updaterReleaseMetadata.ts @@ -58,7 +58,7 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set void; reject: (e: Error) => void; -}; +} export class TeamDataWorkerClient { private worker: Worker | null = null; diff --git a/src/main/services/team/TeamLaunchStateStore.ts b/src/main/services/team/TeamLaunchStateStore.ts index 0f154c5b..556f3b0f 100644 --- a/src/main/services/team/TeamLaunchStateStore.ts +++ b/src/main/services/team/TeamLaunchStateStore.ts @@ -3,8 +3,8 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import { atomicWriteAsync } from './atomicWrite'; +import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator'; import type { PersistedTeamLaunchSnapshot } from '@shared/types'; diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 4b4e4560..4e221bc2 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -1,10 +1,10 @@ +import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; -import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types'; -import { createLogger } from '@shared/utils/logger'; - import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types'; + const LOOKBACK_MS = 10 * 60 * 1000; const CACHE_TTL_MS = 5_000; const TAIL_BYTES = 64 * 1024; @@ -102,11 +102,7 @@ export class TeamMemberRuntimeAdvisoryService { const membersSignature = this.buildMembersSignature(activeMembers); const now = Date.now(); const cachedBatch = this.teamBatchCacheByTeam.get(teamKey); - if ( - cachedBatch && - cachedBatch.membersSignature === membersSignature && - cachedBatch.expiresAt > now - ) { + if (cachedBatch?.membersSignature === membersSignature && cachedBatch.expiresAt > now) { return this.materializeBatchAdvisories(activeMembers, cachedBatch.value); } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index f03cc458..4baf9776 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,7 +1,7 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; -import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/src/main/services/team/idleNotificationMainProcessSemantics.ts b/src/main/services/team/idleNotificationMainProcessSemantics.ts index 36f336e0..e8eaf311 100644 --- a/src/main/services/team/idleNotificationMainProcessSemantics.ts +++ b/src/main/services/team/idleNotificationMainProcessSemantics.ts @@ -5,12 +5,12 @@ import { export type MainProcessIdleHandling = 'silent_noise' | 'passive_activity' | 'visible_actionable'; -export type ClassifiedMainProcessIdle = { +export interface ClassifiedMainProcessIdle { primaryKind: MainProcessIdlePrimaryKind; hasPeerSummary: boolean; peerSummary: string | null; handling: MainProcessIdleHandling; -}; +} export function classifyIdleNotificationForMainProcess( text: string diff --git a/src/main/services/team/inboxMessageIdentity.ts b/src/main/services/team/inboxMessageIdentity.ts index 8e699d86..cae49e5f 100644 --- a/src/main/services/team/inboxMessageIdentity.ts +++ b/src/main/services/team/inboxMessageIdentity.ts @@ -1,11 +1,11 @@ import { createHash } from 'crypto'; -type InboxIdentityLike = { +interface InboxIdentityLike { messageId?: unknown; from?: unknown; timestamp?: unknown; text?: unknown; -}; +} export function buildLegacyInboxMessageId(from: string, timestamp: string, text: string): string { return `inbox-${createHash('sha256').update(`${from}\n${timestamp}\n${text}`).digest('hex').slice(0, 16)}`; diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts index c9cbbf77..bb5acde8 100644 --- a/src/main/services/team/memberUpdateNotifications.ts +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -1,26 +1,26 @@ -export type MemberDiffInput = { +export interface MemberDiffInput { name: string; role?: string; workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; removedAt?: number | string | null; -}; +} -export type ReplaceMembersDiff = { - added: Array<{ +export interface ReplaceMembersDiff { + added: { name: string; role?: string; workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - }>; + }[]; removed: string[]; - updated: Array<{ + updated: { name: string; changes: string[]; - }>; -}; + }[]; +} function normalizeOptionalText(value: string | undefined): string | undefined { const normalized = value?.trim(); @@ -61,13 +61,13 @@ function describeWorkflowChange( export function buildReplaceMembersDiff( previousMembers: MemberDiffInput[], - nextMembers: Array<{ + nextMembers: { name: string; role?: string; workflow?: string; providerId?: 'anthropic' | 'codex' | 'gemini'; model?: string; - }> + }[] ): ReplaceMembersDiff { const previousByName = new Map( previousMembers diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index 2c743d6c..4dbed40b 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -1,13 +1,12 @@ -import { execFile } from 'child_process'; - import { parseCliArgs } from '@shared/utils/cliArgsParser'; +import { execFile } from 'child_process'; const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000; -type DesktopTeammateModeDecision = { +interface DesktopTeammateModeDecision { injectedTeammateMode: 'tmux' | null; forceProcessTeammates: boolean; -}; +} let tmuxAvailabilityCache: { value: boolean; at: number } | null = null; let tmuxAvailablePromise: Promise | null = null; diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts index 3ef3b3c8..869140c3 100644 --- a/src/main/services/team/taskChangePresenceUtils.ts +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -1,8 +1,8 @@ +import { deriveTaskSince } from '@shared/utils/taskChangeSince'; import { getTaskChangeStateBucket, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; -import { deriveTaskSince } from '@shared/utils/taskChangeSince'; import { createHash } from 'crypto'; export interface TaskChangePresenceInterval { diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 71a56b19..39d44938 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -18,12 +18,12 @@ import { createLogger } from '@shared/utils/logger'; +import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider'; import { getProjectsBasePath, getTodosBasePath, setClaudeBasePathOverride, } from './utils/pathDecoder'; -import { LocalFileSystemProvider } from './services/infrastructure/LocalFileSystemProvider'; import type { HttpServices } from './http'; import type { HttpServer } from './services/infrastructure/HttpServer'; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 06fa12c2..99d1a0dd 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -11,12 +11,12 @@ import { parentPort } from 'node:worker_threads'; import { TeamDataService } from '@main/services/team/TeamDataService'; import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import { createLogger } from '@shared/utils/logger'; -import type { MemberLogSummary } from '@shared/types'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse, } from '@main/services/team/teamDataWorkerTypes'; +import type { MemberLogSummary } from '@shared/types'; const logger = createLogger('Worker:TeamData'); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index e5517368..89c83b83 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -2,8 +2,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parentPort } from 'node:worker_threads'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizePersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { isLeadMember } from '@shared/utils/leadDetection'; interface ListTeamsPayload { teamsDir: string; diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 4adad158..7cd94409 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -19,6 +19,7 @@ import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; import { CopyButton } from '../common/CopyButton'; + import { extractTextFromReactNode } from './markdownCopyUtils'; import { createSearchContext, diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index 69bf60ae..c2aeeca4 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -20,7 +20,6 @@ import { import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters'; @@ -37,6 +36,7 @@ import { Sigma, Terminal, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ExecutionTrace } from './ExecutionTrace'; import { MetricsPill } from './MetricsPill'; diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 69a58aaf..81ab8195 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -10,7 +10,6 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -19,6 +18,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { format } from 'date-fns'; import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { MarkdownViewer } from '../viewers/MarkdownViewer'; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index c0a06fcf..38f8ec70 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { type ItemStatus } from '../BaseItem'; import { CollapsibleOutputSection } from './CollapsibleOutputSection'; -import { renderInput, renderOutput } from './renderHelpers'; +import { extractOutputText, renderInput, renderOutput } from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -19,6 +19,13 @@ interface DefaultToolViewerProps { } export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { + const hasMeaningfulOutput = + linkedTool.result && + (() => { + const text = extractOutputText(linkedTool.result.content).trim(); + return text.length > 0 && text !== '[]' && text !== '{}'; + })(); + return ( <> {/* Input Section */} @@ -39,7 +46,7 @@ export const DefaultToolViewer: React.FC = ({ linkedTool
{/* Output Section — Collapsed by default */} - {!linkedTool.isOrphaned && linkedTool.result && ( + {!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && ( {renderOutput(linkedTool.result.content)} diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index 1298010a..940c07bf 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -3,9 +3,9 @@ import React from 'react'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { PROSE_BODY } from '@renderer/constants/cssVariables'; +import { FileLink, isRelativeUrl } from './viewers/FileLink'; import { extractTextFromReactNode } from './markdownCopyUtils'; import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; -import { FileLink, isRelativeUrl } from './viewers/FileLink'; import type { Components } from 'react-markdown'; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 980e03f6..a9b9454c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -42,6 +42,7 @@ import { type SearchContext, } from '../searchHighlightUtils'; import { highlightLine } from '../viewers/syntaxHighlighter'; + import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; diff --git a/src/renderer/components/dashboard/TmuxStatusBanner.tsx b/src/renderer/components/dashboard/TmuxStatusBanner.tsx index 0392d99f..86784408 100644 --- a/src/renderer/components/dashboard/TmuxStatusBanner.tsx +++ b/src/renderer/components/dashboard/TmuxStatusBanner.tsx @@ -14,7 +14,7 @@ type BannerState = const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; -function PlatformInstallMatrix(): React.JSX.Element { +const PlatformInstallMatrix = (): React.JSX.Element => { return (
); -} +}; function getPrimaryDetail(status: TmuxStatus): string { if (status.platform === 'darwin') { diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index f4a9f824..951aecb0 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -7,8 +7,8 @@ import { useEffect, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, Info, Key, Plus } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ApiKeyCard } from './ApiKeyCard'; import { ApiKeyFormDialog } from './ApiKeyFormDialog'; diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index f7e7ee3b..93bdf907 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -13,8 +13,8 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { Check, Loader2, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ExtensionOperationState } from '@shared/types/extensions'; diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 468df397..60ae4157 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -14,11 +14,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SearchInput } from '../common/SearchInput'; diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 500e4e98..23721946 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -24,13 +24,13 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { getCapabilityLabel, inferCapabilities, normalizeCategory, } from '@shared/utils/extensionNormalizers'; import { ExternalLink, Loader2, Mail } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { InstallButton } from '../common/InstallButton'; import { InstallCountBadge } from '../common/InstallCountBadge'; diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 258c35ce..ab8d94e9 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -16,9 +16,9 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers'; import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SearchInput } from '../common/SearchInput'; diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index 1f4c3e25..d70238d6 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -23,8 +23,8 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; interface SkillDetailDialogProps { skillId: string | null; diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 1898f14d..f2fd8792 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -6,7 +6,6 @@ import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, ArrowUpAZ, @@ -19,6 +18,7 @@ import { Plus, Search, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { SearchInput } from '../common/SearchInput'; diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx index 4ddf47bf..df2e9aa0 100644 --- a/src/renderer/components/report/SessionReportTab.tsx +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -1,9 +1,9 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { computeTakeaways } from '@renderer/utils/reportAssessments'; import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; +import { useShallow } from 'zustand/react/shallow'; import { CostSection } from './sections/CostSection'; import { ErrorSection } from './sections/ErrorSection'; diff --git a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx index c7316f5b..3c90d885 100644 --- a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx @@ -1,4 +1,3 @@ -import type { CliProviderStatus } from '@shared/types'; import { Select, SelectContent, @@ -13,11 +12,13 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; -type Props = { +import type { CliProviderStatus } from '@shared/types'; + +interface Props { provider: CliProviderStatus; disabled?: boolean; onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void; -}; +} export function getOptionDisplayLabel( option: NonNullable[number], @@ -47,11 +48,11 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s return getOptionDisplayLabel(selectedOption, resolvedOption); } -export function ProviderRuntimeBackendSelector({ +export const ProviderRuntimeBackendSelector = ({ provider, disabled = false, onSelect, -}: Props): React.JSX.Element | null { +}: Props): React.JSX.Element | null => { const options = provider.availableBackends ?? []; if (options.length === 0) { return null; @@ -191,4 +192,4 @@ export function ProviderRuntimeBackendSelector({ )}
); -} +}; diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 7e1369e9..4ee09057 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -6,7 +6,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { nameColorSet } from '@renderer/utils/projectColor'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { @@ -23,6 +22,7 @@ import { Trash2, Zap, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 6ede9398..5267a413 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -5,7 +5,6 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor'; @@ -13,6 +12,7 @@ import { projectLabelFromPath } from '@renderer/utils/taskGrouping'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { GlobalTask, TeamTaskStatus } from '@shared/types'; import type { LucideIcon } from 'lucide-react'; diff --git a/src/renderer/components/team/ProcessesSection.tsx b/src/renderer/components/team/ProcessesSection.tsx index 81b4cca9..2316fafc 100644 --- a/src/renderer/components/team/ProcessesSection.tsx +++ b/src/renderer/components/team/ProcessesSection.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; + import { formatDistanceToNowStrict } from 'date-fns'; import { ExternalLink, Square, Terminal } from 'lucide-react'; diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index dbb0d319..497b4a35 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -207,7 +207,7 @@ export const ProvisioningProgressBlock = ({
{ const detail = (e as CustomEvent).detail; if (detail?.teamName === teamName) { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; useStore.getState().openTab({ type: 'graph', - label: `${teamName} Graph`, + label: `${displayName} Graph`, teamName, }); } diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx index 72a3b8e6..fc02d12f 100644 --- a/src/renderer/components/team/TeamEmptyState.tsx +++ b/src/renderer/components/team/TeamEmptyState.tsx @@ -1,9 +1,9 @@ import { Button } from '@renderer/components/ui/button'; -type TeamEmptyStateProps = { +interface TeamEmptyStateProps { canCreate: boolean; onCreateTeam: () => void; -}; +} export const TeamEmptyState = ({ canCreate, diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 99ebfc62..5666e93d 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -3,10 +3,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ToolApprovalSettingsContent, diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 7b524fae..2b80003f 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -17,7 +17,6 @@ import { import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { getMessageTypeLabel, getStructuredMessageSummary, @@ -70,6 +69,7 @@ import { Reply, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; @@ -111,7 +111,7 @@ function getCommandOutputSummary(text: string): string { function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } { const trimmed = summary.trim(); - const match = trimmed.match(/^\[to\s+([^\]]+)\]\s*(.*)$/i); + const match = /^\[to\s+([^\]]+)\]\s*(.*)$/i.exec(trimmed); if (!match) { return { recipient: null, body: trimmed }; } diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 89ef016a..28a9ea58 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -2,7 +2,6 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, @@ -14,6 +13,7 @@ import { import { nameColorSet } from '@renderer/utils/projectColor'; import { formatDistanceToNowStrict } from 'date-fns'; import { Loader2, ShieldQuestion, Users } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ResolvedTeamMember } from '@shared/types'; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 5c4ed0d7..b83ab498 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -41,23 +41,24 @@ import { normalizeCreateLaunchProviderForUi, } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { ProjectPathSelector } from './ProjectPathSelector'; import { createInitialProviderChecks, failIncompleteProviderChecks, - getProvisioningProviderBackendSummary, getProvisioningFailureHint, + getProvisioningProviderBackendSummary, + type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, - type ProvisioningProviderCheck, } from './ProvisioningProviderStatusList'; -import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { computeEffectiveTeamModel } from './TeamModelSelector'; import { getNextSuggestedTeamName } from './teamNameSets'; @@ -111,15 +112,7 @@ function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): } function getProviderLabel(providerId: TeamProviderId): string { - switch (providerId) { - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'anthropic': - default: - return 'Anthropic'; - } + return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } export interface TeamCopyData { @@ -484,9 +477,7 @@ export const CreateTeamDialog = ({ }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); const runtimeBackendSummaryByProvider = useMemo(() => { - const entries: Array = ( - cliStatus?.providers ?? [] - ).map( + const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( (provider) => [ provider.providerId as TeamProviderId, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 6ad798d8..5a81a1a9 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; +import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { buildMemberDraftColorMap, buildMemberDraftSuggestions, @@ -13,7 +14,6 @@ import { validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; -import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Combobox } from '@renderer/components/ui/combobox'; @@ -36,16 +36,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { isGeminiUiFrozen, normalizeCreateLaunchProviderForUi, } from '@renderer/utils/geminiUiFreeze'; -import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; -import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; -import { useShallow } from 'zustand/react/shallow'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; +import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, Check, @@ -56,24 +56,25 @@ import { RotateCcw, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; +import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; +import { ProjectPathSelector } from './ProjectPathSelector'; import { createInitialProviderChecks, failIncompleteProviderChecks, - getProvisioningProviderBackendSummary, getProvisioningFailureHint, + getProvisioningProviderBackendSummary, + type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, - type ProvisioningProviderCheck, } from './ProvisioningProviderStatusList'; -import { ProjectPathSelector } from './ProjectPathSelector'; -import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { computeEffectiveTeamModel, formatTeamModelSummary, @@ -160,15 +161,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { } function getProviderLabel(providerId: TeamProviderId): string { - switch (providerId) { - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'anthropic': - default: - return 'Anthropic'; - } + return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } function resolveMemberDraftRuntime( @@ -339,9 +332,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); const runtimeBackendSummaryByProvider = useMemo(() => { - const entries: Array = ( - cliStatus?.providers ?? [] - ).map( + const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( (provider) => [ provider.providerId as TeamProviderId, @@ -643,10 +634,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const runtimeChangeNotes = useMemo(() => { if (!isLaunch) { - return [] as Array<{ key: string; memberName: string; message: string }>; + return [] as { key: string; memberName: string; message: string }[]; } - const notes: Array<{ key: string; memberName: string; message: string }> = []; + const notes: { key: string; memberName: string; message: string }[] = []; const previousLeadModel = previousLaunchParams?.model?.trim() || ''; const previousLeadEffort = previousLaunchParams?.effort; const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel; diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 195a6344..dcf79f8d 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; + import type { TeamProviderId } from '@shared/types'; import type { CliProviderStatus } from '@shared/types'; -import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; @@ -14,15 +16,7 @@ export interface ProvisioningProviderCheck { } export function getProvisioningProviderLabel(providerId: TeamProviderId): string { - switch (providerId) { - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'anthropic': - default: - return 'Anthropic'; - } + return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } export function createInitialProviderChecks( @@ -150,7 +144,7 @@ function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus function getDisplayStatusText(check: ProvisioningProviderCheck): string { const summary = check.details.find(Boolean) - ? summarizeDetail(check.details[0]!, check.status) + ? summarizeDetail(check.details[0], check.status) : null; return summary ?? getStatusLabel(check.status); } @@ -194,7 +188,7 @@ function getStatusColor(status: ProvisioningProviderCheckStatus): string { } } -function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element { +const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element => { if (status === 'checking') { return ; } @@ -205,9 +199,9 @@ function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): Re return ; } return ; -} +}; -export function ProvisioningProviderStatusList({ +export const ProvisioningProviderStatusList = ({ checks, className = '', suppressDetailsMatching, @@ -215,7 +209,7 @@ export function ProvisioningProviderStatusList({ checks: ProvisioningProviderCheck[]; className?: string; suppressDetailsMatching?: string | null; -}): React.JSX.Element | null { +}): React.JSX.Element | null => { if (checks.length === 0) { return null; } @@ -253,7 +247,7 @@ export function ProvisioningProviderStatusList({ })}
); -} +}; export function getProvisioningFailureHint( message: string | null | undefined, diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx index 617f55cb..38003e2f 100644 --- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx +++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx @@ -9,8 +9,8 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types'; diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 402a8236..632f641e 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; + import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index bf911bd5..1834a571 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -20,9 +20,10 @@ import { import { isLeadMember } from '@shared/utils/leadDetection'; import { ExternalLink } from 'lucide-react'; -import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps'; +import { CurrentTaskIndicator } from './CurrentTaskIndicator'; + import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; interface MemberHoverCardProps { diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index f4a98b18..c19f4b10 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -6,12 +6,12 @@ import { getTeamModelLabel, getTeamProviderLabel, } from '@renderer/components/team/dialogs/TeamModelSelector'; -import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { MemberCard } from './MemberCard'; +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, @@ -86,8 +86,7 @@ function areTaskStatusCountsMapsEquivalent( for (const [key, leftCounts] of left) { const rightCounts = right.get(key); if ( - !rightCounts || - leftCounts.pending !== rightCounts.pending || + leftCounts.pending !== rightCounts?.pending || leftCounts.inProgress !== rightCounts.inProgress || leftCounts.completed !== rightCounts.completed ) { @@ -107,8 +106,7 @@ function areMemberTaskMapsEquivalent( for (const [key, leftTask] of left) { const rightTask = right.get(key); if ( - !rightTask || - leftTask.id !== rightTask.id || + leftTask.id !== rightTask?.id || leftTask.displayId !== rightTask.displayId || leftTask.subject !== rightTask.subject || leftTask.owner !== rightTask.owner || @@ -150,8 +148,7 @@ function areMemberSpawnStatusesEquivalent( for (const [key, leftEntry] of left) { const rightEntry = right.get(key); if ( - !rightEntry || - leftEntry.status !== rightEntry.status || + leftEntry.status !== rightEntry?.status || leftEntry.launchState !== rightEntry.launchState || leftEntry.error !== rightEntry.error || leftEntry.livenessSource !== rightEntry.livenessSource || diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 4943d506..316c2c17 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -1,8 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useStore } from '@renderer/store'; -import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; - import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { @@ -11,6 +8,8 @@ import { } from '@renderer/components/team/members/SubagentRecentMessagesPreview'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; +import { useStore } from '@renderer/store'; import { asEnhancedChunkArray } from '@renderer/types/data'; import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { formatDuration } from '@renderer/utils/formatters'; diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 252c3e34..e02a2262 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { MembersEditorSection } from './MembersEditorSection'; import { LeadModelRow } from './LeadModelRow'; +import { MembersEditorSection } from './MembersEditorSection'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 6ab1484d..9fafc7db 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -1,14 +1,14 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; -import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; -import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; +import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isLeadMember } from '@shared/utils/leadDetection'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types'; function isValidMemberName(name: string): boolean { if (name.length < 1 || name.length > 128) return false; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2f2fa1e1..0f039b3a 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -14,7 +14,6 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; @@ -28,6 +27,7 @@ import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { MentionSuggestion } from '@renderer/types/mention'; diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 9716b134..83daddc6 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -6,9 +6,9 @@ import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Filter } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 71ec1294..13729a74 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -9,11 +9,10 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; -import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { createLogger } from '@shared/utils/logger'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; +import { createLogger } from '@shared/utils/logger'; import { CheckCheck, ChevronsDownUp, @@ -24,6 +23,7 @@ import { Search, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ActivityTimeline } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index 6edd3771..bb5eb924 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -17,13 +17,13 @@ export const DISPLAY_STEPS = [ export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length; -export type LaunchJoinMilestones = { +export interface LaunchJoinMilestones { expectedTeammateCount: number; heartbeatConfirmedCount: number; processOnlyAliveCount: number; pendingSpawnCount: number; failedSpawnCount: number; -}; +} type DisplayStepMilestones = LaunchJoinMilestones & { progress: Pick; diff --git a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx index 2566d72c..e26cc6c2 100644 --- a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx +++ b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx @@ -10,8 +10,8 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { CliLogsRichView } from '../CliLogsRichView'; diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index 02204416..b8f44bd4 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -4,7 +4,6 @@ import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { formatNextRun, getCronDescription } from '@renderer/utils/scheduleFormatters'; import { ChevronDown, @@ -17,6 +16,7 @@ import { Trash2, Zap, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog'; diff --git a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx index 2a8f8bbc..c72db17e 100644 --- a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx +++ b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx @@ -2,6 +2,7 @@ import { memo, useState } from 'react'; import { ClaudeLogsSection } from '../ClaudeLogsSection'; import { MessagesPanel } from '../messages/MessagesPanel'; + import type { MouseEventHandler } from 'react'; import type { ComponentProps } from 'react'; diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index bc462313..b6f53552 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -211,23 +211,22 @@ export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): strin export function scaleColorAlpha(color: string, factor: number): string { const safeFactor = Math.max(0, factor); - const rgbaMatch = color.match( - /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i + const rgbaMatch = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec( + color ); if (rgbaMatch) { const [, r, g, b, alpha] = rgbaMatch; return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`; } - const hslaMatch = color.match( - /^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i - ); + const hslaMatch = + /^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i.exec(color); if (hslaMatch) { const [, hue, saturation, lightness, alpha] = hslaMatch; return `hsla(${hue}, ${saturation}, ${lightness}, ${Number(alpha) * safeFactor})`; } - const hexAlphaMatch = color.match(/^#([\da-f]{6})([\da-f]{2})$/i); + const hexAlphaMatch = /^#([\da-f]{6})([\da-f]{2})$/i.exec(color); if (hexAlphaMatch) { const [, hex, alphaHex] = hexAlphaMatch; const alpha = parseInt(alphaHex, 16) / 255; diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 9dd764a0..e172af8b 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -661,6 +661,9 @@ export class TeamGraphAdapter { kind: 'inbox_message', color: '#cc88ff', label, + preview: + getIdleGraphLabel(msg.text ?? '') ?? + TeamGraphAdapter.#buildParticlePreview(msg.summary ?? cleanText), reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse }); continue; @@ -690,6 +693,9 @@ export class TeamGraphAdapter { kind: 'inbox_message', color: msg.color ?? '#66ccff', label: particleLabel, + preview: + getIdleGraphLabel(msgText) ?? + TeamGraphAdapter.#buildParticlePreview(msg.summary ?? msg.text), reverse: isFromTeammate, }); } @@ -783,6 +789,7 @@ export class TeamGraphAdapter { kind: 'task_comment', color: memberColors.get(newComment.author) ?? '#cc88ff', label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'), + preview: TeamGraphAdapter.#buildParticlePreview(newComment.text), }); } } @@ -978,13 +985,7 @@ export class TeamGraphAdapter { kind: 'inbox' | 'comment', max = 52 ): string | undefined { - let normalized = text?.replace(/\s+/g, ' ').trim(); - // Clean up raw task ID hashes like "#363e78de done|sent to review" → "done | sent to review" - if (normalized) { - normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim(); - // Clean pipe separators - normalized = normalized.replace(/\|/g, ' - '); - } + const normalized = TeamGraphAdapter.#normalizeParticleText(text); const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}'; if (!normalized) return prefix; const clipped = @@ -994,6 +995,22 @@ export class TeamGraphAdapter { return `${prefix} ${clipped}`; } + static #buildParticlePreview(text: string | undefined, max = 180): string | undefined { + const normalized = TeamGraphAdapter.#normalizeParticleText(text); + if (!normalized) return undefined; + return normalized.length > max + ? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026` + : normalized; + } + + static #normalizeParticleText(text: string | undefined): string | undefined { + let normalized = text?.replace(/\s+/g, ' ').trim(); + if (!normalized) return normalized; + normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim(); + normalized = normalized.replace(/\|/g, ' - '); + return normalized; + } + static #getMessageParticleKey(msg: InboxMessage): string { if (msg.messageId && msg.messageId.trim().length > 0) { return msg.messageId; diff --git a/src/renderer/hooks/useResizablePanel.ts b/src/renderer/hooks/useResizablePanel.ts index c88cf47b..04f0a0b7 100644 --- a/src/renderer/hooks/useResizablePanel.ts +++ b/src/renderer/hooks/useResizablePanel.ts @@ -13,21 +13,21 @@ const DEFAULT_MAX_WIDTH = 500; const DEFAULT_MIN_HEIGHT = 120; const DEFAULT_MAX_HEIGHT = 520; -type HorizontalResizeOptions = { +interface HorizontalResizeOptions { width: number; onWidthChange: (width: number) => void; minWidth?: number; maxWidth?: number; side: 'left' | 'right'; -}; +} -type VerticalResizeOptions = { +interface VerticalResizeOptions { height: number; onHeightChange: (height: number) => void; minHeight?: number; maxHeight?: number; side: 'top' | 'bottom'; -}; +} type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions; diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index 0c21e6e3..0a6f3218 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; -import { useShallow } from 'zustand/react/shallow'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; +import { useShallow } from 'zustand/react/shallow'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { GlobalTask, TeamTaskWithKanban } from '@shared/types'; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 742e6084..54cf155f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1903,9 +1903,14 @@ export const createTeamSlice: StateCreator = (set, // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; const allTabs = get().getAllPaneTabs(); - const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName); - if (teamTab && teamTab.label !== displayName) { - get().updateTabLabel(teamTab.id, displayName); + const relatedTabs = allTabs.filter( + (tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName + ); + for (const tab of relatedTabs) { + const nextLabel = tab.type === 'graph' ? `${displayName} Graph` : displayName; + if (tab.label !== nextLabel) { + get().updateTabLabel(tab.id, nextLabel); + } } if (opts?.skipProjectAutoSelect) { diff --git a/src/renderer/utils/geminiUiFreeze.ts b/src/renderer/utils/geminiUiFreeze.ts index 52653672..e96e2bf2 100644 --- a/src/renderer/utils/geminiUiFreeze.ts +++ b/src/renderer/utils/geminiUiFreeze.ts @@ -1,5 +1,5 @@ -import type { CliProviderId } from '@shared/types/cliInstaller'; import type { TeamProviderId } from '@shared/types'; +import type { CliProviderId } from '@shared/types/cliInstaller'; export const GEMINI_UI_FROZEN = true; export const GEMINI_UI_DISABLED_REASON = 'Gemini in development'; diff --git a/src/renderer/utils/idleNotificationSemantics.ts b/src/renderer/utils/idleNotificationSemantics.ts index 8941bf08..7590bbb8 100644 --- a/src/renderer/utils/idleNotificationSemantics.ts +++ b/src/renderer/utils/idleNotificationSemantics.ts @@ -7,7 +7,7 @@ import type { IdleNotificationPrimaryKind, } from '@shared/utils/idleNotificationSemantics'; -export type ClassifiedIdleNotification = { +export interface ClassifiedIdleNotification { payload: IdleNotificationPayload; primaryKind: IdleNotificationPrimaryKind; hasPeerSummary: boolean; @@ -15,7 +15,7 @@ export type ClassifiedIdleNotification = { countsAsBootstrapConfirmation: boolean; liveDelivery: 'silent_finalize' | 'passive_activity' | 'visible_actionable'; uiPresentation: 'heartbeat' | 'peer_summary' | 'interrupted' | 'task_terminal' | 'failure'; -}; +} export function classifyIdleNotification( value: string | Pick | Record | IdleNotificationPayload @@ -44,7 +44,7 @@ export function classifyIdleNotification( : shared.primaryKind; return { - ...(shared as SharedClassifiedIdleNotification), + ...shared, liveDelivery, uiPresentation, }; diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 133281c1..292baa03 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -1,10 +1,10 @@ export { + getTeamModelUiDisabledReason, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, - TEAM_MODEL_UI_DISABLED_BADGE_LABEL, - getTeamModelUiDisabledReason, isTeamModelUiDisabled, normalizeTeamModelForUi, + TEAM_MODEL_UI_DISABLED_BADGE_LABEL, } from './teamModelCatalog'; diff --git a/src/renderer/utils/teamRuntimeSummary.ts b/src/renderer/utils/teamRuntimeSummary.ts index 17b0ed8e..e016f1e0 100644 --- a/src/renderer/utils/teamRuntimeSummary.ts +++ b/src/renderer/utils/teamRuntimeSummary.ts @@ -1,10 +1,11 @@ -import type { TeamProviderId } from '@shared/types'; import { doesTeamModelCarryProviderBrand, getTeamModelLabel, getTeamProviderLabel, } from './teamModelCatalog'; +import type { TeamProviderId } from '@shared/types'; + export function getTeamRuntimeModelLabel(model: string | undefined): string | undefined { return getTeamModelLabel(model); } diff --git a/src/shared/utils/idleNotificationSemantics.ts b/src/shared/utils/idleNotificationSemantics.ts index 227c535d..0e61080c 100644 --- a/src/shared/utils/idleNotificationSemantics.ts +++ b/src/shared/utils/idleNotificationSemantics.ts @@ -1,6 +1,6 @@ import { isInboxNoiseMessage, parseInboxJson } from './inboxNoise'; -export type IdleNotificationPayload = { +export interface IdleNotificationPayload { type: 'idle_notification'; from?: string; timestamp?: string; @@ -9,17 +9,17 @@ export type IdleNotificationPayload = { completedTaskId?: string; completedStatus?: 'resolved' | 'blocked' | 'failed'; failureReason?: string; -}; +} export type IdleNotificationPrimaryKind = 'heartbeat' | 'interrupted' | 'task_terminal' | 'failure'; -export type ClassifiedIdleNotification = { +export interface ClassifiedIdleNotification { payload: IdleNotificationPayload; primaryKind: IdleNotificationPrimaryKind; hasPeerSummary: boolean; peerSummary: string | null; countsAsBootstrapConfirmation: boolean; -}; +} function getTrimmedOptionalString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index 36c2cdaa..adb65511 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -82,7 +82,7 @@ export interface ParsedPermissionRequest { */ export function parsePermissionRequest(text: string): ParsedPermissionRequest | null { const parsed = parseInboxJson(text); - if (!parsed || parsed.type !== 'permission_request') return null; + if (parsed?.type !== 'permission_request') return null; const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null; const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null; diff --git a/src/shared/utils/taskChangeSince.ts b/src/shared/utils/taskChangeSince.ts index b21b92ac..aaff4948 100644 --- a/src/shared/utils/taskChangeSince.ts +++ b/src/shared/utils/taskChangeSince.ts @@ -1,12 +1,12 @@ const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; -type TaskChangeIntervalLike = { +interface TaskChangeIntervalLike { startedAt?: string | null; -}; +} -type TaskChangeHistoryEventLike = { +interface TaskChangeHistoryEventLike { timestamp?: string | null; -}; +} export interface TaskChangeSinceLike< TInterval extends TaskChangeIntervalLike = TaskChangeIntervalLike, diff --git a/test/fixtures/team/board-task-activity-message-v1.json b/test/fixtures/team/board-task-activity-message-v1.json new file mode 100644 index 00000000..8d627687 --- /dev/null +++ b/test/fixtures/team/board-task-activity-message-v1.json @@ -0,0 +1,48 @@ +{ + "uuid": "message-1", + "timestamp": "2026-04-12T10:00:00.000Z", + "sessionId": "session-1", + "boardTaskLinks": [ + { + "schemaVersion": 1, + "toolUseId": "tool-1", + "task": { + "ref": "abcd1234", + "refKind": "display", + "canonicalId": "123e4567-e89b-12d3-a456-426614174000" + }, + "targetRole": "subject", + "linkKind": "lifecycle", + "taskArgumentSlot": "taskId", + "actorContext": { + "relation": "idle" + } + }, + { + "schemaVersion": 1, + "task": { + "ref": "", + "refKind": "display" + }, + "targetRole": "subject", + "linkKind": "lifecycle", + "actorContext": { + "relation": "idle" + } + } + ], + "boardTaskToolActions": [ + { + "schemaVersion": 1, + "toolUseId": "tool-1", + "canonicalToolName": "task_add_comment", + "resultRefs": { + "commentId": "comment-1" + } + }, + { + "schemaVersion": 1, + "canonicalToolName": "task_add_comment" + } + ] +} diff --git a/test/renderer/features/agent-graph/transientHandoffs.test.ts b/test/renderer/features/agent-graph/transientHandoffs.test.ts new file mode 100644 index 00000000..1d5fd771 --- /dev/null +++ b/test/renderer/features/agent-graph/transientHandoffs.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphEdge, GraphNode, GraphParticle } from '@claude-teams/agent-graph'; + +import { + createTransientHandoffState, + selectRenderableTransientHandoffCards, + updateTransientHandoffState, +} from '../../../../packages/agent-graph/src/ui/transientHandoffs'; + +const leadNode: GraphNode = { + id: 'lead:team-a', + kind: 'lead', + label: 'team-a', + state: 'active', + x: 0, + y: 0, + domainRef: { kind: 'lead', teamName: 'team-a', memberName: 'team-lead' }, +}; + +const aliceNode: GraphNode = { + id: 'member:team-a:alice', + kind: 'member', + label: 'alice', + state: 'active', + x: 100, + y: 0, + domainRef: { kind: 'member', teamName: 'team-a', memberName: 'alice' }, +}; + +const taskNode: GraphNode = { + id: 'task:team-a:42', + kind: 'task', + label: '#42', + sublabel: 'Fix queue', + state: 'active', + x: 200, + y: 100, + domainRef: { kind: 'task', teamName: 'team-a', taskId: '42' }, +}; + +const nodeMap = new Map([ + [leadNode.id, leadNode], + [aliceNode.id, aliceNode], + [taskNode.id, taskNode], +]); + +const edgeMap = new Map([ + [ + 'edge:lead:alice', + { + id: 'edge:lead:alice', + source: leadNode.id, + target: aliceNode.id, + type: 'parent-child', + }, + ], + [ + 'edge:alice:task', + { + id: 'edge:alice:task', + source: aliceNode.id, + target: taskNode.id, + type: 'message', + }, + ], +]); + +function makeParticle(overrides?: Partial): GraphParticle { + return { + id: 'particle-1', + edgeId: 'edge:lead:alice', + progress: 0.7, + kind: 'inbox_message', + color: '#66ccff', + label: '✉ Ship the patch after green CI', + preview: 'Ship the patch after green CI and send the changelog', + reverse: true, + ...overrides, + }; +} + +describe('transient handoff cards', () => { + it('creates one readable handoff card when a particle reaches the recipient zone', () => { + const state = createTransientHandoffState(); + + updateTransientHandoffState(state, { + particles: [makeParticle()], + edgeMap, + nodeMap, + time: 10, + }); + + const cards = selectRenderableTransientHandoffCards(state); + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + edgeId: 'edge:lead:alice', + sourceNodeId: aliceNode.id, + destinationNodeId: leadNode.id, + kind: 'inbox_message', + count: 1, + preview: 'Ship the patch after green CI and send the changelog', + }); + }); + + it('aggregates repeated sends on the same edge and keeps the latest preview', () => { + const state = createTransientHandoffState(); + + updateTransientHandoffState(state, { + particles: [makeParticle({ id: 'particle-1' })], + edgeMap, + nodeMap, + time: 20, + }); + + updateTransientHandoffState(state, { + particles: [ + makeParticle({ + id: 'particle-2', + label: '✉ Follow-up with the release note diff', + preview: 'Follow-up with the release note diff and deployment checklist', + }), + ], + edgeMap, + nodeMap, + time: 21, + }); + + const cards = selectRenderableTransientHandoffCards(state); + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + count: 2, + preview: 'Follow-up with the release note diff and deployment checklist', + }); + }); + + it('expires old cards and caps renderables per destination', () => { + const state = createTransientHandoffState(); + + updateTransientHandoffState(state, { + particles: [ + makeParticle({ id: 'comment-1', edgeId: 'edge:alice:task', kind: 'task_comment', reverse: false }), + makeParticle({ id: 'comment-2', edgeId: 'edge:alice:task', kind: 'task_comment', reverse: false }), + makeParticle({ id: 'comment-3', edgeId: 'edge:alice:task', kind: 'review_request', reverse: false }), + ], + edgeMap, + nodeMap, + time: 30, + }); + + const cards = selectRenderableTransientHandoffCards(state); + expect(cards).toHaveLength(2); + expect(new Set(cards.map((card) => card.kind))).toEqual( + new Set(['task_comment', 'review_request']) + ); + + updateTransientHandoffState(state, { + particles: [], + edgeMap, + nodeMap, + time: 34, + }); + + expect(selectRenderableTransientHandoffCards(state)).toHaveLength(0); + }); + + it('does not create a card for generic idle inbox noise', () => { + const state = createTransientHandoffState(); + + updateTransientHandoffState(state, { + particles: [ + makeParticle({ + id: 'idle-1', + label: 'idle', + preview: 'idle', + }), + ], + edgeMap, + nodeMap, + time: 40, + }); + + expect(selectRenderableTransientHandoffCards(state)).toHaveLength(0); + }); +}); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 8a352043..c0efa73a 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -78,6 +78,7 @@ function createSliceStore() { }, openTab: vi.fn(), setActiveTab: vi.fn(), + updateTabLabel: vi.fn(), getAllPaneTabs: vi.fn(() => []), warmTaskChangeSummaries: vi.fn(async () => undefined), invalidateTaskChangePresence: vi.fn(), @@ -220,6 +221,35 @@ describe('teamSlice actions', () => { expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); }); + it('syncs both team and graph tab labels when the team display name changes', async () => { + const store = createSliceStore(); + const getAllPaneTabs = vi.fn(() => [ + { id: 'team-tab', type: 'team', teamName: 'my-team', label: 'my-team' }, + { id: 'graph-tab', type: 'graph', teamName: 'my-team', label: 'my-team Graph' }, + ]); + const updateTabLabel = vi.fn(); + + store.setState({ + getAllPaneTabs, + updateTabLabel, + }); + + hoisted.getData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'Northstar', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + await store.getState().selectTeam('my-team'); + + expect(updateTabLabel).toHaveBeenCalledWith('team-tab', 'Northstar'); + expect(updateTabLabel).toHaveBeenCalledWith('graph-tab', 'Northstar Graph'); + }); + it('removes non-selected team cache entries on permanent delete', async () => { const store = createSliceStore(); store.setState({ From cc45549716120a9672ba4da9894fbc01a406c1c0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 22:17:18 +0300 Subject: [PATCH 06/21] feat(messages): add bottom sheet panel mode --- package.json | 4 +- pnpm-lock.yaml | 104 +- .../components/layout/TeamTabSectionNav.tsx | 13 +- .../components/team/TeamDetailView.tsx | 1550 +++++++++-------- .../team/messages/MessageComposer.tsx | 76 +- .../team/messages/MessagesPanel.tsx | 507 +++++- .../components/team/messages/StatusBlock.tsx | 41 +- .../team/sidebar/teamSidebarUiState.ts | 10 +- .../components/ui/MentionableTextarea.tsx | 161 +- src/renderer/index.css | 101 ++ src/renderer/store/slices/teamSlice.ts | 22 +- src/renderer/types/teamMessagesPanelMode.ts | 1 + .../team/messages/MessagesPanel.test.ts | 59 +- 13 files changed, 1753 insertions(+), 896 deletions(-) create mode 100644 src/renderer/types/teamMessagesPanelMode.ts diff --git a/package.json b/package.json index e5defa4b..9c9c5cdc 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ ] }, "dependencies": { + "@claude-teams/agent-graph": "workspace:*", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-cpp": "^6.0.3", @@ -121,7 +122,6 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "agent-teams-controller": "workspace:*", - "@claude-teams/agent-graph": "workspace:*", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -139,12 +139,14 @@ "lucide-react": "^0.577.0", "mdast-util-to-hast": "^13.2.1", "mermaid": "^11.12.3", + "motion": "12.38.0", "node-diff3": "^3.2.0", "node-pty": "^1.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-grid-layout": "^2.2.2", "react-markdown": "^10.1.0", + "react-modal-sheet": "5.6.0", "react-resizable": "^3.1.3", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 972d9968..53f11ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: mermaid: specifier: ^11.12.3 version: 11.12.3 + motion: + specifier: 12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) node-diff3: specifier: ^3.2.0 version: 3.2.0 @@ -248,6 +251,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + react-modal-sheet: + specifier: 5.6.0 + version: 5.6.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-resizable: specifier: ^3.1.3 version: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4796,6 +4802,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -6755,6 +6762,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -8128,6 +8149,26 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -9098,6 +9139,13 @@ packages: '@types/react': '>=18' react: '>=18' + react-modal-sheet@5.6.0: + resolution: {integrity: sha512-+WE2nVPdB/Jx0QbndIBqGvy6k0IXriW2lFaPeZSW1xOVri6rWhAwrSnArtsR1rxOxW8HBdAYeIPUcbjMvNeeyw==} + engines: {node: '>=20'} + peerDependencies: + motion: '>=11' + react: '>=16' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -9138,6 +9186,15 @@ packages: '@types/react': optional: true + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -15343,14 +15400,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0) - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))': dependencies: '@vitest/spy': 3.2.4 @@ -18173,6 +18222,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@0.5.2: {} fresh@2.0.0: {} @@ -19882,6 +19940,20 @@ snapshots: module-details-from-path@1.0.4: {} + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + mrmime@2.0.1: {} ms@2.1.3: {} @@ -21137,6 +21209,14 @@ snapshots: transitivePeerDependencies: - supports-color + react-modal-sheet@5.6.0(motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + transitivePeerDependencies: + - react-dom + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -21173,6 +21253,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -22817,7 +22903,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.15)(sass@1.98.0)(terser@5.46.0)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index e3466adf..2ba585ff 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -29,10 +29,15 @@ export const TeamTabSectionNav = ({ const buttonRef = useRef(null); const menuRef = useRef(null); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); - const visibleSections = SECTIONS.filter( - (section) => - messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs') - ); + const visibleSections = SECTIONS.filter((section) => { + if (messagesPanelMode === 'sidebar') { + return section.id !== 'messages' && section.id !== 'claude-logs'; + } + if (messagesPanelMode === 'bottom-sheet') { + return section.id !== 'messages'; + } + return true; + }); const handleNavigate = useCallback( (sectionId: string) => { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 090b1ad3..0bb4d50c 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,5 +1,4 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ComponentProps } from 'react'; import { api } from '@renderer/api'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; @@ -36,8 +35,8 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, @@ -73,6 +72,8 @@ import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; +import type { ComponentProps } from 'react'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) @@ -92,13 +93,13 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; +import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { isLeadSessionMissing, shouldSuppressMissingLeadSessionFetch, } from './teamSessionFetchGuards'; -import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; -import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; @@ -804,6 +805,9 @@ export const TeamDetailView = ({ const [editorOpen, setEditorOpen] = useState(false); const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); const pendingReplyRefreshTimerRef = useRef(null); @@ -1159,9 +1163,12 @@ export const TeamDetailView = ({ side: 'top', }); - const toggleMessagesPanelMode = useCallback(() => { - setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar'); - }, [messagesPanelMode, setMessagesPanelMode]); + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); useEffect(() => { if (tabId) { @@ -1723,7 +1730,8 @@ export const TeamDetailView = ({ const sharedMessagesPanelProps = useMemo( () => ({ teamName, - onTogglePosition: toggleMessagesPanelMode, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, members: activeMembers, tasks: data?.tasks ?? [], messages: data?.messages ?? [], @@ -1752,11 +1760,12 @@ export const TeamDetailView = ({ handleRestartTeam, handleSelectMember, handleTaskIdClick, + messagesPanelMountPoint, pendingRepliesByMember, teamName, teamSessionIds, timeWindow, - toggleMessagesPanelMode, + changeMessagesPanelMode, ] ); @@ -1925,431 +1934,763 @@ export const TeamDetailView = ({ -
-
- {headerColorSet ? ( +
+
+
+ {headerColorSet ? ( +
+ ) : null}
- ) : null} -
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} + className={cn( + 'flex items-start justify-between gap-2', + headerColorSet && 'relative z-10' + )} + > +
+
+

+ {data.config.name} +

+ {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
-
-
- {data.isAlive && ( +
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + Edit team + - Stop team + Delete team - )} - - - - - Edit team - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} - {(data.config.projectPath || leadBranch) && ( -
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- )} - {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
- - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - -
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - setLaunchDialogOpen(true)} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. -
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
- + + Open project in built-in editor + - - Graph - + )} + {leadBranch && ( + + + {leadBranch} + + )} +
+ )} + {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()} +
+ + {!data.isAlive && !isTeamProvisioning ? ( + setLaunchDialogOpen(true)} + /> + ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
+ + +
+ } + > + +
+ + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + contentClassName="overflow-x-visible" + action={ -
- } - > - - - - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - contentClassName="overflow-x-visible" - action={ - - } - > - } - 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()}` + > + + } + 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 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.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); + } catch { + // best-effort } - } catch { - // best-effort } + } catch { + // error via store } - } 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 } + ); } - })(); - }} - 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'); + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + - // 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 - } - } + } + defaultOpen={false} + > + + - // 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) => { + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // error state is handled in the store and shown in the view } })(); }} + /> + + setSelectedMember(null)} + onSendMessage={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + openCreateTaskDialog('', '', name); + }} + onTaskClick={(task) => { + setSelectedMember(null); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + setSelectedMember(null); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + + + !isLeadMember(m))} + isTeamAlive={data.isAlive && !isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + 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, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); + } + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -2363,389 +2704,66 @@ export const TeamDetailView = ({ ); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - 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 - } - })(); - }} - /> - - setSelectedMember(null)} - onSendMessage={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - openCreateTaskDialog('', '', name); - }} - onTaskClick={(task) => { - setSelectedMember(null); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - setSelectedMember(null); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - !isLeadMember(m))} - isTeamAlive={data.isAlive && !isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - 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, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All - team data and tasks will be deleted. - - - - - - - - - - setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} - /> - - { - void (async () => { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - } catch { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - })(); - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} - /> - - 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 } - ); + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) } - }} - 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 - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open - ? {} - : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} - projectPath={data.config.projectPath} - onEditorAction={handleEditorAction} + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> +
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2f2fa1e1..1776a26a 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -41,6 +41,7 @@ import type { interface MessageComposerProps { teamName: string; members: ResolvedTeamMember[]; + layout?: 'default' | 'compact'; isTeamAlive?: boolean; sending: boolean; sendError: string | null; @@ -67,6 +68,7 @@ interface MessageComposerProps { export const MessageComposer = ({ teamName, members, + layout = 'default', isTeamAlive, sending, sendError, @@ -443,10 +445,27 @@ export const MessageComposer = ({ const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); + const isCompactLayout = layout === 'compact'; + const compactFooterNotice = slashCommandRestrictionReason ? ( + + + {slashCommandRestrictionReason} + + ) : sendError ? ( + + + {sendError} + + ) : lastResult?.deduplicated ? ( + + + Reused recent cross-team request + + ) : null; return (
-
+
{isLeadRecipient ? ( <> @@ -807,11 +826,17 @@ export const MessageComposer = ({ onShiftTab={handleCycleActionMode} dismissMentionsRef={dismissMentionsRef} extraTips={['Tip: You can use "/" to run any Claude commands.']} - minRows={2} + surfaceClassName="message-composer-shell message-composer-orbit-surface border border-transparent bg-[var(--color-surface-raised)] shadow-[0_8px_24px_rgba(0,0,0,0.18),inset_0_1px_0_rgba(255,255,255,0.03)]" + surfaceDecoration="orbit-border" + surfaceFadeColor="var(--color-surface-raised)" + className="border-transparent shadow-none" + minRows={isCompactLayout ? 1 : 2} maxRows={6} maxLength={MAX_TEXT_LENGTH} disabled={sending} hintText={crossTeamHintText} + showHint={!isCompactLayout} + cornerActionInset={isCompactLayout ? 'compact' : 'default'} cornerActionLeft={ } footerRight={ -
- {slashCommandRestrictionReason ? ( - - - {slashCommandRestrictionReason} - - ) : sendError ? ( - - - {sendError} - - ) : lastResult?.deduplicated ? ( - - - Reused recent cross-team request - - ) : null} - {remaining < 200 ? ( - - {remaining} chars left - - ) : null} - {draft.isSaved ? ( - Saved - ) : null} -
+ isCompactLayout ? ( + compactFooterNotice + ) : ( +
+ {compactFooterNotice} + {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {draft.isSaved ? ( + Saved + ) : null} +
+ ) } />
diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 71ec1294..f2c4e44b 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Sheet, type SheetRef } from 'react-modal-sheet'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; @@ -9,21 +10,24 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; -import { useShallow } from 'zustand/react/shallow'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { createLogger } from '@shared/utils/logger'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; +import { createLogger } from '@shared/utils/logger'; import { CheckCheck, ChevronsDownUp, ChevronsUpDown, MessageSquare, + PanelBottom, + PanelBottomClose, + PanelBottomOpen, PanelLeft, PanelLeftClose, Search, X, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { ActivityTimeline } from '../activity/ActivityTimeline'; import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; @@ -41,6 +45,7 @@ import { StatusBlock } from './StatusBlock'; import type { TimelineItem } from '../activity/LeadThoughtsGroup'; import type { ActionMode } from './ActionModeSelector'; import type { MessagesFilterState } from './MessagesFilterPopover'; +import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { @@ -51,11 +56,16 @@ interface TimeWindow { const logger = createLogger('Component:MessagesPanel'); const MESSAGES_PANEL_FILTER_WARN_MS = 8; const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6; +const BOTTOM_SHEET_HEADER_HEIGHT = 40; +const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1; +const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2; +const BOTTOM_SHEET_FULL_SNAP_INDEX = 4; interface MessagesPanelProps { teamName: string; - position: 'sidebar' | 'inline'; - onTogglePosition: () => void; + position: TeamMessagesPanelMode; + onPositionChange: (position: TeamMessagesPanelMode) => void; + mountPoint?: Element | null; /** Active (non-removed) members. */ members: ResolvedTeamMember[]; /** All team tasks. */ @@ -95,7 +105,8 @@ interface MessagesPanelProps { export const MessagesPanel = memo(function MessagesPanel({ teamName, position, - onTogglePosition, + onPositionChange, + mountPoint, members, tasks, messages, @@ -207,6 +218,8 @@ export const MessagesPanel = memo(function MessagesPanel({ const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); + const bottomSheetRef = useRef(null); + const bottomSheetStickyTopRef = useRef(null); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); @@ -224,15 +237,20 @@ export const MessagesPanel = memo(function MessagesPanel({ const [messagesCollapsed, setMessagesCollapsed] = useState( initialSidebarStateRef.current.messagesCollapsed ); - const [sidebarSearchVisible, setSidebarSearchVisible] = useState( - initialSidebarStateRef.current.sidebarSearchVisible + const [messagesSearchBarVisible, setMessagesSearchBarVisible] = useState( + initialSidebarStateRef.current.messagesSearchBarVisible ); const [expandedItemKey, setExpandedItemKey] = useState( initialSidebarStateRef.current.expandedItemKey ); - const [sidebarScrollTop, setSidebarScrollTop] = useState( - initialSidebarStateRef.current.sidebarScrollTop + const [messagesScrollTop, setMessagesScrollTop] = useState( + initialSidebarStateRef.current.messagesScrollTop ); + const [bottomSheetSnapIndex, setBottomSheetSnapIndex] = useState( + initialSidebarStateRef.current.bottomSheetSnapIndex + ); + const [bottomSheetStickyTopHeight, setBottomSheetStickyTopHeight] = useState(196); + const [bottomSheetMountHeight, setBottomSheetMountHeight] = useState(0); useEffect(() => { initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); @@ -240,9 +258,10 @@ export const MessagesPanel = memo(function MessagesPanel({ setMessagesFilter(initialSidebarStateRef.current.messagesFilter); setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); - setSidebarSearchVisible(initialSidebarStateRef.current.sidebarSearchVisible); + setMessagesSearchBarVisible(initialSidebarStateRef.current.messagesSearchBarVisible); setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); - setSidebarScrollTop(initialSidebarStateRef.current.sidebarScrollTop); + setMessagesScrollTop(initialSidebarStateRef.current.messagesScrollTop); + setBottomSheetSnapIndex(initialSidebarStateRef.current.bottomSheetSnapIndex); }, [teamName]); useEffect(() => { @@ -251,9 +270,10 @@ export const MessagesPanel = memo(function MessagesPanel({ messagesFilter, messagesFilterOpen, messagesCollapsed, - sidebarSearchVisible, + messagesSearchBarVisible, expandedItemKey, - sidebarScrollTop, + messagesScrollTop, + bottomSheetSnapIndex, }); }, [ teamName, @@ -261,17 +281,52 @@ export const MessagesPanel = memo(function MessagesPanel({ messagesFilter, messagesFilterOpen, messagesCollapsed, - sidebarSearchVisible, + messagesSearchBarVisible, expandedItemKey, - sidebarScrollTop, + messagesScrollTop, + bottomSheetSnapIndex, ]); useLayoutEffect(() => { if (position !== 'sidebar') return; const el = sidebarScrollRef.current; if (!el) return; - el.scrollTop = sidebarScrollTop; - }, [position, sidebarScrollTop]); + el.scrollTop = messagesScrollTop; + }, [position, messagesScrollTop]); + + useLayoutEffect(() => { + if (position !== 'bottom-sheet' || typeof ResizeObserver === 'undefined') return; + + const mountPointElement = mountPoint instanceof HTMLElement ? mountPoint : null; + const observedEntries: Array<[Element | null, (height: number) => void]> = [ + [bottomSheetStickyTopRef.current, setBottomSheetStickyTopHeight], + [mountPointElement, setBottomSheetMountHeight], + ]; + const observers: ResizeObserver[] = []; + + for (const [element, setHeight] of observedEntries) { + if (!element) continue; + + const updateHeight = () => { + const nextHeight = Math.ceil(element.getBoundingClientRect().height); + if (nextHeight > 0) { + setHeight(nextHeight); + } + }; + + updateHeight(); + + const observer = new ResizeObserver(() => { + updateHeight(); + }); + observer.observe(element); + observers.push(observer); + } + + return () => { + observers.forEach((observer) => observer.disconnect()); + }; + }, [position, mountPoint]); const filteredMessages = useMemo(() => { const startedAt = performance.now(); @@ -348,7 +403,7 @@ export const MessagesPanel = memo(function MessagesPanel({ ); } return result; - }, [expandedItemKey, activityTimelineMessages]); + }, [expandedItemKey, activityTimelineMessages, teamName]); // Auto-clear stale expanded key useEffect(() => { @@ -461,6 +516,60 @@ export const MessagesPanel = memo(function MessagesPanel({ [teamName, sendCrossTeamMessage] ); + const moveToInline = useCallback(() => { + onPositionChange('inline'); + }, [onPositionChange]); + + const moveToSidebar = useCallback(() => { + onPositionChange('sidebar'); + }, [onPositionChange]); + + const moveToBottomSheet = useCallback(() => { + setBottomSheetSnapIndex(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); + onPositionChange('bottom-sheet'); + }, [onPositionChange]); + + const snapBottomSheetTo = useCallback((snapIndex: number) => { + setBottomSheetSnapIndex(snapIndex); + bottomSheetRef.current?.snapTo(snapIndex); + }, []); + + const toggleBottomSheetExpansion = useCallback(() => { + if (bottomSheetSnapIndex === BOTTOM_SHEET_COLLAPSED_SNAP_INDEX) { + snapBottomSheetTo(BOTTOM_SHEET_COMPOSER_SNAP_INDEX); + return; + } + snapBottomSheetTo(BOTTOM_SHEET_COLLAPSED_SNAP_INDEX); + }, [bottomSheetSnapIndex, snapBottomSheetTo]); + + const bottomSheetSnapPoints = useMemo(() => { + const maxOpenHeight = + bottomSheetMountHeight > 0 + ? Math.max(bottomSheetMountHeight - 1, 96) + : Number.POSITIVE_INFINITY; + const collapsedHeight = Math.min(BOTTOM_SHEET_HEADER_HEIGHT, maxOpenHeight); + const composerHeight = Math.min( + Math.max(collapsedHeight + bottomSheetStickyTopHeight, collapsedHeight + 120), + maxOpenHeight + ); + const centeredHeight = Math.min( + Math.max( + bottomSheetMountHeight > 0 ? Math.round(bottomSheetMountHeight * 0.58) : 520, + composerHeight + 140 + ), + maxOpenHeight + ); + + return [0, collapsedHeight, composerHeight, centeredHeight, 1]; + }, [bottomSheetMountHeight, bottomSheetStickyTopHeight]); + + const normalizedBottomSheetSnapIndex = useMemo(() => { + return Math.min( + Math.max(bottomSheetSnapIndex, BOTTOM_SHEET_COLLAPSED_SNAP_INDEX), + BOTTOM_SHEET_FULL_SNAP_INDEX + ); + }, [bottomSheetSnapIndex]); + // ---- Shared content (used in both modes) ---- const searchAndFilterControls = (
@@ -602,9 +711,9 @@ export const MessagesPanel = memo(function MessagesPanel({ // ---- Sidebar mode ---- if (position === 'sidebar') { return ( -
+
{/* Header */} -
+
Messages {filteredMessages.length > 0 && ( @@ -650,6 +759,7 @@ export const MessagesPanel = memo(function MessagesPanel({ size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" onClick={() => setMessagesCollapsed((v) => !v)} + aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} > {messagesCollapsed ? : } @@ -664,13 +774,16 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" - onClick={() => setSidebarSearchVisible((v) => !v)} + onClick={() => setMessagesSearchBarVisible((v) => !v)} + aria-label={ + messagesSearchBarVisible ? 'Hide message search' : 'Show message search' + } > - {sidebarSearchVisible ? : } + {messagesSearchBarVisible ? : } - {sidebarSearchVisible ? 'Hide search' : 'Search messages'} + {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} @@ -679,7 +792,8 @@ export const MessagesPanel = memo(function MessagesPanel({ variant="ghost" size="sm" className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" - onClick={onTogglePosition} + onClick={moveToInline} + aria-label="Move messages to inline panel" > @@ -689,7 +803,7 @@ export const MessagesPanel = memo(function MessagesPanel({
{/* Search & filter bar (toggleable) */} - {sidebarSearchVisible && ( + {messagesSearchBarVisible && (
{searchAndFilterControls}
@@ -698,7 +812,7 @@ export const MessagesPanel = memo(function MessagesPanel({
setSidebarScrollTop(e.currentTarget.scrollTop)} + onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)} >