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',
'notifyOnAllTasksCompleted',
'notifyOnCrossTeamMessage',
'notifyOnTeamLaunched',
'statusChangeOnlySolo',
'statusChangeStatuses',
'triggers',
@ -199,6 +200,12 @@ function validateNotificationsSection(
}
result.notifyOnCrossTeamMessage = value;
break;
case 'notifyOnTeamLaunched':
if (typeof value !== 'boolean') {
return { valid: false, error: `notifications.${key} must be a boolean` };
}
result.notifyOnTeamLaunched = value;
break;
case 'statusChangeOnlySolo':
if (typeof value !== '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 { TriggerColor } from '@shared/constants/triggerColors';
import type { TeamEventType } from '@shared/types/notifications';
// =============================================================================
// Types
@ -52,18 +53,7 @@ export interface DetectedError {
/** Notification domain: 'error' (default/undefined) or 'team' */
category?: 'error' | 'team';
/** For team notifications: specific event sub-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';
teamEventType?: TeamEventType;
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
dedupeKey?: string;
/** Additional context about the error */

View file

@ -56,6 +56,8 @@ export interface NotificationConfig {
notifyOnAllTasksCompleted: boolean;
/** Whether to show native OS notifications for cross-team messages */
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) */
statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
@ -273,6 +275,7 @@ const DEFAULT_CONFIG: AppConfig = {
notifyOnTaskCreated: true,
notifyOnAllTasksCompleted: true,
notifyOnCrossTeamMessage: true,
notifyOnTeamLaunched: true,
statusChangeOnlySolo: false,
statusChangeStatuses: ['in_progress', 'completed'],
triggers: DEFAULT_TRIGGERS,

View file

@ -18,6 +18,7 @@
import { getAppIconPath } from '@main/utils/appIcon';
import { getHomeDir } from '@main/utils/pathDecoder';
import { stripMarkdown } from '@main/utils/textFormatting';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events';
@ -429,7 +430,7 @@ export class NotificationManager extends EventEmitter {
try {
const config = this.configManager.getConfig();
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();
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 */
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import {
@ -5393,6 +5394,9 @@ export class TeamProvisioningService {
this.aliveRunByTeam.set(run.teamName, run.runId);
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.
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`)
@ -5486,12 +5490,52 @@ export class TeamProvisioningService {
this.aliveRunByTeam.set(run.teamName, run.runId);
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.
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
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)
// ---------------------------------------------------------------------------

View file

@ -7,26 +7,19 @@
import { randomUUID } from 'crypto';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import type { DetectedError } from '../services/error/ErrorMessageBuilder';
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
// =============================================================================
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.
* 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' },
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
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,
filePath: '',
source: payload.teamEventType,
message: `[${payload.from}] ${payload.body.slice(0, 300)}`,
message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`,
category: 'team',
teamEventType: payload.teamEventType,
dedupeKey: payload.dedupeKey,

View file

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

View file

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

View file

@ -26,6 +26,7 @@ import {
Mail,
MessageSquare,
PartyPopper,
Rocket,
Send,
Users,
Volume2,
@ -72,6 +73,7 @@ interface NotificationsSectionProps {
| 'notifyOnTaskCreated'
| 'notifyOnAllTasksCompleted'
| 'notifyOnCrossTeamMessage'
| 'notifyOnTeamLaunched'
| 'statusChangeOnlySolo',
value: boolean
) => void;
@ -334,6 +336,17 @@ export const NotificationsSection = ({
disabled={saving || !safeConfig.notifications.enabled}
/>
</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 */}
<div className="last:*:border-b-0">

View file

@ -35,6 +35,8 @@ interface CollapsibleTeamSectionProps {
headerClassName?: string;
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
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;
}
@ -53,6 +55,7 @@ export const CollapsibleTeamSection = ({
contentClassName,
headerClassName,
headerContentClassName,
keepMounted,
children,
}: CollapsibleTeamSectionProps): React.JSX.Element => {
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>
{isOpen && (
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
{keepMounted ? (
<div
className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}
style={isOpen ? undefined : { display: 'none' }}
>
{children}
</div>
) : (
isOpen && (
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
{children}
</div>
)
)}
</section>
);

View file

@ -103,11 +103,8 @@ interface ValidationResult {
}
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',
roleSelection: 'reviewer',
@ -228,7 +225,6 @@ export const CreateTeamDialog = ({
onCreate,
onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => {
const isDev = process.env.NODE_ENV !== 'production';
const { isLight } = useTheme();
const [teamName, setTeamName] = useState('');
@ -503,20 +499,15 @@ export const CreateTeamDialog = ({
return;
}
if (isDev) {
setMembers(
DEV_DEFAULT_MEMBERS.map((member) =>
createMemberDraft({
name: member.name,
roleSelection: member.roleSelection,
workflow: member.workflow,
})
)
);
return;
}
setMembers([createMemberDraft()]);
setMembers(
DEFAULT_MEMBERS.map((member) =>
createMemberDraft({
name: member.name,
roleSelection: member.roleSelection,
workflow: member.workflow,
})
)
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
}, [open]);
@ -528,7 +519,7 @@ export const CreateTeamDialog = ({
}, [initialData, open, suggestedTeamName]);
useEffect(() => {
if (!open || !isDev || initialData) {
if (!open || initialData) {
return;
}
const resolvedTeamName = teamName.trim() || suggestedTeamName;
@ -547,7 +538,7 @@ export const CreateTeamDialog = ({
if (currentDescription === 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)
useEffect(() => {

View file

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

View file

@ -1,8 +1,14 @@
import React, { useEffect, useRef, useState } from 'react';
import { Label } from '@renderer/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
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) ---
@ -164,7 +170,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
type="button"
id={opt.value === value ? id : undefined}
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
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: '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)}
>
{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>
))}
</div>

View file

@ -7,6 +7,7 @@ import {
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -145,8 +146,9 @@ function detectClarificationNotifications(
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
// 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 body =
const rawBody =
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
const body = stripAgentBlocks(rawBody).trim();
void api.teams
?.showMessageNotification({
@ -295,7 +297,11 @@ function fireTaskCommentNotification(
comment: { author: string; text: string; id: string },
suppressToast: boolean
): 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
?.showMessageNotification({
@ -337,7 +343,7 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean):
from: task.owner ?? 'system',
to: 'user',
summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: task.description || task.subject,
body: stripAgentBlocks(task.description || task.subject).trim(),
teamEventType: 'task_created',
dedupeKey: `created:${task.teamName}:${task.id}`,
suppressToast,

View file

@ -15,6 +15,24 @@ import type { TriggerColor } from '@shared/constants/triggerColors';
// 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.
* Used for notification display and deep linking to error locations.
@ -53,18 +71,7 @@ export interface DetectedError {
/** Notification domain: 'error' (default/undefined) or 'team' */
category?: 'error' | 'team';
/** For team notifications: specific event sub-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';
teamEventType?: TeamEventType;
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
dedupeKey?: string;
/** Additional context */
@ -280,6 +287,8 @@ export interface AppConfig {
notifyOnAllTasksCompleted: boolean;
/** Whether to show native OS notifications for cross-team messages */
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) */
statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */

View file

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

View file

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