- 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.
228 lines
6.9 KiB
TypeScript
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)' }}>·</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>
|
|
</>
|
|
);
|
|
};
|