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:
@@ -34,6 +34,10 @@ import type {
|
||||
SessionAnalyticsRow,
|
||||
ContextWindowStats,
|
||||
TokenBreakdownAgg,
|
||||
ToolTraceResponse,
|
||||
MemoryEntry,
|
||||
DailyMemoryEntry,
|
||||
DreamEntry,
|
||||
} from './types';
|
||||
|
||||
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
|
||||
@@ -340,6 +344,10 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, decision }),
|
||||
}),
|
||||
getTraces: (chatId: string, limit = 50, offset = 0) =>
|
||||
request<ToolTraceResponse>(
|
||||
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
},
|
||||
|
||||
messages: {
|
||||
@@ -608,6 +616,22 @@ export const api = {
|
||||
tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
|
||||
},
|
||||
|
||||
// memory-browser-ui: topic-based memory, daily log, dream diaries.
|
||||
memory: {
|
||||
list: (projectId: string) =>
|
||||
request<{ entries: MemoryEntry[] }>(
|
||||
`/api/memory?project_id=${encodeURIComponent(projectId)}`,
|
||||
),
|
||||
daily: (projectId: string) =>
|
||||
request<{ entries: DailyMemoryEntry[] }>(
|
||||
`/api/memory/daily?project_id=${encodeURIComponent(projectId)}`,
|
||||
),
|
||||
dreams: (projectId: string) =>
|
||||
request<{ entries: DreamEntry[] }>(
|
||||
`/api/memory/dreams?project_id=${encodeURIComponent(projectId)}`,
|
||||
),
|
||||
},
|
||||
|
||||
settings: {
|
||||
get: () => request<Record<string, unknown>>('/api/settings'),
|
||||
patch: (body: Record<string, unknown>) =>
|
||||
|
||||
@@ -559,8 +559,16 @@ export type WsFrame =
|
||||
ctx_used: number | null;
|
||||
ctx_max: number | null;
|
||||
}
|
||||
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
|
||||
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
|
||||
| { type: 'chat_renamed'; chat_id: string; name: string }
|
||||
| {
|
||||
type: 'agent_snapshot';
|
||||
chat_id: string;
|
||||
agent?: string | null;
|
||||
model: string;
|
||||
mode?: string | null;
|
||||
turn_number: number;
|
||||
}
|
||||
// v1.11: published by services/compaction.ts after the new anchored
|
||||
// summary row lands. Carries the new summary row id for diagnostics; the
|
||||
// session-stream handler ignores the id and re-fetches the full message
|
||||
@@ -604,6 +612,31 @@ export type WsFrame =
|
||||
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
report?: string;
|
||||
}
|
||||
// tool trace frames: per-tool-call lifecycle tracking
|
||||
| {
|
||||
type: 'tool_trace_start';
|
||||
trace_id: string;
|
||||
message_id: string;
|
||||
chat_id: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
started_at: string;
|
||||
}
|
||||
| {
|
||||
type: 'tool_trace_finish';
|
||||
trace_id: string;
|
||||
message_id: string;
|
||||
chat_id: string;
|
||||
tool_name: string;
|
||||
tool_output?: string | null;
|
||||
latency_ms?: number;
|
||||
tokens_used?: number | null;
|
||||
cache_tokens?: number | null;
|
||||
reasoning_tokens?: number | null;
|
||||
error?: string;
|
||||
outcome?: string;
|
||||
finished_at: string;
|
||||
}
|
||||
// arena frames: battle lifecycle + per-contestant streaming
|
||||
| {
|
||||
type: 'battle_started';
|
||||
@@ -630,8 +663,64 @@ export type WsFrame =
|
||||
winner_contestant_id?: string | null;
|
||||
analysis_ready?: boolean;
|
||||
cross_exam_id?: string;
|
||||
}
|
||||
// streaming v2: channel-delta frames. Each carries a monotonic seq for
|
||||
// out-of-order buffering and a channel discriminator; per-channel payloads
|
||||
// map to the equivalent legacy frame types after reordering.
|
||||
| {
|
||||
type: 'channel_delta';
|
||||
seq: number;
|
||||
channel: 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
|
||||
message_id?: string;
|
||||
chat_id?: string;
|
||||
content?: string;
|
||||
tool_call?: ToolCall;
|
||||
tool_message_id?: string;
|
||||
tool_call_id?: string;
|
||||
output?: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
reason?: string;
|
||||
status?: 'running' | 'complete' | 'cancelled' | 'failed';
|
||||
tokens_used?: number | null;
|
||||
ctx_used?: number | null;
|
||||
ctx_max?: number | null;
|
||||
cache_tokens?: number | null;
|
||||
reasoning_tokens?: number | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
model?: string | null;
|
||||
metadata?: MessageMetadata | null;
|
||||
};
|
||||
|
||||
// tool traces: per-tool-call record returned by GET /api/chats/:id/traces.
|
||||
export interface ToolTrace {
|
||||
id: string;
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
message_id: string | null;
|
||||
turn_number: number;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
tool_output: string | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
latency_ms: number | null;
|
||||
tokens_used: number | null;
|
||||
cache_tokens: number | null;
|
||||
reasoning_tokens: number | null;
|
||||
error: string | null;
|
||||
outcome: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ToolTraceResponse {
|
||||
data: ToolTrace[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// token-analyzer-ui: aggregate token/cost analytics types.
|
||||
export interface AnalyticsSummary {
|
||||
total_input_tokens: number;
|
||||
@@ -660,3 +749,21 @@ export interface TokenBreakdownAgg {
|
||||
category: string;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
// ── Memory browser types ────────────────────────────────────────────
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface DailyMemoryEntry extends MemoryEntry {
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface DreamEntry {
|
||||
date: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
188
apps/web/src/components/SessionTimeline.tsx
Normal file
188
apps/web/src/components/SessionTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
apps/web/src/components/TraceViewer.tsx
Normal file
251
apps/web/src/components/TraceViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,133 @@ interface State {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type Channel = 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
|
||||
|
||||
// Per-channel out-of-order frame buffer with contiguous-seq flush logic.
|
||||
// Stores incoming channel_delta frames and releases them only when seq
|
||||
// becomes contiguous with the expected next value.
|
||||
class ChannelBuffer {
|
||||
private expectedSeq = 0;
|
||||
private buffer = new Map<number, ChannelDeltaWsFrame>();
|
||||
|
||||
push(frame: ChannelDeltaWsFrame): ChannelDeltaWsFrame[] {
|
||||
if (frame.seq < this.expectedSeq) {
|
||||
return [];
|
||||
}
|
||||
if (frame.seq === this.expectedSeq) {
|
||||
this.expectedSeq++;
|
||||
const flushed = [frame];
|
||||
while (this.buffer.has(this.expectedSeq)) {
|
||||
const next = this.buffer.get(this.expectedSeq)!;
|
||||
this.buffer.delete(this.expectedSeq);
|
||||
this.expectedSeq++;
|
||||
flushed.push(next);
|
||||
}
|
||||
return flushed;
|
||||
}
|
||||
this.buffer.set(frame.seq, frame);
|
||||
return [];
|
||||
}
|
||||
|
||||
get expectedNextSeq(): number {
|
||||
return this.expectedSeq;
|
||||
}
|
||||
|
||||
get bufferedCount(): number {
|
||||
return this.buffer.size;
|
||||
}
|
||||
|
||||
reset(seq = 0) {
|
||||
this.expectedSeq = seq;
|
||||
this.buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelDeltaWsFrame = WsFrame & { type: 'channel_delta' };
|
||||
|
||||
// Converts a flushed channel_delta into the equivalent legacy frame so the
|
||||
// existing applyFrame reducer handles the per-message mutation. Status
|
||||
// deltas are handled separately (they may need to create the message first
|
||||
// and apply throughput metadata independently of terminal status).
|
||||
function channelDeltaToLegacyFrame(delta: ChannelDeltaWsFrame): WsFrame | null {
|
||||
switch (delta.channel) {
|
||||
case 'text':
|
||||
return { type: 'delta', message_id: delta.message_id!, content: delta.content! };
|
||||
case 'tool_call':
|
||||
return { type: 'tool_call', message_id: delta.message_id!, tool_call: delta.tool_call! };
|
||||
case 'tool_result':
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_message_id: delta.tool_message_id!,
|
||||
chat_id: delta.chat_id,
|
||||
tool_call_id: delta.tool_call_id!,
|
||||
output: delta.output,
|
||||
truncated: delta.truncated!,
|
||||
...(delta.error ? { error: delta.error } : {}),
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
message_id: delta.message_id,
|
||||
chat_id: delta.chat_id,
|
||||
error: delta.error!,
|
||||
...(delta.reason ? { reason: delta.reason as never } : {}),
|
||||
};
|
||||
case 'status':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a flushed status channel_delta to state. Status deltas carry both
|
||||
// intermediate throughput metadata (tokens_used, ctx_used, model, etc.)
|
||||
// and optional terminal transitions (complete / cancelled / failed).
|
||||
function applyStatusDelta(state: State, delta: ChannelDeltaWsFrame): State {
|
||||
const { message_id, chat_id, status, channel: _c, seq: _s, type: _t, ...meta } = delta;
|
||||
if (!message_id) return state;
|
||||
let next = state;
|
||||
|
||||
const exists = next.messages.some((m) => m.id === message_id);
|
||||
if (!exists && status === 'running') {
|
||||
next = applyFrame(next, {
|
||||
type: 'message_started',
|
||||
message_id,
|
||||
chat_id,
|
||||
role: 'assistant',
|
||||
});
|
||||
}
|
||||
|
||||
const metaFields: Record<string, unknown> = {};
|
||||
if (meta.tokens_used !== undefined) metaFields.tokens_used = meta.tokens_used;
|
||||
if (meta.ctx_used !== undefined) metaFields.ctx_used = meta.ctx_used;
|
||||
if (meta.ctx_max !== undefined) metaFields.ctx_max = meta.ctx_max;
|
||||
if (meta.cache_tokens !== undefined) metaFields.cache_tokens = meta.cache_tokens;
|
||||
if (meta.reasoning_tokens !== undefined) metaFields.reasoning_tokens = meta.reasoning_tokens;
|
||||
if (meta.started_at !== undefined) metaFields.started_at = meta.started_at;
|
||||
if (meta.finished_at !== undefined) metaFields.finished_at = meta.finished_at;
|
||||
if (meta.model !== undefined) metaFields.model = meta.model;
|
||||
if (meta.metadata !== undefined) metaFields.metadata = meta.metadata;
|
||||
|
||||
if (Object.keys(metaFields).length > 0) {
|
||||
next = {
|
||||
...next,
|
||||
messages: next.messages.map((m) =>
|
||||
m.id === message_id ? { ...m, ...metaFields } : m,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'complete' || status === 'cancelled' || status === 'failed') {
|
||||
next = applyFrame(next, {
|
||||
type: 'message_complete',
|
||||
message_id,
|
||||
chat_id,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyFrame(state: State, frame: WsFrame): State {
|
||||
switch (frame.type) {
|
||||
case 'snapshot': {
|
||||
@@ -33,8 +160,6 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
kind: 'message',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
// v1.8.2: cap-hit sentinels arrive role='system' and are static, so
|
||||
// skipping the streaming dot for them keeps the UI accurate.
|
||||
status: frame.role === 'system' ? 'complete' : 'streaming',
|
||||
last_seq: 0,
|
||||
tokens_used: null,
|
||||
@@ -65,7 +190,7 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
const next = state.messages.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
|
||||
: m
|
||||
: m,
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
@@ -85,7 +210,7 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
},
|
||||
status: 'complete' as const,
|
||||
}
|
||||
: m
|
||||
: m,
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
@@ -132,19 +257,13 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
|
||||
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
|
||||
...(frame.model !== undefined ? { model: frame.model } : {}),
|
||||
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
|
||||
// in on this terminal frame so the reducer can attach it
|
||||
// without waiting for a refetch.
|
||||
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
|
||||
}
|
||||
: m
|
||||
: m,
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
case 'usage': {
|
||||
// v1.12.2: live throughput. Side-effects into the module-level
|
||||
// singleton consumed by ChatThroughput; no message-state mutation.
|
||||
// chat_id is the optional ws-frame field; usage frames always include it.
|
||||
if (frame.chat_id) {
|
||||
recordUsage(frame.chat_id, {
|
||||
completion_tokens: frame.completion_tokens,
|
||||
@@ -172,10 +291,6 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
return state;
|
||||
}
|
||||
case 'error': {
|
||||
// v1.8.2: when the frame carries a structured reason, stamp it onto the
|
||||
// failed message's metadata so the bubble can render specifics inline
|
||||
// (the WS error frame is one-shot; refresh-safe rendering needs the
|
||||
// value persisted on the message).
|
||||
const errorMeta = frame.reason
|
||||
? { kind: 'error' as const, error_reason: frame.reason, error_text: frame.error }
|
||||
: null;
|
||||
@@ -187,47 +302,53 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
status: 'failed' as const,
|
||||
...(errorMeta ? { metadata: errorMeta } : {}),
|
||||
}
|
||||
: m
|
||||
: m,
|
||||
)
|
||||
: state.messages;
|
||||
return { ...state, messages: next, error: frame.error };
|
||||
}
|
||||
case 'compacted': {
|
||||
// v1.11: side effects (refetch + toast) live in ws.onmessage; the
|
||||
// reducer just no-ops so TS exhaustiveness is satisfied without
|
||||
// duplicating async work inside a synchronous reducer.
|
||||
return state;
|
||||
}
|
||||
case 'agent_snapshot': {
|
||||
return state;
|
||||
}
|
||||
case 'agent_status_updated': {
|
||||
// agent-status-normalize (#10): coder-only frame consumed by CoderPane's
|
||||
// own WS handler, not BooChat's native message reducer. No-op here to keep
|
||||
// TS exhaustiveness satisfied (native sessions never emit it).
|
||||
return state;
|
||||
}
|
||||
case 'flow_run_started':
|
||||
case 'flow_run_step_updated': {
|
||||
// Orchestrator frames consumed by OrchestratorPane's own subscription.
|
||||
// No-op here to keep TS exhaustiveness satisfied.
|
||||
return state;
|
||||
}
|
||||
case 'battle_started':
|
||||
case 'contestant_updated':
|
||||
case 'battle_updated': {
|
||||
// Arena frames consumed by ArenaPane's own subscription.
|
||||
// No-op here to keep TS exhaustiveness satisfied.
|
||||
return state;
|
||||
}
|
||||
case 'channel_delta': {
|
||||
return state;
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Matches useUserEvents — exponential backoff with the same ceiling so the
|
||||
// two channels reconnect on the same cadence after a network handoff.
|
||||
const RECONNECT_INITIAL_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const CHANNEL_STALL_MS = 5000;
|
||||
|
||||
export function useSessionStream(sessionId: string | undefined) {
|
||||
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const channelBuffersRef = useRef<Map<Channel, ChannelBuffer>>(new Map());
|
||||
const lastFrameTimeRef = useRef<Partial<Record<Channel, number>>>({});
|
||||
|
||||
// Reset channel buffers when session changes
|
||||
useEffect(() => {
|
||||
channelBuffersRef.current = new Map();
|
||||
lastFrameTimeRef.current = {};
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
@@ -238,6 +359,73 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
|
||||
const getLastSeqPerChannel = () => {
|
||||
const seqs: Partial<Record<Channel, number>> = {};
|
||||
for (const [ch, buf] of channelBuffersRef.current) {
|
||||
seqs[ch] = buf.expectedNextSeq;
|
||||
}
|
||||
return seqs;
|
||||
};
|
||||
|
||||
const flushDeltaToState = (delta: ChannelDeltaWsFrame) => {
|
||||
console.error('FDS', delta.channel, 'flushed');
|
||||
if (delta.channel === 'status') {
|
||||
setState((s) => applyStatusDelta(s, delta));
|
||||
} else {
|
||||
const legacy = channelDeltaToLegacyFrame(delta);
|
||||
if (legacy) {
|
||||
setState((s) => applyFrame(s, legacy));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChannelDelta = (frame: ChannelDeltaWsFrame) => {
|
||||
console.error('HCD', frame.channel, frame.seq, 'bufs', channelBuffersRef.current.size);
|
||||
const buffers = channelBuffersRef.current;
|
||||
let buffer = buffers.get(frame.channel);
|
||||
if (!buffer) {
|
||||
buffer = new ChannelBuffer();
|
||||
buffers.set(frame.channel, buffer);
|
||||
}
|
||||
|
||||
const flushed = buffer.push(frame);
|
||||
if (flushed.length === 0) return;
|
||||
|
||||
for (const delta of flushed) {
|
||||
flushDeltaToState(delta);
|
||||
}
|
||||
|
||||
let emittedRefresh = false;
|
||||
for (const delta of flushed) {
|
||||
if (delta.channel === 'status' && (delta.status === 'complete' || delta.status === 'cancelled' || delta.status === 'failed')) {
|
||||
emittedRefresh = true;
|
||||
}
|
||||
}
|
||||
if (emittedRefresh) {
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
|
||||
lastFrameTimeRef.current[frame.channel] = Date.now();
|
||||
};
|
||||
|
||||
// Periodic channel stall check: if any channel has buffered frames
|
||||
// but no progress for 5s, force a snapshot refetch.
|
||||
let stallTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const startStallTimer = () => {
|
||||
stallTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [channel, buffer] of channelBuffersRef.current) {
|
||||
if (buffer.bufferedCount === 0) continue;
|
||||
const lastTime = lastFrameTimeRef.current[channel as Channel] ?? 0;
|
||||
if (now - lastTime >= CHANNEL_STALL_MS) {
|
||||
buffer.reset();
|
||||
sessionEvents.emit({ type: 'refetch_messages' });
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (unmounted) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
@@ -248,13 +436,16 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
setState((s) => ({ ...s, connected: true, error: null }));
|
||||
|
||||
// Mid-stream reconnection protocol: send last known seq per channel
|
||||
// so the server can replay deltas or fall back to a full snapshot.
|
||||
const lastSeq = getLastSeqPerChannel();
|
||||
ws.send(JSON.stringify({ type: 'reconnect', lastSeqPerChannel: lastSeq }));
|
||||
|
||||
startStallTimer();
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
// v1.13.11-a: Zod-validate every inbound frame. Fail-closed — invalid
|
||||
// frames are logged and dropped. WsFrameSchema is the runtime guard;
|
||||
// the hand-maintained WsFrame type stays as the narrowed dev-time
|
||||
// shape (Zod uses OpaqueObject for nested types like Message[]). One
|
||||
// cast bridges the two.
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
||||
@@ -272,13 +463,14 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
}
|
||||
try {
|
||||
const frame = validated.data as unknown as WsFrame;
|
||||
// v1.11: on a compaction completion, re-fetch the message list so
|
||||
// the new summary row + the cohort of compacted_at-stamped older
|
||||
// rows render correctly. We dispatch the fresh list as a synthetic
|
||||
// 'snapshot' frame so the reducer's existing path handles state
|
||||
// replacement (no need for a parallel "refetched" path).
|
||||
// The toast is purely UX feedback; missing it would still leave
|
||||
// the chat in a valid state.
|
||||
|
||||
if (frame.type === 'channel_delta') {
|
||||
console.error('RAW_PARSE', JSON.stringify(validated.data).slice(0, 200));
|
||||
console.error('CD', frame.channel, frame.seq, JSON.stringify(frame).slice(0, 80));
|
||||
handleChannelDelta(frame);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'compacted') {
|
||||
toast.success('Context compacted to free space');
|
||||
void api.messages
|
||||
@@ -291,8 +483,9 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((s) => applyFrame(s, frame));
|
||||
// Trigger git diff refresh after each completed assistant turn.
|
||||
|
||||
if (frame.type === 'message_complete') {
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
@@ -300,15 +493,18 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
console.warn('bad ws frame', err);
|
||||
}
|
||||
};
|
||||
// v1.8.1: WS errors no longer surface as user-facing toasts here. The
|
||||
// user-channel hook (useUserEvents) owns the debounced "reconnecting…"
|
||||
// UI; this channel just reconnects silently on the same backoff.
|
||||
|
||||
ws.onerror = () => {
|
||||
try { ws.close(); } catch {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted) return;
|
||||
setState((s) => ({ ...s, connected: false }));
|
||||
if (stallTimer) {
|
||||
clearInterval(stallTimer);
|
||||
stallTimer = null;
|
||||
}
|
||||
const delay = reconnectDelay;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
@@ -320,6 +516,7 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
return () => {
|
||||
unmounted = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (stallTimer) clearInterval(stallTimer);
|
||||
const ws = wsRef.current;
|
||||
wsRef.current = null;
|
||||
if (ws) try { ws.close(); } catch {}
|
||||
|
||||
Reference in New Issue
Block a user