Files
boocode/apps/web/src/components/MessageList.tsx
indifferentketchup d8ffee1950 v2.1.0-provider-picker: BooCoder systemd migration + provider picker
- BooCoder moves from Docker to host systemd service (boocoder.service)
- Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec
- SSH helpers marked @deprecated (kept for one release cycle)
- Provider registry (5 providers: boocode, opencode, goose, claude, qwen)
- Agent probe with direct which/exec + model discovery (qwen settings, static claude models)
- GET /api/providers route with installed status, models, transport fallback
- ProviderPicker frontend component in CoderPane header
- External provider messages route through tasks row instead of inference enqueue
- Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold)
- DB: available_agents gets models, label, transport columns
- Bug fix: loadContext SELECT includes allowed_read_paths
- Bug fix: cap hit sentinel inserted before buildMessagesPayload
- docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added
- CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
2026-05-25 19:20:53 +00:00

219 lines
7.4 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from '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';
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 },
};
});
}
const SCROLL_THRESHOLD_PX = 150;
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
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;
}, []);
useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
}
}, [messages]);
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 (
<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>
</div>
);
}