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: [],
|
||||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
includeSubagentErrors: false,
|
||||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ export function useSettingsHandlers({
|
|||
ignoredRepositories: [],
|
||||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
includeSubagentErrors: false,
|
||||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue