/ src / components / memory / memory-card.tsx
memory-card.tsx
  1  'use client'
  2  
  3  import { useState } from 'react'
  4  import type { MemoryEntry } from '@/types'
  5  import { AgentAvatar } from '@/components/agents/agent-avatar'
  6  import { deriveMemoryScope, getMemoryScopeLabel, getMemoryTier } from '@/lib/memory-presentation'
  7  import { timeAgoShort } from '@/lib/time-format'
  8  
  9  interface Props {
 10    entry: MemoryEntry
 11    active?: boolean
 12    agentName?: string | null
 13    agentAvatarSeed?: string | null
 14    agentAvatarUrl?: string | null
 15    onClick: () => void
 16  }
 17  
 18  export function MemoryCard({ entry, active, agentName, agentAvatarSeed, agentAvatarUrl, onClick }: Props) {
 19    const [now] = useState(() => Date.now())
 20    const scope = deriveMemoryScope(entry)
 21    const tier = getMemoryTier(entry)
 22    const isDreamOrigin = entry.category === 'dream_reflection'
 23      || (entry.metadata as Record<string, unknown> | undefined)?.origin === 'dream'
 24  
 25    return (
 26      <div
 27        onClick={onClick}
 28        className={`relative py-3 px-4 cursor-pointer rounded-[14px]
 29          transition-all duration-200 active:scale-[0.98]
 30          ${active
 31            ? 'bg-accent-soft border border-accent-bright/10'
 32            : 'bg-transparent border border-transparent hover:bg-white/[0.02] hover:border-white/[0.03]'}`}
 33      >
 34        {active && (
 35          <div className="absolute left-0 top-3 bottom-3 w-[2.5px] rounded-full bg-accent-bright" />
 36        )}
 37        <div className="flex items-center gap-2">
 38          <span className="shrink-0 text-[9px] font-700 uppercase tracking-wider text-accent-bright/70 bg-accent-soft px-1.5 py-0.5 rounded-[5px]">
 39            {entry.category || 'note'}
 40          </span>
 41          {isDreamOrigin && (
 42            <span className="shrink-0 text-[9px] font-700 uppercase tracking-wider text-violet-300/70 bg-violet-400/10 px-1.5 py-0.5 rounded-[5px]">
 43              dream
 44            </span>
 45          )}
 46          {entry.pinned && (
 47            <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="shrink-0 text-amber-400/80">
 48              <path d="M16 2l-4 4-4-4-2 2 4 4-5 5v1h1l5-5 4 4 2-2-4-4 4-4z" transform="rotate(45 12 12)" />
 49            </svg>
 50          )}
 51          <span className="font-display text-[13px] font-600 truncate flex-1 tracking-[-0.01em]">{entry.title}</span>
 52          <span className="text-[10px] text-text-3/60 shrink-0 tabular-nums font-mono">
 53            {timeAgoShort(entry.updatedAt || entry.createdAt, now)}
 54          </span>
 55        </div>
 56        <div className="text-[12px] text-text-2/40 mt-1 line-clamp-3 leading-relaxed">
 57          {entry.content || '(empty)'}
 58        </div>
 59        <div className="mt-2 flex flex-wrap items-center gap-1.5">
 60          <span className="px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-[0.08em] bg-white/[0.04] text-text-3/75">
 61            {getMemoryScopeLabel(scope)}
 62          </span>
 63          <span className={`px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-[0.08em] ${
 64            tier === 'working'
 65              ? 'bg-amber-400/10 text-amber-300'
 66              : tier === 'archive'
 67                ? 'bg-sky-400/10 text-sky-300'
 68                : 'bg-emerald-400/10 text-emerald-300'
 69          }`}>
 70            {tier}
 71          </span>
 72        </div>
 73        {(entry.image?.path || entry.imagePath) && (
 74          <div className="mt-2 w-10 h-10 rounded-[6px] overflow-hidden bg-white/[0.04] shrink-0">
 75            {/* eslint-disable-next-line @next/next/no-img-element */}
 76            <img
 77              src={
 78                (entry.image?.path || entry.imagePath || '').startsWith('data/memory-images/')
 79                  ? `/api/memory-images/${(entry.image?.path || entry.imagePath || '').split('/').pop()}`
 80                  : (entry.image?.path || entry.imagePath || '')
 81              }
 82              alt=""
 83              className="w-full h-full object-cover"
 84            />
 85          </div>
 86        )}
 87        {(entry.references?.length || entry.linkedMemoryIds?.length || entry.image?.path || entry.imagePath) && (
 88          <div className="flex items-center gap-2 mt-1.5 text-[10px] text-text-3/35">
 89            {entry.references?.length ? <span>{entry.references.length} ref{entry.references.length === 1 ? '' : 's'}</span> : null}
 90            {entry.linkedMemoryIds?.length ? <span>{entry.linkedMemoryIds.length} linked</span> : null}
 91            {(entry.image?.path || entry.imagePath) ? <span>image</span> : null}
 92          </div>
 93        )}
 94        {agentName ? (
 95          <div className="flex items-center gap-1.5 mt-1.5">
 96            <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={16} />
 97            <span className="text-[10px] text-text-3/60 truncate">{agentName}</span>
 98          </div>
 99        ) : !entry.agentId ? (
100          <div className="flex items-center gap-1 mt-1.5">
101            <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/50">
102              <circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" />
103              <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
104            </svg>
105            <span className="text-[10px] text-text-3/50">Global</span>
106          </div>
107        ) : null}
108      </div>
109    )
110  }