feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts
- Add ComparePane.tsx: side-by-side AI response comparison - Add Memory.tsx: memory management page with CRUD UI - Add McpPermissionDialog.tsx: MCP tool permission approval dialog - Add McpResponseDisplay.tsx: MCP response visualization - Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience - Add EmptyState.tsx: contextual empty state component - Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference - Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard - Add useDraftPersistence.ts: draft message persistence hook - Add useTerminals.ts: terminal session management hook - Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities - Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes - Extend hooks: useTerminalSocket, useSessionStream test suite - Update pages: Home, Project — workspace layout and session flow
This commit is contained in:
427
apps/web/src/pages/Memory.tsx
Normal file
427
apps/web/src/pages/Memory.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, BrainCircuit, CalendarDays, CloudMoon } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/api/client';
|
||||
import type { MemoryEntry, DailyMemoryEntry, DreamEntry } from '@/api/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
|
||||
|
||||
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
} {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetcher()
|
||||
.then(setData)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { data, loading, error, retry: load };
|
||||
}
|
||||
|
||||
// ─── Skeleton pulse placeholder ─────────────────────────────────────────────
|
||||
|
||||
function SkeletonBar({ className }: { className?: string }) {
|
||||
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||
}
|
||||
|
||||
// ─── Formatters ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateShort(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max) + '…';
|
||||
}
|
||||
|
||||
function relTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return formatDate(iso);
|
||||
}
|
||||
|
||||
// ─── Empty state ────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
|
||||
}
|
||||
|
||||
// ─── Tab bar (same pattern as Results.tsx) ──────────────────────────────────
|
||||
|
||||
type TabId = 'all' | 'daily' | 'dreams';
|
||||
|
||||
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 border-b pb-px">
|
||||
{[
|
||||
{ id: 'all' as TabId, label: 'All Memory', icon: BrainCircuit },
|
||||
{ id: 'daily' as TabId, label: 'Daily Log', icon: CalendarDays },
|
||||
{ id: 'dreams' as TabId, label: 'Dreams', icon: CloudMoon },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
|
||||
active === tab.id
|
||||
? 'bg-background border-border text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<tab.icon className="size-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── All Memory Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
function AllMemoryTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.memory.list(projectId).then((r) => r.entries));
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Card key={i} size="sm">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<SkeletonBar className="h-4 w-16" />
|
||||
<SkeletonBar className="h-5 w-3/4" />
|
||||
<SkeletonBar className="h-3 w-full" />
|
||||
<div className="flex gap-1.5">
|
||||
<SkeletonBar className="h-4 w-12" />
|
||||
<SkeletonBar className="h-4 w-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No topic-based memory entries yet." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{data.map((entry: MemoryEntry) => (
|
||||
<Card key={entry.id}>
|
||||
<CardContent className="pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
{/* Topic badge */}
|
||||
<span className="inline-block text-[10px] uppercase tracking-wider font-medium text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded mb-1.5">
|
||||
{entry.topic}
|
||||
</span>
|
||||
<h3 className="text-sm font-medium">{entry.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{truncate(entry.content, 200)}
|
||||
</p>
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{entry.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] bg-secondary/50 text-secondary-foreground px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{expanded === entry.id && (
|
||||
<div className="mt-3 pt-3 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
||||
{entry.content}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Log Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function DailyLogTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.memory.daily(projectId).then((r) => r.entries));
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const groups: Record<string, DailyMemoryEntry[]> = {};
|
||||
for (const entry of data) {
|
||||
const g = groups[entry.date];
|
||||
if (g) {
|
||||
g.push(entry);
|
||||
} else {
|
||||
groups[entry.date] = [entry];
|
||||
}
|
||||
}
|
||||
return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0]));
|
||||
}, [data]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
{[0, 1].map((day) => (
|
||||
<div key={day}>
|
||||
<SkeletonBar className="h-4 w-24 mb-2" />
|
||||
<div className="space-y-2">
|
||||
{[0, 1].map((e) => (
|
||||
<Card key={e} size="sm">
|
||||
<CardContent className="pt-3 space-y-1">
|
||||
<SkeletonBar className="h-3 w-20" />
|
||||
<SkeletonBar className="h-3 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No daily log entries for the last 7 days." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
{grouped.map(([date, entries]) => (
|
||||
<div key={date}>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{formatDateShort(date)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry: DailyMemoryEntry) => (
|
||||
<Card key={entry.id} size="sm">
|
||||
<CardContent className="pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{entry.title}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
||||
{truncate(entry.content, 150)}
|
||||
</p>
|
||||
</button>
|
||||
{expanded === entry.id && (
|
||||
<div className="mt-2 pt-2 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
||||
{entry.content}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dreams Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DreamsTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.memory.dreams(projectId).then((r) => r.entries));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<SkeletonBar className="h-4 w-32" />
|
||||
<SkeletonBar className="h-3 w-full" />
|
||||
<SkeletonBar className="h-3 w-5/6" />
|
||||
<SkeletonBar className="h-3 w-2/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No dream consolidation diaries yet." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{data.map((entry: DreamEntry, i: number) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="text-sm font-medium mb-2">{formatDateShort(entry.date)}</h3>
|
||||
<pre className="text-xs font-mono leading-relaxed whitespace-pre-wrap bg-muted/20 p-3 rounded-md border border-border/50 overflow-x-auto">
|
||||
{entry.content}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function Memory() {
|
||||
const navigate = useNavigate();
|
||||
const { data: sidebar, activeSession } = useSidebar();
|
||||
|
||||
const [tab, setTab] = useState<TabId>('all');
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
|
||||
// Derive default project from active session or first project.
|
||||
const projects = useMemo(() => {
|
||||
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
|
||||
}, [sidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId && projects.length > 0) {
|
||||
// Prefer active session's project, else first project.
|
||||
const defaultId = activeSession?.project_id ?? projects[0]!.id;
|
||||
setProjectId(defaultId);
|
||||
}
|
||||
}, [projects, activeSession, projectId]);
|
||||
|
||||
function handleBack() {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
|
||||
{/* Header */}
|
||||
<header className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<BrainCircuit className="size-5" />
|
||||
Memory Browser
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Topic-based memories, daily logs, and dream consolidation diaries.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab bar */}
|
||||
<TabBar active={tab} onChange={setTab} />
|
||||
|
||||
{/* Tab content */}
|
||||
{!projectId ? (
|
||||
<EmptyState message="Select a project to view memory." />
|
||||
) : tab === 'all' ? (
|
||||
<AllMemoryTab projectId={projectId} />
|
||||
) : tab === 'daily' ? (
|
||||
<DailyLogTab projectId={projectId} />
|
||||
) : (
|
||||
<DreamsTab projectId={projectId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user