feat(team): expand task and member execution logs

This commit is contained in:
777genius 2026-04-18 18:13:37 +03:00
parent 2e062e4432
commit b7547e5d87
26 changed files with 3080 additions and 105 deletions

File diff suppressed because it is too large Load diff

View file

@ -965,6 +965,7 @@ async function initializeServices(): Promise<void> {
boardTaskExactLogsService, boardTaskExactLogsService,
boardTaskExactLogDetailService, boardTaskExactLogDetailService,
teammateToolTracker ?? undefined, teammateToolTracker ?? undefined,
teamLogSourceTracker,
branchStatusService ?? undefined, branchStatusService ?? undefined,
{ {
rewire: rewireContextEvents, rewire: rewireContextEvents,

View file

@ -106,6 +106,7 @@ import type {
ServiceContextRegistry, ServiceContextRegistry,
SshConnectionManager, SshConnectionManager,
TeamDataService, TeamDataService,
TeamLogSourceTracker,
TeammateToolTracker, TeammateToolTracker,
TeamMemberLogsFinder, TeamMemberLogsFinder,
TeamProvisioningService, TeamProvisioningService,
@ -141,6 +142,7 @@ export function initializeIpcHandlers(
boardTaskExactLogsService: BoardTaskExactLogsService, boardTaskExactLogsService: BoardTaskExactLogsService,
boardTaskExactLogDetailService: BoardTaskExactLogDetailService, boardTaskExactLogDetailService: BoardTaskExactLogDetailService,
teammateToolTracker: TeammateToolTracker | undefined, teammateToolTracker: TeammateToolTracker | undefined,
teamLogSourceTracker: TeamLogSourceTracker | undefined,
branchStatusService: BranchStatusService | undefined, branchStatusService: BranchStatusService | undefined,
contextCallbacks: { contextCallbacks: {
rewire: (context: ServiceContext) => void; rewire: (context: ServiceContext) => void;
@ -184,6 +186,7 @@ export function initializeIpcHandlers(
memberStatsComputer, memberStatsComputer,
teamBackupService, teamBackupService,
teammateToolTracker, teammateToolTracker,
teamLogSourceTracker,
branchStatusService, branchStatusService,
boardTaskActivityService, boardTaskActivityService,
boardTaskActivityDetailService, boardTaskActivityDetailService,

View file

@ -56,6 +56,7 @@ import {
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TASK_CLARIFICATION,
TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION,
@ -135,6 +136,7 @@ import type {
BranchStatusService, BranchStatusService,
MemberStatsComputer, MemberStatsComputer,
TeamDataService, TeamDataService,
TeamLogSourceTracker,
TeammateToolTracker, TeammateToolTracker,
TeamMemberLogsFinder, TeamMemberLogsFinder,
TeamProvisioningService, TeamProvisioningService,
@ -435,6 +437,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null; let memberStatsComputer: MemberStatsComputer | null = null;
let teamBackupService: TeamBackupService | null = null; let teamBackupService: TeamBackupService | null = null;
let teammateToolTracker: TeammateToolTracker | null = null; let teammateToolTracker: TeammateToolTracker | null = null;
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
let branchStatusService: BranchStatusService | null = null; let branchStatusService: BranchStatusService | null = null;
let boardTaskActivityService: BoardTaskActivityService | null = null; let boardTaskActivityService: BoardTaskActivityService | null = null;
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null; let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
@ -471,6 +474,7 @@ export function initializeTeamHandlers(
statsComputer?: MemberStatsComputer, statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService, backupService?: TeamBackupService,
toolTracker?: TeammateToolTracker, toolTracker?: TeammateToolTracker,
logSourceTracker?: TeamLogSourceTracker,
branchTracker?: BranchStatusService, branchTracker?: BranchStatusService,
taskActivityService?: BoardTaskActivityService, taskActivityService?: BoardTaskActivityService,
taskActivityDetailService?: BoardTaskActivityDetailService, taskActivityDetailService?: BoardTaskActivityDetailService,
@ -485,6 +489,7 @@ export function initializeTeamHandlers(
memberStatsComputer = statsComputer ?? null; memberStatsComputer = statsComputer ?? null;
teamBackupService = backupService ?? null; teamBackupService = backupService ?? null;
teammateToolTracker = toolTracker ?? null; teammateToolTracker = toolTracker ?? null;
teamLogSourceTracker = logSourceTracker ?? null;
branchStatusService = branchTracker ?? null; branchStatusService = branchTracker ?? null;
boardTaskActivityService = taskActivityService ?? null; boardTaskActivityService = taskActivityService ?? null;
boardTaskActivityDetailService = taskActivityDetailService ?? null; boardTaskActivityDetailService = taskActivityDetailService ?? null;
@ -499,6 +504,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking); ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking);
ipcMain.handle(TEAM_SET_TASK_LOG_STREAM_TRACKING, handleSetTaskLogStreamTracking);
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
@ -571,6 +577,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING); ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING);
ipcMain.removeHandler(TEAM_SET_TASK_LOG_STREAM_TRACKING);
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
@ -657,6 +664,13 @@ function getTeammateToolTracker(): TeammateToolTracker {
return teammateToolTracker; return teammateToolTracker;
} }
function getTeamLogSourceTracker(): TeamLogSourceTracker {
if (!teamLogSourceTracker) {
throw new Error('Team log source tracker is not initialized');
}
return teamLogSourceTracker;
}
function getBranchStatusService(): BranchStatusService { function getBranchStatusService(): BranchStatusService {
if (!branchStatusService) { if (!branchStatusService) {
throw new Error('Branch status service is not initialized'); throw new Error('Branch status service is not initialized');
@ -911,6 +925,28 @@ async function handleSetToolActivityTracking(
}); });
} }
async function handleSetTaskLogStreamTracking(
_event: IpcMainInvokeEvent,
teamName: unknown,
enabled: unknown
): Promise<IpcResult<void>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'enabled must be a boolean' };
}
return wrapTeamHandler('setTaskLogStreamTracking', async () => {
if (enabled) {
await getTeamLogSourceTracker().enableTracking(validated.value!, 'task_log_stream');
return;
}
await getTeamLogSourceTracker().disableTracking(validated.value!, 'task_log_stream');
});
}
async function handleDeleteTeam( async function handleDeleteTeam(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown teamName: unknown

View file

@ -14,13 +14,15 @@ import type { TeamChangeEvent } from '@shared/types';
import type { FSWatcher } from 'chokidar'; import type { FSWatcher } from 'chokidar';
const logger = createLogger('Service:TeamLogSourceTracker'); const logger = createLogger('Service:TeamLogSourceTracker');
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
interface TeamLogSourceSnapshot { interface TeamLogSourceSnapshot {
projectFingerprint: string | null; projectFingerprint: string | null;
logSourceGeneration: string | null; logSourceGeneration: string | null;
} }
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity'; export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream';
interface TrackingState { interface TrackingState {
watcher: FSWatcher | null; watcher: FSWatcher | null;
@ -31,7 +33,7 @@ interface TrackingState {
recomputePromise: Promise<TeamLogSourceSnapshot> | null; recomputePromise: Promise<TeamLogSourceSnapshot> | null;
recomputeVersion: number | null; recomputeVersion: number | null;
snapshot: TeamLogSourceSnapshot; snapshot: TeamLogSourceSnapshot;
consumers: Set<TeamLogSourceTrackingConsumer>; consumerCounts: Map<TeamLogSourceTrackingConsumer, number>;
lifecycleVersion: number; lifecycleVersion: number;
} }
@ -67,19 +69,29 @@ export class TeamLogSourceTracker {
consumer: TeamLogSourceTrackingConsumer consumer: TeamLogSourceTrackingConsumer
): Promise<TeamLogSourceSnapshot> { ): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName); const state = this.getOrCreateState(teamName);
if (!state.consumers.has(consumer)) { const activeConsumerCountBefore = this.getActiveConsumerCount(state);
state.consumers.add(consumer); state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1);
if (activeConsumerCountBefore === 0) {
state.lifecycleVersion += 1; state.lifecycleVersion += 1;
} }
if ( if (
state.initializePromise && state.initializePromise &&
state.initializeVersion === state.lifecycleVersion && state.initializeVersion === state.lifecycleVersion &&
state.consumers.size > 0 this.getActiveConsumerCount(state) > 0
) { ) {
return state.initializePromise; return state.initializePromise;
} }
if (
activeConsumerCountBefore > 0 &&
(state.watcher !== null ||
state.projectDir !== null ||
state.snapshot.logSourceGeneration !== null)
) {
return { ...state.snapshot };
}
const initializeVersion = state.lifecycleVersion; const initializeVersion = state.lifecycleVersion;
const initializePromise = this.initializeTeam(teamName, initializeVersion) const initializePromise = this.initializeTeam(teamName, initializeVersion)
.catch((error) => { .catch((error) => {
@ -118,13 +130,21 @@ export class TeamLogSourceTracker {
recomputePromise: null, recomputePromise: null,
recomputeVersion: null, recomputeVersion: null,
snapshot: { projectFingerprint: null, logSourceGeneration: null }, snapshot: { projectFingerprint: null, logSourceGeneration: null },
consumers: new Set(), consumerCounts: new Map(),
lifecycleVersion: 0, lifecycleVersion: 0,
}; };
this.stateByTeam.set(teamName, created); this.stateByTeam.set(teamName, created);
return created; return created;
} }
private getActiveConsumerCount(state: TrackingState): number {
let count = 0;
for (const value of state.consumerCounts.values()) {
count += value;
}
return count;
}
async stopTracking(teamName: string): Promise<void> { async stopTracking(teamName: string): Promise<void> {
await this.disableTracking(teamName, 'change_presence'); await this.disableTracking(teamName, 'change_presence');
} }
@ -138,15 +158,24 @@ export class TeamLogSourceTracker {
return { projectFingerprint: null, logSourceGeneration: null }; return { projectFingerprint: null, logSourceGeneration: null };
} }
if (state.consumers.has(consumer)) { const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0;
state.consumers.delete(consumer); if (currentConsumerCount > 1) {
state.lifecycleVersion += 1; state.consumerCounts.set(consumer, currentConsumerCount - 1);
return { ...state.snapshot };
} }
if (state.consumers.size > 0) { if (currentConsumerCount === 1) {
state.consumerCounts.delete(consumer);
}
if (this.getActiveConsumerCount(state) > 0) {
return { ...state.snapshot }; return { ...state.snapshot };
} }
if (currentConsumerCount > 0) {
state.lifecycleVersion += 1;
}
if (state.refreshTimer) { if (state.refreshTimer) {
clearTimeout(state.refreshTimer); clearTimeout(state.refreshTimer);
state.refreshTimer = null; state.refreshTimer = null;
@ -164,7 +193,11 @@ export class TeamLogSourceTracker {
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
const state = this.stateByTeam.get(teamName); const state = this.stateByTeam.get(teamName);
return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion; return (
!!state &&
this.getActiveConsumerCount(state) > 0 &&
state.lifecycleVersion === expectedVersion
);
} }
private async initializeTeam( private async initializeTeam(
@ -207,7 +240,11 @@ export class TeamLogSourceTracker {
expectedVersion: number expectedVersion: number
): Promise<void> { ): Promise<void> {
const state = this.stateByTeam.get(teamName); const state = this.stateByTeam.get(teamName);
if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) { if (
!state ||
this.getActiveConsumerCount(state) === 0 ||
state.lifecycleVersion !== expectedVersion
) {
return; return;
} }
if (state.projectDir === projectDir && state.watcher) { if (state.projectDir === projectDir && state.watcher) {
@ -240,9 +277,15 @@ export class TeamLogSourceTracker {
}, },
}); });
const scheduleRecompute = (): void => { const scheduleRecompute = (changedPath?: string): void => {
const current = this.stateByTeam.get(teamName); const current = this.stateByTeam.get(teamName);
if (!current || current.consumers.size === 0) { if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) {
return;
}
if (
changedPath &&
this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath)
) {
return; return;
} }
if (current.refreshTimer) { if (current.refreshTimer) {
@ -264,15 +307,65 @@ export class TeamLogSourceTracker {
}); });
} }
private handleTaskLogFreshnessSignalChange(
teamName: string,
projectDir: string,
changedPath: string
): boolean {
const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME);
const relativePath = path.relative(signalDir, changedPath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return path.normalize(changedPath) === path.normalize(signalDir);
}
if (relativePath === '.') {
return true;
}
if (relativePath.includes(path.sep)) {
return true;
}
const taskId = this.decodeTaskLogFreshnessTaskId(relativePath);
if (!taskId) {
return true;
}
this.emitter?.({
type: 'task-log-change',
teamName,
taskId,
});
return true;
}
private decodeTaskLogFreshnessTaskId(fileName: string): string | null {
if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) {
return null;
}
const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length);
if (!encodedTaskId) {
return null;
}
try {
const taskId = decodeURIComponent(encodedTaskId);
return taskId.trim().length > 0 ? taskId : null;
} catch {
return null;
}
}
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> { private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName); const state = this.getOrCreateState(teamName);
if (state.consumers.size === 0) { if (this.getActiveConsumerCount(state) === 0) {
return state.snapshot; return state.snapshot;
} }
if ( if (
state.recomputePromise && state.recomputePromise &&
state.recomputeVersion === state.lifecycleVersion && state.recomputeVersion === state.lifecycleVersion &&
state.consumers.size > 0 this.getActiveConsumerCount(state) > 0
) { ) {
return state.recomputePromise; return state.recomputePromise;
} }

View file

@ -161,14 +161,47 @@ function extractBoardToolOutputText(
return null; return null;
} }
const normalizedToolName = toolName.trim().toLowerCase();
const payload = parsedPayload as Record<string, unknown>; const payload = parsedPayload as Record<string, unknown>;
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
const comment = payload.comment as Record<string, unknown> | undefined; const comment = payload.comment as Record<string, unknown> | undefined;
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
return comment.text; return comment.text;
} }
} }
if (normalizedToolName === 'sendmessage') {
const routing = payload.routing as Record<string, unknown> | undefined;
const deliveryMessage =
typeof payload.message === 'string' && payload.message.trim().length > 0
? payload.message.trim()
: null;
const summary =
typeof routing?.summary === 'string' && routing.summary.trim().length > 0
? routing.summary.trim()
: null;
const target =
typeof routing?.target === 'string' && routing.target.trim().length > 0
? routing.target.trim()
: null;
if (deliveryMessage && summary) {
return `${deliveryMessage} - ${summary}`;
}
if (summary && target) {
return `Message sent to ${target} - ${summary}`;
}
if (summary) {
return summary;
}
if (deliveryMessage) {
return deliveryMessage;
}
if (target) {
return `Message sent to ${target}`;
}
}
return null; return null;
} }
@ -289,12 +322,67 @@ function sanitizeToolResultContent(
}; };
} }
function sanitizeToolResultPayloadValue(
value: string | unknown[],
canonicalToolName?: string
): string | unknown[] {
if (typeof value === 'string') {
const parsedPayload = parseJsonLikeString(value);
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
if (typeof extractedText === 'string') {
return extractedText;
}
return parsedPayload ? '' : value;
}
const jsonText = collectTextBlockText(value);
const parsedPayload = parseJsonLikeString(jsonText);
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
if (typeof extractedText === 'string') {
return extractedText;
}
const sanitizedChildren = value
.map((child) => {
if (
typeof child === 'object' &&
child !== null &&
'type' in child &&
child.type === 'text' &&
'text' in child &&
typeof child.text === 'string'
) {
return looksLikeJsonPayload(child.text) ? null : { ...child };
}
return child;
})
.filter((child) => child !== null);
if (parsedPayload && sanitizedChildren.length === value.length) {
return '';
}
return sanitizedChildren.length > 0 ? sanitizedChildren : '';
}
function sanitizeJsonLikeToolResultPayloads( function sanitizeJsonLikeToolResultPayloads(
messages: ParsedMessage[], messages: ParsedMessage[],
canonicalToolName?: string canonicalToolName?: string
): ParsedMessage[] { ): ParsedMessage[] {
return messages.map((message) => { return messages.map((message) => {
let nextMessage = message; let nextMessage = message;
let toolResultsChanged = false;
const nextToolResults = message.toolResults.map((toolResult) => {
const nextContent = sanitizeToolResultPayloadValue(toolResult.content, canonicalToolName);
if (JSON.stringify(nextContent) !== JSON.stringify(toolResult.content)) {
toolResultsChanged = true;
return {
...toolResult,
content: nextContent,
};
}
return toolResult;
});
const rawToolUseResult = message.toolUseResult as unknown; const rawToolUseResult = message.toolUseResult as unknown;
if ( if (
@ -388,12 +476,20 @@ function sanitizeJsonLikeToolResultPayloads(
}); });
if (!changed) { if (!changed) {
return nextMessage; if (!toolResultsChanged) {
return nextMessage;
}
return {
...nextMessage,
toolResults: nextToolResults,
};
} }
return { return {
...nextMessage, ...nextMessage,
content: nextContent, content: nextContent,
toolResults: toolResultsChanged ? nextToolResults : nextMessage.toolResults,
}; };
}); });
} }
@ -1011,6 +1107,15 @@ export class BoardTaskLogStreamService {
continue; continue;
} }
const inferredToolName = [...messageToolUseIds]
.map((toolUseId) => toolNameByUseId.get(toolUseId))
.find((toolName): toolName is string => typeof toolName === 'string');
const sanitizedMessages = sanitizeJsonLikeToolResultPayloads([message], inferredToolName);
const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages);
if (prunedMessages.length === 0) {
continue;
}
inferredSlices.push({ inferredSlices.push({
id: `inferred:${filePath}:${message.uuid}`, id: `inferred:${filePath}:${message.uuid}`,
timestamp: message.timestamp.toISOString(), timestamp: message.timestamp.toISOString(),
@ -1018,7 +1123,7 @@ export class BoardTaskLogStreamService {
sortOrder: index, sortOrder: index,
participantKey: buildParticipantKey(actor), participantKey: buildParticipantKey(actor),
actor, actor,
filteredMessages: [message], filteredMessages: prunedMessages,
}); });
} }
} }

View file

@ -219,6 +219,9 @@ export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking
/** Enable or disable live teammate tool activity tracking for a visible team tab */ /** Enable or disable live teammate tool activity tracking for a visible team tab */
export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking'; export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking';
/** Enable or disable task log stream invalidation tracking for an open task log panel */
export const TEAM_SET_TASK_LOG_STREAM_TRACKING = 'team:setTaskLogStreamTracking';
/** Get buffered Claude CLI logs (paged, newest-first) */ /** Get buffered Claude CLI logs (paged, newest-first) */
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';

View file

@ -161,6 +161,7 @@ import {
TEAM_SAVE_TASK_ATTACHMENT, TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TASK_CLARIFICATION,
TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING,
@ -834,6 +835,9 @@ const electronAPI: ElectronAPI = {
setChangePresenceTracking: async (teamName: string, enabled: boolean) => { setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled); return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
}, },
setTaskLogStreamTracking: async (teamName: string, enabled: boolean) => {
return invokeIpcWithResult<void>(TEAM_SET_TASK_LOG_STREAM_TRACKING, teamName, enabled);
},
setToolActivityTracking: async (teamName: string, enabled: boolean) => { setToolActivityTracking: async (teamName: string, enabled: boolean) => {
return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled); return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled);
}, },

View file

@ -688,6 +688,9 @@ export class HttpAPIClient implements ElectronAPI {
setChangePresenceTracking: async (): Promise<void> => { setChangePresenceTracking: async (): Promise<void> => {
// Not available in browser mode — no-op. // Not available in browser mode — no-op.
}, },
setTaskLogStreamTracking: async (): Promise<void> => {
// Not available in browser mode — no-op.
},
setToolActivityTracking: async (): Promise<void> => { setToolActivityTracking: async (): Promise<void> => {
// Not available in browser mode — no-op. // Not available in browser mode — no-op.
}, },

View file

@ -14,6 +14,8 @@ interface OngoingIndicatorProps {
showLabel?: boolean; showLabel?: boolean;
/** Custom label text */ /** Custom label text */
label?: string; label?: string;
/** Accessible title/tooltip text */
title?: string;
} }
/** /**
@ -24,11 +26,12 @@ export const OngoingIndicator = ({
size = 'sm', size = 'sm',
showLabel = false, showLabel = false,
label = 'Session in progress...', label = 'Session in progress...',
title = label,
}: Readonly<OngoingIndicatorProps>): React.JSX.Element => { }: Readonly<OngoingIndicatorProps>): React.JSX.Element => {
const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5'; const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5';
return ( return (
<span className="inline-flex items-center gap-2" title="Session in progress"> <span className="inline-flex items-center gap-2" title={title}>
<span className={`relative flex ${dotSize} shrink-0`}> <span className={`relative flex ${dotSize} shrink-0`}>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" /> <span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className={`relative inline-flex rounded-full ${dotSize} bg-green-500`} /> <span className={`relative inline-flex rounded-full ${dotSize} bg-green-500`} />

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
import { import {
ImageLightbox, ImageLightbox,
LightboxLockProvider, LightboxLockProvider,
@ -156,6 +157,8 @@ export const TaskDetailDialog = ({
const [logsRefreshing, setLogsRefreshing] = useState(false); const [logsRefreshing, setLogsRefreshing] = useState(false);
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
const [logsSectionOpen, setLogsSectionOpen] = useState(false);
const [taskLogActivityActive, setTaskLogActivityActive] = useState(false);
const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [changesSectionOpen, setChangesSectionOpen] = useState(false);
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null); const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesLoading, setTaskChangesLoading] = useState(false);
@ -231,6 +234,8 @@ export const TaskDetailDialog = ({
setTaskChangesError(null); setTaskChangesError(null);
setLogsRefreshing(false); setLogsRefreshing(false);
setExecutionPreviewOnline(false); setExecutionPreviewOnline(false);
setLogsSectionOpen(false);
setTaskLogActivityActive(false);
}, [open, currentTask?.id]); }, [open, currentTask?.id]);
const [replyTo, setReplyTo] = useState<{ const [replyTo, setReplyTo] = useState<{
@ -1258,16 +1263,23 @@ export const TaskDetailDialog = ({
key={`task-logs:${currentTask.id}`} key={`task-logs:${currentTask.id}`}
title="Task Logs" title="Task Logs"
icon={<ScrollText size={14} />} icon={<ScrollText size={14} />}
headerExtra={
taskLogActivityActive ? (
<OngoingIndicator size="sm" title="New task logs arriving" />
) : null
}
contentClassName="pl-2.5 overflow-visible" contentClassName="pl-2.5 overflow-visible"
headerClassName="-mx-6 w-[calc(100%+3rem)]" headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6" headerContentClassName="pl-6"
defaultOpen={false} defaultOpen={false}
onOpenChange={setLogsSectionOpen}
keepMounted keepMounted
> >
<div className="min-w-0"> <div className="min-w-0">
<TaskLogsPanel <TaskLogsPanel
teamName={teamName} teamName={teamName}
task={currentTask} task={currentTask}
isOpen={logsSectionOpen}
taskSince={taskSince} taskSince={taskSince}
isExecutionRefreshing={logsRefreshing} isExecutionRefreshing={logsRefreshing}
isExecutionPreviewOnline={executionPreviewOnline} isExecutionPreviewOnline={executionPreviewOnline}
@ -1275,6 +1287,7 @@ export const TaskDetailDialog = ({
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask} showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask} showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
onPreviewOnlineChange={setExecutionPreviewOnline} onPreviewOnlineChange={setExecutionPreviewOnline}
onTaskLogActivityChange={setTaskLogActivityActive}
/> />
</div> </div>
</CollapsibleTeamSection> </CollapsibleTeamSection>

View file

@ -28,10 +28,7 @@ export const MemberExecutionLog = ({
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]); const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
// Show newest groups first — most recent activity is most relevant in execution logs. // Show newest groups first — most recent activity is most relevant in execution logs.
const orderedItems = useMemo( const orderedItems = useMemo(() => [...conversation.items].reverse(), [conversation.items]);
() => [...conversation.items].reverse(),
[conversation.items]
);
// Store collapsed groups instead of expanded: by default, everything is expanded. // Store collapsed groups instead of expanded: by default, everything is expanded.
// This avoids resetting state in an effect when conversation changes. // This avoids resetting state in an effect when conversation changes.
@ -179,6 +176,8 @@ const AIExecutionGroup = ({
return enhanceAIGroup({ ...group, processes: filteredProcesses }); return enhanceAIGroup({ ...group, processes: filteredProcesses });
}, [group, memberName]); }, [group, memberName]);
const hasToggleContent = enhanced.displayItems.length > 0; const hasToggleContent = enhanced.displayItems.length > 0;
const visibleLastOutput =
enhanced.lastOutput?.type === 'tool_result' ? null : enhanced.lastOutput;
return ( return (
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}> <div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
@ -219,7 +218,7 @@ const AIExecutionGroup = ({
</div> </div>
) : null} ) : null}
<LastOutputDisplay lastOutput={enhanced.lastOutput} aiGroupId={group.id} /> <LastOutputDisplay lastOutput={visibleLastOutput} aiGroupId={group.id} />
</div> </div>
); );
}; };

View file

@ -74,6 +74,7 @@ interface MemberLogsTabProps {
teamName: string; teamName: string;
memberName?: string; memberName?: string;
taskId?: string; taskId?: string;
enabled?: boolean;
/** When viewing task logs: include owner's sessions when task is in_progress */ /** When viewing task logs: include owner's sessions when task is in_progress */
taskOwner?: string; taskOwner?: string;
taskStatus?: string; taskStatus?: string;
@ -100,6 +101,7 @@ export const MemberLogsTab = ({
teamName, teamName,
memberName, memberName,
taskId, taskId,
enabled = true,
taskOwner, taskOwner,
taskStatus, taskStatus,
taskWorkIntervals, taskWorkIntervals,
@ -375,6 +377,7 @@ export const MemberLogsTab = ({
const previewHasMore = allPreviewMessages.length > previewVisibleCount; const previewHasMore = allPreviewMessages.length > previewVisibleCount;
const previewOnline = useMemo((): boolean => { const previewOnline = useMemo((): boolean => {
if (!enabled) return false;
if (!previewLog) return false; if (!previewLog) return false;
// Determine the most recent activity timestamp from preview messages // Determine the most recent activity timestamp from preview messages
const newest = previewMessages[0]; const newest = previewMessages[0];
@ -398,7 +401,7 @@ export const MemberLogsTab = ({
if (taskStatus === 'in_progress') return ageMs <= 60_000; if (taskStatus === 'in_progress') return ageMs <= 60_000;
// Completed/other tasks — shorter window // Completed/other tasks — shorter window
return ageMs <= 15_000; return ageMs <= 15_000;
}, [previewLog, previewMessages, taskStatus]); }, [enabled, previewLog, previewMessages, taskStatus]);
const expandedLogSummary = useMemo(() => { const expandedLogSummary = useMemo(() => {
if (!expandedId) return null; if (!expandedId) return null;
@ -443,6 +446,17 @@ export const MemberLogsTab = ({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
if (!enabled) {
return () => {
cancelled = true;
refreshCountRef.current = 0;
if (refreshHideTimeoutRef.current) {
clearTimeout(refreshHideTimeoutRef.current);
refreshHideTimeoutRef.current = null;
}
setRefreshing(false);
};
}
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
let didBeginRefreshing = false; let didBeginRefreshing = false;
@ -505,7 +519,17 @@ export const MemberLogsTab = ({
setRefreshing(false); setRefreshing(false);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince, isTabActive]); }, [
enabled,
teamName,
memberName,
taskId,
taskOwner,
taskStatus,
intervalsKey,
taskSince,
isTabActive,
]);
const fetchDetailForLog = useCallback( const fetchDetailForLog = useCallback(
async ( async (
@ -532,6 +556,9 @@ export const MemberLogsTab = ({
); );
useEffect(() => { useEffect(() => {
if (!enabled) {
return;
}
if (!shouldShowPreview) { if (!shouldShowPreview) {
setPreviewChunks(null); setPreviewChunks(null);
return; return;
@ -557,9 +584,10 @@ export const MemberLogsTab = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]); }, [enabled, fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]);
useEffect(() => { useEffect(() => {
if (!enabled) return;
if (!shouldShowPreview) return; if (!shouldShowPreview) return;
if (!previewLog) return; if (!previewLog) return;
@ -594,9 +622,11 @@ export const MemberLogsTab = ({
taskStatus, taskStatus,
intervalsKey, intervalsKey,
isTabActive, isTabActive,
enabled,
]); ]);
useEffect(() => { useEffect(() => {
if (!enabled) return;
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!expandedLogSummary) return; if (!expandedLogSummary) return;
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
@ -634,6 +664,7 @@ export const MemberLogsTab = ({
taskStatus, taskStatus,
intervalsKey, intervalsKey,
isTabActive, isTabActive,
enabled,
]); ]);
const handleExpand = useCallback( const handleExpand = useCallback(

View file

@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data'; import { asEnhancedChunkArray } from '@renderer/types/data';
@ -26,6 +26,7 @@ import type {
interface TaskActivitySectionProps { interface TaskActivitySectionProps {
teamName: string; teamName: string;
taskId: string; taskId: string;
enabled?: boolean;
} }
function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean { function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean {
@ -262,12 +263,14 @@ const Row = ({
export const TaskActivitySection = ({ export const TaskActivitySection = ({
teamName, teamName,
taskId, taskId,
enabled = true,
}: TaskActivitySectionProps): React.JSX.Element => { }: TaskActivitySectionProps): React.JSX.Element => {
const [detailStates, setDetailStates] = useState<Record<string, ActivityDetailState>>({}); const [detailStates, setDetailStates] = useState<Record<string, ActivityDetailState>>({});
const [entries, setEntries] = useState<BoardTaskActivityEntry[]>([]); const [entries, setEntries] = useState<BoardTaskActivityEntry[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
const fetchDetail = useCallback( const fetchDetail = useCallback(
async (entry: BoardTaskActivityEntry): Promise<void> => { async (entry: BoardTaskActivityEntry): Promise<void> => {
@ -325,13 +328,27 @@ export const TaskActivitySection = ({
); );
useEffect(() => { useEffect(() => {
let cancelled = false;
setEntries([]); setEntries([]);
setExpandedId(null); setExpandedId(null);
setDetailStates({}); setDetailStates({});
setLoading(true);
setError(null); setError(null);
setLoading(enabled);
hasLoadedRef.current = false;
}, [taskId, teamName]);
useEffect(() => {
if (!enabled) {
setLoading(false);
}
}, [enabled]);
useEffect(() => {
let cancelled = false;
if (!enabled) {
return () => {
cancelled = true;
};
}
const load = async (showSpinner: boolean): Promise<void> => { const load = async (showSpinner: boolean): Promise<void> => {
try { try {
@ -344,6 +361,7 @@ export const TaskActivitySection = ({
const result = await api.teams.getTaskActivity(teamName, taskId); const result = await api.teams.getTaskActivity(teamName, taskId);
if (!cancelled) { if (!cancelled) {
setEntries(result); setEntries(result);
hasLoadedRef.current = true;
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) { if (!cancelled) {
@ -357,7 +375,7 @@ export const TaskActivitySection = ({
} }
}; };
void load(true); void load(!hasLoadedRef.current);
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
void load(false); void load(false);
}, 8000); }, 8000);
@ -366,7 +384,7 @@ export const TaskActivitySection = ({
cancelled = true; cancelled = true;
window.clearInterval(intervalId); window.clearInterval(intervalId);
}; };
}, [teamName, taskId]); }, [enabled, teamName, taskId]);
const visibleEntries = useMemo( const visibleEntries = useMemo(
() => () =>

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
@ -14,8 +14,12 @@ import type {
interface TaskLogStreamSectionProps { interface TaskLogStreamSectionProps {
teamName: string; teamName: string;
taskId: string; taskId: string;
taskStatus?: string;
liveEnabled?: boolean;
} }
const LIVE_RELOAD_DEBOUNCE_MS = 350;
function formatRelativeTime(isoString: string): string { function formatRelativeTime(isoString: string): string {
const date = new Date(isoString); const date = new Date(isoString);
const diffMs = Date.now() - date.getTime(); const diffMs = Date.now() - date.getTime();
@ -86,39 +90,160 @@ const SegmentBlock = ({
export const TaskLogStreamSection = ({ export const TaskLogStreamSection = ({
teamName, teamName,
taskId, taskId,
taskStatus,
liveEnabled = true,
}: TaskLogStreamSectionProps): React.JSX.Element => { }: TaskLogStreamSectionProps): React.JSX.Element => {
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null); const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
const requestSeqRef = useRef(0);
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; streamRef.current = stream;
}, [stream]);
const run = async (): Promise<void> => { const loadStream = useCallback(
try { async (options?: { resetSelection?: boolean; background?: boolean }): Promise<void> => {
const resetSelection = options?.resetSelection ?? false;
const background = options?.background ?? false;
const hadExistingStream = streamRef.current != null;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
if (!background) {
setLoading(true); setLoading(true);
setError(null); }
setError((prev) => (background ? prev : null));
try {
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId));
if (cancelled) return; if (requestSeqRef.current !== requestSeq) {
return;
}
setStream(response); setStream(response);
setSelectedParticipantKey(response.defaultFilter); setSelectedParticipantKey((prev) => {
if (resetSelection) {
return response.defaultFilter;
}
const availableParticipantKeys = new Set([
'all',
...response.participants.map((participant) => participant.key),
]);
return availableParticipantKeys.has(prev) ? prev : response.defaultFilter;
});
setError(null);
} catch (loadError) { } catch (loadError) {
if (cancelled) return; if (requestSeqRef.current !== requestSeq) {
setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream'); return;
setStream(null); }
if (!background || streamRef.current == null) {
setError(
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
);
setStream(null);
}
} finally { } finally {
if (!cancelled) { if (requestSeqRef.current === requestSeq && (!background || !hadExistingStream)) {
setLoading(false); setLoading(false);
} }
} }
},
[taskId, teamName]
);
useEffect(() => {
setStream(null);
streamRef.current = null;
setError(null);
setSelectedParticipantKey('all');
requestSeqRef.current += 1;
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
void loadStream({ resetSelection: true });
}, [loadStream]);
const previousTaskMetaRef = useRef({ taskId, taskStatus });
useEffect(() => {
const previousTaskMeta = previousTaskMetaRef.current;
previousTaskMetaRef.current = { taskId, taskStatus };
if (previousTaskMeta.taskId !== taskId) {
return;
}
if (
previousTaskMeta.taskStatus === 'in_progress' &&
taskStatus &&
taskStatus !== 'in_progress'
) {
void loadStream({ background: true });
}
}, [loadStream, taskId, taskStatus]);
useEffect(() => {
if (!liveEnabled) {
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
return;
}
const scheduleReload = (): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
}
reloadTimerRef.current = setTimeout(() => {
reloadTimerRef.current = null;
void loadStream({ background: true });
}, LIVE_RELOAD_DEBOUNCE_MS);
}; };
void run(); const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
return () => { if (
cancelled = true; event.teamName !== teamName ||
event.type !== 'task-log-change' ||
event.taskId !== taskId
) {
return;
}
scheduleReload();
});
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'visible') {
scheduleReload();
}
}; };
}, [taskId, teamName]);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
return () => {
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, [liveEnabled, loadStream, taskId, teamName]);
const participants = stream?.participants ?? []; const participants = stream?.participants ?? [];
const showChips = participants.length > 1; const showChips = participants.length > 1;

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { ExecutionSessionsSection } from './ExecutionSessionsSection'; import { ExecutionSessionsSection } from './ExecutionSessionsSection';
@ -14,6 +15,7 @@ type TaskLogsTab = 'activity' | 'stream' | 'sessions';
interface TaskLogsPanelProps { interface TaskLogsPanelProps {
teamName: string; teamName: string;
task: TeamTaskWithKanban; task: TeamTaskWithKanban;
isOpen?: boolean;
taskSince?: string; taskSince?: string;
isExecutionRefreshing?: boolean; isExecutionRefreshing?: boolean;
isExecutionPreviewOnline?: boolean; isExecutionPreviewOnline?: boolean;
@ -21,11 +23,15 @@ interface TaskLogsPanelProps {
showSubagentPreview?: boolean; showSubagentPreview?: boolean;
showLeadPreview?: boolean; showLeadPreview?: boolean;
onPreviewOnlineChange?: (isOnline: boolean) => void; onPreviewOnlineChange?: (isOnline: boolean) => void;
onTaskLogActivityChange?: (isActive: boolean) => void;
} }
const TASK_LOG_ACTIVITY_PULSE_MS = 1800;
export const TaskLogsPanel = ({ export const TaskLogsPanel = ({
teamName, teamName,
task, task,
isOpen = true,
taskSince, taskSince,
isExecutionRefreshing = false, isExecutionRefreshing = false,
isExecutionPreviewOnline = false, isExecutionPreviewOnline = false,
@ -33,6 +39,7 @@ export const TaskLogsPanel = ({
showSubagentPreview = false, showSubagentPreview = false,
showLeadPreview = false, showLeadPreview = false,
onPreviewOnlineChange, onPreviewOnlineChange,
onTaskLogActivityChange,
}: TaskLogsPanelProps): React.JSX.Element => { }: TaskLogsPanelProps): React.JSX.Element => {
const availableTabs = useMemo<TaskLogsTab[]>(() => { const availableTabs = useMemo<TaskLogsTab[]>(() => {
const tabs: TaskLogsTab[] = []; const tabs: TaskLogsTab[] = [];
@ -48,6 +55,10 @@ export const TaskLogsPanel = ({
const defaultTab = availableTabs[0] ?? 'sessions'; const defaultTab = availableTabs[0] ?? 'sessions';
const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab); const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab);
const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false);
const [hasOpenedContent, setHasOpenedContent] = useState(isOpen);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream');
useEffect(() => { useEffect(() => {
setActiveTab(defaultTab); setActiveTab(defaultTab);
@ -59,6 +70,77 @@ export const TaskLogsPanel = ({
} }
}, [activeTab, availableTabs, defaultTab]); }, [activeTab, availableTabs, defaultTab]);
useEffect(() => {
if (isOpen) {
setHasOpenedContent(true);
}
}, [isOpen]);
useEffect(() => {
onTaskLogActivityChange?.(isTaskLogActivityActive);
}, [isTaskLogActivityActive, onTaskLogActivityChange]);
useEffect(() => {
if (pulseTimerRef.current) {
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = null;
}
setIsTaskLogActivityActive(false);
}, [task.id]);
useEffect(() => {
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
return;
}
void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, true)).catch(() => undefined);
return () => {
void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, false)).catch(
() => undefined
);
};
}, [taskLogTrackingEnabled, teamName]);
useEffect(() => {
if (!taskLogTrackingEnabled) {
if (pulseTimerRef.current) {
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = null;
}
setIsTaskLogActivityActive(false);
return;
}
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if (
event.teamName !== teamName ||
event.type !== 'task-log-change' ||
event.taskId !== task.id
) {
return;
}
setIsTaskLogActivityActive(true);
if (pulseTimerRef.current) {
clearTimeout(pulseTimerRef.current);
}
pulseTimerRef.current = setTimeout(() => {
pulseTimerRef.current = null;
setIsTaskLogActivityActive(false);
}, TASK_LOG_ACTIVITY_PULSE_MS);
});
return () => {
if (pulseTimerRef.current) {
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = null;
}
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, [task.id, taskLogTrackingEnabled, teamName]);
return ( return (
<Tabs <Tabs
value={activeTab} value={activeTab}
@ -81,34 +163,42 @@ export const TaskLogsPanel = ({
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{availableTabs.includes('stream') ? ( {availableTabs.includes('stream') && hasOpenedContent ? (
<TabsContent value="stream" className="mt-0"> <TabsContent value="stream" className="mt-0">
<TaskLogStreamSection teamName={teamName} taskId={task.id} /> <TaskLogStreamSection
teamName={teamName}
taskId={task.id}
taskStatus={task.status}
liveEnabled={isOpen && task.status === 'in_progress'}
/>
</TabsContent> </TabsContent>
) : null} ) : null}
{availableTabs.includes('activity') ? ( {availableTabs.includes('activity') && hasOpenedContent ? (
<TabsContent value="activity" className="mt-0"> <TabsContent value="activity" className="mt-0">
<TaskActivitySection teamName={teamName} taskId={task.id} /> <TaskActivitySection teamName={teamName} taskId={task.id} enabled={isOpen} />
</TabsContent> </TabsContent>
) : null} ) : null}
<TabsContent value="sessions" className="mt-0"> {hasOpenedContent ? (
<ExecutionSessionsSection <TabsContent value="sessions" className="mt-0">
teamName={teamName} <ExecutionSessionsSection
taskId={task.id} teamName={teamName}
taskOwner={task.owner} taskId={task.id}
taskStatus={task.status} taskOwner={task.owner}
taskWorkIntervals={task.workIntervals} taskStatus={task.status}
taskSince={taskSince} taskWorkIntervals={task.workIntervals}
isRefreshing={isExecutionRefreshing} taskSince={taskSince}
isPreviewOnline={isExecutionPreviewOnline} isRefreshing={isExecutionRefreshing}
onRefreshingChange={onRefreshingChange} isPreviewOnline={isExecutionPreviewOnline}
showSubagentPreview={showSubagentPreview} enabled={isOpen}
showLeadPreview={showLeadPreview} onRefreshingChange={onRefreshingChange}
onPreviewOnlineChange={onPreviewOnlineChange} showSubagentPreview={showSubagentPreview}
/> showLeadPreview={showLeadPreview}
</TabsContent> onPreviewOnlineChange={onPreviewOnlineChange}
/>
</TabsContent>
) : null}
</Tabs> </Tabs>
); );
}; };

View file

@ -429,6 +429,7 @@ export interface TeamsAPI {
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>; getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>; setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>; setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
setTaskLogStreamTracking: (teamName: string, enabled: boolean) => Promise<void>;
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
deleteTeam: (teamName: string) => Promise<void>; deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>; restoreTeam: (teamName: string) => Promise<void>;

View file

@ -875,6 +875,7 @@ export interface TeamChangeEvent {
| 'config' | 'config'
| 'inbox' | 'inbox'
| 'log-source-change' | 'log-source-change'
| 'task-log-change'
| 'task' | 'task'
| 'lead-activity' | 'lead-activity'
| 'lead-context' | 'lead-context'
@ -885,6 +886,7 @@ export interface TeamChangeEvent {
teamName: string; teamName: string;
runId?: string; runId?: string;
detail?: string; detail?: string;
taskId?: string;
} }
export interface ProjectBranchChangeEvent { export interface ProjectBranchChangeEvent {

View file

@ -586,6 +586,189 @@ describe('BoardTaskLogStreamService integration', () => {
expect(toolNames).toContain('mcp__agent-teams__task_complete'); expect(toolNames).toContain('mcp__agent-teams__task_complete');
}); });
it('sanitizes inferred SendMessage results instead of surfacing raw json payloads', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-sendmessage-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const task = createTask({
owner: 'tom',
workIntervals: [
{
startedAt: '2026-04-12T15:36:00.000Z',
completedAt: '2026-04-12T15:40:00.000Z',
},
],
});
const lines = [
createAssistantEntry({
uuid: 'a-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-start',
timestamp: '2026-04-12T15:36:00.120Z',
sourceToolAssistantUUID: 'a-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-send',
timestamp: '2026-04-12T15:36:10.000Z',
requestId: 'req-send',
content: [
{
type: 'tool_use',
id: 'call-send',
name: 'SendMessage',
input: {
to: 'team-lead',
summary: '#abc done',
message: 'Detailed body',
},
},
],
}),
createUserEntry({
uuid: 'u-send',
timestamp: '2026-04-12T15:36:10.200Z',
sourceToolAssistantUUID: 'a-send',
content: [
{
type: 'tool_result',
tool_use_id: 'call-send',
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: "Message sent to team-lead's inbox",
routing: {
target: '@team-lead',
summary: '#abc done',
content: 'Detailed body',
},
}),
},
],
},
],
toolUseResult: {
success: true,
message: "Message sent to team-lead's inbox",
routing: {
target: '@team-lead',
summary: '#abc done',
content: 'Detailed body',
},
},
}),
];
await writeFile(
transcriptPath,
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
'utf8',
);
const recordSource = {
getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task),
};
const taskReader = {
getTasks: async () => [task],
getDeletedTasks: async () => [] as TeamTask[],
};
const transcriptSourceLocator = {
getContext: async () =>
({
transcriptFiles: [transcriptPath],
config: {
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
}) as never,
};
const service = new BoardTaskLogStreamService(
recordSource as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
taskReader as never,
transcriptSourceLocator as never,
);
const response = await service.getTaskLogStream(TEAM_NAME, task.id);
const rawMessages = flattenRawMessages(response);
const sendResult = rawMessages.find((message) => message.uuid === 'u-send');
const semanticToolResult = response.segments
.flatMap((segment) => segment.chunks)
.flatMap((chunk) => ('semanticSteps' in chunk ? (chunk.semanticSteps ?? []) : []))
.find((step) => step.type === 'tool_result' && step.id === 'call-send');
expect(rawMessages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.name))).toContain(
'SendMessage'
);
expect(sendResult?.toolResults).toEqual([
{
toolUseId: 'call-send',
content: "Message sent to team-lead's inbox - #abc done",
isError: false,
},
]);
expect(semanticToolResult).toMatchObject({
id: 'call-send',
type: 'tool_result',
content: expect.objectContaining({
toolResultContent: "Message sent to team-lead's inbox - #abc done",
}),
});
});
it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-'));
tempDirs.push(dir); tempDirs.push(dir);

View file

@ -630,4 +630,154 @@ describe('BoardTaskLogStreamService', () => {
}); });
expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' }); expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' });
}); });
it('sanitizes SendMessage json payloads into a concise human-readable result', async () => {
const bob = {
memberName: 'bob',
role: 'member' as const,
sessionId: 'session-bob',
agentId: 'agent-bob',
isSidechain: true,
};
const candidate = {
...makeCandidate('c1', '2026-04-12T16:00:00.000Z', bob, 'tool-send'),
actionCategory: 'execution' as const,
canonicalToolName: 'SendMessage',
};
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: bob,
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'assistant-send',
toolUseId: 'tool-send',
sourceOrder: 1,
},
records: candidate.records,
filteredMessages: [
{
uuid: 'assistant-send',
parentUuid: null,
type: 'assistant' as const,
timestamp: new Date('2026-04-12T16:00:00.000Z'),
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-send',
name: 'SendMessage',
input: { to: 'team-lead', summary: '#abc done' },
} as never,
],
toolCalls: [],
toolResults: [],
isSidechain: false,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'user-send-result',
parentUuid: 'assistant-send',
type: 'user' as const,
timestamp: new Date('2026-04-12T16:00:02.000Z'),
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-send',
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: "Message sent to team-lead's inbox",
routing: {
target: '@team-lead',
summary: '#abc done',
content: 'Detailed body that should not leak into the preview.',
},
}),
} as never,
],
} as never,
],
toolCalls: [],
toolResults: [
{
toolUseId: 'tool-send',
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: "Message sent to team-lead's inbox",
routing: {
target: '@team-lead',
summary: '#abc done',
content: 'Detailed body that should not leak into the preview.',
},
}),
},
],
isError: false,
},
],
sourceToolUseID: 'tool-send',
sourceToolAssistantUUID: 'assistant-send',
toolUseResult: {
success: true,
message: "Message sent to team-lead's inbox",
routing: {
target: '@team-lead',
summary: '#abc done',
content: 'Detailed body that should not leak into the preview.',
},
},
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-send-result');
const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : [];
expect(content[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-send',
content: "Message sent to team-lead's inbox - #abc done",
});
expect(toolResultMessage?.toolResults).toEqual([
{
toolUseId: 'tool-send',
content: "Message sent to team-lead's inbox - #abc done",
isError: false,
},
]);
});
}); });

View file

@ -0,0 +1,119 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeamLogSourceTracker } from '../../../../src/main/services/team/TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder';
import type { TeamChangeEvent } from '../../../../src/shared/types';
describe('TeamLogSourceTracker', () => {
let tempDir: string | null = null;
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
it('emits task-log-change for matching runtime freshness signals without broad log-source-change', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-'));
const logsFinder = {
getLogSourceWatchContext: vi.fn(async () => ({
projectDir: tempDir!,
sessionIds: [],
})),
} as unknown as TeamMemberLogsFinder;
const tracker = new TeamLogSourceTracker(logsFinder);
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
tracker.setEmitter(emitter);
await tracker.enableTracking('demo', 'change_presence');
emitter.mockClear();
await new Promise((resolve) => setTimeout(resolve, 100));
const taskId = '123e4567-e89b-12d3-a456-426614174999';
const signalDir = path.join(tempDir, '.board-task-log-freshness');
await mkdir(signalDir, { recursive: true });
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId,
});
});
expect(emitter.mock.calls.map(([event]) => event.type)).not.toContain('log-source-change');
await tracker.disableTracking('demo', 'change_presence');
});
it('keeps task-log tracking alive until the last consumer unsubscribes', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-'));
const logsFinder = {
getLogSourceWatchContext: vi.fn(async () => ({
projectDir: tempDir!,
sessionIds: [],
})),
} 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');
await tracker.enableTracking('demo', 'task_log_stream');
emitter.mockClear();
await new Promise((resolve) => setTimeout(resolve, 100));
await tracker.disableTracking('demo', 'task_log_stream');
const taskId = '223e4567-e89b-12d3-a456-426614174999';
const signalDir = path.join(tempDir, '.board-task-log-freshness');
await mkdir(signalDir, { recursive: true });
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId,
});
});
emitter.mockClear();
await tracker.disableTracking('demo', 'task_log_stream');
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":false}');
await new Promise((resolve) => setTimeout(resolve, 350));
expect(emitter).not.toHaveBeenCalled();
});
it('does not reinitialize when another consumer joins an already tracked team', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-init-'));
const logsFinder = {
getLogSourceWatchContext: vi.fn(async () => ({
projectDir: tempDir!,
sessionIds: [],
})),
} as unknown as TeamMemberLogsFinder;
const tracker = new TeamLogSourceTracker(logsFinder);
await tracker.enableTracking('demo', 'tool_activity');
await tracker.enableTracking('demo', 'task_log_stream');
expect(logsFinder.getLogSourceWatchContext).toHaveBeenCalledTimes(1);
await tracker.disableTracking('demo', 'task_log_stream');
await tracker.disableTracking('demo', 'tool_activity');
});
});

View file

@ -0,0 +1,129 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
const transformState = {
items: [] as Array<{ type: 'ai'; group: Record<string, unknown> }>,
};
const enhanceState = {
value: null as null | Record<string, unknown>,
};
vi.mock('@renderer/utils/groupTransformer', () => ({
transformChunksToConversation: () => ({
items: transformState.items,
}),
}));
vi.mock('@renderer/utils/aiGroupEnhancer', () => ({
enhanceAIGroup: (group: Record<string, unknown>) => ({
...group,
...(enhanceState.value ?? {}),
}),
}));
vi.mock('@renderer/components/chat/LastOutputDisplay', () => ({
LastOutputDisplay: ({ lastOutput }: { lastOutput: unknown }) => {
if (!lastOutput) {
return null;
}
return React.createElement(
'div',
{ 'data-testid': 'last-output' },
JSON.stringify(lastOutput)
);
},
}));
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function setSingleAiGroup(): void {
transformState.items = [
{
type: 'ai',
group: {
id: 'group-1',
steps: [],
responses: [],
processes: [],
},
},
];
}
describe('MemberExecutionLog', () => {
afterEach(() => {
document.body.innerHTML = '';
transformState.items = [];
enhanceState.value = null;
});
it('suppresses duplicated last tool_result banners in execution-log mode', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setSingleAiGroup();
enhanceState.value = {
displayItems: [],
itemsSummary: '1 tool',
lastOutput: {
type: 'tool_result',
toolName: 'Read',
toolResult: 'raw file body',
isError: false,
timestamp: new Date('2026-04-18T13:23:12.982Z'),
},
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(MemberExecutionLog, { chunks: [] }));
await flushMicrotasks();
});
expect(host.querySelector('[data-testid="last-output"]')).toBeNull();
expect(host.textContent).not.toContain('raw file body');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('keeps plain text last output visible', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setSingleAiGroup();
enhanceState.value = {
displayItems: [],
itemsSummary: '1 output',
lastOutput: {
type: 'text',
text: 'final answer',
timestamp: new Date('2026-04-18T13:23:12.982Z'),
},
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(MemberExecutionLog, { chunks: [] }));
await flushMicrotasks();
});
expect(host.querySelector('[data-testid="last-output"]')).not.toBeNull();
expect(host.textContent).toContain('final answer');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -241,6 +241,120 @@ describe('TaskActivitySection', () => {
}); });
}); });
it('does not load activity while disabled', 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(TaskActivitySection, {
teamName: 'demo',
taskId: 'task-a',
enabled: false,
})
);
await flushMicrotasks();
});
expect(apiState.getTaskActivity).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('preserves loaded activity while disabled and refreshes again on re-enable', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskActivity
.mockResolvedValueOnce([
makeEntry({
id: 'started',
timestamp: '2026-04-13T10:34:00.000Z',
linkKind: 'lifecycle',
action: {
canonicalToolName: 'task_start',
category: 'status',
},
}),
])
.mockResolvedValueOnce([
makeEntry({
id: 'started',
timestamp: '2026-04-13T10:34:00.000Z',
linkKind: 'lifecycle',
action: {
canonicalToolName: 'task_start',
category: 'status',
},
}),
makeEntry({
id: 'viewed',
timestamp: '2026-04-13T10:35:00.000Z',
linkKind: 'board_action',
action: {
canonicalToolName: 'task_get',
category: 'read',
},
}),
]);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskActivitySection, {
teamName: 'demo',
taskId: 'task-a',
enabled: true,
})
);
await flushMicrotasks();
});
expect(host.textContent).toContain('Started work');
expect(apiState.getTaskActivity).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
React.createElement(TaskActivitySection, {
teamName: 'demo',
taskId: 'task-a',
enabled: false,
})
);
await flushMicrotasks();
});
expect(host.textContent).toContain('Started work');
expect(apiState.getTaskActivity).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
React.createElement(TaskActivitySection, {
teamName: 'demo',
taskId: 'task-a',
enabled: true,
})
);
await flushMicrotasks();
});
expect(host.textContent).toContain('Started work');
expect(host.textContent).toContain('Viewed task');
expect(apiState.getTaskActivity).toHaveBeenCalledTimes(2);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('loads inline detail lazily and renders metadata plus a linked tool card', async () => { it('loads inline detail lazily and renders metadata plus a linked tool card', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskActivity.mockResolvedValue([ apiState.getTaskActivity.mockResolvedValue([

View file

@ -404,8 +404,8 @@ describe('TaskLogStreamSection integration', () => {
expect(text).toContain('Edit'); expect(text).toContain('Edit');
expect(text).toContain('Claude'); expect(text).toContain('Claude');
expect(text).toContain('3 tool calls'); expect(text).toContain('3 tool calls');
expect(text).toContain('Audit complete');
expect(text).not.toContain('[]'); expect(text).not.toContain('[]');
expect(text).not.toContain('Audit complete');
expect(text).not.toContain('lead session'); expect(text).not.toContain('lead session');
await act(async () => { await act(async () => {

View file

@ -2,12 +2,15 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { TeamChangeEvent } from '../../../../../src/shared/types';
import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types'; import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types';
const apiState = { const apiState = {
getTaskLogStream: vi.fn< getTaskLogStream: vi.fn<
(teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse> (teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>
>(), >(),
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
}; };
vi.mock('@renderer/api', () => ({ vi.mock('@renderer/api', () => ({
@ -15,6 +18,10 @@ vi.mock('@renderer/api', () => ({
teams: { teams: {
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) => getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
apiState.getTaskLogStream(...args), apiState.getTaskLogStream(...args),
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
apiState.onTeamChange(...args),
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
apiState.setTaskLogStreamTracking(...args),
}, },
}, },
})); }));
@ -40,10 +47,46 @@ function flushMicrotasks(): Promise<void> {
return Promise.resolve(); return Promise.resolve();
} }
function buildParticipant(key: string, label: string) {
return {
key,
label,
role: 'member' as const,
isLead: false,
isSidechain: true,
};
}
function buildSegment(args: {
id: string;
participantKey: string;
memberName: string;
startTimestamp: string;
endTimestamp: string;
}) {
return {
id: args.id,
participantKey: args.participantKey,
actor: {
memberName: args.memberName,
role: 'member' as const,
sessionId: `${args.memberName}-session-${args.id}`,
agentId: `${args.memberName}-agent`,
isSidechain: true,
},
startTimestamp: args.startTimestamp,
endTimestamp: args.endTimestamp,
chunks: [{ id: `chunk-${args.id}`, chunkType: 'user', rawMessages: [] }] as never,
};
}
describe('TaskLogStreamSection', () => { describe('TaskLogStreamSection', () => {
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
apiState.getTaskLogStream.mockReset(); apiState.getTaskLogStream.mockReset();
apiState.onTeamChange.mockReset();
apiState.setTaskLogStreamTracking.mockReset();
vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@ -175,6 +218,7 @@ describe('TaskLogStreamSection', () => {
it('honors a participant default filter from the stream response', async () => { it('honors a participant default filter from the stream response', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.onTeamChange.mockImplementation(() => () => undefined);
apiState.getTaskLogStream.mockResolvedValueOnce({ apiState.getTaskLogStream.mockResolvedValueOnce({
participants: [ participants: [
{ {
@ -220,4 +264,248 @@ describe('TaskLogStreamSection', () => {
await flushMicrotasks(); await flushMicrotasks();
}); });
}); });
it('live-refreshes on matching task-log changes and preserves the selected participant filter', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
apiState.onTeamChange.mockImplementation((callback) => {
handler = callback;
return () => {
handler = null;
};
});
apiState.getTaskLogStream
.mockResolvedValueOnce({
participants: [
buildParticipant('member:tom', 'tom'),
buildParticipant('member:alice', 'alice'),
],
defaultFilter: 'all',
segments: [
buildSegment({
id: 'tom-1',
participantKey: 'member:tom',
memberName: 'tom',
startTimestamp: '2026-04-12T16:00:00.000Z',
endTimestamp: '2026-04-12T16:01:00.000Z',
}),
buildSegment({
id: 'alice-1',
participantKey: 'member:alice',
memberName: 'alice',
startTimestamp: '2026-04-12T16:02:00.000Z',
endTimestamp: '2026-04-12T16:03:00.000Z',
}),
],
})
.mockResolvedValueOnce({
participants: [
buildParticipant('member:tom', 'tom'),
buildParticipant('member:alice', 'alice'),
],
defaultFilter: 'all',
segments: [
buildSegment({
id: 'tom-1',
participantKey: 'member:tom',
memberName: 'tom',
startTimestamp: '2026-04-12T16:00:00.000Z',
endTimestamp: '2026-04-12T16:01:00.000Z',
}),
buildSegment({
id: 'alice-1',
participantKey: 'member:alice',
memberName: 'alice',
startTimestamp: '2026-04-12T16:02:00.000Z',
endTimestamp: '2026-04-12T16:03:00.000Z',
}),
buildSegment({
id: 'tom-2',
participantKey: 'member:tom',
memberName: 'tom',
startTimestamp: '2026-04-12T16:04:00.000Z',
endTimestamp: '2026-04-12T16:05:00.000Z',
}),
],
});
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();
});
const tomButton = [...host.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === 'tom'
);
expect(tomButton).toBeDefined();
await act(async () => {
tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flushMicrotasks();
});
expect(
[...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent)
).toEqual(['tom:1']);
expect(handler).toBeTypeOf('function');
await act(async () => {
handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-a' });
vi.advanceTimersByTime(400);
await flushMicrotasks();
});
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-b' });
vi.advanceTimersByTime(400);
await flushMicrotasks();
});
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' });
vi.advanceTimersByTime(400);
await flushMicrotasks();
});
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2);
expect(
[...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent)
).toEqual(['tom:1', 'tom:1']);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('does not subscribe to live refresh when live mode is disabled', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.onTeamChange.mockImplementation(() => () => undefined);
apiState.getTaskLogStream.mockResolvedValueOnce({
participants: [buildParticipant('member:tom', 'tom')],
defaultFilter: 'all',
segments: [
buildSegment({
id: 'tom-1',
participantKey: 'member:tom',
memberName: 'tom',
startTimestamp: '2026-04-12T16:00:00.000Z',
endTimestamp: '2026-04-12T16:01:00.000Z',
}),
],
});
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',
liveEnabled: false,
})
);
await flushMicrotasks();
});
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
expect(apiState.onTeamChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('revalidates once when the task leaves in-progress state', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskLogStream
.mockResolvedValueOnce({
participants: [buildParticipant('member:tom', 'tom')],
defaultFilter: 'all',
segments: [
buildSegment({
id: 'tom-1',
participantKey: 'member:tom',
memberName: 'tom',
startTimestamp: '2026-04-12T16:00:00.000Z',
endTimestamp: '2026-04-12T16:01:00.000Z',
}),
],
})
.mockResolvedValueOnce({
participants: [buildParticipant('member:tom', 'tom')],
defaultFilter: 'all',
segments: [
buildSegment({
id: 'tom-1',
participantKey: 'member:tom',
memberName: 'tom',
startTimestamp: '2026-04-12T16:00:00.000Z',
endTimestamp: '2026-04-12T16:01:00.000Z',
}),
buildSegment({
id: 'tom-2',
participantKey: 'member:tom',
memberName: 'tom',
startTimestamp: '2026-04-12T16:02:00.000Z',
endTimestamp: '2026-04-12T16:03:00.000Z',
}),
],
});
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',
taskStatus: 'in_progress',
liveEnabled: true,
})
);
await flushMicrotasks();
});
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
React.createElement(TaskLogStreamSection, {
teamName: 'demo',
taskId: 'task-a',
taskStatus: 'completed',
liveEnabled: false,
})
);
await flushMicrotasks();
});
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2);
expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(2);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
}); });

View file

@ -4,25 +4,61 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { TaskLogsPanel } from '../../../../../src/renderer/components/team/taskLogs/TaskLogsPanel'; import { TaskLogsPanel } from '../../../../../src/renderer/components/team/taskLogs/TaskLogsPanel';
import type { TeamChangeEvent } from '../../../../../src/shared/types';
import type { TeamTaskWithKanban } from '../../../../../src/shared/types'; import type { TeamTaskWithKanban } from '../../../../../src/shared/types';
const apiState = {
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
apiState.onTeamChange(...args),
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
apiState.setTaskLogStreamTracking(...args),
},
},
}));
const featureGateState = { const featureGateState = {
activityEnabled: true, activityEnabled: true,
exactLogsEnabled: true, exactLogsEnabled: true,
}; };
const taskActivityProps = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>,
}));
vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskActivitySection', () => ({ vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskActivitySection', () => ({
TaskActivitySection: () => React.createElement('div', { 'data-testid': 'task-activity' }, 'activity'), TaskActivitySection: (props: Record<string, unknown>) => {
taskActivityProps.calls.push(props);
return React.createElement('div', { 'data-testid': 'task-activity' }, 'activity');
},
}));
const taskLogStreamProps = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>,
}));
const executionSessionsProps = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>,
})); }));
vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskLogStreamSection', () => ({ vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskLogStreamSection', () => ({
TaskLogStreamSection: () => TaskLogStreamSection: (props: Record<string, unknown>) => {
React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream'), taskLogStreamProps.calls.push(props);
return React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream');
},
})); }));
vi.mock('../../../../../src/renderer/components/team/taskLogs/ExecutionSessionsSection', () => ({ vi.mock('../../../../../src/renderer/components/team/taskLogs/ExecutionSessionsSection', () => ({
ExecutionSessionsSection: () => ExecutionSessionsSection: (props: Record<string, unknown>) => {
React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions'), executionSessionsProps.calls.push(props);
return React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions');
},
})); }));
vi.mock('../../../../../src/renderer/components/team/taskLogs/featureGates', () => ({ vi.mock('../../../../../src/renderer/components/team/taskLogs/featureGates', () => ({
@ -128,6 +164,12 @@ describe('TaskLogsPanel', () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
featureGateState.activityEnabled = true; featureGateState.activityEnabled = true;
featureGateState.exactLogsEnabled = true; featureGateState.exactLogsEnabled = true;
taskActivityProps.calls = [];
taskLogStreamProps.calls = [];
executionSessionsProps.calls = [];
apiState.onTeamChange.mockReset();
apiState.setTaskLogStreamTracking.mockReset();
vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@ -147,6 +189,12 @@ describe('TaskLogsPanel', () => {
expect(host.textContent).toContain('Execution Sessions'); expect(host.textContent).toContain('Execution Sessions');
expect(findTabButton(host, 'Task Log Stream')?.getAttribute('data-state')).toBe('active'); expect(findTabButton(host, 'Task Log Stream')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull(); expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull();
expect(taskLogStreamProps.calls.at(-1)).toMatchObject({
teamName: 'demo',
taskId: 'task-1',
taskStatus: 'in_progress',
liveEnabled: true,
});
const activityTab = findTabButton(host, 'Task Activity'); const activityTab = findTabButton(host, 'Task Activity');
expect(activityTab).not.toBeNull(); expect(activityTab).not.toBeNull();
@ -158,6 +206,11 @@ describe('TaskLogsPanel', () => {
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active'); expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull(); expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull();
expect(taskActivityProps.calls.at(-1)).toMatchObject({
teamName: 'demo',
taskId: 'task-1',
enabled: true,
});
const sessionsTab = findTabButton(host, 'Execution Sessions'); const sessionsTab = findTabButton(host, 'Execution Sessions');
expect(sessionsTab).not.toBeNull(); expect(sessionsTab).not.toBeNull();
@ -169,6 +222,11 @@ describe('TaskLogsPanel', () => {
expect(findTabButton(host, 'Execution Sessions')?.getAttribute('data-state')).toBe('active'); expect(findTabButton(host, 'Execution Sessions')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="execution-sessions"]')).not.toBeNull(); expect(host.querySelector('[data-testid="execution-sessions"]')).not.toBeNull();
expect(executionSessionsProps.calls.at(-1)).toMatchObject({
teamName: 'demo',
taskId: 'task-1',
enabled: true,
});
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
@ -192,6 +250,234 @@ describe('TaskLogsPanel', () => {
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active'); expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull(); expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull();
expect(host.textContent).not.toContain('Task Log Stream'); expect(host.textContent).not.toContain('Task Log Stream');
expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled();
expect(apiState.onTeamChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('does not mount Task Activity content while the section is collapsed and stream is disabled', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
featureGateState.exactLogsEnabled = false;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskLogsPanel, {
teamName: 'demo',
task: makeTask(),
isOpen: false,
})
);
await flushMicrotasks();
});
expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull();
expect(host.querySelector('[data-testid="task-activity"]')).toBeNull();
expect(taskLogStreamProps.calls).toHaveLength(0);
expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled();
expect(apiState.onTeamChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('keeps task-log tracking active across tab switches and pulses on matching live updates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
const activityStates: boolean[] = [];
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
apiState.onTeamChange.mockImplementation((callback) => {
handler = callback;
return () => {
handler = null;
};
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskLogsPanel, {
teamName: 'demo',
task: makeTask(),
onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive),
})
);
await flushMicrotasks();
});
expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledTimes(1);
expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true);
expect(handler).toBeTypeOf('function');
expect(activityStates).toEqual([false]);
const activityTab = findTabButton(host, 'Task Activity');
expect(activityTab).not.toBeNull();
await act(async () => {
activityTab?.click();
await flushMicrotasks();
});
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' });
await flushMicrotasks();
});
expect(activityStates).toEqual([false]);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
await flushMicrotasks();
});
expect(activityStates).toEqual([false, true]);
await act(async () => {
vi.advanceTimersByTime(1800);
await flushMicrotasks();
});
expect(activityStates).toEqual([false, true, false]);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false);
});
it('does not mount Task Log Stream content while the section is collapsed but still pulses on matching updates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
const activityStates: boolean[] = [];
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
apiState.onTeamChange.mockImplementation((callback) => {
handler = callback;
return () => {
handler = null;
};
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TaskLogsPanel, {
teamName: 'demo',
task: makeTask(),
isOpen: false,
onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive),
})
);
await flushMicrotasks();
});
expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull();
expect(taskLogStreamProps.calls).toHaveLength(0);
expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true);
expect(handler).toBeTypeOf('function');
expect(activityStates).toEqual([false]);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
await flushMicrotasks();
});
expect(activityStates).toEqual([false, true]);
await act(async () => {
vi.advanceTimersByTime(1800);
await flushMicrotasks();
});
expect(activityStates).toEqual([false, true, false]);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false);
});
it('pauses mounted activity and sessions tabs when the section collapses', 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(TaskLogsPanel, { teamName: 'demo', task: makeTask() }));
await flushMicrotasks();
});
const activityTab = findTabButton(host, 'Task Activity');
expect(activityTab).not.toBeNull();
await act(async () => {
activityTab?.click();
await flushMicrotasks();
});
expect(taskActivityProps.calls.at(-1)).toMatchObject({ enabled: true });
await act(async () => {
root.render(
React.createElement(TaskLogsPanel, {
teamName: 'demo',
task: makeTask(),
isOpen: false,
})
);
await flushMicrotasks();
});
expect(taskActivityProps.calls.at(-1)).toMatchObject({ enabled: false });
const sessionsTab = findTabButton(host, 'Execution Sessions');
expect(sessionsTab).not.toBeNull();
await act(async () => {
root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() }));
sessionsTab?.click();
await flushMicrotasks();
});
expect(executionSessionsProps.calls.at(-1)).toMatchObject({ enabled: true });
await act(async () => {
root.render(
React.createElement(TaskLogsPanel, {
teamName: 'demo',
task: makeTask(),
isOpen: false,
})
);
await flushMicrotasks();
});
expect(executionSessionsProps.calls.at(-1)).toMatchObject({ enabled: false });
await act(async () => { await act(async () => {
root.unmount(); root.unmount();