feat: add sent messages notification handling and enhance configuration validation

- Introduced functionality to notify users of new messages in sentMessages.json, improving user awareness of team communications.
- Enhanced configuration validation to include new notification settings: notifyOnLeadInbox, notifyOnUserInbox, and notifyOnClarifications, ensuring proper boolean handling.
- Updated TeamProvisioningService to persist sent messages, improving message tracking and reliability.
- Improved UI components to reflect changes in message handling and user feedback during provisioning processes.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-03 15:54:51 +02:00
parent 2615ed0ad7
commit e0eaa27a13
11 changed files with 246 additions and 51 deletions

View file

@ -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<string, number>();
/** Track last-seen message count per team sentMessages.json to detect new user-directed messages. */
const sentMessageCounts = new Map<string, number>();
/** Debounce per-inbox to avoid flooding during batch writes. */
const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>();
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<void> {
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

View file

@ -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[]` };

View file

@ -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,

View file

@ -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,
});
}

View file

@ -82,6 +82,7 @@ export const ProvisioningProgressBlock = ({
const [logsOpen, setLogsOpen] = useState(false);
const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error';
const hasAnyOutput = !!assistantOutput || !!cliLogsTail;
// Auto-scroll assistant output
useEffect(() => {
@ -191,6 +192,16 @@ export const ProvisioningProgressBlock = ({
{logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null}
</div>
) : null}
{!hasAnyOutput ? (
<p
className={cn(
'mt-2 text-[11px]',
isError ? 'text-red-200/80' : 'text-[var(--color-text-muted)]'
)}
>
No output captured yet.
</p>
) : null}
</div>
);
};

View file

@ -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 (
<div className="mb-3 flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
<CheckCircle2 size={14} className="shrink-0 text-emerald-400" />
<p className="flex-1 text-xs text-emerald-200">Team launched process alive</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-emerald-500/40 px-2 text-xs text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
<div className="mb-3">
<div className="mb-2 flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
<CheckCircle2 size={14} className="shrink-0 text-emerald-400" />
<p className="flex-1 text-xs text-emerald-200">Team launched process alive</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-emerald-500/40 px-2 text-xs text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
</div>
<ProvisioningProgressBlock
title="Launch details"
message={progress.message}
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
startedAt={progress.startedAt}
pid={progress.pid}
cliLogsTail={progress.cliLogsTail}
assistantOutput={progress.assistantOutput}
onCancel={null}
/>
</div>
);
}

View file

@ -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 = ({
<CollapsibleTeamSection
title="Execution Logs"
icon={<ScrollText size={14} />}
headerExtra={
logsRefreshing ? (
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<Loader2 size={10} className="animate-spin" />
Updating...
</span>
) : null
}
defaultOpen
>
<div className="min-w-0 overflow-hidden">
@ -599,6 +609,7 @@ export const TaskDetailDialog = ({
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals}
onRefreshingChange={setLogsRefreshing}
/>
</div>
</CollapsibleTeamSection>

View file

@ -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<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(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<void> => {
@ -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 (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />

View file

@ -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<typeof prev> = {
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;
}

View file

@ -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);

View file

@ -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 () => {