improvement(task-change): improve task change presence tracking and related IPC handlers

- Added support for tracking task change presence with new IPC channels: TEAM_GET_TASK_CHANGE_PRESENCE and TEAM_SET_CHANGE_PRESENCE_TRACKING.
- Introduced JsonTaskChangePresenceRepository and TeamLogSourceTracker to manage task change presence data.
- Enhanced ChangeExtractorService to utilize task change presence services for improved task change detection.
- Updated TeamDataService to integrate task change presence tracking and resolve task change presence states.
- Modified UI components to reflect task change presence status in Kanban and task detail views.

This feature aims to provide real-time insights into task changes, enhancing user experience and task management capabilities.
This commit is contained in:
iliya 2026-03-27 17:52:39 +02:00
parent fe90ac866d
commit 507bf798eb
37 changed files with 4362 additions and 1136 deletions

View file

@ -76,7 +76,8 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts')
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'),
'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts')
},
output: {
// CJS format so bundled deps can use __dirname/require.

View file

@ -30,6 +30,7 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import { TeamBackupService } from '@main/services/team/TeamBackupService';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import {
CONTEXT_CHANGED,
@ -104,6 +105,7 @@ import {
TaskBoundaryParser,
TeamDataService,
TeamMemberLogsFinder,
TeamLogSourceTracker,
TeamProvisioningService,
UpdaterService,
} from './services';
@ -780,9 +782,13 @@ function initializeServices(): void {
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
const taskBoundaryParser = new TaskBoundaryParser();
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
teamDataService.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker);
changeExtractor.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker);
const gitDiffFallback = new GitDiffFallback();
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
const reviewApplier = new ReviewApplierService();
@ -839,6 +845,7 @@ function initializeServices(): void {
httpServer?.broadcast('team-change', event);
};
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
teamLogSourceTracker.setEmitter(teamChangeEmitter);
// Allow SchedulerService to push schedule events to renderer
schedulerService.setChangeEmitter((event) => {
@ -1321,7 +1328,9 @@ function createWindow(): void {
markRendererUnavailable(mainWindow);
const activeContext = contextRegistry.getActive();
activeContext?.stopFileWatcher();
scheduleRendererRecovery(mainWindow);
if (mainWindow) {
scheduleRendererRecovery(mainWindow);
}
});
// Set main window reference for notification manager and updater

View file

@ -19,6 +19,7 @@ import {
TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA,
TEAM_GET_TASK_CHANGE_PRESENCE,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
@ -46,6 +47,7 @@ import {
TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
@ -306,6 +308,8 @@ export function initializeTeamHandlers(
export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_LIST, handleListTeams);
ipcMain.handle(TEAM_GET_DATA, handleGetData);
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
@ -368,6 +372,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_LIST);
ipcMain.removeHandler(TEAM_GET_DATA);
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_CREATE);
@ -613,6 +619,38 @@ async function handleGetData(
return { success: true, data: { ...data, isAlive, messages: merged } };
}
async function handleGetTaskChangePresence(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<Record<string, 'has_changes' | 'no_changes' | 'unknown'>>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('getTaskChangePresence', () =>
getTeamDataService().getTaskChangePresence(validated.value!)
);
}
async function handleSetChangePresenceTracking(
_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('setChangePresenceTracking', async () => {
getTeamDataService().setTaskChangePresenceTracking(validated.value!, enabled);
});
}
async function handleDeleteTeam(
_event: IpcMainInvokeEvent,
teamName: unknown

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,706 @@
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import * as readline from 'readline';
import { countLineChanges } from './UnifiedLineCounter';
import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils';
import type { TaskBoundaryParser } from './TaskBoundaryParser';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type {
AgentChangeSet,
FileChangeSummary,
FileEditEvent,
FileEditTimeline,
SnippetDiff,
TaskChangeScope,
TaskChangeSetV2,
} from '@shared/types';
import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes';
const logger = createLogger('Service:TaskChangeComputer');
interface ParsedSnippetsCacheEntry {
data: SnippetDiff[];
mtime: number;
expiresAt: number;
}
interface LogFileRef {
filePath: string;
memberName: string;
}
export class TaskChangeComputer {
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
private readonly parsedSnippetsCacheTtl = 20 * 1000;
private static readonly JSONL_PARSE_CONCURRENCY = 6;
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly boundaryParser: TaskBoundaryParser
) {}
async computeAgentChanges(
teamName: string,
memberName: string,
projectPath?: string
): Promise<{ result: AgentChangeSet; latestMtime: number }> {
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
const parseResults = await this.parseJSONLFilesWithConcurrency(paths);
let latestMtime = 0;
const merged: SnippetDiff[] = [];
for (const result of parseResults) {
merged.push(...result.snippets);
if (result.mtime > latestMtime) {
latestMtime = result.mtime;
}
}
const files = this.aggregateByFile(this.sortSnippetsChronologically(merged), projectPath);
const taskChangeResult = {
teamName,
memberName,
files,
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: files.length,
computedAt: new Date().toISOString(),
} satisfies AgentChangeSet;
return { result: taskChangeResult, latestMtime };
}
async computeTaskChanges(input: ResolvedTaskChangeComputeInput): Promise<TaskChangeSetV2> {
const { teamName, taskId, taskMeta, effectiveOptions, projectPath, includeDetails } = input;
const logRefs = await this.logsFinder.findLogFileRefsForTask(
teamName,
taskId,
effectiveOptions
);
if (logRefs.length === 0) {
return this.emptyTaskChangeSet(teamName, taskId);
}
const allScopes: TaskChangeScope[] = [];
for (const ref of logRefs) {
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
const scope = boundaries.scopes.find((candidate) => candidate.taskId === taskId);
if (scope) {
allScopes.push({ ...scope, memberName: ref.memberName });
}
}
if (allScopes.length === 0) {
const intervals = effectiveOptions.intervals;
if (Array.isArray(intervals) && intervals.length > 0) {
const { files, toolUseIds, startTimestamp, endTimestamp } =
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
return {
teamName,
taskId,
files,
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: files.length,
confidence: 'medium',
computedAt: new Date().toISOString(),
scope: {
taskId,
memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '',
startLine: 0,
endLine: 0,
startTimestamp,
endTimestamp,
toolUseIds,
filePaths: files.map((file) => file.filePath),
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
},
warnings:
files.length === 0
? ['No file edits found within persisted workIntervals.']
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
};
}
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails);
}
const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds));
const files = await this.extractFilteredChanges(
logRefs,
allowedToolUseIds,
projectPath,
includeDetails
);
const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier));
return {
teamName,
taskId,
files,
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: files.length,
confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low',
computedAt: new Date().toISOString(),
scope: allScopes[0],
warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [],
};
}
private async extractIntervalScopedChanges(
logRefs: LogFileRef[],
intervals: { startedAt: string; completedAt?: string }[],
projectPath?: string,
includeDetails = true
): Promise<{
files: FileChangeSummary[];
toolUseIds: string[];
startTimestamp: string;
endTimestamp: string;
}> {
const normalized: {
startMs: number;
endMs: number | null;
startedAt: string;
completedAt?: string;
}[] = [];
for (const interval of intervals) {
const startMs = Date.parse(interval.startedAt);
if (!Number.isFinite(startMs)) continue;
const endMsRaw =
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
normalized.push({
startMs,
endMs,
startedAt: interval.startedAt,
completedAt: interval.completedAt,
});
}
normalized.sort((a, b) => a.startMs - b.startMs);
const startTimestamp = normalized[0]?.startedAt ?? '';
const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>(
(acc, item) => {
if (item.endMs == null || typeof item.completedAt !== 'string') return acc;
if (!acc || item.endMs > acc.endMs) {
return { endMs: item.endMs, endTimestamp: item.completedAt };
}
return acc;
},
null
);
const endTimestamp = maxEnd?.endTimestamp ?? '';
const inAnyInterval = (timestamp: string): boolean => {
const ms = Date.parse(timestamp);
if (!Number.isFinite(ms)) return false;
for (const interval of normalized) {
if (ms < interval.startMs) continue;
if (interval.endMs == null) return true;
if (ms <= interval.endMs) return true;
}
return false;
};
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
const allowedSnippets: SnippetDiff[] = [];
const toolUseIdsSet = new Set<string>();
for (const { snippets } of allParsed) {
for (const snippet of snippets) {
if (snippet.isError) continue;
if (!inAnyInterval(snippet.timestamp)) continue;
allowedSnippets.push(snippet);
if (snippet.toolUseId) {
toolUseIdsSet.add(snippet.toolUseId);
}
}
}
return {
files: this.aggregateByFile(
this.sortSnippetsChronologically(allowedSnippets),
projectPath,
includeDetails
),
toolUseIds: [...toolUseIdsSet],
startTimestamp,
endTimestamp,
};
}
private async extractFilteredChanges(
logRefs: LogFileRef[],
allowedToolUseIds: Set<string>,
projectPath?: string,
includeDetails = true
): Promise<FileChangeSummary[]> {
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
const allSnippets: SnippetDiff[] = [];
for (const { snippets } of allParsed) {
if (allowedToolUseIds.size > 0) {
for (const snippet of snippets) {
if (allowedToolUseIds.has(snippet.toolUseId)) {
allSnippets.push(snippet);
}
}
} else {
allSnippets.push(...snippets);
}
}
return this.aggregateByFile(
this.sortSnippetsChronologically(allSnippets),
projectPath,
includeDetails
);
}
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
logRefs: LogFileRef[],
projectPath?: string,
includeDetails = true
): Promise<TaskChangeSetV2> {
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
const allSnippets = this.sortSnippetsChronologically(
allParsed.flatMap((result) => result.snippets)
);
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
return {
teamName,
taskId,
files: aggregatedFiles,
totalLinesAdded: aggregatedFiles.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: aggregatedFiles.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: aggregatedFiles.length,
confidence: 'fallback',
computedAt: new Date().toISOString(),
scope: {
taskId,
memberName: logRefs[0]?.memberName ?? 'unknown',
startLine: 1,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: aggregatedFiles.map((file) => file.filePath),
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
},
warnings: ['No task boundaries found — showing all changes from related sessions.'],
};
}
private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 {
return {
teamName,
taskId,
files: [],
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: 0,
confidence: 'fallback',
computedAt: new Date().toISOString(),
scope: {
taskId,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: ['No log files found for this task.'],
};
}
private async parseJSONLFilesWithConcurrency(
paths: string[]
): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> {
if (paths.length === 0) return [];
const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (true) {
const currentIndex = nextIndex++;
if (currentIndex >= paths.length) return;
results[currentIndex] = await this.parseJSONLFile(paths[currentIndex]);
}
};
await Promise.all(
Array.from(
{ length: Math.min(TaskChangeComputer.JSONL_PARSE_CONCURRENCY, paths.length) },
() => worker()
)
);
return results;
}
private async parseJSONLFile(
filePath: string
): Promise<{ snippets: SnippetDiff[]; mtime: number }> {
let fileMtime = 0;
try {
const fileStat = await stat(filePath);
fileMtime = fileStat.mtimeMs;
const cached = this.parsedSnippetsCache.get(filePath);
if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) {
return { snippets: cached.data, mtime: fileMtime };
}
} catch (error) {
logger.debug(`Не удалось stat файла ${filePath}: ${String(error)}`);
return { snippets: [], mtime: 0 };
}
const entries: Record<string, unknown>[] = [];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
entries.push(JSON.parse(trimmed) as Record<string, unknown>);
} catch {
// Ignore invalid JSON lines.
}
}
rl.close();
stream.destroy();
} catch (error) {
logger.debug(`Не удалось прочитать файл ${filePath}: ${String(error)}`);
return { snippets: [], mtime: 0 };
}
const erroredIds = this.collectErroredToolUseIds(entries);
const snippets: SnippetDiff[] = [];
const seenFiles = new Set<string>();
for (const entry of entries) {
const role = this.extractRole(entry);
if (role !== 'assistant') continue;
const content = this.extractContent(entry);
if (!content) continue;
const timestamp =
typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString();
for (const block of content) {
if (
!block ||
typeof block !== 'object' ||
(block as Record<string, unknown>).type !== 'tool_use'
) {
continue;
}
const toolBlock = block as Record<string, unknown>;
const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : '';
const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName;
const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : '';
const input = toolBlock.input as Record<string, unknown> | undefined;
if (!input) continue;
const isError = erroredIds.has(toolUseId);
if (toolName === 'Edit') {
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
const newString = typeof input.new_string === 'string' ? input.new_string : '';
const replaceAll = input.replace_all === true;
if (targetPath) {
seenFiles.add(this.normalizeFilePathKey(targetPath));
snippets.push({
toolUseId,
filePath: targetPath,
toolName: 'Edit',
type: 'edit',
oldString,
newString,
replaceAll,
timestamp,
isError,
contextHash: this.computeContextHash(oldString, newString),
});
}
} else if (toolName === 'Write') {
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
const writeContent = typeof input.content === 'string' ? input.content : '';
if (targetPath) {
const normalizedTargetPath = this.normalizeFilePathKey(targetPath);
const isNew = !seenFiles.has(normalizedTargetPath);
seenFiles.add(normalizedTargetPath);
snippets.push({
toolUseId,
filePath: targetPath,
toolName: 'Write',
type: isNew ? 'write-new' : 'write-update',
oldString: '',
newString: writeContent,
replaceAll: false,
timestamp,
isError,
contextHash: this.computeContextHash('', writeContent),
});
}
} else if (toolName === 'MultiEdit') {
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
const edits = Array.isArray(input.edits) ? input.edits : [];
if (targetPath) {
seenFiles.add(this.normalizeFilePathKey(targetPath));
for (const edit of edits) {
if (!edit || typeof edit !== 'object') continue;
const editObj = edit as Record<string, unknown>;
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
snippets.push({
toolUseId,
filePath: targetPath,
toolName: 'MultiEdit',
type: 'multi-edit',
oldString,
newString,
replaceAll: false,
timestamp,
isError,
contextHash: this.computeContextHash(oldString, newString),
});
}
}
}
}
}
this.parsedSnippetsCache.set(filePath, {
data: snippets,
mtime: fileMtime,
expiresAt: Date.now() + this.parsedSnippetsCacheTtl,
});
return { snippets, mtime: fileMtime };
}
private extractContent(entry: Record<string, unknown>): unknown[] | null {
const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) return message.content as unknown[];
if (Array.isArray(entry.content)) return entry.content as unknown[];
return null;
}
private extractRole(entry: Record<string, unknown>): string | null {
if (typeof entry.role === 'string') return entry.role;
const message = entry.message as Record<string, unknown> | undefined;
if (message && typeof message.role === 'string') return message.role;
return null;
}
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
const erroredIds = new Set<string>();
for (const entry of entries) {
if (Array.isArray(entry.content)) {
for (const block of entry.content) {
if (this.isErroredToolResult(block)) {
const toolUseId = (block as Record<string, unknown>).tool_use_id;
if (typeof toolUseId === 'string') {
erroredIds.add(toolUseId);
}
}
}
}
const message = entry.message as Record<string, unknown> | undefined;
if (message && Array.isArray(message.content)) {
for (const block of message.content) {
if (this.isErroredToolResult(block)) {
const toolUseId = (block as Record<string, unknown>).tool_use_id;
if (typeof toolUseId === 'string') {
erroredIds.add(toolUseId);
}
}
}
}
}
return erroredIds;
}
private isErroredToolResult(block: unknown): boolean {
if (!block || typeof block !== 'object') return false;
const obj = block as Record<string, unknown>;
return obj.type === 'tool_result' && obj.is_error === true;
}
private aggregateByFile(
snippets: SnippetDiff[],
projectPath?: string,
includeDetails = true
): FileChangeSummary[] {
const fileMap = new Map<
string,
{ filePath: string; snippets: SnippetDiff[]; isNewFile: boolean }
>();
for (const snippet of snippets) {
if (snippet.isError) continue;
const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath);
const existing = fileMap.get(normalizedFilePath);
if (existing) {
existing.snippets.push(snippet);
if (snippet.type === 'write-new') existing.isNewFile = true;
} else {
fileMap.set(normalizedFilePath, {
filePath: snippet.filePath,
snippets: [snippet],
isNewFile: snippet.type === 'write-new',
});
}
}
return [...fileMap.values()].map((data) => {
let totalAdded = 0;
let totalRemoved = 0;
for (const snippet of data.snippets) {
if (snippet.isError) continue;
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
totalAdded += added;
totalRemoved += removed;
}
const normalizedFilePath = data.filePath.replace(/\\/g, '/');
const normalizedProjectPath = projectPath?.replace(/\\/g, '/');
const relativePath = normalizedProjectPath
? normalizedFilePath.startsWith(normalizedProjectPath + '/')
? normalizedFilePath.slice(normalizedProjectPath.length + 1)
: normalizedFilePath.startsWith(normalizedProjectPath)
? normalizedFilePath.slice(normalizedProjectPath.length)
: normalizedFilePath.split('/').slice(-3).join('/')
: normalizedFilePath.split('/').slice(-3).join('/');
return {
filePath: data.filePath,
relativePath,
snippets: includeDetails ? data.snippets : [],
linesAdded: totalAdded,
linesRemoved: totalRemoved,
isNewFile: data.isNewFile,
timeline: includeDetails ? this.buildTimeline(data.filePath, data.snippets) : undefined,
};
});
}
private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline {
const events: FileEditEvent[] = snippets
.filter((snippet) => !snippet.isError)
.map((snippet, index) => {
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
return {
toolUseId: snippet.toolUseId,
toolName: snippet.toolName as FileEditEvent['toolName'],
timestamp: snippet.timestamp,
summary: this.generateEditSummary(snippet),
linesAdded: added,
linesRemoved: removed,
snippetIndex: index,
};
});
const timestamps = events
.map((event) => new Date(event.timestamp).getTime())
.filter((timestamp) => !Number.isNaN(timestamp));
const durationMs =
timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0;
return { filePath, events, durationMs };
}
private generateEditSummary(snippet: SnippetDiff): string {
switch (snippet.type) {
case 'write-new':
return 'Created new file';
case 'write-update':
return 'Wrote full file content';
case 'multi-edit': {
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
const total = added + removed;
return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`;
}
case 'edit': {
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`;
if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
return `Changed ${removed}${added} lines`;
}
default:
return 'File modified';
}
}
private computeContextHash(oldString: string, newString: string): string {
const take3 = (value: string): string => {
const lines = value.split('\n');
const head = lines.slice(0, 3).join('\n');
const tail = lines.length > 3 ? lines.slice(-3).join('\n') : '';
return `${head}|${tail}`;
};
const raw = `${take3(oldString)}::${take3(newString)}`;
let hash = 5381;
for (let index = 0; index < raw.length; index++) {
hash = ((hash << 5) + hash + raw.charCodeAt(index)) | 0;
}
return (hash >>> 0).toString(36);
}
private sortSnippetsChronologically(snippets: SnippetDiff[]): SnippetDiff[] {
return snippets
.map((snippet, originalIndex) => ({ snippet, originalIndex }))
.sort((a, b) => {
const aMs = Date.parse(a.snippet.timestamp);
const bMs = Date.parse(b.snippet.timestamp);
const safeA = Number.isFinite(aMs) ? aMs : Number.MAX_SAFE_INTEGER;
const safeB = Number.isFinite(bMs) ? bMs : Number.MAX_SAFE_INTEGER;
if (safeA !== safeB) return safeA - safeB;
if (a.snippet.filePath !== b.snippet.filePath) {
return a.snippet.filePath.localeCompare(b.snippet.filePath);
}
if (a.snippet.toolUseId !== b.snippet.toolUseId) {
return a.snippet.toolUseId.localeCompare(b.snippet.toolUseId);
}
return a.originalIndex - b.originalIndex;
})
.map(({ snippet }) => snippet);
}
private normalizeFilePathKey(filePath: string): string {
return normalizeTaskChangePresenceFilePath(filePath);
}
}

View file

@ -0,0 +1,267 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Worker } from 'node:worker_threads';
import { createLogger } from '@shared/utils/logger';
import type {
ResolvedTaskChangeComputeInput,
TaskChangeWorkerRequest,
TaskChangeWorkerResponse,
} from './taskChangeWorkerTypes';
import type { TaskChangeSetV2 } from '@shared/types';
const logger = createLogger('Service:TaskChangeWorkerClient');
const DEFAULT_WORKER_CALL_TIMEOUT_MS = 30_000;
interface WorkerLike {
on(event: 'message', listener: (msg: TaskChangeWorkerResponse) => void): this;
on(event: 'error', listener: (error: Error) => void): this;
on(event: 'exit', listener: (code: number) => void): this;
postMessage(message: TaskChangeWorkerRequest): void;
terminate(): Promise<number>;
}
interface QueueEntry {
id: string;
request: TaskChangeWorkerRequest;
resolve: (value: TaskChangeSetV2) => void;
reject: (error: Error) => void;
}
function makeId(): string {
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
}
function resolveWorkerPath(): string | null {
const baseDir =
typeof __dirname === 'string' && __dirname.length > 0
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(baseDir, 'task-change-worker.cjs'),
path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.cjs'),
path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.js'),
];
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) {
return candidate;
}
} catch {
// ignore
}
}
return null;
}
export class TaskChangeWorkerClient {
private worker: WorkerLike | null = null;
private terminatingWorker: WorkerLike | null = null;
private readonly workerPath: string | null;
private readonly workerFactory: (workerPath: string) => WorkerLike;
private readonly timeoutMs: number;
private readonly enabled: boolean;
private warnedUnavailable = false;
private activeRequestId: string | null = null;
private activeTimeout: ReturnType<typeof setTimeout> | null = null;
private terminatingForTimeoutRequestId: string | null = null;
private pending = new Map<string, QueueEntry>();
private queue: QueueEntry[] = [];
constructor(options?: {
workerPath?: string | null;
workerFactory?: (workerPath: string) => WorkerLike;
timeoutMs?: number;
enabled?: boolean;
}) {
this.workerPath =
options && 'workerPath' in options ? (options.workerPath ?? null) : resolveWorkerPath();
this.workerFactory = options?.workerFactory ?? ((workerPath) => new Worker(workerPath));
this.timeoutMs = options?.timeoutMs ?? DEFAULT_WORKER_CALL_TIMEOUT_MS;
this.enabled = options?.enabled ?? process.env.CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER !== '0';
}
isAvailable(): boolean {
if (!this.enabled) {
return false;
}
if (!this.workerPath && !this.warnedUnavailable) {
this.warnedUnavailable = true;
logger.warn('task-change-worker not found; falling back to main-thread extraction.');
}
return this.workerPath !== null;
}
async computeTaskChanges(payload: ResolvedTaskChangeComputeInput): Promise<TaskChangeSetV2> {
if (!this.isAvailable()) {
throw new Error('Task change worker is not available in this environment');
}
const id = makeId();
const entry: QueueEntry = {
id,
request: { id, op: 'computeTaskChanges', payload },
resolve: () => undefined,
reject: () => undefined,
};
return new Promise<TaskChangeSetV2>((resolve, reject) => {
entry.resolve = resolve;
entry.reject = reject;
this.pending.set(id, entry);
this.queue.push(entry);
this.processQueue();
});
}
private ensureWorker(): WorkerLike {
if (!this.workerPath) {
throw new Error('Task change worker is not available in this environment');
}
if (this.worker) {
return this.worker;
}
const worker = this.workerFactory(this.workerPath);
worker.on('message', (msg) => this.handleMessage(msg));
worker.on('error', (error) => this.handleWorkerFailure(worker, error));
worker.on('exit', (code) => this.handleWorkerExit(worker, code));
this.worker = worker;
return worker;
}
private processQueue(): void {
if (this.activeRequestId || this.queue.length === 0) {
return;
}
const entry = this.queue.shift();
if (!entry) {
return;
}
const worker = this.ensureWorker();
this.activeRequestId = entry.id;
this.activeTimeout = setTimeout(() => {
const activeId = this.activeRequestId;
if (!activeId) {
return;
}
this.clearActiveState();
this.terminatingForTimeoutRequestId = activeId;
const pending = this.pending.get(activeId);
if (pending) {
this.pending.delete(activeId);
pending.reject(
new Error(`Worker call timeout after ${this.timeoutMs}ms (computeTaskChanges)`)
);
}
try {
const workerToTerminate = this.worker;
this.terminatingWorker = workerToTerminate;
workerToTerminate?.terminate().catch(() => undefined);
} catch {
// ignore
} finally {
this.worker = null;
}
this.processQueue();
}, this.timeoutMs);
try {
worker.postMessage(entry.request);
} catch (error) {
this.clearActiveState();
this.pending.delete(entry.id);
entry.reject(error instanceof Error ? error : new Error(String(error)));
this.processQueue();
}
}
private handleMessage(message: TaskChangeWorkerResponse): void {
const entry = this.pending.get(message.id);
if (!entry) {
return;
}
this.pending.delete(message.id);
if (this.activeRequestId === message.id) {
this.clearActiveState();
}
if (message.ok) {
entry.resolve(message.result);
} else {
entry.reject(new Error(message.error));
}
this.processQueue();
}
private handleWorkerFailure(worker: WorkerLike, error: Error): void {
logger.error('Task change worker error', error);
if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) {
this.terminatingForTimeoutRequestId = null;
this.terminatingWorker = null;
return;
}
this.rejectAllPending(error);
this.clearActiveState();
if (this.worker === worker) {
this.worker = null;
}
}
private handleWorkerExit(worker: WorkerLike, code: number): void {
if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) {
this.terminatingForTimeoutRequestId = null;
this.terminatingWorker = null;
return;
}
if (code !== 0) {
logger.warn(`Task change worker exited with code ${code}`);
}
this.rejectAllPending(new Error(`Worker exited with code ${code}`));
this.clearActiveState();
if (this.worker === worker) {
this.worker = null;
}
}
private rejectAllPending(error: Error): void {
for (const entry of this.pending.values()) {
entry.reject(error);
}
this.pending.clear();
this.queue = [];
}
private clearActiveState(): void {
this.activeRequestId = null;
if (this.activeTimeout) {
clearTimeout(this.activeTimeout);
this.activeTimeout = null;
}
}
}
let singleton: TaskChangeWorkerClient | null = null;
export function getTaskChangeWorkerClient(): TaskChangeWorkerClient {
if (!singleton) {
singleton = new TaskChangeWorkerClient();
}
return singleton;
}

View file

@ -40,6 +40,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
import type {
AddMemberRequest,
@ -63,11 +64,15 @@ import type {
TeamSummary,
TeamTask,
TeamTaskStatus,
TaskChangePresenceState,
TeamTaskWithKanban,
ToolCallMeta,
UpdateKanbanPatch,
} from '@shared/types';
import type { AgentTeamsController } from 'agent-teams-controller';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
const { createController } = agentTeamsControllerModule;
@ -91,6 +96,11 @@ interface EligibleTaskCommentNotification {
summary: string;
}
interface TaskChangeLogSourceSnapshot {
projectFingerprint: string | null;
logSourceGeneration: string | null;
}
export class TeamDataService {
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
private processHealthTeams = new Set<string>();
@ -98,6 +108,8 @@ export class TeamDataService {
private notifiedTaskStarts = new Set<string>();
private taskCommentNotificationInitialization: Promise<void> | null = null;
private taskCommentNotificationInFlight = new Set<string>();
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -168,6 +180,120 @@ export class TeamDataService {
return null;
}
setTaskChangePresenceServices(
repository: TaskChangePresenceRepository,
tracker: TeamLogSourceTracker
): void {
this.taskChangePresenceRepository = repository;
this.teamLogSourceTracker = tracker;
}
setTaskChangePresenceTracking(teamName: string, enabled: boolean): void {
if (!this.teamLogSourceTracker) {
return;
}
if (enabled) {
void this.teamLogSourceTracker
.ensureTracking(teamName)
.catch((error) =>
logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`)
);
return;
}
void this.teamLogSourceTracker
.stopTracking(teamName)
.catch((error) =>
logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`)
);
}
private resolveTaskChangePresenceMap(
tasks: readonly TeamTaskWithKanban[],
changePresenceEnabled: boolean,
presenceIndex: PersistedTaskChangePresenceIndex | null,
logSourceSnapshot: TaskChangeLogSourceSnapshot | null
): Record<string, TaskChangePresenceState> {
const result: Record<string, TaskChangePresenceState> = {};
if (
!changePresenceEnabled ||
!presenceIndex ||
!logSourceSnapshot?.projectFingerprint ||
!logSourceSnapshot.logSourceGeneration ||
presenceIndex.projectFingerprint !== logSourceSnapshot.projectFingerprint ||
presenceIndex.logSourceGeneration !== logSourceSnapshot.logSourceGeneration
) {
for (const task of tasks) {
result[task.id] = 'unknown';
}
return result;
}
for (const task of tasks) {
const descriptor = buildTaskChangePresenceDescriptor({
owner: task.owner,
status: task.status,
intervals: task.workIntervals,
reviewState: task.reviewState,
historyEvents: task.historyEvents,
kanbanColumn: task.kanbanColumn,
});
const presenceEntry = presenceIndex.entries[task.id];
result[task.id] =
presenceEntry &&
presenceEntry.taskSignature === descriptor.taskSignature &&
presenceEntry.logSourceGeneration === logSourceSnapshot.logSourceGeneration
? presenceEntry.presence
: 'unknown';
}
return result;
}
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
const changePresenceEnabled =
this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null;
const logSourceSnapshot: TaskChangeLogSourceSnapshot | null =
changePresenceEnabled &&
typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown })
.getSnapshot === 'function'
? ((
this.teamLogSourceTracker as {
getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null;
}
).getSnapshot(teamName) ?? null)
: null;
const [tasks, kanbanState, presenceIndex] = await Promise.all([
this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]),
this.kanbanManager
.getState(teamName)
.catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState),
changePresenceEnabled &&
logSourceSnapshot?.projectFingerprint &&
logSourceSnapshot.logSourceGeneration
? this.taskChangePresenceRepository!.load(teamName)
: Promise.resolve(null),
]);
const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
);
return this.resolveTaskChangePresenceMap(
tasksWithKanbanBase,
changePresenceEnabled,
presenceIndex,
logSourceSnapshot
);
}
async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams();
}
@ -333,6 +459,24 @@ export class TeamDataService {
mark('config');
const warnings: string[] = [];
const changePresenceEnabled =
this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null;
const logSourceSnapshot: TaskChangeLogSourceSnapshot | null =
changePresenceEnabled &&
typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown })
.getSnapshot === 'function'
? ((
this.teamLogSourceTracker as {
getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null;
}
).getSnapshot(teamName) ?? null)
: null;
const presenceIndexPromise =
changePresenceEnabled &&
logSourceSnapshot?.projectFingerprint &&
logSourceSnapshot.logSourceGeneration
? this.taskChangePresenceRepository!.load(teamName)
: Promise.resolve(null);
let tasks: TeamTask[] = [];
try {
@ -473,10 +617,25 @@ export class TeamDataService {
mark('kanbanGc');
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
);
const presenceIndex = await presenceIndexPromise;
const taskChangePresenceById = this.resolveTaskChangePresenceMap(
tasksWithKanbanBase,
changePresenceEnabled,
presenceIndex,
logSourceSnapshot
);
const tasksWithKanban: TeamTaskWithKanban[] = changePresenceEnabled
? tasksWithKanbanBase.map((task) => ({
...task,
changePresence: taskChangePresenceById[task.id] ?? 'unknown',
}))
: tasksWithKanbanBase;
const members = this.memberResolver.resolveMembers(
config,
metaMembers,
@ -492,10 +651,6 @@ export class TeamDataService {
mark('syncComments');
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
);
let processes: TeamProcess[] = [];
try {
processes = await this.readProcesses(teamName);
@ -530,7 +685,7 @@ export class TeamDataService {
return {
teamName,
config,
tasks: tasksToReturn,
tasks: tasksWithKanban,
members,
messages,
kanbanState,

View file

@ -0,0 +1,361 @@
import { createLogger } from '@shared/utils/logger';
import { watch } from 'chokidar';
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
import {
computeTaskChangePresenceProjectFingerprint,
normalizeTaskChangePresenceFilePath,
} from './taskChangePresenceUtils';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { TeamChangeEvent } from '@shared/types';
import type { FSWatcher } from 'chokidar';
const logger = createLogger('Service:TeamLogSourceTracker');
interface TeamLogSourceSnapshot {
projectFingerprint: string | null;
logSourceGeneration: string | null;
}
interface TrackingState {
watcher: FSWatcher | null;
projectDir: string | null;
refreshTimer: ReturnType<typeof setTimeout> | null;
initializePromise: Promise<TeamLogSourceSnapshot> | null;
initializeVersion: number | null;
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
recomputeVersion: number | null;
snapshot: TeamLogSourceSnapshot;
desiredTracking: boolean;
lifecycleVersion: number;
}
export class TeamLogSourceTracker {
private readonly stateByTeam = new Map<string, TrackingState>();
private emitter: ((event: TeamChangeEvent) => void) | null = null;
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
setEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
this.emitter = emitter;
}
getSnapshot(teamName: string): TeamLogSourceSnapshot | null {
const state = this.stateByTeam.get(teamName);
return state ? { ...state.snapshot } : null;
}
async ensureTracking(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (!state.desiredTracking) {
state.desiredTracking = true;
state.lifecycleVersion += 1;
}
if (
state.initializePromise &&
state.initializeVersion === state.lifecycleVersion &&
state.desiredTracking
) {
return state.initializePromise;
}
const initializeVersion = state.lifecycleVersion;
const initializePromise = this.initializeTeam(teamName, initializeVersion)
.catch((error) => {
logger.debug(`Failed to initialize log-source tracker for ${teamName}: ${String(error)}`);
return { projectFingerprint: null, logSourceGeneration: null };
})
.finally(() => {
const current = this.stateByTeam.get(teamName);
if (current?.initializePromise === initializePromise) {
current.initializePromise = null;
current.initializeVersion = null;
}
});
state.initializePromise = initializePromise;
state.initializeVersion = initializeVersion;
return initializePromise;
}
async dispose(): Promise<void> {
await Promise.all([...this.stateByTeam.keys()].map((teamName) => this.stopTracking(teamName)));
}
private getOrCreateState(teamName: string): TrackingState {
const existing = this.stateByTeam.get(teamName);
if (existing) {
return existing;
}
const created: TrackingState = {
watcher: null,
projectDir: null,
refreshTimer: null,
initializePromise: null,
initializeVersion: null,
recomputePromise: null,
recomputeVersion: null,
snapshot: { projectFingerprint: null, logSourceGeneration: null },
desiredTracking: false,
lifecycleVersion: 0,
};
this.stateByTeam.set(teamName, created);
return created;
}
async stopTracking(teamName: string): Promise<void> {
const state = this.stateByTeam.get(teamName);
if (!state) {
return;
}
if (state.desiredTracking) {
state.desiredTracking = false;
state.lifecycleVersion += 1;
}
if (state.refreshTimer) {
clearTimeout(state.refreshTimer);
state.refreshTimer = null;
}
if (state.watcher) {
await state.watcher.close().catch(() => undefined);
state.watcher = null;
}
state.projectDir = null;
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
}
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
const state = this.stateByTeam.get(teamName);
return !!state && state.desiredTracking && state.lifecycleVersion === expectedVersion;
}
private async initializeTeam(
teamName: string,
expectedVersion: number
): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
const previousGeneration = state.snapshot.logSourceGeneration;
const context = await this.logsFinder.getLogSourceWatchContext(teamName, {
forceRefresh: true,
});
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
return this.getOrCreateState(teamName).snapshot;
}
if (!context) {
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
await this.rebuildWatcher(teamName, null, expectedVersion);
return state.snapshot;
}
const snapshot = await this.computeSnapshot(context);
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
return this.getOrCreateState(teamName).snapshot;
}
state.snapshot = snapshot;
await this.rebuildWatcher(teamName, context.projectDir, expectedVersion);
if (
this.isTrackingCurrent(teamName, expectedVersion) &&
state.snapshot.logSourceGeneration &&
previousGeneration !== state.snapshot.logSourceGeneration
) {
this.emitter?.({
type: 'log-source-change',
teamName,
});
}
return snapshot;
}
private async rebuildWatcher(
teamName: string,
projectDir: string | null,
expectedVersion: number
): Promise<void> {
const state = this.stateByTeam.get(teamName);
if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) {
return;
}
if (state.projectDir === projectDir && state.watcher) {
return;
}
if (state.watcher) {
await state.watcher.close().catch(() => undefined);
state.watcher = null;
}
state.projectDir = projectDir;
if (!projectDir) {
return;
}
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
state.projectDir = null;
return;
}
state.watcher = watch(projectDir, {
ignoreInitial: true,
ignorePermissionErrors: true,
followSymlinks: false,
depth: 3,
awaitWriteFinish: {
stabilityThreshold: 250,
pollInterval: 50,
},
});
const scheduleRecompute = (): void => {
const current = this.stateByTeam.get(teamName);
if (!current || !current.desiredTracking) {
return;
}
if (current.refreshTimer) {
clearTimeout(current.refreshTimer);
}
current.refreshTimer = setTimeout(() => {
current.refreshTimer = null;
void this.recompute(teamName);
}, 300);
};
state.watcher.on('add', scheduleRecompute);
state.watcher.on('change', scheduleRecompute);
state.watcher.on('unlink', scheduleRecompute);
state.watcher.on('addDir', scheduleRecompute);
state.watcher.on('unlinkDir', scheduleRecompute);
state.watcher.on('error', (error) => {
logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`);
});
}
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (!state.desiredTracking) {
return state.snapshot;
}
if (
state.recomputePromise &&
state.recomputeVersion === state.lifecycleVersion &&
state.desiredTracking
) {
return state.recomputePromise;
}
const recomputeVersion = state.lifecycleVersion;
const recomputePromise = (async () => {
const previousGeneration = state.snapshot.logSourceGeneration;
const context = await this.logsFinder.getLogSourceWatchContext(teamName, {
forceRefresh: true,
});
if (!this.isTrackingCurrent(teamName, recomputeVersion)) {
return this.getOrCreateState(teamName).snapshot;
}
if (!context) {
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
await this.rebuildWatcher(teamName, null, recomputeVersion);
} else {
state.snapshot = await this.computeSnapshot(context);
if (!this.isTrackingCurrent(teamName, recomputeVersion)) {
return this.getOrCreateState(teamName).snapshot;
}
await this.rebuildWatcher(teamName, context.projectDir, recomputeVersion);
}
if (
this.isTrackingCurrent(teamName, recomputeVersion) &&
previousGeneration &&
state.snapshot.logSourceGeneration &&
previousGeneration !== state.snapshot.logSourceGeneration
) {
this.emitter?.({
type: 'log-source-change',
teamName,
});
}
return state.snapshot;
})().finally(() => {
const current = this.stateByTeam.get(teamName);
if (current?.recomputePromise === recomputePromise) {
current.recomputePromise = null;
current.recomputeVersion = null;
}
});
state.recomputePromise = recomputePromise;
state.recomputeVersion = recomputeVersion;
return recomputePromise;
}
private async computeSnapshot(context: {
projectDir: string;
projectPath?: string;
leadSessionId?: string;
sessionIds: string[];
}): Promise<TeamLogSourceSnapshot> {
const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath);
const parts: string[] = [];
if (context.leadSessionId) {
const leadLogPath = path.join(context.projectDir, `${context.leadSessionId}.jsonl`);
parts.push(await this.describePath('lead', leadLogPath));
}
for (const sessionId of [...context.sessionIds].sort((a, b) => a.localeCompare(b))) {
const sessionDir = path.join(context.projectDir, sessionId);
const subagentsDir = path.join(sessionDir, 'subagents');
parts.push(await this.describePath('session', sessionDir));
parts.push(await this.describePath('subagents', subagentsDir));
let entries: string[] = [];
try {
entries = await fs.readdir(subagentsDir);
} catch {
entries = [];
}
for (const fileName of entries
.filter(
(entry) =>
entry.startsWith('agent-') &&
entry.endsWith('.jsonl') &&
!entry.startsWith('agent-acompact')
)
.sort((a, b) => a.localeCompare(b))) {
parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName)));
}
}
const sourceMaterial =
parts.length > 0
? parts.join('|')
: `empty:${normalizeTaskChangePresenceFilePath(context.projectDir)}`;
return {
projectFingerprint,
logSourceGeneration: createHash('sha256').update(sourceMaterial).digest('hex'),
};
}
private async describePath(kind: string, targetPath: string): Promise<string> {
const normalizedPath = normalizeTaskChangePresenceFilePath(targetPath);
try {
const stats = await fs.stat(targetPath);
const type = stats.isDirectory() ? 'dir' : 'file';
return `${kind}:${type}:${normalizedPath}:${stats.size}:${stats.mtimeMs}`;
} catch {
return `${kind}:missing:${normalizedPath}`;
}
}
}

View file

@ -173,6 +173,32 @@ export class TeamMemberLogsFinder {
);
}
async getLogSourceWatchContext(
teamName: string,
options?: { forceRefresh?: boolean }
): Promise<{
projectDir: string;
projectPath?: string;
leadSessionId?: string;
sessionIds: string[];
} | null> {
if (options?.forceRefresh) {
this.discoveryCache.delete(teamName);
}
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) {
return null;
}
return {
projectDir: discovery.projectDir,
projectPath: discovery.config.projectPath,
leadSessionId: discovery.config.leadSessionId,
sessionIds: [...discovery.sessionIds],
};
}
/**
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
* When the task is in_progress and has an owner, also includes that owner's session logs so

View file

@ -0,0 +1,140 @@
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { getTaskChangePresenceBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import {
normalizePersistedTaskChangePresenceIndex,
toPersistedTaskChangePresenceIndex,
} from './taskChangePresenceCacheSchema';
import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository';
import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes';
const logger = createLogger('Service:JsonTaskChangePresenceRepository');
const READ_TIMEOUT_MS = 5_000;
function encodeFileSegment(value: string): string {
return encodeURIComponent(value);
}
export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepository {
private readonly writeChains = new Map<string, Promise<void>>();
private get basePath(): string {
return getTaskChangePresenceBasePath();
}
private filePath(teamName: string): string {
return path.join(this.basePath, `${encodeFileSegment(teamName)}.json`);
}
private async readIndex(teamName: string): Promise<PersistedTaskChangePresenceIndex | null> {
const filePath = this.filePath(teamName);
let content: string;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
try {
content = await fs.promises.readFile(filePath, {
encoding: 'utf8',
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.warn(`Failed to read task-change presence index ${filePath}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(content) as unknown;
} catch (error) {
logger.warn(`Corrupted task-change presence index ${filePath}: ${String(error)}`);
await fs.promises.unlink(filePath).catch(() => undefined);
return null;
}
const normalized = normalizePersistedTaskChangePresenceIndex(parsed);
if (!normalized) {
await fs.promises.unlink(filePath).catch(() => undefined);
return null;
}
return normalized;
}
async load(teamName: string): Promise<PersistedTaskChangePresenceIndex | null> {
return this.readIndex(teamName);
}
async upsertEntry(
teamName: string,
metadata: {
projectFingerprint: string;
logSourceGeneration: string;
writtenAt: string;
},
entry: {
taskId: string;
taskSignature: string;
presence: 'has_changes' | 'no_changes';
writtenAt: string;
logSourceGeneration: string;
}
): Promise<void> {
const write = async (): Promise<void> => {
const current =
(await this.readIndex(teamName)) ??
({
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName,
projectFingerprint: metadata.projectFingerprint,
logSourceGeneration: metadata.logSourceGeneration,
writtenAt: metadata.writtenAt,
entries: {},
} satisfies PersistedTaskChangePresenceIndex);
const next = toPersistedTaskChangePresenceIndex({
...current,
projectFingerprint: metadata.projectFingerprint,
logSourceGeneration: metadata.logSourceGeneration,
writtenAt: metadata.writtenAt,
entries: {
...current.entries,
[entry.taskId]: {
taskId: entry.taskId,
taskSignature: entry.taskSignature,
presence: entry.presence,
writtenAt: entry.writtenAt,
logSourceGeneration: entry.logSourceGeneration,
},
},
});
await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2));
};
const previous = this.writeChains.get(teamName) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(write)
.finally(() => {
if (this.writeChains.get(teamName) === next) {
this.writeChains.delete(teamName);
}
});
this.writeChains.set(teamName, next);
await next;
}
}

View file

@ -0,0 +1,23 @@
import type {
PersistedTaskChangePresence,
PersistedTaskChangePresenceIndex,
} from './taskChangePresenceCacheTypes';
export interface TaskChangePresenceRepository {
load(teamName: string): Promise<PersistedTaskChangePresenceIndex | null>;
upsertEntry(
teamName: string,
metadata: {
projectFingerprint: string;
logSourceGeneration: string;
writtenAt: string;
},
entry: {
taskId: string;
taskSignature: string;
presence: PersistedTaskChangePresence;
writtenAt: string;
logSourceGeneration: string;
}
): Promise<void>;
}

View file

@ -0,0 +1,107 @@
import {
TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
type PersistedTaskChangePresence,
type PersistedTaskChangePresenceEntry,
type PersistedTaskChangePresenceIndex,
} from './taskChangePresenceCacheTypes';
function isIsoString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value));
}
function normalizePresence(value: unknown): PersistedTaskChangePresence | null {
return value === 'has_changes' || value === 'no_changes' ? value : null;
}
function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
const normalizedPresence = normalizePresence(raw.presence);
if (
typeof raw.taskSignature !== 'string' ||
!normalizedPresence ||
!isIsoString(raw.writtenAt) ||
typeof raw.logSourceGeneration !== 'string' ||
raw.logSourceGeneration.length === 0
) {
return null;
}
return {
taskId,
taskSignature: raw.taskSignature,
presence: normalizedPresence,
writtenAt: raw.writtenAt,
logSourceGeneration: raw.logSourceGeneration,
};
}
export function normalizePersistedTaskChangePresenceIndex(
value: unknown
): PersistedTaskChangePresenceIndex | null {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
if (
raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION ||
typeof raw.teamName !== 'string' ||
typeof raw.projectFingerprint !== 'string' ||
raw.projectFingerprint.length === 0 ||
typeof raw.logSourceGeneration !== 'string' ||
raw.logSourceGeneration.length === 0 ||
!isIsoString(raw.writtenAt) ||
!raw.entries ||
typeof raw.entries !== 'object'
) {
return null;
}
const normalizedEntries: Record<string, PersistedTaskChangePresenceEntry> = {};
for (const [taskId, entryValue] of Object.entries(raw.entries as Record<string, unknown>)) {
if (typeof taskId !== 'string' || taskId.length === 0) {
continue;
}
const normalized = normalizeEntry(taskId, entryValue);
if (normalized) {
normalizedEntries[taskId] = normalized;
}
}
return {
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName: raw.teamName,
projectFingerprint: raw.projectFingerprint,
logSourceGeneration: raw.logSourceGeneration,
writtenAt: raw.writtenAt,
entries: normalizedEntries,
};
}
export function toPersistedTaskChangePresenceIndex(
value: PersistedTaskChangePresenceIndex
): PersistedTaskChangePresenceIndex {
return {
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName: value.teamName,
projectFingerprint: value.projectFingerprint,
logSourceGeneration: value.logSourceGeneration,
writtenAt: value.writtenAt,
entries: Object.fromEntries(
Object.entries(value.entries).map(([taskId, entry]) => [
taskId,
{
taskId,
taskSignature: entry.taskSignature,
presence: entry.presence,
writtenAt: entry.writtenAt,
logSourceGeneration: entry.logSourceGeneration,
},
])
),
};
}

View file

@ -0,0 +1,22 @@
import type { TaskChangePresenceState } from '@shared/types/team';
export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1;
export type PersistedTaskChangePresence = Exclude<TaskChangePresenceState, 'unknown'>;
export interface PersistedTaskChangePresenceEntry {
taskId: string;
taskSignature: string;
presence: PersistedTaskChangePresence;
writtenAt: string;
logSourceGeneration: string;
}
export interface PersistedTaskChangePresenceIndex {
version: typeof TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION;
teamName: string;
projectFingerprint: string;
logSourceGeneration: string;
writtenAt: string;
entries: Record<string, PersistedTaskChangePresenceEntry>;
}

View file

@ -17,6 +17,7 @@ export { TeamInboxReader } from './TeamInboxReader';
export { TeamInboxWriter } from './TeamInboxWriter';
export { TeamKanbanManager } from './TeamKanbanManager';
export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
export { TeamLogSourceTracker } from './TeamLogSourceTracker';
export { TeamMemberResolver } from './TeamMemberResolver';
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
export { TeamProvisioningService } from './TeamProvisioningService';

View file

@ -0,0 +1,152 @@
import {
getTaskChangeStateBucket,
type TaskChangeStateBucket,
} from '@shared/utils/taskChangeState';
import { createHash } from 'crypto';
export interface TaskChangePresenceInterval {
startedAt: string;
completedAt?: string;
}
export interface TaskChangePresenceDescriptorInput {
owner?: string;
status?: string;
intervals?: TaskChangePresenceInterval[];
since?: string;
reviewState?: 'review' | 'needsFix' | 'approved' | 'none';
historyEvents?: unknown[];
kanbanColumn?: 'review' | 'approved';
}
export interface TaskChangePresenceDescriptor {
stateBucket: TaskChangeStateBucket;
taskSignature: string;
effectiveOptions: {
owner?: string;
status?: string;
intervals?: TaskChangePresenceInterval[];
since?: string;
};
}
function deriveIntervalsFromHistory(
historyEvents?: unknown[]
): TaskChangePresenceInterval[] | undefined {
if (!Array.isArray(historyEvents) || historyEvents.length === 0) {
return undefined;
}
const transitions = historyEvents
.map((event) =>
event && typeof event === 'object' ? (event as Record<string, unknown>) : null
)
.filter((event): event is Record<string, unknown> => event !== null)
.filter((event) => event.type === 'status_changed')
.map((event) => ({
to: typeof event.to === 'string' ? event.to : null,
timestamp: typeof event.timestamp === 'string' ? event.timestamp : null,
}))
.filter(
(transition): transition is { to: string; timestamp: string } =>
transition.to !== null && transition.timestamp !== null
)
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
if (transitions.length === 0) {
return undefined;
}
const derived: TaskChangePresenceInterval[] = [];
let currentStart: string | null = null;
for (const transition of transitions) {
if (transition.to === 'in_progress') {
if (!currentStart) {
currentStart = transition.timestamp;
}
continue;
}
if (currentStart) {
derived.push({ startedAt: currentStart, completedAt: transition.timestamp });
currentStart = null;
}
}
if (currentStart) {
derived.push({ startedAt: currentStart });
}
return derived.length > 0 ? derived : undefined;
}
export function normalizeTaskChangePresenceFilePath(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase());
}
export function computeTaskChangePresenceProjectFingerprint(
projectPath?: string | null
): string | null {
const normalizedProjectPath = typeof projectPath === 'string' ? projectPath.trim() : '';
if (!normalizedProjectPath) {
return null;
}
return createHash('sha256')
.update(normalizeTaskChangePresenceFilePath(normalizedProjectPath))
.digest('hex');
}
export function buildTaskChangePresenceDescriptor(
input: TaskChangePresenceDescriptorInput
): TaskChangePresenceDescriptor {
const effectiveIntervals =
Array.isArray(input.intervals) && input.intervals.length > 0
? input.intervals.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
}))
: (deriveIntervalsFromHistory(input.historyEvents)?.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
})) ?? []);
const stateBucket = getTaskChangeStateBucket({
status: input.status,
reviewState: input.reviewState,
historyEvents: input.historyEvents,
kanbanColumn: input.kanbanColumn,
});
const effectiveOptions = {
owner: typeof input.owner === 'string' ? input.owner.trim() : '',
status: typeof input.status === 'string' ? input.status.trim() : '',
intervals: effectiveIntervals,
since: typeof input.since === 'string' ? input.since : '',
};
return {
stateBucket,
taskSignature: JSON.stringify({
owner: effectiveOptions.owner,
status: effectiveOptions.status,
since: effectiveOptions.since,
stateBucket,
intervals: effectiveIntervals,
}),
effectiveOptions: {
owner: effectiveOptions.owner || undefined,
status: effectiveOptions.status || undefined,
intervals:
effectiveIntervals.length > 0
? effectiveIntervals.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt || undefined,
}))
: undefined,
since: effectiveOptions.since || undefined,
},
};
}

View file

@ -0,0 +1,49 @@
import type { TaskChangeSetV2 } from '@shared/types';
export interface TaskChangeTaskMeta {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
reviewState?: 'review' | 'needsFix' | 'approved' | 'none';
historyEvents?: unknown[];
kanbanColumn?: 'review' | 'approved';
}
export interface TaskChangeEffectiveOptions {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
export interface ResolvedTaskChangeComputeInput {
teamName: string;
taskId: string;
taskMeta: TaskChangeTaskMeta | null;
effectiveOptions: TaskChangeEffectiveOptions;
projectPath?: string;
includeDetails: boolean;
}
export interface ComputeTaskChangesRequest {
id: string;
op: 'computeTaskChanges';
payload: ResolvedTaskChangeComputeInput;
}
export interface ComputeTaskChangesSuccessResponse {
id: string;
ok: true;
result: TaskChangeSetV2;
}
export interface ComputeTaskChangesErrorResponse {
id: string;
ok: false;
error: string;
}
export type TaskChangeWorkerRequest = ComputeTaskChangesRequest;
export type TaskChangeWorkerResponse =
| ComputeTaskChangesSuccessResponse
| ComputeTaskChangesErrorResponse;

View file

@ -393,6 +393,10 @@ export function getTaskChangeSummariesBasePath(): string {
return path.join(getClaudeBasePath(), 'task-change-summaries');
}
export function getTaskChangePresenceBasePath(): string {
return path.join(getClaudeBasePath(), 'task-change-presence');
}
/**
* Get the backups directory path for the app's own storage.
*/

View file

@ -0,0 +1,40 @@
import { parentPort } from 'node:worker_threads';
import { TaskBoundaryParser } from '@main/services/team/TaskBoundaryParser';
import { TaskChangeComputer } from '@main/services/team/TaskChangeComputer';
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
import type {
TaskChangeWorkerRequest,
TaskChangeWorkerResponse,
} from '@main/services/team/taskChangeWorkerTypes';
const logsFinder = new TeamMemberLogsFinder();
const boundaryParser = new TaskBoundaryParser();
const computer = new TaskChangeComputer(logsFinder, boundaryParser);
function postMessage(message: TaskChangeWorkerResponse): void {
parentPort?.postMessage(message);
}
parentPort?.on('message', async (message: TaskChangeWorkerRequest) => {
if (!message || message.op !== 'computeTaskChanges') {
postMessage({
id: message?.id ?? 'unknown',
ok: false,
error: `Unsupported task change worker op: ${String(message?.op)}`,
});
return;
}
try {
const result = await computer.computeTaskChanges(message.payload);
postMessage({ id: message.id, ok: true, result });
} catch (error) {
postMessage({
id: message.id,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});

View file

@ -210,6 +210,12 @@ export const TEAM_LIST = 'team:list';
/** Get detailed team data */
export const TEAM_GET_DATA = 'team:getData';
/** Get lightweight task change presence map for the currently viewed team */
export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence';
/** Enable or disable task change presence tracking for a visible team tab */
export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking';
/** Get buffered Claude CLI logs (paged, newest-first) */
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';

View file

@ -121,6 +121,7 @@ import {
TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA,
TEAM_GET_TASK_CHANGE_PRESENCE,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
@ -148,6 +149,7 @@ import {
TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
@ -260,6 +262,7 @@ import type {
SshConnectionStatus,
SshLastConnection,
TaskAttachmentMeta,
TaskChangePresenceState,
TaskChangeSetV2,
TaskComment,
TeamChangeEvent,
@ -800,6 +803,15 @@ const electronAPI: ElectronAPI = {
getData: async (teamName: string) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
},
getTaskChangePresence: async (teamName: string) => {
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
TEAM_GET_TASK_CHANGE_PRESENCE,
teamName
);
},
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
},
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
},

View file

@ -668,6 +668,14 @@ export class HttpAPIClient implements ElectronAPI {
getData: async (_teamName: string): Promise<TeamData> => {
throw new Error('Teams detail is not available in browser mode');
},
getTaskChangePresence: async (): Promise<
Record<string, 'has_changes' | 'no_changes' | 'unknown'>
> => {
return {};
},
setChangePresenceTracking: async (): Promise<void> => {
// Not available in browser mode — no-op.
},
getClaudeLogs: async (
_teamName: string,
_query?: TeamClaudeLogsQuery

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useStore } from '@renderer/store';
import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
@ -22,11 +23,16 @@ export const TeamTabSectionNav = ({
teamName,
onActivate,
}: TeamTabSectionNavProps): React.JSX.Element => {
const messagesPanelMode = useStore((s) => s.messagesPanelMode);
const [open, setOpen] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
const visibleSections = SECTIONS.filter(
(section) =>
messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs')
);
const handleNavigate = useCallback(
(sectionId: string) => {
@ -99,7 +105,7 @@ export const TeamTabSectionNav = ({
if (e.key === 'Escape') setOpen(false);
}}
>
{SECTIONS.map((section) => {
{visibleSections.map((section) => {
const SectionIcon = section.icon;
return (
<button

View file

@ -90,10 +90,21 @@ import type {
FileChangeSummary,
KanbanTaskState,
ResolvedTeamMember,
TaskChangeSetV2,
TaskAttachmentMeta,
TeamTaskWithKanban,
} from '@shared/types';
function resolveTaskChangePresenceFromResult(
data: Pick<TaskChangeSetV2, 'files' | 'confidence'>
): 'has_changes' | 'no_changes' | null {
if (data.files.length > 0) {
return 'has_changes';
}
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
}
interface TaskDetailDialogProps {
open: boolean;
loading?: boolean;
@ -135,6 +146,7 @@ export const TaskDetailDialog = ({
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges);
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
const [logsRefreshing, setLogsRefreshing] = useState(false);
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
@ -324,7 +336,7 @@ export const TaskDetailDialog = ({
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
const loadTaskChangeSummary = useCallback(
async (forceFresh = false): Promise<FileChangeSummary[] | null> => {
async (forceFresh = false): Promise<TaskChangeSetV2 | null> => {
if (
!currentTask ||
!taskChangeSummaryOptions ||
@ -338,7 +350,7 @@ export const TaskDetailDialog = ({
...taskChangeSummaryOptions,
forceFresh,
});
return data.files;
return data;
},
[canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant]
);
@ -356,17 +368,21 @@ export const TaskDetailDialog = ({
}
setTaskChangesError(null);
void loadTaskChangeSummary()
.then((files) => {
.then((data) => {
if (!cancelled) {
setTaskChangesFiles(files ?? null);
setTaskChangesFiles(data?.files ?? null);
if (currentTask && taskChangeRequestOptions) {
recordTaskHasChanges(
teamName,
currentTask.id,
taskChangeRequestOptions,
!!files?.length
!!data?.files.length
);
}
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
if (currentTask && nextPresence) {
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
}
}
})
.catch((error) => {
@ -382,17 +398,21 @@ export const TaskDetailDialog = ({
});
void loadTaskChangeSummary(true)
.then((files) => {
if (!cancelled && files) {
setTaskChangesFiles(files);
.then((data) => {
if (!cancelled && data) {
setTaskChangesFiles(data.files);
if (currentTask && taskChangeRequestOptions) {
recordTaskHasChanges(
teamName,
currentTask.id,
taskChangeRequestOptions,
files.length > 0
data.files.length > 0
);
}
const nextPresence = resolveTaskChangePresenceFromResult(data);
if (currentTask && nextPresence) {
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
}
}
})
.catch(() => undefined);
@ -417,10 +437,19 @@ export const TaskDetailDialog = ({
setTaskChangesLoading(true);
setTaskChangesError(null);
void loadTaskChangeSummary(true)
.then((files) => {
setTaskChangesFiles(files ?? null);
.then((data) => {
setTaskChangesFiles(data?.files ?? null);
if (currentTask && taskChangeRequestOptions) {
recordTaskHasChanges(teamName, currentTask.id, taskChangeRequestOptions, !!files?.length);
recordTaskHasChanges(
teamName,
currentTask.id,
taskChangeRequestOptions,
!!data?.files.length
);
}
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
if (currentTask && nextPresence) {
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
}
})
.catch((error) => {
@ -436,6 +465,7 @@ export const TaskDetailDialog = ({
onViewChanges,
loadTaskChangeSummary,
recordTaskHasChanges,
setSelectedTeamTaskChangePresence,
taskChangeRequestOptions,
teamName,
variant,

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
@ -8,6 +8,7 @@ import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
import { cn } from '@renderer/lib/utils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
CheckCircle2,
ClipboardList,
@ -201,7 +202,7 @@ interface SortableKanbanTaskCardProps {
kanbanState: KanbanState;
compact?: boolean;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
memberColorMap: Map<string, string>;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -222,7 +223,7 @@ const SortableKanbanTaskCard = ({
kanbanState,
compact,
taskMap,
members,
memberColorMap,
onRequestReview,
onApprove,
onRequestChanges,
@ -257,7 +258,7 @@ const SortableKanbanTaskCard = ({
hasReviewers={kanbanState.reviewers.length > 0}
compact={compact}
taskMap={taskMap}
members={members}
memberColorMap={memberColorMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -306,7 +307,28 @@ export const KanbanBoard = ({
const enableTaskSorting =
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
const stableTaskMapRef = useRef<{
signatures: string[];
map: Map<string, TeamTask>;
} | null>(null);
const taskMap = useMemo(() => {
const signatures = tasks.map(
(task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}`
);
const previous = stableTaskMapRef.current;
if (
previous &&
previous.signatures.length === signatures.length &&
previous.signatures.every((signature, index) => signature === signatures[index])
) {
return previous.map;
}
const next = new Map(tasks.map((task) => [task.id, task]));
stableTaskMapRef.current = { signatures, map: next };
return next;
}, [tasks]);
const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]);
const grouped = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
@ -406,7 +428,7 @@ export const KanbanBoard = ({
kanbanState={kanbanState}
compact={compact}
taskMap={taskMap}
members={members}
memberColorMap={memberColorMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -437,7 +459,7 @@ export const KanbanBoard = ({
hasReviewers={kanbanState.reviewers.length > 0}
compact={compact}
taskMap={taskMap}
members={members}
memberColorMap={memberColorMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
@ -6,10 +6,8 @@ import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
@ -28,17 +26,17 @@ import {
XCircle,
} from 'lucide-react';
import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
interface KanbanTaskCardProps {
task: TeamTask;
task: TeamTaskWithKanban;
teamName: string;
columnId: KanbanColumnId;
kanbanTaskState?: KanbanTaskState;
hasReviewers: boolean;
compact?: boolean;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
memberColorMap: Map<string, string>;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -190,6 +188,7 @@ interface TaskActionIconButtonProps {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
className: string;
variant?: 'outline' | 'ghost' | 'destructive';
disabled?: boolean;
}
const TaskActionIconButton = ({
@ -198,6 +197,7 @@ const TaskActionIconButton = ({
onClick,
className,
variant = 'outline',
disabled = false,
}: TaskActionIconButtonProps): React.JSX.Element => (
<Tooltip>
<TooltipTrigger asChild>
@ -207,6 +207,7 @@ const TaskActionIconButton = ({
className={`size-6 shrink-0 rounded-full shadow-sm ${className}`}
aria-label={label}
onClick={onClick}
disabled={disabled}
>
{icon}
</Button>
@ -215,246 +216,201 @@ const TaskActionIconButton = ({
</Tooltip>
);
export const KanbanTaskCard = ({
task,
teamName,
columnId,
kanbanTaskState,
hasReviewers,
compact,
taskMap,
members,
onRequestReview,
onApprove,
onRequestChanges,
onMoveBackToDone,
onStartTask,
onCompleteTask,
onCancelTask,
onScrollToTask,
onTaskClick,
onViewChanges,
onDeleteTask,
}: KanbanTaskCardProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const hasBlockedBy = blockedByIds.length > 0;
const hasBlocks = blocksIds.length > 0;
// Lazy-check if task has file changes
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
const canDisplay = useMemo(
() => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges,
[taskChangeRequestOptions, onViewChanges]
);
const cacheKey = useMemo(
() => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions),
[teamName, task.id, taskChangeRequestOptions]
);
const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]);
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
useEffect(() => {
if (canDisplay && taskHasChanges === undefined) {
void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions);
}
}, [
canDisplay,
task.id,
export const KanbanTaskCard = memo(
function KanbanTaskCard({
task,
teamName,
taskHasChanges,
checkTaskHasChanges,
taskChangeRequestOptions,
]);
columnId,
kanbanTaskState,
hasReviewers,
compact,
taskMap,
memberColorMap,
onRequestReview,
onApprove,
onRequestChanges,
onMoveBackToDone,
onStartTask,
onCompleteTask,
onCancelTask,
onScrollToTask,
onTaskClick,
onViewChanges,
onDeleteTask,
}: KanbanTaskCardProps): React.JSX.Element {
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const hasBlockedBy = blockedByIds.length > 0;
const hasBlocks = blocksIds.length > 0;
const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer;
const metaActions = (
<>
{canDisplay && taskHasChanges === true ? (
<TaskActionIconButton
label="Changes"
icon={<FileCode className="size-2.5" />}
variant="ghost"
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
onClick={(e) => {
e.stopPropagation();
onViewChanges!(task.id);
}}
/>
) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
{onDeleteTask ? (
<TaskActionIconButton
label="Delete task"
icon={<Trash2 size={11} />}
variant="ghost"
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={(e) => {
e.stopPropagation();
onDeleteTask(task.id);
}}
/>
) : null}
</>
);
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
const canDisplay = useMemo(
() => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges,
[taskChangeRequestOptions, onViewChanges]
);
return (
<div
data-task-id={task.id}
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
hasBlockedBy
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
}`}
role="button"
tabIndex={0}
onClick={() => onTaskClick?.(task)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTaskClick?.(task);
}
}}
>
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
{formatTaskDisplayLabel(task)}
</span>
{task.owner ? (
<span className="absolute right-[6px] top-[2px]">
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} size="xs" />
const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer;
const metaActions = (
<>
{canDisplay && task.changePresence === 'has_changes' ? (
<TaskActionIconButton
label="Changes"
icon={<FileCode className="size-2.5" />}
variant="ghost"
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
onClick={(e) => {
e.stopPropagation();
onViewChanges!(task.id);
}}
/>
) : canDisplay && task.changePresence === 'no_changes' ? (
<span className="inline-flex h-6 shrink-0 items-center rounded-full border border-[var(--color-border)] px-2 text-[10px] text-[var(--color-text-muted)]">
No changes
</span>
) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
{onDeleteTask ? (
<TaskActionIconButton
label="Delete task"
icon={<Trash2 size={11} />}
variant="ghost"
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={(e) => {
e.stopPropagation();
onDeleteTask(task.id);
}}
/>
) : null}
</>
);
return (
<div
data-task-id={task.id}
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
hasBlockedBy
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
}`}
role="button"
tabIndex={0}
onClick={() => onTaskClick?.(task)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTaskClick?.(task);
}
}}
>
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
{formatTaskDisplayLabel(task)}
</span>
) : null}
<div className="mb-2 pt-[11px]">
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
{task.needsClarification ? (
<span
className={`mt-1 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
task.needsClarification === 'user'
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400'
}`}
>
<HelpCircle size={10} />
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
{task.owner ? (
<span className="absolute right-[6px] top-[2px]">
<MemberBadge name={task.owner} color={memberColorMap.get(task.owner)} size="xs" />
</span>
) : null}
{task.reviewState === 'needsFix' ? (
<span
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
<div className="mb-2 pt-[11px]">
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
{task.needsClarification ? (
<span
className={`mt-1 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
task.needsClarification === 'user'
? 'bg-red-500/15 text-red-400'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400'
}`}
>
<HelpCircle size={10} />
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span>
) : null}
{task.reviewState === 'needsFix' ? (
<span
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
) : null}
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
</div>
{hasBlockedBy ? (
<div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-700 dark:text-yellow-300">
<ArrowLeftFromLine size={10} />
Blocked by
</span>
{blockedByIds.map((id) => (
<DependencyBadge
key={id}
taskId={id}
taskMap={taskMap}
onScrollToTask={onScrollToTask}
/>
))}
</div>
) : null}
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
</div>
{hasBlockedBy ? (
<div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-700 dark:text-yellow-300">
<ArrowLeftFromLine size={10} />
Blocked by
</span>
{blockedByIds.map((id) => (
<DependencyBadge
key={id}
taskId={id}
taskMap={taskMap}
onScrollToTask={onScrollToTask}
/>
))}
</div>
) : null}
{hasBlocks ? (
<div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-blue-600 dark:text-blue-400">
<ArrowRightFromLine size={10} />
Blocks
</span>
{blocksIds.map((id) => (
<DependencyBadge
key={id}
taskId={id}
taskMap={taskMap}
onScrollToTask={onScrollToTask}
/>
))}
</div>
) : null}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-nowrap gap-2">
{columnId === 'todo' ? (
<>
<TaskActionIconButton
label="Start"
icon={<Play size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onStartTask(task.id);
}}
{hasBlocks ? (
<div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-blue-600 dark:text-blue-400">
<ArrowRightFromLine size={10} />
Blocks
</span>
{blocksIds.map((id) => (
<DependencyBadge
key={id}
taskId={id}
taskMap={taskMap}
onScrollToTask={onScrollToTask}
/>
<TaskActionIconButton
label="Complete"
icon={<CheckCircle2 size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
/>
</>
) : null}
))}
</div>
) : null}
{columnId === 'in_progress' ? (
<>
<TaskActionIconButton
label="Complete"
icon={<CheckCircle2 size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
/>
<CancelTaskButton taskId={task.id} onConfirm={onCancelTask} />
</>
) : null}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-nowrap gap-2">
{columnId === 'todo' ? (
<>
<TaskActionIconButton
label="Start"
icon={<Play size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onStartTask(task.id);
}}
/>
<TaskActionIconButton
label="Complete"
icon={<CheckCircle2 size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
/>
</>
) : null}
{columnId === 'done' ? (
<>
<TaskActionIconButton
label="Approve"
icon={<CheckCircle2 size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
/>
<TaskActionIconButton
label="Request review"
icon={<Eye size={11} />}
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
onClick={(e) => {
e.stopPropagation();
onRequestReview(task.id);
}}
/>
</>
) : null}
{columnId === 'in_progress' ? (
<>
<TaskActionIconButton
label="Complete"
icon={<CheckCircle2 size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
/>
<CancelTaskButton taskId={task.id} onConfirm={onCancelTask} />
</>
) : null}
{columnId === 'review' ? (
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
{isReviewManual ? (
<div className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
Manual review
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{columnId === 'done' ? (
<>
<TaskActionIconButton
label="Approve"
icon={<CheckCircle2 size={11} />}
@ -465,34 +421,84 @@ export const KanbanTaskCard = ({
}}
/>
<TaskActionIconButton
label="Request changes"
icon={<FilePenLine size={11} />}
variant="destructive"
className="bg-red-500/90 text-white hover:bg-red-500"
label="Request review"
icon={<Eye size={11} />}
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
onClick={(e) => {
e.stopPropagation();
onRequestChanges(task.id);
onRequestReview(task.id);
}}
/>
</>
) : null}
{columnId === 'review' ? (
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
{isReviewManual ? (
<div className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
Manual review
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<TaskActionIconButton
label="Approve"
icon={<CheckCircle2 size={11} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
/>
<TaskActionIconButton
label="Request changes"
icon={<FilePenLine size={11} />}
variant="destructive"
className="bg-red-500/90 text-white hover:bg-red-500"
onClick={(e) => {
e.stopPropagation();
onRequestChanges(task.id);
}}
/>
</div>
</div>
</div>
) : null}
) : null}
{columnId === 'approved' ? (
<TaskActionIconButton
label="Disapprove"
icon={<RotateCcw size={11} />}
className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300"
onClick={(e) => {
e.stopPropagation();
onMoveBackToDone(task.id);
}}
/>
) : null}
{columnId === 'approved' ? (
<TaskActionIconButton
label="Disapprove"
icon={<RotateCcw size={11} />}
className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300"
onClick={(e) => {
e.stopPropagation();
onMoveBackToDone(task.id);
}}
/>
) : null}
</div>
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">{metaActions}</div>
</div>
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">{metaActions}</div>
</div>
</div>
);
};
);
},
(prev, next) =>
prev.task === next.task &&
prev.teamName === next.teamName &&
prev.columnId === next.columnId &&
prev.kanbanTaskState === next.kanbanTaskState &&
prev.hasReviewers === next.hasReviewers &&
prev.compact === next.compact &&
prev.taskMap === next.taskMap &&
prev.memberColorMap === next.memberColorMap &&
prev.onRequestReview === next.onRequestReview &&
prev.onApprove === next.onApprove &&
prev.onRequestChanges === next.onRequestChanges &&
prev.onMoveBackToDone === next.onMoveBackToDone &&
prev.onStartTask === next.onStartTask &&
prev.onCompleteTask === next.onCompleteTask &&
prev.onCancelTask === next.onCancelTask &&
prev.onScrollToTask === next.onScrollToTask &&
prev.onTaskClick === next.onTaskClick &&
prev.onViewChanges === next.onViewChanges &&
prev.onDeleteTask === next.onDeleteTask
);

View file

@ -5,6 +5,11 @@
import { api } from '@renderer/api';
import { syncRendererTelemetry } from '@renderer/sentry';
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import { create } from 'zustand';
import { createChangeReviewSlice } from './slices/changeReviewSlice';
@ -41,6 +46,9 @@ import type {
UpdaterStatus,
} from '@shared/types';
const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
// =============================================================================
// Store Creation
// =============================================================================
@ -135,15 +143,25 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => {
if (cliStatusTimer) clearTimeout(cliStatusTimer);
});
const inProgressChangePresencePollTimer = setInterval(() => {
void pollVisibleTeamInProgressChangePresence();
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
cleanupFns.push(() => {
clearInterval(inProgressChangePresencePollTimer);
});
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let inProgressChangePresencePollInFlight = false;
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const SESSION_REFRESH_DEBOUNCE_MS = 150;
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
const TEAM_REFRESH_THROTTLE_MS = 800;
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
@ -152,6 +170,69 @@ export function initializeNotificationListeners(): () => void {
return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId;
};
const pollVisibleTeamInProgressChangePresence = async (): Promise<void> => {
if (inProgressChangePresencePollInFlight) {
return;
}
const state = useStore.getState();
const selectedTeamName = state.selectedTeamName;
const selectedTeamData = state.selectedTeamData;
if (
!selectedTeamName ||
!selectedTeamData ||
selectedTeamData.teamName !== selectedTeamName ||
!isTeamVisibleInAnyPane(selectedTeamName)
) {
return;
}
const candidateTasks = selectedTeamData.tasks.filter((task) => {
if (task.status !== 'in_progress') {
return false;
}
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
});
if (candidateTasks.length === 0) {
inProgressChangePresenceCursorByTeam.delete(selectedTeamName);
return;
}
inProgressChangePresencePollInFlight = true;
try {
const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0;
const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown');
const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks;
const nextTask = sourceTasks[cursor % sourceTasks.length];
inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length);
const current = useStore.getState();
if (
current.selectedTeamName !== selectedTeamName ||
!current.selectedTeamData ||
current.selectedTeamData.teamName !== selectedTeamName ||
!isTeamVisibleInAnyPane(selectedTeamName)
) {
return;
}
const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id);
if (!currentTask || currentTask.status !== 'in_progress') {
return;
}
const requestOptions = buildTaskChangeRequestOptions(currentTask);
const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions);
current.invalidateTaskChangePresence([cacheKey]);
await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions);
} catch {
// Best-effort polling for in-progress tasks only.
} finally {
inProgressChangePresencePollInFlight = false;
}
};
const scheduleSessionRefresh = (projectId: string, sessionId: string): void => {
const key = `${projectId}/${sessionId}`;
// Throttle (not trailing debounce): keep at most one pending refresh per session.
@ -257,6 +338,61 @@ export function initializeNotificationListeners(): () => void {
});
};
const getTrackedChangePresenceTeams = (): Set<string> => {
const { selectedTeamName, selectedTeamData } = useStore.getState();
if (
!selectedTeamName ||
!selectedTeamData ||
selectedTeamData.teamName !== selectedTeamName ||
!isTeamVisibleInAnyPane(selectedTeamName)
) {
return new Set<string>();
}
return new Set([selectedTeamName]);
};
if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) {
let trackedTeamNames = new Set<string>();
const syncVisibleTeamTracking = (): void => {
const nextTrackedTeamNames = getTrackedChangePresenceTeams();
for (const teamName of nextTrackedTeamNames) {
if (!trackedTeamNames.has(teamName)) {
void api.teams.setChangePresenceTracking(teamName, true).catch(() => undefined);
}
}
for (const teamName of trackedTeamNames) {
if (!nextTrackedTeamNames.has(teamName)) {
void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined);
}
}
trackedTeamNames = nextTrackedTeamNames;
};
syncVisibleTeamTracking();
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
if (
state.paneLayout === prevState.paneLayout &&
state.selectedTeamName === prevState.selectedTeamName &&
state.selectedTeamData === prevState.selectedTeamData
) {
return;
}
syncVisibleTeamTracking();
});
cleanupFns.push(() => {
unsubscribeVisibleTeamTracking();
for (const teamName of trackedTeamNames) {
void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined);
}
trackedTeamNames.clear();
});
}
// Listen for task-list file changes to refresh currently viewed session metadata
if (api.onTodoChange) {
const cleanup = api.onTodoChange((event) => {
@ -474,6 +610,22 @@ export function initializeNotificationListeners(): () => void {
return;
}
if (event.type === 'log-source-change') {
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
if (teamPresenceRefreshTimers.has(event.teamName)) {
return;
}
const timer = setTimeout(() => {
teamPresenceRefreshTimers.delete(event.teamName);
const current = useStore.getState();
void current.refreshSelectedTeamChangePresence(event.teamName);
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
teamPresenceRefreshTimers.set(event.teamName, timer);
return;
}
// Throttled refresh of summary list (keeps TeamListView current without flooding).
if (!teamListRefreshTimer) {
teamListRefreshTimer = setTimeout(() => {
@ -513,6 +665,8 @@ export function initializeNotificationListeners(): () => void {
cleanup();
for (const t of teamRefreshTimers.values()) clearTimeout(t);
teamRefreshTimers = new Map();
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
teamPresenceRefreshTimers = new Map();
if (teamListRefreshTimer) {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;

View file

@ -16,6 +16,7 @@ const taskChangesPresenceRevalidationInFlight = new Set<string>();
/** Negative results cached with timestamp — recheck after 30s */
const taskChangesNegativeCache = new Map<string, number>();
const NEGATIVE_CACHE_TTL = 30_000;
const TASK_CHANGE_WARM_CONCURRENCY = 4;
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
let latestTaskChangesRequestToken = 0;
@ -77,6 +78,16 @@ function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean {
return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME;
}
function resolveTaskChangePresenceFromResult(
data: Pick<TaskChangeSetV2, 'files' | 'confidence'>
): 'has_changes' | 'no_changes' | null {
if (data.files.length > 0) {
return 'has_changes';
}
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
}
export interface ChangeReviewSlice {
// Phase 1 state
activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null;
@ -503,10 +514,14 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const data = await api.review.getTaskChanges(teamName, taskId, options);
if (requestToken !== latestTaskChangesRequestToken) return;
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
const nextPresence = resolveTaskChangePresenceFromResult(data);
installActiveChangeSetForLoad(data, {
activeTaskChangeRequestOptions: options,
taskHasChanges: { ...get().taskHasChanges, [cacheKey]: data.files.length > 0 },
});
if (nextPresence) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence);
}
if (data.files.length > 0) {
taskChangesNegativeCache.delete(cacheKey);
} else {
@ -1310,12 +1325,20 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
taskId: string,
options: TaskChangeRequestOptions
) => {
const selectedTask =
get().selectedTeamName === teamName
? get().selectedTeamData?.tasks.find((task) => task.id === taskId)
: undefined;
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
const summaryCacheable = isTaskSummaryCacheableForOptions(options);
if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return;
if (summaryCacheable && get().taskHasChanges[cacheKey] === true) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes');
return;
}
if (taskChangesCheckInFlight.has(cacheKey)) return;
const negativeTs = taskChangesNegativeCache.get(cacheKey);
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return;
const hasUnknownPresence = selectedTask?.changePresence === 'unknown';
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL && !hasUnknownPresence) return;
taskChangesCheckInFlight.add(cacheKey);
try {
@ -1323,11 +1346,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
...options,
summaryOnly: true,
});
const nextPresence = resolveTaskChangePresenceFromResult(data);
if (data.files.length > 0) {
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
}));
taskChangesNegativeCache.delete(cacheKey);
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes');
if (wasRestoredBeforeCurrentSession(data)) {
void revalidateTaskChangePresence(teamName, taskId, options);
}
@ -1336,6 +1361,11 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false },
}));
taskChangesNegativeCache.set(cacheKey, Date.now());
if (nextPresence === 'no_changes') {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes');
} else if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'unknown');
}
}
} catch {
// Allow immediate retry after transient failures (race, file lock, late logs).
@ -1359,39 +1389,46 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
uniqueRequests.set(cacheKey, request);
}
await Promise.all(
[...uniqueRequests.entries()].map(async ([cacheKey, request]) => {
if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey))
return;
const entries = [...uniqueRequests.entries()];
const runWarmRequest = async (
cacheKey: string,
request: { teamName: string; taskId: string; options: TaskChangeRequestOptions }
): Promise<void> => {
if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) {
return;
}
taskChangesCheckInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(request.teamName, request.taskId, {
...request.options,
summaryOnly: true,
});
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
}));
if (data.files.length > 0) {
taskChangesNegativeCache.delete(cacheKey);
if (wasRestoredBeforeCurrentSession(data)) {
void revalidateTaskChangePresence(
request.teamName,
request.taskId,
request.options
);
}
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
taskChangesCheckInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(request.teamName, request.taskId, {
...request.options,
summaryOnly: true,
});
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
}));
if (data.files.length > 0) {
taskChangesNegativeCache.delete(cacheKey);
if (wasRestoredBeforeCurrentSession(data)) {
void revalidateTaskChangePresence(request.teamName, request.taskId, request.options);
}
} catch {
// Best-effort warm path.
} finally {
taskChangesCheckInFlight.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
})
);
} catch {
// Best-effort warm path.
} finally {
taskChangesCheckInFlight.delete(cacheKey);
}
};
for (let index = 0; index < entries.length; index += TASK_CHANGE_WARM_CONCURRENCY) {
await Promise.all(
entries
.slice(index, index + TASK_CHANGE_WARM_CONCURRENCY)
.map(([cacheKey, request]) => runWarmRequest(cacheKey, request))
);
}
},
invalidateTaskChangePresence: (cacheKeys) => {

View file

@ -3,7 +3,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
isTaskSummaryCacheableForOptions,
canDisplayTaskChangesForOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
@ -57,6 +57,43 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
});
}
async function refreshTaskChangePresenceForUpdatedTask(
getState: () => AppState,
teamName: string,
taskId: string
): Promise<void> {
const state = getState();
if (state.selectedTeamName !== teamName || !state.selectedTeamData) {
return;
}
const task = state.selectedTeamData.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
return;
}
const options = buildTaskChangeRequestOptions(task);
if (!canDisplayTaskChangesForOptions(options)) {
return;
}
if (
typeof state.invalidateTaskChangePresence !== 'function' ||
typeof state.checkTaskHasChanges !== 'function'
) {
return;
}
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
state.invalidateTaskChangePresence([cacheKey]);
try {
await state.checkTaskHasChanges(teamName, taskId, options);
} catch {
// Best-effort refresh after explicit task transition.
}
}
async function pollProvisioningStatus(
getState: () => TeamSlice,
runId: string,
@ -105,6 +142,7 @@ import type {
SendMessageRequest,
SendMessageResult,
TaskComment,
TaskChangePresenceState,
TeamCreateRequest,
TeamData,
TeamLaunchRequest,
@ -445,19 +483,6 @@ function collectTaskChangeInvalidationState(
};
}
function buildTaskChangeWarmRequests(
teamName: string,
tasks: TeamData['tasks']
): { teamName: string; taskId: string; options: TaskChangeRequestOptions }[] {
return tasks.flatMap((task) => {
const options = buildTaskChangeRequestOptions(task);
if (!isTaskSummaryCacheableForOptions(options)) {
return [];
}
return [{ teamName, taskId: task.id, options }];
});
}
function mapSendMessageError(error: unknown): string {
const message =
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
@ -556,6 +581,12 @@ export interface TeamSlice {
openTeamsTab: () => void;
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
clearKanbanFilter: () => void;
setSelectedTeamTaskChangePresence: (
teamName: string,
taskId: string,
presence: TaskChangePresenceState
) => void;
refreshSelectedTeamChangePresence: (teamName: string) => Promise<void>;
selectTeam: (
teamName: string,
opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean }
@ -1099,6 +1130,89 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({ kanbanFilterQuery: null });
},
setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => {
set((state) => {
let selectedChanged = false;
const nextSelectedTeamData =
state.selectedTeamName === teamName && state.selectedTeamData
? {
...state.selectedTeamData,
tasks: state.selectedTeamData.tasks.map((task) => {
if (task.id !== taskId || task.changePresence === presence) {
return task;
}
selectedChanged = true;
return { ...task, changePresence: presence };
}),
}
: state.selectedTeamData;
let globalChanged = false;
const nextGlobalTasks = state.globalTasks.map((task) => {
if (task.teamName !== teamName || task.id !== taskId || task.changePresence === presence) {
return task;
}
globalChanged = true;
return { ...task, changePresence: presence };
});
if (!selectedChanged && !globalChanged) {
return {};
}
return {
...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}),
...(globalChanged ? { globalTasks: nextGlobalTasks } : {}),
};
});
},
refreshSelectedTeamChangePresence: async (teamName: string) => {
const selected = get().selectedTeamData;
if (get().selectedTeamName !== teamName || !selected) {
return;
}
try {
const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () =>
api.teams.getTaskChangePresence(teamName)
);
if (get().selectedTeamName !== teamName || !get().selectedTeamData) {
return;
}
set((state) => {
if (state.selectedTeamName !== teamName || !state.selectedTeamData) {
return {};
}
let changed = false;
const nextTasks = state.selectedTeamData.tasks.map((task) => {
const nextPresence = presenceByTaskId[task.id] ?? 'unknown';
if (task.changePresence === nextPresence) {
return task;
}
changed = true;
return { ...task, changePresence: nextPresence };
});
if (!changed) {
return {};
}
return {
selectedTeamData: {
...state.selectedTeamData,
tasks: nextTasks,
},
};
});
} catch {
// best-effort lightweight refresh; keep current UI state on failure
}
},
selectTeam: async (teamName: string, opts) => {
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
// Guard: prevent duplicate in-flight fetches for the same team.
@ -1170,11 +1284,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (invalidationState.taskIds.length > 0) {
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
}
const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks);
if (warmRequests.length > 0) {
void get().warmTaskChangeSummaries(warmRequests);
}
// Sync tab label with the team's display name from config
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
@ -1295,10 +1404,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (invalidationState.taskIds.length > 0) {
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
}
const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks);
if (warmRequests.length > 0) {
void get().warmTaskChangeSummaries(warmRequests);
}
} catch (error) {
if (get().selectedTeamName !== teamName) {
return;
@ -1424,6 +1529,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({ reviewActionError: null });
await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId));
await get().refreshTeamData(teamName);
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
} catch (error) {
set({
reviewActionError: mapReviewError(error),
@ -1441,6 +1547,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
startTask: async (teamName: string, taskId: string) => {
const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
await get().refreshTeamData(teamName);
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
return result;
},
@ -1449,6 +1556,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
api.teams.startTaskByUser(teamName, taskId)
);
await get().refreshTeamData(teamName);
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
return result;
},
@ -1457,6 +1565,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
api.teams.updateTaskStatus(teamName, taskId, status)
);
await get().refreshTeamData(teamName);
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
},
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {

View file

@ -73,6 +73,7 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
TaskChangePresenceState,
ToolApprovalEvent,
ToolApprovalFileContent,
ToolApprovalSettings,
@ -416,6 +417,8 @@ export interface HttpServerAPI {
export interface TeamsAPI {
list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>;
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;

View file

@ -218,11 +218,15 @@ export interface TeamTask {
}
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
export type TaskChangePresenceState = 'has_changes' | 'no_changes' | 'unknown';
export interface TeamTaskWithKanban extends TeamTask {
/** Set when task is in team kanban (review or approved column). */
kanbanColumn?: 'review' | 'approved';
/** Reviewer assigned in kanban state, when applicable. */
reviewer?: string | null;
/** Cheap persisted change-presence state for kanban rendering. */
changePresence?: TaskChangePresenceState;
}
/** Metadata for an attachment associated with a task or comment. */
@ -502,6 +506,7 @@ export interface TeamChangeEvent {
type:
| 'config'
| 'inbox'
| 'log-source-change'
| 'task'
| 'lead-activity'
| 'lead-context'

View file

@ -43,6 +43,7 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_GET_ALL_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
@ -56,6 +57,7 @@ import {
TEAM_ADD_TASK_COMMENT,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DELETED_TASKS,
TEAM_GET_TASK_CHANGE_PRESENCE,
TEAM_GET_PROJECT_BRANCH,
TEAM_KILL_PROCESS,
TEAM_LEAD_ACTIVITY,
@ -105,7 +107,9 @@ describe('ipc teams handlers', () => {
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
})),
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
reconcileTeamArtifacts: vi.fn(async () => undefined),
setTaskChangePresenceTracking: vi.fn(() => undefined),
deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
@ -175,6 +179,8 @@ describe('ipc teams handlers', () => {
it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(true);
expect(handlers.has(TEAM_CREATE)).toBe(true);
@ -224,6 +230,32 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true);
});
it('updates change presence tracking for a team', async () => {
const handler = handlers.get(TEAM_SET_CHANGE_PRESENCE_TRACKING);
expect(handler).toBeDefined();
const result = (await handler!({} as never, 'my-team', true)) as {
success: boolean;
data?: void;
};
expect(result.success).toBe(true);
expect(service.setTaskChangePresenceTracking).toHaveBeenCalledWith('my-team', true);
});
it('returns lightweight task change presence for a team', async () => {
const handler = handlers.get(TEAM_GET_TASK_CHANGE_PRESENCE);
expect(handler).toBeDefined();
const result = (await handler!({} as never, 'my-team')) as {
success: boolean;
data?: Record<string, string>;
};
expect(result).toEqual({ success: true, data: { 'task-1': 'has_changes' } });
expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team');
});
it('returns success false on invalid sendMessage args', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();

View file

@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs/promises';
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
const TEAM_NAME = 'team-a';
@ -70,31 +71,134 @@ function persistedEntryPath(baseDir: string): string {
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeTaskChangeResult(
taskId = TASK_ID,
overrides: Partial<{
teamName: string;
taskId: string;
filePath: string;
confidence: 'high' | 'medium' | 'low' | 'fallback';
content: string;
warning: string;
}> = {}
) {
const teamName = overrides.teamName ?? TEAM_NAME;
const targetTaskId = overrides.taskId ?? taskId;
const filePath = overrides.filePath ?? '/repo/src/file.ts';
const content = overrides.content ?? 'export const value = 1;\n';
const confidence = overrides.confidence ?? 'high';
const confidenceTierByLabel = {
high: 1,
medium: 2,
low: 3,
fallback: 4,
} as const;
const files =
content.length > 0
? [
{
filePath,
relativePath: 'src/file.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
]
: [];
return {
teamName,
taskId: targetTaskId,
files,
totalFiles: files.length,
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
confidence,
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: targetTaskId,
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: files.map((file) => file.filePath),
confidence: {
tier: confidenceTierByLabel[confidence],
label: confidence,
reason: 'test fixture',
},
},
warnings: overrides.warning ? [overrides.warning] : [],
};
}
function createService(params: {
logPaths: string[];
projectPath?: string;
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> };
teamLogSourceTracker?: {
ensureTracking: ReturnType<
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
>;
};
taskChangeWorkerClient?: {
isAvailable: ReturnType<typeof vi.fn<() => boolean>>;
computeTaskChanges: ReturnType<typeof vi.fn<() => Promise<unknown>>>;
};
}) {
const findLogFileRefsForTask =
params.findLogFileRefsForTask ??
vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' })));
const taskChangeWorkerClient =
params.taskChangeWorkerClient ??
({
isAvailable: vi.fn(() => false),
computeTaskChanges: vi.fn(async () => {
throw new Error('worker disabled in test');
}),
} as const);
const service = new ChangeExtractorService(
{
findLogFileRefsForTask,
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any,
undefined,
taskChangeWorkerClient as any
);
if (params.taskChangePresenceRepository && params.teamLogSourceTracker) {
service.setTaskChangePresenceServices(
params.taskChangePresenceRepository as any,
params.teamLogSourceTracker as any
);
}
return {
findLogFileRefsForTask,
service: new ChangeExtractorService(
{
findLogFileRefsForTask,
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any
),
service,
};
}
@ -337,7 +441,14 @@ describe('ChangeExtractorService', () => {
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any
{ getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any,
undefined,
{
isAvailable: vi.fn(() => false),
computeTaskChanges: vi.fn(async () => {
throw new Error('worker disabled in test');
}),
} as any
);
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
@ -373,4 +484,265 @@ describe('ChangeExtractorService', () => {
expect(result.files[0]?.relativePath).toBe('src/same.ts');
expect(result.totalLinesAdded).toBe(2);
});
it('prefers worker task-change results when the worker is available', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const workerResult = makeTaskChangeResult();
const computeTaskChanges = vi.fn(async () => workerResult);
const { service, findLogFileRefsForTask } = createService({
logPaths: [],
taskChangeWorkerClient: {
isAvailable: vi.fn(() => true),
computeTaskChanges,
},
});
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(result).toEqual(workerResult);
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
});
it('falls back inline when task-change worker is unavailable', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const computeTaskChanges = vi.fn();
const { service, findLogFileRefsForTask } = createService({
logPaths: [logPath],
taskChangeWorkerClient: {
isAvailable: vi.fn(() => false),
computeTaskChanges,
},
});
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(result.files).toHaveLength(1);
expect(findLogFileRefsForTask).toHaveBeenCalled();
expect(computeTaskChanges).not.toHaveBeenCalled();
});
it('falls back inline when task-change worker throws', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const computeTaskChanges = vi.fn(async () => {
throw new Error('worker failed');
});
const { service, findLogFileRefsForTask } = createService({
logPaths: [logPath],
taskChangeWorkerClient: {
isAvailable: vi.fn(() => true),
computeTaskChanges,
},
});
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
expect(result.files).toHaveLength(1);
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
expect(findLogFileRefsForTask).toHaveBeenCalled();
});
it('keeps summary cache in main and skips worker on repeat terminal summary requests', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const computeTaskChanges = vi.fn(async () => makeTaskChangeResult());
const { service } = createService({
logPaths: [logPath],
taskChangeWorkerClient: {
isAvailable: vi.fn(() => true),
computeTaskChanges,
},
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
});
it('restores persisted summaries without invoking worker compute', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const firstWorker = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () => makeTaskChangeResult()),
};
await createService({
logPaths: [logPath],
taskChangeWorkerClient: firstWorker,
}).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
const secondWorker = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: 'stale\n' })),
};
const restored = await createService({
logPaths: [logPath],
taskChangeWorkerClient: secondWorker,
}).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(restored.files).toHaveLength(1);
expect(secondWorker.computeTaskChanges).not.toHaveBeenCalled();
});
it('does not let stale worker results populate summary cache after invalidation', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const first = deferred<ReturnType<typeof makeTaskChangeResult>>();
const worker = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(async () =>
makeTaskChangeResult(TASK_ID, { filePath: '/repo/src/newer.ts' })
),
};
const { service } = createService({
logPaths: [],
taskChangeWorkerClient: worker,
});
const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true });
const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
first.resolve(makeTaskChangeResult());
const stale = await stalePromise;
const fresh = await freshPromise;
expect(stale.files[0]?.filePath).toBe('/repo/src/file.ts');
expect(fresh.files[0]?.filePath).toBe('/repo/src/newer.ts');
expect(worker.computeTaskChanges).toHaveBeenCalledTimes(2);
});
it('writes has_changes presence entries after successful task diff computation', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-presence.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry(
'tool-1',
'/repo/src/file.ts',
'export const value = 1;\n',
'2026-03-01T10:00:00.000Z'
),
]);
const upsertEntry = vi.fn(async () => undefined);
const ensureTracking = vi.fn(async () => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () => makeTaskChangeResult()),
};
const { service } = createService({
logPaths: [logPath],
taskChangePresenceRepository: { upsertEntry },
teamLogSourceTracker: { ensureTracking },
taskChangeWorkerClient: workerClient,
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(upsertEntry).toHaveBeenCalledWith(
TEAM_NAME,
expect.objectContaining({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
}),
expect.objectContaining({
taskId: TASK_ID,
presence: 'has_changes',
taskSignature: buildTaskChangePresenceDescriptor({
owner: 'alice',
status: 'completed',
intervals: [
{
startedAt: '2026-03-01T10:00:00.000Z',
completedAt: '2026-03-01T10:10:00.000Z',
},
],
reviewState: 'none',
historyEvents: [],
}).taskSignature,
})
);
});
it('does not write no_changes presence entries for uncertain empty task diff results', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const upsertEntry = vi.fn(async () => undefined);
const ensureTracking = vi.fn(async () => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })),
};
const { service } = createService({
logPaths: [],
taskChangePresenceRepository: { upsertEntry },
teamLogSourceTracker: { ensureTracking },
taskChangeWorkerClient: workerClient,
});
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(result.files).toHaveLength(0);
expect(result.confidence === 'high' || result.confidence === 'medium').toBe(false);
expect(upsertEntry).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,255 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TaskChangeWorkerClient } from '../../../../src/main/services/team/TaskChangeWorkerClient';
import type { TaskChangeSetV2 } from '../../../../src/shared/types';
import type { TaskChangeWorkerRequest, TaskChangeWorkerResponse } from '../../../../src/main/services/team/taskChangeWorkerTypes';
class FakeWorker {
readonly posted: TaskChangeWorkerRequest[] = [];
readonly terminate = vi.fn(async () => 0);
private readonly listeners: {
message: Array<(message: TaskChangeWorkerResponse) => void>;
error: Array<(error: Error) => void>;
exit: Array<(code: number) => void>;
} = {
message: [],
error: [],
exit: [],
};
on(event: 'message' | 'error' | 'exit', listener: ((value: any) => void) & ((value: any) => void)) {
if (event === 'message') this.listeners.message.push(listener as (message: TaskChangeWorkerResponse) => void);
if (event === 'error') this.listeners.error.push(listener as (error: Error) => void);
if (event === 'exit') this.listeners.exit.push(listener as (code: number) => void);
return this;
}
postMessage(message: TaskChangeWorkerRequest): void {
this.posted.push(message);
}
emitMessage(message: TaskChangeWorkerResponse): void {
for (const listener of this.listeners.message) {
listener(message);
}
}
emitError(error: Error): void {
for (const listener of this.listeners.error) {
listener(error);
}
}
emitExit(code: number): void {
for (const listener of this.listeners.exit) {
listener(code);
}
}
}
function makePayload(taskId = 'task-1') {
return {
teamName: 'team-a',
taskId,
taskMeta: {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
reviewState: 'none' as const,
historyEvents: [],
},
effectiveOptions: {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
},
projectPath: '/repo',
includeDetails: false,
};
}
function makeResult(taskId = 'task-1', filePath = '/repo/src/file.ts'): TaskChangeSetV2 {
return {
teamName: 'team-a',
taskId,
files: [
{
filePath,
relativePath: 'src/file.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'high' as const,
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId,
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [filePath],
confidence: { tier: 1, label: 'high', reason: 'test fixture' },
},
warnings: [],
};
}
describe('TaskChangeWorkerClient', () => {
afterEach(() => {
vi.useRealTimers();
});
it('resolves successful worker responses', async () => {
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const promise = client.computeTaskChanges(makePayload());
const request = workers[0]!.posted[0]!;
workers[0]!.emitMessage({ id: request.id, ok: true, result: makeResult() });
await expect(promise).resolves.toEqual(makeResult());
});
it('times out the active request, terminates the worker, and recreates it on the next call', async () => {
vi.useFakeTimers();
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
timeoutMs: 25,
enabled: true,
});
const firstPromise = client.computeTaskChanges(makePayload('task-timeout'));
const firstExpectation = expect(firstPromise).rejects.toThrow('Worker call timeout');
await vi.advanceTimersByTimeAsync(25);
await firstExpectation;
expect(workers[0]!.terminate).toHaveBeenCalledTimes(1);
const secondPromise = client.computeTaskChanges(makePayload('task-next'));
const request = workers[1]!.posted[0]!;
workers[1]!.emitMessage({
id: request.id,
ok: true,
result: makeResult('task-next', '/repo/src/next.ts'),
});
await expect(secondPromise).resolves.toEqual(makeResult('task-next', '/repo/src/next.ts'));
expect(workers).toHaveLength(2);
});
it('rejects all pending requests on worker error and clears queued work', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const first = client.computeTaskChanges(makePayload('task-1'));
const second = client.computeTaskChanges(makePayload('task-2'));
workers[0]!.emitError(new Error('boom'));
await expect(first).rejects.toThrow('boom');
await expect(second).rejects.toThrow('boom');
const third = client.computeTaskChanges(makePayload('task-3'));
const request = workers[1]!.posted[0]!;
workers[1]!.emitMessage({ id: request.id, ok: true, result: makeResult('task-3') });
await expect(third).resolves.toEqual(makeResult('task-3'));
});
it('rejects all pending requests on worker exit', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const first = client.computeTaskChanges(makePayload('task-1'));
const second = client.computeTaskChanges(makePayload('task-2'));
workers[0]!.emitExit(9);
await expect(first).rejects.toThrow('Worker exited with code 9');
await expect(second).rejects.toThrow('Worker exited with code 9');
});
it('executes queued requests sequentially in FIFO order', async () => {
const workers: FakeWorker[] = [];
const client = new TaskChangeWorkerClient({
workerPath: '/tmp/task-change-worker.cjs',
workerFactory: () => {
const worker = new FakeWorker();
workers.push(worker);
return worker as any;
},
enabled: true,
});
const first = client.computeTaskChanges(makePayload('task-1'));
const second = client.computeTaskChanges(makePayload('task-2'));
expect(workers[0]!.posted).toHaveLength(1);
expect(workers[0]!.posted[0]!.payload.taskId).toBe('task-1');
workers[0]!.emitMessage({
id: workers[0]!.posted[0]!.id,
ok: true,
result: makeResult('task-1'),
});
expect(workers[0]!.posted).toHaveLength(2);
expect(workers[0]!.posted[1]!.payload.taskId).toBe('task-2');
workers[0]!.emitMessage({
id: workers[0]!.posted[1]!.id,
ok: true,
result: makeResult('task-2'),
});
await expect(first).resolves.toEqual(makeResult('task-1'));
await expect(second).resolves.toEqual(makeResult('task-2'));
});
it('reports unavailable when the worker file is missing', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new TaskChangeWorkerClient({
workerPath: null,
enabled: true,
});
expect(client.isAvailable()).toBe(false);
});
});

View file

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import type { TeamTask } from '../../../../src/shared/types/team';
@ -145,6 +146,51 @@ describe('TeamDataService', () => {
expect(reconcileArtifacts).toHaveBeenCalledWith({ reason: 'file-watch' });
});
it('starts and stops task change presence tracking outside getTeamData', async () => {
const ensureTracking = vi.fn(async () => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'generation-1',
}));
const stopTracking = vi.fn(async () => undefined);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never
);
service.setTaskChangePresenceServices(
{
load: vi.fn(async () => null),
save: vi.fn(async () => undefined),
deleteTasks: vi.fn(async () => undefined),
} as never,
{
ensureTracking,
stopTracking,
} as never
);
service.setTaskChangePresenceTracking('my-team', true);
service.setTaskChangePresenceTracking('my-team', false);
await Promise.resolve();
expect(ensureTracking).toHaveBeenCalledWith('my-team');
expect(stopTracking).toHaveBeenCalledWith('my-team');
});
it('surfaces controller reconcile failures', async () => {
const reconcileArtifacts = vi.fn(() => {
throw new Error('reconcile failed');
@ -1662,4 +1708,241 @@ describe('TeamDataService', () => {
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
}
});
it('returns unknown changePresence when no cached presence entry exists', async () => {
const task: TeamTask = {
id: 'task-1',
subject: 'Review API',
status: 'completed',
owner: 'alice',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
};
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })),
} as never,
{
getTasks: vi.fn(async () => [task]),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never
);
const load = vi.fn(async () => null);
service.setTaskChangePresenceServices(
{
load,
upsertEntry: vi.fn(async () => undefined),
} as never,
{
ensureTracking: vi.fn(async () => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
})),
} as never
);
const data = await service.getTeamData('my-team');
expect(data.tasks[0]?.changePresence).toBe('unknown');
expect(load).not.toHaveBeenCalled();
});
it('returns cached changePresence only when signature and generation still match', async () => {
const task: TeamTask = {
id: 'task-1',
subject: 'Review API',
status: 'completed',
owner: 'alice',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
};
const descriptor = buildTaskChangePresenceDescriptor({
owner: task.owner,
status: task.status,
intervals: task.workIntervals,
historyEvents: task.historyEvents,
reviewState: 'none',
});
const createServiceWithPresence = (
load: ReturnType<typeof vi.fn>,
trackerSnapshot: { projectFingerprint: string; logSourceGeneration: string } | null
) => {
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })),
} as never,
{
getTasks: vi.fn(async () => [task]),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never
);
service.setTaskChangePresenceServices(
{
load,
upsertEntry: vi.fn(async () => undefined),
} as never,
{
getSnapshot: vi.fn(() => trackerSnapshot),
ensureTracking: vi.fn(async () => trackerSnapshot),
} as never
);
return service;
};
const matched = await createServiceWithPresence(
vi.fn(async () => ({
version: 1,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: descriptor.taskSignature,
presence: 'has_changes',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'log-generation',
},
},
})),
{
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
}
).getTeamData('my-team');
expect(matched.tasks[0]?.changePresence).toBe('has_changes');
const mismatched = await createServiceWithPresence(
vi.fn(async () => ({
version: 1,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'stale-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: descriptor.taskSignature,
presence: 'has_changes',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'stale-generation',
},
},
})),
{
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
}
).getTeamData('my-team');
expect(mismatched.tasks[0]?.changePresence).toBe('unknown');
});
it('returns lightweight task change presence without loading full team data', async () => {
const task: TeamTask = {
id: 'task-1',
subject: 'Review API',
status: 'completed',
owner: 'alice',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
};
const descriptor = buildTaskChangePresenceDescriptor({
owner: task.owner,
status: task.status,
intervals: task.workIntervals,
historyEvents: task.historyEvents,
reviewState: 'none',
});
const getMessages = vi.fn(async () => []);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })),
} as never,
{
getTasks: vi.fn(async () => [task]),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages,
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never
);
service.setTaskChangePresenceServices(
{
load: vi.fn(async () => ({
version: 1,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: descriptor.taskSignature,
presence: 'has_changes',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'log-generation',
},
},
})),
upsertEntry: vi.fn(async () => undefined),
} as never,
{
getSnapshot: vi.fn(() => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
})),
ensureTracking: vi.fn(async () => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
})),
} as never
);
const data = await service.getTaskChangePresence('my-team');
expect(data).toEqual({ 'task-1': 'has_changes' });
expect(getMessages).not.toHaveBeenCalled();
});
});

View file

@ -37,6 +37,7 @@ vi.mock('@renderer/api', () => ({
function createSliceStore() {
return create<any>()((set, get, store) => ({
...createChangeReviewSlice(set as never, get as never, store as never),
setSelectedTeamTaskChangePresence: vi.fn(),
}));
}
@ -203,6 +204,164 @@ describe('changeReviewSlice task changes', () => {
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
it('updates selected team task changePresence after a positive summary check', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit'));
await store.getState().checkTaskHasChanges('team-a', 'presence-hit', OPTIONS_A);
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'presence-hit',
'has_changes'
);
});
it('updates selected team task changePresence to no_changes only for confirmed empty summaries', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName: 'team-a',
taskId: 'presence-empty',
confidence: 'high',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: 'presence-empty',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 1, label: 'high', reason: 'test fixture' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges('team-a', 'presence-empty', OPTIONS_A);
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'presence-empty',
'no_changes'
);
});
it('keeps changePresence unknown for fallback empty summaries', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName: 'team-a',
taskId: 'presence-unknown',
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: 'presence-unknown',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'test fixture' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges('team-a', 'presence-unknown', OPTIONS_A);
expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith(
'team-a',
'presence-unknown',
'no_changes'
);
});
it('downgrades stale known presence to unknown for fallback empty summaries', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'team-a',
selectedTeamData: {
tasks: [{ id: 'presence-stale', changePresence: 'has_changes' }],
},
});
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName: 'team-a',
taskId: 'presence-stale',
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: 'presence-stale',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'test fixture' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges('team-a', 'presence-stale', OPTIONS_A);
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
'team-a',
'presence-stale',
'unknown'
);
});
it('bypasses stale negative cache when selected team task presence is unknown', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName: 'team-a',
taskId: 'presence-bypass',
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: 'presence-bypass',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'test fixture' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A);
store.setState({
selectedTeamName: 'team-a',
selectedTeamData: {
tasks: [{ id: 'presence-bypass', changePresence: 'unknown' }],
},
});
await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
it('ignores stale fetchTaskChanges responses when a newer task request wins', async () => {
const store = createSliceStore();
const first = deferred<any>();
@ -399,6 +558,85 @@ describe('changeReviewSlice task changes', () => {
).toBe(true);
});
it('warms task summaries with bounded concurrency', async () => {
const store = createSliceStore();
const pending = Array.from({ length: 6 }, () => deferred<any>());
let callIndex = 0;
hoisted.getTaskChanges.mockImplementation(() => pending[callIndex++].promise);
const requests = Array.from({ length: 6 }, (_, index) => ({
teamName: 'team-a',
taskId: `task-${index}`,
options: {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: `2026-03-01T1${index}:00:00.000Z` }],
since: `2026-03-01T0${index}:58:00.000Z`,
stateBucket: 'completed' as const,
},
}));
const warmPromise = store.getState().warmTaskChangeSummaries(requests);
await flushAsyncWork();
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(4);
for (let index = 0; index < 4; index++) {
pending[index].resolve({
teamName: 'team-a',
taskId: `task-${index}`,
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-12-01T12:00:00.000Z',
scope: {
taskId: `task-${index}`,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: [],
});
}
await flushAsyncWork();
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(6);
for (let index = 4; index < 6; index++) {
pending[index].resolve({
teamName: 'team-a',
taskId: `task-${index}`,
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-12-01T12:00:00.000Z',
scope: {
taskId: `task-${index}`,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: [],
});
}
await warmPromise;
});
it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
const store = createSliceStore();
const teamName = 'team-revalidate';

View file

@ -31,6 +31,7 @@ vi.mock('@renderer/api', () => ({
})),
},
teams: {
setChangePresenceTracking: vi.fn(async () => undefined),
onTeamChange: vi.fn(
(cb: (event: unknown, data: { teamName: string }) => void): (() => void) => {
hoisted.onTeamChangeCb = cb;
@ -58,6 +59,7 @@ vi.mock('@renderer/api', () => ({
}));
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
import { api } from '@renderer/api';
describe('team change throttling', () => {
let cleanup: (() => void) | null = null;
@ -66,10 +68,14 @@ describe('team change throttling', () => {
vi.useFakeTimers();
const fetchTeams = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined);
const refreshSelectedTeamChangePresence = vi.fn(async () => undefined);
useStore.setState({
fetchTeams,
refreshTeamData,
refreshSelectedTeamChangePresence,
selectedTeamName: null,
selectedTeamData: null,
paneLayout: {
focusedPaneId: 'p1',
panes: [
@ -165,6 +171,99 @@ describe('team change throttling', () => {
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
});
it('log-source-change refreshes only task change presence', async () => {
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
} as never);
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const refreshSelectedTeamChangePresenceSpy = vi.spyOn(
state,
'refreshSelectedTeamChangePresence'
);
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(399);
expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1);
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(fetchTeamsSpy).not.toHaveBeenCalled();
});
it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => {
const invalidateTaskChangePresence = vi.fn();
const checkTaskHasChanges = vi.fn(async () => undefined);
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [
{
id: 'task-1',
owner: 'alice',
status: 'in_progress',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
historyEvents: [],
reviewState: 'none',
changePresence: 'unknown',
},
{
id: 'task-2',
owner: 'alice',
status: 'in_progress',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
historyEvents: [],
reviewState: 'none',
changePresence: 'unknown',
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
invalidateTaskChangePresence,
checkTaskHasChanges,
} as never);
await vi.advanceTimersByTimeAsync(10_000);
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
1,
'my-team',
'task-1',
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
);
await vi.advanceTimersByTimeAsync(10_000);
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
2,
'my-team',
'task-2',
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
);
});
it('per-team throttling: busy team does not block another visible team', async () => {
// Add a second visible team tab
useStore.setState({
@ -204,4 +303,48 @@ describe('team change throttling', () => {
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team');
});
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
const setChangePresenceTrackingSpy = vi.mocked(api.teams.setChangePresenceTracking);
setChangePresenceTrackingSpy.mockClear();
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
} as never);
await Promise.resolve();
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
useStore.setState({
selectedTeamName: 'other-team',
selectedTeamData: null,
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }],
activeTabId: 't2',
},
],
},
} as never);
await Promise.resolve();
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
});
});

View file

@ -144,6 +144,30 @@ describe('teamSlice actions', () => {
);
});
it('does not warm task-change summaries on team open', async () => {
const store = createSliceStore();
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'completed-1',
owner: 'alice',
status: 'completed',
createdAt: '2026-03-20T08:00:00.000Z',
updatedAt: '2026-03-20T12:00:00.000Z',
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
});
await store.getState().selectTeam('my-team');
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
});
describe('refreshTeamData provisioning safety', () => {
it('does not set fatal error on TEAM_PROVISIONING', async () => {
const store = createSliceStore();
@ -265,7 +289,7 @@ describe('teamSlice actions', () => {
expect(store.getState().selectedTeamError).toBe('Team not found');
});
it('invalidates changed task summaries and warms only cacheable terminal tasks', async () => {
it('invalidates changed task summaries without warming task availability on refresh', async () => {
const store = createSliceStore();
const invalidateTaskChangePresence = vi.fn();
const warmTaskChangeSummaries = vi.fn(async () => undefined);
@ -367,9 +391,7 @@ describe('teamSlice actions', () => {
expect(hoisted.invalidateTaskChangeSummaries).toHaveBeenCalledWith('my-team', ['task-1']);
expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1);
expect(warmTaskChangeSummaries).toHaveBeenCalledWith([
expect.objectContaining({ teamName: 'my-team', taskId: 'task-2' }),
]);
expect(warmTaskChangeSummaries).not.toHaveBeenCalled();
});
});