214 lines · 6.4 KB · TypeScript
RawDownload
/** * 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 } 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 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>          {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>    </>  );};