From b7fa5443fda3c3c63a5ec4d17fc2873b7ba00c20 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 23:15:27 +0300 Subject: [PATCH] feat(team): show live task log activity --- .../services/team/TeamLogSourceTracker.ts | 156 +++++++++++--- .../services/team/TeamMemberLogsFinder.ts | 35 ++- .../services/team/TeamProvisioningService.ts | 1 + .../components/team/TeamDetailView.tsx | 3 + .../components/team/kanban/KanbanBoard.tsx | 8 + .../team/kanban/KanbanTaskCard.test.tsx | 43 ++++ .../components/team/kanban/KanbanTaskCard.tsx | 13 +- .../team/taskLogs/TaskLogStreamSection.tsx | 3 +- .../team/taskLogs/TaskLogsPanel.tsx | 3 +- src/renderer/store/index.ts | 162 ++++++++++++++ src/renderer/store/slices/teamSlice.ts | 7 + src/renderer/utils/teamChangeEvents.ts | 18 ++ src/shared/types/team.ts | 2 + .../team/TeamLogSourceTracker.test.ts | 167 +++++++++++++- .../team/TeamMemberLogsFinder.test.ts | 15 +- .../taskLogs/TaskLogStreamSection.test.ts | 41 +++- .../team/taskLogs/TaskLogsPanel.test.ts | 48 ++++- .../renderer/store/teamChangeThrottle.test.ts | 204 +++++++++++++++++- 18 files changed, 880 insertions(+), 49 deletions(-) create mode 100644 src/renderer/utils/teamChangeEvents.ts diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 6435907f..01d531bc 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -69,10 +69,22 @@ type DecodedFreshnessTaskId = | { kind: 'opaque-safe-segment' } | { kind: 'invalid' }; +type TaskFreshnessSignalKind = NonNullable; + function isOpaqueSafeTaskIdSegment(segment: string): boolean { return /^task-id-[0-9a-f]{32}$/.test(segment); } +function pushUniqueNormalizedPath(paths: string[], candidate: string | undefined): void { + if (!candidate || !path.isAbsolute(candidate)) { + return; + } + const normalized = path.normalize(candidate); + if (!paths.some((existing) => path.normalize(existing) === normalized)) { + paths.push(normalized); + } +} + export function shouldIgnoreLogSourceWatcherPath( projectDir: string, watchedPath: string, @@ -368,14 +380,20 @@ export class TeamLogSourceTracker { return; } - await this.ensureLogSourceFreshnessDirs(context.projectDir).catch((error) => { + const taskFreshnessRootDirs = this.getTaskFreshnessRootDirs(context); + const taskFreshnessWatchRootDirs = await this.ensureLogSourceFreshnessDirs( + context.projectDir, + taskFreshnessRootDirs + ).catch((error) => { logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`); + return [path.normalize(context.projectDir)]; }); const { targets, scopedSessionIds } = await this.buildScopedWatchTargets( context.projectDir, context.watchSessionIds, - this.getPendingUnknownSessionIds(state) + this.getPendingUnknownSessionIds(state), + taskFreshnessWatchRootDirs ); if (!this.isTrackingCurrent(teamName, expectedVersion)) { return; @@ -411,6 +429,18 @@ export class TeamLogSourceTracker { ) { return; } + const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext); + pushUniqueNormalizedPath(eventTaskFreshnessRootDirs, current.projectDir); + if ( + this.handleTaskFreshnessSignalChangeForRoots( + teamName, + changedPath, + eventTaskFreshnessRootDirs + ) + ) { + return; + } + const action = classifyLogSourceWatcherEvent({ projectDir: current.projectDir, changedPath, @@ -420,21 +450,6 @@ export class TeamLogSourceTracker { }); if (action.kind === 'task-freshness') { - if ( - !this.handleTaskFreshnessSignalChange( - teamName, - current.projectDir, - changedPath, - BOARD_TASK_LOG_FRESHNESS_DIRNAME - ) - ) { - this.handleTaskFreshnessSignalChange( - teamName, - current.projectDir, - changedPath, - BOARD_TASK_CHANGE_FRESHNESS_DIRNAME - ); - } return; } @@ -458,24 +473,74 @@ export class TeamLogSourceTracker { }); } - private async ensureLogSourceFreshnessDirs(projectDir: string): Promise { + private getTaskFreshnessRootDirs(context: TeamLogSourceLiveContext | null): string[] { + const roots: string[] = []; + pushUniqueNormalizedPath(roots, context?.projectDir); + pushUniqueNormalizedPath(roots, context?.projectPath); + for (const rootDir of context?.taskFreshnessRootDirs ?? []) { + pushUniqueNormalizedPath(roots, rootDir); + } + return roots; + } + + private async ensureLogSourceFreshnessDirs( + transcriptProjectDir: string, + projectDirs: readonly string[] + ): Promise { + const watchRootDirs: string[] = []; + const normalizedTranscriptProjectDir = path.normalize(transcriptProjectDir); + pushUniqueNormalizedPath(watchRootDirs, normalizedTranscriptProjectDir); + await Promise.all([ - fs.mkdir(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { recursive: true }), - fs.mkdir(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true }), + fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { + recursive: true, + }), + fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { + recursive: true, + }), ]); + + await Promise.all( + projectDirs.map(async (projectDir) => { + try { + const normalizedProjectDir = path.normalize(projectDir); + if (normalizedProjectDir === normalizedTranscriptProjectDir) { + return; + } + if (!(await this.isDirectory(normalizedProjectDir))) { + return; + } + await Promise.all([ + fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { + recursive: true, + }), + fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { + recursive: true, + }), + ]); + pushUniqueNormalizedPath(watchRootDirs, normalizedProjectDir); + } catch (error) { + logger.debug(`Failed to ensure task freshness dirs in ${projectDir}: ${String(error)}`); + } + }) + ); + return watchRootDirs; } private async buildScopedWatchTargets( projectDir: string, confirmedSessionIds: readonly string[], - pendingRootSessionIds: readonly string[] + pendingRootSessionIds: readonly string[], + taskFreshnessRootDirs: readonly string[] = [projectDir] ): Promise<{ targets: string[]; scopedSessionIds: Set }> { const targets = new Set(); const scopedSessionIds = new Set(); targets.add(projectDir); - targets.add(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME)); - targets.add(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME)); + for (const freshnessRootDir of taskFreshnessRootDirs) { + targets.add(path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME)); + targets.add(path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME)); + } for (const rawSessionId of confirmedSessionIds) { const sessionId = normalizeLogSourceSessionId(rawSessionId); @@ -664,11 +729,10 @@ export class TeamLogSourceTracker { private handleTaskFreshnessSignalChange( teamName: string, - projectDir: string, changedPath: string, - signalDirName: string + signalDir: string, + taskSignalKind: TaskFreshnessSignalKind ): boolean { - const signalDir = path.join(projectDir, signalDirName); const relativePath = path.relative(signalDir, changedPath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return path.normalize(changedPath) === path.normalize(signalDir); @@ -687,7 +751,7 @@ export class TeamLogSourceTracker { return true; } if (decoded.kind === 'opaque-safe-segment') { - void this.emitTaskFreshnessSignalFromFile(teamName, changedPath); + void this.emitTaskFreshnessSignalFromFile(teamName, changedPath, taskSignalKind); return true; } @@ -695,6 +759,7 @@ export class TeamLogSourceTracker { type: 'task-log-change', teamName, taskId: decoded.taskId, + taskSignalKind, }); return true; } @@ -720,7 +785,11 @@ export class TeamLogSourceTracker { } } - private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise { + private async emitTaskFreshnessSignalFromFile( + teamName: string, + filePath: string, + taskSignalKind: TaskFreshnessSignalKind + ): Promise { try { const raw = await fs.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw) as Record; @@ -733,6 +802,7 @@ export class TeamLogSourceTracker { type: 'task-log-change', teamName, taskId, + taskSignalKind, }); return; } @@ -742,6 +812,36 @@ export class TeamLogSourceTracker { this.emitLogSourceChange(teamName); } + private handleTaskFreshnessSignalChangeForRoots( + teamName: string, + changedPath: string, + taskFreshnessRootDirs: readonly string[] + ): boolean { + for (const freshnessRootDir of taskFreshnessRootDirs) { + if ( + this.handleTaskFreshnessSignalChange( + teamName, + changedPath, + path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), + 'log' + ) + ) { + return true; + } + if ( + this.handleTaskFreshnessSignalChange( + teamName, + changedPath, + path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), + 'change' + ) + ) { + return true; + } + } + return false; + } + private async recompute(teamName: string): Promise { const state = this.getOrCreateState(teamName); if (this.getActiveConsumerCount(state) === 0) { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 3e1828b9..a96380be 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -46,6 +46,7 @@ const SCAN_CONCURRENCY = 15; /** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */ const DISCOVERY_CACHE_TTL = 30_000; +const MAX_TASK_FRESHNESS_ROOT_DIRS = 64; /** Signal sources for subagent member attribution, ordered by reliability. */ type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention'; @@ -116,6 +117,7 @@ export interface MemberLogFileRef { export interface TeamLogSourceLiveContext { projectDir: string; projectPath?: string; + taskFreshnessRootDirs?: string[]; leadSessionId?: string; sessionIds: string[]; watchSessionIds: string[]; @@ -143,6 +145,30 @@ async function mapLimit( return results; } +function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[] { + const roots: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + if (typeof candidate !== 'string') { + continue; + } + const trimmed = candidate.trim(); + if (!trimmed || !path.isAbsolute(trimmed)) { + continue; + } + const normalized = path.normalize(trimmed); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + roots.push(normalized); + if (roots.length >= MAX_TASK_FRESHNESS_ROOT_DIRS) { + break; + } + } + return roots; +} + export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); private readonly attributionCache = new Map< @@ -286,13 +312,13 @@ export class TeamMemberLogsFinder { readBootstrapLaunchSnapshot(teamName).catch(() => null), ]); const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot); - const extraProjectPathCandidates = Object.values(preferredSnapshot?.members ?? {}).map( + const runtimeMemberCwdCandidates = Object.values(preferredSnapshot?.members ?? {}).map( (member) => member.cwd ); const base = await this.projectResolver.getLiveBaseContext(teamName, { forceRefresh: options?.forceRefresh, - extraProjectPathCandidates, + extraProjectPathCandidates: runtimeMemberCwdCandidates, }); if (!base) { return null; @@ -308,6 +334,11 @@ export class TeamMemberLogsFinder { return { projectDir: base.projectDir, projectPath: base.config.projectPath, + taskFreshnessRootDirs: collectTaskFreshnessRootDirs([ + base.config.projectPath, + ...(base.config.members ?? []).map((member) => member.cwd), + ...runtimeMemberCwdCandidates, + ]), leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId, sessionIds: watchSessionIds, watchSessionIds, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index fac7371a..0fdb92c4 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -10645,6 +10645,7 @@ export class TeamProvisioningService { runId, taskId, detail: `opencode-runtime-task-event:${event}`, + taskSignalKind: 'log', }); return { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 1e13a85e..bcf58b78 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1243,6 +1243,7 @@ export const TeamDetailView = memo(function TeamDetailView({ restoreTask, fetchDeletedTasks, deletedTasks, + activeTaskLogActivity, launchParams, messagesPanelMode, messagesPanelWidth, @@ -1299,6 +1300,7 @@ export const TeamDetailView = memo(function TeamDetailView({ restoreTask: s.restoreTask, fetchDeletedTasks: s.fetchDeletedTasks, deletedTasks: s.deletedTasks, + activeTaskLogActivity: teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined, launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, messagesPanelMode: s.messagesPanelMode, messagesPanelWidth: s.messagesPanelWidth, @@ -2554,6 +2556,7 @@ export const TeamDetailView = memo(function TeamDetailView({ sessions={teamSessions} leadSessionId={data.config.leadSessionId} members={activeMembers} + activeTaskLogActivity={activeTaskLogActivity} forceShowAllTasks={isKanbanSearchActive} onFilterChange={setKanbanFilter} onSortChange={setKanbanSort} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 81b24523..21e1487c 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -78,6 +78,7 @@ interface KanbanBoardProps { sessions: Session[]; leadSessionId?: string; members: ResolvedTeamMember[]; + activeTaskLogActivity?: Record; /** Shows all cards when another UI flow, such as search, must not hide matches. */ forceShowAllTasks?: boolean; onFilterChange: (filter: KanbanFilterState) => void; @@ -244,6 +245,7 @@ interface SortableKanbanTaskCardProps { compact?: boolean; taskMap: Map; memberColorMap: Map; + hasLiveTaskLogs?: boolean; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; @@ -265,6 +267,7 @@ const SortableKanbanTaskCard = ({ compact, taskMap, memberColorMap, + hasLiveTaskLogs, onRequestReview, onApprove, onRequestChanges, @@ -300,6 +303,7 @@ const SortableKanbanTaskCard = ({ compact={compact} taskMap={taskMap} memberColorMap={memberColorMap} + hasLiveTaskLogs={hasLiveTaskLogs} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} @@ -325,6 +329,7 @@ export const KanbanBoard = memo(function KanbanBoard({ sessions, leadSessionId, members, + activeTaskLogActivity, forceShowAllTasks = false, onFilterChange, onSortChange, @@ -578,6 +583,7 @@ export const KanbanBoard = memo(function KanbanBoard({ compact={compact} taskMap={taskMap} memberColorMap={memberColorMap} + hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} @@ -610,6 +616,7 @@ export const KanbanBoard = memo(function KanbanBoard({ compact={compact} taskMap={taskMap} memberColorMap={memberColorMap} + hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])} onRequestReview={onRequestReview} onApprove={onApprove} onRequestChanges={onRequestChanges} @@ -630,6 +637,7 @@ export const KanbanBoard = memo(function KanbanBoard({ }, [ enableTaskSorting, + activeTaskLogActivity, handleScrollToTask, hasReviewers, kanbanState, diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 141124c9..5a90d2ae 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -274,3 +274,46 @@ describe('KanbanTaskCard blocked border', () => { } ); }); + +describe('KanbanTaskCard live log indicator', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('shows the live log indicator only when task log activity is active', async () => { + const { host, root } = await renderTaskCard({ hasLiveTaskLogs: true }); + + expect(host.querySelector('[aria-label="Task logs active"]')).not.toBeNull(); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: baseTask, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + hasLiveTaskLogs: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Task logs active"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 5d8686f0..608290e3 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge'; import { Button } from '@renderer/components/ui/button'; @@ -38,6 +39,7 @@ interface KanbanTaskCardProps { compact?: boolean; taskMap: Map; memberColorMap: Map; + hasLiveTaskLogs?: boolean; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; @@ -227,6 +229,7 @@ export const KanbanTaskCard = memo( compact, taskMap, memberColorMap, + hasLiveTaskLogs = false, onRequestReview, onApprove, onRequestChanges, @@ -304,8 +307,13 @@ export const KanbanTaskCard = memo( } }} > - - {formatTaskDisplayLabel(task)} + + {formatTaskDisplayLabel(task)} + {hasLiveTaskLogs ? ( + + + + ) : null} {task.owner ? ( @@ -491,6 +499,7 @@ export const KanbanTaskCard = memo( prev.compact === next.compact && prev.taskMap === next.taskMap && prev.memberColorMap === next.memberColorMap && + prev.hasLiveTaskLogs === next.hasLiveTaskLogs && prev.onRequestReview === next.onRequestReview && prev.onApprove === next.onApprove && prev.onRequestChanges === next.onRequestChanges && diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index aa4d1c02..d9eea725 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -14,6 +14,7 @@ import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { asEnhancedChunkArray } from '@renderer/types/data'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { isLeadMember } from '@shared/utils/leadDetection'; import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; @@ -375,7 +376,7 @@ export const TaskLogStreamSection = ({ } const shouldReload = event.type === 'log-source-change' || - (event.type === 'task-log-change' && event.taskId === taskId); + (isTaskLogActivityChangeEvent(event) && event.taskId === taskId); if (!shouldReload) { return; } diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 2f99e8b0..96dcda35 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; +import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { ExecutionSessionsSection } from './ExecutionSessionsSection'; import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates'; @@ -187,7 +188,7 @@ export const TaskLogsPanel = ({ const unsubscribe = api.teams.onTeamChange?.((_event, event) => { if ( event.teamName !== teamName || - event.type !== 'task-log-change' || + !isTaskLogActivityChangeEvent(event) || event.taskId !== task.id ) { return; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 749951b1..426c1523 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -12,6 +12,7 @@ import { buildTaskChangeRequestOptions, canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; +import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { createLogger } from '@shared/utils/logger'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { create } from 'zustand'; @@ -87,6 +88,7 @@ const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000; const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000; const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000; const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000; +const TASK_LOG_ACTIVITY_PULSE_MS = 2_500; const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet = new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']); export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout'; @@ -273,6 +275,7 @@ export function initializeNotificationListeners(): () => void { let memberSpawnRefreshTimers = new Map>(); let teamAgentRuntimeRefreshTimers = new Map>(); let toolActivityTimers = new Map>(); + let taskLogActivityTimers = new Map>(); let processLiteStructuralReconcileTimers = new Map< string, { firstScheduledAt: number; timer: ReturnType } @@ -547,6 +550,67 @@ export function initializeNotificationListeners(): () => void { toolActivityTimers.delete(key); } }; + const buildTaskLogActivityTimerKey = (teamName: string, taskId: string): string => + `${teamName}\u0000${taskId}`; + const clearTaskLogActivityTimer = (teamName: string, taskId: string): void => { + const key = buildTaskLogActivityTimerKey(teamName, taskId); + const existing = taskLogActivityTimers.get(key); + if (existing) { + clearTimeout(existing); + taskLogActivityTimers.delete(key); + } + }; + const clearTaskLogActivityTimersForTeam = (teamName: string): void => { + const prefix = `${teamName}\u0000`; + for (const [key, timer] of taskLogActivityTimers.entries()) { + if (!key.startsWith(prefix)) continue; + clearTimeout(timer); + taskLogActivityTimers.delete(key); + } + }; + const clearTaskLogActivityStateForTeam = (teamName: string): void => { + clearTaskLogActivityTimersForTeam(teamName); + useStore.setState((prev) => { + if (!(teamName in prev.activeTaskLogActivityByTeam)) { + return {}; + } + const next = { ...prev.activeTaskLogActivityByTeam }; + delete next[teamName]; + return { activeTaskLogActivityByTeam: next }; + }); + }; + const markTaskLogActivity = (teamName: string, taskId: string): void => { + clearTaskLogActivityTimer(teamName, taskId); + useStore.setState((prev) => ({ + activeTaskLogActivityByTeam: { + ...prev.activeTaskLogActivityByTeam, + [teamName]: { + ...(prev.activeTaskLogActivityByTeam[teamName] ?? {}), + [taskId]: true, + }, + }, + })); + const timerKey = buildTaskLogActivityTimerKey(teamName, taskId); + const timer = setTimeout(() => { + taskLogActivityTimers.delete(timerKey); + useStore.setState((prev) => { + const teamActivity = prev.activeTaskLogActivityByTeam[teamName]; + if (!teamActivity?.[taskId]) { + return {}; + } + const nextTeamActivity = { ...teamActivity }; + delete nextTeamActivity[taskId]; + const nextByTeam = { ...prev.activeTaskLogActivityByTeam }; + if (Object.keys(nextTeamActivity).length === 0) { + delete nextByTeam[teamName]; + } else { + nextByTeam[teamName] = nextTeamActivity; + } + return { activeTaskLogActivityByTeam: nextByTeam }; + }); + }, TASK_LOG_ACTIVITY_PULSE_MS); + taskLogActivityTimers.set(timerKey, timer); + }; const clearRuntimeToolStateForTeam = ( prev: AppState, teamName: string @@ -860,6 +924,10 @@ export function initializeNotificationListeners(): () => void { return getVisibleTeamNamesInAnyPane(); }; + const getTrackedTaskLogActivityTeams = (): Set => { + return getVisibleTeamNamesInAnyPane(); + }; + const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => { teamLastRelevantActivityAt.set(teamName, timestamp); }; @@ -1220,6 +1288,46 @@ export function initializeNotificationListeners(): () => void { }); } + if (api.teams?.setTaskLogStreamTracking) { + let trackedTeamNames = new Set(); + const syncVisibleTeamTracking = (): void => { + const nextTrackedTeamNames = getTrackedTaskLogActivityTeams(); + + for (const teamName of nextTrackedTeamNames) { + if (!trackedTeamNames.has(teamName)) { + void api.teams.setTaskLogStreamTracking(teamName, true).catch(() => undefined); + } + } + + for (const teamName of trackedTeamNames) { + if (!nextTrackedTeamNames.has(teamName)) { + void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined); + clearTaskLogActivityStateForTeam(teamName); + } + } + + trackedTeamNames = nextTrackedTeamNames; + }; + + syncVisibleTeamTracking(); + + const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => { + if (state.paneLayout === prevState.paneLayout) { + return; + } + syncVisibleTeamTracking(); + }); + + cleanupFns.push(() => { + unsubscribeVisibleTeamTracking(); + for (const teamName of trackedTeamNames) { + void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined); + clearTaskLogActivityStateForTeam(teamName); + } + trackedTeamNames.clear(); + }); + } + // Listen for task-list file changes to refresh currently viewed session metadata if (api.onTodoChange) { const cleanup = api.onTodoChange((event) => { @@ -1422,6 +1530,8 @@ export function initializeNotificationListeners(): () => void { nextState.leadContextByTeam = { ...prev.leadContextByTeam }; delete nextState.leadContextByTeam[event.teamName]; Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName)); + nextState.activeTaskLogActivityByTeam = { ...prev.activeTaskLogActivityByTeam }; + delete nextState.activeTaskLogActivityByTeam[event.teamName]; nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam }; delete nextState.currentRuntimeRunIdByTeam[event.teamName]; nextState.ignoredRuntimeRunIds = event.runId @@ -1431,6 +1541,7 @@ export function initializeNotificationListeners(): () => void { } : prev.ignoredRuntimeRunIds; clearToolActivityTimersForTeam(event.teamName); + clearTaskLogActivityTimersForTeam(event.teamName); } return nextState as typeof prev; @@ -1585,6 +1696,55 @@ export function initializeNotificationListeners(): () => void { return; } + if (event.type === 'task-log-change') { + if (isStaleRuntimeEvent) { + return; + } + seedCurrentRunIdIfMissing(); + const visible = isTeamVisibleInAnyPane(event.teamName); + if (event.taskId && visible) { + if (isTaskLogActivityChangeEvent(event)) { + markTaskLogActivity(event.teamName, event.taskId); + } + const existingDetailTimer = teamRefreshTimers.get(event.teamName); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: existingDetailTimer ? 'coalesced' : 'scheduled', + reason: 'event:task-log-change:task-state-safety', + operation: 'refreshTeamData', + eventType: event.type, + selected: useStore.getState().selectedTeamName === event.teamName, + visible, + activeTab: getFocusedVisibleTeamName() === event.teamName, + }); + if (!existingDetailTimer) { + const timer = setTimeout(() => { + teamRefreshTimers.delete(event.teamName); + const current = useStore.getState(); + const visibleAtExecution = isTeamVisibleInAnyPane(event.teamName); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: visibleAtExecution ? 'executed' : 'skipped', + reason: 'event:task-log-change:task-state-safety', + operation: 'refreshTeamData', + eventType: event.type, + selected: current.selectedTeamName === event.teamName, + visible: visibleAtExecution, + activeTab: getFocusedVisibleTeamName() === event.teamName, + }); + if (!visibleAtExecution) { + return; + } + void current.refreshTeamData(event.teamName, { withDedup: true }); + }, TEAM_REFRESH_THROTTLE_MS); + teamRefreshTimers.set(event.teamName, timer); + } + } + return; + } + // Member spawn status change: fetch updated spawn statuses for the team. if (event.type === 'member-spawn') { if (isStaleRuntimeEvent) { @@ -1870,6 +2030,8 @@ export function initializeNotificationListeners(): () => void { teamAgentRuntimeRefreshTimers = new Map(); for (const t of toolActivityTimers.values()) clearTimeout(t); toolActivityTimers = new Map(); + for (const t of taskLogActivityTimers.values()) clearTimeout(t); + taskLogActivityTimers = new Map(); for (const state of processLiteStructuralReconcileTimers.values()) { clearTimeout(state.timer); } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 7e8ddc6a..79e405f8 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -328,6 +328,7 @@ function collectTeamScopedStateRemovals( | 'provisioningStartedAtFloorByTeam' | 'leadActivityByTeam' | 'leadContextByTeam' + | 'activeTaskLogActivityByTeam' | 'activeToolsByTeam' | 'finishedVisibleByTeam' | 'toolHistoryByTeam' @@ -353,6 +354,7 @@ function collectTeamScopedStateRemovals( ); const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName); const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName); + const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName); const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName); const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName); const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName); @@ -378,6 +380,9 @@ function collectTeamScopedStateRemovals( : {}), ...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}), ...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}), + ...(nextActiveTaskLogActivity + ? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity } + : {}), ...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}), ...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}), ...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}), @@ -2385,6 +2390,7 @@ export interface TeamSlice { provisioningStartedAtFloorByTeam: Record; leadActivityByTeam: Record; leadContextByTeam: Record; + activeTaskLogActivityByTeam: Record>; activeToolsByTeam: Record>>; finishedVisibleByTeam: Record>>; toolHistoryByTeam: Record>; @@ -2727,6 +2733,7 @@ export const createTeamSlice: StateCreator = (set, provisioningStartedAtFloorByTeam: {}, leadActivityByTeam: {}, leadContextByTeam: {}, + activeTaskLogActivityByTeam: {}, activeToolsByTeam: {}, finishedVisibleByTeam: {}, toolHistoryByTeam: {}, diff --git a/src/renderer/utils/teamChangeEvents.ts b/src/renderer/utils/teamChangeEvents.ts new file mode 100644 index 00000000..f8ced134 --- /dev/null +++ b/src/renderer/utils/teamChangeEvents.ts @@ -0,0 +1,18 @@ +import type { TeamChangeEvent } from '@shared/types'; + +const RUNTIME_TASK_EVENT_DETAIL_PREFIX = 'opencode-runtime-task-event:'; + +export function isTaskLogActivityChangeEvent(event: TeamChangeEvent): boolean { + if (event.type !== 'task-log-change') { + return false; + } + if (event.taskSignalKind === 'log') { + return true; + } + if (event.taskSignalKind === 'change') { + return false; + } + return ( + typeof event.detail === 'string' && event.detail.startsWith(RUNTIME_TASK_EVENT_DETAIL_PREFIX) + ); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e42bcf6e..4d87e1a4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1208,6 +1208,8 @@ export interface TeamChangeEvent { runId?: string; detail?: string; taskId?: string; + /** Distinguishes real task log freshness from task-change presence freshness. */ + taskSignalKind?: 'log' | 'change'; } export interface ProjectBranchChangeEvent { diff --git a/test/main/services/team/TeamLogSourceTracker.test.ts b/test/main/services/team/TeamLogSourceTracker.test.ts index 74beea88..7f5e0f0a 100644 --- a/test/main/services/team/TeamLogSourceTracker.test.ts +++ b/test/main/services/team/TeamLogSourceTracker.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'crypto'; -import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import { mkdtemp, mkdir, rm, stat, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -55,6 +55,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'log', }); }); @@ -95,6 +96,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'log', }); }); @@ -106,6 +108,167 @@ describe('TeamLogSourceTracker', () => { expect(emitter).not.toHaveBeenCalled(); }); + it('creates transcript freshness dirs without creating missing live cwd roots', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-missing-root-')); + const transcriptProjectDir = path.join(tempDir, 'transcript-project'); + const missingWorkspaceDir = path.join(tempDir, 'missing-workspace'); + + const logsFinder = { + getLiveLogSourceWatchContext: vi.fn(async () => ({ + projectDir: transcriptProjectDir, + projectPath: missingWorkspaceDir, + taskFreshnessRootDirs: [missingWorkspaceDir], + sessionIds: [], + watchSessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'task_log_stream'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect((await stat(path.join(transcriptProjectDir, '.board-task-log-freshness'))).isDirectory()) + .toBe(true); + await expect(stat(missingWorkspaceDir)).rejects.toThrow(); + + const taskId = 'transcript-root-task'; + await writeFile( + path.join( + transcriptProjectDir, + '.board-task-log-freshness', + `${encodeURIComponent(taskId)}.json` + ), + JSON.stringify({ taskId }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + taskSignalKind: 'log', + }); + }); + + await tracker.disableTracking('demo', 'task_log_stream'); + }); + + it('emits log freshness kind from Windows-safe hashed task-log freshness files', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-log-')); + + const logsFinder = { + getLiveLogSourceWatchContext: vi.fn(async () => ({ + projectDir: tempDir!, + sessionIds: [], + watchSessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'task_log_stream'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const taskId = 'AUX'; + const signalDir = path.join(tempDir, '.board-task-log-freshness'); + await mkdir(signalDir, { recursive: true }); + await writeFile( + path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`), + JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + taskSignalKind: 'log', + }); + }); + + await tracker.disableTracking('demo', 'task_log_stream'); + }); + + it('watches live cwd freshness roots used by Codex Native traces', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-codex-root-')); + const transcriptProjectDir = path.join(tempDir, 'transcripts'); + const workspaceProjectDir = path.join(tempDir, 'workspace'); + const memberProjectDir = path.join(tempDir, 'member-workspace'); + await mkdir(transcriptProjectDir, { recursive: true }); + await mkdir(workspaceProjectDir, { recursive: true }); + await mkdir(memberProjectDir, { recursive: true }); + + const logsFinder = { + getLiveLogSourceWatchContext: vi.fn(async () => ({ + projectDir: transcriptProjectDir, + projectPath: workspaceProjectDir, + taskFreshnessRootDirs: [workspaceProjectDir, memberProjectDir], + sessionIds: [], + watchSessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'task_log_stream'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const logTaskId = 'codex-task-1'; + await writeFile( + path.join( + memberProjectDir, + '.board-task-log-freshness', + `${encodeURIComponent(logTaskId)}.json` + ), + JSON.stringify({ taskId: logTaskId, source: 'codex-native-trace' }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId: logTaskId, + taskSignalKind: 'log', + }); + }); + + emitter.mockClear(); + const changeTaskId = 'codex-task-2'; + await writeFile( + path.join( + workspaceProjectDir, + '.board-task-change-freshness', + `${encodeURIComponent(changeTaskId)}.json` + ), + JSON.stringify({ taskId: changeTaskId }), + 'utf8' + ); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId: changeTaskId, + taskSignalKind: 'change', + }); + }); + + await tracker.disableTracking('demo', 'task_log_stream'); + }); + it('emits log-source-change for scoped root transcripts', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-scoped-root-')); await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":1}\n'); @@ -275,6 +438,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'log', }); }); @@ -314,6 +478,7 @@ describe('TeamLogSourceTracker', () => { type: 'task-log-change', teamName: 'demo', taskId, + taskSignalKind: 'change', }); }); expect(emitter.mock.calls).not.toContainEqual([ diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 19ea0cff..63933f06 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -24,13 +24,15 @@ describe('TeamMemberLogsFinder', () => { const teamName = 'live-context-team'; const projectPath = '/Users/test/live-context'; + const memberProjectPath = '/Users/test/member-cwd'; + const runtimeProjectPath = '/Users/test/runtime-bob-cwd'; const projectRoot = path.join(tmpDir, 'projects', '-Users-test-live-context'); const config = { name: teamName, projectPath, leadSessionId: 'lead-session', sessionHistory: ['old-session', 'recent-session'], - members: [], + members: [{ name: 'bob', cwd: memberProjectPath }], }; await fs.mkdir(projectRoot, { recursive: true }); @@ -61,6 +63,7 @@ describe('TeamMemberLogsFinder', () => { bootstrapConfirmed: false, hardFailure: false, runtimeSessionId: 'runtime-bob', + cwd: runtimeProjectPath, updatedAt: '2026-05-03T12:00:00.000Z', }, }, @@ -81,7 +84,10 @@ describe('TeamMemberLogsFinder', () => { expect(projectResolver.getLiveBaseContext).toHaveBeenCalledWith( teamName, - expect.objectContaining({ forceRefresh: true }) + expect.objectContaining({ + forceRefresh: true, + extraProjectPathCandidates: [runtimeProjectPath], + }) ); expect(projectResolver.getContext).not.toHaveBeenCalled(); expect(context?.projectDir).toBe(projectRoot); @@ -92,6 +98,11 @@ describe('TeamMemberLogsFinder', () => { 'old-session', ]); expect(context?.sessionIds).toEqual(context?.watchSessionIds); + expect(context?.taskFreshnessRootDirs).toEqual([ + projectPath, + memberProjectPath, + runtimeProjectPath, + ]); }); it('returns subagent logs for a member and lead session for team-lead', async () => { diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts index 610d2356..17a393c0 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts @@ -478,7 +478,12 @@ describe('TaskLogStreamSection', () => { expect(handler).toBeTypeOf('function'); await act(async () => { - handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-a' }); + handler?.(null, { + teamName: 'other-team', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); @@ -486,7 +491,12 @@ describe('TaskLogStreamSection', () => { expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-b' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-b', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); @@ -494,7 +504,25 @@ describe('TaskLogStreamSection', () => { expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'change', + }); + vi.advanceTimersByTime(400); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); @@ -586,7 +614,12 @@ describe('TaskLogStreamSection', () => { ).toBe('false'); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-a', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(400); await flushMicrotasks(); }); diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts index 8692e530..1283dfd1 100644 --- a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -341,15 +341,36 @@ describe('TaskLogsPanel', () => { expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledTimes(1); await act(async () => { - handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-1' }); - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-2' }); + handler?.(null, { + teamName: 'other-team', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-2', + taskSignalKind: 'log', + }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'change', + }); await flushMicrotasks(); }); expect(activityStates).toEqual([false]); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); await flushMicrotasks(); }); @@ -411,7 +432,12 @@ describe('TaskLogsPanel', () => { expect(activityStates).toEqual([false]); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); await flushMicrotasks(); }); @@ -443,7 +469,12 @@ describe('TaskLogsPanel', () => { expect(handler).toBeTypeOf('function'); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); await flushMicrotasks(); }); @@ -564,7 +595,12 @@ describe('TaskLogsPanel', () => { expect(counts).toEqual([undefined, 4]); await act(async () => { - handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { + teamName: 'demo', + type: 'task-log-change', + taskId: 'task-1', + taskSignalKind: 'log', + }); vi.advanceTimersByTime(350); await flushMicrotasks(); }); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 23331506..27cea90a 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -4,7 +4,14 @@ const hoisted = vi.hoisted(() => ({ onTeamChangeCb: null as | (( event: unknown, - data: { type?: string; teamName: string; detail?: string; runId?: string } + data: { + type?: string; + teamName: string; + detail?: string; + runId?: string; + taskId?: string; + taskSignalKind?: 'log' | 'change'; + } ) => void) | null, onProvisioningProgressCb: null as @@ -36,11 +43,19 @@ vi.mock('@renderer/api', () => ({ teams: { setChangePresenceTracking: vi.fn(async () => undefined), setToolActivityTracking: vi.fn(async () => undefined), + setTaskLogStreamTracking: vi.fn(async () => undefined), onTeamChange: vi.fn( ( cb: ( event: unknown, - data: { teamName: string; type?: string; detail?: string; runId?: string } + data: { + teamName: string; + type?: string; + detail?: string; + runId?: string; + taskId?: string; + taskSignalKind?: 'log' | 'change'; + } ) => void ): (() => void) => { hoisted.onTeamChangeCb = cb; @@ -112,6 +127,7 @@ describe('team change throttling', () => { currentRuntimeRunIdByTeam: {}, ignoredProvisioningRunIds: {}, ignoredRuntimeRunIds: {}, + activeTaskLogActivityByTeam: {}, memberSpawnStatusesByTeam: {}, memberSpawnSnapshotsByTeam: {}, teamAgentRuntimeByTeam: {}, @@ -1543,6 +1559,190 @@ describe('team change throttling', () => { expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); }); + it('tracks visible team tabs for task log activity and disables tracking when tab disappears', async () => { + const setTaskLogStreamTrackingSpy = vi.mocked(api.teams.setTaskLogStreamTracking); + setTaskLogStreamTrackingSpy.mockClear(); + + cleanup?.(); + cleanup = initializeNotificationListeners(); + await vi.advanceTimersByTimeAsync(0); + + expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', true); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + await vi.advanceTimersByTimeAsync(0); + + expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', false); + }); + + it('pulses task log activity only for real log signals and clears it after inactivity', async () => { + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-change-only', + taskSignalKind: 'change', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + useStore.setState({ currentRuntimeRunIdByTeam: { 'my-team': 'run-current' } } as never); + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + runId: 'run-old', + taskId: 'task-stale', + taskSignalKind: 'log', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + runId: 'run-current', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(2499); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(1); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + }); + + it('schedules a bounded team data refresh for visible task log signals', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.( + {}, + { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + } + ); + + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + }); + + it('refreshes visible team data for task change freshness without pulsing live log activity', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.( + {}, + { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-completed', + taskSignalKind: 'change', + } + ); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + }); + + it('skips the bounded task log refresh if the team is hidden before execution', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.( + {}, + { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + } + ); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + }); + + it('extends task log activity pulse on repeated log signals and ignores hidden teams', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + await vi.advanceTimersByTimeAsync(2000); + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-live', + taskSignalKind: 'log', + }); + + await vi.advanceTimersByTimeAsync(2499); + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({ + 'task-live': true, + }); + + await vi.advanceTimersByTimeAsync(1); + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }], + }, + } as never); + + hoisted.onTeamChangeCb?.({}, { + type: 'task-log-change', + teamName: 'my-team', + taskId: 'task-hidden', + taskSignalKind: 'log', + }); + + expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(800); + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); + }); + it('applies targeted tool resets without clearing sibling tools', async () => { useStore.setState({ activeToolsByTeam: {