HistorySidebar.tsx4.1 KBView on GitHub
'use client';

import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils';

type Origin = 'human' | 'agent' | 'system';

export interface HistorySidebarSession {
  id: string;
  fromSeq: number;
  toSeq: number;
  isClosed: boolean;
  origin: Origin;
  actorUserId: string | null;
  actorLabel: string | null;
  /** Server-resolved display name + email (null when unknown). */
  actor: { name: string; email: string | null } | null;
  editCount: number;
  wordsBefore: number;
  wordsAfter: number;
  startedAt: Date | string;
  endedAt: Date | string;
}

export interface HistorySidebarProps {
  sessions: HistorySidebarSession[];
  selectedSeq: number | null;
  onSelect: (toSeq: number) => void;
}

const ROLE_LABEL: Record<Origin, 'user' | 'agent' | 'system'> = {
  human: 'user',
  agent: 'agent',
  system: 'system',
};

function toDate(v: Date | string): Date {
  return v instanceof Date ? v : new Date(v);
}

function formatTimestamp(d: Date): string {
  const time = format(d, 'h:mm a').toLowerCase();
  if (isToday(d)) return `Today - ${time}`;
  if (isYesterday(d)) return `Yesterday - ${time}`;
  return `${format(d, 'MMM d')} - ${time}`;
}

function formatWordDelta(delta: number): string {
  if (delta === 0) return 'No word change';
  const sign = delta > 0 ? '+' : '−';
  const abs = Math.abs(delta);
  return `${sign}${abs} ${abs === 1 ? 'word' : 'words'}`;
}

/**
 * Sidebar list of document version-history sessions, newest first. Each
 * block carries three rows:
 *
 *   [ Today - 4:32 pm ]
 *   [ +12 words ]
 *   [ Jesse Li (user) ]
 *
 * Actor names are resolved server-side (see documents.history) so the UI
 * always shows a real name regardless of frontend cache state. No timeline
 * rail.
 */
export function HistorySidebar({
  sessions,
  selectedSeq,
  onSelect,
}: HistorySidebarProps) {
  function actorName(s: HistorySidebarSession): string {
    if (s.actor?.name) return s.actor.name;
    if (s.actor?.email) return s.actor.email;
    if (s.origin === 'human') return 'Unknown user';
    if (s.origin === 'agent') return s.actorLabel ?? 'Agent';
    return s.actorLabel ?? 'System';
  }

  return (
    <aside
      className="flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-border bg-muted/30"
      aria-label="Version history"
    >
      <div className="px-4 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
        Version history
      </div>
      {sessions.length === 0 ? (
        <p className="px-4 py-2 text-xs italic text-muted-foreground">
          No edits recorded yet.
        </p>
      ) : (
        <ol className="space-y-1 px-2 pb-4">
          {sessions.map((s) => {
            const isSelected = s.toSeq === selectedSeq;
            const delta = s.wordsAfter - s.wordsBefore;
            const name = actorName(s);
            const role = ROLE_LABEL[s.origin];
            return (
              <li key=[redacted]
                <button
                  type="button"
                  onClick={() => onSelect(s.toSeq)}
                  data-testid={`history-session-${s.toSeq}`}
                  data-origin={s.origin}
                  data-selected={isSelected ? 'true' : 'false'}
                  aria-pressed={isSelected}
                  className={cn(
                    'block w-full rounded-md px-3 py-2 text-left transition-colors',
                    isSelected
                      ? 'bg-accent ring-1 ring-accent-foreground/10'
                      : 'hover:bg-accent/50',
                  )}
                >
                  <div className="text-xs text-muted-foreground">
                    {formatTimestamp(toDate(s.endedAt))}
                  </div>
                  <div className="mt-0.5 text-xs text-foreground">
                    {formatWordDelta(delta)}
                  </div>
                  <div className="mt-0.5 text-xs">
                    <span className="font-medium text-foreground">{name}</span>{' '}
                    <span className="text-muted-foreground">({role})</span>
                  </div>
                </button>
              </li>
            );
          })}
        </ol>
      )}
    </aside>
  );
}