feat: enhance notification system with task status change support
- Added new configuration options for task status change notifications, including toggles for enabling notifications, solo mode restrictions, and customizable target statuses. - Updated validation logic to accommodate new notification settings, ensuring proper handling of boolean and array types. - Enhanced UI components to allow users to manage status change notification preferences effectively. - Implemented backend logic to detect and trigger notifications based on task status changes, improving user awareness of task progress.
This commit is contained in:
parent
dfc2a43a91
commit
01660f0791
26 changed files with 986 additions and 130 deletions
|
|
@ -112,6 +112,9 @@ function validateNotificationsSection(
|
||||||
'ignoredRepositories',
|
'ignoredRepositories',
|
||||||
'snoozedUntil',
|
'snoozedUntil',
|
||||||
'snoozeMinutes',
|
'snoozeMinutes',
|
||||||
|
'notifyOnStatusChange',
|
||||||
|
'statusChangeOnlySolo',
|
||||||
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -162,6 +165,24 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnClarifications = value;
|
result.notifyOnClarifications = value;
|
||||||
break;
|
break;
|
||||||
|
case 'notifyOnStatusChange':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.notifyOnStatusChange = value;
|
||||||
|
break;
|
||||||
|
case 'statusChangeOnlySolo':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.statusChangeOnlySolo = value;
|
||||||
|
break;
|
||||||
|
case 'statusChangeStatuses':
|
||||||
|
if (!isStringArray(value)) {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a string[]` };
|
||||||
|
}
|
||||||
|
result.statusChangeStatuses = value;
|
||||||
|
break;
|
||||||
case 'ignoredRegex':
|
case 'ignoredRegex':
|
||||||
if (!isStringArray(value)) {
|
if (!isStringArray(value)) {
|
||||||
return { valid: false, error: `notifications.${key} must be a string[]` };
|
return { valid: false, error: `notifications.${key} must be a string[]` };
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ export function buildGroups(messages: ParsedMessage[], subagents: Process[]): Co
|
||||||
subagents
|
subagents
|
||||||
);
|
);
|
||||||
|
|
||||||
// Link subagents to this group
|
// Link subagents to this group via deterministic parentTaskId matching
|
||||||
const groupSubagents = linkSubagentsToGroup(userMsg, nextUserMsg, subagents);
|
const groupSubagents = linkSubagentsToGroup(aiResponses, subagents);
|
||||||
|
|
||||||
// Calculate metrics
|
// Calculate metrics
|
||||||
const { startTime, endTime, durationMs } = calculateGroupTiming(userMsg, aiResponses);
|
const { startTime, endTime, durationMs } = calculateGroupTiming(userMsg, aiResponses);
|
||||||
|
|
@ -168,25 +168,21 @@ function separateTaskExecutions(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link subagents to a conversation group based on timing.
|
* Link subagents to a conversation group via deterministic parentTaskId matching.
|
||||||
|
* Only includes subagents whose parentTaskId matches a Task tool_use ID in the AI responses.
|
||||||
*/
|
*/
|
||||||
function linkSubagentsToGroup(
|
function linkSubagentsToGroup(aiResponses: ParsedMessage[], allSubagents: Process[]): Process[] {
|
||||||
userMsg: ParsedMessage,
|
const groupTaskIds = new Set<string>();
|
||||||
nextUserMsg: ParsedMessage | undefined,
|
for (const msg of aiResponses) {
|
||||||
allSubagents: Process[]
|
for (const toolCall of msg.toolCalls) {
|
||||||
): Process[] {
|
if (toolCall.isTask) {
|
||||||
const groupSubagents: Process[] = [];
|
groupTaskIds.add(toolCall.id);
|
||||||
const startTime = userMsg.timestamp;
|
}
|
||||||
const endTime = nextUserMsg?.timestamp ?? new Date(Date.now() + 1000 * 60 * 60 * 24); // Far future if no next message
|
|
||||||
|
|
||||||
// Collect subagents that start within this group's time range
|
|
||||||
for (const subagent of allSubagents) {
|
|
||||||
if (subagent.startTime >= startTime && subagent.startTime < endTime) {
|
|
||||||
groupSubagents.push(subagent);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return allSubagents
|
||||||
return groupSubagents;
|
.filter((s) => s.parentTaskId && groupTaskIds.has(s.parentTaskId))
|
||||||
|
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* ProcessLinker service - Links subagent processes to AI chunks.
|
* ProcessLinker service - Links subagent processes to AI chunks.
|
||||||
*
|
*
|
||||||
* Uses a two-tier linking strategy:
|
* Uses deterministic parentTaskId matching only. If a subagent has no parentTaskId
|
||||||
* 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call
|
* or it doesn't match any Task call in the chunk, the subagent is NOT linked.
|
||||||
* that spawned them. This is reliable even when the response is still in progress.
|
* No timing-based or positional fallbacks — avoids false positives.
|
||||||
* 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to
|
|
||||||
* checking if the subagent's startTime falls within the chunk's time range.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type EnhancedAIChunk, type Process } from '@main/types';
|
import { type EnhancedAIChunk, type Process } from '@main/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link processes to a single AI chunk.
|
* Link processes to a single AI chunk via deterministic parentTaskId matching.
|
||||||
*
|
*
|
||||||
* Uses a two-tier linking strategy:
|
* Only links subagents whose parentTaskId matches a Task tool_use ID in the chunk.
|
||||||
* 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call
|
* Subagents without parentTaskId or with non-matching parentTaskId are skipped.
|
||||||
* that spawned them. This is reliable even when the response is still in progress.
|
|
||||||
* 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to
|
|
||||||
* checking if the subagent's startTime falls within the chunk's time range.
|
|
||||||
*/
|
*/
|
||||||
export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Process[]): void {
|
export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Process[]): void {
|
||||||
// Build set of Task tool IDs from this chunk's responses
|
// Build set of Task tool IDs from this chunk's responses
|
||||||
|
|
@ -30,30 +25,10 @@ export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Proces
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track which subagents have been linked
|
// Deterministic linking: Match subagents to Task calls by parentTaskId only
|
||||||
const linkedSubagentIds = new Set<string>();
|
|
||||||
|
|
||||||
// Primary linking: Match subagents to Task calls by parentTaskId
|
|
||||||
for (const subagent of subagents) {
|
for (const subagent of subagents) {
|
||||||
if (subagent.parentTaskId && chunkTaskIds.has(subagent.parentTaskId)) {
|
if (subagent.parentTaskId && chunkTaskIds.has(subagent.parentTaskId)) {
|
||||||
chunk.processes.push(subagent);
|
chunk.processes.push(subagent);
|
||||||
linkedSubagentIds.add(subagent.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback linking: For orphaned subagents, use timing-based matching
|
|
||||||
// This handles edge cases where parentTaskId might not be set
|
|
||||||
for (const subagent of subagents) {
|
|
||||||
if (linkedSubagentIds.has(subagent.id)) {
|
|
||||||
continue; // Already linked via parentTaskId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only use timing fallback if subagent has no parentTaskId
|
|
||||||
// (If it has parentTaskId but didn't match, it belongs to a different chunk)
|
|
||||||
if (!subagent.parentTaskId) {
|
|
||||||
if (subagent.startTime >= chunk.startTime && subagent.startTime <= chunk.endTime) {
|
|
||||||
chunk.processes.push(subagent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,16 +153,16 @@ export class SubagentResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the summary attribute from the first <teammate-message> tag in a subagent's messages.
|
* Extract the teammate_id attribute from the first <teammate-message> tag in a subagent's messages.
|
||||||
* Returns the summary string if found, undefined otherwise.
|
* Returns the teammate_id string if found, undefined otherwise.
|
||||||
* Used to match team member files to their spawning Task calls.
|
* Used for deterministic matching of team member files to their spawning Task calls.
|
||||||
*/
|
*/
|
||||||
private extractTeamMessageSummary(messages: ParsedMessage[]): string | undefined {
|
private extractTeammateId(messages: ParsedMessage[]): string | undefined {
|
||||||
const firstUserMessage = messages.find((m) => m.type === 'user');
|
const firstUserMessage = messages.find((m) => m.type === 'user');
|
||||||
if (!firstUserMessage) return undefined;
|
if (!firstUserMessage) return undefined;
|
||||||
|
|
||||||
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
|
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
|
||||||
const match = /<teammate-message[^>]*\bsummary="([^"]+)"/.exec(text);
|
const match = /<teammate-message\s+[^>]*\bteammate_id="([^"]+)"/.exec(text);
|
||||||
return match?.[1];
|
return match?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,38 +250,41 @@ export class SubagentResolver {
|
||||||
if (!taskCall) continue;
|
if (!taskCall) continue;
|
||||||
|
|
||||||
this.enrichSubagentFromTask(subagent, taskCall);
|
this.enrichSubagentFromTask(subagent, taskCall);
|
||||||
|
subagent.linkType = 'agent-id';
|
||||||
matchedSubagentIds.add(subagent.id);
|
matchedSubagentIds.add(subagent.id);
|
||||||
matchedTaskIds.add(taskCallId);
|
matchedTaskIds.add(taskCallId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Description-based matching for team members
|
// Phase 2: Deterministic teammate_id matching for team members
|
||||||
// Team spawns use agent_id = "name@team_name" (not a file UUID), so Phase 1 can't match them.
|
// Team spawns use agent_id = "name@team_name" (not a file UUID), so Phase 1 can't match them.
|
||||||
// Instead, match by comparing the Task description to the summary attribute in the
|
// Instead, match by comparing Task call input.name to the teammate_id XML attribute
|
||||||
// subagent file's first <teammate-message> tag.
|
// in the subagent file's first <teammate-message> tag.
|
||||||
const teamTaskCalls = taskCallsOnly.filter(
|
const teamTaskCalls = taskCallsOnly.filter(
|
||||||
(tc) => !matchedTaskIds.has(tc.id) && tc.input?.team_name && tc.input?.name
|
(tc) =>
|
||||||
|
!matchedTaskIds.has(tc.id) &&
|
||||||
|
typeof tc.input?.team_name === 'string' &&
|
||||||
|
typeof tc.input?.name === 'string'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (teamTaskCalls.length > 0) {
|
if (teamTaskCalls.length > 0) {
|
||||||
// Pre-extract summaries from unmatched subagent files
|
// Pre-extract teammate_ids from unmatched subagent files
|
||||||
const subagentSummaries = new Map<string, string>();
|
const subagentTeammateIds = new Map<string, string>();
|
||||||
for (const subagent of subagents) {
|
for (const subagent of subagents) {
|
||||||
if (matchedSubagentIds.has(subagent.id)) continue;
|
if (matchedSubagentIds.has(subagent.id)) continue;
|
||||||
const summary = this.extractTeamMessageSummary(subagent.messages);
|
const teammateId = this.extractTeammateId(subagent.messages);
|
||||||
if (summary) {
|
if (teammateId) {
|
||||||
subagentSummaries.set(subagent.id, summary);
|
subagentTeammateIds.set(subagent.id, teammateId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match each team Task call to the earliest subagent file with matching summary
|
// Match each team Task call to the earliest subagent file with matching teammate_id
|
||||||
for (const taskCall of teamTaskCalls) {
|
for (const taskCall of teamTaskCalls) {
|
||||||
const description = taskCall.taskDescription;
|
const inputName = taskCall.input?.name as string;
|
||||||
if (!description) continue;
|
|
||||||
|
|
||||||
let bestMatch: Process | undefined;
|
let bestMatch: Process | undefined;
|
||||||
for (const subagent of subagents) {
|
for (const subagent of subagents) {
|
||||||
if (matchedSubagentIds.has(subagent.id)) continue;
|
if (matchedSubagentIds.has(subagent.id)) continue;
|
||||||
if (subagentSummaries.get(subagent.id) !== description) continue;
|
if (subagentTeammateIds.get(subagent.id) !== inputName) continue;
|
||||||
if (!bestMatch || subagent.startTime < bestMatch.startTime) {
|
if (!bestMatch || subagent.startTime < bestMatch.startTime) {
|
||||||
bestMatch = subagent;
|
bestMatch = subagent;
|
||||||
}
|
}
|
||||||
|
|
@ -289,22 +292,18 @@ export class SubagentResolver {
|
||||||
|
|
||||||
if (bestMatch) {
|
if (bestMatch) {
|
||||||
this.enrichSubagentFromTask(bestMatch, taskCall);
|
this.enrichSubagentFromTask(bestMatch, taskCall);
|
||||||
|
bestMatch.linkType = 'team-member-id';
|
||||||
matchedSubagentIds.add(bestMatch.id);
|
matchedSubagentIds.add(bestMatch.id);
|
||||||
matchedTaskIds.add(taskCall.id);
|
matchedTaskIds.add(taskCall.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Positional fallback for remaining unmatched non-team subagents (no wrap-around)
|
// Mark remaining unmatched subagents as unlinked (no Phase 3 positional fallback)
|
||||||
const unmatchedSubagents = [...subagents]
|
for (const subagent of subagents) {
|
||||||
.filter((s) => !matchedSubagentIds.has(s.id))
|
if (!matchedSubagentIds.has(subagent.id) && !subagent.linkType) {
|
||||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
subagent.linkType = 'unlinked';
|
||||||
const unmatchedTasks = taskCallsOnly.filter(
|
}
|
||||||
(tc) => !matchedTaskIds.has(tc.id) && !(tc.input?.team_name && tc.input?.name)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < unmatchedSubagents.length && i < unmatchedTasks.length; i++) {
|
|
||||||
this.enrichSubagentFromTask(unmatchedSubagents[i], unmatchedTasks[i]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,6 +397,7 @@ export class SubagentResolver {
|
||||||
subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId;
|
subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId;
|
||||||
subagent.description = subagent.description ?? ancestor.description;
|
subagent.description = subagent.description ?? ancestor.description;
|
||||||
subagent.subagentType = subagent.subagentType ?? ancestor.subagentType;
|
subagent.subagentType = subagent.subagentType ?? ancestor.subagentType;
|
||||||
|
subagent.linkType = subagent.linkType ?? (ancestor.linkType ? 'parent-chain' : undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ export interface NotificationConfig {
|
||||||
notifyOnUserInbox: boolean;
|
notifyOnUserInbox: boolean;
|
||||||
/** Whether to show native OS notifications when a task needs user clarification */
|
/** Whether to show native OS notifications when a task needs user clarification */
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
|
/** Whether to show native OS notifications when a task status changes */
|
||||||
|
notifyOnStatusChange: boolean;
|
||||||
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
|
statusChangeOnlySolo: boolean;
|
||||||
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
statusChangeStatuses: string[];
|
||||||
/** Notification triggers - define when to generate notifications */
|
/** Notification triggers - define when to generate notifications */
|
||||||
triggers: NotificationTrigger[];
|
triggers: NotificationTrigger[];
|
||||||
}
|
}
|
||||||
|
|
@ -254,6 +260,9 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnLeadInbox: false,
|
notifyOnLeadInbox: false,
|
||||||
notifyOnUserInbox: true,
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
|
notifyOnStatusChange: true,
|
||||||
|
statusChangeOnlySolo: true,
|
||||||
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { stat } from 'fs/promises';
|
import { readFile, stat } from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
|
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
|
|
@ -104,7 +106,11 @@ export class ChangeExtractorService {
|
||||||
|
|
||||||
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
|
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
|
||||||
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
|
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
|
||||||
const logs = await this.logsFinder.findLogsForTask(teamName, taskId);
|
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
||||||
|
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, {
|
||||||
|
owner: taskMeta?.owner,
|
||||||
|
status: taskMeta?.status,
|
||||||
|
});
|
||||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
||||||
if (logRefs.length === 0) {
|
if (logRefs.length === 0) {
|
||||||
return this.emptyTaskChangeSet(teamName, taskId);
|
return this.emptyTaskChangeSet(teamName, taskId);
|
||||||
|
|
@ -163,6 +169,24 @@ export class ChangeExtractorService {
|
||||||
|
|
||||||
// ---- Private methods ----
|
// ---- Private methods ----
|
||||||
|
|
||||||
|
/** Read task metadata (owner, status) from the task JSON file */
|
||||||
|
private async readTaskMeta(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string
|
||||||
|
): Promise<{ owner?: string; status?: 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>;
|
||||||
|
return {
|
||||||
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
|
status: typeof parsed.status === 'string' ? parsed.status : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Получить projectPath из конфига команды */
|
/** Получить projectPath из конфига команды */
|
||||||
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,10 @@ export class TeamMemberLogsFinder {
|
||||||
typeof normalizedOwner === 'string' &&
|
typeof normalizedOwner === 'string' &&
|
||||||
normalizedOwner.length > 0 &&
|
normalizedOwner.length > 0 &&
|
||||||
normalizedOwner.toLowerCase() === leadMemberName.toLowerCase();
|
normalizedOwner.toLowerCase() === leadMemberName.toLowerCase();
|
||||||
|
const ownerRelevantStatus =
|
||||||
|
options?.status === 'in_progress' || options?.status === 'completed';
|
||||||
const includeOwnerSessions =
|
const includeOwnerSessions =
|
||||||
options?.status === 'in_progress' &&
|
ownerRelevantStatus &&
|
||||||
typeof normalizedOwner === 'string' &&
|
typeof normalizedOwner === 'string' &&
|
||||||
normalizedOwner.length > 0 &&
|
normalizedOwner.length > 0 &&
|
||||||
!isLeadOwner;
|
!isLeadOwner;
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ export interface Process {
|
||||||
isParallel: boolean;
|
isParallel: boolean;
|
||||||
/** The tool_use ID of the Task call that spawned this */
|
/** The tool_use ID of the Task call that spawned this */
|
||||||
parentTaskId?: string;
|
parentTaskId?: string;
|
||||||
|
/** How this process was linked to its parent Task call */
|
||||||
|
linkType?: 'agent-id' | 'team-member-id' | 'parent-chain' | 'unlinked';
|
||||||
/** Whether this subagent is still in progress */
|
/** Whether this subagent is still in progress */
|
||||||
isOngoing?: boolean;
|
isOngoing?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ export const SettingsView = (): React.JSX.Element | null => {
|
||||||
onAddTrigger={handlers.handleAddTrigger}
|
onAddTrigger={handlers.handleAddTrigger}
|
||||||
onUpdateTrigger={handlers.handleUpdateTrigger}
|
onUpdateTrigger={handlers.handleUpdateTrigger}
|
||||||
onRemoveTrigger={handlers.handleRemoveTrigger}
|
onRemoveTrigger={handlers.handleRemoveTrigger}
|
||||||
|
onStatusChangeStatusesUpdate={handlers.handleStatusChangeStatusesUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { AppConfig } from '@renderer/types/data';
|
import type { AppConfig } from '@renderer/types/data';
|
||||||
|
import type { TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
// Get the setState function from the store to update appConfig globally
|
// Get the setState function from the store to update appConfig globally
|
||||||
const setStoreState = useStore.setState;
|
const setStoreState = useStore.setState;
|
||||||
|
|
@ -45,6 +46,9 @@ export interface SafeConfig {
|
||||||
notifyOnLeadInbox: boolean;
|
notifyOnLeadInbox: boolean;
|
||||||
notifyOnUserInbox: boolean;
|
notifyOnUserInbox: boolean;
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
|
notifyOnStatusChange: boolean;
|
||||||
|
statusChangeOnlySolo: boolean;
|
||||||
|
statusChangeStatuses: TeamTaskStatus[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
};
|
};
|
||||||
display: {
|
display: {
|
||||||
|
|
@ -175,6 +179,11 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
||||||
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
||||||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||||
|
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
||||||
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
|
statusChangeStatuses: (displayConfig?.notifications?.statusChangeStatuses as
|
||||||
|
| TeamTaskStatus[]
|
||||||
|
| undefined) ?? ['in_progress', 'completed'],
|
||||||
triggers: displayConfig?.notifications?.triggers ?? [],
|
triggers: displayConfig?.notifications?.triggers ?? [],
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ interface SettingsHandlers {
|
||||||
|
|
||||||
// Notification handlers
|
// Notification handlers
|
||||||
handleNotificationToggle: (key: keyof AppConfig['notifications'], value: boolean) => void;
|
handleNotificationToggle: (key: keyof AppConfig['notifications'], value: boolean) => void;
|
||||||
|
handleStatusChangeStatusesUpdate: (statuses: string[]) => void;
|
||||||
handleSnooze: (minutes: number) => Promise<void>;
|
handleSnooze: (minutes: number) => Promise<void>;
|
||||||
handleClearSnooze: () => Promise<void>;
|
handleClearSnooze: () => Promise<void>;
|
||||||
handleAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
|
handleAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
|
||||||
|
|
@ -104,6 +105,13 @@ export function useSettingsHandlers({
|
||||||
[updateConfig]
|
[updateConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleStatusChangeStatusesUpdate = useCallback(
|
||||||
|
(statuses: string[]) => {
|
||||||
|
void updateConfig('notifications', { statusChangeStatuses: statuses });
|
||||||
|
},
|
||||||
|
[updateConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSnooze = useCallback(
|
const handleSnooze = useCallback(
|
||||||
async (minutes: number) => {
|
async (minutes: number) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -290,6 +298,9 @@ export function useSettingsHandlers({
|
||||||
notifyOnLeadInbox: false,
|
notifyOnLeadInbox: false,
|
||||||
notifyOnUserInbox: true,
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
|
notifyOnStatusChange: true,
|
||||||
|
statusChangeOnlySolo: true,
|
||||||
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
|
|
@ -390,6 +401,7 @@ export function useSettingsHandlers({
|
||||||
handleLanguageChange,
|
handleLanguageChange,
|
||||||
handleDefaultTabChange,
|
handleDefaultTabChange,
|
||||||
handleNotificationToggle,
|
handleNotificationToggle,
|
||||||
|
handleStatusChangeStatusesUpdate,
|
||||||
handleSnooze,
|
handleSnooze,
|
||||||
handleClearSnooze,
|
handleClearSnooze,
|
||||||
handleAddIgnoredRepository,
|
handleAddIgnoredRepository,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { NotificationTriggerSettings } from '../NotificationTriggerSettings';
|
||||||
|
|
||||||
import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig';
|
import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig';
|
||||||
import type { NotificationTrigger } from '@renderer/types/data';
|
import type { NotificationTrigger } from '@renderer/types/data';
|
||||||
|
import type { TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
// Snooze duration options
|
// Snooze duration options
|
||||||
const SNOOZE_OPTIONS = [
|
const SNOOZE_OPTIONS = [
|
||||||
|
|
@ -38,9 +39,12 @@ interface NotificationsSectionProps {
|
||||||
| 'includeSubagentErrors'
|
| 'includeSubagentErrors'
|
||||||
| 'notifyOnLeadInbox'
|
| 'notifyOnLeadInbox'
|
||||||
| 'notifyOnUserInbox'
|
| 'notifyOnUserInbox'
|
||||||
| 'notifyOnClarifications',
|
| 'notifyOnClarifications'
|
||||||
|
| 'notifyOnStatusChange'
|
||||||
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
readonly onStatusChangeStatusesUpdate: (statuses: TeamTaskStatus[]) => void;
|
||||||
readonly onSnooze: (minutes: number) => Promise<void>;
|
readonly onSnooze: (minutes: number) => Promise<void>;
|
||||||
readonly onClearSnooze: () => Promise<void>;
|
readonly onClearSnooze: () => Promise<void>;
|
||||||
readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
|
readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
|
||||||
|
|
@ -67,6 +71,7 @@ export const NotificationsSection = ({
|
||||||
onAddTrigger,
|
onAddTrigger,
|
||||||
onUpdateTrigger,
|
onUpdateTrigger,
|
||||||
onRemoveTrigger,
|
onRemoveTrigger,
|
||||||
|
onStatusChangeStatusesUpdate,
|
||||||
}: NotificationsSectionProps): React.JSX.Element => {
|
}: NotificationsSectionProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -166,6 +171,40 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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
|
<SettingRow
|
||||||
label="Snooze notifications"
|
label="Snooze notifications"
|
||||||
description={
|
description={
|
||||||
|
|
@ -230,3 +269,46 @@ export const NotificationsSection = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: TeamTaskStatus; label: string }[] = [
|
||||||
|
{ value: 'in_progress', label: 'Started' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'deleted', label: 'Deleted' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const StatusCheckboxGroup = ({
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
selected: TeamTaskStatus[];
|
||||||
|
onChange: (statuses: TeamTaskStatus[]) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{STATUS_OPTIONS.map((opt) => {
|
||||||
|
const checked = selected.includes(opt.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const next = checked
|
||||||
|
? selected.filter((s) => s !== opt.value)
|
||||||
|
: [...selected, opt.value];
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
checked
|
||||||
|
? 'bg-indigo-500/20 text-indigo-400'
|
||||||
|
: 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ interface CollapsibleTeamSectionProps {
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
/** Stable identifier used for programmatic section navigation. */
|
/** Stable identifier used for programmatic section navigation. */
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
|
/** Extra classes applied to the content wrapper (e.g. padding). */
|
||||||
|
contentClassName?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +40,7 @@ export const CollapsibleTeamSection = ({
|
||||||
forceOpen,
|
forceOpen,
|
||||||
action,
|
action,
|
||||||
sectionId,
|
sectionId,
|
||||||
|
contentClassName,
|
||||||
children,
|
children,
|
||||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
@ -93,7 +96,11 @@ export const CollapsibleTeamSection = ({
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && <div className="mt-1.5 min-w-0 overflow-x-hidden pb-2 pl-2.5">{children}</div>}
|
{isOpen && (
|
||||||
|
<div className={`mt-1.5 min-w-0 overflow-x-hidden pb-2 ${contentClassName ?? ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,12 @@ export const TeamProvisioningBanner = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress.state === 'cancelled') {
|
if (progress.state === 'cancelled' || progress.state === 'disconnected') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isReady = progress.state === 'ready';
|
const isReady = progress.state === 'ready';
|
||||||
const isFailed = progress.state === 'failed';
|
const isFailed = progress.state === 'failed';
|
||||||
const isDisconnected = progress.state === 'disconnected';
|
|
||||||
const isActive =
|
const isActive =
|
||||||
progress.state === 'validating' ||
|
progress.state === 'validating' ||
|
||||||
progress.state === 'spawning' ||
|
progress.state === 'spawning' ||
|
||||||
|
|
@ -106,22 +105,6 @@ export const TeamProvisioningBanner = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDisconnected) {
|
|
||||||
return (
|
|
||||||
<div className="mb-3 flex items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
|
||||||
<p className="flex-1 text-xs text-amber-200">Team offline</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 shrink-0 border-amber-500/40 px-2 text-xs text-amber-300 hover:bg-amber-500/10 hover:text-amber-200"
|
|
||||||
onClick={() => setDismissed(true)}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
|
CARD_BG_ZEBRA,
|
||||||
CARD_BORDER_STYLE,
|
CARD_BORDER_STYLE,
|
||||||
CARD_ICON_MUTED,
|
CARD_ICON_MUTED,
|
||||||
CARD_TEXT_LIGHT,
|
CARD_TEXT_LIGHT,
|
||||||
|
|
@ -43,6 +44,8 @@ interface ActivityItemProps {
|
||||||
onReply?: (message: InboxMessage) => void;
|
onReply?: (message: InboxMessage) => void;
|
||||||
/** Called when a task ID link (e.g. #10) is clicked in message text. */
|
/** Called when a task ID link (e.g. #10) is clicked in message text. */
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
|
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||||
|
zebraShade?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStringField(obj: StructuredMessage, key: string): string | null {
|
function getStringField(obj: StructuredMessage, key: string): string | null {
|
||||||
|
|
@ -50,6 +53,12 @@ function getStringField(obj: StructuredMessage, key: string): string | null {
|
||||||
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check if a message renders as a compact noise row (idle, shutdown, etc.). */
|
||||||
|
export function isNoiseMessage(text: string): boolean {
|
||||||
|
const parsed = parseStructuredAgentMessage(text);
|
||||||
|
return parsed !== null && getNoiseLabel(parsed) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
function getNoiseLabel(parsed: StructuredMessage): string | null {
|
function getNoiseLabel(parsed: StructuredMessage): string | null {
|
||||||
const type = getStringField(parsed, 'type');
|
const type = getStringField(parsed, 'type');
|
||||||
|
|
||||||
|
|
@ -165,6 +174,7 @@ export const ActivityItem = ({
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onReply,
|
onReply,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
|
zebraShade,
|
||||||
}: ActivityItemProps): React.JSX.Element => {
|
}: ActivityItemProps): React.JSX.Element => {
|
||||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||||
const formattedRole = formatAgentRole(memberRole);
|
const formattedRole = formatAgentRole(memberRole);
|
||||||
|
|
@ -227,7 +237,12 @@ export const ActivityItem = ({
|
||||||
<article
|
<article
|
||||||
className="group overflow-hidden rounded-md"
|
className="group overflow-hidden rounded-md"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: rateLimited || isApiError ? 'var(--tool-result-error-bg)' : CARD_BG,
|
backgroundColor:
|
||||||
|
rateLimited || isApiError
|
||||||
|
? 'var(--tool-result-error-bg)'
|
||||||
|
: zebraShade
|
||||||
|
? CARD_BG_ZEBRA
|
||||||
|
: CARD_BG,
|
||||||
border:
|
border:
|
||||||
rateLimited || isApiError
|
rateLimited || isApiError
|
||||||
? '1px solid var(--tool-result-error-border)'
|
? '1px solid var(--tool-result-error-border)'
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import { ActivityItem } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
|
|
||||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ const MessageRowWithObserver = ({
|
||||||
recipientColor,
|
recipientColor,
|
||||||
isUnread,
|
isUnread,
|
||||||
isNew,
|
isNew,
|
||||||
|
zebraShade,
|
||||||
onMemberNameClick,
|
onMemberNameClick,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onReply,
|
onReply,
|
||||||
|
|
@ -48,6 +50,7 @@ const MessageRowWithObserver = ({
|
||||||
recipientColor?: string;
|
recipientColor?: string;
|
||||||
isUnread?: boolean;
|
isUnread?: boolean;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
zebraShade?: boolean;
|
||||||
onMemberNameClick?: (name: string) => void;
|
onMemberNameClick?: (name: string) => void;
|
||||||
onCreateTask?: (subject: string, description: string) => void;
|
onCreateTask?: (subject: string, description: string) => void;
|
||||||
onReply?: (message: InboxMessage) => void;
|
onReply?: (message: InboxMessage) => void;
|
||||||
|
|
@ -93,6 +96,7 @@ const MessageRowWithObserver = ({
|
||||||
memberColor={memberColor}
|
memberColor={memberColor}
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
isUnread={isUnread}
|
isUnread={isUnread}
|
||||||
|
zebraShade={zebraShade}
|
||||||
onMemberNameClick={onMemberNameClick}
|
onMemberNameClick={onMemberNameClick}
|
||||||
onCreateTask={onCreateTask}
|
onCreateTask={onCreateTask}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
|
|
@ -172,6 +176,18 @@ export const ActivityTimeline = ({
|
||||||
[messages, visibleCount, hiddenCount]
|
[messages, visibleCount, hiddenCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Zebra striping: alternate shade on non-noise (full card) messages only.
|
||||||
|
const zebraShadeSet = useMemo(() => {
|
||||||
|
const result = new Set<number>();
|
||||||
|
let cardCount = 0;
|
||||||
|
for (let i = 0; i < visibleMessages.length; i++) {
|
||||||
|
if (isNoiseMessage(visibleMessages[i].text)) continue;
|
||||||
|
if (cardCount % 2 === 1) result.add(i);
|
||||||
|
cardCount++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [visibleMessages]);
|
||||||
|
|
||||||
// Determine which messages are "new" (should animate).
|
// Determine which messages are "new" (should animate).
|
||||||
|
|
||||||
const newMessageKeys = useMemo(() => {
|
const newMessageKeys = useMemo(() => {
|
||||||
|
|
@ -251,6 +267,7 @@ export const ActivityTimeline = ({
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
isUnread={isUnread}
|
isUnread={isUnread}
|
||||||
isNew={newMessageKeys.has(messageKey)}
|
isNew={newMessageKeys.has(messageKey)}
|
||||||
|
zebraShade={zebraShadeSet.has(index)}
|
||||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||||
onCreateTask={onCreateTaskFromMessage}
|
onCreateTask={onCreateTaskFromMessage}
|
||||||
onReply={onReplyToMessage}
|
onReply={onReplyToMessage}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ export const TaskCommentsSection = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{visibleComments.map((comment) => (
|
{visibleComments.map((comment, index) => (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
className={[
|
className={[
|
||||||
|
|
@ -165,6 +165,11 @@ export const TaskCommentsSection = ({
|
||||||
? 'border border-blue-500/20 bg-blue-500/5'
|
? 'border border-blue-500/20 bg-blue-500/5'
|
||||||
: '',
|
: '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
|
style={
|
||||||
|
!comment.type && index % 2 === 1
|
||||||
|
? { backgroundColor: 'var(--card-bg-zebra)' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
||||||
|
|
|
||||||
|
|
@ -453,7 +453,12 @@ export const TaskDetailDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
|
<CollapsibleTeamSection
|
||||||
|
title="Description"
|
||||||
|
icon={<AlignLeft size={14} />}
|
||||||
|
contentClassName="pl-2.5"
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
{editingDescription ? (
|
{editingDescription ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -563,6 +568,7 @@ export const TaskDetailDialog = ({
|
||||||
title="Changes"
|
title="Changes"
|
||||||
icon={<FileDiff size={14} />}
|
icon={<FileDiff size={14} />}
|
||||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||||
|
contentClassName="pl-2.5"
|
||||||
defaultOpen={taskKnownHasChanges}
|
defaultOpen={taskKnownHasChanges}
|
||||||
>
|
>
|
||||||
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
||||||
|
|
@ -616,6 +622,7 @@ export const TaskDetailDialog = ({
|
||||||
</span>
|
</span>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
contentClassName="pl-2.5"
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<div className="min-w-0 overflow-hidden">
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
|
@ -766,6 +773,7 @@ export const TaskDetailDialog = ({
|
||||||
title="Status History"
|
title="Status History"
|
||||||
icon={<History size={14} />}
|
icon={<History size={14} />}
|
||||||
badge={currentTask.statusHistory.length}
|
badge={currentTask.statusHistory.length}
|
||||||
|
contentClassName="pl-2.5"
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
>
|
>
|
||||||
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
||||||
|
|
@ -781,6 +789,7 @@ export const TaskDetailDialog = ({
|
||||||
? (currentTask.comments?.length ?? 0)
|
? (currentTask.comments?.length ?? 0)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
contentClassName="pl-2.5"
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<TaskCommentInput
|
<TaskCommentInput
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,9 @@ export const WORKTREE_BADGE_TEXT = '#a1a1aa';
|
||||||
/** Card background */
|
/** Card background */
|
||||||
export const CARD_BG = 'var(--card-bg)';
|
export const CARD_BG = 'var(--card-bg)';
|
||||||
|
|
||||||
|
/** Card background — zebra-striped alternate row */
|
||||||
|
export const CARD_BG_ZEBRA = 'var(--card-bg-zebra)';
|
||||||
|
|
||||||
/** Card border color */
|
/** Card border color */
|
||||||
const CARD_BORDER = 'var(--card-border)';
|
const CARD_BORDER = 'var(--card-border)';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@
|
||||||
|
|
||||||
/* Subagent/Card styling */
|
/* Subagent/Card styling */
|
||||||
--card-bg: #0f1018;
|
--card-bg: #0f1018;
|
||||||
|
--card-bg-zebra: #14151f;
|
||||||
--card-border: #1c1d26;
|
--card-border: #1c1d26;
|
||||||
--card-header-bg: #151620;
|
--card-header-bg: #151620;
|
||||||
--card-header-hover: #1a1b26;
|
--card-header-hover: #1a1b26;
|
||||||
|
|
@ -380,6 +381,7 @@
|
||||||
|
|
||||||
/* Subagent/Card styling - Warm light mode */
|
/* Subagent/Card styling - Warm light mode */
|
||||||
--card-bg: #f9f9f7;
|
--card-bg: #f9f9f7;
|
||||||
|
--card-bg-zebra: #f2f1ee;
|
||||||
--card-border: #d5d3cf;
|
--card-border: #d5d3cf;
|
||||||
--card-header-bg: #f0efed;
|
--card-header-bg: #f0efed;
|
||||||
--card-header-hover: #eae9e6;
|
--card-header-hover: #eae9e6;
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ async function pollProvisioningStatus(
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
import type { AppConfig } from '@renderer/types/data';
|
||||||
import type {
|
import type {
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
|
|
@ -87,6 +88,7 @@ import type { StateCreator } from 'zustand';
|
||||||
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
|
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
|
||||||
// handles clarification-specific logic (e.g., marking tasks as needing user input).
|
// handles clarification-specific logic (e.g., marking tasks as needing user input).
|
||||||
const notifiedClarificationTaskKeys = new Set<string>();
|
const notifiedClarificationTaskKeys = new Set<string>();
|
||||||
|
const notifiedStatusChangeKeys = new Set<string>();
|
||||||
|
|
||||||
let isFirstFetchAllTasks = true;
|
let isFirstFetchAllTasks = true;
|
||||||
|
|
||||||
|
|
@ -127,6 +129,61 @@ function fireClarificationNotification(task: GlobalTask): void {
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectStatusChangeNotifications(
|
||||||
|
oldTasks: GlobalTask[],
|
||||||
|
newTasks: GlobalTask[],
|
||||||
|
config: AppConfig | null,
|
||||||
|
teamByName: Record<string, TeamSummary>
|
||||||
|
): void {
|
||||||
|
if (!config?.notifications?.notifyOnStatusChange) return;
|
||||||
|
if (!config.notifications.enabled) return;
|
||||||
|
|
||||||
|
const statuses = config.notifications.statusChangeStatuses ?? ['in_progress', 'completed'];
|
||||||
|
if (statuses.length === 0) return;
|
||||||
|
|
||||||
|
const onlySolo = config.notifications.statusChangeOnlySolo ?? true;
|
||||||
|
|
||||||
|
for (const task of newTasks) {
|
||||||
|
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
||||||
|
if (!oldTask) continue;
|
||||||
|
if (oldTask.status === task.status) continue;
|
||||||
|
|
||||||
|
if (onlySolo) {
|
||||||
|
const team = teamByName[task.teamName];
|
||||||
|
if (team && team.memberCount > 0) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!statuses.includes(task.status)) continue;
|
||||||
|
|
||||||
|
const key = `${task.teamName}:${task.id}:${task.status}`;
|
||||||
|
if (notifiedStatusChangeKeys.has(key)) continue;
|
||||||
|
notifiedStatusChangeKeys.add(key);
|
||||||
|
|
||||||
|
fireStatusChangeNotification(task, oldTask.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireStatusChangeNotification(task: GlobalTask, fromStatus: string): void {
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
deleted: 'Deleted',
|
||||||
|
};
|
||||||
|
const from = statusLabels[fromStatus] ?? fromStatus;
|
||||||
|
const to = statusLabels[task.status] ?? task.status;
|
||||||
|
|
||||||
|
void api.teams
|
||||||
|
?.showMessageNotification({
|
||||||
|
teamDisplayName: task.teamDisplayName,
|
||||||
|
from: task.owner ?? 'system',
|
||||||
|
to: 'user',
|
||||||
|
summary: `Task #${task.id}: ${from} → ${to}`,
|
||||||
|
body: task.subject,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
function mapSendMessageError(error: unknown): string {
|
function mapSendMessageError(error: unknown): string {
|
||||||
const message =
|
const message =
|
||||||
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
||||||
|
|
@ -380,12 +437,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const notifyOnClarifications =
|
const notifyOnClarifications =
|
||||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||||
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
||||||
|
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
||||||
} else {
|
} else {
|
||||||
// Initial load — seed the Set to prevent false notifications on next update
|
// Initial load — seed the Sets to prevent false notifications on next update
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.needsClarification === 'user') {
|
if (task.needsClarification === 'user') {
|
||||||
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||||
}
|
}
|
||||||
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,12 @@ export interface AppConfig {
|
||||||
notifyOnUserInbox: boolean;
|
notifyOnUserInbox: boolean;
|
||||||
/** Whether to show native OS notifications when a task needs user clarification */
|
/** Whether to show native OS notifications when a task needs user clarification */
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
|
/** Whether to show native OS notifications when a task status changes */
|
||||||
|
notifyOnStatusChange: boolean;
|
||||||
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
|
statusChangeOnlySolo: boolean;
|
||||||
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
statusChangeStatuses: string[];
|
||||||
/** Notification triggers - define when to generate notifications */
|
/** Notification triggers - define when to generate notifications */
|
||||||
triggers: NotificationTrigger[];
|
triggers: NotificationTrigger[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -109,33 +109,76 @@ describe('configValidation', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['notifyOnLeadInbox', 'notifyOnUserInbox', 'notifyOnClarifications'] as const)(
|
it.each([
|
||||||
'accepts boolean %s toggle',
|
'notifyOnLeadInbox',
|
||||||
(key) => {
|
'notifyOnUserInbox',
|
||||||
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
'notifyOnClarifications',
|
||||||
expect(resultOn.valid).toBe(true);
|
'notifyOnStatusChange',
|
||||||
if (resultOn.valid) {
|
'statusChangeOnlySolo',
|
||||||
expect(resultOn.data).toEqual({ [key]: true });
|
] as const)('accepts boolean %s toggle', (key) => {
|
||||||
}
|
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
||||||
|
expect(resultOn.valid).toBe(true);
|
||||||
const resultOff = validateConfigUpdatePayload('notifications', { [key]: false });
|
if (resultOn.valid) {
|
||||||
expect(resultOff.valid).toBe(true);
|
expect(resultOn.data).toEqual({ [key]: true });
|
||||||
if (resultOff.valid) {
|
|
||||||
expect(resultOff.data).toEqual({ [key]: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['notifyOnLeadInbox', 'notifyOnUserInbox', 'notifyOnClarifications'] as const)(
|
const resultOff = validateConfigUpdatePayload('notifications', { [key]: false });
|
||||||
'rejects non-boolean %s',
|
expect(resultOff.valid).toBe(true);
|
||||||
(key) => {
|
if (resultOff.valid) {
|
||||||
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });
|
expect(resultOff.data).toEqual({ [key]: false });
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
if (!result.valid) {
|
|
||||||
expect(result.error).toContain('boolean');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'notifyOnLeadInbox',
|
||||||
|
'notifyOnUserInbox',
|
||||||
|
'notifyOnClarifications',
|
||||||
|
'notifyOnStatusChange',
|
||||||
|
'statusChangeOnlySolo',
|
||||||
|
] as const)('rejects non-boolean %s', (key) => {
|
||||||
|
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.error).toContain('boolean');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid statusChangeStatuses string array', () => {
|
||||||
|
const result = validateConfigUpdatePayload('notifications', {
|
||||||
|
statusChangeStatuses: ['completed', 'in_progress'],
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
if (result.valid) {
|
||||||
|
expect(result.data).toEqual({ statusChangeStatuses: ['completed', 'in_progress'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts empty statusChangeStatuses array', () => {
|
||||||
|
const result = validateConfigUpdatePayload('notifications', {
|
||||||
|
statusChangeStatuses: [],
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-array statusChangeStatuses', () => {
|
||||||
|
const result = validateConfigUpdatePayload('notifications', {
|
||||||
|
statusChangeStatuses: true,
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.error).toContain('string[]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects statusChangeStatuses with non-string items', () => {
|
||||||
|
const result = validateConfigUpdatePayload('notifications', {
|
||||||
|
statusChangeStatuses: [42],
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.error).toContain('string[]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects out-of-range snoozeMinutes', () => {
|
it('rejects out-of-range snoozeMinutes', () => {
|
||||||
const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 });
|
const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 });
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,50 @@ describe('ChunkBuilder', () => {
|
||||||
expect(chunks[0].processes[0].id).toBe(subagent.id);
|
expect(chunks[0].processes[0].id).toBe(subagent.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT link subagent without parentTaskId (no timing fallback)', () => {
|
||||||
|
const taskId = 'task-456';
|
||||||
|
const messages = [
|
||||||
|
createMessage({
|
||||||
|
type: 'assistant',
|
||||||
|
timestamp: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Spawning' },
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: taskId,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'Do something' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: taskId,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'Do something' },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'Do something',
|
||||||
|
taskSubagentType: 'explore',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Subagent with NO parentTaskId — should NOT be linked even if time overlaps
|
||||||
|
const orphan = createSubagent({
|
||||||
|
parentTaskId: undefined,
|
||||||
|
startTime: new Date('2026-01-01T00:00:01Z'),
|
||||||
|
endTime: new Date('2026-01-01T00:00:30Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks = builder.buildChunks(messages, [orphan]);
|
||||||
|
expect(chunks).toHaveLength(1);
|
||||||
|
expect(isAIChunk(chunks[0])).toBe(true);
|
||||||
|
|
||||||
|
if (isAIChunk(chunks[0])) {
|
||||||
|
expect(chunks[0].processes).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
187
test/main/services/analysis/ProcessLinker.test.ts
Normal file
187
test/main/services/analysis/ProcessLinker.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
/**
|
||||||
|
* Tests for ProcessLinker — deterministic parentTaskId-only linking.
|
||||||
|
*
|
||||||
|
* Verifies:
|
||||||
|
* - Subagents with matching parentTaskId are linked to the chunk
|
||||||
|
* - Subagents without parentTaskId are NOT linked (no timing fallback)
|
||||||
|
* - Subagents with non-matching parentTaskId are NOT linked
|
||||||
|
* - Multiple subagents linked and sorted by startTime
|
||||||
|
* - Empty subagents array produces empty processes
|
||||||
|
* - Empty chunk (no Task calls) links nothing
|
||||||
|
* - Duplicate parentTaskId: both subagents linked
|
||||||
|
* - Already-populated chunk.processes is appended to
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { linkProcessesToAIChunk } from '../../../../src/main/services/analysis/ProcessLinker';
|
||||||
|
|
||||||
|
import type { EnhancedAIChunk, Process, SessionMetrics } from '../../../../src/main/types';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const baseMetrics: SessionMetrics = {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeChunk(taskIds: string[]): EnhancedAIChunk {
|
||||||
|
return {
|
||||||
|
type: 'ai',
|
||||||
|
responses: [
|
||||||
|
{
|
||||||
|
uuid: 'resp-1',
|
||||||
|
parentUuid: null,
|
||||||
|
type: 'assistant',
|
||||||
|
timestamp: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
content: [{ type: 'text', text: 'response' }],
|
||||||
|
isSidechain: false,
|
||||||
|
isMeta: false,
|
||||||
|
toolCalls: taskIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'do stuff' },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'do stuff',
|
||||||
|
taskSubagentType: 'general-purpose',
|
||||||
|
})),
|
||||||
|
toolResults: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
processes: [],
|
||||||
|
startTime: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
endTime: new Date('2026-01-01T00:01:00Z'),
|
||||||
|
durationMs: 60_000,
|
||||||
|
metrics: { ...baseMetrics },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSubagent(overrides: Partial<Process> & { id: string }): Process {
|
||||||
|
return {
|
||||||
|
filePath: `/path/${overrides.id}.jsonl`,
|
||||||
|
parentTaskId: undefined,
|
||||||
|
description: 'test',
|
||||||
|
subagentType: 'general-purpose',
|
||||||
|
isParallel: false,
|
||||||
|
startTime: new Date('2026-01-01T00:00:10Z'),
|
||||||
|
endTime: new Date('2026-01-01T00:00:50Z'),
|
||||||
|
durationMs: 40_000,
|
||||||
|
messages: [],
|
||||||
|
metrics: { ...baseMetrics },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('linkProcessesToAIChunk', () => {
|
||||||
|
it('links subagent with matching parentTaskId', () => {
|
||||||
|
const chunk = makeChunk(['task-1']);
|
||||||
|
const sub = makeSubagent({ id: 'agent-a', parentTaskId: 'task-1' });
|
||||||
|
|
||||||
|
linkProcessesToAIChunk(chunk, [sub]);
|
||||||
|
|
||||||
|
expect(chunk.processes).toHaveLength(1);
|
||||||
|
expect(chunk.processes[0].id).toBe('agent-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT link subagent without parentTaskId (no timing fallback)', () => {
|
||||||
|
const chunk = makeChunk(['task-1']);
|
||||||
|
const sub = makeSubagent({
|
||||||
|
id: 'orphan',
|
||||||
|
parentTaskId: undefined,
|
||||||
|
startTime: new Date('2026-01-01T00:00:30Z'), // within chunk time range
|
||||||
|
});
|
||||||
|
|
||||||
|
linkProcessesToAIChunk(chunk, [sub]);
|
||||||
|
|
||||||
|
expect(chunk.processes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT link subagent with non-matching parentTaskId', () => {
|
||||||
|
const chunk = makeChunk(['task-1']);
|
||||||
|
const sub = makeSubagent({ id: 'agent-b', parentTaskId: 'task-999' });
|
||||||
|
|
||||||
|
linkProcessesToAIChunk(chunk, [sub]);
|
||||||
|
|
||||||
|
expect(chunk.processes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links multiple subagents sorted by startTime', () => {
|
||||||
|
const chunk = makeChunk(['task-1', 'task-2']);
|
||||||
|
const sub1 = makeSubagent({
|
||||||
|
id: 'late',
|
||||||
|
parentTaskId: 'task-1',
|
||||||
|
startTime: new Date('2026-01-01T00:00:30Z'),
|
||||||
|
});
|
||||||
|
const sub2 = makeSubagent({
|
||||||
|
id: 'early',
|
||||||
|
parentTaskId: 'task-2',
|
||||||
|
startTime: new Date('2026-01-01T00:00:10Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
linkProcessesToAIChunk(chunk, [sub1, sub2]);
|
||||||
|
|
||||||
|
expect(chunk.processes).toHaveLength(2);
|
||||||
|
expect(chunk.processes[0].id).toBe('early');
|
||||||
|
expect(chunk.processes[1].id).toBe('late');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty subagents array', () => {
|
||||||
|
const chunk = makeChunk(['task-1']);
|
||||||
|
|
||||||
|
linkProcessesToAIChunk(chunk, []);
|
||||||
|
|
||||||
|
expect(chunk.processes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles chunk with no Task calls', () => {
|
||||||
|
const chunk = makeChunk([]);
|
||||||
|
const sub = makeSubagent({ id: 'agent-a', parentTaskId: 'task-1' });
|
||||||
|
|
||||||
|
linkProcessesToAIChunk(chunk, [sub]);
|
||||||
|
|
||||||
|
expect(chunk.processes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links both subagents when they share the same parentTaskId', () => {
|
||||||
|
const chunk = makeChunk(['task-1']);
|
||||||
|
const sub1 = makeSubagent({
|
||||||
|
id: 'a1',
|
||||||
|
parentTaskId: 'task-1',
|
||||||
|
startTime: new Date('2026-01-01T00:00:20Z'),
|
||||||
|
});
|
||||||
|
const sub2 = makeSubagent({
|
||||||
|
id: 'a2',
|
||||||
|
parentTaskId: 'task-1',
|
||||||
|
startTime: new Date('2026-01-01T00:00:10Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
linkProcessesToAIChunk(chunk, [sub1, sub2]);
|
||||||
|
|
||||||
|
expect(chunk.processes).toHaveLength(2);
|
||||||
|
expect(chunk.processes[0].id).toBe('a2'); // earlier
|
||||||
|
expect(chunk.processes[1].id).toBe('a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends to existing chunk.processes', () => {
|
||||||
|
const chunk = makeChunk(['task-1']);
|
||||||
|
const existing = makeSubagent({ id: 'existing', parentTaskId: 'task-0' });
|
||||||
|
chunk.processes.push(existing);
|
||||||
|
|
||||||
|
const sub = makeSubagent({ id: 'new', parentTaskId: 'task-1' });
|
||||||
|
linkProcessesToAIChunk(chunk, [sub]);
|
||||||
|
|
||||||
|
// existing + new, sorted by time
|
||||||
|
expect(chunk.processes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
343
test/main/services/discovery/SubagentResolver.linkType.test.ts
Normal file
343
test/main/services/discovery/SubagentResolver.linkType.test.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
/**
|
||||||
|
* Tests for SubagentResolver linkType assignment.
|
||||||
|
*
|
||||||
|
* Verifies:
|
||||||
|
* - Phase 1: agentId match → linkType 'agent-id'
|
||||||
|
* - Phase 2: teammate_id match → linkType 'team-member-id'
|
||||||
|
* - Unmatched subagents → linkType 'unlinked'
|
||||||
|
* - No positional fallback (Phase 3 removed)
|
||||||
|
* - propagateTeamMetadata → linkType 'parent-chain'
|
||||||
|
* - Different description but same teammate_id still matches
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { SubagentResolver } from '../../../../src/main/services/discovery/SubagentResolver';
|
||||||
|
|
||||||
|
import type { ParsedMessage, Process } from '../../../../src/main/types';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function msg(overrides: Partial<ParsedMessage>): ParsedMessage {
|
||||||
|
return {
|
||||||
|
uuid: `msg-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
parentUuid: null,
|
||||||
|
type: 'user',
|
||||||
|
timestamp: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
content: '',
|
||||||
|
isSidechain: false,
|
||||||
|
isMeta: false,
|
||||||
|
toolCalls: [],
|
||||||
|
toolResults: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagent(overrides: Partial<Process> & { id: string }): Process {
|
||||||
|
return {
|
||||||
|
filePath: `/path/${overrides.id}.jsonl`,
|
||||||
|
parentTaskId: undefined,
|
||||||
|
isParallel: false,
|
||||||
|
startTime: new Date('2026-01-01T00:00:05Z'),
|
||||||
|
endTime: new Date('2026-01-01T00:00:55Z'),
|
||||||
|
durationMs: 50_000,
|
||||||
|
messages: [],
|
||||||
|
metrics: {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('SubagentResolver.linkType', () => {
|
||||||
|
const resolver = new SubagentResolver();
|
||||||
|
|
||||||
|
// Access private method via prototype for testing
|
||||||
|
const linkToTaskCalls = (
|
||||||
|
resolver as unknown as { linkToTaskCalls: Function }
|
||||||
|
).linkToTaskCalls.bind(resolver);
|
||||||
|
|
||||||
|
describe('Phase 1: agent-id matching', () => {
|
||||||
|
it('sets linkType to agent-id when agentId matches subagent id', () => {
|
||||||
|
const subagentId = 'abc-123-def';
|
||||||
|
const taskCallId = 'task-call-1';
|
||||||
|
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
msg({
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'spawning' },
|
||||||
|
{ type: 'tool_use', id: taskCallId, name: 'Task', input: { prompt: 'explore' } },
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: taskCallId,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'explore' },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'explore',
|
||||||
|
taskSubagentType: 'Explore',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
// Tool result with agentId linking back to subagent
|
||||||
|
msg({
|
||||||
|
type: 'user',
|
||||||
|
isMeta: true,
|
||||||
|
content: [{ type: 'tool_result', tool_use_id: taskCallId, content: 'done' }],
|
||||||
|
toolResults: [{ toolUseId: taskCallId, content: 'done' }],
|
||||||
|
sourceToolUseID: taskCallId,
|
||||||
|
toolUseResult: { agentId: subagentId },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const sub = subagent({ id: subagentId });
|
||||||
|
linkToTaskCalls([sub], messages);
|
||||||
|
|
||||||
|
expect(sub.linkType).toBe('agent-id');
|
||||||
|
expect(sub.parentTaskId).toBe(taskCallId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Phase 2: team-member-id matching', () => {
|
||||||
|
it('sets linkType to team-member-id when teammate_id matches input.name', () => {
|
||||||
|
const taskCallId = 'task-call-2';
|
||||||
|
const memberName = 'researcher';
|
||||||
|
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
msg({
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: taskCallId,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'research', team_name: 'my-team', name: memberName },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: taskCallId,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'research', team_name: 'my-team', name: memberName },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'research stuff',
|
||||||
|
taskSubagentType: 'general-purpose',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const sub = subagent({
|
||||||
|
id: 'team-file-xyz',
|
||||||
|
messages: [
|
||||||
|
msg({
|
||||||
|
type: 'user',
|
||||||
|
content: `<teammate-message teammate_id="${memberName}" color="#ff0000" summary="do research">Hello</teammate-message>`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
linkToTaskCalls([sub], messages);
|
||||||
|
|
||||||
|
expect(sub.linkType).toBe('team-member-id');
|
||||||
|
expect(sub.parentTaskId).toBe(taskCallId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches by teammate_id even when descriptions differ', () => {
|
||||||
|
const taskCallId = 'task-call-3';
|
||||||
|
const memberName = 'coder';
|
||||||
|
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
msg({
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: taskCallId,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'write code', team_name: 'team-x', name: memberName },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: taskCallId,
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'write code', team_name: 'team-x', name: memberName },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'COMPLETELY DIFFERENT description',
|
||||||
|
taskSubagentType: 'general-purpose',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const sub = subagent({
|
||||||
|
id: 'team-file-abc',
|
||||||
|
messages: [
|
||||||
|
msg({
|
||||||
|
type: 'user',
|
||||||
|
content: `<teammate-message teammate_id="${memberName}" color="#00ff00" summary="some other summary">Content</teammate-message>`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
linkToTaskCalls([sub], messages);
|
||||||
|
|
||||||
|
expect(sub.linkType).toBe('team-member-id');
|
||||||
|
expect(sub.parentTaskId).toBe(taskCallId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unlinked subagents', () => {
|
||||||
|
it('sets linkType to unlinked when no match found', () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
msg({
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'task-call-x',
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'something' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: 'task-call-x',
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'something' },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'something',
|
||||||
|
taskSubagentType: 'Explore',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Subagent with no matching agentId and no teammate_id
|
||||||
|
const sub = subagent({
|
||||||
|
id: 'orphan-agent',
|
||||||
|
messages: [msg({ type: 'user', content: 'plain message without teammate tag' })],
|
||||||
|
});
|
||||||
|
|
||||||
|
linkToTaskCalls([sub], messages);
|
||||||
|
|
||||||
|
expect(sub.linkType).toBe('unlinked');
|
||||||
|
expect(sub.parentTaskId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT use positional fallback (Phase 3 removed)', () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
msg({
|
||||||
|
type: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'task-1',
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'first task' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'task-2',
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'second task' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'first task' },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'first',
|
||||||
|
taskSubagentType: 'Explore',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
name: 'Task',
|
||||||
|
input: { prompt: 'second task' },
|
||||||
|
isTask: true,
|
||||||
|
taskDescription: 'second',
|
||||||
|
taskSubagentType: 'Plan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Two subagents, neither has agentId match or teammate_id
|
||||||
|
const sub1 = subagent({
|
||||||
|
id: 'sub-1',
|
||||||
|
startTime: new Date('2026-01-01T00:00:10Z'),
|
||||||
|
});
|
||||||
|
const sub2 = subagent({
|
||||||
|
id: 'sub-2',
|
||||||
|
startTime: new Date('2026-01-01T00:00:20Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
linkToTaskCalls([sub1, sub2], messages);
|
||||||
|
|
||||||
|
// In the old code, sub1 would get task-1 and sub2 would get task-2 by position.
|
||||||
|
// Now both should be unlinked.
|
||||||
|
expect(sub1.linkType).toBe('unlinked');
|
||||||
|
expect(sub2.linkType).toBe('unlinked');
|
||||||
|
expect(sub1.parentTaskId).toBeUndefined();
|
||||||
|
expect(sub2.parentTaskId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('propagateTeamMetadata linkType', () => {
|
||||||
|
it('propagates parent-chain linkType from ancestor', () => {
|
||||||
|
// Access private method
|
||||||
|
const propagate = (
|
||||||
|
resolver as unknown as { propagateTeamMetadata: Function }
|
||||||
|
).propagateTeamMetadata.bind(resolver);
|
||||||
|
|
||||||
|
const parentId = 'parent-last-uuid';
|
||||||
|
|
||||||
|
const parent = subagent({
|
||||||
|
id: 'parent-agent',
|
||||||
|
parentTaskId: 'task-parent',
|
||||||
|
linkType: 'team-member-id',
|
||||||
|
messages: [
|
||||||
|
msg({
|
||||||
|
uuid: parentId,
|
||||||
|
type: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'done' }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
parent.team = { teamName: 'my-team', memberName: 'worker', memberColor: '#ff0' };
|
||||||
|
|
||||||
|
const child = subagent({
|
||||||
|
id: 'child-agent',
|
||||||
|
messages: [
|
||||||
|
msg({
|
||||||
|
type: 'user',
|
||||||
|
parentUuid: parentId,
|
||||||
|
content: 'continuation',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
propagate([parent, child]);
|
||||||
|
|
||||||
|
expect(child.team).toEqual(parent.team);
|
||||||
|
expect(child.linkType).toBe('parent-chain');
|
||||||
|
expect(child.parentTaskId).toBe('task-parent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue