diff --git a/package.json b/package.json index ffb89ac9..0c0df825 100644 --- a/package.json +++ b/package.json @@ -247,6 +247,10 @@ "from": "resources/runtime", "to": "runtime" }, + { + "from": "src/renderer/assets/participant-avatars", + "to": "participant-avatars" + }, { "from": "mcp-server/dist/index.js", "to": "mcp-server/index.js" diff --git a/src/main/index.ts b/src/main/index.ts index 1bf686f0..01ddf9d7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -498,6 +498,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise summary, body: extracted.body, dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`, + target: isCrossTeam + ? { kind: 'team', teamName, section: 'messages' } + : { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' }, suppressToast: effectiveSuppressToast, }) .catch(() => undefined); @@ -557,6 +560,7 @@ async function notifyNewSentMessages(teamName: string): Promise { summary, body: extracted.body, dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`, + target: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' }, suppressToast, }) .catch(() => undefined); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 49002931..32132aaf 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -415,6 +415,7 @@ function checkRateLimitMessages( summary: `Rate limit: ${msg.from}`, body: msg.text.slice(0, 200), dedupeKey, + target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, projectPath, }) .catch(() => undefined); @@ -489,6 +490,7 @@ function checkApiErrorMessages( summary: `API Error ${statusCode}: ${msg.from}`, body: msg.text.slice(0, 400), dedupeKey, + target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, projectPath, }) .catch(() => undefined); @@ -4444,6 +4446,7 @@ async function handleShowMessageNotification( summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`, body: d.body, dedupeKey, + target: d.target, suppressToast: d.suppressToast, }) .catch(() => undefined); diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 1fdfe40b..b8f1c3f3 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -14,7 +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'; +import type { NotificationTarget, TeamEventType } from '@shared/types/notifications'; // ============================================================================= // Types @@ -54,6 +54,8 @@ export interface DetectedError { category?: 'error' | 'team'; /** For team notifications: specific event sub-type */ teamEventType?: TeamEventType; + /** Structured destination for notification clicks. */ + target?: NotificationTarget; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ dedupeKey?: string; /** Additional context about the error */ diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 3ebc49e6..0491e5dc 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -16,15 +16,19 @@ */ import { getAppIconPath } from '@main/utils/appIcon'; -import { getHomeDir } from '@main/utils/pathDecoder'; +import { getAppDataPath, getHomeDir, getTeamsBasePath } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { getMemberColorByName, MEMBER_COLOR_HUE } from '@shared/constants/memberColors'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { Notification as ElectronNotification } from 'electron'; +import { Notification as ElectronNotification, nativeImage } from 'electron'; import { EventEmitter } from 'events'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import * as fsp from 'fs/promises'; import * as path from 'path'; +import { pathToFileURL } from 'url'; import { type DetectedError } from '../error/ErrorMessageBuilder'; @@ -101,6 +105,16 @@ const LEGACY_NOTIFICATION_FILENAMES = [ const LEGACY_NOTIFICATION_PATHS = LEGACY_NOTIFICATION_FILENAMES.map((filename) => path.join(getHomeDir(), '.claude', filename) ); +const SENDER_ICON_CACHE = new Map(); +const WINDOWS_TOAST_AVATAR_CACHE = new Map(); +const PARTICIPANT_AVATAR_COUNT = 13; +const LEAD_PARTICIPANT_AVATAR_NUMBER = 1; + +interface TeamNotificationAvatarMember { + name: string; + removedAt?: number | string | null; + agentType?: string; +} interface LegacyNotificationData { path: string; @@ -123,6 +137,385 @@ function getNotificationClass(): NotificationClass | null { return (ElectronNotification as NotificationClass | undefined) ?? null; } +function getNativeImage(): typeof nativeImage | null { + return nativeImage && typeof nativeImage.createFromPath === 'function' ? nativeImage : null; +} + +function hashStringToIndex(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +function getParticipantAvatarNumberByIndex(index: number): number { + const normalized = + ((Math.trunc(index) % PARTICIPANT_AVATAR_COUNT) + PARTICIPANT_AVATAR_COUNT) % + PARTICIPANT_AVATAR_COUNT; + return normalized + 1; +} + +function getFallbackParticipantAvatarNumber(name: string): number { + const normalized = name.trim().toLowerCase(); + if (normalized === 'team-lead' || normalized === 'lead') { + return LEAD_PARTICIPANT_AVATAR_NUMBER; + } + return getParticipantAvatarNumberByIndex(hashStringToIndex(normalized)); +} + +function getParticipantAvatarNumber( + sender: string, + members: readonly TeamNotificationAvatarMember[] +): number { + const senderName = sender.trim(); + if (!senderName) return getFallbackParticipantAvatarNumber(sender); + + const map = new Map(); + const activeMembers = members.filter((member) => !member.removedAt); + const leadMembers = activeMembers.filter((member) => isLeadMember(member)); + const teammateMembers = activeMembers.filter((member) => !isLeadMember(member)); + + for (const [index, member] of leadMembers.entries()) { + map.set( + member.name, + index === 0 ? LEAD_PARTICIPANT_AVATAR_NUMBER : getFallbackParticipantAvatarNumber(member.name) + ); + } + + for (const [index, member] of teammateMembers.entries()) { + map.set(member.name, 2 + (index % (PARTICIPANT_AVATAR_COUNT - 1))); + } + + for (const member of members) { + if (!map.has(member.name)) { + map.set( + member.name, + isLeadMember(member) + ? LEAD_PARTICIPANT_AVATAR_NUMBER + : getFallbackParticipantAvatarNumber(member.name) + ); + } + } + + map.set('user', getFallbackParticipantAvatarNumber('user')); + map.set('system', getFallbackParticipantAvatarNumber('system')); + + return map.get(senderName) ?? getFallbackParticipantAvatarNumber(senderName); +} + +function readTeamNotificationMembers(teamName: string): TeamNotificationAvatarMember[] { + try { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + if (!existsSync(configPath)) return []; + + const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as { + members?: unknown; + }; + if (!Array.isArray(parsed.members)) return []; + + return parsed.members + .map((member): TeamNotificationAvatarMember | null => { + if (!member || typeof member !== 'object') return null; + const record = member as Record; + const name = typeof record.name === 'string' ? record.name.trim() : ''; + if (!name) return null; + return { + name, + removedAt: + typeof record.removedAt === 'number' || typeof record.removedAt === 'string' + ? record.removedAt + : null, + agentType: typeof record.agentType === 'string' ? record.agentType : undefined, + }; + }) + .filter((member): member is TeamNotificationAvatarMember => Boolean(member)); + } catch (error) { + logger.debug(`[team-toast] failed to read team members for avatar: ${String(error)}`); + return []; + } +} + +function resolveParticipantAvatarPath(avatarNumber: number): string | undefined { + const filename = `${String(avatarNumber).padStart(2, '0')}.png`; + const resourceRoot = + typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0 + ? process.resourcesPath + : null; + const candidates = [ + path.join(process.cwd(), 'src/renderer/assets/participant-avatars', filename), + ...(resourceRoot ? [path.join(resourceRoot, 'participant-avatars', filename)] : []), + ]; + + return candidates.find((candidate) => existsSync(candidate)); +} + +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function escapeXmlText(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function formatSenderLabel(sender: string): string | null { + const trimmed = sender.trim(); + if (!trimmed) return null; + if (trimmed.toLowerCase() === 'system') return 'System'; + return trimmed.startsWith('@') ? trimmed : `@${trimmed}`; +} + +function cleanNotificationText(value: string): string { + return stripMarkdown(stripAgentBlocks(value)).replace(/\s+/g, ' ').trim(); +} + +function truncateNotificationText(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function extractTaskRef(summary: string): string | null { + const match = summary.match(/#([A-Za-z0-9][A-Za-z0-9-]*)/); + return match ? `#${match[1]}` : null; +} + +function extractTaskSubject(summary: string): string { + return summary + .replace(/^Comment on\s+#[^:]+:\s*/i, '') + .replace(/^Comment on\s+#[^\s]+/i, '') + .replace(/^Clarification needed\s+-\s+Task\s+#[^:]+:\s*/i, '') + .replace(/^Clarification needed\s+-\s+Task\s+#[^\s]+/i, '') + .replace(/^New task\s+#[^:]+:\s*/i, '') + .replace(/^New task\s+#[^\s]+/i, '') + .replace(/^Task\s+#[^:]+:\s*/i, '') + .trim(); +} + +function getTeamNotificationAction( + payload: TeamNotificationPayload, + taskRef: string | null +): string { + switch (payload.teamEventType) { + case 'task_comment': + return taskRef ? `commented on ${taskRef}` : 'commented on a task'; + case 'task_clarification': + return taskRef ? `needs clarification on ${taskRef}` : 'needs clarification'; + case 'task_status_change': + return taskRef ? `changed ${taskRef}` : 'changed task status'; + case 'task_created': + return taskRef ? `created ${taskRef}` : 'created a task'; + case 'all_tasks_completed': + return 'completed all tasks'; + case 'lead_inbox': + case 'user_inbox': + return 'sent a message'; + case 'cross_team_message': + return 'sent a cross-team message'; + case 'rate_limit': + return /api error/i.test(`${payload.summary} ${payload.body}`) + ? 'hit an API error' + : 'hit rate limit'; + case 'schedule_completed': + return 'completed a schedule'; + case 'schedule_failed': + return 'schedule failed'; + case 'team_launched': + return 'launched a team'; + default: + return 'sent an update'; + } +} + +function getTeamNotificationWhere( + payload: TeamNotificationPayload, + taskRef: string | null +): string { + const team = cleanNotificationText(payload.teamDisplayName) || payload.teamDisplayName; + const summary = cleanNotificationText(payload.summary); + + if (payload.teamEventType.startsWith('task_')) { + const subject = extractTaskSubject(summary); + const taskContext = subject || taskRef; + return taskContext ? `${taskContext} - ${team}` : team; + } + + return team; +} + +function buildTeamNotificationPresentation( + payload: TeamNotificationPayload, + body: string +): { title: string; where: string; body: string } { + const who = formatSenderLabel(payload.from) ?? cleanNotificationText(payload.teamDisplayName); + const summary = cleanNotificationText(payload.summary); + const taskRef = extractTaskRef(summary); + const action = getTeamNotificationAction(payload, taskRef); + const where = getTeamNotificationWhere(payload, taskRef); + const normalizedBody = cleanNotificationText(body); + + return { + title: truncateNotificationText(`${who} ${action}`.trim(), 96), + where: truncateNotificationText(where, 120), + body: truncateNotificationText(normalizedBody || summary, 300), + }; +} + +function getSenderInitials(sender: string): string { + const trimmed = sender.trim().replace(/^@+/, ''); + if (!trimmed) return '?'; + + const parts = trimmed.split(/[\s._:-]+/).filter(Boolean); + const initials = + parts.length >= 2 + ? `${parts[0]?.[0] ?? ''}${parts[1]?.[0] ?? ''}` + : trimmed.replace(/[\s._:-]+/g, '').slice(0, 2); + + return initials.toLocaleUpperCase() || '?'; +} + +function resolveSenderParticipantAvatarPath( + sender: string, + teamName: string, + members: readonly TeamNotificationAvatarMember[] | undefined +): string | undefined { + const senderLabel = sender.trim(); + if (!senderLabel || senderLabel.toLowerCase() === 'system') return undefined; + + const roster = members && members.length > 0 ? members : readTeamNotificationMembers(teamName); + const avatarNumber = getParticipantAvatarNumber(senderLabel, roster); + return resolveParticipantAvatarPath(avatarNumber); +} + +function getWindowsToastAvatarPath(avatarPath: string): string { + const cached = WINDOWS_TOAST_AVATAR_CACHE.get(avatarPath); + if (cached) return cached; + + const NativeImage = getNativeImage(); + if (!NativeImage) { + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } + + try { + const source = NativeImage.createFromPath(avatarPath); + if (source.isEmpty()) { + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } + + const resized = source.resize({ width: 96, height: 96 }); + if (resized.isEmpty()) { + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } + + const cacheDir = path.join(getAppDataPath(), 'notification-avatars'); + mkdirSync(cacheDir, { recursive: true }); + + const parsed = path.parse(avatarPath); + const outPath = path.join(cacheDir, `${parsed.name}-96.png`); + writeFileSync(outPath, resized.toPNG()); + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, outPath); + return outPath; + } catch (error) { + logger.debug(`[team-toast] failed to prepare Windows toast avatar: ${String(error)}`); + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } +} + +function buildSenderNotificationIcon( + sender: string, + teamName: string, + members: readonly TeamNotificationAvatarMember[] | undefined +): NotificationConstructorOptions['icon'] { + const senderLabel = sender.trim(); + if (!senderLabel || senderLabel.toLowerCase() === 'system') return getAppIconPath(); + + const senderAvatarPath = resolveSenderParticipantAvatarPath(senderLabel, teamName, members); + const cacheKey = `${teamName}:${senderLabel}:${senderAvatarPath ?? 'generated'}`.toLowerCase(); + if (SENDER_ICON_CACHE.has(cacheKey)) { + return SENDER_ICON_CACHE.get(cacheKey); + } + + try { + if (senderAvatarPath) { + const NativeImage = getNativeImage(); + if (NativeImage) { + const avatarIcon = NativeImage.createFromPath(senderAvatarPath); + if (!avatarIcon.isEmpty()) { + SENDER_ICON_CACHE.set(cacheKey, avatarIcon); + return avatarIcon; + } + } + } + + const colorName = getMemberColorByName(senderLabel); + const hue = MEMBER_COLOR_HUE[colorName] ?? 210; + const initials = escapeXmlAttribute(getSenderInitials(senderLabel)); + const svg = [ + '', + ``, + ``, + ``, + ``, + `${initials}`, + '', + ].join(''); + const NativeImage = getNativeImage(); + const icon = NativeImage?.createFromDataURL( + `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` + ); + const resolvedIcon = icon && !icon.isEmpty() ? icon : getAppIconPath(); + SENDER_ICON_CACHE.set(cacheKey, resolvedIcon); + return resolvedIcon; + } catch (error) { + logger.debug(`[team-toast] sender icon fallback for "${senderLabel}": ${String(error)}`); + const fallbackIcon = getAppIconPath(); + SENDER_ICON_CACHE.set(cacheKey, fallbackIcon); + return fallbackIcon; + } +} + +function buildWindowsTeamToastXml(input: { + title: string; + summary?: string; + body: string; + sender: string; + avatarPath?: string; + silent: boolean; +}): string { + const textRows = [ + `${escapeXmlText(input.title)}`, + input.summary ? `${escapeXmlText(input.summary)}` : null, + input.body ? `${escapeXmlText(input.body)}` : null, + ].filter(Boolean); + + const avatarRow = input.avatarPath + ? `${escapeXmlAttribute(`${input.sender} avatar`)}` + : null; + + return [ + '', + '', + '', + ...textRows, + avatarRow, + '', + '', + input.silent ? '', + ] + .filter(Boolean) + .join(''); +} + async function migrateLegacyNotificationPath(): Promise { try { await fsp.readFile(NOTIFICATIONS_PATH, 'utf8'); @@ -603,7 +996,7 @@ export class NotificationManager extends EventEmitter { /** * Shows a native notification for a team event. - * Uses team-specific formatting (title = team name, subtitle = summary). + * Uses a consistent who + what + where presentation for all team events. */ private showTeamNativeNotification( stored: StoredNotification, @@ -618,20 +1011,45 @@ export class NotificationManager extends EventEmitter { try { const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; - const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300); - const iconPath = isMac ? undefined : getAppIconPath(); + const presentation = buildTeamNotificationPresentation(payload, payload.body); + const senderAvatarPath = resolveSenderParticipantAvatarPath( + payload.from, + payload.teamName, + payload.members + ); + const toastXml = + process.platform === 'win32' && senderAvatarPath + ? buildWindowsTeamToastXml({ + title: presentation.title, + summary: presentation.where, + body: presentation.body, + sender: payload.from, + avatarPath: getWindowsToastAvatarPath(senderAvatarPath), + silent: !config.notifications.soundEnabled, + }) + : undefined; + const senderIcon = toastXml + ? undefined + : buildSenderNotificationIcon(payload.from, payload.teamName, payload.members); logger.debug( - `[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}` + `[team-toast] creating: title="${presentation.title}" where="${presentation.where}" bodyLen=${presentation.body.length}` ); - const notification = new NotificationClass({ - title: payload.teamDisplayName, - ...(isMac ? { subtitle: payload.summary } : {}), - body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody, - sound: config.notifications.soundEnabled ? 'default' : undefined, - ...(iconPath ? { icon: iconPath } : {}), - }); + const notificationOptions: NotificationConstructorOptions = toastXml + ? { toastXml } + : { + title: presentation.title, + ...(isMac ? { subtitle: presentation.where } : {}), + body: + !isMac && presentation.where + ? `${presentation.where}\n${presentation.body}` + : presentation.body, + sound: config.notifications.soundEnabled ? 'default' : undefined, + ...(senderIcon ? { icon: senderIcon } : {}), + }; + + const notification = new NotificationClass(notificationOptions); // Hold a strong reference to prevent GC from collecting the notification this.activeNotifications.add(notification); @@ -647,7 +1065,7 @@ export class NotificationManager extends EventEmitter { notification.on('show', () => { logger.debug( - `[team-toast] OS confirmed show: "${payload.teamDisplayName}" — ${payload.summary ?? ''}` + `[team-toast] OS confirmed show: "${presentation.title}" - ${presentation.where}` ); }); notification.on('failed', (_, error) => { diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 781dea22..c2d84055 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -76,7 +76,10 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean { export function shouldIgnoreLogSourceWatcherPath( projectDir: string, watchedPath: string, - _scope?: { scopedSessionIds?: ReadonlySet } + scope?: { + scopedSessionIds?: ReadonlySet; + pendingRootSessionIds?: ReadonlySet; + } ): boolean { const parts = getRelativeLogSourceParts(projectDir, watchedPath); if (!parts) { @@ -90,6 +93,31 @@ export function shouldIgnoreLogSourceWatcherPath( if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME) return false; if (first === BOARD_TASK_CHANGE_FRESHNESS_DIRNAME) return false; + const scopedSessionIds = scope?.scopedSessionIds; + if (scopedSessionIds) { + if (parts.length === 1) { + if (first.endsWith('.jsonl')) { + const sessionId = normalizeLogSourceSessionId(first.slice(0, -'.jsonl'.length)); + return ( + !sessionId || + (!scopedSessionIds.has(sessionId) && !scope?.pendingRootSessionIds?.has(sessionId)) + ); + } + return !scopedSessionIds.has(first); + } + + if (!scopedSessionIds.has(first)) { + return true; + } + + if (parts[1] === 'subagents') { + if (parts.length === 2) return false; + if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]); + } + + return true; + } + if (parts.length >= 2 && parts[1] === 'subagents') { if (parts.length === 2) return false; if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]); @@ -360,7 +388,10 @@ export class TeamLogSourceTracker { followSymlinks: false, depth: 0, ignored: (watchedPath) => - shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { scopedSessionIds }), + shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { + scopedSessionIds, + pendingRootSessionIds: new Set(this.getPendingUnknownSessionIds(state)), + }), awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a824e498..29307f3c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1820,6 +1820,123 @@ function isDefinitiveOpenCodePreLaunchFailure( ); } +const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC = + 'opencode_bootstrap_pending_after_materialized_session'; + +function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean { + if (typeof sessionId !== 'string') { + return false; + } + const trimmed = sessionId.trim(); + return trimmed.length > 0 && !trimmed.startsWith('failed:'); +} + +function hasMaterializedOpenCodeRuntimeForBootstrap( + member: TeamRuntimeMemberLaunchEvidence | undefined +): member is TeamRuntimeMemberLaunchEvidence { + if (!member) { + return false; + } + if (isMaterializedOpenCodeSessionId(member.sessionId)) { + return true; + } + return ( + hasOpenCodeRuntimeLivenessMarker(member) && + typeof member.runtimePid === 'number' && + Number.isFinite(member.runtimePid) && + member.runtimePid > 0 + ); +} + +function hasRecoverableOpenCodeBootstrapDiagnostic(diagnostics: readonly string[]): boolean { + const text = diagnostics.join('\n').toLowerCase(); + if (!text) { + return false; + } + if (hasRealOpenCodeFailureDiagnostic(text)) { + return false; + } + return ( + text.includes('runtime_bootstrap_checkin') || + text.includes('member_briefing') || + text.includes('bootstrap mcp') || + text.includes('member_session_recorded') || + text.includes('not connected') || + text.includes('mcp not connected') || + text.includes('member_launch_reconcile_pending') || + text.includes('member_launch_preview_timeout') + ); +} + +function isRecoverableOpenCodeBootstrapPendingLaunchResult( + result: TeamRuntimeLaunchResult, + memberName: string +): boolean { + const member = result.members[memberName]; + if (!hasMaterializedOpenCodeRuntimeForBootstrap(member)) { + return false; + } + if (member.bootstrapConfirmed || member.launchState === 'confirmed_alive') { + return false; + } + if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { + return false; + } + return hasRecoverableOpenCodeBootstrapDiagnostic( + collectRuntimeLaunchFailureDiagnostics(result, memberName) + ); +} + +function normalizeRecoverableOpenCodeBootstrapPendingLaunchResult( + result: TeamRuntimeLaunchResult, + memberName: string, + diagnostics: readonly string[] +): TeamRuntimeLaunchResult { + const member = result.members[memberName]; + if (!member) { + return result; + } + const memberDiagnostics = Array.from( + new Set([ + ...(member.diagnostics ?? []), + OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC, + 'OpenCode runtime session materialized; waiting for runtime_bootstrap_checkin.', + ...diagnostics, + ]) + ); + const normalizedMember: TeamRuntimeMemberLaunchEvidence = { + ...member, + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds: undefined, + livenessKind: + member.livenessKind === 'confirmed_bootstrap' + ? 'runtime_process' + : (member.livenessKind ?? 'runtime_process'), + runtimeDiagnostic: + member.runtimeDiagnostic ?? + 'OpenCode runtime process detected; waiting for bootstrap check-in.', + runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'info', + diagnostics: memberDiagnostics, + }; + const members = { + ...result.members, + [memberName]: normalizedMember, + }; + const teamLaunchState = summarizeRuntimeLaunchResultMembers(members); + return { + ...result, + launchPhase: teamLaunchState === 'clean_success' ? result.launchPhase : 'active', + teamLaunchState, + members, + diagnostics: Array.from(new Set([...result.diagnostics, ...memberDiagnostics])), + }; +} + const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC = 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'; @@ -2064,7 +2181,7 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: { 'opencode_bootstrap_evidence_committed', ]), ]; - const runtimeAlive = input.current.runtimeAlive === true; + const runtimeAlive = true; return { ...input.previous, ...input.current, @@ -6574,6 +6691,7 @@ export class TeamProvisioningService { let liveSecondaryLaneRunId: string | null = null; let trackedSecondaryLanePresent = false; let trackedSecondaryLaneSnapshotKnown = false; + let trackedSecondaryLaneBootstrapConfirmed: boolean | null = null; if ( trackedRun && laneIdentity.laneKind === 'secondary' && @@ -6588,6 +6706,15 @@ export class TeamProvisioningService { ); trackedSecondaryLanePresent = liveLane != null; liveSecondaryLaneRunId = liveLane ? trackedRunId : null; + const liveLaneMember = liveLane + ? (liveLane.result?.members?.[canonicalMemberName] ?? + liveLane.result?.members?.[liveLane.member.name]) + : null; + if (liveLaneMember) { + trackedSecondaryLaneBootstrapConfirmed = + liveLaneMember.bootstrapConfirmed === true || + liveLaneMember.launchState === 'confirmed_alive'; + } if (!liveLane && trackedSecondaryLaneSnapshotKnown) { return { delivered: false, reason: 'opencode_runtime_not_active' }; } @@ -6681,6 +6808,26 @@ export class TeamProvisioningService { return { delivered: false, reason: 'opencode_runtime_not_active' }; } + if (laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode') { + const bootstrapReady = + trackedSecondaryLaneBootstrapConfirmed === true || + (await this.hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + })); + if (!bootstrapReady) { + return { + delivered: false, + reason: 'opencode_runtime_not_active', + diagnostics: [ + `OpenCode runtime bootstrap is not confirmed for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`, + ], + }; + } + } + if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { const result = await adapter.sendMessageToMember({ ...(runtimeRunId ? { runId: runtimeRunId } : {}), @@ -8928,6 +9075,31 @@ export class TeamProvisioningService { ); } + private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: { + teamName: string; + runId: string | null; + laneId: string; + memberName: string; + }): Promise { + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), + teamName: input.teamName, + laneId: input.laneId, + }).catch(() => null); + if (!evidence?.committed) { + return false; + } + const activeRunId = evidence.activeRunId?.trim() || null; + if (activeRunId !== input.runId) { + return false; + } + return evidence.sessions.some( + (session) => + session.runId === input.runId && + namesMatchCaseInsensitive(session.memberName, input.memberName) + ); + } + private async readOpenCodeRuntimeSessionStore( filePath: string ): Promise[]> { @@ -19656,25 +19828,49 @@ export class TeamProvisioningService { }, } : result; - lane.result = resultWithTiming; - lane.warnings = [...resultWithTiming.warnings]; + const baseFailureDiagnostics = appendDiagnosticOnce( + [...requestedDiagnostics, ...migration.diagnostics], + timingDiagnostic + ); + const recoverableBootstrapPending = isRecoverableOpenCodeBootstrapPendingLaunchResult( + resultWithTiming, + lane.member.name + ); + const normalizedResult = recoverableBootstrapPending + ? normalizeRecoverableOpenCodeBootstrapPendingLaunchResult( + resultWithTiming, + lane.member.name, + baseFailureDiagnostics + ) + : resultWithTiming; + lane.result = normalizedResult; + lane.warnings = [...normalizedResult.warnings]; const launchDiagnostics = appendDiagnosticOnce( - [...requestedDiagnostics, ...migration.diagnostics, ...resultWithTiming.diagnostics], + [...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics], timingDiagnostic ); lane.diagnostics = launchDiagnostics; - if ( - isDefinitiveOpenCodePreLaunchFailure(resultWithTiming, lane.member.name) || - resultWithTiming.teamLaunchState === 'partial_failure' + if (recoverableBootstrapPending) { + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: run.teamName, + laneId: lane.laneId, + state: 'active', + diagnostics: collectOpenCodeSecondaryLaneFailureDiagnostics( + normalizedResult, + lane.member.name, + baseFailureDiagnostics + ), + }).catch(() => undefined); + } else if ( + isDefinitiveOpenCodePreLaunchFailure(normalizedResult, lane.member.name) || + normalizedResult.teamLaunchState === 'partial_failure' ) { const diagnostics = collectOpenCodeSecondaryLaneFailureDiagnostics( - resultWithTiming, + normalizedResult, lane.member.name, - appendDiagnosticOnce( - [...requestedDiagnostics, ...migration.diagnostics], - timingDiagnostic - ) + baseFailureDiagnostics ); await upsertOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: getTeamsBasePath(), @@ -24078,6 +24274,7 @@ export class TeamProvisioningService { summary: run.isLaunch ? 'Team launched' : 'Team provisioned', body, dedupeKey: `team_launched:${run.teamName}:${run.runId}`, + target: { kind: 'team', teamName: run.teamName, section: 'overview' }, projectPath: run.request.cwd, suppressToast, }); diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 429d73e7..558ffb0c 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -267,17 +267,24 @@ export function resolveTeamMemberRuntimeLiveness( }); } return result({ - alive: false, - livenessKind: 'runtime_process_candidate', - pidSource: 'opencode_bridge', - pid: runtimePidRow.pid, + alive: hasConfirmedBootstrap, + livenessKind: hasConfirmedBootstrap ? 'confirmed_bootstrap' : 'runtime_process_candidate', + pidSource: hasConfirmedBootstrap ? 'runtime_bootstrap' : 'opencode_bridge', + pid: hasConfirmedBootstrap ? undefined : runtimePidRow.pid, runtimeSessionId, - processCommand, - runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', - runtimeDiagnosticSeverity: 'warning', + processCommand: hasConfirmedBootstrap ? undefined : processCommand, + runtimeLastSeenAt: hasConfirmedBootstrap + ? (tracked?.lastHeartbeatAt ?? tracked?.updatedAt) + : undefined, + runtimeDiagnostic: hasConfirmedBootstrap + ? 'bootstrap confirmed; runtime pid currently points to a different process' + : 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: hasConfirmedBootstrap ? 'info' : 'warning', diagnostics: [ ...diagnostics, - 'matched OpenCode runtime pid without OpenCode process identity', + hasConfirmedBootstrap + ? 'bootstrap confirmed despite runtime pid identity mismatch' + : 'matched OpenCode runtime pid without OpenCode process identity', ], }); } diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index 40b5e810..c6649e4e 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'crypto'; import type { DetectedError } from '../services/error/ErrorMessageBuilder'; import type { TriggerColor } from '@shared/constants/triggerColors'; -import type { TeamEventType } from '@shared/types/notifications'; +import type { NotificationTarget, TeamEventType } from '@shared/types/notifications'; // Re-export for callers that import TeamEventType from this module export type { TeamEventType } from '@shared/types/notifications'; @@ -29,10 +29,18 @@ export interface TeamNotificationPayload { teamDisplayName: string; from: string; to?: string; + /** Optional team roster for resolving the same participant avatar shown in the UI. */ + members?: readonly { + name: string; + removedAt?: number | string | null; + agentType?: string; + }[]; summary: string; body: string; /** Stable key for storage deduplication. REQUIRED — no fallback to Date.now(). */ dedupeKey: string; + /** Structured destination used by notification click handling. */ + target?: NotificationTarget; projectPath?: string; /** * When true, the notification is stored in-app but no native OS toast is shown. @@ -76,6 +84,9 @@ const TEAM_NOTIFICATION_CONFIG: Record = */ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): DetectedError { const config = TEAM_NOTIFICATION_CONFIG[payload.teamEventType]; + const summary = stripAgentBlocks(payload.summary).replace(/\s+/g, ' ').trim(); + const body = stripAgentBlocks(payload.body).replace(/\s+/g, ' ').trim(); + const preview = summary && body ? `${summary}: ${body}` : summary || body; return { id: randomUUID(), @@ -84,9 +95,10 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De projectId: payload.teamName, filePath: '', source: payload.teamEventType, - message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`, + message: `[${payload.from}] ${preview.slice(0, 300)}`, category: 'team', teamEventType: payload.teamEventType, + target: payload.target, dedupeKey: payload.dedupeKey, triggerColor: config.triggerColor, triggerName: config.triggerName, diff --git a/src/renderer/components/team/LiveRuntimeStatusBridge.tsx b/src/renderer/components/team/LiveRuntimeStatusBridge.tsx new file mode 100644 index 00000000..b646dcf2 --- /dev/null +++ b/src/renderer/components/team/LiveRuntimeStatusBridge.tsx @@ -0,0 +1,77 @@ +import { memo, useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { Activity } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; +import { LiveRuntimeStatusSection } from './LiveRuntimeStatusSection'; +import { + buildTeamRuntimeDisplayRows, + type TeamRuntimeDisplayMember, +} from './teamRuntimeDisplayRows'; + +export const TEAM_RUNTIME_UI_DECOUPLING_STORAGE_KEY = 'teamRuntimeUiDecouplingEnabled'; + +interface LiveRuntimeStatusBridgeProps { + teamName: string; + members: readonly TeamRuntimeDisplayMember[]; +} + +export const LiveRuntimeStatusBridge = memo(function LiveRuntimeStatusBridge({ + teamName, + members, +}: LiveRuntimeStatusBridgeProps): React.JSX.Element | null { + if (!isTeamRuntimeUiDecouplingEnabled()) return null; + + return ; +}); + +const LiveRuntimeStatusStoreBridge = memo(function LiveRuntimeStatusStoreBridge({ + teamName, + members, +}: LiveRuntimeStatusBridgeProps): React.JSX.Element | null { + const { runtimeSnapshot, spawnStatuses } = useStore( + useShallow((s) => ({ + runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName], + spawnStatuses: s.memberSpawnStatusesByTeam[teamName], + })) + ); + const rows = useMemo( + () => + buildTeamRuntimeDisplayRows({ + members, + runtimeSnapshot, + spawnStatuses, + }), + [members, runtimeSnapshot, spawnStatuses] + ); + + if (rows.length === 0) return null; + + const liveCount = rows.filter((row) => row.state === 'running').length; + const attentionCount = rows.filter((row) => row.state === 'degraded').length; + const badge = attentionCount > 0 ? attentionCount : liveCount > 0 ? liveCount : undefined; + + return ( + } + badge={badge} + defaultOpen={false} + > + + + ); +}); + +export function isTeamRuntimeUiDecouplingEnabled(): boolean { + if (typeof window === 'undefined') return false; + + try { + return window.localStorage.getItem(TEAM_RUNTIME_UI_DECOUPLING_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} diff --git a/src/renderer/components/team/LiveRuntimeStatusSection.tsx b/src/renderer/components/team/LiveRuntimeStatusSection.tsx new file mode 100644 index 00000000..b0ccd897 --- /dev/null +++ b/src/renderer/components/team/LiveRuntimeStatusSection.tsx @@ -0,0 +1,98 @@ +import { memo } from 'react'; + +import type { RuntimeDisplayState, TeamRuntimeDisplayRow } from './teamRuntimeDisplayRows'; + +interface LiveRuntimeStatusSectionProps { + rows: readonly TeamRuntimeDisplayRow[]; +} + +const STATE_LABELS: Record = { + running: 'Running', + starting: 'Starting', + waiting: 'Waiting', + degraded: 'Needs attention', + stopped: 'Stopped', + unknown: 'Unknown', +}; + +const STATE_CLASS_NAMES: Record = { + running: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + starting: 'border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300', + waiting: 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300', + degraded: 'border-rose-500/25 bg-rose-500/10 text-rose-700 dark:text-rose-300', + stopped: 'border-zinc-500/25 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300', + unknown: 'border-zinc-500/20 bg-zinc-500/5 text-zinc-600 dark:text-zinc-400', +}; + +export const LiveRuntimeStatusSection = memo(function LiveRuntimeStatusSection({ + rows, +}: LiveRuntimeStatusSectionProps): React.JSX.Element | null { + if (rows.length === 0) return null; + + return ( +
+ Live runtime status +

+ Display-only heartbeat and launch state. Process controls remain below. +

+
+ {rows.map((row) => ( +
+
+
+
{row.memberName}
+
+ {row.stateReason} +
+
+ + {STATE_LABELS[row.state]} + +
+ +
+ source: {row.source} + {row.runtimeModel ? ( + {row.runtimeModel} + ) : null} + {row.laneKind ? ( + {row.laneKind} lane + ) : null} + {row.pidLabel ? ( + + {row.pidLabel} + + ) : null} + {row.updatedAt ? ( + + updated {formatRuntimeUpdatedAt(row.updatedAt)} + + ) : null} +
+
+ ))} +
+
+ ); +}); + +function formatRuntimeUpdatedAt(value: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + + const secondsAgo = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); + if (secondsAgo < 60) return `${secondsAgo}s ago`; + + const minutesAgo = Math.round(secondsAgo / 60); + if (minutesAgo < 60) return `${minutesAgo}m ago`; + + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 341485f5..e2331837 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -133,6 +133,7 @@ import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provis import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { deriveLeadContextButtonLabel } from './leadContextLoadGuards'; import { LeadSessionDetailGate } from './LeadSessionDetailGate'; +import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge'; import { loadTeamSessionMetadata } from './teamSessionFetchGuards'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -1847,13 +1848,22 @@ export const TeamDetailView = memo(function TeamDetailView({ const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); useEffect(() => { if (!pendingMemberProfile || !data) return; - const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); + if (pendingMemberProfile.teamName && pendingMemberProfile.teamName !== teamName) return; + + const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile.memberName); if (member) { setSelectedMember(member); - setSelectedMemberView(null); + setSelectedMemberView({ + initialTab: + pendingMemberProfile.focus === 'logs' + ? 'logs' + : pendingMemberProfile.focus === 'messages' + ? 'activity' + : undefined, + }); } useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches]); + }, [pendingMemberProfile, membersWithLiveBranches, teamName, data]); const handleDeleteTask = useCallback( (taskId: string) => { @@ -2638,6 +2648,8 @@ export const TeamDetailView = memo(function TeamDetailView({ + + {(data.processes?.length ?? 0) > 0 && ( { const teamName = globalTaskDetail?.teamName ?? ''; const taskId = globalTaskDetail?.taskId ?? ''; + const commentId = globalTaskDetail?.commentId; const hasTargetTeamData = hasSelectedTargetTeamData( teamName, selectedTeamName, @@ -150,6 +151,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { onClose={closeGlobalTaskDetail} onOwnerChange={undefined} onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined} + focusCommentId={commentId} headerExtra={