diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 3b370905..8030a0e1 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -256,7 +256,7 @@ const DEFAULT_CONFIG: AppConfig = { ignoredRepositories: [], snoozedUntil: null, snoozeMinutes: 30, - includeSubagentErrors: true, + includeSubagentErrors: false, notifyOnLeadInbox: false, notifyOnUserInbox: true, notifyOnClarifications: true, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c68a05af..4a1dceee 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -758,9 +758,9 @@ function buildLaunchPrompt( let step2And3Block: string; if (isSolo) { - step2And3Block = `3) Skip — solo team, no teammates to spawn. + step2And3Block = `2) Skip — solo team, no teammates to spawn. -4) SOLO TASK EXECUTION (IMPORTANT — timing matters): +3) SOLO TASK EXECUTION (IMPORTANT — timing matters): - Do NOT start executing tasks in THIS reconnect turn. - This turn is ONLY to reconnect and confirm you are ready. - After the reconnect is marked ready, you will receive a follow-up message telling you to begin work. @@ -808,7 +808,7 @@ function buildLaunchPrompt( }) .join('\n\n'); - step2And3Block = `3) Spawn each existing member as a live teammate using the Task tool: + step2And3Block = `2) Spawn each existing member as a live teammate using the Task tool: - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" @@ -822,7 +822,7 @@ ${processRegistration} Per-member spawn instructions: ${memberSpawnInstructions} -4) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`; +3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`; } const membersFooter = membersBlock @@ -863,15 +863,11 @@ ${agentBlockPolicy} Steps (execute in this exact order): -1) TeamCreate — create team "${request.teamName}": - - description: "Reconnecting existing team" - NOTE: The team directory already exists on disk. TeamCreate will register you as a member of this team so that SendMessage routes messages correctly. - -2) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state. +1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state. ${step2And3Block} -5) After all steps, output a short summary of reconnected members and what happens next. +4) After all steps, output a short summary of reconnected members and what happens next. ${membersFooter} `; @@ -2340,6 +2336,71 @@ export class TeamProvisioningService { return next; } + /** + * Intercept SendMessage(to: "user") tool_use blocks from the lead's stream-json output. + * + * Claude Code's internal teamContext may be lost after session resume (--resume), causing + * SendMessage to route messages to ~/.claude/teams/default/ instead of the real team. + * By capturing tool_use calls directly from stdout, we persist them to sentMessages.json + * under the correct team name — ensuring the UI and OS notifications work correctly + * regardless of the internal teamContext state. + */ + private captureSendMessageToUser(run: ProvisioningRun, content: Record[]): void { + for (const part of content) { + if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue; + const input = part.input; + if (!input || typeof input !== 'object') continue; + const inp = input as Record; + + // Only capture messages addressed to the human user + const recipient = typeof inp.recipient === 'string' ? inp.recipient : ''; + if (recipient !== 'user') continue; + + const msgContent = typeof inp.content === 'string' ? inp.content : ''; + if (msgContent.trim().length === 0) continue; + + const summary = typeof inp.summary === 'string' ? inp.summary : ''; + const leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || + 'team-lead'; + + const cleanContent = stripAgentBlocks(msgContent); + if (cleanContent.trim().length === 0) continue; + + const msg: InboxMessage = { + from: leadName, + to: 'user', + text: cleanContent, + timestamp: nowIso(), + read: false, + summary: + (summary || cleanContent).length > 60 + ? (summary || cleanContent).slice(0, 57) + '...' + : summary || cleanContent, + messageId: `lead-sendmsg-${run.runId}-${Date.now()}`, + source: 'lead_process', + }; + + this.pushLiveLeadProcessMessage(run.teamName, msg); + void this.sentMessagesStore + .appendMessage(run.teamName, msg) + .catch((e: unknown) => + logger.warn( + `[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${e}` + ) + ); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'sentMessages.json', + }); + + logger.debug( + `[${run.teamName}] Captured SendMessage→user from stdout: ${cleanContent.slice(0, 100)}` + ); + } + } + pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { const MAX = 100; const list = this.liveLeadProcessMessages.get(teamName) ?? []; @@ -2426,6 +2487,15 @@ export class TeamProvisioningService { run.directReplyParts.push(text); } } + + // Capture SendMessage(to: "user") tool_use blocks from assistant output. + // Claude Code's internal teamContext may route to "default" instead of the real team + // (e.g., after session resume when teamContext is lost). We intercept the tool calls + // from stdout and persist them to sentMessages.json under the correct team name, + // ensuring the UI and notifications show the right team. + if (run.provisioningComplete) { + this.captureSendMessageToUser(run, content ?? []); + } } // Capture session_id from any message type (first occurrence wins) diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 3951f170..c60682c2 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -10,7 +10,6 @@ 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; @@ -48,7 +47,7 @@ export interface SafeConfig { notifyOnClarifications: boolean; notifyOnStatusChange: boolean; statusChangeOnlySolo: boolean; - statusChangeStatuses: TeamTaskStatus[]; + statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; }; display: { @@ -175,15 +174,16 @@ export function useSettingsConfig(): UseSettingsConfigReturn { ignoredRepositories: displayConfig?.notifications?.ignoredRepositories ?? [], snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null, snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30, - includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true, + includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? false, 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'], + statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ + 'in_progress', + 'completed', + ], triggers: displayConfig?.notifications?.triggers ?? [], }, display: { diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 3e617270..d2c4f3a7 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -294,7 +294,7 @@ export function useSettingsHandlers({ ignoredRepositories: [], snoozedUntil: null, snoozeMinutes: 30, - includeSubagentErrors: true, + includeSubagentErrors: false, notifyOnLeadInbox: false, notifyOnUserInbox: true, notifyOnClarifications: true, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 8afd75b2..92aeb124 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -16,6 +16,9 @@ import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsCon import type { NotificationTrigger } from '@renderer/types/data'; import type { TeamTaskStatus } from '@shared/types'; +/** Statuses available for notification filtering — real status values + kanban-only 'approved'. */ +type NotifiableStatus = TeamTaskStatus | 'approved'; + // Snooze duration options const SNOOZE_OPTIONS = [ { value: 15, label: '15 minutes' }, @@ -44,7 +47,7 @@ interface NotificationsSectionProps { | 'statusChangeOnlySolo', value: boolean ) => void; - readonly onStatusChangeStatusesUpdate: (statuses: TeamTaskStatus[]) => void; + readonly onStatusChangeStatusesUpdate: (statuses: string[]) => void; readonly onSnooze: (minutes: number) => Promise; readonly onClearSnooze: () => Promise; readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise; @@ -304,9 +307,10 @@ export const NotificationsSection = ({ ); }; -const STATUS_OPTIONS: { value: TeamTaskStatus; label: string }[] = [ +const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [ { value: 'in_progress', label: 'Started' }, { value: 'completed', label: 'Completed' }, + { value: 'approved', label: 'Approved' }, { value: 'pending', label: 'Pending' }, { value: 'deleted', label: 'Deleted' }, ]; @@ -316,8 +320,8 @@ const StatusCheckboxGroup = ({ onChange, disabled, }: { - selected: TeamTaskStatus[]; - onChange: (statuses: TeamTaskStatus[]) => void; + selected: string[]; + onChange: (statuses: string[]) => void; disabled: boolean; }) => (
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 4b4723b2..cac578ea 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -146,32 +146,46 @@ function detectStatusChangeNotifications( 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; + + // Detect kanbanColumn change to 'approved' (status stays 'completed', column changes) + const becameApproved = task.kanbanColumn === 'approved' && oldTask.kanbanColumn !== 'approved'; + + const statusChanged = oldTask.status !== task.status; + if (!statusChanged && !becameApproved) continue; if (onlySolo) { const team = teamByName[task.teamName]; if (team && team.memberCount > 0) continue; } - if (!statuses.includes(task.status)) continue; + // Resolve the effective status for notification matching + const effectiveStatus = becameApproved ? 'approved' : task.status; + if (!statuses.includes(effectiveStatus)) continue; - const key = `${task.teamName}:${task.id}:${task.status}`; + const key = `${task.teamName}:${task.id}:${effectiveStatus}`; if (notifiedStatusChangeKeys.has(key)) continue; notifiedStatusChangeKeys.add(key); - fireStatusChangeNotification(task, oldTask.status); + const fromLabel = becameApproved ? 'Completed' : oldTask.status; + fireStatusChangeNotification(task, fromLabel, becameApproved ? 'approved' : undefined); } } -function fireStatusChangeNotification(task: GlobalTask, fromStatus: string): void { +function fireStatusChangeNotification( + task: GlobalTask, + fromStatus: string, + overrideToStatus?: string +): void { const statusLabels: Record = { pending: 'Pending', in_progress: 'In Progress', completed: 'Completed', deleted: 'Deleted', + approved: 'Approved', }; const from = statusLabels[fromStatus] ?? fromStatus; - const to = statusLabels[task.status] ?? task.status; + const toStatus = overrideToStatus ?? task.status; + const to = statusLabels[toStatus] ?? toStatus; void api.teams ?.showMessageNotification({ @@ -445,6 +459,9 @@ export const createTeamSlice: StateCreator = (set, notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`); } notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`); + if (task.kanbanColumn === 'approved') { + notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`); + } } }