feat: Paseo-like orchestrator Phase 1-2 — trace system, session persistence, timeline, run_command, auto-fix loop

Phase 1: Trace System + Observability
- tool_traces DB table + insert/update service
- tool_trace_start/tool_trace_finish WS frames (contracts + FE types)
- Instrumented tool-phase.ts with timing around every tool call
- GET /api/chats/:id/traces paginated endpoint
- Trace viewer frontend (collapsible panel with timing bars + token breakdown)

Phase 2: Session Persistence + Resume
- agent_snapshots table (UPSERT per chat, persisted on turn boundaries)
- save/load/delete service functions
- Agent snapshot sent on WS reconnect
- Session timeline view (vertical timeline with scroll-to + restore)

Tooling:
- run_command tool (execFile, 30s timeout, 32KB cap, path-guarded)
- Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
This commit is contained in:
2026-06-08 02:26:47 +00:00
parent 7cb692d8be
commit abe9c5a3a8
22 changed files with 2231 additions and 101 deletions

View File

@@ -1,10 +1,14 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { Pin } from 'lucide-react';
import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
import { RequestReadAccessCard } from './RequestReadAccessCard';
import { MessageListErrorBoundary } from './MessageListErrorBoundary';
interface Props {
messages: Message[];
@@ -142,27 +146,63 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
});
}
const SCROLL_THRESHOLD_PX = 150;
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const isNearBottomRef = useRef(true);
const renderedKeysRef = useRef(new Set<string>());
const prefersReducedMotionRef = useRef(false);
const [animateEnabled, setAnimateEnabled] = useState(true);
const [pinMessageId, setPinMessageId] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash;
if (hash.startsWith('#pin=')) return hash.slice(5);
}
return null;
});
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
const pinIndex = useMemo(() => {
if (!pinMessageId) return -1;
return renderItems.findIndex(
(item) => item.kind === 'message' && item.message.id === pinMessageId,
);
}, [pinMessageId, renderItems]);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mq.matches;
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotionRef.current = e.matches;
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
const handler = () => {
const hash = window.location.hash;
if (hash.startsWith('#pin=')) {
setPinMessageId(hash.slice(5));
} else {
setPinMessageId(null);
}
};
window.addEventListener('hashchange', handler);
return () => window.removeEventListener('hashchange', handler);
}, []);
const atBottomStateChange = useCallback((atBottom: boolean) => {
isNearBottomRef.current = atBottom;
setAnimateEnabled(atBottom);
}, []);
const scrollToPin = useCallback(() => {
if (pinIndex >= 0 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index: pinIndex, align: 'center' });
}
}, [messages]);
}, [pinIndex]);
if (messages.length === 0) {
return (
@@ -173,46 +213,78 @@ export function MessageList({ messages, sessionChats }: Props) {
}
return (
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}>
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => {
if (item.kind === 'message') {
return (
<MessageBubble
key={item.message.id}
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
if (item.run.call.name === 'request_read_access') {
return (
<RequestReadAccessCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
<div ref={endRef} />
</div>
<MessageListErrorBoundary>
<div className="flex-1 flex flex-col">
{pinMessageId && pinIndex >= 0 && (
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 bg-primary/10 border-b border-primary/20 text-xs text-primary">
<Pin className="size-3" />
<span>Pinned message</span>
<button
type="button"
onClick={scrollToPin}
className="ml-auto underline hover:no-underline"
>
Jump to pinned
</button>
</div>
)}
<Virtuoso
ref={virtuosoRef}
className="flex-1"
data={renderItems}
followOutput="auto"
overscan={5}
atBottomStateChange={atBottomStateChange}
itemContent={(index, item) => {
const key = item.kind === 'message' ? `msg-${item.message.id}` : item.key;
const isNew = !renderedKeysRef.current.has(key);
if (isNew) renderedKeysRef.current.add(key);
const reducedMotion = prefersReducedMotionRef.current;
const delay = isNew && !reducedMotion ? Math.min(index * 0.04, 0.5) : 0;
const shouldAnimate = isNew && animateEnabled;
return (
<div
className="max-w-[1000px] mx-auto w-full px-6 py-2"
id={item.kind === 'message' ? `msg-${item.message.id}` : undefined}
>
<motion.div
initial={shouldAnimate ? { opacity: 0, y: 8 } : false}
animate={{ opacity: 1, y: 0 }}
transition={delay > 0 ? { duration: 0.2, delay } : { duration: 0 }}
>
{item.kind === 'message' ? (
<MessageBubble
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
) : item.kind === 'tool_run' ? (
item.run.call.name === 'ask_user_input' ? (
<AskUserInputCard
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
) : item.run.call.name === 'request_read_access' ? (
<RequestReadAccessCard
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
) : (
<ToolCallLine run={item.run} />
)
) : (
<ToolCallGroup runs={item.runs} />
)}
</motion.div>
</div>
);
}}
/>
</div>
</MessageListErrorBoundary>
);
}

View File

@@ -0,0 +1,188 @@
import { useMemo } from 'react';
import { Clock, Cpu, Hash, Layers, RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Message } from '@/api/types';
interface TurnEntry {
message: Message;
turnNumber: number;
elapsed: string;
toolCallCount: number;
}
interface Props {
messages: Message[];
chatId: string;
onClose: () => void;
onScrollToMessage: (messageId: string) => void;
}
function formatElapsed(startedAt: string | null, finishedAt: string | null): string {
if (!startedAt || !finishedAt) return '—';
const start = new Date(startedAt).getTime();
const end = new Date(finishedAt).getTime();
if (Number.isNaN(start) || Number.isNaN(end)) return '—';
const ms = end - start;
if (ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
/**
* SessionTimeline — vertical timeline of assistant turns in a chat.
*
* Renders a side-panel overlay with each turn's model, tokens, duration,
* and tool-call count. Clicking a turn scrolls the main chat to that
* message. The latest turn shows a "Scroll to latest" restore button.
*/
export function SessionTimeline({ messages, onClose, onScrollToMessage }: Props) {
const turns = useMemo<TurnEntry[]>(() => {
const assistantMsgs = messages.filter(
(m) => m.role === 'assistant' && m.status === 'complete',
);
return assistantMsgs.map((message, i) => ({
message,
turnNumber: i + 1,
elapsed: formatElapsed(message.started_at, message.finished_at),
toolCallCount: message.tool_calls?.length ?? 0,
}));
}, [messages]);
const latestTurnId = turns.length > 0 ? turns[turns.length - 1]!.message.id : null;
return (
<div className="absolute inset-y-0 right-0 w-80 z-20 bg-background border-l border-border shadow-xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border shrink-0">
<h3 className="text-sm font-semibold">Session Timeline</h3>
<Button variant="ghost" size="icon-xs" onClick={onClose} aria-label="Close timeline">
<X size={14} />
</Button>
</div>
{/* Timeline entries */}
<div className="flex-1 overflow-y-auto px-3 py-3">
{turns.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-8">
No assistant turns yet.
</div>
) : (
<div className="relative">
{turns.map((turn, i) => {
const isLatest = turn.message.id === latestTurnId;
return (
<div key={turn.message.id} className="relative flex gap-3 pb-4 last:pb-0">
{/* Vertical connector line */}
{i < turns.length - 1 && (
<div className="absolute left-[11px] top-5 bottom-0 w-px bg-border" />
)}
{/* Timeline dot button */}
<button
type="button"
onClick={() => onScrollToMessage(turn.message.id)}
className="relative flex-shrink-0 mt-1 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-full"
aria-label={`Scroll to turn ${turn.turnNumber}`}
>
<div
className={cn(
'size-[22px] rounded-full border-2 flex items-center justify-center',
isLatest
? 'border-primary bg-primary/10'
: 'border-muted-foreground/30 bg-background',
)}
>
<div
className={cn(
'size-2 rounded-full',
isLatest ? 'bg-primary' : 'bg-muted-foreground/50',
)}
/>
</div>
</button>
{/* Content card */}
<div className="flex-1 min-w-0">
<div
className="rounded-lg border border-border bg-card p-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => onScrollToMessage(turn.message.id)}
>
{/* Turn number + latest badge */}
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-foreground">
Turn {turn.turnNumber}
</span>
{isLatest && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full leading-none">
Latest
</span>
)}
</div>
{/* Model name */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1.5">
<Cpu size={11} className="shrink-0" />
<span className="truncate">{turn.message.model ?? 'Unknown model'}</span>
</div>
{/* Token count with breakdown */}
{turn.message.tokens_used != null && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1 flex-wrap">
<Hash size={11} className="shrink-0" />
<span>{turn.message.tokens_used.toLocaleString()} total</span>
{turn.message.cache_tokens != null && turn.message.cache_tokens > 0 && (
<span className="text-blue-500 dark:text-blue-400">
({turn.message.cache_tokens.toLocaleString()} cache)
</span>
)}
{turn.message.reasoning_tokens != null && turn.message.reasoning_tokens > 0 && (
<span className="text-amber-500 dark:text-amber-400">
({turn.message.reasoning_tokens.toLocaleString()} reasoning)
</span>
)}
</div>
)}
{/* Duration + tool calls */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock size={11} />
{turn.elapsed}
</span>
{turn.toolCallCount > 0 && (
<span className="inline-flex items-center gap-1">
<Layers size={11} />
{turn.toolCallCount} tool call{turn.toolCallCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Restore button for latest turn */}
{isLatest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onScrollToMessage(turn.message.id);
}}
className="mt-1.5 w-full inline-flex items-center justify-center gap-1 text-[11px] font-medium text-primary hover:text-primary/80 transition-colors py-1 rounded-md hover:bg-primary/5"
>
<RefreshCw size={11} />
Scroll to latest
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { api } from '@/api/client';
import type { ToolTrace } from '@/api/types';
interface Props {
chatId: string;
}
// Max latency used as the 100% reference for the bar visualization
const MAX_LATENCY_REF = 30_000; // 30s
function latencyBarWidth(latencyMs: number | null): number {
if (latencyMs == null) return 0;
return Math.min(latencyMs / MAX_LATENCY_REF, 1);
}
function TraceRow({ trace }: { trace: ToolTrace }) {
const [expanded, setExpanded] = useState(false);
const isError = trace.outcome !== null && trace.outcome !== 'success';
const barWidth = latencyBarWidth(trace.latency_ms);
const latencyLabel =
trace.latency_ms != null
? trace.latency_ms >= 1000
? `${(trace.latency_ms / 1000).toFixed(1)}s`
: `${trace.latency_ms}ms`
: null;
return (
<div className="border-b border-border/40 last:border-0">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left px-2 py-1.5 hover:bg-muted/40 text-[11px]"
>
<span className="shrink-0 text-muted-foreground">
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
</span>
<span className="font-medium truncate min-w-0">
{trace.tool_name}
</span>
{isError && (
<span className="shrink-0 text-destructive" title={trace.error ?? 'error'}>
<AlertCircle size={10} />
</span>
)}
<span className="shrink-0 text-muted-foreground font-mono tabular-nums min-w-[3rem] text-right">
{latencyLabel ?? '—'}
</span>
<span className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden min-w-[24px] max-w-[60px]">
<span
className="block h-full rounded-full bg-primary/30 transition-all"
style={{ width: `${barWidth * 100}%` }}
/>
</span>
{trace.tokens_used != null && trace.tokens_used > 0 && (
<span className="shrink-0 text-muted-foreground font-mono tabular-nums">
{trace.tokens_used}t
</span>
)}
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
c{trace.cache_tokens}
</span>
)}
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
r{trace.reasoning_tokens}
</span>
)}
</button>
{expanded && (
<div className="px-3 pb-2 space-y-1.5 text-[11px] border-t border-border/40 pt-1.5">
<div>
<span className="text-muted-foreground font-medium">Input</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{JSON.stringify(trace.tool_input, null, 1)}
</pre>
</div>
{trace.tool_output != null && (
<div>
<span className="text-muted-foreground font-medium">Output</span>
<pre className="mt-0.5 font-mono text-[10px] leading-relaxed text-muted-foreground bg-muted/30 rounded p-1.5 overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">
{trace.tool_output.length > 2000
? `${trace.tool_output.slice(0, 2000)}`
: trace.tool_output}
</pre>
</div>
)}
{trace.error != null && (
<div className="text-destructive text-[10px] font-mono leading-relaxed bg-destructive/10 rounded p-1.5">
{trace.error}
</div>
)}
</div>
)}
</div>
);
}
function TraceGroup({ toolName, traces }: { toolName: string; traces: ToolTrace[] }) {
const [collapsed, setCollapsed] = useState(false);
const totalLatency = traces.reduce((sum, t) => sum + (t.latency_ms ?? 0), 0);
const totalTokens = traces.reduce((sum, t) => sum + (t.tokens_used ?? 0), 0);
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex items-center gap-1.5 w-full text-left px-2 py-1 text-[11px] font-medium text-muted-foreground hover:bg-muted/30 sticky top-0 bg-background"
>
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
<span>{toolName}</span>
<span className="text-muted-foreground/60 font-mono tabular-nums">
×{traces.length}
</span>
{totalTokens > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalTokens}t
</span>
)}
{totalLatency > 0 && (
<span className="text-muted-foreground/60 font-mono tabular-nums text-[10px]">
{totalLatency >= 1000
? `${(totalLatency / 1000).toFixed(1)}s`
: `${totalLatency}ms`}
</span>
)}
{errorCount > 0 && (
<span className="ml-auto text-destructive text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
</button>
{!collapsed && traces.map((trace) => (
<TraceRow key={trace.id} trace={trace} />
))}
</div>
);
}
export function TraceViewer({ chatId }: Props) {
const [open, setOpen] = useState(false);
const [traces, setTraces] = useState<ToolTrace[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTraces = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.chats.getTraces(chatId);
setTraces(res.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to load traces');
} finally {
setLoading(false);
}
}, [chatId]);
useEffect(() => {
if (open) {
void fetchTraces();
}
}, [open, fetchTraces]);
const groups = useMemo(() => {
const map = new Map<string, ToolTrace[]>();
for (const t of traces) {
const existing = map.get(t.tool_name);
if (existing) {
existing.push(t);
} else {
map.set(t.tool_name, [t]);
}
}
return map;
}, [traces]);
const totalCount = traces.length;
const errorCount = traces.filter(
(t) => t.outcome !== null && t.outcome !== 'success',
).length;
return (
<div className="border-t">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-muted/20"
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Tool traces</span>
{totalCount > 0 && (
<span className="font-mono tabular-nums text-muted-foreground/60">
{totalCount}
</span>
)}
{errorCount > 0 && (
<span className="text-destructive ml-auto text-[10px] font-medium">
{errorCount} error{errorCount > 1 ? 's' : ''}
</span>
)}
{loading && (
<span className="ml-auto inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</button>
{open && (
<div className="max-h-80 overflow-y-auto border-t border-border/40">
{loading && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
Loading traces
</div>
)}
{error && (
<div className="px-3 py-2 text-[11px] text-destructive">
{error}
<button
type="button"
onClick={() => void fetchTraces()}
className="ml-2 underline hover:no-underline"
>
retry
</button>
</div>
)}
{!loading && !error && traces.length === 0 && (
<div className="px-3 py-4 text-[11px] text-muted-foreground text-center">
No tool traces yet.
</div>
)}
{traces.length > 0 && (
<div className="divide-y divide-border/40">
{Array.from(groups.entries()).map(([toolName, groupTraces]) => (
<TraceGroup
key={toolName}
toolName={toolName}
traces={groupTraces}
/>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Pencil, Send, X } from 'lucide-react';
import { History, Pencil, Send, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { StaleStreamBanner } from '@/components/StaleStreamBanner';
import { SessionTimeline } from '@/components/SessionTimeline';
import { TraceViewer } from '@/components/TraceViewer';
import { sendToChat } from '@/lib/events';
interface Props {
@@ -25,6 +27,7 @@ interface Props {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [showTimeline, setShowTimeline] = useState(false);
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
const queueIdRef = useRef(0);
const processingRef = useRef(false);
@@ -203,11 +206,41 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
}
}
const handleScrollToMessage = useCallback((messageId: string) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, []);
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-col h-full min-h-0 relative">
{chatMessages.length > 0 && (
<div className="absolute top-2 right-2 z-10">
<button
type="button"
onClick={() => setShowTimeline((v) => !v)}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
transition-colors border
${showTimeline
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground'
}
`}
aria-label={showTimeline ? 'Close timeline' : 'Open timeline'}
>
<History size={12} />
Timeline
</button>
</div>
)}
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
<MessageList messages={chatMessages} sessionChats={sessionChats} />
<TraceViewer chatId={chatId} />
{/* Queued messages */}
{queue.length > 0 && (
<div className="border-t">
@@ -275,6 +308,16 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
messages={chatMessages}
modelContextLimit={modelContextLimit}
/>
{/* Timeline overlay panel */}
{showTimeline && (
<SessionTimeline
messages={chatMessages}
chatId={chatId}
onClose={() => setShowTimeline(false)}
onScrollToMessage={handleScrollToMessage}
/>
)}
</div>
);
}