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:
parent
b857c42437
commit
e8f3c2c8b6
6 changed files with 119 additions and 28 deletions
|
|
@ -256,7 +256,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
ignoredRepositories: [],
|
ignoredRepositories: [],
|
||||||
snoozedUntil: null,
|
snoozedUntil: null,
|
||||||
snoozeMinutes: 30,
|
snoozeMinutes: 30,
|
||||||
includeSubagentErrors: true,
|
includeSubagentErrors: false,
|
||||||
notifyOnLeadInbox: false,
|
notifyOnLeadInbox: false,
|
||||||
notifyOnUserInbox: true,
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
|
|
|
||||||
|
|
@ -758,9 +758,9 @@ function buildLaunchPrompt(
|
||||||
|
|
||||||
let step2And3Block: string;
|
let step2And3Block: string;
|
||||||
if (isSolo) {
|
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.
|
- Do NOT start executing tasks in THIS reconnect turn.
|
||||||
- This turn is ONLY to reconnect and confirm you are ready.
|
- 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.
|
- 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');
|
.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}"
|
- team_name: "${request.teamName}"
|
||||||
- name: the member's name
|
- name: the member's name
|
||||||
- subagent_type: "general-purpose"
|
- subagent_type: "general-purpose"
|
||||||
|
|
@ -822,7 +822,7 @@ ${processRegistration}
|
||||||
Per-member spawn instructions:
|
Per-member spawn instructions:
|
||||||
${memberSpawnInstructions}
|
${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
|
const membersFooter = membersBlock
|
||||||
|
|
@ -863,15 +863,11 @@ ${agentBlockPolicy}
|
||||||
|
|
||||||
Steps (execute in this exact order):
|
Steps (execute in this exact order):
|
||||||
|
|
||||||
1) TeamCreate — create team "${request.teamName}":
|
1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state.
|
||||||
- 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.
|
|
||||||
|
|
||||||
${step2And3Block}
|
${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}
|
${membersFooter}
|
||||||
`;
|
`;
|
||||||
|
|
@ -2340,6 +2336,71 @@ export class TeamProvisioningService {
|
||||||
return next;
|
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 {
|
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||||
const MAX = 100;
|
const MAX = 100;
|
||||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
||||||
|
|
@ -2426,6 +2487,15 @@ export class TeamProvisioningService {
|
||||||
run.directReplyParts.push(text);
|
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)
|
// Capture session_id from any message type (first occurrence wins)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { AppConfig } from '@renderer/types/data';
|
import type { AppConfig } from '@renderer/types/data';
|
||||||
import type { TeamTaskStatus } from '@shared/types';
|
|
||||||
|
|
||||||
// Get the setState function from the store to update appConfig globally
|
// Get the setState function from the store to update appConfig globally
|
||||||
const setStoreState = useStore.setState;
|
const setStoreState = useStore.setState;
|
||||||
|
|
@ -48,7 +47,7 @@ export interface SafeConfig {
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: TeamTaskStatus[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
};
|
};
|
||||||
display: {
|
display: {
|
||||||
|
|
@ -175,15 +174,16 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
ignoredRepositories: displayConfig?.notifications?.ignoredRepositories ?? [],
|
ignoredRepositories: displayConfig?.notifications?.ignoredRepositories ?? [],
|
||||||
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
||||||
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
||||||
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
|
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? false,
|
||||||
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
||||||
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
||||||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||||
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
||||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: (displayConfig?.notifications?.statusChangeStatuses as
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
| TeamTaskStatus[]
|
'in_progress',
|
||||||
| undefined) ?? ['in_progress', 'completed'],
|
'completed',
|
||||||
|
],
|
||||||
triggers: displayConfig?.notifications?.triggers ?? [],
|
triggers: displayConfig?.notifications?.triggers ?? [],
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ export function useSettingsHandlers({
|
||||||
ignoredRepositories: [],
|
ignoredRepositories: [],
|
||||||
snoozedUntil: null,
|
snoozedUntil: null,
|
||||||
snoozeMinutes: 30,
|
snoozeMinutes: 30,
|
||||||
includeSubagentErrors: true,
|
includeSubagentErrors: false,
|
||||||
notifyOnLeadInbox: false,
|
notifyOnLeadInbox: false,
|
||||||
notifyOnUserInbox: true,
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsCon
|
||||||
import type { NotificationTrigger } from '@renderer/types/data';
|
import type { NotificationTrigger } from '@renderer/types/data';
|
||||||
import type { TeamTaskStatus } from '@shared/types';
|
import type { TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
|
/** Statuses available for notification filtering — real status values + kanban-only 'approved'. */
|
||||||
|
type NotifiableStatus = TeamTaskStatus | 'approved';
|
||||||
|
|
||||||
// Snooze duration options
|
// Snooze duration options
|
||||||
const SNOOZE_OPTIONS = [
|
const SNOOZE_OPTIONS = [
|
||||||
{ value: 15, label: '15 minutes' },
|
{ value: 15, label: '15 minutes' },
|
||||||
|
|
@ -44,7 +47,7 @@ interface NotificationsSectionProps {
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
readonly onStatusChangeStatusesUpdate: (statuses: TeamTaskStatus[]) => void;
|
readonly onStatusChangeStatusesUpdate: (statuses: string[]) => void;
|
||||||
readonly onSnooze: (minutes: number) => Promise<void>;
|
readonly onSnooze: (minutes: number) => Promise<void>;
|
||||||
readonly onClearSnooze: () => Promise<void>;
|
readonly onClearSnooze: () => Promise<void>;
|
||||||
readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => 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: 'in_progress', label: 'Started' },
|
||||||
{ value: 'completed', label: 'Completed' },
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
{ value: 'deleted', label: 'Deleted' },
|
{ value: 'deleted', label: 'Deleted' },
|
||||||
];
|
];
|
||||||
|
|
@ -316,8 +320,8 @@ const StatusCheckboxGroup = ({
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
selected: TeamTaskStatus[];
|
selected: string[];
|
||||||
onChange: (statuses: TeamTaskStatus[]) => void;
|
onChange: (statuses: string[]) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
|
||||||
|
|
@ -146,32 +146,46 @@ function detectStatusChangeNotifications(
|
||||||
for (const task of newTasks) {
|
for (const task of newTasks) {
|
||||||
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
||||||
if (!oldTask) continue;
|
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) {
|
if (onlySolo) {
|
||||||
const team = teamByName[task.teamName];
|
const team = teamByName[task.teamName];
|
||||||
if (team && team.memberCount > 0) continue;
|
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;
|
if (notifiedStatusChangeKeys.has(key)) continue;
|
||||||
notifiedStatusChangeKeys.add(key);
|
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> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
deleted: 'Deleted',
|
deleted: 'Deleted',
|
||||||
|
approved: 'Approved',
|
||||||
};
|
};
|
||||||
const from = statusLabels[fromStatus] ?? fromStatus;
|
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
|
void api.teams
|
||||||
?.showMessageNotification({
|
?.showMessageNotification({
|
||||||
|
|
@ -445,6 +459,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||||
}
|
}
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||||
|
if (task.kanbanColumn === 'approved') {
|
||||||
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue