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',
|
||||
'snoozedUntil',
|
||||
'snoozeMinutes',
|
||||
'notifyOnStatusChange',
|
||||
'statusChangeOnlySolo',
|
||||
'statusChangeStatuses',
|
||||
'triggers',
|
||||
];
|
||||
|
||||
|
|
@ -162,6 +165,24 @@ function validateNotificationsSection(
|
|||
}
|
||||
result.notifyOnClarifications = value;
|
||||
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':
|
||||
if (!isStringArray(value)) {
|
||||
return { valid: false, error: `notifications.${key} must be a string[]` };
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ export function buildGroups(messages: ParsedMessage[], subagents: Process[]): Co
|
|||
subagents
|
||||
);
|
||||
|
||||
// Link subagents to this group
|
||||
const groupSubagents = linkSubagentsToGroup(userMsg, nextUserMsg, subagents);
|
||||
// Link subagents to this group via deterministic parentTaskId matching
|
||||
const groupSubagents = linkSubagentsToGroup(aiResponses, subagents);
|
||||
|
||||
// Calculate metrics
|
||||
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(
|
||||
userMsg: ParsedMessage,
|
||||
nextUserMsg: ParsedMessage | undefined,
|
||||
allSubagents: Process[]
|
||||
): Process[] {
|
||||
const groupSubagents: Process[] = [];
|
||||
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);
|
||||
function linkSubagentsToGroup(aiResponses: ParsedMessage[], allSubagents: Process[]): Process[] {
|
||||
const groupTaskIds = new Set<string>();
|
||||
for (const msg of aiResponses) {
|
||||
for (const toolCall of msg.toolCalls) {
|
||||
if (toolCall.isTask) {
|
||||
groupTaskIds.add(toolCall.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groupSubagents;
|
||||
return allSubagents
|
||||
.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.
|
||||
*
|
||||
* Uses a two-tier linking strategy:
|
||||
* 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call
|
||||
* 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.
|
||||
* Uses deterministic parentTaskId matching only. If a subagent has no parentTaskId
|
||||
* or it doesn't match any Task call in the chunk, the subagent is NOT linked.
|
||||
* No timing-based or positional fallbacks — avoids false positives.
|
||||
*/
|
||||
|
||||
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:
|
||||
* 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call
|
||||
* 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.
|
||||
* Only links subagents whose parentTaskId matches a Task tool_use ID in the chunk.
|
||||
* Subagents without parentTaskId or with non-matching parentTaskId are skipped.
|
||||
*/
|
||||
export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Process[]): void {
|
||||
// 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
|
||||
const linkedSubagentIds = new Set<string>();
|
||||
|
||||
// Primary linking: Match subagents to Task calls by parentTaskId
|
||||
// Deterministic linking: Match subagents to Task calls by parentTaskId only
|
||||
for (const subagent of subagents) {
|
||||
if (subagent.parentTaskId && chunkTaskIds.has(subagent.parentTaskId)) {
|
||||
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.
|
||||
* Returns the summary string if found, undefined otherwise.
|
||||
* Used to match team member files to their spawning Task calls.
|
||||
* Extract the teammate_id attribute from the first <teammate-message> tag in a subagent's messages.
|
||||
* Returns the teammate_id string if found, undefined otherwise.
|
||||
* 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');
|
||||
if (!firstUserMessage) return undefined;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
|
|
@ -250,38 +250,41 @@ export class SubagentResolver {
|
|||
if (!taskCall) continue;
|
||||
|
||||
this.enrichSubagentFromTask(subagent, taskCall);
|
||||
subagent.linkType = 'agent-id';
|
||||
matchedSubagentIds.add(subagent.id);
|
||||
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.
|
||||
// Instead, match by comparing the Task description to the summary attribute in the
|
||||
// subagent file's first <teammate-message> tag.
|
||||
// Instead, match by comparing Task call input.name to the teammate_id XML attribute
|
||||
// in the subagent file's first <teammate-message> tag.
|
||||
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) {
|
||||
// Pre-extract summaries from unmatched subagent files
|
||||
const subagentSummaries = new Map<string, string>();
|
||||
// Pre-extract teammate_ids from unmatched subagent files
|
||||
const subagentTeammateIds = new Map<string, string>();
|
||||
for (const subagent of subagents) {
|
||||
if (matchedSubagentIds.has(subagent.id)) continue;
|
||||
const summary = this.extractTeamMessageSummary(subagent.messages);
|
||||
if (summary) {
|
||||
subagentSummaries.set(subagent.id, summary);
|
||||
const teammateId = this.extractTeammateId(subagent.messages);
|
||||
if (teammateId) {
|
||||
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) {
|
||||
const description = taskCall.taskDescription;
|
||||
if (!description) continue;
|
||||
const inputName = taskCall.input?.name as string;
|
||||
|
||||
let bestMatch: Process | undefined;
|
||||
for (const subagent of subagents) {
|
||||
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) {
|
||||
bestMatch = subagent;
|
||||
}
|
||||
|
|
@ -289,22 +292,18 @@ export class SubagentResolver {
|
|||
|
||||
if (bestMatch) {
|
||||
this.enrichSubagentFromTask(bestMatch, taskCall);
|
||||
bestMatch.linkType = 'team-member-id';
|
||||
matchedSubagentIds.add(bestMatch.id);
|
||||
matchedTaskIds.add(taskCall.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Positional fallback for remaining unmatched non-team subagents (no wrap-around)
|
||||
const unmatchedSubagents = [...subagents]
|
||||
.filter((s) => !matchedSubagentIds.has(s.id))
|
||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
||||
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]);
|
||||
// Mark remaining unmatched subagents as unlinked (no Phase 3 positional fallback)
|
||||
for (const subagent of subagents) {
|
||||
if (!matchedSubagentIds.has(subagent.id) && !subagent.linkType) {
|
||||
subagent.linkType = 'unlinked';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +397,7 @@ export class SubagentResolver {
|
|||
subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId;
|
||||
subagent.description = subagent.description ?? ancestor.description;
|
||||
subagent.subagentType = subagent.subagentType ?? ancestor.subagentType;
|
||||
subagent.linkType = subagent.linkType ?? (ancestor.linkType ? 'parent-chain' : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ export interface NotificationConfig {
|
|||
notifyOnUserInbox: boolean;
|
||||
/** Whether to show native OS notifications when a task needs user clarification */
|
||||
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 */
|
||||
triggers: NotificationTrigger[];
|
||||
}
|
||||
|
|
@ -254,6 +260,9 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
notifyOnStatusChange: true,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: DEFAULT_TRIGGERS,
|
||||
},
|
||||
general: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
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 { TeamConfigReader } from './TeamConfigReader';
|
||||
|
|
@ -104,7 +106,11 @@ export class ChangeExtractorService {
|
|||
|
||||
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
|
||||
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);
|
||||
if (logRefs.length === 0) {
|
||||
return this.emptyTaskChangeSet(teamName, taskId);
|
||||
|
|
@ -163,6 +169,24 @@ export class ChangeExtractorService {
|
|||
|
||||
// ---- 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 из конфига команды */
|
||||
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -176,8 +176,10 @@ export class TeamMemberLogsFinder {
|
|||
typeof normalizedOwner === 'string' &&
|
||||
normalizedOwner.length > 0 &&
|
||||
normalizedOwner.toLowerCase() === leadMemberName.toLowerCase();
|
||||
const ownerRelevantStatus =
|
||||
options?.status === 'in_progress' || options?.status === 'completed';
|
||||
const includeOwnerSessions =
|
||||
options?.status === 'in_progress' &&
|
||||
ownerRelevantStatus &&
|
||||
typeof normalizedOwner === 'string' &&
|
||||
normalizedOwner.length > 0 &&
|
||||
!isLeadOwner;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface Process {
|
|||
isParallel: boolean;
|
||||
/** The tool_use ID of the Task call that spawned this */
|
||||
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 */
|
||||
isOngoing?: boolean;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ export const SettingsView = (): React.JSX.Element | null => {
|
|||
onAddTrigger={handlers.handleAddTrigger}
|
||||
onUpdateTrigger={handlers.handleUpdateTrigger}
|
||||
onRemoveTrigger={handlers.handleRemoveTrigger}
|
||||
onStatusChangeStatusesUpdate={handlers.handleStatusChangeStatusesUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useStore } from '@renderer/store';
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { AppConfig } from '@renderer/types/data';
|
||||
import type { TeamTaskStatus } from '@shared/types';
|
||||
|
||||
// Get the setState function from the store to update appConfig globally
|
||||
const setStoreState = useStore.setState;
|
||||
|
|
@ -45,6 +46,9 @@ export interface SafeConfig {
|
|||
notifyOnLeadInbox: boolean;
|
||||
notifyOnUserInbox: boolean;
|
||||
notifyOnClarifications: boolean;
|
||||
notifyOnStatusChange: boolean;
|
||||
statusChangeOnlySolo: boolean;
|
||||
statusChangeStatuses: TeamTaskStatus[];
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
};
|
||||
display: {
|
||||
|
|
@ -175,6 +179,11 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
||||
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? 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 ?? [],
|
||||
},
|
||||
display: {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ interface SettingsHandlers {
|
|||
|
||||
// Notification handlers
|
||||
handleNotificationToggle: (key: keyof AppConfig['notifications'], value: boolean) => void;
|
||||
handleStatusChangeStatusesUpdate: (statuses: string[]) => void;
|
||||
handleSnooze: (minutes: number) => Promise<void>;
|
||||
handleClearSnooze: () => Promise<void>;
|
||||
handleAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
|
||||
|
|
@ -104,6 +105,13 @@ export function useSettingsHandlers({
|
|||
[updateConfig]
|
||||
);
|
||||
|
||||
const handleStatusChangeStatusesUpdate = useCallback(
|
||||
(statuses: string[]) => {
|
||||
void updateConfig('notifications', { statusChangeStatuses: statuses });
|
||||
},
|
||||
[updateConfig]
|
||||
);
|
||||
|
||||
const handleSnooze = useCallback(
|
||||
async (minutes: number) => {
|
||||
try {
|
||||
|
|
@ -290,6 +298,9 @@ export function useSettingsHandlers({
|
|||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
notifyOnStatusChange: true,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: defaultTriggers,
|
||||
},
|
||||
general: {
|
||||
|
|
@ -390,6 +401,7 @@ export function useSettingsHandlers({
|
|||
handleLanguageChange,
|
||||
handleDefaultTabChange,
|
||||
handleNotificationToggle,
|
||||
handleStatusChangeStatusesUpdate,
|
||||
handleSnooze,
|
||||
handleClearSnooze,
|
||||
handleAddIgnoredRepository,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { NotificationTriggerSettings } from '../NotificationTriggerSettings';
|
|||
|
||||
import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig';
|
||||
import type { NotificationTrigger } from '@renderer/types/data';
|
||||
import type { TeamTaskStatus } from '@shared/types';
|
||||
|
||||
// Snooze duration options
|
||||
const SNOOZE_OPTIONS = [
|
||||
|
|
@ -38,9 +39,12 @@ interface NotificationsSectionProps {
|
|||
| 'includeSubagentErrors'
|
||||
| 'notifyOnLeadInbox'
|
||||
| 'notifyOnUserInbox'
|
||||
| 'notifyOnClarifications',
|
||||
| 'notifyOnClarifications'
|
||||
| 'notifyOnStatusChange'
|
||||
| 'statusChangeOnlySolo',
|
||||
value: boolean
|
||||
) => void;
|
||||
readonly onStatusChangeStatusesUpdate: (statuses: TeamTaskStatus[]) => void;
|
||||
readonly onSnooze: (minutes: number) => Promise<void>;
|
||||
readonly onClearSnooze: () => Promise<void>;
|
||||
readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
|
||||
|
|
@ -67,6 +71,7 @@ export const NotificationsSection = ({
|
|||
onAddTrigger,
|
||||
onUpdateTrigger,
|
||||
onRemoveTrigger,
|
||||
onStatusChangeStatusesUpdate,
|
||||
}: NotificationsSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -166,6 +171,40 @@ 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={
|
||||
|
|
@ -230,3 +269,46 @@ export const NotificationsSection = ({
|
|||
</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;
|
||||
/** Stable identifier used for programmatic section navigation. */
|
||||
sectionId?: string;
|
||||
/** Extra classes applied to the content wrapper (e.g. padding). */
|
||||
contentClassName?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +40,7 @@ export const CollapsibleTeamSection = ({
|
|||
forceOpen,
|
||||
action,
|
||||
sectionId,
|
||||
contentClassName,
|
||||
children,
|
||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
|
@ -93,7 +96,11 @@ export const CollapsibleTeamSection = ({
|
|||
</div>
|
||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -55,13 +55,12 @@ export const TeamProvisioningBanner = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (progress.state === 'cancelled') {
|
||||
if (progress.state === 'cancelled' || progress.state === 'disconnected') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isReady = progress.state === 'ready';
|
||||
const isFailed = progress.state === 'failed';
|
||||
const isDisconnected = progress.state === 'disconnected';
|
||||
const isActive =
|
||||
progress.state === 'validating' ||
|
||||
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) {
|
||||
return (
|
||||
<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 {
|
||||
CARD_BG,
|
||||
CARD_BG_ZEBRA,
|
||||
CARD_BORDER_STYLE,
|
||||
CARD_ICON_MUTED,
|
||||
CARD_TEXT_LIGHT,
|
||||
|
|
@ -43,6 +44,8 @@ interface ActivityItemProps {
|
|||
onReply?: (message: InboxMessage) => void;
|
||||
/** Called when a task ID link (e.g. #10) is clicked in message text. */
|
||||
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 {
|
||||
|
|
@ -50,6 +53,12 @@ function getStringField(obj: StructuredMessage, key: string): string | 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 {
|
||||
const type = getStringField(parsed, 'type');
|
||||
|
||||
|
|
@ -165,6 +174,7 @@ export const ActivityItem = ({
|
|||
onCreateTask,
|
||||
onReply,
|
||||
onTaskIdClick,
|
||||
zebraShade,
|
||||
}: ActivityItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||
const formattedRole = formatAgentRole(memberRole);
|
||||
|
|
@ -227,7 +237,12 @@ export const ActivityItem = ({
|
|||
<article
|
||||
className="group overflow-hidden rounded-md"
|
||||
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:
|
||||
rateLimited || isApiError
|
||||
? '1px solid var(--tool-result-error-border)'
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import { ActivityItem } from './ActivityItem';
|
||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||
|
||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ const MessageRowWithObserver = ({
|
|||
recipientColor,
|
||||
isUnread,
|
||||
isNew,
|
||||
zebraShade,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
|
|
@ -48,6 +50,7 @@ const MessageRowWithObserver = ({
|
|||
recipientColor?: string;
|
||||
isUnread?: boolean;
|
||||
isNew?: boolean;
|
||||
zebraShade?: boolean;
|
||||
onMemberNameClick?: (name: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -93,6 +96,7 @@ const MessageRowWithObserver = ({
|
|||
memberColor={memberColor}
|
||||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
zebraShade={zebraShade}
|
||||
onMemberNameClick={onMemberNameClick}
|
||||
onCreateTask={onCreateTask}
|
||||
onReply={onReply}
|
||||
|
|
@ -172,6 +176,18 @@ export const ActivityTimeline = ({
|
|||
[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).
|
||||
|
||||
const newMessageKeys = useMemo(() => {
|
||||
|
|
@ -251,6 +267,7 @@ export const ActivityTimeline = ({
|
|||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
isNew={newMessageKeys.has(messageKey)}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleComments.map((comment) => (
|
||||
{visibleComments.map((comment, index) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={[
|
||||
|
|
@ -165,6 +165,11 @@ export const TaskCommentsSection = ({
|
|||
? 'border border-blue-500/20 bg-blue-500/5'
|
||||
: '',
|
||||
].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)]">
|
||||
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
||||
|
|
|
|||
|
|
@ -453,7 +453,12 @@ export const TaskDetailDialog = ({
|
|||
) : null}
|
||||
|
||||
{/* Description */}
|
||||
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
|
||||
<CollapsibleTeamSection
|
||||
title="Description"
|
||||
icon={<AlignLeft size={14} />}
|
||||
contentClassName="pl-2.5"
|
||||
defaultOpen
|
||||
>
|
||||
{editingDescription ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -563,6 +568,7 @@ export const TaskDetailDialog = ({
|
|||
title="Changes"
|
||||
icon={<FileDiff size={14} />}
|
||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||
contentClassName="pl-2.5"
|
||||
defaultOpen={taskKnownHasChanges}
|
||||
>
|
||||
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
||||
|
|
@ -616,6 +622,7 @@ export const TaskDetailDialog = ({
|
|||
</span>
|
||||
) : null
|
||||
}
|
||||
contentClassName="pl-2.5"
|
||||
defaultOpen
|
||||
>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
|
|
@ -766,6 +773,7 @@ export const TaskDetailDialog = ({
|
|||
title="Status History"
|
||||
icon={<History size={14} />}
|
||||
badge={currentTask.statusHistory.length}
|
||||
contentClassName="pl-2.5"
|
||||
defaultOpen={false}
|
||||
>
|
||||
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
||||
|
|
@ -781,6 +789,7 @@ export const TaskDetailDialog = ({
|
|||
? (currentTask.comments?.length ?? 0)
|
||||
: undefined
|
||||
}
|
||||
contentClassName="pl-2.5"
|
||||
defaultOpen
|
||||
>
|
||||
<TaskCommentInput
|
||||
|
|
|
|||
|
|
@ -169,6 +169,9 @@ export const WORKTREE_BADGE_TEXT = '#a1a1aa';
|
|||
/** Card background */
|
||||
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 */
|
||||
const CARD_BORDER = 'var(--card-border)';
|
||||
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@
|
|||
|
||||
/* Subagent/Card styling */
|
||||
--card-bg: #0f1018;
|
||||
--card-bg-zebra: #14151f;
|
||||
--card-border: #1c1d26;
|
||||
--card-header-bg: #151620;
|
||||
--card-header-hover: #1a1b26;
|
||||
|
|
@ -380,6 +381,7 @@
|
|||
|
||||
/* Subagent/Card styling - Warm light mode */
|
||||
--card-bg: #f9f9f7;
|
||||
--card-bg-zebra: #f2f1ee;
|
||||
--card-border: #d5d3cf;
|
||||
--card-header-bg: #f0efed;
|
||||
--card-header-hover: #eae9e6;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ async function pollProvisioningStatus(
|
|||
}
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { AppConfig } from '@renderer/types/data';
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
CreateTaskRequest,
|
||||
|
|
@ -87,6 +88,7 @@ import type { StateCreator } from 'zustand';
|
|||
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
|
||||
// handles clarification-specific logic (e.g., marking tasks as needing user input).
|
||||
const notifiedClarificationTaskKeys = new Set<string>();
|
||||
const notifiedStatusChangeKeys = new Set<string>();
|
||||
|
||||
let isFirstFetchAllTasks = true;
|
||||
|
||||
|
|
@ -127,6 +129,61 @@ function fireClarificationNotification(task: GlobalTask): void {
|
|||
.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 {
|
||||
const message =
|
||||
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
||||
|
|
@ -380,12 +437,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const notifyOnClarifications =
|
||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
||||
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
|
||||
} 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) {
|
||||
if (task.needsClarification === 'user') {
|
||||
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||
}
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -253,6 +253,12 @@ export interface AppConfig {
|
|||
notifyOnUserInbox: boolean;
|
||||
/** Whether to show native OS notifications when a task needs user clarification */
|
||||
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 */
|
||||
triggers: NotificationTrigger[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,33 +109,76 @@ describe('configValidation', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each(['notifyOnLeadInbox', 'notifyOnUserInbox', 'notifyOnClarifications'] as const)(
|
||||
'accepts boolean %s toggle',
|
||||
(key) => {
|
||||
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
||||
expect(resultOn.valid).toBe(true);
|
||||
if (resultOn.valid) {
|
||||
expect(resultOn.data).toEqual({ [key]: true });
|
||||
}
|
||||
|
||||
const resultOff = validateConfigUpdatePayload('notifications', { [key]: false });
|
||||
expect(resultOff.valid).toBe(true);
|
||||
if (resultOff.valid) {
|
||||
expect(resultOff.data).toEqual({ [key]: false });
|
||||
}
|
||||
it.each([
|
||||
'notifyOnLeadInbox',
|
||||
'notifyOnUserInbox',
|
||||
'notifyOnClarifications',
|
||||
'notifyOnStatusChange',
|
||||
'statusChangeOnlySolo',
|
||||
] as const)('accepts boolean %s toggle', (key) => {
|
||||
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
||||
expect(resultOn.valid).toBe(true);
|
||||
if (resultOn.valid) {
|
||||
expect(resultOn.data).toEqual({ [key]: true });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['notifyOnLeadInbox', 'notifyOnUserInbox', 'notifyOnClarifications'] 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');
|
||||
}
|
||||
const resultOff = validateConfigUpdatePayload('notifications', { [key]: false });
|
||||
expect(resultOff.valid).toBe(true);
|
||||
if (resultOff.valid) {
|
||||
expect(resultOff.data).toEqual({ [key]: false });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 });
|
||||
|
|
|
|||
|
|
@ -385,6 +385,50 @@ describe('ChunkBuilder', () => {
|
|||
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