diff --git a/src/components/ChatBox/BottomBox/index.tsx b/src/components/ChatBox/BottomBox/index.tsx index 6b77c7e4..25416b97 100644 --- a/src/components/ChatBox/BottomBox/index.tsx +++ b/src/components/ChatBox/BottomBox/index.tsx @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'; import { type ChatTaskStatusType } from '@/types/constants'; import { TriangleAlert } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import type { AskInputDescriptor } from '../ask/askPayload'; import { BoxHeaderConfirm, BoxHeaderSave } from './BoxHeader'; import { FileAttachment, Inputbox, InputboxProps } from './InputBox'; import { QueuedBox, QueuedMessage } from './QueuedBox'; @@ -23,6 +24,8 @@ import { UsageLimitBanner, type UsageLimitBannerProps, } from './UsageLimitBanner'; +import { QuestionVariant } from './variants/QuestionVariant'; +import { resolveBottomBoxVariant } from './variants/registry'; export type BottomBoxState = | 'input' @@ -60,6 +63,15 @@ interface BottomBoxProps { inputProps: Omit & { className?: string }; usageLimitBanner?: UsageLimitBannerProps | null; + /** + * Active structured ask awaiting an answer. When present and non-text, the + * BottomBox switches to the question variant (question + option buttons) + * instead of the free-text composer. + */ + askInput?: AskInputDescriptor | null; + /** Submit a structured ask answer. `reply` is canonical, `display` is echoed. */ + onAnswerAsk?: (reply: string, display?: string) => void | Promise; + // Loading states loading?: boolean; @@ -79,6 +91,8 @@ export default function BottomBox({ onEdit, inputProps, usageLimitBanner, + askInput = null, + onAnswerAsk, loading = false, noModelOverlay = false, onSelectModel, @@ -86,16 +100,20 @@ export default function BottomBox({ const { t } = useTranslation(); const enableQueuedBox = true; //TODO: Fix the reason of queued box disable in https://github.com/eigent-ai/eigent/issues/684 + // Which input affordance to render inside the box shell. + const variant = resolveBottomBoxVariant({ ask: askInput }); + const isQuestion = variant === 'question' && !!askInput && !!onAnswerAsk; + // Background color reflects current state only let backgroundClass = 'bg-ds-bg-neutral-subtle-default'; if (state === 'confirm' || state === 'save') backgroundClass = 'bg-ds-bg-completed-subtle-default'; return ( -
+
{/* QueuedBox overlay (should not affect BoxMain layout) */} {enableQueuedBox && queuedMessages.length > 0 && ( -
+
- {/* BoxHeader variants */} - {state === 'confirm' && ( - - )} - {state === 'save' && ( - - )} - - {/* Inputbox (always visible) */} {usageLimitBanner && } - + + {isQuestion ? ( + // Question-and-answer mode: the model's ask drives the input surface. + + ) : ( + <> + {/* BoxHeader variants */} + {state === 'confirm' && ( + + )} + {state === 'save' && ( + + )} + + {/* Inputbox (default free-text composer) */} + + + )} {noModelOverlay && onSelectModel ? (

diff --git a/src/components/ChatBox/BottomBox/variants/QuestionVariant.tsx b/src/components/ChatBox/BottomBox/variants/QuestionVariant.tsx new file mode 100644 index 00000000..387b2f02 --- /dev/null +++ b/src/components/ChatBox/BottomBox/variants/QuestionVariant.tsx @@ -0,0 +1,270 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * BottomBox "question and answer" wizard. + * + * All ask kinds are normalised into a flat list of 1..N questions and + * shown one at a time. Layout per question: + * + * ┌─────────────────────────────────────────┐ + * │ [1 / 4] Question text here … │ + * │ │ + * │ [Option A] │ + * │ [Option B] ← or textarea for text │ + * │ │ + * │ [← Back] [Next →] │ + * └─────────────────────────────────────────┘ + * + * Calls `onAnswer(reply, display)` exactly once when the user reaches + * the last question and submits. + */ + +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; +import { + labelForOption, + type AskInputDescriptor, + type AskOption, +} from '../../ask/askPayload'; + +export interface QuestionVariantProps { + ask: AskInputDescriptor; + onAnswer: (reply: string, display?: string) => void | Promise; + disabled?: boolean; +} + +// ── Internal wizard question shape ────────────────────────────────────────── + +interface WizardQuestion { + id: string; + text: string; + inputKind: 'text' | 'single' | 'multi' | 'confirm'; + options: AskOption[]; +} + +type AnswerMap = Record; + +/** Flatten any AskInputDescriptor into a 1..N question list. */ +function normalizeQuestions(ask: AskInputDescriptor): WizardQuestion[] { + if (ask.kind === 'followup' && ask.questions?.length) { + return ask.questions.map((q) => ({ + id: q.id, + text: q.prompt, + inputKind: q.inputKind, + options: q.options, + })); + } + // Single-question kinds. + const options = + ask.options && ask.options.length > 0 + ? ask.options + : ask.kind === 'confirm' + ? [ + { value: 'yes', label: 'Yes' }, + { value: 'no', label: 'No' }, + ] + : []; + return [ + { + id: '__root__', + text: ask.question, + inputKind: + ask.kind === 'confirm' + ? 'confirm' + : ask.kind === 'multi' + ? 'multi' + : ask.kind === 'text' + ? 'text' + : 'single', + options, + }, + ]; +} + +function hasAnswer(q: WizardQuestion, answers: AnswerMap): boolean { + const a = answers[q.id]; + if (!a) return false; + return q.inputKind === 'text' + ? (a.text ?? '').trim().length > 0 + : (a.selected ?? []).length > 0; +} + +function buildReply( + ask: AskInputDescriptor, + questions: WizardQuestion[], + answers: AnswerMap +): { reply: string; display: string } { + if (ask.kind === 'followup') { + const payload = questions.map((q) => { + const a = answers[q.id] ?? { selected: [], text: '' }; + const labels = + q.inputKind === 'text' + ? [a.text] + : a.selected.map((v) => labelForOption(q.options, v)); + return { question: q.text, answer: labels }; + }); + const display = payload + .map((p) => `${p.question} → ${p.answer.join(', ')}`) + .join('\n'); + return { reply: JSON.stringify(payload), display }; + } + + const a = answers['__root__'] ?? { selected: [], text: '' }; + if (ask.kind === 'text') return { reply: a.text, display: a.text }; + const labels = a.selected.map((v) => labelForOption(ask.options ?? [], v)); + return { reply: a.selected.join(', '), display: labels.join(', ') }; +} + +// ── Styles ─────────────────────────────────────────────────────────────────── + +const optionBase = + 'w-full rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors disabled:opacity-50'; +const optionIdle = + 'bg-ds-bg-neutral-subtle-default text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-hover'; +const optionSelected = + 'bg-ds-bg-information-subtle-default text-ds-text-information-default-default'; + +// ── Main component ──────────────────────────────────────────────────────────── + +export function QuestionVariant({ + ask, + onAnswer, + disabled = false, +}: QuestionVariantProps) { + const [submitting, setSubmitting] = useState(false); + const [currentIdx, setCurrentIdx] = useState(0); + const [answers, setAnswers] = useState({}); + + const busy = disabled || submitting; + const questions = normalizeQuestions(ask); + const total = questions.length; + const current = questions[currentIdx]!; + const isLast = currentIdx === total - 1; + const answered = hasAnswer(current, answers); + + const selectedForCurrent = answers[current.id]?.selected ?? []; + const textForCurrent = answers[current.id]?.text ?? ''; + + const toggleOption = (value: string) => { + setAnswers((prev) => { + const cur = prev[current.id]?.selected ?? []; + const multi = current.inputKind === 'multi'; + const next = multi + ? cur.includes(value) + ? cur.filter((v) => v !== value) + : [...cur, value] + : [value]; + return { + ...prev, + [current.id]: { ...(prev[current.id] ?? { text: '' }), selected: next }, + }; + }); + }; + + const setTextAnswer = (text: string) => { + setAnswers((prev) => ({ + ...prev, + [current.id]: { ...(prev[current.id] ?? { selected: [] }), text }, + })); + }; + + const handleNext = async () => { + if (busy || !answered) return; + if (!isLast) { + setCurrentIdx((i) => i + 1); + return; + } + setSubmitting(true); + try { + const { reply, display } = buildReply(ask, questions, answers); + await onAnswer(reply, display); + } finally { + setSubmitting(false); + } + }; + + return ( +

+ {/* ── Header: number tag + question ── */} +
+ + {currentIdx + 1} / {total} + + + {current.text} + +
+ + {/* ── Answer controls ── */} + {current.inputKind === 'text' ? ( +