- 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
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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[];
|
|
sessionChats?: Chat[];
|
|
}
|
|
|
|
// v1.8.2: pre-render units. The single linear `messages` array gets walked
|
|
// into a render-time list where each tool_call is a first-class item and
|
|
// tool_result messages are folded onto their matching tool_run by id.
|
|
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
|
|
// answer without threading the chat id through MessageList's parent.
|
|
type RenderItem =
|
|
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
|
|
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
|
|
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
|
|
|
const GROUP_THRESHOLD = 3;
|
|
|
|
function isCapHitSentinel(m: Message): boolean {
|
|
return m.role === 'system' && m.metadata?.kind === 'cap_hit';
|
|
}
|
|
|
|
// First pass: walk messages chronologically, expanding assistant tool_calls
|
|
// into per-call run items and folding tool_result messages onto their
|
|
// matching runs. Tool messages themselves never produce a render item.
|
|
// Assistant messages produce a text render item only when they have text;
|
|
// pure tool-call messages are "transparent" so consecutive tool runs can
|
|
// still group across them.
|
|
function flatten(messages: Message[]): RenderItem[] {
|
|
const items: RenderItem[] = [];
|
|
const runsByCallId = new Map<string, ToolRun>();
|
|
|
|
for (const m of messages) {
|
|
if (m.role === 'tool') {
|
|
if (m.tool_results) {
|
|
const run = runsByCallId.get(m.tool_results.tool_call_id);
|
|
if (run) run.result = m.tool_results;
|
|
}
|
|
continue;
|
|
}
|
|
const hasToolCalls = m.tool_calls != null && m.tool_calls.length > 0;
|
|
// v1.13.7: trim before checking. AI SDK v6 streaming occasionally emits a
|
|
// leading "\n" text-delta on tool-call-only turns, which used to flow into
|
|
// messages.content with length=1 and render an empty bubble + ActionRow
|
|
// between each tool call. Whitespace-only content has no visible payload,
|
|
// so treat it as no-content.
|
|
const hasText = m.content.trim().length > 0;
|
|
if (m.role === 'assistant' && hasToolCalls) {
|
|
if (hasText || m.status === 'streaming') {
|
|
items.push({ kind: 'message', message: m });
|
|
}
|
|
for (const tc of m.tool_calls!) {
|
|
const run: ToolRun = { call: tc, result: null };
|
|
runsByCallId.set(tc.id, run);
|
|
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
|
|
}
|
|
continue;
|
|
}
|
|
items.push({ kind: 'message', message: m });
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
|
|
// of the same tool name into a single tool_group. Any other render item
|
|
// (text bubble, sentinel, user message) breaks the chain.
|
|
// Batch 9.7: ask_user_input never groups — each pause has its own card so
|
|
// grouping would render them as collapsed ToolCallLines which can't surface
|
|
// the interactive form.
|
|
function group(items: RenderItem[]): RenderItem[] {
|
|
const out: RenderItem[] = [];
|
|
let i = 0;
|
|
while (i < items.length) {
|
|
const item = items[i]!;
|
|
if (item.kind !== 'tool_run') {
|
|
out.push(item);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
const name = item.run.call.name;
|
|
if (name === 'ask_user_input' || name === 'request_read_access') {
|
|
// v1.13.17: same rationale as ask_user_input — grouping would collapse
|
|
// the interactive pause card into a non-actionable ToolCallLine.
|
|
out.push(item);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
let j = i + 1;
|
|
while (
|
|
j < items.length &&
|
|
items[j]!.kind === 'tool_run' &&
|
|
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
|
|
) {
|
|
j += 1;
|
|
}
|
|
const run = items.slice(i, j) as Array<{
|
|
kind: 'tool_run';
|
|
run: ToolRun;
|
|
key: string;
|
|
chatId: string;
|
|
}>;
|
|
if (run.length >= GROUP_THRESHOLD) {
|
|
out.push({
|
|
kind: 'tool_group',
|
|
runs: run.map((r) => r.run),
|
|
key: `group-${run[0]!.key}`,
|
|
});
|
|
} else {
|
|
for (const r of run) out.push(r);
|
|
}
|
|
i = j;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Third pass: number cap-hit sentinels (1-indexed) and mark the latest.
|
|
// CapHitSentinel uses position to compute the "N continues remaining"
|
|
// tooltip, and isLatest to gate the Continue button (only the most recent
|
|
// sentinel is actionable).
|
|
function stampCapHits(items: RenderItem[]): RenderItem[] {
|
|
const totalCapHits = items.reduce(
|
|
(n, it) => n + (it.kind === 'message' && isCapHitSentinel(it.message) ? 1 : 0),
|
|
0,
|
|
);
|
|
if (totalCapHits === 0) return items;
|
|
let index = 0;
|
|
return items.map((it) => {
|
|
if (it.kind !== 'message' || !isCapHitSentinel(it.message)) return it;
|
|
index += 1;
|
|
return {
|
|
...it,
|
|
capHitInfo: { position: index, isLatest: index === totalCapHits },
|
|
};
|
|
});
|
|
}
|
|
|
|
export function MessageList({ messages, sessionChats }: Props) {
|
|
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 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(() => {
|
|
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' });
|
|
}
|
|
}, [pinIndex]);
|
|
|
|
if (messages.length === 0) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
|
Send a message to start.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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} chatId={item.chatId} />
|
|
)
|
|
) : (
|
|
<ToolCallGroup runs={item.runs} />
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
</MessageListErrorBoundary>
|
|
);
|
|
}
|