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 { showTeamNativeNotification } from './ipc/teams';
import { HttpServer } from './services/infrastructure/HttpServer'; import { HttpServer } from './services/infrastructure/HttpServer';
import { TeamInboxReader } from './services/team/TeamInboxReader'; import { TeamInboxReader } from './services/team/TeamInboxReader';
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
import { getAppIconPath } from './utils/appIcon'; import { getAppIconPath } from './utils/appIcon';
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
import { import {
@ -71,8 +72,11 @@ const logger = createLogger('App');
// --- Team message notification tracking --- // --- Team message notification tracking ---
const teamInboxReader = new TeamInboxReader(); const teamInboxReader = new TeamInboxReader();
const sentMessagesStore = new TeamSentMessagesStore();
/** Track last-seen message count per inbox file to detect new messages. */ /** Track last-seen message count per inbox file to detect new messages. */
const inboxMessageCounts = new Map<string, number>(); 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. */ /** Debounce per-inbox to avoid flooding during batch writes. */
const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>(); const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>();
const INBOX_NOTIFY_DEBOUNCE_MS = 500; 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) => { process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection in main process:', 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. // Show native OS notification for new lead → user messages (sentMessages.json).
// These don't go through inbox files — they're held in-memory by TeamProvisioningService. if (detail === 'sentMessages.json') {
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') { const timerKey = `${teamName}:sentMessages`;
const cfg = configManager.getConfig(); const existing = inboxNotifyTimers.get(timerKey);
if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) { if (existing) clearTimeout(existing);
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName); inboxNotifyTimers.set(
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined; timerKey,
// Only notify for messages addressed to the human user, skip noise setTimeout(() => {
if (latest?.to === 'user' && !isInboxNoiseMessage(latest.text)) { inboxNotifyTimers.delete(timerKey);
const fromLabel = latest.from || 'team-lead'; void notifyNewSentMessages(teamName).catch(() => undefined);
const extracted = extractNotificationContent(latest.text); }, INBOX_NOTIFY_DEBOUNCE_MS)
const summary = latest.summary || extracted.summary; );
void resolveTeamDisplayName(teamName)
.then((displayName) => {
showTeamNativeNotification({
title: displayName,
subtitle: `${fromLabel}: ${summary}`,
body: extracted.body,
});
})
.catch(() => undefined);
}
}
} }
} catch { } catch {
// ignore // ignore

View file

@ -105,6 +105,9 @@ function validateNotificationsSection(
'enabled', 'enabled',
'soundEnabled', 'soundEnabled',
'includeSubagentErrors', 'includeSubagentErrors',
'notifyOnLeadInbox',
'notifyOnUserInbox',
'notifyOnClarifications',
'ignoredRegex', 'ignoredRegex',
'ignoredRepositories', 'ignoredRepositories',
'snoozedUntil', 'snoozedUntil',
@ -141,6 +144,24 @@ function validateNotificationsSection(
} }
result.includeSubagentErrors = value; result.includeSubagentErrors = value;
break; 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': case 'ignoredRegex':
if (!isStringArray(value)) { if (!isStringArray(value)) {
return { valid: false, error: `notifications.${key} must be a string[]` }; return { valid: false, error: `notifications.${key} must be a string[]` };

View file

@ -478,6 +478,12 @@ ${AGENT_BLOCK_OPEN}
${AGENT_BLOCK_CLOSE} ${AGENT_BLOCK_CLOSE}
- Put ONLY the internal instructions inside the agent-only block. - 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" (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.").`; - 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. - Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work. - Use the team task board for assigned/substantial work.
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - 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} ${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: 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 ${request.members
.map( .map(
(m) => ` For "${m.name}": (m) => ` For "${m.name}":
@ -691,6 +704,7 @@ Constraints:
- Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work. - Use the team task board for assigned/substantial work.
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - 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} ${teamCtlOps}
@ -1937,7 +1951,7 @@ export class TeamProvisioningService {
// that is not meant for the human user. // that is not meant for the human user.
const cleanReply = replyText ? stripAgentBlocks(replyText) : null; const cleanReply = replyText ? stripAgentBlocks(replyText) : null;
if (cleanReply) { if (cleanReply) {
this.pushLiveLeadProcessMessage(teamName, { const relayMsg: InboxMessage = {
from: leadName, from: leadName,
to: 'user', to: 'user',
text: cleanReply, text: cleanReply,
@ -1946,7 +1960,10 @@ export class TeamProvisioningService {
summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply, summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply,
messageId: `lead-process-${runId}-${Date.now()}`, messageId: `lead-process-${runId}-${Date.now()}`,
source: 'lead_process', 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?.({ this.teamChangeEmitter?.({
type: 'inbox', type: 'inbox',
teamName, teamName,

View file

@ -63,7 +63,7 @@ export class TeamSentMessagesStore {
messageId: typeof row.messageId === 'string' ? row.messageId : undefined, messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
color: typeof row.color === 'string' ? row.color : undefined, color: typeof row.color === 'string' ? row.color : undefined,
attachments: Array.isArray(row.attachments) ? row.attachments : 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 [logsOpen, setLogsOpen] = useState(false);
const outputScrollRef = useRef<HTMLDivElement>(null); const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error'; const isError = tone === 'error';
const hasAnyOutput = !!assistantOutput || !!cliLogsTail;
// Auto-scroll assistant output // Auto-scroll assistant output
useEffect(() => { useEffect(() => {
@ -191,6 +192,16 @@ export const ProvisioningProgressBlock = ({
{logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null} {logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null}
</div> </div>
) : null} ) : 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> </div>
); );
}; };

View file

@ -53,13 +53,24 @@ export const TeamProvisioningBanner = ({
if (progress?.state !== 'ready') { if (progress?.state !== 'ready') {
return; 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(() => { const timer = window.setTimeout(() => {
setDismissed(true); setDismissed(true);
}, READY_DISMISS_MS); }, READY_DISMISS_MS);
return () => { return () => {
window.clearTimeout(timer); window.clearTimeout(timer);
}; };
}, [progress?.state, progress?.runId]); }, [
progress?.state,
progress?.runId,
progress?.assistantOutput,
progress?.cliLogsTail,
progress?.error,
]);
if (!progress || dismissed) { if (!progress || dismissed) {
return null; return null;
@ -132,17 +143,29 @@ export const TeamProvisioningBanner = ({
if (isReady) { if (isReady) {
return ( 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"> <div className="mb-3">
<CheckCircle2 size={14} className="shrink-0 text-emerald-400" /> <div className="mb-2 flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
<p className="flex-1 text-xs text-emerald-200">Team launched process alive</p> <CheckCircle2 size={14} className="shrink-0 text-emerald-400" />
<Button <p className="flex-1 text-xs text-emerald-200">Team launched process alive</p>
variant="outline" <Button
size="sm" variant="outline"
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" size="sm"
onClick={() => setDismissed(true)} 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> <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> </div>
); );
} }

View file

@ -97,6 +97,8 @@ export const TaskDetailDialog = ({
const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields); const updateTaskFields = useStore((s) => s.updateTaskFields);
const [logsRefreshing, setLogsRefreshing] = useState(false);
// Inline editing: subject // Inline editing: subject
const [editingSubject, setEditingSubject] = useState(false); const [editingSubject, setEditingSubject] = useState(false);
const [subjectDraft, setSubjectDraft] = useState(''); const [subjectDraft, setSubjectDraft] = useState('');
@ -590,6 +592,14 @@ export const TaskDetailDialog = ({
<CollapsibleTeamSection <CollapsibleTeamSection
title="Execution Logs" title="Execution Logs"
icon={<ScrollText size={14} />} 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 defaultOpen
> >
<div className="min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
@ -599,6 +609,7 @@ export const TaskDetailDialog = ({
taskOwner={currentTask.owner} taskOwner={currentTask.owner}
taskStatus={currentTask.status} taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals} taskWorkIntervals={currentTask.workIntervals}
onRefreshingChange={setLogsRefreshing}
/> />
</div> </div>
</CollapsibleTeamSection> </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 { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
@ -26,6 +26,8 @@ interface MemberLogsTabProps {
taskStatus?: string; taskStatus?: string;
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */ /** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
/** Notifies parent when a background refresh starts/ends. */
onRefreshingChange?: (isRefreshing: boolean) => void;
} }
export const MemberLogsTab = ({ export const MemberLogsTab = ({
@ -35,17 +37,29 @@ export const MemberLogsTab = ({
taskOwner, taskOwner,
taskStatus, taskStatus,
taskWorkIntervals, taskWorkIntervals,
onRefreshingChange,
}: MemberLogsTabProps): React.JSX.Element => { }: MemberLogsTabProps): React.JSX.Element => {
const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
[taskWorkIntervals]
);
const hasLoadedRef = useRef(false);
const [logs, setLogs] = useState<MemberLogSummary[]>([]); const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null); const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
onRefreshingChange?.(refreshing);
return () => onRefreshingChange?.(false);
}, [refreshing, onRefreshingChange]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let isInitial = true;
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
@ -54,8 +68,10 @@ export const MemberLogsTab = ({
if (!cancelled) setLogs([]); if (!cancelled) setLogs([]);
return; return;
} }
if (isInitial) { if (!hasLoadedRef.current) {
setLoading(true); setLoading(true);
} else {
setRefreshing(true);
} }
setError(null); setError(null);
@ -69,16 +85,17 @@ export const MemberLogsTab = ({
: await api.teams.getMemberLogs(teamName, memberName!); : await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) { if (!cancelled) {
setLogs(result); setLogs(result);
hasLoadedRef.current = true;
} }
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unknown error'); setError(e instanceof Error ? e.message : 'Unknown error');
} }
} finally { } finally {
if (!cancelled && isInitial) { if (!cancelled) {
setLoading(false); setLoading(false);
setRefreshing(false);
} }
isInitial = false;
} }
}; };
@ -90,7 +107,8 @@ export const MemberLogsTab = ({
cancelled = true; cancelled = true;
if (interval) clearInterval(interval); 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( const handleExpand = useCallback(
async (log: MemberLogSummary) => { async (log: MemberLogSummary) => {
@ -124,7 +142,7 @@ export const MemberLogsTab = ({
[expandedId] [expandedId]
); );
if (loading) { if (loading && logs.length === 0) {
return ( return (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--color-text-muted)]"> <div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" /> <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) => { const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
// Immediate in-memory update for lead activity — no filesystem refresh needed // Immediate in-memory update for lead activity — no filesystem refresh needed
if (event.type === 'lead-activity' && event.detail) { if (event.type === 'lead-activity' && event.detail) {
useStore.setState((prev) => ({ const nextActivity = event.detail as 'active' | 'idle' | 'offline';
leadActivityByTeam: { useStore.setState((prev) => {
...prev.leadActivityByTeam, const nextState: Partial<typeof prev> = {
[event.teamName]: event.detail as 'active' | 'idle' | 'offline', 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; 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', () => { it('rejects out-of-range snoozeMinutes', () => {
const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 }); const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 });
expect(result.valid).toBe(false); expect(result.valid).toBe(false);

View file

@ -187,6 +187,14 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(first).toBe(1); expect(first).toBe(1);
expect(second).toBe(0); expect(second).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(1); 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 () => { it('does not mark as relayed when stdin is not writable', async () => {