feat(webui): render ask_user choices
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AskUserPromptProps {
|
||||
question: string;
|
||||
buttons: string[][];
|
||||
onAnswer: (answer: string) => void;
|
||||
}
|
||||
|
||||
export function AskUserPrompt({
|
||||
question,
|
||||
buttons,
|
||||
onAnswer,
|
||||
}: AskUserPromptProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [custom, setCustom] = useState("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const options = buttons.flat().filter(Boolean);
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [customOpen]);
|
||||
|
||||
const submitCustom = useCallback(() => {
|
||||
const answer = custom.trim();
|
||||
if (!answer) return;
|
||||
onAnswer(answer);
|
||||
setCustom("");
|
||||
setCustomOpen(false);
|
||||
}, [custom, onAnswer]);
|
||||
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto mb-2 w-full max-w-[49.5rem] rounded-[16px] border border-primary/30",
|
||||
"bg-card/95 p-3 shadow-sm backdrop-blur",
|
||||
)}
|
||||
role="group"
|
||||
aria-label="Question"
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
|
||||
<MessageSquareText className="h-3.5 w-3.5" aria-hidden />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 text-sm font-medium leading-5 text-foreground">
|
||||
{question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAnswer(option)}
|
||||
className="justify-start rounded-[10px] px-3 text-left"
|
||||
>
|
||||
<span className="truncate">{option}</span>
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCustomOpen((open) => !open)}
|
||||
className="justify-start rounded-[10px] px-3 text-muted-foreground"
|
||||
>
|
||||
Other...
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{customOpen ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={custom}
|
||||
onChange={(event) => setCustom(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
submitCustom();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Type your own answer..."
|
||||
className={cn(
|
||||
"min-h-9 flex-1 resize-none rounded-[10px] border border-border/70 bg-background",
|
||||
"px-3 py-2 text-sm leading-5 outline-none placeholder:text-muted-foreground",
|
||||
"focus-visible:ring-1 focus-visible:ring-primary/40",
|
||||
)}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={submitCustom} disabled={!custom.trim()}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
@@ -57,6 +58,21 @@ export function ThreadShell({
|
||||
dismissStreamError,
|
||||
} = useNanobotStream(chatId, initial);
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
const pendingAsk = useMemo(() => {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.kind === "trace") continue;
|
||||
if (message.role === "user") return null;
|
||||
if (message.role === "assistant" && message.buttons?.some((row) => row.length > 0)) {
|
||||
return {
|
||||
question: message.content,
|
||||
buttons: message.buttons,
|
||||
};
|
||||
}
|
||||
if (message.role === "assistant") return null;
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
@@ -152,6 +168,13 @@ export function ThreadShell({
|
||||
onDismiss={dismissStreamError}
|
||||
/>
|
||||
) : null}
|
||||
{pendingAsk ? (
|
||||
<AskUserPrompt
|
||||
question={pendingAsk.question}
|
||||
buttons={pendingAsk.buttons}
|
||||
onAnswer={send}
|
||||
/>
|
||||
) : null}
|
||||
{session ? (
|
||||
<ThreadComposer
|
||||
onSend={send}
|
||||
|
||||
@@ -160,13 +160,15 @@ export function useNanobotStream(
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) => {
|
||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||
const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text;
|
||||
return [
|
||||
...filtered,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
content: ev.text,
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(ev.buttons && ev.buttons.length > 0 ? { buttons: ev.buttons } : {}),
|
||||
...(media && media.length > 0 ? { media } : {}),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface UIMessage {
|
||||
images?: UIImage[];
|
||||
/** Signed or local UI-renderable media attachments. */
|
||||
media?: UIMediaAttachment[];
|
||||
/** Optional answer choices for a pending ask_user question. */
|
||||
buttons?: string[][];
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
@@ -82,6 +84,9 @@ export type InboundEvent =
|
||||
reply_to?: string;
|
||||
media?: string[];
|
||||
media_urls?: Array<{ url: string; name?: string }>;
|
||||
buttons?: string[][];
|
||||
/** Original prompt before the websocket text fallback appends buttons. */
|
||||
button_prompt?: string;
|
||||
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
||||
* generic progress line) rather than a conversational reply. */
|
||||
kind?: "tool_hint" | "progress";
|
||||
|
||||
@@ -7,11 +7,22 @@ import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
function makeClient() {
|
||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onChat: () => () => {},
|
||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||
let handlers = chatHandlers.get(chatId);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
chatHandlers.set(chatId, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers?.delete(handler);
|
||||
};
|
||||
},
|
||||
onError: (handler: (err: { kind: string }) => void) => {
|
||||
errorHandlers.add(handler);
|
||||
return () => {
|
||||
@@ -21,6 +32,9 @@ function makeClient() {
|
||||
_emitError(err: { kind: string }) {
|
||||
for (const h of errorHandlers) h(err);
|
||||
},
|
||||
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
@@ -411,4 +425,46 @@ describe("ThreadShell", () => {
|
||||
await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument());
|
||||
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ask_user options above the composer and sends selected answers", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
client._emitChat("chat-a", {
|
||||
event: "message",
|
||||
chat_id: "chat-a",
|
||||
text: "How should I continue?",
|
||||
buttons: [["Short answer", "Detailed answer"]],
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByRole("group", { name: "Question" })).toHaveTextContent(
|
||||
"How should I continue?",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Short answer" }));
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"Short answer",
|
||||
undefined,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("group", { name: "Question" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,4 +113,27 @@ describe("useNanobotStream", () => {
|
||||
{ kind: "video", url: "/api/media/sig/payload", name: "demo.mp4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps assistant buttons on complete messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-q", []), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-q", {
|
||||
event: "message",
|
||||
chat_id: "chat-q",
|
||||
text: "How should I continue?\n\n1. Short answer\n2. Detailed answer",
|
||||
button_prompt: "How should I continue?",
|
||||
buttons: [["Short answer", "Detailed answer"]],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("How should I continue?");
|
||||
expect(result.current.messages[0].buttons).toEqual([
|
||||
["Short answer", "Detailed answer"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user