feat: enhance notification management and team messaging functionality
- Refactored notification handling to utilize NotificationManager for team events, improving consistency and reducing duplicate notifications. - Introduced deduplication keys for notifications to prevent storage of identical messages. - Updated notifyNewInboxMessages and notifyNewSentMessages functions to streamline message processing and enhance user experience. - Enhanced rate limit message handling with in-memory tracking to prevent re-notification of deleted messages. - Improved UI components with new animations and consistent styling for better user engagement. - Added support for team-specific notifications, including new event types and improved error handling in notifications.
This commit is contained in:
parent
e9b369e667
commit
355fe237a6
30 changed files with 1531 additions and 408 deletions
|
|
@ -43,7 +43,6 @@ import { join } from 'path';
|
|||
|
||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||
import { showTeamNativeNotification } from './ipc/teams';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
|
|
@ -154,9 +153,7 @@ function extractNotificationContent(text: string): { summary: string; body: stri
|
|||
}
|
||||
|
||||
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
||||
// Check global toggle
|
||||
const config = configManager.getConfig();
|
||||
if (!config.notifications.enabled) return;
|
||||
|
||||
// Skip orphaned team directories without config.json (e.g., "default").
|
||||
// Claude Code may write to these when its internal teamContext is lost after session resume.
|
||||
|
|
@ -172,15 +169,19 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
if (!match) return;
|
||||
const memberName = match[1];
|
||||
|
||||
// Determine inbox type and check per-inbox toggle
|
||||
// Determine inbox type and per-type toggle state.
|
||||
// Storage is always unconditional; toggles only suppress the OS toast.
|
||||
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
|
||||
const isLeadInbox = leadName !== null && memberName === leadName;
|
||||
const isUserInbox = memberName === 'user';
|
||||
|
||||
if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return;
|
||||
if (isUserInbox && !config.notifications.notifyOnUserInbox) return;
|
||||
if (!isLeadInbox && !isUserInbox) return;
|
||||
|
||||
const suppressToast =
|
||||
!config.notifications.enabled ||
|
||||
(isLeadInbox && !config.notifications.notifyOnLeadInbox) ||
|
||||
(isUserInbox && !config.notifications.notifyOnUserInbox);
|
||||
|
||||
const key = `${teamName}:${memberName}`;
|
||||
|
||||
try {
|
||||
|
|
@ -205,7 +206,8 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
|
||||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
||||
|
||||
for (const msg of newMessages) {
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const msg = newMessages[i];
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||
|
|
@ -214,12 +216,20 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
const fromLabel = msg.from || 'Unknown';
|
||||
const extracted = extractNotificationContent(msg.text);
|
||||
const summary = msg.summary || extracted.summary;
|
||||
const msgId = msg.timestamp ?? String(prevCount + i);
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: teamDisplayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: extracted.body,
|
||||
});
|
||||
void notificationManager
|
||||
.addTeamNotification({
|
||||
teamEventType: isLeadInbox ? 'lead_inbox' : 'user_inbox',
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: fromLabel,
|
||||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check inbox messages for ${key}:`, error);
|
||||
|
|
@ -232,8 +242,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
*/
|
||||
async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||
const config = configManager.getConfig();
|
||||
if (!config.notifications.enabled) return;
|
||||
if (!config.notifications.notifyOnUserInbox) return;
|
||||
const suppressToast = !config.notifications.enabled || !config.notifications.notifyOnUserInbox;
|
||||
|
||||
try {
|
||||
const messages = await sentMessagesStore.readMessages(teamName);
|
||||
|
|
@ -256,7 +265,8 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
|
||||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
||||
|
||||
for (const msg of newMessages) {
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const msg = newMessages[i];
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise
|
||||
|
|
@ -266,11 +276,18 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
const extracted = extractNotificationContent(msg.text);
|
||||
const summary = msg.summary || extracted.summary;
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: teamDisplayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: extracted.body,
|
||||
});
|
||||
void notificationManager
|
||||
.addTeamNotification({
|
||||
teamEventType: 'user_inbox',
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: fromLabel,
|
||||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`,
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check sent messages for ${teamName}:`, error);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
|
|
@ -79,10 +77,6 @@ import {
|
|||
validateTeamName,
|
||||
} from './guards';
|
||||
|
||||
/** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */
|
||||
const notifiedRateLimitKeys = new Set<string>();
|
||||
const RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
|
||||
|
|
@ -130,7 +124,17 @@ import type {
|
|||
const logger = createLogger('IPC:teams');
|
||||
|
||||
/**
|
||||
* Check messages for rate limit indicators and fire native notifications for new ones.
|
||||
* In-memory set of rate-limit message keys already processed.
|
||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
||||
* Without this, deleted rate-limit notifications would re-appear on next getData() scan.
|
||||
*/
|
||||
const seenRateLimitKeys = new Set<string>();
|
||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
/**
|
||||
* Check messages for rate limit indicators and fire notifications for new ones.
|
||||
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
|
||||
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
||||
*/
|
||||
function checkRateLimitMessages(
|
||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||
|
|
@ -142,33 +146,29 @@ function checkRateLimitMessages(
|
|||
if (msg.from === 'user') continue;
|
||||
if (!isRateLimitMessage(msg.text)) continue;
|
||||
|
||||
// Prefix key with teamName to avoid collisions across teams
|
||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||
const key = `${teamName}:${rawKey}`;
|
||||
if (notifiedRateLimitKeys.has(key)) continue;
|
||||
notifiedRateLimitKeys.add(key);
|
||||
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
||||
|
||||
// Prevent unbounded memory growth
|
||||
if (notifiedRateLimitKeys.size > RATE_LIMIT_KEYS_MAX) {
|
||||
const first = notifiedRateLimitKeys.values().next().value!;
|
||||
notifiedRateLimitKeys.delete(first);
|
||||
// In-memory guard: prevents resurrection after user deletes the notification
|
||||
if (seenRateLimitKeys.has(dedupeKey)) continue;
|
||||
seenRateLimitKeys.add(dedupeKey);
|
||||
|
||||
// Evict oldest entries to prevent unbounded growth
|
||||
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
||||
const first = seenRateLimitKeys.values().next().value;
|
||||
if (first) seenRateLimitKeys.delete(first);
|
||||
}
|
||||
|
||||
void NotificationManager.getInstance()
|
||||
.addError({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
sessionId: `team:${teamName}`,
|
||||
projectId: teamName,
|
||||
filePath: '',
|
||||
source: 'rate-limit',
|
||||
message: `[${msg.from}] ${msg.text.slice(0, 200)}`,
|
||||
triggerColor: 'red',
|
||||
triggerName: 'Rate Limit',
|
||||
context: {
|
||||
projectName: teamDisplayName,
|
||||
cwd: projectPath,
|
||||
},
|
||||
.addTeamNotification({
|
||||
teamEventType: 'rate_limit',
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: msg.from,
|
||||
summary: `Rate limit: ${msg.from}`,
|
||||
body: msg.text.slice(0, 200),
|
||||
dedupeKey,
|
||||
projectPath,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
|
@ -1950,19 +1950,39 @@ async function handleShowMessageNotification(
|
|||
if (!d.teamDisplayName || !d.from || !d.body) {
|
||||
return { success: false, error: 'Missing required fields (teamDisplayName, from, body)' };
|
||||
}
|
||||
if (!d.teamName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing required field: teamName (needed for deep-link navigation)',
|
||||
};
|
||||
}
|
||||
|
||||
// Route through NotificationManager for unified storage + native toast.
|
||||
// dedupeKey is required from renderer — built from stable identifiers (taskId, teamName, etc.)
|
||||
const dedupeKey =
|
||||
d.dedupeKey ?? `msg:${d.teamName}:${d.from}:${d.summary ?? d.body.slice(0, 50)}`;
|
||||
|
||||
void NotificationManager.getInstance()
|
||||
.addTeamNotification({
|
||||
teamEventType: d.teamEventType ?? 'task_clarification',
|
||||
teamName: d.teamName,
|
||||
teamDisplayName: d.teamDisplayName,
|
||||
from: d.from,
|
||||
to: d.to,
|
||||
summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||||
body: d.body,
|
||||
dedupeKey,
|
||||
suppressToast: d.suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: d.teamDisplayName,
|
||||
subtitle: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||||
body: d.body,
|
||||
});
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a native OS notification for a team event.
|
||||
* Respects user's notification settings (enabled, snoozed).
|
||||
* Cross-platform: macOS, Linux, Windows via Electron Notification API.
|
||||
* @deprecated Use NotificationManager.addTeamNotification() instead for unified storage + toast.
|
||||
* Kept for backward compatibility with any remaining callers.
|
||||
*/
|
||||
export function showTeamNativeNotification(opts: {
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,17 @@ export interface DetectedError {
|
|||
triggerId?: string;
|
||||
/** Human-readable name of the trigger that produced this notification */
|
||||
triggerName?: string;
|
||||
/** 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';
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context about the error */
|
||||
context: {
|
||||
/** Human-readable project name */
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
/**
|
||||
* NotificationManager service - Manages native macOS notifications and error history.
|
||||
* NotificationManager service - Manages native notifications and notification history.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Store error history at ~/.claude/claude-devtools-notifications.json (max 100 entries)
|
||||
* - Store notification history at ~/.claude/claude-devtools-notifications.json (max 100 entries)
|
||||
* - Show native notifications using Electron's Notification API (cross-platform)
|
||||
* - Implement throttling (5 seconds per unique error hash)
|
||||
* - Respect config.notifications.enabled and snoozedUntil
|
||||
* - Filter errors matching ignoredRegex patterns
|
||||
* - Filter errors from ignoredProjects
|
||||
* - Two adapters: addError() for error notifications, addTeamNotification() for team events
|
||||
* - Shared internal pipeline: storeNotification() for unconditional storage + IPC emission
|
||||
* - Two-level dedup: dedupeKey for storage dedup, toast throttle (5s) for native toasts
|
||||
* - Storage is unconditional — enabled/snoozed only affect native OS toasts
|
||||
* - Respect config.notifications.enabled and snoozedUntil for toasts
|
||||
* - Filter errors matching ignoredRegex patterns (error-specific)
|
||||
* - Filter errors from ignoredProjects (error-specific)
|
||||
* - Auto-prune notifications over 100 on startup
|
||||
* - Emit IPC events to renderer: notification:new, notification:updated
|
||||
*/
|
||||
|
|
@ -24,6 +27,10 @@ import * as path from 'path';
|
|||
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
||||
|
||||
const logger = createLogger('Service:NotificationManager');
|
||||
import {
|
||||
buildDetectedErrorFromTeam,
|
||||
type TeamNotificationPayload,
|
||||
} from '@main/utils/teamNotificationBuilder';
|
||||
import { projectPathResolver } from '../discovery/ProjectPathResolver';
|
||||
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
||||
|
||||
|
|
@ -31,6 +38,8 @@ import { ConfigManager } from './ConfigManager';
|
|||
|
||||
// Re-export DetectedError for backward compatibility
|
||||
export type { DetectedError };
|
||||
// Re-export team notification types for callers
|
||||
export type { TeamNotificationPayload, TeamEventType } from '@main/utils/teamNotificationBuilder';
|
||||
|
||||
/**
|
||||
* Stored notification with read status.
|
||||
|
|
@ -236,18 +245,19 @@ export class NotificationManager extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks if an error should be throttled.
|
||||
* Checks if a native toast should be throttled.
|
||||
* Uses dedupeKey if present, else falls back to projectId:message hash.
|
||||
*/
|
||||
private isThrottled(error: DetectedError): boolean {
|
||||
const hash = this.generateErrorHash(error);
|
||||
const lastSeen = this.throttleMap.get(hash);
|
||||
private isToastThrottled(error: DetectedError): boolean {
|
||||
const key = error.dedupeKey ?? this.generateErrorHash(error);
|
||||
const lastSeen = this.throttleMap.get(key);
|
||||
|
||||
if (lastSeen && Date.now() - lastSeen < THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update throttle map
|
||||
this.throttleMap.set(hash, Date.now());
|
||||
this.throttleMap.set(key, Date.now());
|
||||
|
||||
// Clean up old entries periodically
|
||||
this.cleanupThrottleMap();
|
||||
|
|
@ -349,81 +359,90 @@ export class NotificationManager extends EventEmitter {
|
|||
return ignoredRepositories.includes(identity.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error should generate a notification.
|
||||
*/
|
||||
private async shouldNotify(error: DetectedError): Promise<boolean> {
|
||||
// Check if notifications are enabled
|
||||
if (!this.areNotificationsEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if error is from an ignored repository
|
||||
if (await this.isFromIgnoredRepository(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if error matches an ignored regex
|
||||
if (this.matchesIgnoredRegex(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check throttling (for native toast dedup only — storage is unconditional)
|
||||
if (this.isThrottled(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Native Notifications
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Shows a native notification for an error.
|
||||
* Note: Electron's `subtitle` option only works on macOS.
|
||||
* On Windows/Linux, we prepend the subtitle to the body instead.
|
||||
* Closes over `stored` (StoredNotification) so click handler has full data.
|
||||
*/
|
||||
private showNativeNotification(error: DetectedError): void {
|
||||
// Guard against standalone/Docker mode where Electron's Notification API is unavailable
|
||||
private showErrorNativeNotification(stored: StoredNotification): void {
|
||||
if (!this.isNativeNotificationSupported()) return;
|
||||
|
||||
const config = this.configManager.getConfig();
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedMessage = stripMarkdown(stored.message).slice(0, 200);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: 'Claude Code Error',
|
||||
...(isMac ? { subtitle: stored.context.projectName } : {}),
|
||||
body: isMac ? truncatedMessage : `${stored.context.projectName}\n${truncatedMessage}`,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
this.handleNativeNotificationClick(stored);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a native notification for a team event.
|
||||
* Uses team-specific formatting (title = team name, subtitle = summary).
|
||||
*/
|
||||
private showTeamNativeNotification(
|
||||
stored: StoredNotification,
|
||||
payload: TeamNotificationPayload
|
||||
): void {
|
||||
if (!this.isNativeNotificationSupported()) return;
|
||||
|
||||
const config = this.configManager.getConfig();
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedBody = stripMarkdown(payload.body).slice(0, 300);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const notification = new Notification({
|
||||
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 } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
this.handleNativeNotificationClick(stored);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared click handler for native notifications — focuses window and emits deep-link.
|
||||
*/
|
||||
private handleNativeNotificationClick(stored: StoredNotification): void {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
this.mainWindow.webContents.send('notification:clicked', stored);
|
||||
}
|
||||
this.emit('notification-clicked', stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: checks if Electron's Notification API is available.
|
||||
*/
|
||||
private isNativeNotificationSupported(): boolean {
|
||||
if (
|
||||
typeof Notification === 'undefined' ||
|
||||
typeof Notification.isSupported !== 'function' ||
|
||||
!Notification.isSupported()
|
||||
) {
|
||||
logger.warn('Native notifications not supported');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = this.configManager.getConfig();
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedMessage = stripMarkdown(error.message).slice(0, 200);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: 'Claude Code Error',
|
||||
...(isMac ? { subtitle: error.context.projectName } : {}),
|
||||
body: isMac ? truncatedMessage : `${error.context.projectName}\n${truncatedMessage}`,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
// Focus app window
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
|
||||
// Send deep link to renderer
|
||||
this.mainWindow.webContents.send('notification:clicked', error);
|
||||
}
|
||||
|
||||
// Emit event for other listeners
|
||||
this.emit('notification-clicked', error);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -463,17 +482,21 @@ export class NotificationManager extends EventEmitter {
|
|||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Adds an error and shows a notification if enabled.
|
||||
* @param error - The detected error to add
|
||||
* @returns The stored notification, or null if filtered/throttled
|
||||
* Stores a notification unconditionally. Emits IPC events to renderer.
|
||||
* Returns null if dedupeKey already exists in storage (storage-level dedupe)
|
||||
* or if toolUseId-based dedup skips it.
|
||||
*/
|
||||
async addError(error: DetectedError): Promise<StoredNotification | null> {
|
||||
// Wait for async initialization to complete before modifying notifications.
|
||||
// Prevents a race where saveNotifications() overwrites not-yet-loaded data.
|
||||
private async storeNotification(error: DetectedError): Promise<StoredNotification | null> {
|
||||
if (this.initPromise) {
|
||||
await this.initPromise;
|
||||
}
|
||||
|
||||
// Storage-level dedupe by dedupeKey (persistent, lives as long as notification is in storage)
|
||||
if (error.dedupeKey) {
|
||||
const exists = this.notifications.some((n) => n.dedupeKey === error.dedupeKey);
|
||||
if (exists) return null;
|
||||
}
|
||||
|
||||
// Deduplicate by toolUseId: the same tool call can appear in both the
|
||||
// subagent JSONL file and the parent session JSONL (as a progress event).
|
||||
// Keep the subagent-annotated version (with subagentId) when possible.
|
||||
|
|
@ -511,12 +534,46 @@ export class NotificationManager extends EventEmitter {
|
|||
// Emit authoritative counters (total/unread) so renderer badge stays in sync.
|
||||
this.emitNotificationUpdated();
|
||||
|
||||
// Show native notification if enabled and not filtered
|
||||
if (await this.shouldNotify(error)) {
|
||||
this.showNativeNotification(error);
|
||||
return storedNotification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an error notification. Storage is unconditional; native toast respects
|
||||
* enabled/snoozed, ignored repos, ignored regex, and 5s throttle.
|
||||
*/
|
||||
async addError(error: DetectedError): Promise<StoredNotification | null> {
|
||||
const stored = await this.storeNotification(error);
|
||||
if (!stored) return null;
|
||||
|
||||
// Error-specific toast policy: repo filter + regex filter + enabled/snoozed + throttle
|
||||
if (
|
||||
this.areNotificationsEnabled() &&
|
||||
!(await this.isFromIgnoredRepository(error)) &&
|
||||
!this.matchesIgnoredRegex(error) &&
|
||||
!this.isToastThrottled(error)
|
||||
) {
|
||||
this.showErrorNativeNotification(stored);
|
||||
}
|
||||
|
||||
return storedNotification;
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a team notification. Storage is unconditional; native toast respects
|
||||
* enabled/snoozed, suppressToast flag, and 5s dedupeKey-based throttle.
|
||||
* Skips repo/regex filters (not applicable to team events).
|
||||
*/
|
||||
async addTeamNotification(payload: TeamNotificationPayload): Promise<StoredNotification | null> {
|
||||
const error = buildDetectedErrorFromTeam(payload);
|
||||
const stored = await this.storeNotification(error);
|
||||
if (!stored) return null;
|
||||
|
||||
// Team-specific toast policy: enabled/snoozed + suppressToast + dedupeKey throttle only
|
||||
if (!payload.suppressToast && this.areNotificationsEnabled() && !this.isToastThrottled(error)) {
|
||||
this.showTeamNativeNotification(stored, payload);
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
94
src/main/utils/teamNotificationBuilder.ts
Normal file
94
src/main/utils/teamNotificationBuilder.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Team notification builder — creates DetectedError objects from team event payloads.
|
||||
*
|
||||
* Pure utility with no service dependencies. Used by NotificationManager.addTeamNotification()
|
||||
* to convert domain-level team payloads into the unified notification format.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
|
||||
import type { DetectedError } from '../services/error/ErrorMessageBuilder';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type TeamEventType =
|
||||
| 'rate_limit'
|
||||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change';
|
||||
|
||||
/**
|
||||
* Domain payload for team notifications.
|
||||
* Single source of truth — both storage and native presentation are derived from this.
|
||||
*/
|
||||
export interface TeamNotificationPayload {
|
||||
teamEventType: TeamEventType;
|
||||
teamName: string;
|
||||
teamDisplayName: string;
|
||||
from: string;
|
||||
to?: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
/** Stable key for storage deduplication. REQUIRED — no fallback to Date.now(). */
|
||||
dedupeKey: string;
|
||||
projectPath?: string;
|
||||
/**
|
||||
* When true, the notification is stored in-app but no native OS toast is shown.
|
||||
* Used when per-type toggle (e.g. notifyOnLeadInbox) is off — storage is unconditional,
|
||||
* but the user opted out of OS interruptions for this event type.
|
||||
*/
|
||||
suppressToast?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config mapping
|
||||
// =============================================================================
|
||||
|
||||
interface TeamNotificationConfig {
|
||||
triggerName: string;
|
||||
triggerColor: TriggerColor;
|
||||
}
|
||||
|
||||
const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> = {
|
||||
rate_limit: { triggerName: 'Rate Limit', triggerColor: 'red' },
|
||||
lead_inbox: { triggerName: 'Team Inbox', triggerColor: 'blue' },
|
||||
user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' },
|
||||
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Builder
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Converts a team notification payload into a DetectedError for unified storage.
|
||||
* Uses `sessionId: 'team:{teamName}'` convention (established by rate-limit notifications).
|
||||
*/
|
||||
export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): DetectedError {
|
||||
const config = TEAM_NOTIFICATION_CONFIG[payload.teamEventType];
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
sessionId: `team:${payload.teamName}`,
|
||||
projectId: payload.teamName,
|
||||
filePath: '',
|
||||
source: payload.teamEventType,
|
||||
message: `[${payload.from}] ${payload.body.slice(0, 300)}`,
|
||||
category: 'team',
|
||||
teamEventType: payload.teamEventType,
|
||||
dedupeKey: payload.dedupeKey,
|
||||
triggerColor: config.triggerColor,
|
||||
triggerName: config.triggerName,
|
||||
context: {
|
||||
projectName: payload.teamDisplayName,
|
||||
cwd: payload.projectPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
|
|||
warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' },
|
||||
};
|
||||
|
||||
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
|
||||
const BANNER_MIN_H = 'min-h-[4.25rem]';
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
|
@ -180,7 +183,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (cliStatusError && !cliStatusLoading) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{
|
||||
borderColor: VARIANT_STYLES.error.border,
|
||||
backgroundColor: VARIANT_STYLES.error.bg,
|
||||
|
|
@ -211,7 +214,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (!cliStatusLoading) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 flex items-center justify-between gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
@ -232,7 +235,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
// Loading state: show spinner only while an actual request is in-flight.
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<Loader2
|
||||
|
|
@ -250,7 +253,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'downloading') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 space-y-2 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 space-y-2 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -292,11 +295,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...';
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{label}
|
||||
</span>
|
||||
|
|
@ -310,11 +313,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'installing') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Installing Claude CLI...
|
||||
</span>
|
||||
|
|
@ -328,7 +331,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'completed') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
|
||||
|
|
@ -343,7 +346,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'error') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<ErrorDisplay error={installerError ?? 'Installation failed'} onRetry={handleInstall} />
|
||||
|
|
@ -446,7 +449,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
// Installed — show version, path, update info
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useState } from 'react';
|
|||
|
||||
import { getTriggerColorDef } from '@shared/constants/triggerColors';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ArrowRight, Bot, Check, Trash2 } from 'lucide-react';
|
||||
import { ArrowRight, Bot, Check, Trash2, Users } from 'lucide-react';
|
||||
|
||||
import type { DetectedError } from '@renderer/types/data';
|
||||
|
||||
|
|
@ -41,6 +41,7 @@ export const NotificationRow = ({
|
|||
const truncatedMessage = truncateMessage(error.message);
|
||||
const colorDef = getTriggerColorDef(error.triggerColor);
|
||||
const displayName = error.triggerName ?? error.source;
|
||||
const isTeamNotification = error.category === 'team' || error.sessionId?.startsWith('team:');
|
||||
|
||||
const handleArchiveClick = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -102,6 +103,19 @@ export const NotificationRow = ({
|
|||
<span className="truncate text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{projectName}
|
||||
</span>
|
||||
{isTeamNotification && !error.subagentId && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--tag-bg)',
|
||||
border: '1px solid var(--tag-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
<Users className="size-3" />
|
||||
team
|
||||
</span>
|
||||
)}
|
||||
{error.subagentId && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
|
|
|
|||
|
|
@ -489,17 +489,6 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
+{pendingNewCount} new
|
||||
</Button>
|
||||
)}
|
||||
{showMoreVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -527,6 +516,21 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
void loadOlderLogs();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
showMoreVisible ? (
|
||||
<div className="flex justify-center py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{!error && data.lines.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ interface CliLogsRichViewProps {
|
|||
/** Optional local search query override for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
className?: string;
|
||||
/** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -237,6 +239,7 @@ export const CliLogsRichView = ({
|
|||
containerRefCallback,
|
||||
searchQueryOverride,
|
||||
className,
|
||||
footer,
|
||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToEdgeRef = useRef(true);
|
||||
|
|
@ -370,6 +373,7 @@ export const CliLogsRichView = ({
|
|||
Waiting for CLI output...
|
||||
</p>
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -426,6 +430,7 @@ export const CliLogsRichView = ({
|
|||
/>
|
||||
)
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import {
|
||||
getTeamColorSet,
|
||||
getThemedBadge,
|
||||
getThemedBorder,
|
||||
getThemedText,
|
||||
} from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||
|
||||
|
|
@ -32,8 +37,8 @@ export const MemberBadge = ({
|
|||
|
||||
const badgeStyle = {
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
color: getThemedText(colors, isLight),
|
||||
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||
};
|
||||
|
||||
const avatar = (
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import {
|
|||
CARD_ICON_MUTED,
|
||||
CARD_TEXT_LIGHT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import {
|
||||
getMessageTypeLabel,
|
||||
getStructuredMessageSummary,
|
||||
|
|
@ -250,6 +251,7 @@ export const ActivityItem = ({
|
|||
collapseState,
|
||||
}: ActivityItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||
const { isLight } = useTheme();
|
||||
const formattedRole = formatAgentRole(memberRole);
|
||||
|
||||
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
||||
|
|
@ -353,7 +355,7 @@ export const ActivityItem = ({
|
|||
? '3px solid var(--tool-result-error-text)'
|
||||
: isSystemMessage
|
||||
? '3px solid var(--system-activity-accent)'
|
||||
: `3px solid ${colors.border}`,
|
||||
: `3px solid ${getThemedBorder(colors, isLight)}`,
|
||||
}}
|
||||
>
|
||||
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
|
||||
import { AnimatedHeightReveal } from './AnimatedHeightReveal';
|
||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
|
||||
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||
|
|
@ -106,7 +107,7 @@ const MessageRowWithObserver = ({
|
|||
}, [onVisible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
||||
<ActivityItem
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
|
|
@ -123,7 +124,7 @@ const MessageRowWithObserver = ({
|
|||
onRestartTeam={onRestartTeam}
|
||||
collapseState={collapseState}
|
||||
/>
|
||||
</div>
|
||||
</AnimatedHeightReveal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -229,10 +230,7 @@ export const ActivityTimeline = ({
|
|||
return result;
|
||||
}, [timelineItems]);
|
||||
|
||||
// Determine which items are "new" (should animate).
|
||||
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
|
||||
|
||||
const newItemKeys = useMemo(() => {
|
||||
const timelineItemKeys = useMemo(() => {
|
||||
const getItemKey = (item: TimelineItem): string => {
|
||||
if (item.type === 'lead-thoughts') {
|
||||
// Stable key: identify group by its first thought, not by count (which changes)
|
||||
|
|
@ -242,43 +240,35 @@ export const ActivityTimeline = ({
|
|||
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
|
||||
};
|
||||
|
||||
const allKeys: string[] = [];
|
||||
for (const item of timelineItems) {
|
||||
allKeys.push(getItemKey(item));
|
||||
}
|
||||
return timelineItems.map(getItemKey);
|
||||
}, [timelineItems]);
|
||||
|
||||
// First render: seed known keys, no animations
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
for (const key of allKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
const isPaginationExpansion =
|
||||
isInitializedRef.current && visibleCount > prevVisibleCountRef.current;
|
||||
|
||||
const newItemKeys = useMemo(() => {
|
||||
if (!isInitializedRef.current || isPaginationExpansion) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Pagination expansion ("Show more" / "Show all"): add keys silently
|
||||
const isPaginationExpansion = visibleCount > prevVisibleCountRef.current;
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
|
||||
if (isPaginationExpansion) {
|
||||
for (const key of allKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Normal update: unknown keys are new items
|
||||
const newKeys = new Set<string>();
|
||||
for (const key of allKeys) {
|
||||
for (const key of timelineItemKeys) {
|
||||
if (!knownKeysRef.current.has(key)) {
|
||||
newKeys.add(key);
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
}
|
||||
return newKeys;
|
||||
}, [timelineItems, visibleCount]);
|
||||
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
||||
}, [isPaginationExpansion, timelineItemKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
for (const key of timelineItemKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
}, [timelineItemKeys, visibleCount]);
|
||||
|
||||
const handleShowMore = (): void => {
|
||||
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||
|
|
@ -354,6 +344,7 @@ export const ActivityTimeline = ({
|
|||
collapseState={collapseState}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
onReply={onReplyToMessage}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
@ -403,6 +394,7 @@ export const ActivityTimeline = ({
|
|||
collapseState={collapseState}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
onReply={onReplyToMessage}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
|
|||
101
src/renderer/components/team/activity/AnimatedHeightReveal.tsx
Normal file
101
src/renderer/components/team/activity/AnimatedHeightReveal.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react';
|
||||
|
||||
export const ENTRY_REVEAL_ANIMATION_MS = 700;
|
||||
export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
interface AnimatedHeightRevealProps extends PropsWithChildren {
|
||||
animate?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
containerRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
(ref as MutableRefObject<T | null>).current = value;
|
||||
}
|
||||
|
||||
export const AnimatedHeightReveal = ({
|
||||
animate,
|
||||
className,
|
||||
style,
|
||||
containerRef,
|
||||
children,
|
||||
}: AnimatedHeightRevealProps): JSX.Element => {
|
||||
const shouldAnimateOnMountRef = useRef(Boolean(animate));
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const prefersReducedMotionRef = useRef(false);
|
||||
const [isExpanded, setIsExpanded] = useState(() => !shouldAnimateOnMountRef.current);
|
||||
|
||||
const setWrapperRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
wrapperRef.current = node;
|
||||
assignRef(containerRef, node);
|
||||
},
|
||||
[containerRef]
|
||||
);
|
||||
|
||||
const clearPendingAnimation = useCallback(() => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
prefersReducedMotionRef.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (!shouldAnimateOnMountRef.current || prefersReducedMotionRef.current) {
|
||||
setIsExpanded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
setIsExpanded(true);
|
||||
animationFrameRef.current = null;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearPendingAnimation();
|
||||
};
|
||||
}, [clearPendingAnimation]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearPendingAnimation();
|
||||
},
|
||||
[clearPendingAnimation]
|
||||
);
|
||||
|
||||
const shouldTransition =
|
||||
shouldAnimateOnMountRef.current && !prefersReducedMotionRef.current && isExpanded;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setWrapperRef}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
transition: shouldTransition
|
||||
? [
|
||||
`grid-template-rows ${ENTRY_REVEAL_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`,
|
||||
`opacity ${ENTRY_REVEAL_ANIMATION_MS}ms ease`,
|
||||
].join(', ')
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ minHeight: 0, overflow: 'hidden' }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
|
||||
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
|
|
@ -15,8 +15,14 @@ import {
|
|||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
|
||||
import {
|
||||
AnimatedHeightReveal,
|
||||
ENTRY_REVEAL_ANIMATION_MS,
|
||||
ENTRY_REVEAL_EASING,
|
||||
} from './AnimatedHeightReveal';
|
||||
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
|
||||
import { isManagedCollapseState } from './collapseState';
|
||||
|
||||
|
|
@ -86,7 +92,7 @@ const VIEWPORT_THRESHOLD = 0.15;
|
|||
const LIVE_WINDOW_MS = 5_000;
|
||||
const COLLAPSED_THOUGHTS_HEIGHT = 200;
|
||||
const AUTO_SCROLL_THRESHOLD = 30;
|
||||
const THOUGHT_HEIGHT_ANIMATION_MS = 220;
|
||||
const THOUGHT_HEIGHT_ANIMATION_MS = ENTRY_REVEAL_ANIMATION_MS;
|
||||
|
||||
interface LeadThoughtsGroupRowProps {
|
||||
group: LeadThoughtGroup;
|
||||
|
|
@ -103,6 +109,8 @@ interface LeadThoughtsGroupRowProps {
|
|||
onTaskIdClick?: (taskId: string) => void;
|
||||
/** Map of member name → color name for @mention badge rendering. */
|
||||
memberColorMap?: Map<string, string>;
|
||||
/** Called when user clicks the reply button on a thought. */
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
|
|
@ -187,6 +195,7 @@ interface LeadThoughtItemProps {
|
|||
shouldAnimate: boolean;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
memberColorMap?: Map<string, string>;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
}
|
||||
|
||||
const LeadThoughtItem = ({
|
||||
|
|
@ -195,6 +204,7 @@ const LeadThoughtItem = ({
|
|||
shouldAnimate,
|
||||
onTaskIdClick,
|
||||
memberColorMap,
|
||||
onReply,
|
||||
}: LeadThoughtItemProps): JSX.Element => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -229,6 +239,7 @@ const LeadThoughtItem = ({
|
|||
wrapper.style.opacity = '1';
|
||||
wrapper.style.overflow = 'visible';
|
||||
wrapper.style.transition = '';
|
||||
wrapper.style.willChange = '';
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
|
@ -246,12 +257,18 @@ const LeadThoughtItem = ({
|
|||
wrapper.style.overflow = 'hidden';
|
||||
wrapper.style.height = `${Math.max(startHeight, 0)}px`;
|
||||
wrapper.style.opacity = `${startOpacity}`;
|
||||
wrapper.style.willChange = 'height, opacity';
|
||||
void wrapper.offsetHeight;
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
wrapper.style.transition = `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease, opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`;
|
||||
wrapper.style.height = `${Math.max(targetHeight, 0)}px`;
|
||||
wrapper.style.opacity = '1';
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
wrapper.style.transition = [
|
||||
`height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`,
|
||||
`opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`,
|
||||
].join(', ');
|
||||
wrapper.style.height = `${Math.max(targetHeight, 0)}px`;
|
||||
wrapper.style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
cleanupTimerRef.current = window.setTimeout(() => {
|
||||
|
|
@ -352,7 +369,24 @@ const LeadThoughtItem = ({
|
|||
<MarkdownViewer content={displayContent} maxHeight="max-h-none" bare />
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute right-1 top-0.5 opacity-0 transition-opacity group-hover/thought:opacity-100">
|
||||
<div className="absolute right-1 top-0.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/thought:opacity-100">
|
||||
{onReply ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReply(thought);
|
||||
}}
|
||||
>
|
||||
<Reply size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Reply</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<CopyButton text={thought.text} inline />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -393,6 +427,7 @@ export const LeadThoughtsGroupRow = ({
|
|||
collapseState,
|
||||
onTaskIdClick,
|
||||
memberColorMap,
|
||||
onReply,
|
||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -559,7 +594,7 @@ export const LeadThoughtsGroupRow = ({
|
|||
[clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isBodyVisible) return;
|
||||
const contentEl = contentRef.current;
|
||||
if (!contentEl) return;
|
||||
|
|
@ -612,11 +647,7 @@ export const LeadThoughtsGroupRow = ({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}
|
||||
style={{ overflowAnchor: 'none' }}
|
||||
>
|
||||
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
|
||||
<article
|
||||
className="group rounded-md [overflow:clip]"
|
||||
style={{
|
||||
|
|
@ -656,15 +687,21 @@ export const LeadThoughtsGroupRow = ({
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* Live / offline indicator */}
|
||||
{isLive ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-zinc-500" />
|
||||
)}
|
||||
{/* Lead avatar with optional live indicator */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(leadName, 24)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
{isLive ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{thoughts.length} thoughts
|
||||
|
|
@ -716,6 +753,7 @@ export const LeadThoughtsGroupRow = ({
|
|||
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={memberColorMap}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -758,6 +796,6 @@ export const LeadThoughtsGroupRow = ({
|
|||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AnimatedHeightReveal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AlertCircle, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -7,6 +7,8 @@ import { ImageLightbox } from './ImageLightbox';
|
|||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
const ANIMATION_MS = 400;
|
||||
|
||||
interface AttachmentPreviewListProps {
|
||||
attachments: AttachmentPayload[];
|
||||
onRemove: (id: string) => void;
|
||||
|
|
@ -27,30 +29,117 @@ export const AttachmentPreviewList = ({
|
|||
disabledHint,
|
||||
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set());
|
||||
// Track IDs known on previous render to detect newly added items
|
||||
const knownIdsRef = useRef<Set<string>>(new Set());
|
||||
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set());
|
||||
const exitTimersRef = useRef<Map<string, number>>(new Map());
|
||||
const enterTimersRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
if (attachments.length === 0 && !error) return null;
|
||||
// Detect newly added attachments
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(attachments.map((a) => a.id));
|
||||
const newIds = new Set<string>();
|
||||
for (const id of currentIds) {
|
||||
if (!knownIdsRef.current.has(id)) {
|
||||
newIds.add(id);
|
||||
}
|
||||
}
|
||||
knownIdsRef.current = currentIds;
|
||||
|
||||
const lightboxSlides = attachments.map((att) => ({
|
||||
if (newIds.size === 0) return;
|
||||
|
||||
setEnteringIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of newIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Clear entering state after animation completes
|
||||
for (const id of newIds) {
|
||||
const timer = window.setTimeout(() => {
|
||||
setEnteringIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
enterTimersRef.current.delete(id);
|
||||
}, ANIMATION_MS);
|
||||
enterTimersRef.current.set(id, timer);
|
||||
}
|
||||
}, [attachments]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const t of exitTimersRef.current.values()) window.clearTimeout(t);
|
||||
for (const t of enterTimersRef.current.values()) window.clearTimeout(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
// Start exit animation
|
||||
setExitingIds((prev) => new Set(prev).add(id));
|
||||
|
||||
// Actually remove after animation
|
||||
const timer = window.setTimeout(() => {
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
exitTimersRef.current.delete(id);
|
||||
onRemove(id);
|
||||
}, ANIMATION_MS);
|
||||
exitTimersRef.current.set(id, timer);
|
||||
},
|
||||
[onRemove]
|
||||
);
|
||||
|
||||
// Include exiting items that are no longer in attachments (they were removed by parent)
|
||||
// This shouldn't normally happen since we delay onRemove, but guard against it.
|
||||
const visibleAttachments = attachments;
|
||||
|
||||
if (visibleAttachments.length === 0 && exitingIds.size === 0 && !error) return null;
|
||||
|
||||
const lightboxSlides = visibleAttachments.map((att) => ({
|
||||
src: `data:${att.mimeType};base64,${att.data}`,
|
||||
alt: att.filename,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 px-1">
|
||||
{attachments.length > 0 ? (
|
||||
{visibleAttachments.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto py-1">
|
||||
{attachments.map((att, i) => (
|
||||
<AttachmentPreviewItem
|
||||
key={att.id}
|
||||
attachment={att}
|
||||
onRemove={onRemove}
|
||||
onPreview={() => setLightboxIndex(i)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{visibleAttachments.map((att, i) => {
|
||||
const isExiting = exitingIds.has(att.id);
|
||||
const isEntering = enteringIds.has(att.id);
|
||||
return (
|
||||
<div
|
||||
key={att.id}
|
||||
style={{
|
||||
transition: `transform ${ANIMATION_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity ${ANIMATION_MS}ms ease`,
|
||||
transform: isExiting ? 'scale(0)' : isEntering ? undefined : 'scale(1)',
|
||||
opacity: isExiting ? 0 : 1,
|
||||
transformOrigin: 'center center',
|
||||
animation: isEntering
|
||||
? `att-scale-in ${ANIMATION_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<AttachmentPreviewItem
|
||||
attachment={att}
|
||||
onRemove={handleRemove}
|
||||
onPreview={() => setLightboxIndex(i)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{disabled && disabledHint && attachments.length > 0 ? (
|
||||
{disabled && disabledHint && visibleAttachments.length > 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5"
|
||||
style={{ backgroundColor: 'var(--warning-bg)', color: 'var(--warning-text)' }}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ export const SendMessageDialog = ({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="min-w-0 sm:max-w-4xl"
|
||||
className="min-w-0 max-w-3xl"
|
||||
onDragEnter={canAttach ? handleDragEnter : undefined}
|
||||
onDragLeave={canAttach ? handleDragLeave : undefined}
|
||||
onDragOver={canAttach ? handleDragOver : undefined}
|
||||
|
|
@ -331,9 +331,9 @@ export const SendMessageDialog = ({
|
|||
|
||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||
{quote ? (
|
||||
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
|
||||
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-400/30 bg-blue-100/80 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
|
||||
{/* Decorative quotation mark */}
|
||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[64px] leading-none text-blue-400/[0.08]">
|
||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[64px] leading-none text-blue-500/[0.08] dark:text-blue-400/[0.08]">
|
||||
“
|
||||
</span>
|
||||
|
||||
|
|
@ -341,7 +341,7 @@ export const SendMessageDialog = ({
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-300/40 hover:text-blue-200"
|
||||
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-400/60 hover:text-blue-600 dark:text-blue-300/40 dark:hover:text-blue-200"
|
||||
onClick={() => setQuote(undefined)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
|
@ -351,11 +351,13 @@ export const SendMessageDialog = ({
|
|||
</Tooltip>
|
||||
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-blue-300/60">Replying to</span>
|
||||
<span className="text-[10px] text-blue-600/70 dark:text-blue-300/60">
|
||||
Replying to
|
||||
</span>
|
||||
<MemberBadge name={quote.from} color={colorMap.get(quote.from)} size="sm" />
|
||||
</div>
|
||||
<div
|
||||
className={`pr-5 opacity-50 ${quoteExpanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
|
||||
className={`pr-5 opacity-60 dark:opacity-50 ${quoteExpanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={quote.text}
|
||||
|
|
@ -366,7 +368,7 @@ export const SendMessageDialog = ({
|
|||
{isQuoteLong ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
|
||||
className="mt-0.5 text-[10px] text-blue-500 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
|
||||
onClick={() => setQuoteExpanded((v) => !v)}
|
||||
>
|
||||
{quoteExpanded ? 'less' : 'more'}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { AnimatedHeightReveal } from '@renderer/components/team/activity/AnimatedHeightReveal';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
|
|
@ -89,6 +90,9 @@ export const TaskCommentsSection = ({
|
|||
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
||||
const knownCommentIdsRef = useRef<Set<string>>(new Set());
|
||||
const isInitializedRef = useRef(false);
|
||||
const prevVisibleCountRef = useRef(INITIAL_VISIBLE_COMMENTS);
|
||||
|
||||
// Reset local UI state when team/task changes.
|
||||
useEffect(() => {
|
||||
|
|
@ -96,6 +100,9 @@ export const TaskCommentsSection = ({
|
|||
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
||||
setReplyTo(null);
|
||||
setPreviewImageUrl(null);
|
||||
knownCommentIdsRef.current = new Set();
|
||||
isInitializedRef.current = false;
|
||||
prevVisibleCountRef.current = INITIAL_VISIBLE_COMMENTS;
|
||||
}, [teamName, taskId]);
|
||||
|
||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
|
|
@ -119,6 +126,38 @@ export const TaskCommentsSection = ({
|
|||
[sortedComments, visibleCount]
|
||||
);
|
||||
|
||||
const visibleCommentIds = useMemo(
|
||||
() => visibleComments.map((comment) => comment.id),
|
||||
[visibleComments]
|
||||
);
|
||||
|
||||
const isPaginationExpansion =
|
||||
isInitializedRef.current && visibleCount > prevVisibleCountRef.current;
|
||||
|
||||
const newCommentIds = useMemo(() => {
|
||||
if (!isInitializedRef.current || isPaginationExpansion) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const next = new Set<string>();
|
||||
for (const id of visibleCommentIds) {
|
||||
if (!knownCommentIdsRef.current.has(id)) {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}, [isPaginationExpansion, visibleCommentIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
for (const id of visibleCommentIds) {
|
||||
knownCommentIdsRef.current.add(id);
|
||||
}
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
}, [visibleCommentIds, visibleCount]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
|
|
@ -171,133 +210,134 @@ export const TaskCommentsSection = ({
|
|||
|
||||
<div className={containerClassName ?? ''}>
|
||||
{visibleComments.map((comment, index) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={[
|
||||
'group px-4 py-2.5',
|
||||
comment.type === 'review_approved'
|
||||
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
||||
: comment.type === 'review_request'
|
||||
? 'border-y border-blue-500/20 bg-blue-500/5'
|
||||
: '',
|
||||
].join(' ')}
|
||||
style={
|
||||
!comment.type || comment.type === 'regular'
|
||||
? {
|
||||
backgroundColor:
|
||||
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<MemberBadge
|
||||
name={comment.author}
|
||||
color={colorMap.get(comment.author)}
|
||||
hideAvatar={comment.author === 'user'}
|
||||
/>
|
||||
{comment.type === 'review_approved' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<CheckCircle2 size={10} />
|
||||
Approved
|
||||
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
||||
<div
|
||||
className={[
|
||||
'group px-4 py-2.5',
|
||||
comment.type === 'review_approved'
|
||||
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
||||
: comment.type === 'review_request'
|
||||
? 'border-y border-blue-500/20 bg-blue-500/5'
|
||||
: '',
|
||||
].join(' ')}
|
||||
style={
|
||||
!comment.type || comment.type === 'regular'
|
||||
? {
|
||||
backgroundColor:
|
||||
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<MemberBadge
|
||||
name={comment.author}
|
||||
color={colorMap.get(comment.author)}
|
||||
hideAvatar={comment.author === 'user'}
|
||||
/>
|
||||
{comment.type === 'review_approved' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<CheckCircle2 size={10} />
|
||||
Approved
|
||||
</span>
|
||||
) : comment.type === 'review_request' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<Eye size={10} />
|
||||
Review requested
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{(() => {
|
||||
const date = new Date(comment.createdAt);
|
||||
return isNaN(date.getTime())
|
||||
? 'unknown time'
|
||||
: formatDistanceToNow(date, { addSuffix: true });
|
||||
})()}
|
||||
</span>
|
||||
) : comment.type === 'review_request' ? (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<Eye size={10} />
|
||||
Review requested
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{(() => {
|
||||
const date = new Date(comment.createdAt);
|
||||
return isNaN(date.getTime())
|
||||
? 'unknown time'
|
||||
: formatDistanceToNow(date, { addSuffix: true });
|
||||
})()}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const replyText = stripAgentBlocks(
|
||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||
);
|
||||
if (onReply) {
|
||||
onReply(comment.author, replyText);
|
||||
} else {
|
||||
setReplyTo({ author: comment.author, text: replyText });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<CopyButton text={comment.text} inline />
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
||||
return (
|
||||
<ExpandableContent collapsedHeight={120} className="text-xs">
|
||||
{reply ? (
|
||||
<ReplyQuoteBlock
|
||||
reply={{
|
||||
...reply,
|
||||
originalText: stripAgentBlocks(reply.originalText),
|
||||
replyText: stripAgentBlocks(reply.replyText),
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const replyText = stripAgentBlocks(
|
||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||
);
|
||||
if (onReply) {
|
||||
onReply(comment.author, replyText);
|
||||
} else {
|
||||
setReplyTo({ author: comment.author, text: replyText });
|
||||
}
|
||||
}}
|
||||
memberColor={colorMap.get(reply.agentName)}
|
||||
bodyMaxHeight="max-h-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = link.getAttribute('href')?.replace('task://', '');
|
||||
if (id) onTaskIdClick(id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={(() => {
|
||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||
return t;
|
||||
})()}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<CopyButton text={comment.text} inline />
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
||||
return (
|
||||
<ExpandableContent collapsedHeight={120} className="text-xs">
|
||||
{reply ? (
|
||||
<ReplyQuoteBlock
|
||||
reply={{
|
||||
...reply,
|
||||
originalText: stripAgentBlocks(reply.originalText),
|
||||
replyText: stripAgentBlocks(reply.replyText),
|
||||
}}
|
||||
memberColor={colorMap.get(reply.agentName)}
|
||||
bodyMaxHeight="max-h-none"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</ExpandableContent>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (
|
||||
e.target as HTMLElement
|
||||
).closest<HTMLAnchorElement>('a[href^="task://"]');
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = link.getAttribute('href')?.replace('task://', '');
|
||||
if (id) onTaskIdClick(id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={(() => {
|
||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||
return t;
|
||||
})()}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</ExpandableContent>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AnimatedHeightReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -601,7 +601,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
const rotatingTips = React.useMemo(
|
||||
() => [
|
||||
'Tip: Use @ to mention team members or search files',
|
||||
'Tip: Mention "create a task" to add it to the kanban',
|
||||
'Tip: Mention "delegate a task to a teammate" to add it to the kanban',
|
||||
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
|
||||
],
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
export interface TeamColorSet {
|
||||
/** Border accent color */
|
||||
border: string;
|
||||
/** Border accent color for light theme */
|
||||
borderLight?: string;
|
||||
/** Badge background (semi-transparent) */
|
||||
badge: string;
|
||||
/** Badge background for light theme (more visible on white) */
|
||||
|
|
@ -85,10 +87,11 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
|
|||
/** Reserved for the human user — never assigned to team members. */
|
||||
user: {
|
||||
border: '#f5f5f4',
|
||||
borderLight: '#a8a29e',
|
||||
badge: 'rgba(245, 245, 244, 0.12)',
|
||||
badgeLight: 'rgba(0, 0, 0, 0.08)',
|
||||
badgeLight: 'rgba(120, 113, 108, 0.14)',
|
||||
text: '#d6d3d1',
|
||||
textLight: '#57534e',
|
||||
textLight: '#44403c',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -148,3 +151,17 @@ export function getTeamColorSet(colorName: string): TeamColorSet {
|
|||
export function getThemedBadge(colorSet: TeamColorSet, isLight: boolean): string {
|
||||
return isLight && colorSet.badgeLight ? colorSet.badgeLight : colorSet.badge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate text color for the current theme.
|
||||
*/
|
||||
export function getThemedText(colorSet: TeamColorSet, isLight: boolean): string {
|
||||
return isLight && colorSet.textLight ? colorSet.textLight : colorSet.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate border color for the current theme.
|
||||
*/
|
||||
export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): string {
|
||||
return isLight && colorSet.borderLight ? colorSet.borderLight : colorSet.border;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,12 @@ export function useTheme(): {
|
|||
// Initialize from cache to prevent flash
|
||||
try {
|
||||
const cached = localStorage.getItem(THEME_CACHE_KEY);
|
||||
if (cached === 'light') return 'light';
|
||||
if (cached === 'light' || cached === 'dark') return cached;
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
return 'dark';
|
||||
// No cache — detect system preference for flash-free first launch
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
// Fetch config on mount if not loaded.
|
||||
|
|
@ -50,7 +51,7 @@ export function useTheme(): {
|
|||
}, [appConfig, configLoading, fetchConfig]);
|
||||
|
||||
// Get configured theme
|
||||
const configuredTheme: Theme = appConfig?.general?.theme ?? 'dark';
|
||||
const configuredTheme: Theme = appConfig?.general?.theme ?? 'system';
|
||||
|
||||
// Get system theme preference
|
||||
const getSystemTheme = useCallback((): ResolvedTheme => {
|
||||
|
|
|
|||
|
|
@ -624,21 +624,6 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes message-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message-enter-animate {
|
||||
animation: message-enter 300ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes chat-message-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -670,6 +655,17 @@ body {
|
|||
animation: thought-expand 350ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes att-scale-in {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
animation: skeleton-fade-in 0.4s ease-out both;
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -206,8 +206,8 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
// Mark the notification as read
|
||||
void state.markNotificationRead(error.id);
|
||||
|
||||
// Team rate-limit notifications: open the team tab instead of a session tab
|
||||
if (error.source === 'rate-limit' && error.sessionId.startsWith('team:')) {
|
||||
// Team notifications (inbox, clarification, status change, rate-limit): open team tab
|
||||
if (error.sessionId.startsWith('team:')) {
|
||||
const teamName = error.sessionId.slice('team:'.length);
|
||||
state.openTeamTab(teamName, error.context.cwd);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -106,9 +106,8 @@ function detectClarificationNotifications(
|
|||
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
||||
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
|
||||
notifiedClarificationTaskKeys.add(key);
|
||||
if (notifyEnabled) {
|
||||
fireClarificationNotification(task);
|
||||
}
|
||||
// Always store in-app; suppress OS toast when per-type toggle is off
|
||||
fireClarificationNotification(task, !notifyEnabled);
|
||||
}
|
||||
} else {
|
||||
notifiedClarificationTaskKeys.delete(key);
|
||||
|
|
@ -116,18 +115,22 @@ function detectClarificationNotifications(
|
|||
}
|
||||
}
|
||||
|
||||
function fireClarificationNotification(task: GlobalTask): void {
|
||||
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 = latestComment?.text || task.description || `Task #${task.id}: ${task.subject}`;
|
||||
|
||||
void api.teams
|
||||
?.showMessageNotification({
|
||||
teamName: task.teamName,
|
||||
teamDisplayName: task.teamDisplayName,
|
||||
from: latestComment?.author || 'team-lead',
|
||||
to: 'user',
|
||||
summary: `Clarification needed — Task #${task.id}`,
|
||||
body,
|
||||
teamEventType: 'task_clarification',
|
||||
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
|
@ -138,13 +141,12 @@ function detectStatusChangeNotifications(
|
|||
config: AppConfig | null,
|
||||
teamByName: Record<string, TeamSummary>
|
||||
): void {
|
||||
if (!config?.notifications?.notifyOnStatusChange) return;
|
||||
if (!config.notifications.enabled) return;
|
||||
|
||||
const statuses = config.notifications.statusChangeStatuses ?? ['in_progress', 'completed'];
|
||||
const statusChangeEnabled =
|
||||
!!config?.notifications?.notifyOnStatusChange && !!config.notifications.enabled;
|
||||
const statuses = config?.notifications?.statusChangeStatuses ?? ['in_progress', 'completed'];
|
||||
if (statuses.length === 0) return;
|
||||
|
||||
const onlySolo = config.notifications.statusChangeOnlySolo ?? true;
|
||||
const onlySolo = config?.notifications?.statusChangeOnlySolo ?? true;
|
||||
|
||||
for (const task of newTasks) {
|
||||
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
||||
|
|
@ -170,14 +172,20 @@ function detectStatusChangeNotifications(
|
|||
notifiedStatusChangeKeys.add(key);
|
||||
|
||||
const fromLabel = becameApproved ? 'Completed' : oldTask.status;
|
||||
fireStatusChangeNotification(task, fromLabel, becameApproved ? 'approved' : undefined);
|
||||
fireStatusChangeNotification(
|
||||
task,
|
||||
fromLabel,
|
||||
becameApproved ? 'approved' : undefined,
|
||||
!statusChangeEnabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fireStatusChangeNotification(
|
||||
task: GlobalTask,
|
||||
fromStatus: string,
|
||||
overrideToStatus?: string
|
||||
overrideToStatus?: string,
|
||||
suppressToast?: boolean
|
||||
): void {
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
|
|
@ -192,11 +200,15 @@ function fireStatusChangeNotification(
|
|||
|
||||
void api.teams
|
||||
?.showMessageNotification({
|
||||
teamName: task.teamName,
|
||||
teamDisplayName: task.teamDisplayName,
|
||||
from: task.owner ?? 'system',
|
||||
to: 'user',
|
||||
summary: `Task #${task.id}: ${from} → ${to}`,
|
||||
body: task.subject,
|
||||
teamEventType: 'task_status_change',
|
||||
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,17 @@ export interface DetectedError {
|
|||
triggerId?: string;
|
||||
/** Human-readable name of the trigger that produced this notification */
|
||||
triggerName?: string;
|
||||
/** 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';
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context */
|
||||
context: {
|
||||
/** Display name of the project */
|
||||
|
|
|
|||
|
|
@ -516,6 +516,8 @@ export interface ReplaceMembersRequest {
|
|||
/** Data sent from renderer to main for native OS team message notification. */
|
||||
export interface TeamMessageNotificationData {
|
||||
teamDisplayName: string;
|
||||
/** Team directory name (for notification storage and deep-linking). */
|
||||
teamName?: string;
|
||||
/** Who sent the message. */
|
||||
from: string;
|
||||
/** Who received the message (member name or "user"). */
|
||||
|
|
@ -526,6 +528,16 @@ export interface TeamMessageNotificationData {
|
|||
body: string;
|
||||
/** Optional sender color for visual context. */
|
||||
color?: string;
|
||||
/** Team event sub-type for notification categorization. */
|
||||
teamEventType?: 'task_clarification' | 'task_status_change';
|
||||
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
|
||||
dedupeKey?: string;
|
||||
/**
|
||||
* When true, the notification is stored in-app but no native OS toast is shown.
|
||||
* Used when per-type toggle is off — storage is unconditional,
|
||||
* but the user opted out of OS interruptions for this event type.
|
||||
*/
|
||||
suppressToast?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -14,6 +14,18 @@ vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
|
|||
return { ...actual };
|
||||
});
|
||||
|
||||
// Mock NotificationManager — handleShowMessageNotification calls addTeamNotification
|
||||
const { mockAddTeamNotification } = vi.hoisted(() => ({
|
||||
mockAddTeamNotification: vi.fn().mockResolvedValue({ id: 'n1', isRead: false, createdAt: Date.now() }),
|
||||
}));
|
||||
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||
NotificationManager: {
|
||||
getInstance: vi.fn().mockReturnValue({
|
||||
addTeamNotification: mockAddTeamNotification,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_STOP,
|
||||
|
|
@ -668,6 +680,61 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('showMessageNotification', () => {
|
||||
it('returns success on valid notification data', async () => {
|
||||
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamDisplayName: 'My Team',
|
||||
from: 'alice',
|
||||
body: 'Hello!',
|
||||
teamName: 'my-team',
|
||||
teamEventType: 'task_clarification',
|
||||
dedupeKey: 'clarification:my-team:42',
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when missing required fields', async () => {
|
||||
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamDisplayName: 'My Team',
|
||||
// missing from and body
|
||||
})) as { success: boolean; error: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Missing required fields');
|
||||
});
|
||||
|
||||
it('rejects null data', async () => {
|
||||
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
||||
const result = (await handler({} as never, null)) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('generates fallback dedupeKey when not provided', async () => {
|
||||
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamDisplayName: 'My Team',
|
||||
teamName: 'my-team',
|
||||
from: 'bob',
|
||||
body: 'Some message',
|
||||
})) as { success: boolean };
|
||||
// Should succeed even without explicit dedupeKey (fallback is generated)
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when teamName is missing', async () => {
|
||||
const handler = handlers.get(TEAM_SHOW_MESSAGE_NOTIFICATION)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamDisplayName: 'My Team',
|
||||
from: 'alice',
|
||||
body: 'Hello!',
|
||||
// teamName intentionally omitted
|
||||
})) as { success: boolean; error: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('teamName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved teammate names', () => {
|
||||
it('rejects teammate name "user" in createTeam', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE)!;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* NotificationManager team notification tests.
|
||||
*
|
||||
* Tests the addTeamNotification() adapter and its interaction with the
|
||||
* shared storage pipeline (storeNotification), dedupeKey-based dedupe,
|
||||
* and toast throttling.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder';
|
||||
|
||||
// --- Mock electron Notification before importing NotificationManager ---
|
||||
const mockNotificationShow = vi.fn();
|
||||
const mockNotificationOn = vi.fn();
|
||||
vi.mock('electron', () => ({
|
||||
Notification: Object.assign(
|
||||
vi.fn().mockImplementation(() => ({
|
||||
show: mockNotificationShow,
|
||||
on: mockNotificationOn,
|
||||
})),
|
||||
{ isSupported: vi.fn().mockReturnValue(true) }
|
||||
),
|
||||
BrowserWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
// --- Mock fs/promises to prevent disk I/O ---
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// --- Mock ConfigManager ---
|
||||
vi.mock('@main/services/infrastructure/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
getInstance: vi.fn().mockReturnValue({
|
||||
getConfig: vi.fn().mockReturnValue({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
soundEnabled: false,
|
||||
snoozedUntil: null,
|
||||
ignoredRegex: [],
|
||||
ignoredRepositories: [],
|
||||
},
|
||||
}),
|
||||
clearSnooze: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Mock path/service dependencies that NotificationManager imports ---
|
||||
vi.mock('@main/services/discovery/ProjectPathResolver', () => ({
|
||||
projectPathResolver: { resolveProjectPath: vi.fn().mockResolvedValue('/tmp') },
|
||||
}));
|
||||
vi.mock('@main/services/parsing/GitIdentityResolver', () => ({
|
||||
gitIdentityResolver: { resolveIdentity: vi.fn().mockResolvedValue(null) },
|
||||
}));
|
||||
vi.mock('@main/utils/appIcon', () => ({
|
||||
getAppIconPath: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
vi.mock('@main/utils/textFormatting', () => ({
|
||||
stripMarkdown: vi.fn((s: string) => s),
|
||||
}));
|
||||
|
||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||
|
||||
function makeTeamPayload(
|
||||
overrides: Partial<TeamNotificationPayload> = {}
|
||||
): TeamNotificationPayload {
|
||||
return {
|
||||
teamEventType: 'user_inbox',
|
||||
teamName: 'test-team',
|
||||
teamDisplayName: 'Test Team',
|
||||
from: 'alice',
|
||||
summary: 'New message from Alice',
|
||||
body: 'Hello from Alice!',
|
||||
dedupeKey: `inbox:test-team:alice:${Date.now()}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationManager.addTeamNotification', () => {
|
||||
let manager: NotificationManager;
|
||||
|
||||
const defaultConfig = {
|
||||
notifications: {
|
||||
enabled: true,
|
||||
soundEnabled: false,
|
||||
snoozedUntil: null,
|
||||
ignoredRegex: [],
|
||||
ignoredRepositories: [],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
NotificationManager.resetInstance();
|
||||
manager = new NotificationManager();
|
||||
await manager.initialize();
|
||||
mockNotificationShow.mockClear();
|
||||
mockNotificationOn.mockClear();
|
||||
// Restore default config — tests that override must not leak state
|
||||
const configMock = ConfigManager.getInstance().getConfig as ReturnType<typeof vi.fn>;
|
||||
configMock.mockReturnValue(defaultConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
NotificationManager.resetInstance();
|
||||
});
|
||||
|
||||
it('stores team notification and returns StoredNotification', async () => {
|
||||
const result = await manager.addTeamNotification(makeTeamPayload());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.category).toBe('team');
|
||||
expect(result!.teamEventType).toBe('user_inbox');
|
||||
expect(result!.isRead).toBe(false);
|
||||
expect(result!.createdAt).toBeGreaterThan(0);
|
||||
expect(result!.sessionId).toBe('team:test-team');
|
||||
expect(result!.dedupeKey).toContain('inbox:test-team:alice:');
|
||||
});
|
||||
|
||||
it('shows native toast when notifications are enabled', async () => {
|
||||
await manager.addTeamNotification(makeTeamPayload());
|
||||
expect(mockNotificationShow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('stores notification but suppresses toast when suppressToast is true', async () => {
|
||||
const result = await manager.addTeamNotification(
|
||||
makeTeamPayload({ dedupeKey: 'suppress-test' }),
|
||||
);
|
||||
// Clear from the first call
|
||||
mockNotificationShow.mockClear();
|
||||
|
||||
const result2 = await manager.addTeamNotification(
|
||||
makeTeamPayload({ dedupeKey: 'suppress-test-2', suppressToast: true }),
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
// The second call with suppressToast=true should NOT show a toast
|
||||
expect(mockNotificationShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores notification even when notifications are disabled (storage is unconditional)', async () => {
|
||||
const configMock = ConfigManager.getInstance().getConfig as ReturnType<typeof vi.fn>;
|
||||
configMock.mockReturnValue({
|
||||
notifications: {
|
||||
enabled: false,
|
||||
soundEnabled: false,
|
||||
snoozedUntil: null,
|
||||
ignoredRegex: [],
|
||||
ignoredRepositories: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.addTeamNotification(makeTeamPayload());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.category).toBe('team');
|
||||
// But no native toast
|
||||
expect(mockNotificationShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores notification even when snoozed (storage is unconditional)', async () => {
|
||||
const configMock = ConfigManager.getInstance().getConfig as ReturnType<typeof vi.fn>;
|
||||
configMock.mockReturnValue({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
soundEnabled: false,
|
||||
snoozedUntil: Date.now() + 60_000, // snoozed for 1 minute
|
||||
ignoredRegex: [],
|
||||
ignoredRepositories: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.addTeamNotification(makeTeamPayload());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockNotificationShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates by dedupeKey — same key returns null on second call', async () => {
|
||||
const payload = makeTeamPayload({ dedupeKey: 'unique-key-123' });
|
||||
|
||||
const first = await manager.addTeamNotification(payload);
|
||||
const second = await manager.addTeamNotification(payload);
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it('does not deduplicate different dedupeKeys', async () => {
|
||||
const first = await manager.addTeamNotification(
|
||||
makeTeamPayload({ dedupeKey: 'key-1' })
|
||||
);
|
||||
const second = await manager.addTeamNotification(
|
||||
makeTeamPayload({ dedupeKey: 'key-2' })
|
||||
);
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBeNull();
|
||||
});
|
||||
|
||||
it('throttles native toast for same dedupeKey within 5s', async () => {
|
||||
// First call with a unique dedupeKey (not in storage yet) — shows toast
|
||||
const result1 = await manager.addTeamNotification(
|
||||
makeTeamPayload({ dedupeKey: 'throttle-key-a' })
|
||||
);
|
||||
expect(result1).not.toBeNull();
|
||||
|
||||
// Second call with different dedupeKey — also shows toast (different key)
|
||||
const result2 = await manager.addTeamNotification(
|
||||
makeTeamPayload({ dedupeKey: 'throttle-key-b' })
|
||||
);
|
||||
expect(result2).not.toBeNull();
|
||||
|
||||
// Both should have shown toasts (different keys, not throttled)
|
||||
expect(mockNotificationShow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('is accessible via getNotifications', async () => {
|
||||
await manager.addTeamNotification(makeTeamPayload({ dedupeKey: 'get-test' }));
|
||||
const result = await manager.getNotifications({ limit: 10 });
|
||||
|
||||
expect(result.notifications).toHaveLength(1);
|
||||
expect(result.notifications[0].category).toBe('team');
|
||||
expect(result.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('increments unread count correctly', async () => {
|
||||
await manager.addTeamNotification(makeTeamPayload({ dedupeKey: 'count-1' }));
|
||||
await manager.addTeamNotification(makeTeamPayload({ dedupeKey: 'count-2' }));
|
||||
|
||||
expect(manager.getUnreadCountSync()).toBe(2);
|
||||
});
|
||||
|
||||
it('markRead works on team notifications', async () => {
|
||||
const stored = await manager.addTeamNotification(makeTeamPayload({ dedupeKey: 'read-test' }));
|
||||
expect(stored).not.toBeNull();
|
||||
|
||||
await manager.markRead(stored!.id);
|
||||
expect(manager.getUnreadCountSync()).toBe(0);
|
||||
});
|
||||
|
||||
it('deleteNotification removes team notification', async () => {
|
||||
const stored = await manager.addTeamNotification(
|
||||
makeTeamPayload({ dedupeKey: 'delete-test' })
|
||||
);
|
||||
expect(stored).not.toBeNull();
|
||||
|
||||
const deleted = manager.deleteNotification(stored!.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const result = await manager.getNotifications({ limit: 10 });
|
||||
expect(result.notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -295,6 +295,37 @@ describe('teamctl.js', () => {
|
|||
expect(parsed.owner).toBe('bob');
|
||||
});
|
||||
|
||||
it('creates blocked task with reverse links and keeps status pending even with owner', () => {
|
||||
writeTask(claudeDir, '1', { id: '1', subject: 'API contract', status: 'pending' });
|
||||
writeTask(claudeDir, '2', { id: '2', subject: 'Database schema', status: 'pending' });
|
||||
writeTask(claudeDir, '3', { id: '3', subject: 'Frontend shell', status: 'pending' });
|
||||
|
||||
const { stdout, exitCode } = run(claudeDir, [
|
||||
'task',
|
||||
'create',
|
||||
'--subject',
|
||||
'Implement feature',
|
||||
'--owner',
|
||||
'bob',
|
||||
'--blocked-by',
|
||||
'1,2',
|
||||
'--related',
|
||||
'3',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(parsed.id).toBe('4');
|
||||
expect(parsed.owner).toBe('bob');
|
||||
expect(parsed.status).toBe('pending');
|
||||
expect(parsed.blockedBy).toEqual(['1', '2']);
|
||||
expect(parsed.related).toEqual(['3']);
|
||||
|
||||
expect(readTask(claudeDir, '1').blocks).toEqual(['4']);
|
||||
expect(readTask(claudeDir, '2').blocks).toEqual(['4']);
|
||||
expect(readTask(claudeDir, '3').related).toEqual(['4']);
|
||||
});
|
||||
|
||||
it('increments task IDs', () => {
|
||||
run(claudeDir, ['task', 'create', '--subject', 'Task 1']);
|
||||
run(claudeDir, ['task', 'create', '--subject', 'Task 2']);
|
||||
|
|
@ -356,8 +387,9 @@ describe('teamctl.js', () => {
|
|||
expect(inbox.length).toBe(1);
|
||||
const msg = inbox[0] as Record<string, unknown>;
|
||||
expect(msg.from).toBe('alice');
|
||||
expect(String(msg.text)).toContain('New task assigned');
|
||||
expect(String(msg.text)).toContain('#1');
|
||||
expect(String(msg.text)).toMatch(/^New task assigned to you: #1 "Assigned task"\./);
|
||||
expect(msg.summary).toBe('New task #1 assigned');
|
||||
expect(msg.source).toBe('system_notification');
|
||||
});
|
||||
|
||||
it('sends inbox notification with --notify including prompt and tool instructions', () => {
|
||||
|
|
@ -563,6 +595,63 @@ describe('teamctl.js', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Task Link / Unlink
|
||||
// =========================================================================
|
||||
describe('task link / unlink', () => {
|
||||
beforeEach(() => {
|
||||
writeTask(claudeDir, '1', { id: '1', subject: 'Foundation', status: 'pending' });
|
||||
writeTask(claudeDir, '2', { id: '2', subject: 'Feature', status: 'pending' });
|
||||
writeTask(claudeDir, '3', { id: '3', subject: 'Docs', status: 'pending' });
|
||||
});
|
||||
|
||||
it('task link --blocked-by updates reverse blocks relationship', () => {
|
||||
const { stdout, exitCode } = run(claudeDir, ['task', 'link', '2', '--blocked-by', '1']);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe('OK task #2 blocked-by #1\n');
|
||||
expect(readTask(claudeDir, '2').blockedBy).toEqual(['1']);
|
||||
expect(readTask(claudeDir, '1').blocks).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('task link --blocks delegates to reverse blockedBy relationship', () => {
|
||||
const { stdout, exitCode } = run(claudeDir, ['task', 'link', '1', '--blocks', '2']);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe('OK task #1 blocks #2\n');
|
||||
expect(readTask(claudeDir, '1').blocks).toEqual(['2']);
|
||||
expect(readTask(claudeDir, '2').blockedBy).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('task unlink removes related links symmetrically', () => {
|
||||
expect(run(claudeDir, ['task', 'link', '2', '--related', '3']).exitCode).toBe(0);
|
||||
expect(readTask(claudeDir, '2').related).toEqual(['3']);
|
||||
expect(readTask(claudeDir, '3').related).toEqual(['2']);
|
||||
|
||||
const { stdout, exitCode } = run(claudeDir, ['task', 'unlink', '2', '--related', '3']);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe('OK task #2 unlinked related #3\n');
|
||||
expect(readTask(claudeDir, '2').related).toEqual([]);
|
||||
expect(readTask(claudeDir, '3').related).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails when link type is ambiguous', () => {
|
||||
const { exitCode, stderr } = run(claudeDir, [
|
||||
'task',
|
||||
'link',
|
||||
'2',
|
||||
'--blocked-by',
|
||||
'1',
|
||||
'--related',
|
||||
'3',
|
||||
]);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain('Specify exactly one');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Task Set-Owner / Assign
|
||||
// =========================================================================
|
||||
|
|
@ -652,8 +741,9 @@ describe('teamctl.js', () => {
|
|||
expect(inbox.length).toBe(1);
|
||||
const msg = inbox[0] as Record<string, unknown>;
|
||||
expect(msg.from).toBe('alice');
|
||||
expect(String(msg.text)).toContain('Task assigned to you');
|
||||
expect(String(msg.text)).toContain('#1');
|
||||
expect(String(msg.text)).toMatch(/^Task assigned to you: #1 "Unowned task"\./);
|
||||
expect(msg.summary).toBe('Task #1 assigned');
|
||||
expect(msg.source).toBe('system_notification');
|
||||
});
|
||||
|
||||
it('does NOT send notification without --notify', () => {
|
||||
|
|
@ -749,6 +839,10 @@ describe('teamctl.js', () => {
|
|||
it('sends inbox notification to owner (skip self-notification)', () => {
|
||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Review this', '--from', 'alice']);
|
||||
expect(readInbox(claudeDir, 'bob').length).toBe(1);
|
||||
const msg = readInbox(claudeDir, 'bob')[0] as Record<string, unknown>;
|
||||
expect(String(msg.text)).toBe('Comment on task #1 "Commentable task":\n\nReview this');
|
||||
expect(msg.summary).toBe('Comment on #1');
|
||||
expect(msg.source).toBe('system_notification');
|
||||
|
||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Self note', '--from', 'bob']);
|
||||
expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1
|
||||
|
|
@ -1385,8 +1479,9 @@ describe('teamctl.js', () => {
|
|||
const inbox = readInbox(claudeDir, 'bob');
|
||||
expect(inbox.length).toBe(1);
|
||||
const text = String((inbox[0] as Record<string, unknown>).text);
|
||||
expect(text).toContain('approved');
|
||||
expect(text).toContain('Looks great!');
|
||||
expect(text).toBe('Task #1 approved.\n\nLooks great!');
|
||||
expect((inbox[0] as Record<string, unknown>).summary).toBe('Approved #1');
|
||||
expect((inbox[0] as Record<string, unknown>).source).toBe('system_notification');
|
||||
expect((inbox[0] as Record<string, unknown>).from).toBe('alice');
|
||||
});
|
||||
|
||||
|
|
@ -1412,8 +1507,15 @@ describe('teamctl.js', () => {
|
|||
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
||||
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
||||
const text = String((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).text);
|
||||
expect(text).toContain('Fix the edge case');
|
||||
expect(text).toContain('Please fix');
|
||||
expect(text).toBe(
|
||||
'Task #1 needs fixes.\n\nFix the edge case\n\nPlease fix and mark it as completed when ready.'
|
||||
);
|
||||
expect((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).summary).toBe(
|
||||
'Fix request for #1'
|
||||
);
|
||||
expect((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).source).toBe(
|
||||
'system_notification'
|
||||
);
|
||||
});
|
||||
|
||||
it('request-changes without --comment uses default text', () => {
|
||||
|
|
|
|||
106
test/main/utils/teamNotificationBuilder.test.ts
Normal file
106
test/main/utils/teamNotificationBuilder.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildDetectedErrorFromTeam,
|
||||
type TeamEventType,
|
||||
type TeamNotificationPayload,
|
||||
} from '@main/utils/teamNotificationBuilder';
|
||||
|
||||
function makePayload(overrides: Partial<TeamNotificationPayload> = {}): TeamNotificationPayload {
|
||||
return {
|
||||
teamEventType: 'user_inbox',
|
||||
teamName: 'my-team',
|
||||
teamDisplayName: 'My Team',
|
||||
from: 'alice',
|
||||
summary: 'Hello from Alice',
|
||||
body: 'Full message body here',
|
||||
dedupeKey: 'inbox:my-team:alice:123',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildDetectedErrorFromTeam', () => {
|
||||
it('creates a DetectedError with category "team"', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload());
|
||||
expect(result.category).toBe('team');
|
||||
});
|
||||
|
||||
it('sets sessionId as "team:{teamName}"', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload({ teamName: 'alpha-team' }));
|
||||
expect(result.sessionId).toBe('team:alpha-team');
|
||||
});
|
||||
|
||||
it('sets projectId to teamName', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload({ teamName: 'beta' }));
|
||||
expect(result.projectId).toBe('beta');
|
||||
});
|
||||
|
||||
it('sets source to teamEventType', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload({ teamEventType: 'task_clarification' }));
|
||||
expect(result.source).toBe('task_clarification');
|
||||
});
|
||||
|
||||
it('includes dedupeKey', () => {
|
||||
const result = buildDetectedErrorFromTeam(
|
||||
makePayload({ dedupeKey: 'clarification:team1:42' })
|
||||
);
|
||||
expect(result.dedupeKey).toBe('clarification:team1:42');
|
||||
});
|
||||
|
||||
it('sets teamEventType on the result', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload({ teamEventType: 'rate_limit' }));
|
||||
expect(result.teamEventType).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('constructs message from "from" and body', () => {
|
||||
const result = buildDetectedErrorFromTeam(
|
||||
makePayload({ from: 'bob', body: 'Something happened' })
|
||||
);
|
||||
expect(result.message).toBe('[bob] Something happened');
|
||||
});
|
||||
|
||||
it('truncates body to 300 chars in message', () => {
|
||||
const longBody = 'x'.repeat(500);
|
||||
const result = buildDetectedErrorFromTeam(makePayload({ body: longBody }));
|
||||
// "[alice] " = 8 chars + 300 chars body = 308 total
|
||||
expect(result.message.length).toBe(8 + 300);
|
||||
});
|
||||
|
||||
it('sets context.projectName to teamDisplayName', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload({ teamDisplayName: 'Alpha Squad' }));
|
||||
expect(result.context.projectName).toBe('Alpha Squad');
|
||||
});
|
||||
|
||||
it('sets context.cwd to projectPath when provided', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload({ projectPath: '/home/user/project' }));
|
||||
expect(result.context.cwd).toBe('/home/user/project');
|
||||
});
|
||||
|
||||
it('generates a UUID id', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload());
|
||||
expect(result.id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it('sets filePath to empty string', () => {
|
||||
const result = buildDetectedErrorFromTeam(makePayload());
|
||||
expect(result.filePath).toBe('');
|
||||
});
|
||||
|
||||
const EXPECTED_CONFIG: Record<TeamEventType, { triggerName: string; triggerColor: string }> = {
|
||||
rate_limit: { triggerName: 'Rate Limit', triggerColor: 'red' },
|
||||
lead_inbox: { triggerName: 'Team Inbox', triggerColor: 'blue' },
|
||||
user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' },
|
||||
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||
};
|
||||
|
||||
for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) {
|
||||
it(`maps ${eventType} → triggerName="${expected.triggerName}", triggerColor="${expected.triggerColor}"`, () => {
|
||||
const result = buildDetectedErrorFromTeam(
|
||||
makePayload({ teamEventType: eventType as TeamEventType })
|
||||
);
|
||||
expect(result.triggerName).toBe(expected.triggerName);
|
||||
expect(result.triggerColor).toBe(expected.triggerColor);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -458,6 +458,52 @@ describe('notificationSlice', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('team notification navigation', () => {
|
||||
it('should open team tab for any team notification (sessionId starts with "team:")', () => {
|
||||
const teamError = createMockError({
|
||||
sessionId: 'team:alpha-team',
|
||||
source: 'user_inbox',
|
||||
category: 'team' as never,
|
||||
teamEventType: 'user_inbox' as never,
|
||||
});
|
||||
|
||||
store.getState().navigateToError(teamError);
|
||||
|
||||
// Should open a team tab, not a session tab
|
||||
const tabs = store.getState().openTabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0].type).toBe('team');
|
||||
});
|
||||
|
||||
it('should open team tab for rate-limit notification with team sessionId', () => {
|
||||
const teamError = createMockError({
|
||||
sessionId: 'team:beta-team',
|
||||
source: 'rate_limit',
|
||||
category: 'team' as never,
|
||||
teamEventType: 'rate_limit' as never,
|
||||
});
|
||||
|
||||
store.getState().navigateToError(teamError);
|
||||
|
||||
const tabs = store.getState().openTabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0].type).toBe('team');
|
||||
});
|
||||
|
||||
it('should open team tab regardless of source field value', () => {
|
||||
const teamError = createMockError({
|
||||
sessionId: 'team:gamma-team',
|
||||
source: 'task_clarification',
|
||||
});
|
||||
|
||||
store.getState().navigateToError(teamError);
|
||||
|
||||
const tabs = store.getState().openTabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0].type).toBe('team');
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing tab behavior', () => {
|
||||
it('should focus existing tab if session is already open', () => {
|
||||
// Open target session tab first
|
||||
|
|
|
|||
Loading…
Reference in a new issue