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