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>
);
}