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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user