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 { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||||
import { showTeamNativeNotification } from './ipc/teams';
|
|
||||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
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> {
|
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
||||||
// Check global toggle
|
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
if (!config.notifications.enabled) return;
|
|
||||||
|
|
||||||
// Skip orphaned team directories without config.json (e.g., "default").
|
// 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.
|
// 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;
|
if (!match) return;
|
||||||
const memberName = match[1];
|
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 leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
|
||||||
const isLeadInbox = leadName !== null && memberName === leadName;
|
const isLeadInbox = leadName !== null && memberName === leadName;
|
||||||
const isUserInbox = memberName === 'user';
|
const isUserInbox = memberName === 'user';
|
||||||
|
|
||||||
if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return;
|
|
||||||
if (isUserInbox && !config.notifications.notifyOnUserInbox) return;
|
|
||||||
if (!isLeadInbox && !isUserInbox) return;
|
if (!isLeadInbox && !isUserInbox) return;
|
||||||
|
|
||||||
|
const suppressToast =
|
||||||
|
!config.notifications.enabled ||
|
||||||
|
(isLeadInbox && !config.notifications.notifyOnLeadInbox) ||
|
||||||
|
(isUserInbox && !config.notifications.notifyOnUserInbox);
|
||||||
|
|
||||||
const key = `${teamName}:${memberName}`;
|
const key = `${teamName}:${memberName}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -205,7 +206,8 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
|
|
||||||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
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
|
// Skip messages sent from our own UI
|
||||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
// 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 fromLabel = msg.from || 'Unknown';
|
||||||
const extracted = extractNotificationContent(msg.text);
|
const extracted = extractNotificationContent(msg.text);
|
||||||
const summary = msg.summary || extracted.summary;
|
const summary = msg.summary || extracted.summary;
|
||||||
|
const msgId = msg.timestamp ?? String(prevCount + i);
|
||||||
|
|
||||||
showTeamNativeNotification({
|
void notificationManager
|
||||||
title: teamDisplayName,
|
.addTeamNotification({
|
||||||
subtitle: `${fromLabel}: ${summary}`,
|
teamEventType: isLeadInbox ? 'lead_inbox' : 'user_inbox',
|
||||||
body: extracted.body,
|
teamName,
|
||||||
});
|
teamDisplayName,
|
||||||
|
from: fromLabel,
|
||||||
|
summary,
|
||||||
|
body: extracted.body,
|
||||||
|
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||||
|
suppressToast,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to check inbox messages for ${key}:`, 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> {
|
async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
if (!config.notifications.enabled) return;
|
const suppressToast = !config.notifications.enabled || !config.notifications.notifyOnUserInbox;
|
||||||
if (!config.notifications.notifyOnUserInbox) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = await sentMessagesStore.readMessages(teamName);
|
const messages = await sentMessagesStore.readMessages(teamName);
|
||||||
|
|
@ -256,7 +265,8 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||||
|
|
||||||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
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
|
// Skip messages sent from our own UI
|
||||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||||
// Skip internal coordination noise
|
// Skip internal coordination noise
|
||||||
|
|
@ -266,11 +276,18 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||||
const extracted = extractNotificationContent(msg.text);
|
const extracted = extractNotificationContent(msg.text);
|
||||||
const summary = msg.summary || extracted.summary;
|
const summary = msg.summary || extracted.summary;
|
||||||
|
|
||||||
showTeamNativeNotification({
|
void notificationManager
|
||||||
title: teamDisplayName,
|
.addTeamNotification({
|
||||||
subtitle: `${fromLabel}: ${summary}`,
|
teamEventType: 'user_inbox',
|
||||||
body: extracted.body,
|
teamName,
|
||||||
});
|
teamDisplayName,
|
||||||
|
from: fromLabel,
|
||||||
|
summary,
|
||||||
|
body: extracted.body,
|
||||||
|
dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`,
|
||||||
|
suppressToast,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to check sent messages for ${teamName}:`, 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 { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
|
|
@ -79,10 +77,6 @@ import {
|
||||||
validateTeamName,
|
validateTeamName,
|
||||||
} from './guards';
|
} 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 { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
|
|
||||||
|
|
@ -130,7 +124,17 @@ import type {
|
||||||
const logger = createLogger('IPC:teams');
|
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(
|
function checkRateLimitMessages(
|
||||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||||
|
|
@ -142,33 +146,29 @@ function checkRateLimitMessages(
|
||||||
if (msg.from === 'user') continue;
|
if (msg.from === 'user') continue;
|
||||||
if (!isRateLimitMessage(msg.text)) continue;
|
if (!isRateLimitMessage(msg.text)) continue;
|
||||||
|
|
||||||
// Prefix key with teamName to avoid collisions across teams
|
|
||||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||||
const key = `${teamName}:${rawKey}`;
|
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
||||||
if (notifiedRateLimitKeys.has(key)) continue;
|
|
||||||
notifiedRateLimitKeys.add(key);
|
|
||||||
|
|
||||||
// Prevent unbounded memory growth
|
// In-memory guard: prevents resurrection after user deletes the notification
|
||||||
if (notifiedRateLimitKeys.size > RATE_LIMIT_KEYS_MAX) {
|
if (seenRateLimitKeys.has(dedupeKey)) continue;
|
||||||
const first = notifiedRateLimitKeys.values().next().value!;
|
seenRateLimitKeys.add(dedupeKey);
|
||||||
notifiedRateLimitKeys.delete(first);
|
|
||||||
|
// 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()
|
void NotificationManager.getInstance()
|
||||||
.addError({
|
.addTeamNotification({
|
||||||
id: randomUUID(),
|
teamEventType: 'rate_limit',
|
||||||
timestamp: Date.now(),
|
teamName,
|
||||||
sessionId: `team:${teamName}`,
|
teamDisplayName,
|
||||||
projectId: teamName,
|
from: msg.from,
|
||||||
filePath: '',
|
summary: `Rate limit: ${msg.from}`,
|
||||||
source: 'rate-limit',
|
body: msg.text.slice(0, 200),
|
||||||
message: `[${msg.from}] ${msg.text.slice(0, 200)}`,
|
dedupeKey,
|
||||||
triggerColor: 'red',
|
projectPath,
|
||||||
triggerName: 'Rate Limit',
|
|
||||||
context: {
|
|
||||||
projectName: teamDisplayName,
|
|
||||||
cwd: projectPath,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -1950,19 +1950,39 @@ async function handleShowMessageNotification(
|
||||||
if (!d.teamDisplayName || !d.from || !d.body) {
|
if (!d.teamDisplayName || !d.from || !d.body) {
|
||||||
return { success: false, error: 'Missing required fields (teamDisplayName, from, 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 };
|
return { success: true, data: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a native OS notification for a team event.
|
* Show a native OS notification for a team event.
|
||||||
* Respects user's notification settings (enabled, snoozed).
|
* @deprecated Use NotificationManager.addTeamNotification() instead for unified storage + toast.
|
||||||
* Cross-platform: macOS, Linux, Windows via Electron Notification API.
|
* Kept for backward compatibility with any remaining callers.
|
||||||
*/
|
*/
|
||||||
export function showTeamNativeNotification(opts: {
|
export function showTeamNativeNotification(opts: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,17 @@ export interface DetectedError {
|
||||||
triggerId?: string;
|
triggerId?: string;
|
||||||
/** Human-readable name of the trigger that produced this notification */
|
/** Human-readable name of the trigger that produced this notification */
|
||||||
triggerName?: string;
|
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 */
|
/** Additional context about the error */
|
||||||
context: {
|
context: {
|
||||||
/** Human-readable project name */
|
/** 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:
|
* 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)
|
* - Show native notifications using Electron's Notification API (cross-platform)
|
||||||
* - Implement throttling (5 seconds per unique error hash)
|
* - Two adapters: addError() for error notifications, addTeamNotification() for team events
|
||||||
* - Respect config.notifications.enabled and snoozedUntil
|
* - Shared internal pipeline: storeNotification() for unconditional storage + IPC emission
|
||||||
* - Filter errors matching ignoredRegex patterns
|
* - Two-level dedup: dedupeKey for storage dedup, toast throttle (5s) for native toasts
|
||||||
* - Filter errors from ignoredProjects
|
* - 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
|
* - Auto-prune notifications over 100 on startup
|
||||||
* - Emit IPC events to renderer: notification:new, notification:updated
|
* - Emit IPC events to renderer: notification:new, notification:updated
|
||||||
*/
|
*/
|
||||||
|
|
@ -24,6 +27,10 @@ import * as path from 'path';
|
||||||
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
||||||
|
|
||||||
const logger = createLogger('Service:NotificationManager');
|
const logger = createLogger('Service:NotificationManager');
|
||||||
|
import {
|
||||||
|
buildDetectedErrorFromTeam,
|
||||||
|
type TeamNotificationPayload,
|
||||||
|
} from '@main/utils/teamNotificationBuilder';
|
||||||
import { projectPathResolver } from '../discovery/ProjectPathResolver';
|
import { projectPathResolver } from '../discovery/ProjectPathResolver';
|
||||||
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
||||||
|
|
||||||
|
|
@ -31,6 +38,8 @@ import { ConfigManager } from './ConfigManager';
|
||||||
|
|
||||||
// Re-export DetectedError for backward compatibility
|
// Re-export DetectedError for backward compatibility
|
||||||
export type { DetectedError };
|
export type { DetectedError };
|
||||||
|
// Re-export team notification types for callers
|
||||||
|
export type { TeamNotificationPayload, TeamEventType } from '@main/utils/teamNotificationBuilder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stored notification with read status.
|
* 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 {
|
private isToastThrottled(error: DetectedError): boolean {
|
||||||
const hash = this.generateErrorHash(error);
|
const key = error.dedupeKey ?? this.generateErrorHash(error);
|
||||||
const lastSeen = this.throttleMap.get(hash);
|
const lastSeen = this.throttleMap.get(key);
|
||||||
|
|
||||||
if (lastSeen && Date.now() - lastSeen < THROTTLE_MS) {
|
if (lastSeen && Date.now() - lastSeen < THROTTLE_MS) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update throttle map
|
// Update throttle map
|
||||||
this.throttleMap.set(hash, Date.now());
|
this.throttleMap.set(key, Date.now());
|
||||||
|
|
||||||
// Clean up old entries periodically
|
// Clean up old entries periodically
|
||||||
this.cleanupThrottleMap();
|
this.cleanupThrottleMap();
|
||||||
|
|
@ -349,81 +359,90 @@ export class NotificationManager extends EventEmitter {
|
||||||
return ignoredRepositories.includes(identity.id);
|
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
|
// Native Notifications
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a native notification for an error.
|
* Shows a native notification for an error.
|
||||||
* Note: Electron's `subtitle` option only works on macOS.
|
* Closes over `stored` (StoredNotification) so click handler has full data.
|
||||||
* On Windows/Linux, we prepend the subtitle to the body instead.
|
|
||||||
*/
|
*/
|
||||||
private showNativeNotification(error: DetectedError): void {
|
private showErrorNativeNotification(stored: StoredNotification): void {
|
||||||
// Guard against standalone/Docker mode where Electron's Notification API is unavailable
|
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 (
|
if (
|
||||||
typeof Notification === 'undefined' ||
|
typeof Notification === 'undefined' ||
|
||||||
typeof Notification.isSupported !== 'function' ||
|
typeof Notification.isSupported !== 'function' ||
|
||||||
!Notification.isSupported()
|
!Notification.isSupported()
|
||||||
) {
|
) {
|
||||||
logger.warn('Native notifications not supported');
|
logger.warn('Native notifications not supported');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -463,17 +482,21 @@ export class NotificationManager extends EventEmitter {
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds an error and shows a notification if enabled.
|
* Stores a notification unconditionally. Emits IPC events to renderer.
|
||||||
* @param error - The detected error to add
|
* Returns null if dedupeKey already exists in storage (storage-level dedupe)
|
||||||
* @returns The stored notification, or null if filtered/throttled
|
* or if toolUseId-based dedup skips it.
|
||||||
*/
|
*/
|
||||||
async addError(error: DetectedError): Promise<StoredNotification | null> {
|
private async storeNotification(error: DetectedError): Promise<StoredNotification | null> {
|
||||||
// Wait for async initialization to complete before modifying notifications.
|
|
||||||
// Prevents a race where saveNotifications() overwrites not-yet-loaded data.
|
|
||||||
if (this.initPromise) {
|
if (this.initPromise) {
|
||||||
await 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
|
// Deduplicate by toolUseId: the same tool call can appear in both the
|
||||||
// subagent JSONL file and the parent session JSONL (as a progress event).
|
// subagent JSONL file and the parent session JSONL (as a progress event).
|
||||||
// Keep the subagent-annotated version (with subagentId) when possible.
|
// 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.
|
// Emit authoritative counters (total/unread) so renderer badge stays in sync.
|
||||||
this.emitNotificationUpdated();
|
this.emitNotificationUpdated();
|
||||||
|
|
||||||
// Show native notification if enabled and not filtered
|
return storedNotification;
|
||||||
if (await this.shouldNotify(error)) {
|
}
|
||||||
this.showNativeNotification(error);
|
|
||||||
|
/**
|
||||||
|
* 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)' },
|
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
|
// Sub-components
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -180,7 +183,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
if (cliStatusError && !cliStatusLoading) {
|
if (cliStatusError && !cliStatusLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
borderColor: VARIANT_STYLES.error.border,
|
borderColor: VARIANT_STYLES.error.border,
|
||||||
backgroundColor: VARIANT_STYLES.error.bg,
|
backgroundColor: VARIANT_STYLES.error.bg,
|
||||||
|
|
@ -211,7 +214,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
if (!cliStatusLoading) {
|
if (!cliStatusLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
<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.
|
// Loading state: show spinner only while an actual request is in-flight.
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<Loader2
|
<Loader2
|
||||||
|
|
@ -250,7 +253,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
if (installerState === 'downloading') {
|
if (installerState === 'downloading') {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<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...';
|
installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...';
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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)' }}>
|
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -310,11 +313,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
if (installerState === 'installing') {
|
if (installerState === 'installing') {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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)' }}>
|
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
Installing Claude CLI...
|
Installing Claude CLI...
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -328,7 +331,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
if (installerState === 'completed') {
|
if (installerState === 'completed') {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
|
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
|
||||||
|
|
@ -343,7 +346,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
if (installerState === 'error') {
|
if (installerState === 'error') {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<ErrorDisplay error={installerError ?? 'Installation failed'} onRetry={handleInstall} />
|
<ErrorDisplay error={installerError ?? 'Installation failed'} onRetry={handleInstall} />
|
||||||
|
|
@ -446,7 +449,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
// Installed — show version, path, update info
|
// Installed — show version, path, update info
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useState } from 'react';
|
||||||
|
|
||||||
import { getTriggerColorDef } from '@shared/constants/triggerColors';
|
import { getTriggerColorDef } from '@shared/constants/triggerColors';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
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';
|
import type { DetectedError } from '@renderer/types/data';
|
||||||
|
|
||||||
|
|
@ -41,6 +41,7 @@ export const NotificationRow = ({
|
||||||
const truncatedMessage = truncateMessage(error.message);
|
const truncatedMessage = truncateMessage(error.message);
|
||||||
const colorDef = getTriggerColorDef(error.triggerColor);
|
const colorDef = getTriggerColorDef(error.triggerColor);
|
||||||
const displayName = error.triggerName ?? error.source;
|
const displayName = error.triggerName ?? error.source;
|
||||||
|
const isTeamNotification = error.category === 'team' || error.sessionId?.startsWith('team:');
|
||||||
|
|
||||||
const handleArchiveClick = (e: React.MouseEvent): void => {
|
const handleArchiveClick = (e: React.MouseEvent): void => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -102,6 +103,19 @@ export const NotificationRow = ({
|
||||||
<span className="truncate text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
<span className="truncate text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
{projectName}
|
{projectName}
|
||||||
</span>
|
</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 && (
|
{error.subagentId && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
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
|
+{pendingNewCount} new
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -527,6 +516,21 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
void loadOlderLogs();
|
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}
|
) : null}
|
||||||
{!error && data.lines.length === 0 ? (
|
{!error && data.lines.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ interface CliLogsRichViewProps {
|
||||||
/** Optional local search query override for inline highlighting */
|
/** Optional local search query override for inline highlighting */
|
||||||
searchQueryOverride?: string;
|
searchQueryOverride?: string;
|
||||||
className?: 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,
|
containerRefCallback,
|
||||||
searchQueryOverride,
|
searchQueryOverride,
|
||||||
className,
|
className,
|
||||||
|
footer,
|
||||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const stickToEdgeRef = useRef(true);
|
const stickToEdgeRef = useRef(true);
|
||||||
|
|
@ -370,6 +373,7 @@ export const CliLogsRichView = ({
|
||||||
Waiting for CLI output...
|
Waiting for CLI output...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -426,6 +430,7 @@ export const CliLogsRichView = ({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{footer}
|
||||||
</div>
|
</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 { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
|
|
@ -32,8 +37,8 @@ export const MemberBadge = ({
|
||||||
|
|
||||||
const badgeStyle = {
|
const badgeStyle = {
|
||||||
backgroundColor: getThemedBadge(colors, isLight),
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
color: colors.text,
|
color: getThemedText(colors, isLight),
|
||||||
border: `1px solid ${colors.border}40`,
|
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatar = (
|
const avatar = (
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import {
|
||||||
CARD_ICON_MUTED,
|
CARD_ICON_MUTED,
|
||||||
CARD_TEXT_LIGHT,
|
CARD_TEXT_LIGHT,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
||||||
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import {
|
import {
|
||||||
getMessageTypeLabel,
|
getMessageTypeLabel,
|
||||||
getStructuredMessageSummary,
|
getStructuredMessageSummary,
|
||||||
|
|
@ -250,6 +251,7 @@ export const ActivityItem = ({
|
||||||
collapseState,
|
collapseState,
|
||||||
}: ActivityItemProps): React.JSX.Element => {
|
}: ActivityItemProps): React.JSX.Element => {
|
||||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||||
|
const { isLight } = useTheme();
|
||||||
const formattedRole = formatAgentRole(memberRole);
|
const formattedRole = formatAgentRole(memberRole);
|
||||||
|
|
||||||
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
||||||
|
|
@ -353,7 +355,7 @@ export const ActivityItem = ({
|
||||||
? '3px solid var(--tool-result-error-text)'
|
? '3px solid var(--tool-result-error-text)'
|
||||||
: isSystemMessage
|
: isSystemMessage
|
||||||
? '3px solid var(--system-activity-accent)'
|
? '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) */}
|
{/* 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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
||||||
|
import { AnimatedHeightReveal } from './AnimatedHeightReveal';
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
|
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
|
||||||
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||||
|
|
@ -106,7 +107,7 @@ const MessageRowWithObserver = ({
|
||||||
}, [onVisible]);
|
}, [onVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
||||||
<ActivityItem
|
<ActivityItem
|
||||||
message={message}
|
message={message}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -123,7 +124,7 @@ const MessageRowWithObserver = ({
|
||||||
onRestartTeam={onRestartTeam}
|
onRestartTeam={onRestartTeam}
|
||||||
collapseState={collapseState}
|
collapseState={collapseState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</AnimatedHeightReveal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -229,10 +230,7 @@ export const ActivityTimeline = ({
|
||||||
return result;
|
return result;
|
||||||
}, [timelineItems]);
|
}, [timelineItems]);
|
||||||
|
|
||||||
// Determine which items are "new" (should animate).
|
const timelineItemKeys = useMemo(() => {
|
||||||
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
|
|
||||||
|
|
||||||
const newItemKeys = useMemo(() => {
|
|
||||||
const getItemKey = (item: TimelineItem): string => {
|
const getItemKey = (item: TimelineItem): string => {
|
||||||
if (item.type === 'lead-thoughts') {
|
if (item.type === 'lead-thoughts') {
|
||||||
// Stable key: identify group by its first thought, not by count (which changes)
|
// 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}`;
|
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allKeys: string[] = [];
|
return timelineItems.map(getItemKey);
|
||||||
for (const item of timelineItems) {
|
}, [timelineItems]);
|
||||||
allKeys.push(getItemKey(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
// First render: seed known keys, no animations
|
const isPaginationExpansion =
|
||||||
if (!isInitializedRef.current) {
|
isInitializedRef.current && visibleCount > prevVisibleCountRef.current;
|
||||||
isInitializedRef.current = true;
|
|
||||||
for (const key of allKeys) {
|
const newItemKeys = useMemo(() => {
|
||||||
knownKeysRef.current.add(key);
|
if (!isInitializedRef.current || isPaginationExpansion) {
|
||||||
}
|
|
||||||
prevVisibleCountRef.current = visibleCount;
|
|
||||||
return new Set<string>();
|
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>();
|
const newKeys = new Set<string>();
|
||||||
for (const key of allKeys) {
|
for (const key of timelineItemKeys) {
|
||||||
if (!knownKeysRef.current.has(key)) {
|
if (!knownKeysRef.current.has(key)) {
|
||||||
newKeys.add(key);
|
newKeys.add(key);
|
||||||
knownKeysRef.current.add(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newKeys;
|
return newKeys;
|
||||||
}, [timelineItems, visibleCount]);
|
}, [isPaginationExpansion, timelineItemKeys]);
|
||||||
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitializedRef.current) {
|
||||||
|
isInitializedRef.current = true;
|
||||||
|
}
|
||||||
|
for (const key of timelineItemKeys) {
|
||||||
|
knownKeysRef.current.add(key);
|
||||||
|
}
|
||||||
|
prevVisibleCountRef.current = visibleCount;
|
||||||
|
}, [timelineItemKeys, visibleCount]);
|
||||||
|
|
||||||
const handleShowMore = (): void => {
|
const handleShowMore = (): void => {
|
||||||
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||||
|
|
@ -354,6 +344,7 @@ export const ActivityTimeline = ({
|
||||||
collapseState={collapseState}
|
collapseState={collapseState}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
memberColorMap={colorMap}
|
memberColorMap={colorMap}
|
||||||
|
onReply={onReplyToMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -403,6 +394,7 @@ export const ActivityTimeline = ({
|
||||||
collapseState={collapseState}
|
collapseState={collapseState}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
memberColorMap={colorMap}
|
memberColorMap={colorMap}
|
||||||
|
onReply={onReplyToMessage}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</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 { 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 { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
|
@ -15,8 +15,14 @@ import {
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnimatedHeightReveal,
|
||||||
|
ENTRY_REVEAL_ANIMATION_MS,
|
||||||
|
ENTRY_REVEAL_EASING,
|
||||||
|
} from './AnimatedHeightReveal';
|
||||||
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
|
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
|
||||||
import { isManagedCollapseState } from './collapseState';
|
import { isManagedCollapseState } from './collapseState';
|
||||||
|
|
||||||
|
|
@ -86,7 +92,7 @@ const VIEWPORT_THRESHOLD = 0.15;
|
||||||
const LIVE_WINDOW_MS = 5_000;
|
const LIVE_WINDOW_MS = 5_000;
|
||||||
const COLLAPSED_THOUGHTS_HEIGHT = 200;
|
const COLLAPSED_THOUGHTS_HEIGHT = 200;
|
||||||
const AUTO_SCROLL_THRESHOLD = 30;
|
const AUTO_SCROLL_THRESHOLD = 30;
|
||||||
const THOUGHT_HEIGHT_ANIMATION_MS = 220;
|
const THOUGHT_HEIGHT_ANIMATION_MS = ENTRY_REVEAL_ANIMATION_MS;
|
||||||
|
|
||||||
interface LeadThoughtsGroupRowProps {
|
interface LeadThoughtsGroupRowProps {
|
||||||
group: LeadThoughtGroup;
|
group: LeadThoughtGroup;
|
||||||
|
|
@ -103,6 +109,8 @@ interface LeadThoughtsGroupRowProps {
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
/** Map of member name → color name for @mention badge rendering. */
|
/** Map of member name → color name for @mention badge rendering. */
|
||||||
memberColorMap?: Map<string, string>;
|
memberColorMap?: Map<string, string>;
|
||||||
|
/** Called when user clicks the reply button on a thought. */
|
||||||
|
onReply?: (message: InboxMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: string): string {
|
function formatTime(timestamp: string): string {
|
||||||
|
|
@ -187,6 +195,7 @@ interface LeadThoughtItemProps {
|
||||||
shouldAnimate: boolean;
|
shouldAnimate: boolean;
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
memberColorMap?: Map<string, string>;
|
memberColorMap?: Map<string, string>;
|
||||||
|
onReply?: (message: InboxMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LeadThoughtItem = ({
|
const LeadThoughtItem = ({
|
||||||
|
|
@ -195,6 +204,7 @@ const LeadThoughtItem = ({
|
||||||
shouldAnimate,
|
shouldAnimate,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
memberColorMap,
|
memberColorMap,
|
||||||
|
onReply,
|
||||||
}: LeadThoughtItemProps): JSX.Element => {
|
}: LeadThoughtItemProps): JSX.Element => {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -229,6 +239,7 @@ const LeadThoughtItem = ({
|
||||||
wrapper.style.opacity = '1';
|
wrapper.style.opacity = '1';
|
||||||
wrapper.style.overflow = 'visible';
|
wrapper.style.overflow = 'visible';
|
||||||
wrapper.style.transition = '';
|
wrapper.style.transition = '';
|
||||||
|
wrapper.style.willChange = '';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|
@ -246,12 +257,18 @@ const LeadThoughtItem = ({
|
||||||
wrapper.style.overflow = 'hidden';
|
wrapper.style.overflow = 'hidden';
|
||||||
wrapper.style.height = `${Math.max(startHeight, 0)}px`;
|
wrapper.style.height = `${Math.max(startHeight, 0)}px`;
|
||||||
wrapper.style.opacity = `${startOpacity}`;
|
wrapper.style.opacity = `${startOpacity}`;
|
||||||
|
wrapper.style.willChange = 'height, opacity';
|
||||||
void wrapper.offsetHeight;
|
void wrapper.offsetHeight;
|
||||||
|
|
||||||
animationFrameRef.current = requestAnimationFrame(() => {
|
animationFrameRef.current = requestAnimationFrame(() => {
|
||||||
wrapper.style.transition = `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease, opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`;
|
animationFrameRef.current = requestAnimationFrame(() => {
|
||||||
wrapper.style.height = `${Math.max(targetHeight, 0)}px`;
|
wrapper.style.transition = [
|
||||||
wrapper.style.opacity = '1';
|
`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(() => {
|
cleanupTimerRef.current = window.setTimeout(() => {
|
||||||
|
|
@ -352,7 +369,24 @@ const LeadThoughtItem = ({
|
||||||
<MarkdownViewer content={displayContent} maxHeight="max-h-none" bare />
|
<MarkdownViewer content={displayContent} maxHeight="max-h-none" bare />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 />
|
<CopyButton text={thought.text} inline />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -393,6 +427,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
collapseState,
|
collapseState,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
memberColorMap,
|
memberColorMap,
|
||||||
|
onReply,
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -559,7 +594,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
[clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
|
[clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isBodyVisible) return;
|
if (!isBodyVisible) return;
|
||||||
const contentEl = contentRef.current;
|
const contentEl = contentRef.current;
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
|
@ -612,11 +647,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
|
||||||
ref={ref}
|
|
||||||
className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}
|
|
||||||
style={{ overflowAnchor: 'none' }}
|
|
||||||
>
|
|
||||||
<article
|
<article
|
||||||
className="group rounded-md [overflow:clip]"
|
className="group rounded-md [overflow:clip]"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -656,15 +687,21 @@ export const LeadThoughtsGroupRow = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Live / offline indicator */}
|
{/* Lead avatar with optional live indicator */}
|
||||||
{isLive ? (
|
<div className="relative shrink-0">
|
||||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
<img
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
src={agentAvatarUrl(leadName, 24)}
|
||||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
alt=""
|
||||||
</span>
|
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
) : (
|
loading="lazy"
|
||||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-zinc-500" />
|
/>
|
||||||
)}
|
{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 />
|
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{thoughts.length} thoughts
|
{thoughts.length} thoughts
|
||||||
|
|
@ -716,6 +753,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
memberColorMap={memberColorMap}
|
memberColorMap={memberColorMap}
|
||||||
|
onReply={onReply}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -758,6 +796,6 @@ export const LeadThoughtsGroupRow = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</AnimatedHeightReveal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { AlertCircle, X } from 'lucide-react';
|
import { AlertCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
import type { AttachmentPayload } from '@shared/types';
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
const ANIMATION_MS = 400;
|
||||||
|
|
||||||
interface AttachmentPreviewListProps {
|
interface AttachmentPreviewListProps {
|
||||||
attachments: AttachmentPayload[];
|
attachments: AttachmentPayload[];
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
|
@ -27,30 +29,117 @@ export const AttachmentPreviewList = ({
|
||||||
disabledHint,
|
disabledHint,
|
||||||
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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}`,
|
src: `data:${att.mimeType};base64,${att.data}`,
|
||||||
alt: att.filename,
|
alt: att.filename,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5 px-1">
|
<div className="space-y-1.5 px-1">
|
||||||
{attachments.length > 0 ? (
|
{visibleAttachments.length > 0 ? (
|
||||||
<div className="flex gap-2 overflow-x-auto py-1">
|
<div className="flex gap-2 overflow-x-auto py-1">
|
||||||
{attachments.map((att, i) => (
|
{visibleAttachments.map((att, i) => {
|
||||||
<AttachmentPreviewItem
|
const isExiting = exitingIds.has(att.id);
|
||||||
key={att.id}
|
const isEntering = enteringIds.has(att.id);
|
||||||
attachment={att}
|
return (
|
||||||
onRemove={onRemove}
|
<div
|
||||||
onPreview={() => setLightboxIndex(i)}
|
key={att.id}
|
||||||
disabled={disabled}
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{disabled && disabledHint && attachments.length > 0 ? (
|
{disabled && disabledHint && visibleAttachments.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5"
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5"
|
||||||
style={{ backgroundColor: 'var(--warning-bg)', color: 'var(--warning-text)' }}
|
style={{ backgroundColor: 'var(--warning-bg)', color: 'var(--warning-text)' }}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export const SendMessageDialog = ({
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="min-w-0 sm:max-w-4xl"
|
className="min-w-0 max-w-3xl"
|
||||||
onDragEnter={canAttach ? handleDragEnter : undefined}
|
onDragEnter={canAttach ? handleDragEnter : undefined}
|
||||||
onDragLeave={canAttach ? handleDragLeave : undefined}
|
onDragLeave={canAttach ? handleDragLeave : undefined}
|
||||||
onDragOver={canAttach ? handleDragOver : undefined}
|
onDragOver={canAttach ? handleDragOver : undefined}
|
||||||
|
|
@ -331,9 +331,9 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||||
{quote ? (
|
{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 */}
|
{/* 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>
|
</span>
|
||||||
|
|
||||||
|
|
@ -341,7 +341,7 @@ export const SendMessageDialog = ({
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => setQuote(undefined)}
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
|
|
@ -351,11 +351,13 @@ export const SendMessageDialog = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
<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" />
|
<MemberBadge name={quote.from} color={colorMap.get(quote.from)} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<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
|
<MarkdownViewer
|
||||||
content={quote.text}
|
content={quote.text}
|
||||||
|
|
@ -366,7 +368,7 @@ export const SendMessageDialog = ({
|
||||||
{isQuoteLong ? (
|
{isQuoteLong ? (
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => setQuoteExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
{quoteExpanded ? 'less' : 'more'}
|
{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 { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
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 { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
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 [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
||||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
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.
|
// Reset local UI state when team/task changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,6 +100,9 @@ export const TaskCommentsSection = ({
|
||||||
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setPreviewImageUrl(null);
|
setPreviewImageUrl(null);
|
||||||
|
knownCommentIdsRef.current = new Set();
|
||||||
|
isInitializedRef.current = false;
|
||||||
|
prevVisibleCountRef.current = INITIAL_VISIBLE_COMMENTS;
|
||||||
}, [teamName, taskId]);
|
}, [teamName, taskId]);
|
||||||
|
|
||||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||||
|
|
@ -119,6 +126,38 @@ export const TaskCommentsSection = ({
|
||||||
[sortedComments, visibleCount]
|
[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[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
members.map((m) => ({
|
members.map((m) => ({
|
||||||
|
|
@ -171,133 +210,134 @@ export const TaskCommentsSection = ({
|
||||||
|
|
||||||
<div className={containerClassName ?? ''}>
|
<div className={containerClassName ?? ''}>
|
||||||
{visibleComments.map((comment, index) => (
|
{visibleComments.map((comment, index) => (
|
||||||
<div
|
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
||||||
key={comment.id}
|
<div
|
||||||
className={[
|
className={[
|
||||||
'group px-4 py-2.5',
|
'group px-4 py-2.5',
|
||||||
comment.type === 'review_approved'
|
comment.type === 'review_approved'
|
||||||
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
||||||
: comment.type === 'review_request'
|
: comment.type === 'review_request'
|
||||||
? 'border-y border-blue-500/20 bg-blue-500/5'
|
? 'border-y border-blue-500/20 bg-blue-500/5'
|
||||||
: '',
|
: '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={
|
style={
|
||||||
!comment.type || comment.type === 'regular'
|
!comment.type || comment.type === 'regular'
|
||||||
? {
|
? {
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={comment.author}
|
name={comment.author}
|
||||||
color={colorMap.get(comment.author)}
|
color={colorMap.get(comment.author)}
|
||||||
hideAvatar={comment.author === 'user'}
|
hideAvatar={comment.author === 'user'}
|
||||||
/>
|
/>
|
||||||
{comment.type === 'review_approved' ? (
|
{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">
|
<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} />
|
<CheckCircle2 size={10} />
|
||||||
Approved
|
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>
|
</span>
|
||||||
) : comment.type === 'review_request' ? (
|
<Tooltip>
|
||||||
<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">
|
<TooltipTrigger asChild>
|
||||||
<Eye size={10} />
|
<button
|
||||||
Review requested
|
type="button"
|
||||||
</span>
|
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"
|
||||||
) : null}
|
onClick={() => {
|
||||||
<span>
|
const replyText = stripAgentBlocks(
|
||||||
{(() => {
|
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||||
const date = new Date(comment.createdAt);
|
);
|
||||||
return isNaN(date.getTime())
|
if (onReply) {
|
||||||
? 'unknown time'
|
onReply(comment.author, replyText);
|
||||||
: formatDistanceToNow(date, { addSuffix: true });
|
} else {
|
||||||
})()}
|
setReplyTo({ author: comment.author, text: replyText });
|
||||||
</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),
|
|
||||||
}}
|
}}
|
||||||
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
|
<Reply size={11} />
|
||||||
content={(() => {
|
Reply
|
||||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
</button>
|
||||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
</TooltipTrigger>
|
||||||
return t;
|
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||||
})()}
|
</Tooltip>
|
||||||
maxHeight="max-h-none"
|
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
bare
|
<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>
|
) : (
|
||||||
)}
|
<span
|
||||||
</ExpandableContent>
|
onClickCapture={
|
||||||
);
|
onTaskIdClick
|
||||||
})()}
|
? (e) => {
|
||||||
{comment.attachments && comment.attachments.length > 0 ? (
|
const link = (
|
||||||
<CommentAttachments
|
e.target as HTMLElement
|
||||||
attachments={comment.attachments}
|
).closest<HTMLAnchorElement>('a[href^="task://"]');
|
||||||
teamName={teamName}
|
if (link) {
|
||||||
taskId={taskId}
|
e.preventDefault();
|
||||||
onPreview={setPreviewImageUrl}
|
e.stopPropagation();
|
||||||
/>
|
const id = link.getAttribute('href')?.replace('task://', '');
|
||||||
) : null}
|
if (id) onTaskIdClick(id);
|
||||||
</div>
|
}
|
||||||
|
}
|
||||||
|
: 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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -601,7 +601,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
const rotatingTips = React.useMemo(
|
const rotatingTips = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
'Tip: Use @ to mention team members or search files',
|
'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",
|
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
export interface TeamColorSet {
|
export interface TeamColorSet {
|
||||||
/** Border accent color */
|
/** Border accent color */
|
||||||
border: string;
|
border: string;
|
||||||
|
/** Border accent color for light theme */
|
||||||
|
borderLight?: string;
|
||||||
/** Badge background (semi-transparent) */
|
/** Badge background (semi-transparent) */
|
||||||
badge: string;
|
badge: string;
|
||||||
/** Badge background for light theme (more visible on white) */
|
/** 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. */
|
/** Reserved for the human user — never assigned to team members. */
|
||||||
user: {
|
user: {
|
||||||
border: '#f5f5f4',
|
border: '#f5f5f4',
|
||||||
|
borderLight: '#a8a29e',
|
||||||
badge: 'rgba(245, 245, 244, 0.12)',
|
badge: 'rgba(245, 245, 244, 0.12)',
|
||||||
badgeLight: 'rgba(0, 0, 0, 0.08)',
|
badgeLight: 'rgba(120, 113, 108, 0.14)',
|
||||||
text: '#d6d3d1',
|
text: '#d6d3d1',
|
||||||
textLight: '#57534e',
|
textLight: '#44403c',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -148,3 +151,17 @@ export function getTeamColorSet(colorName: string): TeamColorSet {
|
||||||
export function getThemedBadge(colorSet: TeamColorSet, isLight: boolean): string {
|
export function getThemedBadge(colorSet: TeamColorSet, isLight: boolean): string {
|
||||||
return isLight && colorSet.badgeLight ? colorSet.badgeLight : colorSet.badge;
|
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
|
// Initialize from cache to prevent flash
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(THEME_CACHE_KEY);
|
const cached = localStorage.getItem(THEME_CACHE_KEY);
|
||||||
if (cached === 'light') return 'light';
|
if (cached === 'light' || cached === 'dark') return cached;
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage may not be available
|
// 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.
|
// Fetch config on mount if not loaded.
|
||||||
|
|
@ -50,7 +51,7 @@ export function useTheme(): {
|
||||||
}, [appConfig, configLoading, fetchConfig]);
|
}, [appConfig, configLoading, fetchConfig]);
|
||||||
|
|
||||||
// Get configured theme
|
// Get configured theme
|
||||||
const configuredTheme: Theme = appConfig?.general?.theme ?? 'dark';
|
const configuredTheme: Theme = appConfig?.general?.theme ?? 'system';
|
||||||
|
|
||||||
// Get system theme preference
|
// Get system theme preference
|
||||||
const getSystemTheme = useCallback((): ResolvedTheme => {
|
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 {
|
@keyframes chat-message-enter {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -670,6 +655,17 @@ body {
|
||||||
animation: thought-expand 350ms ease-out both;
|
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 {
|
.skeleton-card {
|
||||||
animation: skeleton-fade-in 0.4s ease-out both;
|
animation: skeleton-fade-in 0.4s ease-out both;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
|
|
@ -206,8 +206,8 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
// Mark the notification as read
|
// Mark the notification as read
|
||||||
void state.markNotificationRead(error.id);
|
void state.markNotificationRead(error.id);
|
||||||
|
|
||||||
// Team rate-limit notifications: open the team tab instead of a session tab
|
// Team notifications (inbox, clarification, status change, rate-limit): open team tab
|
||||||
if (error.source === 'rate-limit' && error.sessionId.startsWith('team:')) {
|
if (error.sessionId.startsWith('team:')) {
|
||||||
const teamName = error.sessionId.slice('team:'.length);
|
const teamName = error.sessionId.slice('team:'.length);
|
||||||
state.openTeamTab(teamName, error.context.cwd);
|
state.openTeamTab(teamName, error.context.cwd);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,8 @@ function detectClarificationNotifications(
|
||||||
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
||||||
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
|
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
|
||||||
notifiedClarificationTaskKeys.add(key);
|
notifiedClarificationTaskKeys.add(key);
|
||||||
if (notifyEnabled) {
|
// Always store in-app; suppress OS toast when per-type toggle is off
|
||||||
fireClarificationNotification(task);
|
fireClarificationNotification(task, !notifyEnabled);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
notifiedClarificationTaskKeys.delete(key);
|
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)
|
// 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 latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
|
||||||
const body = latestComment?.text || task.description || `Task #${task.id}: ${task.subject}`;
|
const body = latestComment?.text || task.description || `Task #${task.id}: ${task.subject}`;
|
||||||
|
|
||||||
void api.teams
|
void api.teams
|
||||||
?.showMessageNotification({
|
?.showMessageNotification({
|
||||||
|
teamName: task.teamName,
|
||||||
teamDisplayName: task.teamDisplayName,
|
teamDisplayName: task.teamDisplayName,
|
||||||
from: latestComment?.author || 'team-lead',
|
from: latestComment?.author || 'team-lead',
|
||||||
to: 'user',
|
to: 'user',
|
||||||
summary: `Clarification needed — Task #${task.id}`,
|
summary: `Clarification needed — Task #${task.id}`,
|
||||||
body,
|
body,
|
||||||
|
teamEventType: 'task_clarification',
|
||||||
|
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
|
||||||
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -138,13 +141,12 @@ function detectStatusChangeNotifications(
|
||||||
config: AppConfig | null,
|
config: AppConfig | null,
|
||||||
teamByName: Record<string, TeamSummary>
|
teamByName: Record<string, TeamSummary>
|
||||||
): void {
|
): void {
|
||||||
if (!config?.notifications?.notifyOnStatusChange) return;
|
const statusChangeEnabled =
|
||||||
if (!config.notifications.enabled) return;
|
!!config?.notifications?.notifyOnStatusChange && !!config.notifications.enabled;
|
||||||
|
const statuses = config?.notifications?.statusChangeStatuses ?? ['in_progress', 'completed'];
|
||||||
const statuses = config.notifications.statusChangeStatuses ?? ['in_progress', 'completed'];
|
|
||||||
if (statuses.length === 0) return;
|
if (statuses.length === 0) return;
|
||||||
|
|
||||||
const onlySolo = config.notifications.statusChangeOnlySolo ?? true;
|
const onlySolo = config?.notifications?.statusChangeOnlySolo ?? true;
|
||||||
|
|
||||||
for (const task of newTasks) {
|
for (const task of newTasks) {
|
||||||
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
||||||
|
|
@ -170,14 +172,20 @@ function detectStatusChangeNotifications(
|
||||||
notifiedStatusChangeKeys.add(key);
|
notifiedStatusChangeKeys.add(key);
|
||||||
|
|
||||||
const fromLabel = becameApproved ? 'Completed' : oldTask.status;
|
const fromLabel = becameApproved ? 'Completed' : oldTask.status;
|
||||||
fireStatusChangeNotification(task, fromLabel, becameApproved ? 'approved' : undefined);
|
fireStatusChangeNotification(
|
||||||
|
task,
|
||||||
|
fromLabel,
|
||||||
|
becameApproved ? 'approved' : undefined,
|
||||||
|
!statusChangeEnabled
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fireStatusChangeNotification(
|
function fireStatusChangeNotification(
|
||||||
task: GlobalTask,
|
task: GlobalTask,
|
||||||
fromStatus: string,
|
fromStatus: string,
|
||||||
overrideToStatus?: string
|
overrideToStatus?: string,
|
||||||
|
suppressToast?: boolean
|
||||||
): void {
|
): void {
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
|
|
@ -192,11 +200,15 @@ function fireStatusChangeNotification(
|
||||||
|
|
||||||
void api.teams
|
void api.teams
|
||||||
?.showMessageNotification({
|
?.showMessageNotification({
|
||||||
|
teamName: task.teamName,
|
||||||
teamDisplayName: task.teamDisplayName,
|
teamDisplayName: task.teamDisplayName,
|
||||||
from: task.owner ?? 'system',
|
from: task.owner ?? 'system',
|
||||||
to: 'user',
|
to: 'user',
|
||||||
summary: `Task #${task.id}: ${from} → ${to}`,
|
summary: `Task #${task.id}: ${from} → ${to}`,
|
||||||
body: task.subject,
|
body: task.subject,
|
||||||
|
teamEventType: 'task_status_change',
|
||||||
|
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
|
||||||
|
suppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,17 @@ export interface DetectedError {
|
||||||
triggerId?: string;
|
triggerId?: string;
|
||||||
/** Human-readable name of the trigger that produced this notification */
|
/** Human-readable name of the trigger that produced this notification */
|
||||||
triggerName?: string;
|
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 */
|
/** Additional context */
|
||||||
context: {
|
context: {
|
||||||
/** Display name of the project */
|
/** Display name of the project */
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,8 @@ export interface ReplaceMembersRequest {
|
||||||
/** Data sent from renderer to main for native OS team message notification. */
|
/** Data sent from renderer to main for native OS team message notification. */
|
||||||
export interface TeamMessageNotificationData {
|
export interface TeamMessageNotificationData {
|
||||||
teamDisplayName: string;
|
teamDisplayName: string;
|
||||||
|
/** Team directory name (for notification storage and deep-linking). */
|
||||||
|
teamName?: string;
|
||||||
/** Who sent the message. */
|
/** Who sent the message. */
|
||||||
from: string;
|
from: string;
|
||||||
/** Who received the message (member name or "user"). */
|
/** Who received the message (member name or "user"). */
|
||||||
|
|
@ -526,6 +528,16 @@ export interface TeamMessageNotificationData {
|
||||||
body: string;
|
body: string;
|
||||||
/** Optional sender color for visual context. */
|
/** Optional sender color for visual context. */
|
||||||
color?: string;
|
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 };
|
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 {
|
import {
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
TEAM_STOP,
|
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', () => {
|
describe('reserved teammate names', () => {
|
||||||
it('rejects teammate name "user" in createTeam', async () => {
|
it('rejects teammate name "user" in createTeam', async () => {
|
||||||
const handler = handlers.get(TEAM_CREATE)!;
|
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');
|
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', () => {
|
it('increments task IDs', () => {
|
||||||
run(claudeDir, ['task', 'create', '--subject', 'Task 1']);
|
run(claudeDir, ['task', 'create', '--subject', 'Task 1']);
|
||||||
run(claudeDir, ['task', 'create', '--subject', 'Task 2']);
|
run(claudeDir, ['task', 'create', '--subject', 'Task 2']);
|
||||||
|
|
@ -356,8 +387,9 @@ describe('teamctl.js', () => {
|
||||||
expect(inbox.length).toBe(1);
|
expect(inbox.length).toBe(1);
|
||||||
const msg = inbox[0] as Record<string, unknown>;
|
const msg = inbox[0] as Record<string, unknown>;
|
||||||
expect(msg.from).toBe('alice');
|
expect(msg.from).toBe('alice');
|
||||||
expect(String(msg.text)).toContain('New task assigned');
|
expect(String(msg.text)).toMatch(/^New task assigned to you: #1 "Assigned task"\./);
|
||||||
expect(String(msg.text)).toContain('#1');
|
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', () => {
|
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
|
// Task Set-Owner / Assign
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -652,8 +741,9 @@ describe('teamctl.js', () => {
|
||||||
expect(inbox.length).toBe(1);
|
expect(inbox.length).toBe(1);
|
||||||
const msg = inbox[0] as Record<string, unknown>;
|
const msg = inbox[0] as Record<string, unknown>;
|
||||||
expect(msg.from).toBe('alice');
|
expect(msg.from).toBe('alice');
|
||||||
expect(String(msg.text)).toContain('Task assigned to you');
|
expect(String(msg.text)).toMatch(/^Task assigned to you: #1 "Unowned task"\./);
|
||||||
expect(String(msg.text)).toContain('#1');
|
expect(msg.summary).toBe('Task #1 assigned');
|
||||||
|
expect(msg.source).toBe('system_notification');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT send notification without --notify', () => {
|
it('does NOT send notification without --notify', () => {
|
||||||
|
|
@ -749,6 +839,10 @@ describe('teamctl.js', () => {
|
||||||
it('sends inbox notification to owner (skip self-notification)', () => {
|
it('sends inbox notification to owner (skip self-notification)', () => {
|
||||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Review this', '--from', 'alice']);
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Review this', '--from', 'alice']);
|
||||||
expect(readInbox(claudeDir, 'bob').length).toBe(1);
|
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']);
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Self note', '--from', 'bob']);
|
||||||
expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1
|
expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1
|
||||||
|
|
@ -1385,8 +1479,9 @@ describe('teamctl.js', () => {
|
||||||
const inbox = readInbox(claudeDir, 'bob');
|
const inbox = readInbox(claudeDir, 'bob');
|
||||||
expect(inbox.length).toBe(1);
|
expect(inbox.length).toBe(1);
|
||||||
const text = String((inbox[0] as Record<string, unknown>).text);
|
const text = String((inbox[0] as Record<string, unknown>).text);
|
||||||
expect(text).toContain('approved');
|
expect(text).toBe('Task #1 approved.\n\nLooks great!');
|
||||||
expect(text).toContain('Looks 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');
|
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((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
||||||
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
||||||
const text = String((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).text);
|
const text = String((readInbox(claudeDir, 'bob')[0] as Record<string, unknown>).text);
|
||||||
expect(text).toContain('Fix the edge case');
|
expect(text).toBe(
|
||||||
expect(text).toContain('Please fix');
|
'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', () => {
|
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', () => {
|
describe('existing tab behavior', () => {
|
||||||
it('should focus existing tab if session is already open', () => {
|
it('should focus existing tab if session is already open', () => {
|
||||||
// Open target session tab first
|
// Open target session tab first
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue