agent-ecosystem/src/renderer/components/notifications/NotificationRow.tsx
iliya 355fe237a6 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.
2026-03-07 13:44:07 +02:00

228 lines
6.9 KiB
TypeScript

/**
* NotificationRow - Linear Inbox-style notification row.
* Compact, high-density layout with hover actions.
*/
import { useState } from 'react';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { formatDistanceToNow } from 'date-fns';
import { ArrowRight, Bot, Check, Trash2, Users } from 'lucide-react';
import type { DetectedError } from '@renderer/types/data';
interface NotificationRowProps {
error: DetectedError;
onRowClick: () => void;
onArchive: () => void;
onDelete: () => void;
}
/**
* Truncates a string to a maximum length, adding ellipsis if truncated.
*/
function truncateMessage(message: string, maxLength: number = 100): string {
if (message.length <= maxLength) return message;
return message.slice(0, maxLength).trim() + '...';
}
export const NotificationRow = ({
error,
onRowClick,
onArchive,
onDelete,
}: Readonly<NotificationRowProps>): React.JSX.Element => {
const [isHovered, setIsHovered] = useState(false);
const isUnread = !error.isRead;
const projectName = error.context?.projectName || 'Unknown Project';
const relativeTime = formatDistanceToNow(new Date(error.timestamp), {
addSuffix: true,
});
const truncatedMessage = truncateMessage(error.message);
const colorDef = getTriggerColorDef(error.triggerColor);
const displayName = error.triggerName ?? error.source;
const isTeamNotification = error.category === 'team' || error.sessionId?.startsWith('team:');
const handleArchiveClick = (e: React.MouseEvent): void => {
e.stopPropagation();
onArchive();
};
const handleDeleteClick = (e: React.MouseEvent): void => {
e.stopPropagation();
onDelete();
};
const handleNavigateClick = (e: React.MouseEvent): void => {
e.stopPropagation();
onRowClick();
};
return (
<div
role="button"
tabIndex={0}
onClick={onRowClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick();
}
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex h-full cursor-pointer items-center gap-3 border-b px-4 transition-colors"
style={{
borderColor: 'var(--color-border)',
backgroundColor: isHovered ? 'var(--color-surface-raised)' : undefined,
opacity: isUnread ? 1 : 0.5,
}}
>
{/* Color Dot — always visible, opacity indicates read state */}
<div className="flex w-3 shrink-0 justify-center">
<span
className="size-2.5 rounded-full"
style={{
backgroundColor: colorDef.hex,
opacity: isUnread ? 1 : 0.4,
}}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1 py-2">
{/* Title Row */}
<div className="flex items-center gap-1.5">
<span
className="truncate text-sm font-medium"
style={{ color: isUnread ? 'var(--color-text)' : 'var(--color-text-secondary)' }}
>
{displayName}
</span>
<span style={{ color: 'var(--color-text-muted)' }}>&middot;</span>
<span className="truncate text-sm" style={{ color: 'var(--color-text-muted)' }}>
{projectName}
</span>
{isTeamNotification && !error.subagentId && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: 'var(--tag-bg)',
border: '1px solid var(--tag-border)',
color: 'var(--color-text-muted)',
}}
>
<Users className="size-3" />
team
</span>
)}
{error.subagentId && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: 'var(--tag-bg)',
border: '1px solid var(--tag-border)',
color: 'var(--color-text-muted)',
}}
>
<Bot className="size-3" />
subagent
</span>
)}
</div>
{/* Description */}
<p className="mt-0.5 truncate text-xs" style={{ color: 'var(--color-text-muted)' }}>
{truncatedMessage}
</p>
</div>
{/* Right Side: Time or Hover Actions */}
<div className="flex shrink-0 items-center gap-1">
{isHovered ? (
<HoverActions
isUnread={isUnread}
onArchiveClick={handleArchiveClick}
onDeleteClick={handleDeleteClick}
onNavigateClick={handleNavigateClick}
/>
) : (
<span
className="whitespace-nowrap text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{relativeTime}
</span>
)}
</div>
</div>
);
};
/**
* HoverActions - Action buttons shown on hover.
*/
interface HoverActionsProps {
isUnread: boolean;
onArchiveClick: (e: React.MouseEvent) => void;
onDeleteClick: (e: React.MouseEvent) => void;
onNavigateClick: (e: React.MouseEvent) => void;
}
const HoverActions = ({
isUnread,
onArchiveClick,
onDeleteClick,
onNavigateClick,
}: HoverActionsProps): React.JSX.Element => {
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const getButtonStyle = (buttonId: string, isDelete = false): React.CSSProperties => ({
color:
hoveredButton === buttonId
? isDelete
? 'var(--tool-result-error-text)'
: 'var(--color-text)'
: 'var(--color-text-muted)',
backgroundColor: hoveredButton === buttonId ? 'var(--color-border-emphasis)' : undefined,
});
return (
<>
{/* Archive Button (mark as read) */}
{isUnread && (
<button
onClick={onArchiveClick}
onMouseEnter={() => setHoveredButton('archive')}
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('archive')}
title="Mark as read"
>
<Check className="size-4" />
</button>
)}
{/* Delete Button */}
<button
onClick={onDeleteClick}
onMouseEnter={() => setHoveredButton('delete')}
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('delete', true)}
title="Delete"
>
<Trash2 className="size-4" />
</button>
{/* Navigate Button */}
<button
onClick={onNavigateClick}
onMouseEnter={() => setHoveredButton('navigate')}
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('navigate')}
title="View in session"
>
<ArrowRight className="size-4" />
</button>
</>
);
};