diff --git a/src/main/index.ts b/src/main/index.ts index 74a5e60b..1075e7d4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -44,6 +44,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { showTeamNativeNotification } from './ipc/teams'; import { HttpServer } from './services/infrastructure/HttpServer'; import { TeamInboxReader } from './services/team/TeamInboxReader'; +import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { @@ -71,8 +72,11 @@ const logger = createLogger('App'); // --- Team message notification tracking --- const teamInboxReader = new TeamInboxReader(); +const sentMessagesStore = new TeamSentMessagesStore(); /** Track last-seen message count per inbox file to detect new messages. */ const inboxMessageCounts = new Map(); +/** Track last-seen message count per team sentMessages.json to detect new user-directed messages. */ +const sentMessageCounts = new Map(); /** Debounce per-inbox to avoid flooding during batch writes. */ const inboxNotifyTimers = new Map>(); const INBOX_NOTIFY_DEBOUNCE_MS = 500; @@ -245,6 +249,57 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise } } +/** + * Notify for new messages in sentMessages.json (lead → user messages). + * Mirrors notifyNewInboxMessages() but reads from TeamSentMessagesStore. + */ +async function notifyNewSentMessages(teamName: string): Promise { + const config = configManager.getConfig(); + if (!config.notifications.enabled) return; + if (!config.notifications.notifyOnUserInbox) return; + + try { + const messages = await sentMessagesStore.readMessages(teamName); + const isFirstLoad = !sentMessageCounts.has(teamName); + const prevCount = sentMessageCounts.get(teamName) ?? 0; + + if (isFirstLoad) { + sentMessageCounts.set(teamName, messages.length); + return; + } + + if (messages.length <= prevCount) { + sentMessageCounts.set(teamName, messages.length); + return; + } + + // Messages are appended at the end, new ones are at the tail + const newMessages = messages.slice(prevCount); + sentMessageCounts.set(teamName, messages.length); + + const teamDisplayName = await resolveTeamDisplayName(teamName); + + for (const msg of newMessages) { + // Skip messages sent from our own UI + if (msg.source && suppressedSources.has(msg.source)) continue; + // Skip internal coordination noise + if (isInboxNoiseMessage(msg.text)) continue; + + const fromLabel = msg.from || 'team-lead'; + const extracted = extractNotificationContent(msg.text); + const summary = msg.summary || extracted.summary; + + showTeamNativeNotification({ + title: teamDisplayName, + subtitle: `${fromLabel}: ${summary}`, + body: extracted.body, + }); + } + } catch (error) { + logger.warn(`Failed to check sent messages for ${teamName}:`, error); + } +} + process.on('unhandledRejection', (reason) => { logger.error('Unhandled promise rejection in main process:', reason); }); @@ -400,29 +455,18 @@ function wireFileWatcherEvents(context: ServiceContext): void { ); } - // Show native OS notification for live lead process replies. - // These don't go through inbox files — they're held in-memory by TeamProvisioningService. - if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') { - const cfg = configManager.getConfig(); - if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) { - const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName); - const latest = messages.length > 0 ? messages[messages.length - 1] : undefined; - // Only notify for messages addressed to the human user, skip noise - if (latest?.to === 'user' && !isInboxNoiseMessage(latest.text)) { - const fromLabel = latest.from || 'team-lead'; - const extracted = extractNotificationContent(latest.text); - const summary = latest.summary || extracted.summary; - void resolveTeamDisplayName(teamName) - .then((displayName) => { - showTeamNativeNotification({ - title: displayName, - subtitle: `${fromLabel}: ${summary}`, - body: extracted.body, - }); - }) - .catch(() => undefined); - } - } + // Show native OS notification for new lead → user messages (sentMessages.json). + if (detail === 'sentMessages.json') { + const timerKey = `${teamName}:sentMessages`; + const existing = inboxNotifyTimers.get(timerKey); + if (existing) clearTimeout(existing); + inboxNotifyTimers.set( + timerKey, + setTimeout(() => { + inboxNotifyTimers.delete(timerKey); + void notifyNewSentMessages(teamName).catch(() => undefined); + }, INBOX_NOTIFY_DEBOUNCE_MS) + ); } } catch { // ignore diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index a15cb2c1..b2923875 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -105,6 +105,9 @@ function validateNotificationsSection( 'enabled', 'soundEnabled', 'includeSubagentErrors', + 'notifyOnLeadInbox', + 'notifyOnUserInbox', + 'notifyOnClarifications', 'ignoredRegex', 'ignoredRepositories', 'snoozedUntil', @@ -141,6 +144,24 @@ function validateNotificationsSection( } result.includeSubagentErrors = value; break; + case 'notifyOnLeadInbox': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnLeadInbox = value; + break; + case 'notifyOnUserInbox': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnUserInbox = value; + break; + case 'notifyOnClarifications': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnClarifications = value; + break; case 'ignoredRegex': if (!isStringArray(value)) { return { valid: false, error: `notifications.${key} must be a string[]` }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0542f87b..dc03d68a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -478,6 +478,12 @@ ${AGENT_BLOCK_OPEN} ${AGENT_BLOCK_CLOSE} - Put ONLY the internal instructions inside the agent-only block. - CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty. +- CRITICAL: Messages to "user" must NEVER mention internal tooling, scripts, or CLI commands — not even in plain text. The user interacts through the UI, NOT the terminal. Specifically, NEVER include in user-facing messages: + - teamctl.js commands or references + - any node/bash commands (e.g. node "$HOME/.claude/tools/...") + - internal file paths (~/.claude/tools/, ~/.claude/teams/, etc.) + - instructions to run commands in terminal + Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF — never ask the user to run a command. - CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`; } @@ -572,6 +578,7 @@ Constraints: - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command. ${teamCtlOps} @@ -590,6 +597,12 @@ Steps (execute in this exact order): 2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown: +// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt +// below, even though the text is identical across members. This duplicates ~4K chars per member +// in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool. +// Extracting them once and telling the lead to "insert the protocol block" risks hallucination +// or omission — the lead may rephrase rules, skip items, or forget to include them. +// Cost: ~1K tokens per extra member. At 200K context window this is negligible. ${request.members .map( (m) => ` For "${m.name}": @@ -691,6 +704,7 @@ Constraints: - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command. ${teamCtlOps} @@ -1937,7 +1951,7 @@ export class TeamProvisioningService { // that is not meant for the human user. const cleanReply = replyText ? stripAgentBlocks(replyText) : null; if (cleanReply) { - this.pushLiveLeadProcessMessage(teamName, { + const relayMsg: InboxMessage = { from: leadName, to: 'user', text: cleanReply, @@ -1946,7 +1960,10 @@ export class TeamProvisioningService { summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply, messageId: `lead-process-${runId}-${Date.now()}`, source: 'lead_process', - }); + }; + this.pushLiveLeadProcessMessage(teamName, relayMsg); + // Persist to disk so relayed replies survive app restart and trigger FileWatcher + void this.sentMessagesStore.appendMessage(teamName, relayMsg).catch(() => undefined); this.teamChangeEmitter?.({ type: 'inbox', teamName, diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 6b1e6eb2..c91b5a66 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -63,7 +63,7 @@ export class TeamSentMessagesStore { messageId: typeof row.messageId === 'string' ? row.messageId : undefined, color: typeof row.color === 'string' ? row.color : undefined, attachments: Array.isArray(row.attachments) ? row.attachments : undefined, - source: 'user_sent', + source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, }); } diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 423bd630..8540b91c 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -82,6 +82,7 @@ export const ProvisioningProgressBlock = ({ const [logsOpen, setLogsOpen] = useState(false); const outputScrollRef = useRef(null); const isError = tone === 'error'; + const hasAnyOutput = !!assistantOutput || !!cliLogsTail; // Auto-scroll assistant output useEffect(() => { @@ -191,6 +192,16 @@ export const ProvisioningProgressBlock = ({ {logsOpen ? : null} ) : null} + {!hasAnyOutput ? ( +

+ No output captured yet. +

+ ) : null} ); }; diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index e99a2cc5..9cce9a3a 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -53,13 +53,24 @@ export const TeamProvisioningBanner = ({ if (progress?.state !== 'ready') { return; } + // If we captured any logs/output, keep the banner visible so the user + // can inspect what happened (common for fast stop→start cycles). + if (progress.assistantOutput || progress.cliLogsTail || progress.error) { + return; + } const timer = window.setTimeout(() => { setDismissed(true); }, READY_DISMISS_MS); return () => { window.clearTimeout(timer); }; - }, [progress?.state, progress?.runId]); + }, [ + progress?.state, + progress?.runId, + progress?.assistantOutput, + progress?.cliLogsTail, + progress?.error, + ]); if (!progress || dismissed) { return null; @@ -132,17 +143,29 @@ export const TeamProvisioningBanner = ({ if (isReady) { return ( -
- -

Team launched — process alive

- +
+
+ +

Team launched — process alive

+ +
+ = 0 ? progressStepIndex : -1} + startedAt={progress.startedAt} + pid={progress.pid} + cliLogsTail={progress.cliLogsTail} + assistantOutput={progress.assistantOutput} + onCancel={null} + />
); } diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 1b9c5402..ee9a9599 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -97,6 +97,8 @@ export const TaskDetailDialog = ({ const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); + const [logsRefreshing, setLogsRefreshing] = useState(false); + // Inline editing: subject const [editingSubject, setEditingSubject] = useState(false); const [subjectDraft, setSubjectDraft] = useState(''); @@ -590,6 +592,14 @@ export const TaskDetailDialog = ({ } + headerExtra={ + logsRefreshing ? ( + + + Updating... + + ) : null + } defaultOpen >
@@ -599,6 +609,7 @@ export const TaskDetailDialog = ({ taskOwner={currentTask.owner} taskStatus={currentTask.status} taskWorkIntervals={currentTask.workIntervals} + onRefreshingChange={setLogsRefreshing} />
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index bd9d5108..2911d47c 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; @@ -26,6 +26,8 @@ interface MemberLogsTabProps { taskStatus?: string; /** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */ taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; + /** Notifies parent when a background refresh starts/ends. */ + onRefreshingChange?: (isRefreshing: boolean) => void; } export const MemberLogsTab = ({ @@ -35,17 +37,29 @@ export const MemberLogsTab = ({ taskOwner, taskStatus, taskWorkIntervals, + onRefreshingChange, }: MemberLogsTabProps): React.JSX.Element => { + const intervalsKey = useMemo( + () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), + [taskWorkIntervals] + ); + const hasLoadedRef = useRef(false); + const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + useEffect(() => { + onRefreshingChange?.(refreshing); + return () => onRefreshingChange?.(false); + }, [refreshing, onRefreshingChange]); + useEffect(() => { let cancelled = false; - let isInitial = true; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const load = async (): Promise => { @@ -54,8 +68,10 @@ export const MemberLogsTab = ({ if (!cancelled) setLogs([]); return; } - if (isInitial) { + if (!hasLoadedRef.current) { setLoading(true); + } else { + setRefreshing(true); } setError(null); @@ -69,16 +85,17 @@ export const MemberLogsTab = ({ : await api.teams.getMemberLogs(teamName, memberName!); if (!cancelled) { setLogs(result); + hasLoadedRef.current = true; } } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : 'Unknown error'); } } finally { - if (!cancelled && isInitial) { + if (!cancelled) { setLoading(false); + setRefreshing(false); } - isInitial = false; } }; @@ -90,7 +107,8 @@ export const MemberLogsTab = ({ cancelled = true; if (interval) clearInterval(interval); }; - }, [teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); const handleExpand = useCallback( async (log: MemberLogSummary) => { @@ -124,7 +142,7 @@ export const MemberLogsTab = ({ [expandedId] ); - if (loading) { + if (loading && logs.length === 0) { return (
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 18012c57..b300c95a 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -337,12 +337,26 @@ export function initializeNotificationListeners(): () => void { const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => { // Immediate in-memory update for lead activity — no filesystem refresh needed if (event.type === 'lead-activity' && event.detail) { - useStore.setState((prev) => ({ - leadActivityByTeam: { - ...prev.leadActivityByTeam, - [event.teamName]: event.detail as 'active' | 'idle' | 'offline', - }, - })); + const nextActivity = event.detail as 'active' | 'idle' | 'offline'; + useStore.setState((prev) => { + const nextState: Partial = { + leadActivityByTeam: { + ...prev.leadActivityByTeam, + [event.teamName]: nextActivity, + }, + }; + + // Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive, + // which isn't refreshed for lead-activity events. + if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) { + nextState.selectedTeamData = { + ...prev.selectedTeamData, + isAlive: nextActivity !== 'offline', + }; + } + + return nextState as typeof prev; + }); return; } diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index 4dcb9714..cf8becc2 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -109,6 +109,34 @@ 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'] 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('rejects out-of-range snoozeMinutes', () => { const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 }); expect(result.valid).toBe(false); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 6240114a..deea380a 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -187,6 +187,14 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(first).toBe(1); expect(second).toBe(0); expect(writeSpy).toHaveBeenCalledTimes(1); + + // Relay now also persists to sentMessages.json via appendMessage() which uses + // atomicWriteAsync — expected to fail here since atomicWriteShouldFail=true. + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('TeamSentMessagesStore'), + expect.stringContaining('Failed to append sent message') + ); + vi.mocked(console.error).mockClear(); }); it('does not mark as relayed when stdin is not writable', async () => {