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:
parent
2615ed0ad7
commit
e0eaa27a13
11 changed files with 246 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]` };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue