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:
parent
01660f0791
commit
31c4c7a441
10 changed files with 206 additions and 68 deletions
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue