feat: add team launched notification and related configuration

- Introduced a new notification setting for when a team finishes launching, enhancing user awareness of team readiness.
- Updated the configuration interface and default settings to include the new notification option.
- Implemented logic to trigger the "team launched" notification within the team provisioning service.
- Enhanced validation to ensure the new setting accepts boolean values.
- Updated relevant UI components to allow users to toggle the new notification setting.
This commit is contained in:
iliya 2026-03-17 15:13:37 +02:00
parent 5ae1d4164a
commit 6ace707653
17 changed files with 165 additions and 69 deletions

View file

@ -117,6 +117,7 @@ function validateNotificationsSection(
'notifyOnTaskCreated', 'notifyOnTaskCreated',
'notifyOnAllTasksCompleted', 'notifyOnAllTasksCompleted',
'notifyOnCrossTeamMessage', 'notifyOnCrossTeamMessage',
'notifyOnTeamLaunched',
'statusChangeOnlySolo', 'statusChangeOnlySolo',
'statusChangeStatuses', 'statusChangeStatuses',
'triggers', 'triggers',
@ -199,6 +200,12 @@ function validateNotificationsSection(
} }
result.notifyOnCrossTeamMessage = value; result.notifyOnCrossTeamMessage = value;
break; break;
case 'notifyOnTeamLaunched':
if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` };
}
result.notifyOnTeamLaunched = value;
break;
case 'statusChangeOnlySolo': case 'statusChangeOnlySolo':
if (typeof value !== 'boolean') { if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` }; return { valid: false, error: `notifications.${key} must be a boolean` };

View file

@ -14,6 +14,7 @@ import { randomUUID } from 'crypto';
import { type ExtractedToolResult } from '../analysis/ToolResultExtractor'; import { type ExtractedToolResult } from '../analysis/ToolResultExtractor';
import type { TriggerColor } from '@shared/constants/triggerColors'; import type { TriggerColor } from '@shared/constants/triggerColors';
import type { TeamEventType } from '@shared/types/notifications';
// ============================================================================= // =============================================================================
// Types // Types
@ -52,18 +53,7 @@ export interface DetectedError {
/** Notification domain: 'error' (default/undefined) or 'team' */ /** Notification domain: 'error' (default/undefined) or 'team' */
category?: 'error' | 'team'; category?: 'error' | 'team';
/** For team notifications: specific event sub-type */ /** For team notifications: specific event sub-type */
teamEventType?: teamEventType?: TeamEventType;
| 'rate_limit'
| 'lead_inbox'
| 'user_inbox'
| 'task_clarification'
| 'task_status_change'
| 'task_comment'
| 'task_created'
| 'all_tasks_completed'
| 'cross_team_message'
| 'schedule_completed'
| 'schedule_failed';
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
dedupeKey?: string; dedupeKey?: string;
/** Additional context about the error */ /** Additional context about the error */

View file

@ -56,6 +56,8 @@ export interface NotificationConfig {
notifyOnAllTasksCompleted: boolean; notifyOnAllTasksCompleted: boolean;
/** Whether to show native OS notifications for cross-team messages */ /** Whether to show native OS notifications for cross-team messages */
notifyOnCrossTeamMessage: boolean; notifyOnCrossTeamMessage: boolean;
/** Whether to show native OS notifications when a team finishes launching */
notifyOnTeamLaunched: boolean;
/** Only notify on status changes in solo teams (no teammates) */ /** Only notify on status changes in solo teams (no teammates) */
statusChangeOnlySolo: boolean; statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
@ -273,6 +275,7 @@ const DEFAULT_CONFIG: AppConfig = {
notifyOnTaskCreated: true, notifyOnTaskCreated: true,
notifyOnAllTasksCompleted: true, notifyOnAllTasksCompleted: true,
notifyOnCrossTeamMessage: true, notifyOnCrossTeamMessage: true,
notifyOnTeamLaunched: true,
statusChangeOnlySolo: false, statusChangeOnlySolo: false,
statusChangeStatuses: ['in_progress', 'completed'], statusChangeStatuses: ['in_progress', 'completed'],
triggers: DEFAULT_TRIGGERS, triggers: DEFAULT_TRIGGERS,

View file

@ -18,6 +18,7 @@
import { getAppIconPath } from '@main/utils/appIcon'; import { getAppIconPath } from '@main/utils/appIcon';
import { getHomeDir } from '@main/utils/pathDecoder'; import { getHomeDir } from '@main/utils/pathDecoder';
import { stripMarkdown } from '@main/utils/textFormatting'; import { stripMarkdown } from '@main/utils/textFormatting';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron'; import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
@ -429,7 +430,7 @@ export class NotificationManager extends EventEmitter {
try { try {
const config = this.configManager.getConfig(); const config = this.configManager.getConfig();
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const truncatedBody = stripMarkdown(payload.body).slice(0, 300); const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300);
const iconPath = isMac ? undefined : getAppIconPath(); const iconPath = isMac ? undefined : getAppIconPath();
logger.debug( logger.debug(

View file

@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { import {
@ -5393,6 +5394,9 @@ export class TeamProvisioningService {
this.aliveRunByTeam.set(run.teamName, run.runId); this.aliveRunByTeam.set(run.teamName, run.runId);
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
// Fire "Team Launched" notification
void this.fireTeamLaunchedNotification(run);
// Pick up any direct messages that arrived before/while reconnecting. // Pick up any direct messages that arrived before/while reconnecting.
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`) logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`)
@ -5486,12 +5490,52 @@ export class TeamProvisioningService {
this.aliveRunByTeam.set(run.teamName, run.runId); this.aliveRunByTeam.set(run.teamName, run.runId);
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
// Fire "Team Launched" notification
void this.fireTeamLaunchedNotification(run);
// Pick up any direct messages that arrived during provisioning. // Pick up any direct messages that arrived during provisioning.
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`) logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`)
); );
} }
// ---------------------------------------------------------------------------
// Team Launched notification
// ---------------------------------------------------------------------------
/**
* Fires a "team_launched" notification when a team transitions to ready state.
* Uses the existing addTeamNotification() pipeline.
*/
private async fireTeamLaunchedNotification(run: ProvisioningRun): Promise<void> {
try {
const config = ConfigManager.getInstance().getConfig();
const suppressToast = !config.notifications.notifyOnTeamLaunched;
const displayName = run.request.displayName || run.teamName;
const body = run.isLaunch
? `Team "${displayName}" has been launched and is ready for tasks.`
: `Team "${displayName}" has been provisioned and is ready for tasks.`;
await NotificationManager.getInstance().addTeamNotification({
teamEventType: 'team_launched',
teamName: run.teamName,
teamDisplayName: displayName,
from: 'system',
summary: run.isLaunch ? 'Team launched' : 'Team provisioned',
body,
dedupeKey: `team_launched:${run.teamName}:${run.runId}`,
projectPath: run.request.cwd,
suppressToast,
});
} catch (error) {
logger.warn(
`[${run.teamName}] Failed to fire team_launched notification: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Same-team native delivery dedup (Layer 2) // Same-team native delivery dedup (Layer 2)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -7,26 +7,19 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import type { DetectedError } from '../services/error/ErrorMessageBuilder'; import type { DetectedError } from '../services/error/ErrorMessageBuilder';
import type { TriggerColor } from '@shared/constants/triggerColors'; import type { TriggerColor } from '@shared/constants/triggerColors';
import type { TeamEventType } from '@shared/types/notifications';
// Re-export for callers that import TeamEventType from this module
export type { TeamEventType } from '@shared/types/notifications';
// ============================================================================= // =============================================================================
// Types // Types
// ============================================================================= // =============================================================================
export type TeamEventType =
| 'rate_limit'
| 'lead_inbox'
| 'user_inbox'
| 'task_clarification'
| 'task_status_change'
| 'task_comment'
| 'task_created'
| 'all_tasks_completed'
| 'cross_team_message'
| 'schedule_completed'
| 'schedule_failed';
/** /**
* Domain payload for team notifications. * Domain payload for team notifications.
* Single source of truth both storage and native presentation are derived from this. * Single source of truth both storage and native presentation are derived from this.
@ -71,6 +64,7 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
team_launched: { triggerName: 'Team Launched', triggerColor: 'green' },
}; };
// ============================================================================= // =============================================================================
@ -91,7 +85,7 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De
projectId: payload.teamName, projectId: payload.teamName,
filePath: '', filePath: '',
source: payload.teamEventType, source: payload.teamEventType,
message: `[${payload.from}] ${payload.body.slice(0, 300)}`, message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`,
category: 'team', category: 'team',
teamEventType: payload.teamEventType, teamEventType: payload.teamEventType,
dedupeKey: payload.dedupeKey, dedupeKey: payload.dedupeKey,

View file

@ -50,6 +50,7 @@ export interface SafeConfig {
notifyOnTaskCreated: boolean; notifyOnTaskCreated: boolean;
notifyOnAllTasksCompleted: boolean; notifyOnAllTasksCompleted: boolean;
notifyOnCrossTeamMessage: boolean; notifyOnCrossTeamMessage: boolean;
notifyOnTeamLaunched: boolean;
statusChangeOnlySolo: boolean; statusChangeOnlySolo: boolean;
statusChangeStatuses: string[]; statusChangeStatuses: string[];
triggers: AppConfig['notifications']['triggers']; triggers: AppConfig['notifications']['triggers'];
@ -185,9 +186,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true, notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true, notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true, notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true,
notifyOnAllTasksCompleted: notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true, notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
'in_progress', 'in_progress',

View file

@ -303,6 +303,7 @@ export function useSettingsHandlers({
notifyOnTaskCreated: true, notifyOnTaskCreated: true,
notifyOnAllTasksCompleted: true, notifyOnAllTasksCompleted: true,
notifyOnCrossTeamMessage: true, notifyOnCrossTeamMessage: true,
notifyOnTeamLaunched: true,
statusChangeOnlySolo: true, statusChangeOnlySolo: true,
statusChangeStatuses: ['in_progress', 'completed'], statusChangeStatuses: ['in_progress', 'completed'],
triggers: defaultTriggers, triggers: defaultTriggers,

View file

@ -26,6 +26,7 @@ import {
Mail, Mail,
MessageSquare, MessageSquare,
PartyPopper, PartyPopper,
Rocket,
Send, Send,
Users, Users,
Volume2, Volume2,
@ -72,6 +73,7 @@ interface NotificationsSectionProps {
| 'notifyOnTaskCreated' | 'notifyOnTaskCreated'
| 'notifyOnAllTasksCompleted' | 'notifyOnAllTasksCompleted'
| 'notifyOnCrossTeamMessage' | 'notifyOnCrossTeamMessage'
| 'notifyOnTeamLaunched'
| 'statusChangeOnlySolo', | 'statusChangeOnlySolo',
value: boolean value: boolean
) => void; ) => void;
@ -334,6 +336,17 @@ export const NotificationsSection = ({
disabled={saving || !safeConfig.notifications.enabled} disabled={saving || !safeConfig.notifications.enabled}
/> />
</SettingRow> </SettingRow>
<SettingRow
label="Team launched notifications"
description="Notify when a team finishes launching and is ready"
icon={<Rocket className="size-4" />}
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnTeamLaunched}
onChange={(v) => onNotificationToggle('notifyOnTeamLaunched', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
{/* Task Status Change Notifications — nested within team card */} {/* Task Status Change Notifications — nested within team card */}
<div className="last:*:border-b-0"> <div className="last:*:border-b-0">

View file

@ -35,6 +35,8 @@ interface CollapsibleTeamSectionProps {
headerClassName?: string; headerClassName?: string;
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */ /** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
headerContentClassName?: string; headerContentClassName?: string;
/** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */
keepMounted?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@ -53,6 +55,7 @@ export const CollapsibleTeamSection = ({
contentClassName, contentClassName,
headerClassName, headerClassName,
headerContentClassName, headerContentClassName,
keepMounted,
children, children,
}: CollapsibleTeamSectionProps): React.JSX.Element => { }: CollapsibleTeamSectionProps): React.JSX.Element => {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
@ -133,10 +136,19 @@ export const CollapsibleTeamSection = ({
<div className="relative z-10 flex shrink-0 items-center self-start">{action}</div> <div className="relative z-10 flex shrink-0 items-center self-start">{action}</div>
)} )}
</div> </div>
{isOpen && ( {keepMounted ? (
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}> <div
className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}
style={isOpen ? undefined : { display: 'none' }}
>
{children} {children}
</div> </div>
) : (
isOpen && (
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
{children}
</div>
)
)} )}
</section> </section>
); );

View file

@ -103,11 +103,8 @@ interface ValidationResult {
} }
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
const DEV_DEFAULT_TEAM = {
teamName: 'signal-ops',
} as const;
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [ const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [
{ {
name: 'alice', name: 'alice',
roleSelection: 'reviewer', roleSelection: 'reviewer',
@ -228,7 +225,6 @@ export const CreateTeamDialog = ({
onCreate, onCreate,
onOpenTeam, onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => { }: CreateTeamDialogProps): React.JSX.Element => {
const isDev = process.env.NODE_ENV !== 'production';
const { isLight } = useTheme(); const { isLight } = useTheme();
const [teamName, setTeamName] = useState(''); const [teamName, setTeamName] = useState('');
@ -503,20 +499,15 @@ export const CreateTeamDialog = ({
return; return;
} }
if (isDev) { setMembers(
setMembers( DEFAULT_MEMBERS.map((member) =>
DEV_DEFAULT_MEMBERS.map((member) => createMemberDraft({
createMemberDraft({ name: member.name,
name: member.name, roleSelection: member.roleSelection,
roleSelection: member.roleSelection, workflow: member.workflow,
workflow: member.workflow, })
}) )
) );
);
return;
}
setMembers([createMemberDraft()]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open // eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
}, [open]); }, [open]);
@ -528,7 +519,7 @@ export const CreateTeamDialog = ({
}, [initialData, open, suggestedTeamName]); }, [initialData, open, suggestedTeamName]);
useEffect(() => { useEffect(() => {
if (!open || !isDev || initialData) { if (!open || initialData) {
return; return;
} }
const resolvedTeamName = teamName.trim() || suggestedTeamName; const resolvedTeamName = teamName.trim() || suggestedTeamName;
@ -547,7 +538,7 @@ export const CreateTeamDialog = ({
if (currentDescription === nextAutoDescription) { if (currentDescription === nextAutoDescription) {
lastAutoDescriptionRef.current = nextAutoDescription; lastAutoDescriptionRef.current = nextAutoDescription;
} }
}, [descriptionDraft, initialData, isDev, open, suggestedTeamName, teamName]); }, [descriptionDraft, initialData, open, suggestedTeamName, teamName]);
// Pre-select defaultProjectPath when projects loaded (only while dialog is open) // Pre-select defaultProjectPath when projects loaded (only while dialog is open)
useEffect(() => { useEffect(() => {

View file

@ -1107,6 +1107,7 @@ export const TaskDetailDialog = ({
headerClassName="-mx-6 w-[calc(100%+3rem)]" headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6" headerContentClassName="pl-6"
defaultOpen={false} defaultOpen={false}
keepMounted
> >
<div className="min-w-0"> <div className="min-w-0">
<MemberLogsTab <MemberLogsTab

View file

@ -1,8 +1,14 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown, Info } from 'lucide-react';
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) --- // --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
@ -164,7 +170,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
type="button" type="button"
id={opt.value === value ? id : undefined} id={opt.value === value ? id : undefined}
className={cn( className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors', 'flex items-center gap-1 rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]' : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
@ -172,6 +178,20 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
onClick={() => onValueChange(opt.value)} onClick={() => onValueChange(opt.value)}
> >
{opt.label} {opt.label}
{opt.value === '' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Info className="size-3 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Default model from Claude CLI (/model).
<br />
Currently Sonnet 4.6, but may change with CLI updates.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</button> </button>
))} ))}
</div> </div>

View file

@ -7,6 +7,7 @@ import {
type TaskChangeRequestOptions, type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -145,8 +146,9 @@ function detectClarificationNotifications(
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void { function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
// Delegate to main process for native OS notification (cross-platform, no permission needed) // Delegate to main process for native OS notification (cross-platform, no permission needed)
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined; const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
const body = const rawBody =
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`; latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
const body = stripAgentBlocks(rawBody).trim();
void api.teams void api.teams
?.showMessageNotification({ ?.showMessageNotification({
@ -295,7 +297,11 @@ function fireTaskCommentNotification(
comment: { author: string; text: string; id: string }, comment: { author: string; text: string; id: string },
suppressToast: boolean suppressToast: boolean
): void { ): void {
const preview = comment.text.length > 100 ? comment.text.slice(0, 100) + '...' : comment.text; // Double-check: never notify about user's own comments
if (comment.author === 'user') return;
const stripped = stripAgentBlocks(comment.text).trim();
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
void api.teams void api.teams
?.showMessageNotification({ ?.showMessageNotification({
@ -337,7 +343,7 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean):
from: task.owner ?? 'system', from: task.owner ?? 'system',
to: 'user', to: 'user',
summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`, summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: task.description || task.subject, body: stripAgentBlocks(task.description || task.subject).trim(),
teamEventType: 'task_created', teamEventType: 'task_created',
dedupeKey: `created:${task.teamName}:${task.id}`, dedupeKey: `created:${task.teamName}:${task.id}`,
suppressToast, suppressToast,

View file

@ -15,6 +15,24 @@ import type { TriggerColor } from '@shared/constants/triggerColors';
// Detected Error Types // Detected Error Types
// ============================================================================= // =============================================================================
/**
* Team notification event sub-types.
* Single source of truth used by DetectedError, TeamNotificationPayload, and TEAM_NOTIFICATION_CONFIG.
*/
export type TeamEventType =
| 'rate_limit'
| 'lead_inbox'
| 'user_inbox'
| 'task_clarification'
| 'task_status_change'
| 'task_comment'
| 'task_created'
| 'all_tasks_completed'
| 'cross_team_message'
| 'schedule_completed'
| 'schedule_failed'
| 'team_launched';
/** /**
* Detected error from session JSONL files. * Detected error from session JSONL files.
* Used for notification display and deep linking to error locations. * Used for notification display and deep linking to error locations.
@ -53,18 +71,7 @@ export interface DetectedError {
/** Notification domain: 'error' (default/undefined) or 'team' */ /** Notification domain: 'error' (default/undefined) or 'team' */
category?: 'error' | 'team'; category?: 'error' | 'team';
/** For team notifications: specific event sub-type */ /** For team notifications: specific event sub-type */
teamEventType?: teamEventType?: TeamEventType;
| 'rate_limit'
| 'lead_inbox'
| 'user_inbox'
| 'task_clarification'
| 'task_status_change'
| 'task_comment'
| 'task_created'
| 'all_tasks_completed'
| 'cross_team_message'
| 'schedule_completed'
| 'schedule_failed';
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
dedupeKey?: string; dedupeKey?: string;
/** Additional context */ /** Additional context */
@ -280,6 +287,8 @@ export interface AppConfig {
notifyOnAllTasksCompleted: boolean; notifyOnAllTasksCompleted: boolean;
/** Whether to show native OS notifications for cross-team messages */ /** Whether to show native OS notifications for cross-team messages */
notifyOnCrossTeamMessage: boolean; notifyOnCrossTeamMessage: boolean;
/** Whether to show native OS notifications when a team finishes launching */
notifyOnTeamLaunched: boolean;
/** Only notify on status changes in solo teams (no teammates) */ /** Only notify on status changes in solo teams (no teammates) */
statusChangeOnlySolo: boolean; statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */

View file

@ -114,6 +114,7 @@ describe('configValidation', () => {
'notifyOnUserInbox', 'notifyOnUserInbox',
'notifyOnClarifications', 'notifyOnClarifications',
'notifyOnStatusChange', 'notifyOnStatusChange',
'notifyOnTeamLaunched',
'statusChangeOnlySolo', 'statusChangeOnlySolo',
] as const)('accepts boolean %s toggle', (key) => { ] as const)('accepts boolean %s toggle', (key) => {
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true }); const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
@ -134,6 +135,7 @@ describe('configValidation', () => {
'notifyOnUserInbox', 'notifyOnUserInbox',
'notifyOnClarifications', 'notifyOnClarifications',
'notifyOnStatusChange', 'notifyOnStatusChange',
'notifyOnTeamLaunched',
'statusChangeOnlySolo', 'statusChangeOnlySolo',
] as const)('rejects non-boolean %s', (key) => { ] as const)('rejects non-boolean %s', (key) => {
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' }); const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });

View file

@ -98,6 +98,7 @@ describe('buildDetectedErrorFromTeam', () => {
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' }, cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
team_launched: { triggerName: 'Team Launched', triggerColor: 'green' },
}; };
for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) { for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) {