fix: update notification settings and improve task status handling

- Changed default value of includeSubagentErrors to false across multiple components for consistency in notification settings.
- Updated type definitions for statusChangeStatuses to use string arrays instead of TeamTaskStatus, enhancing flexibility in status management.
- Adjusted step numbering in TeamProvisioningService to improve clarity in task execution instructions.
- Enhanced logic in teamSlice to correctly handle status changes, including the new 'approved' kanban column, ensuring accurate notifications for task transitions.
This commit is contained in:
iliya 2026-03-04 17:28:41 +02:00
parent b857c42437
commit e8f3c2c8b6
6 changed files with 119 additions and 28 deletions

View file

@ -256,7 +256,7 @@ const DEFAULT_CONFIG: AppConfig = {
ignoredRepositories: [],
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
includeSubagentErrors: false,
notifyOnLeadInbox: false,
notifyOnUserInbox: true,
notifyOnClarifications: true,

View file

@ -758,9 +758,9 @@ function buildLaunchPrompt(
let step2And3Block: string;
if (isSolo) {
step2And3Block = `3) Skip — solo team, no teammates to spawn.
step2And3Block = `2) Skip — solo team, no teammates to spawn.
4) SOLO TASK EXECUTION (IMPORTANT timing matters):
3) SOLO TASK EXECUTION (IMPORTANT timing matters):
- Do NOT start executing tasks in THIS reconnect turn.
- This turn is ONLY to reconnect and confirm you are ready.
- After the reconnect is marked ready, you will receive a follow-up message telling you to begin work.
@ -808,7 +808,7 @@ function buildLaunchPrompt(
})
.join('\n\n');
step2And3Block = `3) Spawn each existing member as a live teammate using the Task tool:
step2And3Block = `2) Spawn each existing member as a live teammate using the Task tool:
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
@ -822,7 +822,7 @@ ${processRegistration}
Per-member spawn instructions:
${memberSpawnInstructions}
4) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
}
const membersFooter = membersBlock
@ -863,15 +863,11 @@ ${agentBlockPolicy}
Steps (execute in this exact order):
1) TeamCreate create team "${request.teamName}":
- description: "Reconnecting existing team"
NOTE: The team directory already exists on disk. TeamCreate will register you as a member of this team so that SendMessage routes messages correctly.
2) Read team config at ~/.claude/teams/${request.teamName}/config.json understand current team state.
1) Read team config at ~/.claude/teams/${request.teamName}/config.json understand current team state.
${step2And3Block}
5) After all steps, output a short summary of reconnected members and what happens next.
4) After all steps, output a short summary of reconnected members and what happens next.
${membersFooter}
`;
@ -2340,6 +2336,71 @@ export class TeamProvisioningService {
return next;
}
/**
* Intercept SendMessage(to: "user") tool_use blocks from the lead's stream-json output.
*
* Claude Code's internal teamContext may be lost after session resume (--resume), causing
* SendMessage to route messages to ~/.claude/teams/default/ instead of the real team.
* By capturing tool_use calls directly from stdout, we persist them to sentMessages.json
* under the correct team name ensuring the UI and OS notifications work correctly
* regardless of the internal teamContext state.
*/
private captureSendMessageToUser(run: ProvisioningRun, content: Record<string, unknown>[]): void {
for (const part of content) {
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
const input = part.input;
if (!input || typeof input !== 'object') continue;
const inp = input as Record<string, unknown>;
// Only capture messages addressed to the human user
const recipient = typeof inp.recipient === 'string' ? inp.recipient : '';
if (recipient !== 'user') continue;
const msgContent = typeof inp.content === 'string' ? inp.content : '';
if (msgContent.trim().length === 0) continue;
const summary = typeof inp.summary === 'string' ? inp.summary : '';
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
const cleanContent = stripAgentBlocks(msgContent);
if (cleanContent.trim().length === 0) continue;
const msg: InboxMessage = {
from: leadName,
to: 'user',
text: cleanContent,
timestamp: nowIso(),
read: false,
summary:
(summary || cleanContent).length > 60
? (summary || cleanContent).slice(0, 57) + '...'
: summary || cleanContent,
messageId: `lead-sendmsg-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, msg);
void this.sentMessagesStore
.appendMessage(run.teamName, msg)
.catch((e: unknown) =>
logger.warn(
`[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${e}`
)
);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'sentMessages.json',
});
logger.debug(
`[${run.teamName}] Captured SendMessage→user from stdout: ${cleanContent.slice(0, 100)}`
);
}
}
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
const MAX = 100;
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
@ -2426,6 +2487,15 @@ export class TeamProvisioningService {
run.directReplyParts.push(text);
}
}
// Capture SendMessage(to: "user") tool_use blocks from assistant output.
// Claude Code's internal teamContext may route to "default" instead of the real team
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
// from stdout and persist them to sentMessages.json under the correct team name,
// ensuring the UI and notifications show the right team.
if (run.provisioningComplete) {
this.captureSendMessageToUser(run, content ?? []);
}
}
// Capture session_id from any message type (first occurrence wins)

View file

@ -10,7 +10,6 @@ import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import type { AppConfig } from '@renderer/types/data';
import type { TeamTaskStatus } from '@shared/types';
// Get the setState function from the store to update appConfig globally
const setStoreState = useStore.setState;
@ -48,7 +47,7 @@ export interface SafeConfig {
notifyOnClarifications: boolean;
notifyOnStatusChange: boolean;
statusChangeOnlySolo: boolean;
statusChangeStatuses: TeamTaskStatus[];
statusChangeStatuses: string[];
triggers: AppConfig['notifications']['triggers'];
};
display: {
@ -175,15 +174,16 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
ignoredRepositories: displayConfig?.notifications?.ignoredRepositories ?? [],
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? false,
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
statusChangeStatuses: (displayConfig?.notifications?.statusChangeStatuses as
| TeamTaskStatus[]
| undefined) ?? ['in_progress', 'completed'],
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
'in_progress',
'completed',
],
triggers: displayConfig?.notifications?.triggers ?? [],
},
display: {

View file

@ -294,7 +294,7 @@ export function useSettingsHandlers({
ignoredRepositories: [],
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
includeSubagentErrors: false,
notifyOnLeadInbox: false,
notifyOnUserInbox: true,
notifyOnClarifications: true,

View file

@ -16,6 +16,9 @@ import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsCon
import type { NotificationTrigger } from '@renderer/types/data';
import type { TeamTaskStatus } from '@shared/types';
/** Statuses available for notification filtering — real status values + kanban-only 'approved'. */
type NotifiableStatus = TeamTaskStatus | 'approved';
// Snooze duration options
const SNOOZE_OPTIONS = [
{ value: 15, label: '15 minutes' },
@ -44,7 +47,7 @@ interface NotificationsSectionProps {
| 'statusChangeOnlySolo',
value: boolean
) => void;
readonly onStatusChangeStatusesUpdate: (statuses: TeamTaskStatus[]) => void;
readonly onStatusChangeStatusesUpdate: (statuses: string[]) => void;
readonly onSnooze: (minutes: number) => Promise<void>;
readonly onClearSnooze: () => Promise<void>;
readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise<void>;
@ -304,9 +307,10 @@ export const NotificationsSection = ({
);
};
const STATUS_OPTIONS: { value: TeamTaskStatus; label: string }[] = [
const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
{ value: 'in_progress', label: 'Started' },
{ value: 'completed', label: 'Completed' },
{ value: 'approved', label: 'Approved' },
{ value: 'pending', label: 'Pending' },
{ value: 'deleted', label: 'Deleted' },
];
@ -316,8 +320,8 @@ const StatusCheckboxGroup = ({
onChange,
disabled,
}: {
selected: TeamTaskStatus[];
onChange: (statuses: TeamTaskStatus[]) => void;
selected: string[];
onChange: (statuses: string[]) => void;
disabled: boolean;
}) => (
<div className="flex flex-wrap gap-2">

View file

@ -146,32 +146,46 @@ function detectStatusChangeNotifications(
for (const task of newTasks) {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (!oldTask) continue;
if (oldTask.status === task.status) continue;
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
const becameApproved = task.kanbanColumn === 'approved' && oldTask.kanbanColumn !== 'approved';
const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved) continue;
if (onlySolo) {
const team = teamByName[task.teamName];
if (team && team.memberCount > 0) continue;
}
if (!statuses.includes(task.status)) continue;
// Resolve the effective status for notification matching
const effectiveStatus = becameApproved ? 'approved' : task.status;
if (!statuses.includes(effectiveStatus)) continue;
const key = `${task.teamName}:${task.id}:${task.status}`;
const key = `${task.teamName}:${task.id}:${effectiveStatus}`;
if (notifiedStatusChangeKeys.has(key)) continue;
notifiedStatusChangeKeys.add(key);
fireStatusChangeNotification(task, oldTask.status);
const fromLabel = becameApproved ? 'Completed' : oldTask.status;
fireStatusChangeNotification(task, fromLabel, becameApproved ? 'approved' : undefined);
}
}
function fireStatusChangeNotification(task: GlobalTask, fromStatus: string): void {
function fireStatusChangeNotification(
task: GlobalTask,
fromStatus: string,
overrideToStatus?: string
): void {
const statusLabels: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
deleted: 'Deleted',
approved: 'Approved',
};
const from = statusLabels[fromStatus] ?? fromStatus;
const to = statusLabels[task.status] ?? task.status;
const toStatus = overrideToStatus ?? task.status;
const to = statusLabels[toStatus] ?? toStatus;
void api.teams
?.showMessageNotification({
@ -445,6 +459,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
}
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (task.kanbanColumn === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
}
}
}