Add top_p/top_k/min_p/presence_penalty to AGENTS.md frontmatter and thread through inference (agents.ts parser → Agent type → stream-phase → sentinel summaries). Null means omit from request body, preserving provider defaults. Wire ask_user_input interactive card into both BooCoder frontends: the CoderPane in BooChat's SPA (CoderMessageList now renders AskUserInputCard instead of ToolCallLine for ask_user_input tool calls) and the standalone coder SPA (MessageBubble + new AskUserInputCard + shadcn ui primitives). Additional fixes: SessionLandingPage uses ChatInput with slash-command support and lazy chat creation; Session.tsx hydrate-race fix for empty pane promotion; AgentPicker wider dropdown with line-clamp; ModelPicker min-width; Textarea converted to forwardRef; Recon agent added to AGENTS.md; codecontext host port exposed in docker-compose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
import Markdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import type { Message, ToolResult } from '@/api/types';
|
|
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
|
import { AskUserInputCard } from './AskUserInputCard';
|
|
|
|
interface Props {
|
|
message: Message;
|
|
chatId: string;
|
|
toolResultsMap: Record<string, ToolResult>;
|
|
}
|
|
|
|
export function MessageBubble({ message, chatId }: Props) {
|
|
if (message.role === 'tool') {
|
|
return <ToolResultBubble message={message} />;
|
|
}
|
|
|
|
const isUser = message.role === 'user';
|
|
const isStreaming = message.status === 'streaming';
|
|
const isFailed = message.status === 'failed';
|
|
|
|
return (
|
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
|
<div
|
|
className={`max-w-[85%] rounded-lg px-4 py-2.5 ${
|
|
isUser
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-zinc-800 text-zinc-100 border border-zinc-700'
|
|
}`}
|
|
>
|
|
{isFailed && (
|
|
<div className="flex items-center gap-1.5 text-red-400 text-xs mb-1">
|
|
<AlertCircle size={12} />
|
|
<span>Failed</span>
|
|
</div>
|
|
)}
|
|
|
|
{message.tool_calls && message.tool_calls.length > 0 && (
|
|
<div className="mb-2 space-y-1">
|
|
{message.tool_calls.map((tc) => {
|
|
if (tc.name === 'ask_user_input') {
|
|
const result = message.tool_results ?? null;
|
|
return (
|
|
<AskUserInputCard
|
|
key={tc.id}
|
|
toolCall={tc}
|
|
toolResult={result}
|
|
chatId={chatId}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
key={tc.id}
|
|
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
|
>
|
|
<Wrench size={11} />
|
|
<span className="font-mono">{tc.name}</span>
|
|
<span className="text-zinc-500 truncate max-w-[200px]">
|
|
{truncateArgs(tc.args)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{message.content.trim() && (
|
|
<div className="prose prose-invert prose-sm max-w-none [&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto [&_code]:text-zinc-300 [&_p]:my-1.5">
|
|
<Markdown remarkPlugins={[remarkGfm]}>{message.content}</Markdown>
|
|
</div>
|
|
)}
|
|
|
|
{isStreaming && !message.content.trim() && (
|
|
<div className="flex items-center gap-1.5 text-zinc-400">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
<span className="text-xs">Thinking...</span>
|
|
</div>
|
|
)}
|
|
|
|
{isStreaming && message.content.trim() && (
|
|
<span className="inline-block w-1.5 h-4 bg-zinc-400 animate-pulse ml-0.5 align-middle" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolResultBubble({ message }: { message: Message }) {
|
|
const result = message.tool_results;
|
|
if (!result) return null;
|
|
|
|
const isError = result.error;
|
|
const output = result.output != null ? String(result.output) : '';
|
|
const displayOutput =
|
|
output.length > 300 ? output.slice(0, 300) + '...' : output;
|
|
|
|
return (
|
|
<div className="flex justify-start mb-2 ml-6">
|
|
<div
|
|
className={`max-w-[80%] rounded px-3 py-2 text-xs font-mono border ${
|
|
isError
|
|
? 'bg-red-950/30 border-red-800/50 text-red-300'
|
|
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400'
|
|
}`}
|
|
>
|
|
{result.truncated && (
|
|
<span className="text-yellow-500 text-[10px] block mb-1">
|
|
[truncated]
|
|
</span>
|
|
)}
|
|
<pre className="whitespace-pre-wrap break-all">{displayOutput}</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function truncateArgs(args: unknown): string {
|
|
if (!args) return '';
|
|
try {
|
|
if (typeof args === 'object' && args !== null) {
|
|
const obj = args as Record<string, unknown>;
|
|
const keys = Object.keys(obj);
|
|
if (keys.length === 0) return '';
|
|
const first = keys[0]!;
|
|
const val = String(obj[first] ?? '');
|
|
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
|
return `${first}: ${display}`;
|
|
}
|
|
const str = String(args);
|
|
return str.length > 50 ? str.slice(0, 50) + '...' : str;
|
|
} catch {
|
|
return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
|
|
}
|
|
}
|