feat: enhance task change retrieval with additional options

- Updated the `getTaskChanges` method to accept an optional `options` parameter, allowing for more granular control over the retrieval of task changes.
- Introduced new fields in the options object, including `owner`, `status`, `since`, and `intervals`, to improve filtering capabilities.
- Refactored related services and IPC methods to accommodate the new options, enhancing the overall task change management experience.
- Improved type definitions for better clarity and maintainability.
This commit is contained in:
iliya 2026-03-04 15:21:13 +02:00
parent 01660f0791
commit 31c4c7a441
10 changed files with 206 additions and 68 deletions

View file

@ -146,10 +146,39 @@ async function handleGetAgentChanges(
async function handleGetTaskChanges(
_event: IpcMainInvokeEvent,
teamName: string,
taskId: string
taskId: string,
options?: unknown
): Promise<IpcResult<TaskChangeSetV2>> {
const opts =
options && typeof options === 'object'
? {
owner:
typeof (options as Record<string, unknown>).owner === 'string'
? ((options as Record<string, unknown>).owner as string)
: undefined,
status:
typeof (options as Record<string, unknown>).status === 'string'
? ((options as Record<string, unknown>).status as string)
: undefined,
since:
typeof (options as Record<string, unknown>).since === 'string'
? ((options as Record<string, unknown>).since as string)
: undefined,
intervals: Array.isArray((options as Record<string, unknown>).intervals)
? (((options as Record<string, unknown>).intervals as unknown[]).filter(
(i): i is { startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
) as { startedAt: string; completedAt?: string }[])
: undefined,
}
: undefined;
return wrapReviewHandler('getTaskChanges', () =>
getChangeExtractor().getTaskChanges(teamName, taskId)
getChangeExtractor().getTaskChanges(teamName, taskId, opts)
);
}

View file

@ -105,11 +105,22 @@ export class ChangeExtractorService {
}
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
async getTaskChanges(
teamName: string,
taskId: string,
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
): Promise<TaskChangeSetV2> {
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<string, unknown>;
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<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
: undefined;
return {
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
status: typeof parsed.status === 'string' ? parsed.status : undefined,
intervals,
};
} catch {
return null;

View file

@ -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;

View file

@ -905,8 +905,22 @@ const electronAPI: ElectronAPI = {
getAgentChanges: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName);
},
getTaskChanges: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<TaskChangeSetV2>(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<TaskChangeSetV2>(
REVIEW_GET_TASK_CHANGES,
teamName,
taskId,
options
);
},
getChangeStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName);

View file

@ -33,7 +33,7 @@ export const NotificationTriggerSettings = ({
const customTriggers = triggers.filter((t) => !t.isBuiltin);
return (
<div className="space-y-8">
<div className="mt-6 space-y-8">
{/* Builtin Triggers */}
{builtinTriggers.length > 0 && (
<div>

View file

@ -103,15 +103,6 @@ export const NotificationsSection = ({
</button>
</div>
{/* Notification Triggers */}
<NotificationTriggerSettings
triggers={safeConfig.notifications.triggers || []}
saving={saving}
onUpdateTrigger={onUpdateTrigger}
onAddTrigger={onAddTrigger}
onRemoveTrigger={onRemoveTrigger}
/>
{/* Notification Settings */}
<SettingsSectionHeader title="Notification Settings" />
<SettingRow
@ -171,40 +162,6 @@ export const NotificationsSection = ({
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="Task status change notifications"
description="Show native OS notifications when a task's status changes"
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnStatusChange}
onChange={(v) => onNotificationToggle('notifyOnStatusChange', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
{safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? (
<>
<SettingRow
label="Only in Solo mode"
description="Only notify when the team has no teammates (lead works alone)"
>
<SettingsToggle
enabled={safeConfig.notifications.statusChangeOnlySolo}
onChange={(v) => onNotificationToggle('statusChangeOnlySolo', v)}
disabled={saving}
/>
</SettingRow>
<SettingRow
label="Notify on these statuses"
description="Select which status transitions trigger notifications"
>
<StatusCheckboxGroup
selected={safeConfig.notifications.statusChangeStatuses}
onChange={onStatusChangeStatusesUpdate}
disabled={saving}
/>
</SettingRow>
</>
) : null}
<SettingRow
label="Snooze notifications"
description={
@ -234,6 +191,83 @@ export const NotificationsSection = ({
</div>
</SettingRow>
{/* Task Status Change Notifications — grouped section */}
<div className="border-b py-3" style={{ borderColor: 'var(--color-border-subtle)' }}>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Task status change notifications
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Show native OS notifications when a task&apos;s status changes
</div>
</div>
<div className="shrink-0">
<SettingsToggle
enabled={safeConfig.notifications.notifyOnStatusChange}
onChange={(v) => onNotificationToggle('notifyOnStatusChange', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</div>
</div>
{safeConfig.notifications.notifyOnStatusChange && safeConfig.notifications.enabled ? (
<div
className="mt-3 flex flex-col gap-3 border-t pt-3"
style={{ borderColor: 'var(--color-border-subtle)', paddingLeft: 15 }}
>
<div className="flex items-center justify-between">
<div>
<div
className="text-sm font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Only in Solo mode
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Notify only when the team has no teammates
</div>
</div>
<div className="shrink-0">
<SettingsToggle
enabled={safeConfig.notifications.statusChangeOnlySolo}
onChange={(v) => onNotificationToggle('statusChangeOnlySolo', v)}
disabled={saving}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<div
className="text-sm font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Notify on these statuses
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Which target statuses trigger a notification
</div>
</div>
<div className="shrink-0">
<StatusCheckboxGroup
selected={safeConfig.notifications.statusChangeStatuses}
onChange={onStatusChangeStatusesUpdate}
disabled={saving}
/>
</div>
</div>
</div>
) : null}
</div>
{/* Custom Triggers */}
<NotificationTriggerSettings
triggers={safeConfig.notifications.triggers || []}
saving={saving}
onUpdateTrigger={onUpdateTrigger}
onAddTrigger={onAddTrigger}
onRemoveTrigger={onRemoveTrigger}
/>
<SettingsSectionHeader title="Ignored Repositories" />
<p className="mb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Notifications from these repositories will be ignored

View file

@ -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]);

View file

@ -487,7 +487,18 @@ export interface TeamsAPI {
export interface ReviewAPI {
// Phase 1
getAgentChanges: (teamName: string, memberName: string) => Promise<AgentChangeSet>;
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSetV2>;
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<TaskChangeSetV2>;
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
getFileContent: (
teamName: string,

View file

@ -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,

View file

@ -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<Process> & { 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.