返回 DeepSeek-Reasonix
AskCard.tsx
根目录 / desktop / frontend / src / components / AskCard.tsx
1 import { useEffect, useId, useMemo, useRef, useState } from "react";
2 import { useT } from "../lib/i18n";
3 import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
4 import {
5 DecisionConfirmBar,
6 PromptAction,
7 PromptDescriptionDisclosure,
8 PromptHeaderAction,
9 PromptShelf,
10 } from "./PromptShelf";
11
12 // AskCard renders the `ask` tool as a decision shelf near the composer. It
13 // walks multi-question asks one at a time. Selecting (click / digit) never
14 // advances; Enter / Confirm submits or moves to the next question.
15 export function AskCard({
16 ask,
17 onAnswer,
18 onDismiss,
19 onStop,
20 }: {
21 ask: WireAsk;
22 onAnswer: (id: string, answers: QuestionAnswer[]) => void;
23 onDismiss: () => void;
24 onStop: () => void;
25 }) {
26 const t = useT();
27 // Per-question state: selected option labels, and an optional typed answer.
28 const [sel, setSel] = useState<Record<string, string[]>>({});
29 const [custom, setCustom] = useState<Record<string, string>>({});
30 const [customOpen, setCustomOpen] = useState(false);
31 const [active, setActive] = useState(0);
32 // Extra decision row after option labels: custom answer. Skip is a
33 // secondary footer action rather than an answer choice.
34 const [selectedIndex, setSelectedIndex] = useState(0);
35 const [expandedDescriptionId, setExpandedDescriptionId] = useState<string | null>(null);
36 const [descriptionTruncated, setDescriptionTruncated] = useState(false);
37 const [submitting, setSubmitting] = useState(false);
38 const shelfRef = useRef<HTMLDivElement | null>(null);
39 const customInputRef = useRef<HTMLInputElement | null>(null);
40 const instanceId = useId();
41
42 const questions = ask.questions;
43 const q = questions[Math.min(active, questions.length - 1)];
44 const isLast = active >= questions.length - 1;
45 const progress = `${Math.min(active + 1, questions.length)}/${questions.length}`;
46 const hasMultipleQuestions = questions.length > 1;
47
48 // Row layout: [options...] [custom]
49 const optionCount = q?.options.length ?? 0;
50 const customRowIndex = optionCount;
51 const rowCount = optionCount + 1;
52 const selectedOption = selectedIndex >= 0 && selectedIndex < optionCount
53 ? q?.options[selectedIndex]
54 : undefined;
55 const selectedDescriptionId = selectedOption
56 ? `${instanceId}-description-${selectedIndex}`
57 : undefined;
58 const descriptionExpanded = selectedDescriptionId !== undefined && expandedDescriptionId === selectedDescriptionId;
59
60 useEffect(() => {
61 shelfRef.current?.focus();
62 setSel({});
63 setCustom({});
64 setCustomOpen(false);
65 setActive(0);
66 setSelectedIndex(0);
67 setSubmitting(false);
68 }, [ask.id]);
69
70 useEffect(() => {
71 setCustomOpen(false);
72 setSelectedIndex(0);
73 }, [active]);
74
75 useEffect(() => {
76 setExpandedDescriptionId(null);
77 }, [active, ask.id]);
78
79 useEffect(() => {
80 if (customOpen) customInputRef.current?.focus();
81 }, [customOpen]);
82
83 const answersFrom = (
84 nextSel: Record<string, string[]> = sel,
85 nextCustom: Record<string, string> = custom,
86 ): QuestionAnswer[] =>
87 questions.map((question) => ({
88 questionId: question.id,
89 selected: nextCustom[question.id]?.trim() ? [nextCustom[question.id].trim()] : (nextSel[question.id] ?? []),
90 }));
91
92 const answerLabel = (question: WireAskQuestion) => {
93 const typed = custom[question.id]?.trim();
94 if (typed) return typed;
95 return (sel[question.id] ?? []).join(", ");
96 };
97
98 const answered = (question: WireAskQuestion) =>
99 (sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
100
101 const currentAnswered = q ? answered(q) : false;
102
103 const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
104 if (submitting) return;
105 if (isLast) {
106 setSubmitting(true);
107 onAnswer(ask.id, answersFrom(nextSel, nextCustom));
108 return;
109 }
110 setActive((i) => Math.min(i + 1, questions.length - 1));
111 };
112
113 const toggleOption = (question: WireAskQuestion, label: string) => {
114 if (submitting) return;
115 const nextCustom = { ...custom, [question.id]: "" };
116 const cur = sel[question.id] ?? [];
117 const nextSel = question.multi
118 ? { ...sel, [question.id]: cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label] }
119 : { ...sel, [question.id]: [label] };
120
121 setCustom(nextCustom);
122 setSel(nextSel);
123 setCustomOpen(false);
124 };
125
126 const setTyped = (question: WireAskQuestion, text: string) => {
127 setCustom((c) => ({ ...c, [question.id]: text }));
128 if (text.trim()) setSel((s) => ({ ...s, [question.id]: [] }));
129 };
130
131 const goBack = () => {
132 if (submitting) return;
133 setActive((i) => Math.max(0, i - 1));
134 };
135
136 const selectRow = (index: number) => {
137 if (submitting || !q) return;
138 setSelectedIndex(index);
139 if (index < optionCount) {
140 const option = q.options[index];
141 if (!option) return;
142 if (q.multi) {
143 toggleOption(q, option.label);
144 } else {
145 // Single-select: click/digit only selects the row and marks the option.
146 setCustom((c) => ({ ...c, [q.id]: "" }));
147 setSel((s) => ({ ...s, [q.id]: [option.label] }));
148 setCustomOpen(false);
149 }
150 } else if (index === customRowIndex) {
151 // Opening custom clears option picks for this question.
152 setCustomOpen(true);
153 setSel((s) => ({ ...s, [q.id]: [] }));
154 }
155 };
156
157 const canConfirm = (): boolean => {
158 if (!q || submitting) return false;
159 if (selectedIndex === customRowIndex) {
160 return Boolean(custom[q.id]?.trim());
161 }
162 // Multi-select: answers come from checked options / typed custom, not the
163 // keyboard cursor alone.
164 if (q.multi) return currentAnswered;
165 // Single-select: the keyboard cursor is authoritative for option rows so
166 // initial Enter and ArrowDown+Enter work without a prior click.
167 if (selectedIndex >= 0 && selectedIndex < optionCount) return true;
168 return (sel[q.id]?.length ?? 0) > 0;
169 };
170
171 const confirmSelected = () => {
172 if (!q || submitting || !canConfirm()) return;
173 if (selectedIndex === customRowIndex) {
174 finishOrAdvance();
175 return;
176 }
177 // Ensure the highlighted option is reflected for single-select.
178 if (!q.multi && selectedIndex < optionCount) {
179 const option = q.options[selectedIndex];
180 if (option) {
181 const nextSel = { ...sel, [q.id]: [option.label] };
182 const nextCustom = { ...custom, [q.id]: "" };
183 setSel(nextSel);
184 setCustom(nextCustom);
185 finishOrAdvance(nextSel, nextCustom);
186 return;
187 }
188 }
189 finishOrAdvance();
190 };
191
192 useEffect(() => {
193 const onKeyDown = (event: globalThis.KeyboardEvent) => {
194 if (submitting || !q) return;
195 const target = event.target instanceof Element ? event.target : null;
196 const tag = target?.tagName.toLowerCase();
197 if (tag === "input" || tag === "textarea" || (target instanceof HTMLElement && target.isContentEditable)) return;
198
199 if (event.key === "Escape") {
200 event.preventDefault();
201 onStop();
202 return;
203 }
204 if (event.key === "ArrowUp") {
205 event.preventDefault();
206 setSelectedIndex((i) => (i - 1 + rowCount) % rowCount);
207 return;
208 }
209 if (event.key === "ArrowDown") {
210 event.preventDefault();
211 setSelectedIndex((i) => (i + 1) % rowCount);
212 return;
213 }
214 if (event.key === "Enter") {
215 event.preventDefault();
216 confirmSelected();
217 return;
218 }
219 if ((event.key === "ArrowLeft" || event.key === "Backspace") && active > 0) {
220 event.preventDefault();
221 goBack();
222 return;
223 }
224
225 const index = Number(event.key) - 1;
226 if (!Number.isInteger(index) || index < 0 || index >= optionCount) return;
227 event.preventDefault();
228 selectRow(index);
229 };
230 document.addEventListener("keydown", onKeyDown);
231 return () => document.removeEventListener("keydown", onKeyDown);
232 });
233
234 const answeredSummary = useMemo(
235 () =>
236 questions
237 .slice(0, active)
238 .map((question) => answerLabel(question))
239 .filter(Boolean),
240 [active, custom, questions, sel],
241 );
242
243 if (!q) return null;
244
245 const confirmLabel = isLast
246 ? t("common.submit")
247 : t("ask.next");
248
249 return (
250 <PromptShelf
251 decision
252 className="prompt-shelf--ask"
253 barRef={shelfRef}
254 titleId="ask-shelf-title"
255 title={t("ask.title")}
256 badges={
257 <span className="ask-shelf__header-meta">
258 {q.header && <span className="ask-shelf__header-text">{q.header}</span>}
259 {hasMultipleQuestions && (
260 <span className="ask-shelf__header-text ask-shelf__header-text--progress">
261 {t("ask.questionProgress", { progress })}
262 </span>
263 )}
264 </span>
265 }
266 meta={q.prompt}
267 headerActions={
268 <PromptHeaderAction onClick={onStop} ariaLabel={t("decision.stopTask")} disabled={submitting}>
269 {t("decision.stopTask")}
270 </PromptHeaderAction>
271 }
272 actions={
273 <>
274 {q.options.map((o, index) => {
275 const on = (sel[q.id] ?? []).includes(o.label);
276 const cursor = selectedIndex === index;
277 return (
278 <PromptAction
279 key={o.label}
280 actionId={`${instanceId}-row-${index}`}
281 keyLabel={q.options.length <= 9 ? String(index + 1) : ""}
282 label={o.label}
283 description={o.description}
284 descriptionId={`${instanceId}-description-${index}`}
285 descriptionDisclosure
286 onDescriptionOverflowChange={selectedIndex === index ? setDescriptionTruncated : undefined}
287 onClick={() => selectRow(index)}
288 // Single-select: cursor owns selection. Multi-select: selected
289 // means checked; active is the keyboard cursor only.
290 selected={q.multi ? on : cursor}
291 active={q.multi ? cursor : false}
292 disabled={submitting}
293 />
294 );
295 })}
296 <PromptAction
297 actionId={`${instanceId}-row-${customRowIndex}`}
298 keyLabel=""
299 label={t("ask.customAnswer")}
300 onClick={() => selectRow(customRowIndex)}
301 selected={selectedIndex === customRowIndex || customOpen}
302 disabled={submitting}
303 />
304 </>
305 }
306 quickActions={
307 active > 0 ? (
308 <PromptAction keyLabel="" label={t("ask.back")} onClick={goBack} quiet disabled={submitting} role="button" />
309 ) : undefined
310 }
311 crumbs={
312 answeredSummary.length > 0 && (
313 <div className="ask-shelf__crumbs">
314 {answeredSummary.map((answer, index) => (
315 <span className="ask-shelf__crumb" key={`${index}-${answer}`}>
316 {index + 1}. {answer}
317 </span>
318 ))}
319 </div>
320 )
321 }
322 note={
323 <>
324 {selectedDescriptionId && descriptionTruncated && (
325 <PromptDescriptionDisclosure
326 descriptionId={`${selectedDescriptionId}-detail`}
327 label={selectedOption?.label}
328 description={selectedOption?.description}
329 expanded={descriptionExpanded}
330 onToggle={() => setExpandedDescriptionId((current) => current === selectedDescriptionId ? null : selectedDescriptionId)}
331 disabled={submitting}
332 />
333 )}
334 {customOpen && (
335 <div className="ask-shelf__custom-row">
336 <input
337 ref={customInputRef}
338 className="ask-shelf__custom"
339 placeholder={t("ask.customPlaceholder")}
340 value={custom[q.id] ?? ""}
341 disabled={submitting}
342 onChange={(e) => setTyped(q, e.target.value)}
343 onKeyDown={(e) => {
344 if (e.key === "Enter" && canConfirm()) {
345 e.preventDefault();
346 confirmSelected();
347 }
348 e.stopPropagation();
349 }}
350 />
351 </div>
352 )}
353 </>
354 }
355 footer={
356 <DecisionConfirmBar
357 hint={t("decision.selectHint")}
358 confirmLabel={confirmLabel}
359 onConfirm={confirmSelected}
360 secondaryLabel={t("ask.justChat")}
361 onSecondary={() => {
362 if (submitting) return;
363 setSubmitting(true);
364 onDismiss();
365 }}
366 disabled={submitting}
367 confirmDisabled={!canConfirm()}
368 />
369 }
370 />
371 );
372 }
373
373 lines Plain Text