feat(team): add live task log stream count badge

This commit is contained in:
777genius 2026-04-18 21:04:15 +03:00
parent 372d744879
commit d1c33cec64
11 changed files with 347 additions and 27 deletions

View file

@ -35,6 +35,7 @@ import {
TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_DETAIL,
TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
@ -155,6 +156,7 @@ import type {
BoardTaskExactLogDetailResult, BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse, BoardTaskExactLogSummariesResponse,
BoardTaskLogStreamResponse, BoardTaskLogStreamResponse,
BoardTaskLogStreamSummary,
CreateTaskRequest, CreateTaskRequest,
EffortLevel, EffortLevel,
GlobalTask, GlobalTask,
@ -536,6 +538,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity); ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity);
ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail); ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail);
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM_SUMMARY, handleGetTaskLogStreamSummary);
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream); ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream);
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail); ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail);
@ -611,6 +614,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY);
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL); ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL);
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM_SUMMARY);
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM);
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL);
@ -2645,6 +2649,24 @@ async function handleGetTaskLogStream(
); );
} }
async function handleGetTaskLogStreamSummary(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<BoardTaskLogStreamSummary>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vTask = validateTaskId(taskId);
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' };
}
return wrapTeamHandler('getTaskLogStreamSummary', () =>
getBoardTaskLogStreamService().getTaskLogStreamSummary(vTeam.value!, vTask.value!)
);
}
async function handleGetTaskExactLogSummaries( async function handleGetTaskExactLogSummaries(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,

View file

@ -19,6 +19,7 @@ import type {
BoardTaskLogParticipant, BoardTaskLogParticipant,
BoardTaskLogSegment, BoardTaskLogSegment,
BoardTaskLogStreamResponse, BoardTaskLogStreamResponse,
BoardTaskLogStreamSummary,
TeamTask, TeamTask,
} from '@shared/types'; } from '@shared/types';
@ -47,6 +48,11 @@ interface TimeWindow {
endMs: number | null; endMs: number | null;
} }
interface StreamLayout {
participants: BoardTaskLogParticipant[];
visibleSlices: StreamSlice[];
}
const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000;
const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000;
@ -61,6 +67,12 @@ function emptyResponse(): BoardTaskLogStreamResponse {
}; };
} }
function emptySummary(): BoardTaskLogStreamSummary {
return {
segmentCount: 0,
};
}
function normalizeMemberName(value: string): string { function normalizeMemberName(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
} }
@ -1018,6 +1030,46 @@ function compareSlices(left: StreamSlice, right: StreamSlice): number {
return left.id.localeCompare(right.id); return left.id.localeCompare(right.id);
} }
function buildOrderedParticipants(visibleSlices: StreamSlice[]): BoardTaskLogParticipant[] {
const participantsByKey = new Map<string, BoardTaskLogParticipant>();
const participantOrder: string[] = [];
for (const slice of visibleSlices) {
if (participantsByKey.has(slice.participantKey)) {
continue;
}
participantsByKey.set(
slice.participantKey,
buildParticipant(slice.actor, slice.participantKey)
);
participantOrder.push(slice.participantKey);
}
return participantOrder
.map((key) => participantsByKey.get(key))
.filter((participant): participant is BoardTaskLogParticipant => Boolean(participant))
.sort((left, right) => {
if (left.isLead && !right.isLead) return 1;
if (!left.isLead && right.isLead) return -1;
return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key);
});
}
function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number {
if (visibleSlices.length === 0) {
return 0;
}
let segmentCount = 1;
for (let index = 1; index < visibleSlices.length; index += 1) {
if (visibleSlices[index]?.participantKey !== visibleSlices[index - 1]?.participantKey) {
segmentCount += 1;
}
}
return segmentCount;
}
export class BoardTaskLogStreamService { export class BoardTaskLogStreamService {
constructor( constructor(
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
@ -1131,14 +1183,20 @@ export class BoardTaskLogStreamService {
return inferredSlices.sort(compareSlices); return inferredSlices.sort(compareSlices);
} }
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> { private async buildStreamLayout(teamName: string, taskId: string): Promise<StreamLayout> {
if (!isBoardTaskExactLogsReadEnabled()) { if (!isBoardTaskExactLogsReadEnabled()) {
return emptyResponse(); return {
participants: [],
visibleSlices: [],
};
} }
const records = await this.recordSource.getTaskRecords(teamName, taskId); const records = await this.recordSource.getTaskRecords(teamName, taskId);
if (records.length === 0) { if (records.length === 0) {
return emptyResponse(); return {
participants: [],
visibleSlices: [],
};
} }
const fileVersionsByPath = await getBoardTaskExactLogFileVersions( const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
@ -1154,7 +1212,10 @@ export class BoardTaskLogStreamService {
.sort(compareCandidates); .sort(compareCandidates);
if (candidates.length === 0) { if (candidates.length === 0) {
return emptyResponse(); return {
participants: [],
visibleSlices: [],
};
} }
const parsedMessagesByFile = await this.strictParser.parseFiles( const parsedMessagesByFile = await this.strictParser.parseFiles(
@ -1202,7 +1263,10 @@ export class BoardTaskLogStreamService {
} }
if (slices.length === 0) { if (slices.length === 0) {
return emptyResponse(); return {
participants: [],
visibleSlices: [],
};
} }
const inferredExecutionSlices = await this.buildInferredExecutionSlices( const inferredExecutionSlices = await this.buildInferredExecutionSlices(
@ -1220,27 +1284,31 @@ export class BoardTaskLogStreamService {
const visibleSlices = const visibleSlices =
namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices; namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices;
const participantsByKey = new Map<string, BoardTaskLogParticipant>(); return {
const participantOrder: string[] = []; participants: buildOrderedParticipants(visibleSlices),
for (const slice of visibleSlices) { visibleSlices,
if (participantsByKey.has(slice.participantKey)) { };
continue; }
}
participantsByKey.set( async getTaskLogStreamSummary(
slice.participantKey, teamName: string,
buildParticipant(slice.actor, slice.participantKey) taskId: string
); ): Promise<BoardTaskLogStreamSummary> {
participantOrder.push(slice.participantKey); const layout = await this.buildStreamLayout(teamName, taskId);
if (layout.visibleSlices.length === 0) {
return emptySummary();
} }
const orderedParticipants = participantOrder return {
.map((key) => participantsByKey.get(key)) segmentCount: countSegmentsFromSlices(layout.visibleSlices),
.filter((participant): participant is BoardTaskLogParticipant => Boolean(participant)) };
.sort((left, right) => { }
if (left.isLead && !right.isLead) return 1;
if (!left.isLead && right.isLead) return -1; async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key); const layout = await this.buildStreamLayout(teamName, taskId);
}); if (layout.visibleSlices.length === 0) {
return emptyResponse();
}
const segments: BoardTaskLogSegment[] = []; const segments: BoardTaskLogSegment[] = [];
let currentSegmentSlices: StreamSlice[] = []; let currentSegmentSlices: StreamSlice[] = [];
@ -1274,7 +1342,7 @@ export class BoardTaskLogStreamService {
currentSegmentSlices = []; currentSegmentSlices = [];
}; };
for (const slice of visibleSlices) { for (const slice of layout.visibleSlices) {
if ( if (
currentSegmentSlices.length > 0 && currentSegmentSlices.length > 0 &&
currentSegmentSlices[0].participantKey !== slice.participantKey currentSegmentSlices[0].participantKey !== slice.participantKey
@ -1285,11 +1353,11 @@ export class BoardTaskLogStreamService {
} }
flushSegment(); flushSegment();
const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead); const namedParticipants = layout.participants.filter((participant) => !participant.isLead);
const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all'; const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all';
return { return {
participants: orderedParticipants, participants: layout.participants,
defaultFilter, defaultFilter,
segments, segments,
}; };

View file

@ -313,6 +313,9 @@ export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail';
/** Get one task-scoped log stream derived from explicit board-task activity */ /** Get one task-scoped log stream derived from explicit board-task activity */
export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream';
/** Get lightweight task log stream summary for header badges/live counters */
export const TEAM_GET_TASK_LOG_STREAM_SUMMARY = 'team:getTaskLogStreamSummary';
/** Get exact task-log summaries derived from explicit board-task activity records */ /** Get exact task-log summaries derived from explicit board-task activity records */
export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'; export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries';

View file

@ -139,6 +139,7 @@ import {
TEAM_GET_TASK_EXACT_LOG_DETAIL, TEAM_GET_TASK_EXACT_LOG_DETAIL,
TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
@ -243,6 +244,7 @@ import type {
BoardTaskExactLogDetailResult, BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse, BoardTaskExactLogSummariesResponse,
BoardTaskLogStreamResponse, BoardTaskLogStreamResponse,
BoardTaskLogStreamSummary,
ChangeStats, ChangeStats,
ClaudeRootFolderSelection, ClaudeRootFolderSelection,
ClaudeRootInfo, ClaudeRootInfo,
@ -993,6 +995,13 @@ const electronAPI: ElectronAPI = {
activityId activityId
); );
}, },
getTaskLogStreamSummary: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<BoardTaskLogStreamSummary>(
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
teamName,
taskId
);
},
getTaskLogStream: async (teamName: string, taskId: string) => { getTaskLogStream: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<BoardTaskLogStreamResponse>( return invokeIpcWithResult<BoardTaskLogStreamResponse>(
TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_LOG_STREAM,

View file

@ -14,6 +14,7 @@ import type {
BoardTaskExactLogDetailResult, BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse, BoardTaskExactLogSummariesResponse,
BoardTaskLogStreamResponse, BoardTaskLogStreamResponse,
BoardTaskLogStreamSummary,
ClaudeMdFileInfo, ClaudeMdFileInfo,
ClaudeRootFolderSelection, ClaudeRootFolderSelection,
ClaudeRootInfo, ClaudeRootInfo,
@ -827,6 +828,10 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode'); console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode');
return { status: 'missing' }; return { status: 'missing' };
}, },
getTaskLogStreamSummary: async (): Promise<BoardTaskLogStreamSummary> => {
console.warn('[HttpAPIClient] getTaskLogStreamSummary is not available in browser mode');
return { segmentCount: 0 };
},
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => { getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode'); console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
return { return {

View file

@ -159,6 +159,7 @@ export const TaskDetailDialog = ({
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
const [logsSectionOpen, setLogsSectionOpen] = useState(false); const [logsSectionOpen, setLogsSectionOpen] = useState(false);
const [taskLogActivityActive, setTaskLogActivityActive] = useState(false); const [taskLogActivityActive, setTaskLogActivityActive] = useState(false);
const [taskLogStreamCount, setTaskLogStreamCount] = useState<number | undefined>(undefined);
const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [changesSectionOpen, setChangesSectionOpen] = useState(false);
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null); const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesLoading, setTaskChangesLoading] = useState(false);
@ -236,6 +237,7 @@ export const TaskDetailDialog = ({
setExecutionPreviewOnline(false); setExecutionPreviewOnline(false);
setLogsSectionOpen(false); setLogsSectionOpen(false);
setTaskLogActivityActive(false); setTaskLogActivityActive(false);
setTaskLogStreamCount(undefined);
}, [open, currentTask?.id]); }, [open, currentTask?.id]);
const [replyTo, setReplyTo] = useState<{ const [replyTo, setReplyTo] = useState<{
@ -1263,6 +1265,7 @@ export const TaskDetailDialog = ({
key={`task-logs:${currentTask.id}`} key={`task-logs:${currentTask.id}`}
title="Task Logs" title="Task Logs"
icon={<ScrollText size={14} />} icon={<ScrollText size={14} />}
badge={taskLogStreamCount}
headerExtra={ headerExtra={
taskLogActivityActive ? ( taskLogActivityActive ? (
<OngoingIndicator size="sm" title="New task logs arriving" /> <OngoingIndicator size="sm" title="New task logs arriving" />
@ -1288,6 +1291,7 @@ export const TaskDetailDialog = ({
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask} showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
onPreviewOnlineChange={setExecutionPreviewOnline} onPreviewOnlineChange={setExecutionPreviewOnline}
onTaskLogActivityChange={setTaskLogActivityActive} onTaskLogActivityChange={setTaskLogActivityActive}
onTaskLogCountChange={setTaskLogStreamCount}
/> />
</div> </div>
</CollapsibleTeamSection> </CollapsibleTeamSection>

View file

@ -24,9 +24,11 @@ interface TaskLogsPanelProps {
showLeadPreview?: boolean; showLeadPreview?: boolean;
onPreviewOnlineChange?: (isOnline: boolean) => void; onPreviewOnlineChange?: (isOnline: boolean) => void;
onTaskLogActivityChange?: (isActive: boolean) => void; onTaskLogActivityChange?: (isActive: boolean) => void;
onTaskLogCountChange?: (count: number | undefined) => void;
} }
const TASK_LOG_ACTIVITY_PULSE_MS = 1800; const TASK_LOG_ACTIVITY_PULSE_MS = 1800;
const TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS = 350;
export const TaskLogsPanel = ({ export const TaskLogsPanel = ({
teamName, teamName,
@ -40,6 +42,7 @@ export const TaskLogsPanel = ({
showLeadPreview = false, showLeadPreview = false,
onPreviewOnlineChange, onPreviewOnlineChange,
onTaskLogActivityChange, onTaskLogActivityChange,
onTaskLogCountChange,
}: TaskLogsPanelProps): React.JSX.Element => { }: TaskLogsPanelProps): React.JSX.Element => {
const availableTabs = useMemo<TaskLogsTab[]>(() => { const availableTabs = useMemo<TaskLogsTab[]>(() => {
const tabs: TaskLogsTab[] = []; const tabs: TaskLogsTab[] = [];
@ -56,9 +59,13 @@ export const TaskLogsPanel = ({
const defaultTab = availableTabs[0] ?? 'sessions'; const defaultTab = availableTabs[0] ?? 'sessions';
const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab); const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab);
const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false); const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false);
const [taskLogSegmentCount, setTaskLogSegmentCount] = useState<number | null>(null);
const [hasOpenedContent, setHasOpenedContent] = useState(isOpen); const [hasOpenedContent, setHasOpenedContent] = useState(isOpen);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countRequestSeqRef = useRef(0);
const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream'); const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream');
const taskLogSummaryEnabled = availableTabs.includes('stream');
useEffect(() => { useEffect(() => {
setActiveTab(defaultTab); setActiveTab(defaultTab);
@ -80,14 +87,50 @@ export const TaskLogsPanel = ({
onTaskLogActivityChange?.(isTaskLogActivityActive); onTaskLogActivityChange?.(isTaskLogActivityActive);
}, [isTaskLogActivityActive, onTaskLogActivityChange]); }, [isTaskLogActivityActive, onTaskLogActivityChange]);
useEffect(() => {
onTaskLogCountChange?.(
taskLogSegmentCount != null && taskLogSegmentCount > 0 ? taskLogSegmentCount : undefined
);
}, [onTaskLogCountChange, taskLogSegmentCount]);
useEffect(() => { useEffect(() => {
if (pulseTimerRef.current) { if (pulseTimerRef.current) {
clearTimeout(pulseTimerRef.current); clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = null; pulseTimerRef.current = null;
} }
if (countReloadTimerRef.current) {
clearTimeout(countReloadTimerRef.current);
countReloadTimerRef.current = null;
}
countRequestSeqRef.current += 1;
setIsTaskLogActivityActive(false); setIsTaskLogActivityActive(false);
setTaskLogSegmentCount(null);
}, [task.id]); }, [task.id]);
useEffect(() => {
if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) {
setTaskLogSegmentCount(null);
return;
}
const requestSeq = countRequestSeqRef.current + 1;
countRequestSeqRef.current = requestSeq;
void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id))
.then((summary) => {
if (countRequestSeqRef.current !== requestSeq) {
return;
}
setTaskLogSegmentCount(summary.segmentCount);
})
.catch(() => {
if (countRequestSeqRef.current !== requestSeq) {
return;
}
setTaskLogSegmentCount((prev) => prev);
});
}, [task.id, taskLogSummaryEnabled, teamName]);
useEffect(() => { useEffect(() => {
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) { if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
return; return;
@ -107,10 +150,39 @@ export const TaskLogsPanel = ({
clearTimeout(pulseTimerRef.current); clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = null; pulseTimerRef.current = null;
} }
if (countReloadTimerRef.current) {
clearTimeout(countReloadTimerRef.current);
countReloadTimerRef.current = null;
}
setIsTaskLogActivityActive(false); setIsTaskLogActivityActive(false);
return; return;
} }
const scheduleCountReload = (): void => {
if (!api.teams.getTaskLogStreamSummary) {
return;
}
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
if (countReloadTimerRef.current) {
clearTimeout(countReloadTimerRef.current);
}
countReloadTimerRef.current = setTimeout(() => {
countReloadTimerRef.current = null;
const requestSeq = countRequestSeqRef.current + 1;
countRequestSeqRef.current = requestSeq;
void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id))
.then((summary) => {
if (countRequestSeqRef.current !== requestSeq) {
return;
}
setTaskLogSegmentCount(summary.segmentCount);
})
.catch(() => undefined);
}, TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS);
};
const unsubscribe = api.teams.onTeamChange?.((_event, event) => { const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if ( if (
event.teamName !== teamName || event.teamName !== teamName ||
@ -128,13 +200,31 @@ export const TaskLogsPanel = ({
pulseTimerRef.current = null; pulseTimerRef.current = null;
setIsTaskLogActivityActive(false); setIsTaskLogActivityActive(false);
}, TASK_LOG_ACTIVITY_PULSE_MS); }, TASK_LOG_ACTIVITY_PULSE_MS);
scheduleCountReload();
}); });
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'visible') {
scheduleCountReload();
}
};
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
return () => { return () => {
if (pulseTimerRef.current) { if (pulseTimerRef.current) {
clearTimeout(pulseTimerRef.current); clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = null; pulseTimerRef.current = null;
} }
if (countReloadTimerRef.current) {
clearTimeout(countReloadTimerRef.current);
countReloadTimerRef.current = null;
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (typeof unsubscribe === 'function') { if (typeof unsubscribe === 'function') {
unsubscribe(); unsubscribe();
} }

View file

@ -45,6 +45,7 @@ import type {
BoardTaskExactLogDetailResult, BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse, BoardTaskExactLogSummariesResponse,
BoardTaskLogStreamResponse, BoardTaskLogStreamResponse,
BoardTaskLogStreamSummary,
CreateTaskRequest, CreateTaskRequest,
CrossTeamMessage, CrossTeamMessage,
CrossTeamSendRequest, CrossTeamSendRequest,
@ -493,6 +494,7 @@ export interface TeamsAPI {
taskId: string, taskId: string,
activityId: string activityId: string
) => Promise<BoardTaskActivityDetailResult>; ) => Promise<BoardTaskActivityDetailResult>;
getTaskLogStreamSummary: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamSummary>;
getTaskLogStream: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>; getTaskLogStream: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>;
getTaskExactLogSummaries: ( getTaskExactLogSummaries: (
teamName: string, teamName: string,

View file

@ -342,6 +342,10 @@ export interface BoardTaskLogStreamResponse {
segments: BoardTaskLogSegment[]; segments: BoardTaskLogSegment[];
} }
export interface BoardTaskLogStreamSummary {
segmentCount: number;
}
export interface TaskComment { export interface TaskComment {
id: string; id: string;
author: string; author: string;

View file

@ -181,6 +181,63 @@ describe('BoardTaskLogStreamService', () => {
expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2); expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2);
}); });
it('returns lightweight segment count without building stream chunks', async () => {
const tom = {
memberName: 'tom',
role: 'member' as const,
sessionId: 'session-tom',
agentId: 'agent-tom',
isSidechain: true,
};
const alice = {
memberName: 'alice',
role: 'member' as const,
sessionId: 'session-alice',
agentId: 'agent-alice',
isSidechain: true,
};
const candidates = [
makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'),
makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'),
makeCandidate('c3', '2026-04-12T16:02:00.000Z', alice, 'tool-3'),
makeCandidate('c4', '2026-04-12T16:03:00.000Z', tom, 'tool-4'),
];
const recordSource = {
getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)),
};
const summarySelector = {
selectSummaries: vi.fn(() => candidates),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
records: candidate.records,
filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)],
})),
};
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]);
const service = new BoardTaskLogStreamService(
recordSource as never,
summarySelector as never,
strictParser as never,
detailSelector as never,
{ buildBundleChunks } as never,
);
await expect(service.getTaskLogStreamSummary('demo', 'task-a')).resolves.toEqual({
segmentCount: 3,
});
expect(buildBundleChunks).not.toHaveBeenCalled();
});
it('merges duplicate message uuids inside one participant segment before chunk building', async () => { it('merges duplicate message uuids inside one participant segment before chunk building', async () => {
const tom = { const tom = {
memberName: 'tom', memberName: 'tom',

View file

@ -9,6 +9,9 @@ import type { TeamTaskWithKanban } from '../../../../../src/shared/types';
const apiState = { const apiState = {
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(), onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
getTaskLogStreamSummary: vi.fn<
(teamName: string, taskId: string) => Promise<{ segmentCount: number }>
>(),
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(), setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
}; };
@ -17,6 +20,8 @@ vi.mock('@renderer/api', () => ({
teams: { teams: {
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) => onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
apiState.onTeamChange(...args), apiState.onTeamChange(...args),
getTaskLogStreamSummary: (...args: Parameters<typeof apiState.getTaskLogStreamSummary>) =>
apiState.getTaskLogStreamSummary(...args),
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) => setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
apiState.setTaskLogStreamTracking(...args), apiState.setTaskLogStreamTracking(...args),
}, },
@ -168,7 +173,9 @@ describe('TaskLogsPanel', () => {
taskLogStreamProps.calls = []; taskLogStreamProps.calls = [];
executionSessionsProps.calls = []; executionSessionsProps.calls = [];
apiState.onTeamChange.mockReset(); apiState.onTeamChange.mockReset();
apiState.getTaskLogStreamSummary.mockReset();
apiState.setTaskLogStreamTracking.mockReset(); apiState.setTaskLogStreamTracking.mockReset();
apiState.getTaskLogStreamSummary.mockResolvedValue({ segmentCount: 0 });
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@ -484,4 +491,53 @@ describe('TaskLogsPanel', () => {
await flushMicrotasks(); await flushMicrotasks();
}); });
}); });
it('loads task log count for the header badge and refreshes it on matching live updates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
const counts: Array<number | undefined> = [];
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
apiState.onTeamChange.mockImplementation((callback) => {
handler = callback;
return () => {
handler = null;
};
});
apiState.getTaskLogStreamSummary
.mockResolvedValueOnce({ segmentCount: 4 })
.mockResolvedValueOnce({ segmentCount: 5 });
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(),
onTaskLogCountChange: (count) => counts.push(count),
})
);
await flushMicrotasks();
});
expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledWith('demo', 'task-1');
expect(counts).toEqual([undefined, 4]);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
vi.advanceTimersByTime(350);
await flushMicrotasks();
});
expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledTimes(2);
expect(counts).toEqual([undefined, 4, 5]);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
}); });