diff --git a/examples/next/app/blogs/[blog]/page.tsx b/examples/next/app/blogs/[blog]/page.tsx index 57b0d9ef7..a8af67b20 100644 --- a/examples/next/app/blogs/[blog]/page.tsx +++ b/examples/next/app/blogs/[blog]/page.tsx @@ -1,19 +1,22 @@ -"use server"; +"use client"; import { notFound } from "next/navigation"; import { fetchVal, fetchValRoute } from "../../../val/rsc"; import blogsVal from "./page.val"; import Link from "next/link"; import authorsVal from "../../../content/authors.val"; import { ValRichText } from "@valbuild/next"; +import { useVal, useValRoute } from "../../../val/client"; -export default async function BlogPage({ +export default function BlogPage({ params, }: { params: Promise<{ blog: string }>; }) { - const blog = await fetchValRoute(blogsVal, params); - const authors = await fetchVal(authorsVal); + console.log("here"); + const blog = useValRoute(blogsVal, params); + const authors = useVal(authorsVal); if (!blog) { + console.log("not found"); return notFound(); } const author = authors[blog.author]; diff --git a/examples/next/val.config.ts b/examples/next/val.config.ts index cfde56f86..068807b4a 100644 --- a/examples/next/val.config.ts +++ b/examples/next/val.config.ts @@ -7,7 +7,7 @@ const { s, c, val, config, nextAppRouter, externalPageRouter } = initVal({ ai: { chat: { experimental: { - enable: false, + enable: true, }, suggestions: [ "Summarize", diff --git a/packages/next/src/client/initValClient.ts b/packages/next/src/client/initValClient.ts index 3e88c47e2..86390b845 100644 --- a/packages/next/src/client/initValClient.ts +++ b/packages/next/src/client/initValClient.ts @@ -41,6 +41,7 @@ function useValStega(selector: T): UseValType { return; }, ); + console.log("->", valOverlayContext.enabled, store?.hasAllLoaded(moduleIds)); // Suspense (Val-enabled branch only). The gate is `enabled` (the VAL_ENABLE // cookie), not `draftMode`: `enabled` is stable for the lifetime of the page // whereas `draftMode` is polled and can change, and we must not start/stop @@ -49,6 +50,7 @@ function useValStega(selector: T): UseValType { // React.use — which React permits inside conditionals and loops — or throws // the promise for classic Suspense, neither of which is a hook. if (valOverlayContext.enabled && store && !store.hasAllLoaded(moduleIds)) { + console.log("suspending", moduleIds); valSuspense(store.waitForLoad(moduleIds)); } return stegaEncode(selector, { diff --git a/packages/ui/spa/components/AIChat.stories.tsx b/packages/ui/spa/components/AIChat.stories.tsx index b12c2be7a..9310a4011 100644 --- a/packages/ui/spa/components/AIChat.stories.tsx +++ b/packages/ui/spa/components/AIChat.stories.tsx @@ -1,6 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react"; import { useEffect, useRef } from "react"; -import { AIChat, AIChatHandle, ChatMessage } from "./AIChat"; +import { + AIChat, + AIChatHandle, + AskUserQuestionAnswer, + AskUserQuestionItem, + ChatMessage, +} from "./AIChat"; const meta: Meta = { title: "Components/AIChat", @@ -345,3 +351,264 @@ function InteractiveDemo() { export const Interactive: Story = { render: () => , }; + +// --------------------------------------------------------------------------- +// 7. ask_user_question — interactive question card +// --------------------------------------------------------------------------- + +type AskQuestionDemoProps = { + userPrompt: string; + questions: AskUserQuestionItem[]; +}; + +function AskQuestionDemo({ userPrompt, questions }: AskQuestionDemoProps) { + const chatRef = useRef(null); + const messageIdRef = useRef(null); + const toolCallIdRef = useRef(null); + + useEffect(() => { + if (!chatRef.current) return; + const messageId = `ask-msg-${Date.now()}`; + const toolCallId = `ask-tc-${Date.now()}`; + messageIdRef.current = messageId; + toolCallIdRef.current = toolCallId; + chatRef.current.startAssistantMessage(messageId); + chatRef.current.addToolCall( + messageId, + toolCallId, + "ask_user_question", + questions, + ); + }, [questions]); + + const handleAnswer = ( + _toolCallId: string, + answers: AskUserQuestionAnswer[], + ) => { + console.log("Submitted answers:", answers); + const messageId = messageIdRef.current; + if (!messageId || !chatRef.current) return; + const summary = answers + .map((a, qi) => { + const labels = a.selectedOptions + .map((idx) => questions[qi]?.options[idx]?.label) + .filter((l): l is string => Boolean(l)); + const parts = [...labels]; + if (a.customAnswer) parts.push(`"${a.customAnswer}"`); + return `- ${a.question} → ${parts.join(", ") || "(no answer)"}`; + }) + .join("\n"); + chatRef.current.appendAssistantChunk( + messageId, + `\nThanks! Here's what I'll do based on your answers:\n${summary}\n`, + ); + chatRef.current.completeAssistantMessage(messageId); + }; + + const handleCancel = () => { + const messageId = messageIdRef.current; + if (!messageId || !chatRef.current) return; + chatRef.current.appendAssistantChunk( + messageId, + `\nNo problem — let me know if you change your mind.\n`, + ); + chatRef.current.completeAssistantMessage(messageId); + }; + + return ( + + ); +} + +export const AskQuestionSingleSelect: Story = { + render: () => ( + + ), +}; + +export const AskQuestionMultiSelect: Story = { + render: () => ( + + ), +}; + +export const AskQuestionMultipleQuestions: Story = { + render: () => ( + + ), +}; + +export const AskQuestionWithDefaults: Story = { + render: () => ( + + ), +}; + +export const AskQuestionAnswered: Story = { + args: { + initialMessages: [ + { + id: "answered-user-1", + role: "user", + content: "Update the title", + status: "complete", + }, + { + id: "answered-assistant-1", + role: "assistant", + content: "Got it — updating the About page title.", + status: "complete", + toolActivities: [ + { + toolCallId: "answered-tc-1", + name: "ask_user_question", + status: "complete", + questions: [ + { + question: "Which page should I update?", + header: "Which page?", + options: [ + { label: "Home" }, + { label: "About" }, + { label: "Contact" }, + ], + }, + ], + answers: [ + { + question: "Which page should I update?", + selectedOptions: [1], + customAnswer: null, + }, + ], + }, + ], + }, + ], + }, +}; + +export const AskQuestionCancelled: Story = { + args: { + initialMessages: [ + { + id: "cancelled-user-1", + role: "user", + content: "Update the title", + status: "complete", + }, + { + id: "cancelled-assistant-1", + role: "assistant", + content: + "No problem — let me know which page you want to update and I'll get to it.", + status: "complete", + toolActivities: [ + { + toolCallId: "cancelled-tc-1", + name: "ask_user_question", + status: "error", + cancelled: true, + questions: [ + { + question: "Which page should I update?", + header: "Which page?", + options: [ + { label: "Home" }, + { label: "About" }, + { label: "Contact" }, + ], + }, + ], + }, + ], + }, + ], + }, +}; diff --git a/packages/ui/spa/components/AIChat.tsx b/packages/ui/spa/components/AIChat.tsx index 2cd1a4f59..1a91e4609 100644 --- a/packages/ui/spa/components/AIChat.tsx +++ b/packages/ui/spa/components/AIChat.tsx @@ -32,6 +32,7 @@ import { Tag, Paperclip, X, + HelpCircle, } from "lucide-react"; import type { AISession } from "../hooks/useAIWebSocket"; import type { AIContentBlock, AIMessageContent } from "./ValProvider"; @@ -49,10 +50,27 @@ export type ChatMessageStatus = "complete" | "streaming" | "error"; export type ToolActivityStatus = "pending" | "complete" | "error"; +export type AskUserQuestionItem = { + question: string; + header?: string; + options: { label: string; description?: string }[]; + multiSelect?: boolean; + defaults?: number[]; +}; + +export type AskUserQuestionAnswer = { + question: string; + selectedOptions: number[]; + customAnswer: string | null; +}; + export type ToolActivity = { toolCallId: string; name: string; status: ToolActivityStatus; + questions?: AskUserQuestionItem[]; + answers?: AskUserQuestionAnswer[]; + cancelled?: boolean; }; export type ChatMessageAttachment = { @@ -100,11 +118,20 @@ export type AIChatHandle = { messageId: string, toolCallId: string, toolName: string, + questions?: AskUserQuestionItem[], ) => void; /** Mark a tool call as complete */ completeToolCall: (messageId: string, toolCallId: string) => void; /** Mark a tool call as errored */ errorToolCall: (messageId: string, toolCallId: string) => void; + /** Record the user's answers to an ask_user_question tool call */ + recordToolAnswers: ( + messageId: string, + toolCallId: string, + answers: AskUserQuestionAnswer[], + ) => void; + /** Mark an ask_user_question tool call as cancelled by the user */ + recordToolCancel: (messageId: string, toolCallId: string) => void; /** Clear all messages (used when starting a new session) */ clearMessages: () => void; /** Bulk-load historical messages (e.g. when restoring a session) */ @@ -141,6 +168,13 @@ export type AIChatProps = { onFetchSessions?: () => void; /** Called to rename a session */ onSetSessionName?: (sessionId: string, name: string) => void; + /** Called when the user submits answers to an ask_user_question tool call */ + onAnswerToolQuestions?: ( + toolCallId: string, + answers: AskUserQuestionAnswer[], + ) => void; + /** Called when the user cancels an ask_user_question tool call */ + onCancelToolQuestion?: (toolCallId: string) => void; /** * @internal – seed messages for Storybook / testing only. * Not part of the public API. @@ -211,6 +245,8 @@ export const AIChat = forwardRef(function AIChat( onLoadSession, onFetchSessions, onSetSessionName, + onAnswerToolQuestions, + onCancelToolQuestion, initialMessages, }, ref, @@ -277,6 +313,52 @@ export const AIChat = forwardRef(function AIChat( return () => clearTimeout(timer); }, [currentMessage]); + // ---- Local state mutators (shared by imperative handle and inline UI) ---- + + const recordAnswersInState = useCallback( + ( + messageId: string, + toolCallId: string, + answers: AskUserQuestionAnswer[], + ) => { + setCurrentMessage((prev) => { + if (!prev || prev.message.id !== messageId) return prev; + return { + ...prev, + message: { + ...prev.message, + toolActivities: (prev.message.toolActivities ?? []).map((t) => + t.toolCallId === toolCallId + ? { ...t, status: "complete" as const, answers } + : t, + ), + }, + }; + }); + }, + [], + ); + + const recordCancelInState = useCallback( + (messageId: string, toolCallId: string) => { + setCurrentMessage((prev) => { + if (!prev || prev.message.id !== messageId) return prev; + return { + ...prev, + message: { + ...prev.message, + toolActivities: (prev.message.toolActivities ?? []).map((t) => + t.toolCallId === toolCallId + ? { ...t, status: "error" as const, cancelled: true } + : t, + ), + }, + }; + }); + }, + [], + ); + // ---- Imperative handle for WebSocket layer ---- useImperativeHandle(ref, () => ({ @@ -319,11 +401,17 @@ export const AIChat = forwardRef(function AIChat( return null; }); }, - addToolCall(messageId: string, toolCallId: string, toolName: string) { + addToolCall( + messageId: string, + toolCallId: string, + toolName: string, + questions?: AskUserQuestionItem[], + ) { const activity: ToolActivity = { toolCallId, name: toolName, status: "pending", + ...(questions ? { questions } : {}), }; setCurrentMessage((prev) => { if (prev && prev.message.id === messageId) { @@ -373,6 +461,16 @@ export const AIChat = forwardRef(function AIChat( }; }); }, + recordToolAnswers( + messageId: string, + toolCallId: string, + answers: AskUserQuestionAnswer[], + ) { + recordAnswersInState(messageId, toolCallId, answers); + }, + recordToolCancel(messageId: string, toolCallId: string) { + recordCancelInState(messageId, toolCallId); + }, clearMessages() { setCompletedMessages([]); setCurrentMessage(null); @@ -731,7 +829,19 @@ export const AIChat = forwardRef(function AIChat( /> ) : ( messages.map((msg) => ( - + { + recordAnswersInState(msg.id, toolCallId, answers); + onAnswerToolQuestions?.(toolCallId, answers); + }} + onCancelToolQuestion={(toolCallId) => { + recordCancelInState(msg.id, toolCallId); + onCancelToolQuestion?.(toolCallId); + }} + /> )) )}
@@ -944,9 +1054,16 @@ function EmptyState({ function MessageBubble({ message, onRetry, + onSubmitToolAnswers, + onCancelToolQuestion, }: { message: ChatMessage; onRetry: (id: string) => void; + onSubmitToolAnswers: ( + toolCallId: string, + answers: AskUserQuestionAnswer[], + ) => void; + onCancelToolQuestion: (toolCallId: string) => void; }) { const config = useValConfig(); const appHostUrl = config?.appHost || DEFAULT_APP_HOST; @@ -956,6 +1073,9 @@ function MessageBubble({ const isUser = message.role === "user"; const isError = message.status === "error"; const isStreamingMsg = message.status === "streaming"; + const hasPendingQuestion = (message.toolActivities ?? []).some( + (a) => a.questions && !a.answers && !a.cancelled && a.status === "pending", + ); const textContent = getTextContent(message.content); const fileUrls = getImageUrls(message.content); @@ -1021,7 +1141,12 @@ function MessageBubble({ ) : (
{message.toolActivities && message.toolActivities.length > 0 && ( - + )} {fileUrls.length > 0 && (
@@ -1037,10 +1162,13 @@ function MessageBubble({ )} {textContent ? ( {textContent} - ) : isStreamingMsg || isError || fileUrls.length > 0 ? null : ( + ) : isStreamingMsg || + isError || + fileUrls.length > 0 || + hasPendingQuestion ? null : (

Empty response

)} - {isStreamingMsg && } + {isStreamingMsg && !hasPendingQuestion && }
)} @@ -1151,12 +1279,25 @@ const TOOL_DISPLAY: Record = label: "Naming session", icon: , }, + ask_user_question: { + label: "Asking a question", + icon: , + }, }; function ToolActivitiesIndicator({ activities, + messageId, + onSubmitAnswers, + onCancel, }: { activities: ToolActivity[]; + messageId: string; + onSubmitAnswers: ( + toolCallId: string, + answers: AskUserQuestionAnswer[], + ) => void; + onCancel: (toolCallId: string) => void; }) { return (
@@ -1167,9 +1308,48 @@ function ToolActivitiesIndicator({ }; const isPending = activity.status === "pending"; const isError = activity.status === "error"; + const isQuestionPending = + activity.questions && + !activity.answers && + !activity.cancelled && + isPending; + if (isQuestionPending && activity.questions) { + return ( + + onSubmitAnswers(activity.toolCallId, answers) + } + onCancel={() => onCancel(activity.toolCallId)} + /> + ); + } + if (activity.questions && activity.answers) { + return ( + + ); + } + if (activity.questions && activity.cancelled) { + return ( +
+ + + Question dismissed +
+ ); + } return (
); } + +function AnsweredQuestionSummary({ + questions, + answers, +}: { + questions: AskUserQuestionItem[]; + answers: AskUserQuestionAnswer[]; +}) { + return ( +
+
+ + Asked a question +
+ {answers.map((a, i) => { + const q = questions[i]; + const parts: string[] = []; + if (q) { + a.selectedOptions.forEach((idx) => { + const label = q.options[idx]?.label; + if (label) parts.push(label); + }); + } + if (a.customAnswer) parts.push(a.customAnswer); + const answerText = parts.join(", ") || "(no answer)"; + return ( +
+ Q: {a.question}{" "} + {answerText} +
+ ); + })} +
+ ); +} + +type DraftAnswer = { + selected: Set; + custom: string; + otherSelected: boolean; +}; + +function initialDrafts(questions: AskUserQuestionItem[]): DraftAnswer[] { + return questions.map((q) => { + const selected = new Set(); + if (q.defaults && q.defaults.length > 0) { + const validDefaults = q.defaults.filter( + (idx) => Number.isInteger(idx) && idx >= 0 && idx < q.options.length, + ); + if (q.multiSelect) { + validDefaults.forEach((idx) => selected.add(idx)); + } else if (validDefaults.length > 0) { + selected.add(validDefaults[0]); + } + } + return { selected, custom: "", otherSelected: false }; + }); +} + +function QuestionCard({ + questions, + onSubmit, + onCancel, +}: { + questions: AskUserQuestionItem[]; + onSubmit: (answers: AskUserQuestionAnswer[]) => void; + onCancel: () => void; +}) { + const [drafts, setDrafts] = useState(() => + initialDrafts(questions), + ); + const [submitted, setSubmitted] = useState(false); + + const canSubmit = drafts.every( + (d) => + d.selected.size > 0 || (d.otherSelected && d.custom.trim().length > 0), + ); + + const toggleOption = (qi: number, oi: number, multiSelect: boolean) => { + if (submitted) return; + setDrafts((prev) => + prev.map((d, i) => { + if (i !== qi) return d; + const next = new Set(d.selected); + if (multiSelect) { + if (next.has(oi)) next.delete(oi); + else next.add(oi); + return { ...d, selected: next }; + } + next.clear(); + next.add(oi); + // Single-select: picking an option deselects Other. + return { ...d, selected: next, otherSelected: false }; + }), + ); + }; + + const selectOther = (qi: number, multiSelect: boolean) => { + if (submitted) return; + setDrafts((prev) => + prev.map((d, i) => { + if (i !== qi) return d; + if (multiSelect) { + return { ...d, otherSelected: !d.otherSelected }; + } + // Single-select: selecting Other deselects all listed options. + return { ...d, selected: new Set(), otherSelected: true }; + }), + ); + }; + + const setCustom = (qi: number, value: string, multiSelect: boolean) => { + if (submitted) return; + setDrafts((prev) => + prev.map((d, i) => { + if (i !== qi) return d; + // Typing in the Other input auto-selects Other. In single-select + // mode, this also deselects the listed options. + if (multiSelect) { + return { ...d, custom: value, otherSelected: true }; + } + return { + ...d, + custom: value, + otherSelected: true, + selected: new Set(), + }; + }), + ); + }; + + const handleSubmit = () => { + if (submitted || !canSubmit) return; + setSubmitted(true); + const answers: AskUserQuestionAnswer[] = questions.map((q, i) => { + const d = drafts[i]; + return { + question: q.question, + selectedOptions: Array.from(d.selected).sort((a, b) => a - b), + customAnswer: + d.otherSelected && d.custom.trim().length > 0 + ? d.custom.trim() + : null, + }; + }); + onSubmit(answers); + }; + + const handleCancel = () => { + if (submitted) return; + setSubmitted(true); + onCancel(); + }; + + return ( +
+
+ + Please answer to continue +
+ {questions.map((q, qi) => { + const multiSelect = q.multiSelect === true; + const draft = drafts[qi]; + return ( +
+ {q.header && ( +
+ {q.header} +
+ )} +
{q.question}
+
+ {q.options.map((opt, oi) => { + const isSelected = draft.selected.has(oi); + return ( + + ); + })} +
+
+ + setCustom(qi, e.target.value, multiSelect)} + className={cn( + "flex-1 bg-transparent text-fg-primary", + "focus:outline-none placeholder:text-fg-tertiary", + )} + /> +
+
+ ); + })} +
+ + +
+
+ ); +} diff --git a/packages/ui/spa/components/ToolsMenu.tsx b/packages/ui/spa/components/ToolsMenu.tsx index 6d0f702b8..7e8ff7023 100644 --- a/packages/ui/spa/components/ToolsMenu.tsx +++ b/packages/ui/spa/components/ToolsMenu.tsx @@ -80,6 +80,8 @@ export function ToolsMenu() { getSessions, setSessionName, loadSession, + answerToolQuestions, + cancelToolQuestion, } = useAI(chatRef); return (
)} diff --git a/packages/ui/spa/components/ValFieldProvider.tsx b/packages/ui/spa/components/ValFieldProvider.tsx index 0c5b00c34..43df7b4d2 100644 --- a/packages/ui/spa/components/ValFieldProvider.tsx +++ b/packages/ui/spa/components/ValFieldProvider.tsx @@ -420,7 +420,11 @@ export function useGetDirectFileUploadSettings() { } export function useValConfig() { - const { config } = useValFieldContext(); + // Tolerate being rendered without a ValFieldProvider (e.g. in Storybook). + // Config is already typed as optional throughout — returning undefined here + // is consistent with that contract. + const ctx = useContext(ValFieldContext); + const config = ctx?.config; const lastConfig = useRef< | (ValConfig & { remoteHost: string; diff --git a/packages/ui/spa/components/ValOverlay.tsx b/packages/ui/spa/components/ValOverlay.tsx index c55a03cfc..ece3677ce 100644 --- a/packages/ui/spa/components/ValOverlay.tsx +++ b/packages/ui/spa/components/ValOverlay.tsx @@ -846,6 +846,8 @@ function ChatWindow({ getSessions, setSessionName, loadSession, + answerToolQuestions, + cancelToolQuestion, } = useAI(chatRef); const mode = useValMode(); const [windowPos, setWindowPos] = useState({ @@ -1016,6 +1018,8 @@ function ChatWindow({ onLoadSession={loadSession} onFetchSessions={getSessions} onSetSessionName={setSessionName} + onAnswerToolQuestions={answerToolQuestions} + onCancelToolQuestion={cancelToolQuestion} />
{!isMobile && ( diff --git a/packages/ui/spa/hooks/useAI.ts b/packages/ui/spa/hooks/useAI.ts index 3bffb9efc..ec30f26ec 100644 --- a/packages/ui/spa/hooks/useAI.ts +++ b/packages/ui/spa/hooks/useAI.ts @@ -1,5 +1,10 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import type { AIChatHandle, ChatMessageAttachment } from "../components/AIChat"; +import type { + AIChatHandle, + AskUserQuestionAnswer, + AskUserQuestionItem, + ChatMessageAttachment, +} from "../components/AIChat"; import { type AITool, SessionImageToPatchError, @@ -311,6 +316,78 @@ const SET_SESSION_NAME_TOOL: AITool = { required: ["name"], }, }; +const ASK_USER_QUESTION_TOOL: AITool = { + name: "ask_user_question", + description: + "Ask the user 1-4 structured clarification questions at once and wait for their answers. " + + "Use ONLY when the request is ambiguous and a small set of concrete options would resolve it. " + + "Prefer this over open-ended follow-up text. Group related clarifications into one call rather than asking sequentially. " + + "Each question is single-select by default; set multiSelect: true on a question to let the user pick multiple options. " + + "Each question may set defaults (indices of options to pre-select). The user can also type a free-text 'Other' answer per question. " + + "Returns { answers: Array<{ question: string; selectedOptions: number[]; customAnswer: string | null }> } in the same order as the input questions. " + + "selectedOptions holds the indices of options the user kept selected (length 0 or 1 for single-select; 0..N for multi-select). " + + "customAnswer is the user's free-text 'Other' input for that question (null if they didn't type one). " + + "The user may also cancel: in that case the tool result is an error and you should follow up in plain text instead.", + parameters: { + type: "object", + properties: { + questions: { + type: "array", + description: + "1-4 questions to ask the user, all shown together in a single card.", + items: { + type: "object", + properties: { + question: { + type: "string", + description: + "The full question shown to the user. One sentence, plain language.", + }, + header: { + type: "string", + description: + "Optional short label (1-4 words) shown above the question, e.g. 'Which page?'", + }, + options: { + type: "array", + description: "2-4 distinct answer options.", + items: { + type: "object", + properties: { + label: { + type: "string", + description: "Short button label (max ~40 chars)", + }, + description: { + type: "string", + description: "Optional one-line elaboration", + }, + }, + required: ["label"], + }, + }, + multiSelect: { + type: "boolean", + description: + "If true, the user can select multiple options for this question. Defaults to false (single-select).", + }, + defaults: { + type: "array", + description: + "Optional indices of options to pre-select. For single-select questions only the first entry is honored. Out-of-range indices are ignored.", + items: { type: "number" }, + }, + }, + required: ["question", "options"], + }, + }, + }, + required: ["questions"], + }, + // The tool blocks on user interaction — disable the server-side default + // 30s timeout so the call doesn't get aborted while the user is deciding. + timeoutMs: null, +}; const ALL_TOOLS: AITool[] = [ GET_ALL_SCHEMA_TOOL, GET_SOURCE_TOOL, @@ -324,6 +401,7 @@ const ALL_TOOLS: AITool[] = [ GET_PATCHES_TOOL, GET_SOURCE_PATH_FROM_ROUTE_TOOL, SET_SESSION_NAME_TOOL, + ASK_USER_QUESTION_TOOL, ]; export function useAI(chatRef: React.RefObject) { @@ -355,6 +433,8 @@ export function useAI(chatRef: React.RefObject) { // Track active streaming ID — startAssistantMessage always appends a new // message (NOT idempotent), so we must only call it once per message ID. const activeIdRef = useRef(null); + // Track pending ask_user_question tool calls so we can reject them on session change + const pendingQuestionToolCallIdsRef = useRef>(new Set()); useEffect(() => { const handler = (message: AIServerMessage) => { @@ -383,13 +463,29 @@ export function useAI(chatRef: React.RefObject) { chatRef.current.startAssistantMessage(message.id); setIsStreaming(true); } - chatRef.current.addToolCall( + // ask_user_question registers itself below with the question payload + if (message.name !== "ask_user_question") { + chatRef.current.addToolCall( + message.id, + message.toolCallId, + message.name, + ); + } + } + if (message.name === "ask_user_question") { + const args = message.arguments as { + questions: AskUserQuestionItem[]; + }; + chatRef.current?.addToolCall( message.id, message.toolCallId, message.name, + args.questions, ); - } - if (message.name === "get_all_schema") { + pendingQuestionToolCallIdsRef.current.add(message.toolCallId); + // Do NOT send ai_tool_result. Wait for the user to submit or cancel + // via answerToolQuestions / cancelToolQuestion. + } else if (message.name === "get_all_schema") { const schemas = syncEngine.getAllSchemasSnapshot(); sendWsMessage({ type: "ai_tool_result", @@ -1318,6 +1414,7 @@ Always call get_all_schema first unless the question clearly does not require it - get_current_context: understand who the user is, what time it is, and where they are in the content tree and the site. Use this to inform your responses and actions. If the user is on a Next.js app-router page, the matching source path will be included in the context. - set_session_name: give the current chat session a short, descriptive name once the topic is clear. Call this at least once per session, early in the conversation. Max 5 words, plain language (e.g. 'Update homepage hero text'). If there is a title or a topic use it directly. - get_source_path_from_route: given a URL path like '/blogs/blog-1', find which Next.js page module it belongs to and return the source path. Use this when the user mentions a specific page URL or route. +- ask_user_question: ask the user 1-4 structured clarification questions at once, rendered together in one card. Each question is single-select by default; set multiSelect: true on a question to let the user pick multiple options (e.g. "Which sections should I update?"). You may set defaults (option indices to pre-select) to bias toward the most likely answer. Use ONLY when the user's request is ambiguous and a small set of concrete options would resolve it (e.g. "Which page should I update — Home, About, or Contact?"). When several clarifications are needed, group them into a SINGLE call rather than asking sequentially. Do NOT use for open-ended questions or yes/no checks — just ask in plain text instead. Never call this more than once in a row; if the user answered with custom "Other" text, proceed with that — do not re-ask. If the tool result is an error (user cancelled), drop the structured-question approach and ask once in plain text instead. ## navigate_to: how to build a source path A source path is a module file path optionally followed by ?p= and a dot-separated field path. String keys are JSON-quoted, array indices are plain numbers. @@ -1374,12 +1471,48 @@ Do not describe what you will do unless you do it for clarification — just do [sendWsMessage], ); + const answerToolQuestions = useCallback( + (toolCallId: string, answers: AskUserQuestionAnswer[]) => { + pendingQuestionToolCallIdsRef.current.delete(toolCallId); + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { answers }, + }); + }, + [sendWsMessage], + ); + + const cancelToolQuestion = useCallback( + (toolCallId: string) => { + pendingQuestionToolCallIdsRef.current.delete(toolCallId); + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { error: "User declined to answer the question(s)." }, + isError: true, + }); + }, + [sendWsMessage], + ); + const newSession = useCallback(() => { + // Reject any pending ask_user_question tool calls so the server-side + // conversation doesn't keep a dangling tool call open. + for (const toolCallId of pendingQuestionToolCallIdsRef.current) { + sendWsMessage({ + type: "ai_tool_result", + toolCallId, + result: { error: "User started a new session." }, + isError: true, + }); + } + pendingQuestionToolCallIdsRef.current.clear(); const id = crypto.randomUUID(); sessionIdRef.current = id; setCurrentSessionId(id); chatRef.current?.clearMessages(); - }, [chatRef]); + }, [chatRef, sendWsMessage]); const getSessions = useCallback( async (opts?: { @@ -1456,5 +1589,7 @@ Do not describe what you will do unless you do it for clarification — just do getSessions, setSessionName, loadSession, + answerToolQuestions, + cancelToolQuestion, }; } diff --git a/packages/ui/spa/hooks/useAIWebSocket.ts b/packages/ui/spa/hooks/useAIWebSocket.ts index ff11dd939..bda24b6e4 100644 --- a/packages/ui/spa/hooks/useAIWebSocket.ts +++ b/packages/ui/spa/hooks/useAIWebSocket.ts @@ -15,6 +15,9 @@ export const AITool = z.object({ properties: z.record(z.string(), z.unknown()), required: z.array(z.string()).optional(), }), + // Optional server-side timeout for waiting on the matching ai_tool_result. + // Omitted → server default (30s). A number → wait that many ms. null → wait indefinitely. + timeoutMs: z.union([z.number().nonnegative(), z.null()]).optional(), }); export type AITool = z.infer; diff --git a/packages/ui/spa/utils/toolNames.ts b/packages/ui/spa/utils/toolNames.ts index dd0e88700..68980dac7 100644 --- a/packages/ui/spa/utils/toolNames.ts +++ b/packages/ui/spa/utils/toolNames.ts @@ -11,5 +11,6 @@ export const toolNames = [ "get_patches", "get_source_path_from_route", "set_session_name", + "ask_user_question", ] as const; export type ToolName = (typeof toolNames)[number];