diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 4cfc74a9..084e816c 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -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` }; diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 43141da9..1fdfe40b 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -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 */ diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 2733948a..79999f95 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -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, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 58a5d2ed..c76543bd 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -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( diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d95e4c56..ed847516 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { + 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) // --------------------------------------------------------------------------- diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index 63a01d75..f038a588 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -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 = 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, diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index eb5ff447..47bd4bdd 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -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', diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 1ed4f7c4..8e797cb2 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -303,6 +303,7 @@ export function useSettingsHandlers({ notifyOnTaskCreated: true, notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, + notifyOnTeamLaunched: true, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index b2cede91..94341322 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -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} /> + } + > + onNotificationToggle('notifyOnTeamLaunched', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + {/* Task Status Change Notifications — nested within team card */}
diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 2cd656ee..485567e1 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -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 = ({
{action}
)}
- {isOpen && ( -
+ {keepMounted ? ( +
{children}
+ ) : ( + isOpen && ( +
+ {children} +
+ ) )} ); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index e215137f..36dfd18b 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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(() => { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index abeff191..0730d67a 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -1107,6 +1107,7 @@ export const TaskDetailDialog = ({ headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} + keepMounted >
= ({ 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 = ({ onClick={() => onValueChange(opt.value)} > {opt.label} + {opt.value === '' && ( + + + e.stopPropagation()}> + + + + Default model from Claude CLI (/model). +
+ Currently Sonnet 4.6, but may change with CLI updates. +
+
+
+ )} ))}
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 91ef7aaf..af1995ea 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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, diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index f5cc5e57..66bdb2a0 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -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']) */ diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index b3e343c7..b4d35a66 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -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' }); diff --git a/test/main/utils/teamNotificationBuilder.test.ts b/test/main/utils/teamNotificationBuilder.test.ts index 7765f604..0dee4cee 100644 --- a/test/main/utils/teamNotificationBuilder.test.ts +++ b/test/main/utils/teamNotificationBuilder.test.ts @@ -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)) {