diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 1869e9a6..6efb399c 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -146,10 +146,39 @@ async function handleGetAgentChanges( async function handleGetTaskChanges( _event: IpcMainInvokeEvent, teamName: string, - taskId: string + taskId: string, + options?: unknown ): Promise> { + const opts = + options && typeof options === 'object' + ? { + owner: + typeof (options as Record).owner === 'string' + ? ((options as Record).owner as string) + : undefined, + status: + typeof (options as Record).status === 'string' + ? ((options as Record).status as string) + : undefined, + since: + typeof (options as Record).since === 'string' + ? ((options as Record).since as string) + : undefined, + intervals: Array.isArray((options as Record).intervals) + ? (((options as Record).intervals as unknown[]).filter( + (i): i is { startedAt: string; completedAt?: string } => + Boolean(i) && + typeof i === 'object' && + typeof (i as Record).startedAt === 'string' && + ((i as Record).completedAt === undefined || + typeof (i as Record).completedAt === 'string') + ) as { startedAt: string; completedAt?: string }[]) + : undefined, + } + : undefined; + return wrapReviewHandler('getTaskChanges', () => - getChangeExtractor().getTaskChanges(teamName, taskId) + getChangeExtractor().getTaskChanges(teamName, taskId, opts) ); } diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index a3c88ee3..03ed46ac 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -105,11 +105,22 @@ export class ChangeExtractorService { } /** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */ - async getTaskChanges(teamName: string, taskId: string): Promise { + async getTaskChanges( + teamName: string, + taskId: string, + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + } + ): Promise { const taskMeta = await this.readTaskMeta(teamName, taskId); const logs = await this.logsFinder.findLogsForTask(teamName, taskId, { - owner: taskMeta?.owner, - status: taskMeta?.status, + owner: options?.owner ?? taskMeta?.owner, + status: options?.status ?? taskMeta?.status, + intervals: options?.intervals ?? taskMeta?.intervals, + since: options?.since, }); const logRefs = await this.resolveLogFileRefs(teamName, logs); if (logRefs.length === 0) { @@ -173,14 +184,29 @@ export class ChangeExtractorService { private async readTaskMeta( teamName: string, taskId: string - ): Promise<{ owner?: string; status?: string } | null> { + ): Promise<{ + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + } | null> { try { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const raw = await readFile(taskPath, 'utf8'); const parsed = JSON.parse(raw) as Record; + const intervals = Array.isArray(parsed.workIntervals) + ? (parsed.workIntervals as unknown[]).filter( + (i): i is { startedAt: string; completedAt?: string } => + Boolean(i) && + typeof i === 'object' && + typeof (i as Record).startedAt === 'string' && + ((i as Record).completedAt === undefined || + typeof (i as Record).completedAt === 'string') + ) + : undefined; return { owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, status: typeof parsed.status === 'string' ? parsed.status : undefined, + intervals, }; } catch { return null; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index c95e579b..4979863a 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -500,13 +500,21 @@ export class TeamMemberLogsFinder { try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let foundTask = false; + let foundTeam = false; for await (const line of rl) { - // Require both taskId and teamName to avoid cross-team collisions when multiple + // We require BOTH taskId and teamName to avoid cross-team collisions when multiple // teams share the same projectPath (task IDs are only unique per team). - const hasTaskId = patterns.some((re) => re.test(line)); - if (!hasTaskId) continue; - const hasTeam = teamPatterns.some((re) => re.test(line)); - if (hasTeam) { + // + // But they often appear on different lines (e.g. team_name is in Task tool input, while + // taskId appears in a tool result or CLI output). So we track them independently. + if (!foundTask && patterns.some((re) => re.test(line))) { + foundTask = true; + } + if (!foundTeam && teamPatterns.some((re) => re.test(line))) { + foundTeam = true; + } + if (foundTask && foundTeam) { rl.close(); stream.destroy(); return true; diff --git a/src/preload/index.ts b/src/preload/index.ts index 069fc45a..3a513c61 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -905,8 +905,22 @@ const electronAPI: ElectronAPI = { getAgentChanges: async (teamName: string, memberName: string) => { return invokeIpcWithResult(REVIEW_GET_AGENT_CHANGES, teamName, memberName); }, - getTaskChanges: async (teamName: string, taskId: string) => { - return invokeIpcWithResult(REVIEW_GET_TASK_CHANGES, teamName, taskId); + getTaskChanges: async ( + teamName: string, + taskId: string, + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + } + ) => { + return invokeIpcWithResult( + REVIEW_GET_TASK_CHANGES, + teamName, + taskId, + options + ); }, getChangeStats: async (teamName: string, memberName: string) => { return invokeIpcWithResult(REVIEW_GET_CHANGE_STATS, teamName, memberName); diff --git a/src/renderer/components/settings/NotificationTriggerSettings/index.tsx b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx index c0ba60f3..06899fc3 100644 --- a/src/renderer/components/settings/NotificationTriggerSettings/index.tsx +++ b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx @@ -33,7 +33,7 @@ export const NotificationTriggerSettings = ({ const customTriggers = triggers.filter((t) => !t.isBuiltin); return ( -
+
{/* Builtin Triggers */} {builtinTriggers.length > 0 && (
diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 1ec89ba2..8afd75b2 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -103,15 +103,6 @@ export const NotificationsSection = ({
- {/* Notification Triggers */} - - {/* Notification Settings */} - - onNotificationToggle('notifyOnStatusChange', v)} - disabled={saving || !safeConfig.notifications.enabled} - /> - - {safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? ( - <> - - onNotificationToggle('statusChangeOnlySolo', v)} - disabled={saving} - /> - - - - - - ) : null} + {/* Task Status Change Notifications — grouped section */} +
+
+
+
+ Task status change notifications +
+
+ Show native OS notifications when a task's status changes +
+
+
+ onNotificationToggle('notifyOnStatusChange', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> +
+
+ {safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? ( +
+
+
+
+ Only in Solo mode +
+
+ Notify only when the team has no teammates +
+
+
+ onNotificationToggle('statusChangeOnlySolo', v)} + disabled={saving} + /> +
+
+
+
+
+ Notify on these statuses +
+
+ Which target statuses trigger a notification +
+
+
+ +
+
+
+ ) : null} +
+ + {/* Custom Triggers */} + +

Notifications from these repositories will be ignored diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 9ced4abf..53187958 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -201,7 +201,7 @@ export const KanbanTaskCard = ({ const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); useEffect(() => { - if (showChangesColumn && task.status === 'completed' && taskHasChanges == null) { + if (showChangesColumn && task.status === 'completed' && taskHasChanges !== true) { void checkTaskHasChanges(teamName, task.id); } }, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 6a93f82d..c4b962c5 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -487,7 +487,18 @@ export interface TeamsAPI { export interface ReviewAPI { // Phase 1 getAgentChanges: (teamName: string, memberName: string) => Promise; - getTaskChanges: (teamName: string, taskId: string) => Promise; + getTaskChanges: ( + teamName: string, + taskId: string, + options?: { + owner?: string; + status?: string; + /** Persisted work intervals (preferred for reliable owner-log attribution). */ + intervals?: { startedAt: string; completedAt?: string }[]; + /** Back-compat: single since timestamp (deprecated). */ + since?: string; + } + ) => Promise; getChangeStats: (teamName: string, memberName: string) => Promise; getFileContent: ( teamName: string, diff --git a/test/main/services/analysis/ProcessLinker.test.ts b/test/main/services/analysis/ProcessLinker.test.ts index cc2943ec..3c8971f7 100644 --- a/test/main/services/analysis/ProcessLinker.test.ts +++ b/test/main/services/analysis/ProcessLinker.test.ts @@ -34,7 +34,8 @@ const baseMetrics: SessionMetrics = { function makeChunk(taskIds: string[]): EnhancedAIChunk { return { - type: 'ai', + id: 'chunk-1', + chunkType: 'ai', responses: [ { uuid: 'resp-1', @@ -56,6 +57,10 @@ function makeChunk(taskIds: string[]): EnhancedAIChunk { }, ], processes: [], + sidechainMessages: [], + toolExecutions: [], + semanticSteps: [], + rawMessages: [], startTime: new Date('2026-01-01T00:00:00Z'), endTime: new Date('2026-01-01T00:01:00Z'), durationMs: 60_000, diff --git a/test/main/services/discovery/SubagentResolver.linkType.test.ts b/test/main/services/discovery/SubagentResolver.linkType.test.ts index dd4665d7..dd894cf4 100644 --- a/test/main/services/discovery/SubagentResolver.linkType.test.ts +++ b/test/main/services/discovery/SubagentResolver.linkType.test.ts @@ -14,7 +14,8 @@ import { describe, expect, it } from 'vitest'; import { SubagentResolver } from '../../../../src/main/services/discovery/SubagentResolver'; -import type { ParsedMessage, Process } from '../../../../src/main/types'; +import type { ParsedMessage, Process, ToolCall } from '../../../../src/main/types'; +import type { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; // ============================================================================= // Helpers @@ -57,12 +58,22 @@ function subagent(overrides: Partial & { id: string }): Process { }; } +function extractTaskCalls(messages: ParsedMessage[]): ToolCall[] { + const calls: ToolCall[] = []; + for (const m of messages) { + for (const tc of m.toolCalls) { + if (tc.isTask) calls.push(tc); + } + } + return calls; +} + // ============================================================================= // Tests // ============================================================================= describe('SubagentResolver.linkType', () => { - const resolver = new SubagentResolver(); + const resolver = new SubagentResolver({} as ProjectScanner); // Access private method via prototype for testing const linkToTaskCalls = ( @@ -97,14 +108,14 @@ describe('SubagentResolver.linkType', () => { type: 'user', isMeta: true, content: [{ type: 'tool_result', tool_use_id: taskCallId, content: 'done' }], - toolResults: [{ toolUseId: taskCallId, content: 'done' }], + toolResults: [{ toolUseId: taskCallId, content: 'done', isError: false }], sourceToolUseID: taskCallId, toolUseResult: { agentId: subagentId }, }), ]; const sub = subagent({ id: subagentId }); - linkToTaskCalls([sub], messages); + linkToTaskCalls([sub], extractTaskCalls(messages), messages); expect(sub.linkType).toBe('agent-id'); expect(sub.parentTaskId).toBe(taskCallId); @@ -150,7 +161,7 @@ describe('SubagentResolver.linkType', () => { ], }); - linkToTaskCalls([sub], messages); + linkToTaskCalls([sub], extractTaskCalls(messages), messages); expect(sub.linkType).toBe('team-member-id'); expect(sub.parentTaskId).toBe(taskCallId); @@ -194,7 +205,7 @@ describe('SubagentResolver.linkType', () => { ], }); - linkToTaskCalls([sub], messages); + linkToTaskCalls([sub], extractTaskCalls(messages), messages); expect(sub.linkType).toBe('team-member-id'); expect(sub.parentTaskId).toBe(taskCallId); @@ -233,7 +244,7 @@ describe('SubagentResolver.linkType', () => { messages: [msg({ type: 'user', content: 'plain message without teammate tag' })], }); - linkToTaskCalls([sub], messages); + linkToTaskCalls([sub], extractTaskCalls(messages), messages); expect(sub.linkType).toBe('unlinked'); expect(sub.parentTaskId).toBeUndefined(); @@ -288,7 +299,7 @@ describe('SubagentResolver.linkType', () => { startTime: new Date('2026-01-01T00:00:20Z'), }); - linkToTaskCalls([sub1, sub2], messages); + linkToTaskCalls([sub1, sub2], extractTaskCalls(messages), messages); // In the old code, sub1 would get task-1 and sub2 would get task-2 by position. // Now both should be unlinked.