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,
boardTaskExactLogDetailService,
teammateToolTracker ?? undefined,
teamLogSourceTracker,
branchStatusService ?? undefined,
{
rewire: rewireContextEvents,

View file

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

View file

@ -56,6 +56,7 @@ import {
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SHOW_MESSAGE_NOTIFICATION,
@ -135,6 +136,7 @@ import type {
BranchStatusService,
MemberStatsComputer,
TeamDataService,
TeamLogSourceTracker,
TeammateToolTracker,
TeamMemberLogsFinder,
TeamProvisioningService,
@ -435,6 +437,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null;
let teamBackupService: TeamBackupService | null = null;
let teammateToolTracker: TeammateToolTracker | null = null;
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
let branchStatusService: BranchStatusService | null = null;
let boardTaskActivityService: BoardTaskActivityService | null = null;
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
@ -471,6 +474,7 @@ export function initializeTeamHandlers(
statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService,
toolTracker?: TeammateToolTracker,
logSourceTracker?: TeamLogSourceTracker,
branchTracker?: BranchStatusService,
taskActivityService?: BoardTaskActivityService,
taskActivityDetailService?: BoardTaskActivityDetailService,
@ -485,6 +489,7 @@ export function initializeTeamHandlers(
memberStatsComputer = statsComputer ?? null;
teamBackupService = backupService ?? null;
teammateToolTracker = toolTracker ?? null;
teamLogSourceTracker = logSourceTracker ?? null;
branchStatusService = branchTracker ?? null;
boardTaskActivityService = taskActivityService ?? 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_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
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_GET_CLAUDE_LOGS, handleGetClaudeLogs);
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_SET_CHANGE_PRESENCE_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_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
@ -657,6 +664,13 @@ function getTeammateToolTracker(): TeammateToolTracker {
return teammateToolTracker;
}
function getTeamLogSourceTracker(): TeamLogSourceTracker {
if (!teamLogSourceTracker) {
throw new Error('Team log source tracker is not initialized');
}
return teamLogSourceTracker;
}
function getBranchStatusService(): BranchStatusService {
if (!branchStatusService) {
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(
_event: IpcMainInvokeEvent,
teamName: unknown

View file

@ -14,13 +14,15 @@ import type { TeamChangeEvent } from '@shared/types';
import type { FSWatcher } from 'chokidar';
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 {
projectFingerprint: string | null;
logSourceGeneration: string | null;
}
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity';
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream';
interface TrackingState {
watcher: FSWatcher | null;
@ -31,7 +33,7 @@ interface TrackingState {
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
recomputeVersion: number | null;
snapshot: TeamLogSourceSnapshot;
consumers: Set<TeamLogSourceTrackingConsumer>;
consumerCounts: Map<TeamLogSourceTrackingConsumer, number>;
lifecycleVersion: number;
}
@ -67,19 +69,29 @@ export class TeamLogSourceTracker {
consumer: TeamLogSourceTrackingConsumer
): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (!state.consumers.has(consumer)) {
state.consumers.add(consumer);
const activeConsumerCountBefore = this.getActiveConsumerCount(state);
state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1);
if (activeConsumerCountBefore === 0) {
state.lifecycleVersion += 1;
}
if (
state.initializePromise &&
state.initializeVersion === state.lifecycleVersion &&
state.consumers.size > 0
this.getActiveConsumerCount(state) > 0
) {
return state.initializePromise;
}
if (
activeConsumerCountBefore > 0 &&
(state.watcher !== null ||
state.projectDir !== null ||
state.snapshot.logSourceGeneration !== null)
) {
return { ...state.snapshot };
}
const initializeVersion = state.lifecycleVersion;
const initializePromise = this.initializeTeam(teamName, initializeVersion)
.catch((error) => {
@ -118,13 +130,21 @@ export class TeamLogSourceTracker {
recomputePromise: null,
recomputeVersion: null,
snapshot: { projectFingerprint: null, logSourceGeneration: null },
consumers: new Set(),
consumerCounts: new Map(),
lifecycleVersion: 0,
};
this.stateByTeam.set(teamName, 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> {
await this.disableTracking(teamName, 'change_presence');
}
@ -138,15 +158,24 @@ export class TeamLogSourceTracker {
return { projectFingerprint: null, logSourceGeneration: null };
}
if (state.consumers.has(consumer)) {
state.consumers.delete(consumer);
state.lifecycleVersion += 1;
const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0;
if (currentConsumerCount > 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 };
}
if (currentConsumerCount > 0) {
state.lifecycleVersion += 1;
}
if (state.refreshTimer) {
clearTimeout(state.refreshTimer);
state.refreshTimer = null;
@ -164,7 +193,11 @@ export class TeamLogSourceTracker {
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
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(
@ -207,7 +240,11 @@ export class TeamLogSourceTracker {
expectedVersion: number
): Promise<void> {
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;
}
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);
if (!current || current.consumers.size === 0) {
if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) {
return;
}
if (
changedPath &&
this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath)
) {
return;
}
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> {
const state = this.getOrCreateState(teamName);
if (state.consumers.size === 0) {
if (this.getActiveConsumerCount(state) === 0) {
return state.snapshot;
}
if (
state.recomputePromise &&
state.recomputeVersion === state.lifecycleVersion &&
state.consumers.size > 0
this.getActiveConsumerCount(state) > 0
) {
return state.recomputePromise;
}

View file

@ -161,14 +161,47 @@ function extractBoardToolOutputText(
return null;
}
const normalizedToolName = toolName.trim().toLowerCase();
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;
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
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;
}
@ -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(
messages: ParsedMessage[],
canonicalToolName?: string
): ParsedMessage[] {
return messages.map((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;
if (
@ -388,12 +476,20 @@ function sanitizeJsonLikeToolResultPayloads(
});
if (!changed) {
return nextMessage;
if (!toolResultsChanged) {
return nextMessage;
}
return {
...nextMessage,
toolResults: nextToolResults,
};
}
return {
...nextMessage,
content: nextContent,
toolResults: toolResultsChanged ? nextToolResults : nextMessage.toolResults,
};
});
}
@ -1011,6 +1107,15 @@ export class BoardTaskLogStreamService {
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({
id: `inferred:${filePath}:${message.uuid}`,
timestamp: message.timestamp.toISOString(),
@ -1018,7 +1123,7 @@ export class BoardTaskLogStreamService {
sortOrder: index,
participantKey: buildParticipantKey(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 */
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) */
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';

View file

@ -161,6 +161,7 @@ import {
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
@ -834,6 +835,9 @@ const electronAPI: ElectronAPI = {
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
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) => {
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> => {
// Not available in browser mode — no-op.
},
setTaskLogStreamTracking: async (): Promise<void> => {
// Not available in browser mode — no-op.
},
setToolActivityTracking: async (): Promise<void> => {
// Not available in browser mode — no-op.
},

View file

@ -14,6 +14,8 @@ interface OngoingIndicatorProps {
showLabel?: boolean;
/** Custom label text */
label?: string;
/** Accessible title/tooltip text */
title?: string;
}
/**
@ -24,11 +26,12 @@ export const OngoingIndicator = ({
size = 'sm',
showLabel = false,
label = 'Session in progress...',
title = label,
}: Readonly<OngoingIndicatorProps>): React.JSX.Element => {
const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5';
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="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`} />

View file

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

View file

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

View file

@ -74,6 +74,7 @@ interface MemberLogsTabProps {
teamName: string;
memberName?: string;
taskId?: string;
enabled?: boolean;
/** When viewing task logs: include owner's sessions when task is in_progress */
taskOwner?: string;
taskStatus?: string;
@ -100,6 +101,7 @@ export const MemberLogsTab = ({
teamName,
memberName,
taskId,
enabled = true,
taskOwner,
taskStatus,
taskWorkIntervals,
@ -375,6 +377,7 @@ export const MemberLogsTab = ({
const previewHasMore = allPreviewMessages.length > previewVisibleCount;
const previewOnline = useMemo((): boolean => {
if (!enabled) return false;
if (!previewLog) return false;
// Determine the most recent activity timestamp from preview messages
const newest = previewMessages[0];
@ -398,7 +401,7 @@ export const MemberLogsTab = ({
if (taskStatus === 'in_progress') return ageMs <= 60_000;
// Completed/other tasks — shorter window
return ageMs <= 15_000;
}, [previewLog, previewMessages, taskStatus]);
}, [enabled, previewLog, previewMessages, taskStatus]);
const expandedLogSummary = useMemo(() => {
if (!expandedId) return null;
@ -443,6 +446,17 @@ export const MemberLogsTab = ({
useEffect(() => {
let cancelled = false;
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> => {
let didBeginRefreshing = false;
@ -505,7 +519,17 @@ export const MemberLogsTab = ({
setRefreshing(false);
};
// 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(
async (
@ -532,6 +556,9 @@ export const MemberLogsTab = ({
);
useEffect(() => {
if (!enabled) {
return;
}
if (!shouldShowPreview) {
setPreviewChunks(null);
return;
@ -557,9 +584,10 @@ export const MemberLogsTab = ({
return () => {
cancelled = true;
};
}, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]);
}, [enabled, fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]);
useEffect(() => {
if (!enabled) return;
if (!shouldShowPreview) return;
if (!previewLog) return;
@ -594,9 +622,11 @@ export const MemberLogsTab = ({
taskStatus,
intervalsKey,
isTabActive,
enabled,
]);
useEffect(() => {
if (!enabled) return;
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!expandedLogSummary) return;
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
@ -634,6 +664,7 @@ export const MemberLogsTab = ({
taskStatus,
intervalsKey,
isTabActive,
enabled,
]);
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 { asEnhancedChunkArray } from '@renderer/types/data';
@ -26,6 +26,7 @@ import type {
interface TaskActivitySectionProps {
teamName: string;
taskId: string;
enabled?: boolean;
}
function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean {
@ -262,12 +263,14 @@ const Row = ({
export const TaskActivitySection = ({
teamName,
taskId,
enabled = true,
}: TaskActivitySectionProps): React.JSX.Element => {
const [detailStates, setDetailStates] = useState<Record<string, ActivityDetailState>>({});
const [entries, setEntries] = useState<BoardTaskActivityEntry[]>([]);
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 hasLoadedRef = useRef(false);
const fetchDetail = useCallback(
async (entry: BoardTaskActivityEntry): Promise<void> => {
@ -325,13 +328,27 @@ export const TaskActivitySection = ({
);
useEffect(() => {
let cancelled = false;
setEntries([]);
setExpandedId(null);
setDetailStates({});
setLoading(true);
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> => {
try {
@ -344,6 +361,7 @@ export const TaskActivitySection = ({
const result = await api.teams.getTaskActivity(teamName, taskId);
if (!cancelled) {
setEntries(result);
hasLoadedRef.current = true;
}
} catch (loadError) {
if (!cancelled) {
@ -357,7 +375,7 @@ export const TaskActivitySection = ({
}
};
void load(true);
void load(!hasLoadedRef.current);
const intervalId = window.setInterval(() => {
void load(false);
}, 8000);
@ -366,7 +384,7 @@ export const TaskActivitySection = ({
cancelled = true;
window.clearInterval(intervalId);
};
}, [teamName, taskId]);
}, [enabled, teamName, taskId]);
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 { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
@ -14,8 +14,12 @@ import type {
interface TaskLogStreamSectionProps {
teamName: string;
taskId: string;
taskStatus?: string;
liveEnabled?: boolean;
}
const LIVE_RELOAD_DEBOUNCE_MS = 350;
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
@ -86,39 +90,160 @@ const SegmentBlock = ({
export const TaskLogStreamSection = ({
teamName,
taskId,
taskStatus,
liveEnabled = true,
}: TaskLogStreamSectionProps): React.JSX.Element => {
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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(() => {
let cancelled = false;
streamRef.current = stream;
}, [stream]);
const run = async (): Promise<void> => {
try {
const loadStream = useCallback(
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);
setError(null);
}
setError((prev) => (background ? prev : null));
try {
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId));
if (cancelled) return;
if (requestSeqRef.current !== requestSeq) {
return;
}
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) {
if (cancelled) return;
setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream');
setStream(null);
if (requestSeqRef.current !== requestSeq) {
return;
}
if (!background || streamRef.current == null) {
setError(
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
);
setStream(null);
}
} finally {
if (!cancelled) {
if (requestSeqRef.current === requestSeq && (!background || !hadExistingStream)) {
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();
return () => {
cancelled = true;
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if (
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 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 { ExecutionSessionsSection } from './ExecutionSessionsSection';
@ -14,6 +15,7 @@ type TaskLogsTab = 'activity' | 'stream' | 'sessions';
interface TaskLogsPanelProps {
teamName: string;
task: TeamTaskWithKanban;
isOpen?: boolean;
taskSince?: string;
isExecutionRefreshing?: boolean;
isExecutionPreviewOnline?: boolean;
@ -21,11 +23,15 @@ interface TaskLogsPanelProps {
showSubagentPreview?: boolean;
showLeadPreview?: boolean;
onPreviewOnlineChange?: (isOnline: boolean) => void;
onTaskLogActivityChange?: (isActive: boolean) => void;
}
const TASK_LOG_ACTIVITY_PULSE_MS = 1800;
export const TaskLogsPanel = ({
teamName,
task,
isOpen = true,
taskSince,
isExecutionRefreshing = false,
isExecutionPreviewOnline = false,
@ -33,6 +39,7 @@ export const TaskLogsPanel = ({
showSubagentPreview = false,
showLeadPreview = false,
onPreviewOnlineChange,
onTaskLogActivityChange,
}: TaskLogsPanelProps): React.JSX.Element => {
const availableTabs = useMemo<TaskLogsTab[]>(() => {
const tabs: TaskLogsTab[] = [];
@ -48,6 +55,10 @@ export const TaskLogsPanel = ({
const defaultTab = availableTabs[0] ?? 'sessions';
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(() => {
setActiveTab(defaultTab);
@ -59,6 +70,77 @@ export const TaskLogsPanel = ({
}
}, [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 (
<Tabs
value={activeTab}
@ -81,34 +163,42 @@ export const TaskLogsPanel = ({
</TabsTrigger>
</TabsList>
{availableTabs.includes('stream') ? (
{availableTabs.includes('stream') && hasOpenedContent ? (
<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>
) : null}
{availableTabs.includes('activity') ? (
{availableTabs.includes('activity') && hasOpenedContent ? (
<TabsContent value="activity" className="mt-0">
<TaskActivitySection teamName={teamName} taskId={task.id} />
<TaskActivitySection teamName={teamName} taskId={task.id} enabled={isOpen} />
</TabsContent>
) : null}
<TabsContent value="sessions" className="mt-0">
<ExecutionSessionsSection
teamName={teamName}
taskId={task.id}
taskOwner={task.owner}
taskStatus={task.status}
taskWorkIntervals={task.workIntervals}
taskSince={taskSince}
isRefreshing={isExecutionRefreshing}
isPreviewOnline={isExecutionPreviewOnline}
onRefreshingChange={onRefreshingChange}
showSubagentPreview={showSubagentPreview}
showLeadPreview={showLeadPreview}
onPreviewOnlineChange={onPreviewOnlineChange}
/>
</TabsContent>
{hasOpenedContent ? (
<TabsContent value="sessions" className="mt-0">
<ExecutionSessionsSection
teamName={teamName}
taskId={task.id}
taskOwner={task.owner}
taskStatus={task.status}
taskWorkIntervals={task.workIntervals}
taskSince={taskSince}
isRefreshing={isExecutionRefreshing}
isPreviewOnline={isExecutionPreviewOnline}
enabled={isOpen}
onRefreshingChange={onRefreshingChange}
showSubagentPreview={showSubagentPreview}
showLeadPreview={showLeadPreview}
onPreviewOnlineChange={onPreviewOnlineChange}
/>
</TabsContent>
) : null}
</Tabs>
);
};

View file

@ -429,6 +429,7 @@ export interface TeamsAPI {
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (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>;
deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;

View file

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

View file

@ -586,6 +586,189 @@ describe('BoardTaskLogStreamService integration', () => {
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 () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-'));
tempDirs.push(dir);

View file

@ -630,4 +630,154 @@ describe('BoardTaskLogStreamService', () => {
});
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 () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskActivity.mockResolvedValue([

View file

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

View file

@ -2,12 +2,15 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { TeamChangeEvent } from '../../../../../src/shared/types';
import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types';
const apiState = {
getTaskLogStream: vi.fn<
(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', () => ({
@ -15,6 +18,10 @@ vi.mock('@renderer/api', () => ({
teams: {
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
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();
}
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', () => {
afterEach(() => {
document.body.innerHTML = '';
apiState.getTaskLogStream.mockReset();
apiState.onTeamChange.mockReset();
apiState.setTaskLogStreamTracking.mockReset();
vi.useRealTimers();
vi.unstubAllGlobals();
});
@ -175,6 +218,7 @@ describe('TaskLogStreamSection', () => {
it('honors a participant default filter from the stream response', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.onTeamChange.mockImplementation(() => () => undefined);
apiState.getTaskLogStream.mockResolvedValueOnce({
participants: [
{
@ -220,4 +264,248 @@ describe('TaskLogStreamSection', () => {
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 type { TeamChangeEvent } 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 = {
activityEnabled: true,
exactLogsEnabled: true,
};
const taskActivityProps = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>,
}));
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', () => ({
TaskLogStreamSection: () =>
React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream'),
TaskLogStreamSection: (props: Record<string, unknown>) => {
taskLogStreamProps.calls.push(props);
return React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream');
},
}));
vi.mock('../../../../../src/renderer/components/team/taskLogs/ExecutionSessionsSection', () => ({
ExecutionSessionsSection: () =>
React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions'),
ExecutionSessionsSection: (props: Record<string, unknown>) => {
executionSessionsProps.calls.push(props);
return React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions');
},
}));
vi.mock('../../../../../src/renderer/components/team/taskLogs/featureGates', () => ({
@ -128,6 +164,12 @@ describe('TaskLogsPanel', () => {
document.body.innerHTML = '';
featureGateState.activityEnabled = true;
featureGateState.exactLogsEnabled = true;
taskActivityProps.calls = [];
taskLogStreamProps.calls = [];
executionSessionsProps.calls = [];
apiState.onTeamChange.mockReset();
apiState.setTaskLogStreamTracking.mockReset();
vi.useRealTimers();
vi.unstubAllGlobals();
});
@ -147,6 +189,12 @@ describe('TaskLogsPanel', () => {
expect(host.textContent).toContain('Execution Sessions');
expect(findTabButton(host, 'Task Log Stream')?.getAttribute('data-state')).toBe('active');
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');
expect(activityTab).not.toBeNull();
@ -158,6 +206,11 @@ describe('TaskLogsPanel', () => {
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
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');
expect(sessionsTab).not.toBeNull();
@ -169,6 +222,11 @@ describe('TaskLogsPanel', () => {
expect(findTabButton(host, 'Execution Sessions')?.getAttribute('data-state')).toBe('active');
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 () => {
root.unmount();
@ -192,6 +250,234 @@ describe('TaskLogsPanel', () => {
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull();
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 () => {
root.unmount();