feat: add team launched notification and related configuration
- Introduced a new notification setting for when a team finishes launching, enhancing user awareness of team readiness. - Updated the configuration interface and default settings to include the new notification option. - Implemented logic to trigger the "team launched" notification within the team provisioning service. - Enhanced validation to ensure the new setting accepts boolean values. - Updated relevant UI components to allow users to toggle the new notification setting.
This commit is contained in:
parent
5ae1d4164a
commit
6ace707653
17 changed files with 165 additions and 69 deletions
|
|
@ -117,6 +117,7 @@ function validateNotificationsSection(
|
|||
'notifyOnTaskCreated',
|
||||
'notifyOnAllTasksCompleted',
|
||||
'notifyOnCrossTeamMessage',
|
||||
'notifyOnTeamLaunched',
|
||||
'statusChangeOnlySolo',
|
||||
'statusChangeStatuses',
|
||||
'triggers',
|
||||
|
|
@ -199,6 +200,12 @@ function validateNotificationsSection(
|
|||
}
|
||||
result.notifyOnCrossTeamMessage = value;
|
||||
break;
|
||||
case 'notifyOnTeamLaunched':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
}
|
||||
result.notifyOnTeamLaunched = value;
|
||||
break;
|
||||
case 'statusChangeOnlySolo':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { randomUUID } from 'crypto';
|
|||
import { type ExtractedToolResult } from '../analysis/ToolResultExtractor';
|
||||
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
import type { TeamEventType } from '@shared/types/notifications';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -52,18 +53,7 @@ export interface DetectedError {
|
|||
/** Notification domain: 'error' (default/undefined) or 'team' */
|
||||
category?: 'error' | 'team';
|
||||
/** For team notifications: specific event sub-type */
|
||||
teamEventType?:
|
||||
| 'rate_limit'
|
||||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change'
|
||||
| 'task_comment'
|
||||
| 'task_created'
|
||||
| 'all_tasks_completed'
|
||||
| 'cross_team_message'
|
||||
| 'schedule_completed'
|
||||
| 'schedule_failed';
|
||||
teamEventType?: TeamEventType;
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context about the error */
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export interface NotificationConfig {
|
|||
notifyOnAllTasksCompleted: boolean;
|
||||
/** Whether to show native OS notifications for cross-team messages */
|
||||
notifyOnCrossTeamMessage: boolean;
|
||||
/** Whether to show native OS notifications when a team finishes launching */
|
||||
notifyOnTeamLaunched: boolean;
|
||||
/** Only notify on status changes in solo teams (no teammates) */
|
||||
statusChangeOnlySolo: boolean;
|
||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||
|
|
@ -273,6 +275,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
notifyOnTaskCreated: true,
|
||||
notifyOnAllTasksCompleted: true,
|
||||
notifyOnCrossTeamMessage: true,
|
||||
notifyOnTeamLaunched: true,
|
||||
statusChangeOnlySolo: false,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: DEFAULT_TRIGGERS,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type BrowserWindow, Notification } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
|
|
@ -429,7 +430,7 @@ export class NotificationManager extends EventEmitter {
|
|||
try {
|
||||
const config = this.configManager.getConfig();
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedBody = stripMarkdown(payload.body).slice(0, 300);
|
||||
const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
|
||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import {
|
||||
|
|
@ -5393,6 +5394,9 @@ export class TeamProvisioningService {
|
|||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Fire "Team Launched" notification
|
||||
void this.fireTeamLaunchedNotification(run);
|
||||
|
||||
// Pick up any direct messages that arrived before/while reconnecting.
|
||||
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`)
|
||||
|
|
@ -5486,12 +5490,52 @@ export class TeamProvisioningService {
|
|||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Fire "Team Launched" notification
|
||||
void this.fireTeamLaunchedNotification(run);
|
||||
|
||||
// Pick up any direct messages that arrived during provisioning.
|
||||
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||
logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team Launched notification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fires a "team_launched" notification when a team transitions to ready state.
|
||||
* Uses the existing addTeamNotification() pipeline.
|
||||
*/
|
||||
private async fireTeamLaunchedNotification(run: ProvisioningRun): Promise<void> {
|
||||
try {
|
||||
const config = ConfigManager.getInstance().getConfig();
|
||||
const suppressToast = !config.notifications.notifyOnTeamLaunched;
|
||||
const displayName = run.request.displayName || run.teamName;
|
||||
const body = run.isLaunch
|
||||
? `Team "${displayName}" has been launched and is ready for tasks.`
|
||||
: `Team "${displayName}" has been provisioned and is ready for tasks.`;
|
||||
|
||||
await NotificationManager.getInstance().addTeamNotification({
|
||||
teamEventType: 'team_launched',
|
||||
teamName: run.teamName,
|
||||
teamDisplayName: displayName,
|
||||
from: 'system',
|
||||
summary: run.isLaunch ? 'Team launched' : 'Team provisioned',
|
||||
body,
|
||||
dedupeKey: `team_launched:${run.teamName}:${run.runId}`,
|
||||
projectPath: run.request.cwd,
|
||||
suppressToast,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] Failed to fire team_launched notification: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Same-team native delivery dedup (Layer 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -7,26 +7,19 @@
|
|||
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
|
||||
import type { DetectedError } from '../services/error/ErrorMessageBuilder';
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
import type { TeamEventType } from '@shared/types/notifications';
|
||||
|
||||
// Re-export for callers that import TeamEventType from this module
|
||||
export type { TeamEventType } from '@shared/types/notifications';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type TeamEventType =
|
||||
| 'rate_limit'
|
||||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change'
|
||||
| 'task_comment'
|
||||
| 'task_created'
|
||||
| 'all_tasks_completed'
|
||||
| 'cross_team_message'
|
||||
| 'schedule_completed'
|
||||
| 'schedule_failed';
|
||||
|
||||
/**
|
||||
* Domain payload for team notifications.
|
||||
* Single source of truth — both storage and native presentation are derived from this.
|
||||
|
|
@ -71,6 +64,7 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
|
|||
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
|
||||
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||
team_launched: { triggerName: 'Team Launched', triggerColor: 'green' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -91,7 +85,7 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De
|
|||
projectId: payload.teamName,
|
||||
filePath: '',
|
||||
source: payload.teamEventType,
|
||||
message: `[${payload.from}] ${payload.body.slice(0, 300)}`,
|
||||
message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`,
|
||||
category: 'team',
|
||||
teamEventType: payload.teamEventType,
|
||||
dedupeKey: payload.dedupeKey,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface SafeConfig {
|
|||
notifyOnTaskCreated: boolean;
|
||||
notifyOnAllTasksCompleted: boolean;
|
||||
notifyOnCrossTeamMessage: boolean;
|
||||
notifyOnTeamLaunched: boolean;
|
||||
statusChangeOnlySolo: boolean;
|
||||
statusChangeStatuses: string[];
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
|
|
@ -185,9 +186,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
||||
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
|
||||
notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true,
|
||||
notifyOnAllTasksCompleted:
|
||||
displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
|
||||
notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
|
||||
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||
'in_progress',
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ export function useSettingsHandlers({
|
|||
notifyOnTaskCreated: true,
|
||||
notifyOnAllTasksCompleted: true,
|
||||
notifyOnCrossTeamMessage: true,
|
||||
notifyOnTeamLaunched: true,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: defaultTriggers,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
Mail,
|
||||
MessageSquare,
|
||||
PartyPopper,
|
||||
Rocket,
|
||||
Send,
|
||||
Users,
|
||||
Volume2,
|
||||
|
|
@ -72,6 +73,7 @@ interface NotificationsSectionProps {
|
|||
| 'notifyOnTaskCreated'
|
||||
| 'notifyOnAllTasksCompleted'
|
||||
| 'notifyOnCrossTeamMessage'
|
||||
| 'notifyOnTeamLaunched'
|
||||
| 'statusChangeOnlySolo',
|
||||
value: boolean
|
||||
) => void;
|
||||
|
|
@ -334,6 +336,17 @@ export const NotificationsSection = ({
|
|||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Team launched notifications"
|
||||
description="Notify when a team finishes launching and is ready"
|
||||
icon={<Rocket className="size-4" />}
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnTeamLaunched}
|
||||
onChange={(v) => onNotificationToggle('notifyOnTeamLaunched', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Task Status Change Notifications — nested within team card */}
|
||||
<div className="last:*:border-b-0">
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ interface CollapsibleTeamSectionProps {
|
|||
headerClassName?: string;
|
||||
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
|
||||
headerContentClassName?: string;
|
||||
/** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */
|
||||
keepMounted?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ export const CollapsibleTeamSection = ({
|
|||
contentClassName,
|
||||
headerClassName,
|
||||
headerContentClassName,
|
||||
keepMounted,
|
||||
children,
|
||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
|
@ -133,10 +136,19 @@ export const CollapsibleTeamSection = ({
|
|||
<div className="relative z-10 flex shrink-0 items-center self-start">{action}</div>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
|
||||
{keepMounted ? (
|
||||
<div
|
||||
className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}
|
||||
style={isOpen ? undefined : { display: 'none' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
isOpen && (
|
||||
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -103,11 +103,8 @@ interface ValidationResult {
|
|||
}
|
||||
|
||||
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
const DEV_DEFAULT_TEAM = {
|
||||
teamName: 'signal-ops',
|
||||
} as const;
|
||||
|
||||
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [
|
||||
const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [
|
||||
{
|
||||
name: 'alice',
|
||||
roleSelection: 'reviewer',
|
||||
|
|
@ -228,7 +225,6 @@ export const CreateTeamDialog = ({
|
|||
onCreate,
|
||||
onOpenTeam,
|
||||
}: CreateTeamDialogProps): React.JSX.Element => {
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const [teamName, setTeamName] = useState('');
|
||||
|
|
@ -503,20 +499,15 @@ export const CreateTeamDialog = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
setMembers(
|
||||
DEV_DEFAULT_MEMBERS.map((member) =>
|
||||
createMemberDraft({
|
||||
name: member.name,
|
||||
roleSelection: member.roleSelection,
|
||||
workflow: member.workflow,
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMembers([createMemberDraft()]);
|
||||
setMembers(
|
||||
DEFAULT_MEMBERS.map((member) =>
|
||||
createMemberDraft({
|
||||
name: member.name,
|
||||
roleSelection: member.roleSelection,
|
||||
workflow: member.workflow,
|
||||
})
|
||||
)
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -528,7 +519,7 @@ export const CreateTeamDialog = ({
|
|||
}, [initialData, open, suggestedTeamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isDev || initialData) {
|
||||
if (!open || initialData) {
|
||||
return;
|
||||
}
|
||||
const resolvedTeamName = teamName.trim() || suggestedTeamName;
|
||||
|
|
@ -547,7 +538,7 @@ export const CreateTeamDialog = ({
|
|||
if (currentDescription === nextAutoDescription) {
|
||||
lastAutoDescriptionRef.current = nextAutoDescription;
|
||||
}
|
||||
}, [descriptionDraft, initialData, isDev, open, suggestedTeamName, teamName]);
|
||||
}, [descriptionDraft, initialData, open, suggestedTeamName, teamName]);
|
||||
|
||||
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1107,6 +1107,7 @@ export const TaskDetailDialog = ({
|
|||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen={false}
|
||||
keepMounted
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<MemberLogsTab
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { Check, ChevronDown, Info } from 'lucide-react';
|
||||
|
||||
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
|
||||
|
||||
|
|
@ -164,7 +170,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
'flex items-center gap-1 rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
|
|
@ -172,6 +178,20 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === '' && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Info className="size-3 opacity-40 transition-opacity hover:opacity-70" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||
Default model from Claude CLI (/model).
|
||||
<br />
|
||||
Currently Sonnet 4.6, but may change with CLI updates.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
|
@ -145,8 +146,9 @@ function detectClarificationNotifications(
|
|||
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
|
||||
// Delegate to main process for native OS notification (cross-platform, no permission needed)
|
||||
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
|
||||
const body =
|
||||
const rawBody =
|
||||
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
|
||||
const body = stripAgentBlocks(rawBody).trim();
|
||||
|
||||
void api.teams
|
||||
?.showMessageNotification({
|
||||
|
|
@ -295,7 +297,11 @@ function fireTaskCommentNotification(
|
|||
comment: { author: string; text: string; id: string },
|
||||
suppressToast: boolean
|
||||
): void {
|
||||
const preview = comment.text.length > 100 ? comment.text.slice(0, 100) + '...' : comment.text;
|
||||
// Double-check: never notify about user's own comments
|
||||
if (comment.author === 'user') return;
|
||||
|
||||
const stripped = stripAgentBlocks(comment.text).trim();
|
||||
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
|
||||
|
||||
void api.teams
|
||||
?.showMessageNotification({
|
||||
|
|
@ -337,7 +343,7 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean):
|
|||
from: task.owner ?? 'system',
|
||||
to: 'user',
|
||||
summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
|
||||
body: task.description || task.subject,
|
||||
body: stripAgentBlocks(task.description || task.subject).trim(),
|
||||
teamEventType: 'task_created',
|
||||
dedupeKey: `created:${task.teamName}:${task.id}`,
|
||||
suppressToast,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ import type { TriggerColor } from '@shared/constants/triggerColors';
|
|||
// Detected Error Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Team notification event sub-types.
|
||||
* Single source of truth — used by DetectedError, TeamNotificationPayload, and TEAM_NOTIFICATION_CONFIG.
|
||||
*/
|
||||
export type TeamEventType =
|
||||
| 'rate_limit'
|
||||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change'
|
||||
| 'task_comment'
|
||||
| 'task_created'
|
||||
| 'all_tasks_completed'
|
||||
| 'cross_team_message'
|
||||
| 'schedule_completed'
|
||||
| 'schedule_failed'
|
||||
| 'team_launched';
|
||||
|
||||
/**
|
||||
* Detected error from session JSONL files.
|
||||
* Used for notification display and deep linking to error locations.
|
||||
|
|
@ -53,18 +71,7 @@ export interface DetectedError {
|
|||
/** Notification domain: 'error' (default/undefined) or 'team' */
|
||||
category?: 'error' | 'team';
|
||||
/** For team notifications: specific event sub-type */
|
||||
teamEventType?:
|
||||
| 'rate_limit'
|
||||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change'
|
||||
| 'task_comment'
|
||||
| 'task_created'
|
||||
| 'all_tasks_completed'
|
||||
| 'cross_team_message'
|
||||
| 'schedule_completed'
|
||||
| 'schedule_failed';
|
||||
teamEventType?: TeamEventType;
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context */
|
||||
|
|
@ -280,6 +287,8 @@ export interface AppConfig {
|
|||
notifyOnAllTasksCompleted: boolean;
|
||||
/** Whether to show native OS notifications for cross-team messages */
|
||||
notifyOnCrossTeamMessage: boolean;
|
||||
/** Whether to show native OS notifications when a team finishes launching */
|
||||
notifyOnTeamLaunched: boolean;
|
||||
/** Only notify on status changes in solo teams (no teammates) */
|
||||
statusChangeOnlySolo: boolean;
|
||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ describe('configValidation', () => {
|
|||
'notifyOnUserInbox',
|
||||
'notifyOnClarifications',
|
||||
'notifyOnStatusChange',
|
||||
'notifyOnTeamLaunched',
|
||||
'statusChangeOnlySolo',
|
||||
] as const)('accepts boolean %s toggle', (key) => {
|
||||
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
|
||||
|
|
@ -134,6 +135,7 @@ describe('configValidation', () => {
|
|||
'notifyOnUserInbox',
|
||||
'notifyOnClarifications',
|
||||
'notifyOnStatusChange',
|
||||
'notifyOnTeamLaunched',
|
||||
'statusChangeOnlySolo',
|
||||
] as const)('rejects non-boolean %s', (key) => {
|
||||
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ describe('buildDetectedErrorFromTeam', () => {
|
|||
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
|
||||
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||
team_launched: { triggerName: 'Team Launched', triggerColor: 'green' },
|
||||
};
|
||||
|
||||
for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue