feat: implement AskUserInterrupt component and integrate user question handling in ChatInterface and ChatMessage

This commit is contained in:
Xi Zhang
2026-06-01 01:15:03 +01:00
parent 7672883b67
commit 2b0322c5ce
4 changed files with 253 additions and 9 deletions
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MessageCircleQuestion } from "lucide-react";
import { cn } from "@/lib/utils";
interface Choice {
value: string;
}
export interface AskUserQuestion {
question: string;
type: "text" | "multiple_choice";
choices?: Choice[];
required?: boolean;
}
interface AskUserInterruptProps {
questions: AskUserQuestion[];
onSubmit: (answers: string[]) => void;
onCancel: () => void;
isLoading?: boolean;
}
const OTHER = "__other__";
export function AskUserInterrupt({
questions,
onSubmit,
onCancel,
isLoading,
}: AskUserInterruptProps) {
// answers[i] = final answer string; picked[i] = selected choice value or
// OTHER for multiple_choice (null for free-text questions).
const [answers, setAnswers] = useState<string[]>(() =>
questions.map(() => "")
);
const [picked, setPicked] = useState<(string | null)[]>(() =>
questions.map(() => null)
);
const setAnswer = (i: number, value: string) =>
setAnswers((prev) => prev.map((a, idx) => (idx === i ? value : a)));
const setPick = (i: number, value: string | null) =>
setPicked((prev) => prev.map((p, idx) => (idx === i ? value : p)));
const isOptional = (q: AskUserQuestion) =>
q.type === "text" && q.required === false;
const canSubmit = questions.every(
(q, i) => isOptional(q) || answers[i].trim() !== ""
);
return (
<div className="w-full rounded-lg border border-border bg-muted/30 p-4">
<div className="mb-3 flex items-center gap-2 text-foreground">
<MessageCircleQuestion
size={16}
className="text-[var(--brand)]"
aria-hidden="true"
/>
<span className="text-xs font-semibold uppercase tracking-wider">
EvoScientist Needs Your Input
</span>
</div>
<div className="space-y-5">
{questions.map((q, i) => (
<div key={i}>
<p className="mb-2 text-sm font-medium text-foreground">
{q.question}
{!isOptional(q) && (
<span className="ml-1 text-destructive">*</span>
)}
</p>
{q.type === "multiple_choice" ? (
<div className="flex flex-col gap-2">
{q.choices?.map((choice) => {
const selected = picked[i] === choice.value;
return (
<button
key={choice.value}
type="button"
disabled={isLoading}
onClick={() => {
setPick(i, choice.value);
setAnswer(i, choice.value);
}}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
selected
? "border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-foreground)]"
: "border-border bg-background hover:bg-accent"
)}
>
{choice.value}
</button>
);
})}
<button
type="button"
disabled={isLoading}
onClick={() => {
setPick(i, OTHER);
setAnswer(i, "");
}}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
picked[i] === OTHER
? "border-[var(--brand)] text-foreground"
: "border-border bg-background hover:bg-accent"
)}
>
Other…
</button>
{picked[i] === OTHER && (
<Textarea
value={answers[i]}
onChange={(e) => setAnswer(i, e.target.value)}
placeholder="Type your answer…"
className="text-sm"
rows={2}
disabled={isLoading}
/>
)}
</div>
) : (
<Textarea
value={answers[i]}
onChange={(e) => setAnswer(i, e.target.value)}
placeholder={
isOptional(q)
? "Type your answer… (optional)"
: "Type your answer…"
}
className="text-sm"
rows={2}
disabled={isLoading}
/>
)}
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => onSubmit(answers)}
disabled={!canSubmit || isLoading}
className="bg-[var(--brand)] text-[var(--brand-foreground)] hover:bg-[var(--brand-hover)]"
>
{isLoading ? "Submitting…" : "Submit"}
</Button>
</div>
</div>
);
}
+58 -3
View File
@@ -20,6 +20,10 @@ import {
ShieldCheck,
} from "lucide-react";
import { ChatMessage } from "@/app/components/ChatMessage";
import {
AskUserInterrupt,
type AskUserQuestion,
} from "@/app/components/AskUserInterrupt";
import type {
TodoItem,
ToolCall,
@@ -88,7 +92,18 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
resumeInterrupt,
} = useChatContext();
const submitDisabled = isLoading || !assistant;
// While the agent waits on an *actionable* interrupt (approval or ask_user),
// lock the composer so the user answers via the in-message controls — a free
// message would cancel the pending tool call and corrupt the thread.
// A bare/leftover interrupt value (e.g. after Stop) must NOT lock the input.
const interruptValue = interrupt?.value as
| { type?: string; action_requests?: unknown[] }
| undefined;
const hasPendingInterrupt =
interruptValue?.type === "ask_user" ||
(Array.isArray(interruptValue?.action_requests) &&
interruptValue.action_requests.length > 0);
const submitDisabled = isLoading || !assistant || hasPendingInterrupt;
const handleSubmit = useCallback(
(e?: FormEvent) => {
@@ -145,6 +160,28 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
});
}, [autoApprove, interrupt, resumeInterrupt]);
// ask_user: the agent is asking the user structured questions.
const askUserQuestions = useMemo<AskUserQuestion[] | null>(() => {
const value = interrupt?.value as
| { type?: string; questions?: AskUserQuestion[] }
| undefined;
if (value?.type === "ask_user" && Array.isArray(value.questions)) {
return value.questions;
}
return null;
}, [interrupt]);
const handleAskUserSubmit = useCallback(
(answers: string[]) => {
resumeInterrupt({ status: "answered", answers });
},
[resumeInterrupt]
);
const handleAskUserCancel = useCallback(() => {
resumeInterrupt({ status: "cancelled" });
}, [resumeInterrupt]);
// TODO: can we make this part of the hook?
const processedMessages = useMemo(() => {
/*
@@ -314,9 +351,20 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
onResumeInterrupt={resumeInterrupt}
graphId={assistant?.graph_id}
onEditMessage={handleEditMessage}
autoApprove={autoApprove}
/>
);
})}
{askUserQuestions && (
<div className="mt-4">
<AskUserInterrupt
questions={askUserQuestions}
onSubmit={handleAskUserSubmit}
onCancel={handleAskUserCancel}
isLoading={isLoading}
/>
</div>
)}
</>
)}
</div>
@@ -547,8 +595,15 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-label="Message"
placeholder={isLoading ? "Running…" : "Write your message…"}
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
disabled={hasPendingInterrupt}
placeholder={
hasPendingInterrupt
? "Respond to the request above to continue…"
: isLoading
? "Running…"
: "Write your message…"
}
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary disabled:cursor-not-allowed"
rows={1}
/>
<div className="flex items-center justify-between gap-2 p-3">
+3
View File
@@ -29,6 +29,7 @@ interface ChatMessageProps {
onResumeInterrupt?: (value: any) => void;
graphId?: string;
onEditMessage?: (content: string) => void;
autoApprove?: boolean;
}
export const ChatMessage = React.memo<ChatMessageProps>(
@@ -43,6 +44,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
onResumeInterrupt,
graphId,
onEditMessage,
autoApprove,
}) => {
const isUser = message.type === "human";
const messageContent = extractStringFromMessageContent(message);
@@ -244,6 +246,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
reviewConfig={reviewConfig}
onResume={onResumeInterrupt}
isLoading={isLoading}
autoApprove={autoApprove}
/>
);
})}
+24 -6
View File
@@ -1,6 +1,12 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from "react";
import {
ChevronDown,
ChevronUp,
@@ -25,6 +31,7 @@ interface ToolCallBoxProps {
reviewConfig?: ReviewConfig;
onResume?: (value: any) => void;
isLoading?: boolean;
autoApprove?: boolean;
}
export const ToolCallBox = React.memo<ToolCallBoxProps>(
@@ -37,19 +44,30 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
reviewConfig,
onResume,
isLoading,
autoApprove,
}) => {
const [isExpanded, setIsExpanded] = useState(
() => !!uiComponent || !!actionRequest
() => !!uiComponent || (!!actionRequest && !autoApprove)
);
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
{}
);
// Approval interrupts arrive after the tool box has already rendered, so
// auto-expand once an action request appears (or generative UI loads).
// Generative UI: expand to show it.
useEffect(() => {
if (actionRequest || uiComponent) setIsExpanded(true);
}, [actionRequest, uiComponent]);
if (uiComponent) setIsExpanded(true);
}, [uiComponent]);
// Approval lifecycle: expand when an approval appears (skip if auto-approve
// is on), and collapse again once it's resolved.
const prevActionRequestRef = useRef(actionRequest);
useEffect(() => {
const had = !!prevActionRequestRef.current;
const has = !!actionRequest;
if (has && !had && !autoApprove) setIsExpanded(true);
else if (!has && had) setIsExpanded(false);
prevActionRequestRef.current = actionRequest;
}, [actionRequest, autoApprove]);
const { name, args, result, status } = useMemo(() => {
return {