feat(team): expand task and member execution logs
This commit is contained in:
parent
2e062e4432
commit
b7547e5d87
26 changed files with 3080 additions and 105 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -965,6 +965,7 @@ async function initializeServices(): Promise<void> {
|
|||
boardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService,
|
||||
teammateToolTracker ?? undefined,
|
||||
teamLogSourceTracker,
|
||||
branchStatusService ?? undefined,
|
||||
{
|
||||
rewire: rewireContextEvents,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
119
test/main/services/team/TeamLogSourceTracker.test.ts
Normal file
119
test/main/services/team/TeamLogSourceTracker.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
129
test/renderer/components/team/members/MemberExecutionLog.test.ts
Normal file
129
test/renderer/components/team/members/MemberExecutionLog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue