From 792bbb9da3b154de8c7868211f582d9f46e9f2d4 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 26 May 2026 21:02:21 +0000 Subject: [PATCH] v2.3.0-sampling-params-ask-user: agent sampling params, ask_user_input in CoderPane, UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/coder/web/src/api/client.ts | 10 +- apps/coder/web/src/api/types.ts | 25 +- .../web/src/components/AskUserInputCard.tsx | 323 ++++++++++++++++++ apps/coder/web/src/components/ChatPane.tsx | 12 +- .../web/src/components/MessageBubble.tsx | 70 ++-- apps/coder/web/src/components/ui/button.tsx | 35 ++ .../web/src/components/ui/radio-group.tsx | 56 +++ apps/server/src/services/agents.ts | 48 +++ .../services/inference/sentinel-summaries.ts | 6 +- .../src/services/inference/stream-phase.ts | 13 +- apps/server/src/types/api.ts | 4 + apps/web/src/components/AgentPicker.tsx | 4 +- apps/web/src/components/ModelPicker.tsx | 2 +- .../web/src/components/SessionLandingPage.tsx | 83 +++-- apps/web/src/components/Workspace.tsx | 2 + .../src/components/panes/CoderMessageList.tsx | 19 +- apps/web/src/components/panes/CoderPane.tsx | 1 + apps/web/src/components/ui/textarea.tsx | 8 +- apps/web/src/pages/Session.tsx | 15 +- data/AGENTS.md | 62 +++- docker-compose.yml | 2 + 21 files changed, 721 insertions(+), 79 deletions(-) create mode 100644 apps/coder/web/src/components/AskUserInputCard.tsx create mode 100644 apps/coder/web/src/components/ui/button.tsx create mode 100644 apps/coder/web/src/components/ui/radio-group.tsx diff --git a/apps/coder/web/src/api/client.ts b/apps/coder/web/src/api/client.ts index 77c407e..43c7b2e 100644 --- a/apps/coder/web/src/api/client.ts +++ b/apps/coder/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Project, Session, Chat, Message, PendingChange } from './types'; +import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } from './types'; export class ApiError extends Error { constructor( @@ -52,6 +52,14 @@ export const api = { method: 'POST', body: JSON.stringify(body ?? {}), }), + answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) => + request<{ tool_message_id: string; assistant_message_id: string }>( + `/api/chats/${chatId}/answer_user_input`, + { + method: 'POST', + body: JSON.stringify({ tool_call_id: toolCallId, answers }), + }, + ), }, messages: { diff --git a/apps/coder/web/src/api/types.ts b/apps/coder/web/src/api/types.ts index fc9d114..34d5b74 100644 --- a/apps/coder/web/src/api/types.ts +++ b/apps/coder/web/src/api/types.ts @@ -32,16 +32,37 @@ export interface Chat { export interface ToolCall { id: string; name: string; - arguments: string; + args: unknown; } export interface ToolResult { tool_call_id: string; - output: string; + output: unknown; truncated?: boolean; error?: boolean; } +// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] } +// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the +// same order. AskUserInputCard renders questions and POSTs answers. +export type AskUserQuestionType = 'single_select' | 'multi_select'; + +export interface AskUserQuestion { + question: string; + type: AskUserQuestionType; + options: string[]; +} + +export interface AskUserAnswer { + question: string; + selected_options: string[]; + free_text: string | null; +} + +export interface AskUserAnswerSet { + answers: AskUserAnswer[]; +} + export interface Message { id: string; session_id: string; diff --git a/apps/coder/web/src/components/AskUserInputCard.tsx b/apps/coder/web/src/components/AskUserInputCard.tsx new file mode 100644 index 0000000..0fac253 --- /dev/null +++ b/apps/coder/web/src/components/AskUserInputCard.tsx @@ -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 ( +
+ ask_user_input: malformed tool args +
+ ); + } + + // 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 ; + } + + return ( + + ); +} + +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(() => questions.map(() => [])); + const [freeTexts, setFreeTexts] = useState(() => 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 ( +
+
+ {questions.map((q, i) => ( +
+ {questions.length > 1 && ( +
+ Question {i + 1} +
+ )} +
{q.question}
+ {q.type === 'single_select' ? ( + pickSingle(i, v)} + disabled={submitting} + className="gap-1.5" + > + {q.options.map((opt, j) => { + const id = `q${i}-opt${j}`; + return ( + + ); + })} + + ) : ( +
+ {q.options.map((opt, j) => { + const id = `q${i}-opt${j}`; + const checked = selections[i]!.includes(opt); + return ( + + ); + })} +
+ )} +
+
+ Or type a custom answer +
+ 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" + /> +
+
+ ))} +
+ {showSubmitButton && ( +
+ +
+ )} +
+ ); +} + +function AnsweredView({ + questions, + answers, +}: { + questions: AskUserQuestion[]; + answers: AskUserAnswerSet | null; +}) { + if (!answers) { + return ( +
+ ask_user_input: answers unavailable +
+ ); + } + + return ( +
+
+ {questions.map((q, i) => { + const a = answers.answers[i]; + if (!a) return null; + return ( +
+ {questions.length > 1 && ( +
+ Question {i + 1} +
+ )} +
{q.question}
+
+ {q.options.map((opt, j) => { + const selected = a.selected_options.includes(opt); + return ( +
+ + {selected && } + + {opt} +
+ ); + })} +
+ {a.free_text && ( +
+ {a.free_text} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/coder/web/src/components/ChatPane.tsx b/apps/coder/web/src/components/ChatPane.tsx index c56fdda..7f4f003 100644 --- a/apps/coder/web/src/components/ChatPane.tsx +++ b/apps/coder/web/src/components/ChatPane.tsx @@ -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 = {}; + for (const msg of messages) { + if (msg.tool_results) { + toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results; + } + } + return (
{/* Connection indicator */} @@ -88,7 +96,7 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
)} {visibleMessages.map((msg) => ( - + ))}
diff --git a/apps/coder/web/src/components/MessageBubble.tsx b/apps/coder/web/src/components/MessageBubble.tsx index 08e0098..2a99dec 100644 --- a/apps/coder/web/src/components/MessageBubble.tsx +++ b/apps/coder/web/src/components/MessageBubble.tsx @@ -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; } -export function MessageBubble({ message }: Props) { +export function MessageBubble({ message, chatId }: Props) { if (message.role === 'tool') { return ; } @@ -34,18 +37,31 @@ export function MessageBubble({ message }: Props) { {message.tool_calls && message.tool_calls.length > 0 && (
- {message.tool_calls.map((tc) => ( -
- - {tc.name} - - {truncateArgs(tc.arguments)} - -
- ))} + {message.tool_calls.map((tc) => { + if (tc.name === 'ask_user_input') { + const result = message.tool_results ?? null; + return ( + + ); + } + return ( +
+ + {tc.name} + + {truncateArgs(tc.args)} + +
+ ); + })}
)} @@ -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; + 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); } } diff --git a/apps/coder/web/src/components/ui/button.tsx b/apps/coder/web/src/components/ui/button.tsx new file mode 100644 index 0000000..64ae057 --- /dev/null +++ b/apps/coder/web/src/components/ui/button.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +export interface ButtonProps + extends React.ButtonHTMLAttributes { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; +} + +const variantClasses: Record = { + 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 = { + 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( + ({ 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 - + {error && (
{error}
)} @@ -128,7 +128,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) { {a.name} {a.description && ( - + {a.description} )} diff --git a/apps/web/src/components/ModelPicker.tsx b/apps/web/src/components/ModelPicker.tsx index 7c99b43..a2f56bf 100644 --- a/apps/web/src/components/ModelPicker.tsx +++ b/apps/web/src/components/ModelPicker.tsx @@ -107,7 +107,7 @@ export function ModelPicker({ value, onChange }: Props) { - + {error && (
{error}
)} diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index 5f30d82..67559db 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; import type { Chat } from '@/api/types'; +import { api } from '@/api/client'; import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; +import { ChatInput } from '@/components/ChatInput'; import { ContextMenu, ContextMenuContent, @@ -25,6 +27,8 @@ interface Props { chats: Chat[]; onOpenChat: (chatId: string) => void; onSend: (content: string) => void; + /** Create a chat and return its id. Used by slash-command handler. */ + createChat: () => Promise<{ id: string }>; onReopenChat: (chatId: string) => Promise; onArchiveChat: (chatId: string) => Promise; onRenameChat: (chatId: string, name: string) => Promise; @@ -153,12 +157,15 @@ export function SessionLandingPage({ chats, onOpenChat, onSend, + projectId, + createChat, onReopenChat, onArchiveChat, onRenameChat, onDeleteChat, }: Props) { const [composerValue, setComposerValue] = useState(''); + const [chatId, setChatId] = useState(null); const [showArchived, setShowArchived] = useState(false); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); @@ -172,13 +179,43 @@ export function SessionLandingPage({ .filter((c) => c.status === 'archived') .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); - function handleSend() { + // Create a chat lazily on first send or slash command. + const ensureChat = useCallback(async (): Promise => { + if (chatId) return chatId; + try { + const chat = await createChat(); + setChatId(chat.id); + return chat.id; + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create chat'); + throw err; + } + }, [chatId, createChat]); + + async function handleSend() { const text = composerValue.trim(); if (!text) return; - onSend(text); - setComposerValue(''); + try { + const cid = await ensureChat(); + onSend(text); + setComposerValue(''); + } catch { + // Error already surfaced via toast. + } } + // v2.3: slash-command dispatch on landing page. Creates a chat first if + // one doesn't exist, then invokes the skill on that chat. + const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => { + try { + const cid = await ensureChat(); + await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null); + setComposerValue(''); + } catch (err) { + toast.error(err instanceof Error ? err.message : `/${skillName} failed`); + } + }, [ensureChat]); + function startRename(chat: Chat) { setRenamingId(chat.id); setRenameValue(chat.name ?? ''); @@ -293,33 +330,17 @@ export function SessionLandingPage({ )} -
-