v2.3.0-sampling-params-ask-user: agent sampling params, ask_user_input in CoderPane, UX polish

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>
This commit is contained in:
2026-05-26 21:02:21 +00:00
parent 31e1b32be1
commit 792bbb9da3
21 changed files with 721 additions and 79 deletions

View File

@@ -0,0 +1,323 @@
import { useMemo, useState } from 'react';
import { Check } from 'lucide-react';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import type {
AskUserAnswer,
AskUserAnswerSet,
AskUserQuestion,
ToolCall,
ToolResult,
} from '@/api/types';
// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of
// the standard ToolCallLine when the assistant emits an ask_user_input tool
// call. While the tool result is null (server pre-stamps a sentinel with
// output=null), shows the form; once the WS tool_result frame arrives with a
// real AnswerSet, flips to read-only review mode.
interface Props {
toolCall: ToolCall;
toolResult: ToolResult | null;
chatId: string;
}
function parseQuestions(raw: unknown): AskUserQuestion[] {
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
const arr = (raw as { questions: unknown }).questions;
if (!Array.isArray(arr)) return [];
const out: AskUserQuestion[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; type?: unknown; options?: unknown };
if (typeof q.question !== 'string') continue;
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
if (!Array.isArray(q.options)) continue;
const opts = q.options.filter((o): o is string => typeof o === 'string');
if (opts.length < 2) continue;
out.push({ question: q.question, type: q.type, options: opts });
}
return out;
}
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
const arr = (raw as { answers: unknown }).answers;
if (!Array.isArray(arr)) return null;
const answers: AskUserAnswer[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
if (typeof a.question !== 'string') continue;
if (!Array.isArray(a.selected_options)) continue;
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
answers.push({
question: a.question,
selected_options: sel,
free_text: (a.free_text as string | null) ?? null,
});
}
return { answers };
}
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) {
return (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null;
if (answered) {
const answerSet = parseAnswerSet(toolResult!.output);
return <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
function PendingView({
questions,
toolCallId,
chatId,
}: {
questions: AskUserQuestion[];
toolCallId: string;
chatId: string;
}) {
// Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1.
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const singleQuestion = questions.length === 1;
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
// Submit button shows when:
// - more than one question (always batched), OR
// - one question and the user has typed free text (committing it needs an
// explicit Submit so an accidental Tab/click doesn't lose it).
// For one question with no free text, clicking an option submits inline.
const showSubmitButton = !singleQuestion || anyFreeText;
// Every question must have at least one of (option, free text).
const allComplete = questions.every((_, i) => {
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
});
function buildAnswers(): AskUserAnswer[] {
return questions.map((q, i) => {
const freeText = freeTexts[i]!.trim();
return {
question: q.question,
selected_options: selections[i]!,
free_text: freeText.length > 0 ? freeText : null,
};
});
}
async function submit(answers: AskUserAnswer[]) {
if (submitting) return;
setSubmitting(true);
try {
await api.chats.answerUserInput(chatId, toolCallId, answers);
// Card stays mounted; the incoming WS tool_result frame will flip it
// into AnsweredView via the parent prop change.
} catch (err) {
console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err);
setSubmitting(false);
}
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
// Immediate submit for the single-question single-select shortcut. Only
// fires when no free text exists anywhere — once the user typed, the
// Submit button takes over so the typed text isn't silently dropped.
if (singleQuestion && !anyFreeText) {
const answers: AskUserAnswer[] = [
{
question: questions[0]!.question,
selected_options: [option],
free_text: null,
},
];
void submit(answers);
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
function setFreeText(qIdx: number, value: string) {
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
}
return (
<div className="rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={submitting}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={submitting}
placeholder="Free text…"
onChange={(e) => setFreeText(i, e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Square } from 'lucide-react';
import type { Message } from '@/api/types';
import type { Message, ToolResult } from '@/api/types';
import { api } from '@/api/client';
import { MessageBubble } from './MessageBubble';
@@ -66,6 +66,14 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
// Filter out system messages for display (sentinels)
const visibleMessages = messages.filter((m) => m.role !== 'system');
// Build a lookup map from tool_call_id -> ToolResult for all messages
const toolResultsMap: Record<string, ToolResult> = {};
for (const msg of messages) {
if (msg.tool_results) {
toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results;
}
}
return (
<div className="flex flex-col h-full">
{/* Connection indicator */}
@@ -88,7 +96,7 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
</div>
)}
{visibleMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
<MessageBubble key={msg.id} message={msg} chatId={msg.chat_id} toolResultsMap={toolResultsMap} />
))}
<div ref={messagesEndRef} />
</div>

View File

@@ -1,13 +1,16 @@
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Message } from '@/api/types';
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 }: Props) {
export function MessageBubble({ message, chatId }: Props) {
if (message.role === 'tool') {
return <ToolResultBubble message={message} />;
}
@@ -34,18 +37,31 @@ export function MessageBubble({ message }: Props) {
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="mb-2 space-y-1">
{message.tool_calls.map((tc) => (
<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.arguments)}
</span>
</div>
))}
{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>
)}
@@ -70,12 +86,12 @@ export function MessageBubble({ message }: Props) {
);
}
function ToolResultBubble({ message }: Props) {
function ToolResultBubble({ message }: { message: Message }) {
const result = message.tool_results;
if (!result) return null;
const isError = result.error;
const output = result.output || '';
const output = result.output != null ? String(result.output) : '';
const displayOutput =
output.length > 300 ? output.slice(0, 300) + '...' : output;
@@ -99,17 +115,21 @@ function ToolResultBubble({ message }: Props) {
);
}
function truncateArgs(args: string): string {
function truncateArgs(args: unknown): string {
if (!args) return '';
try {
const parsed = JSON.parse(args);
const keys = Object.keys(parsed);
if (keys.length === 0) return '';
const first = keys[0]!;
const val = String(parsed[first]);
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
return `${first}: ${display}`;
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 args.length > 50 ? args.slice(0, 50) + '...' : args;
return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
}
}

View File

@@ -0,0 +1,35 @@
import * as React from 'react';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const variantClasses: Record<string, string> = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
};
const sizeClasses: Record<string, string> = {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
const base =
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60';
const cls = [base, variantClasses[variant] ?? '', sizeClasses[size] ?? '', className ?? ''].join(' ');
return <button className={cls} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button };

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
const RadioGroupContext = React.createContext<{
value: string | undefined;
onValueChange: (v: string) => void;
disabled?: boolean;
} | null>(null);
interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
value?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
}
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
({ className, value, onValueChange, disabled, ...props }, ref) => {
const ctx = React.useMemo(() => ({ value, onValueChange: onValueChange ?? (() => {}), disabled }), [value, onValueChange, disabled]);
return (
<RadioGroupContext.Provider value={ctx}>
<div
ref={ref}
role="radiogroup"
className={className}
{...props}
/>
</RadioGroupContext.Provider>
);
},
);
RadioGroup.displayName = 'RadioGroup';
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
value: string;
}
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
({ className, value, ...props }, ref) => {
const ctx = React.useContext(RadioGroupContext);
if (!ctx) return <input ref={ref} type="radio" className={className} value={value} {...props} />;
const checked = ctx.value === value;
return (
<input
ref={ref}
type="radio"
checked={checked}
disabled={ctx.disabled}
onChange={() => ctx.onValueChange(value)}
className={className}
{...props}
/>
);
},
);
RadioGroupItem.displayName = 'RadioGroupItem';
export { RadioGroup, RadioGroupItem };