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 { 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
|
||||||
|
|
|
||||||
|
|
@ -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[]` };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue