返回 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 { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, X } from "lucide-react";
4 import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
5 import { createInteractionDraftStore } from "../lib/interactionDraftStore";
6 import { usePromptStop } from "../lib/usePromptStop";
7 import {
8 DecisionConfirmBar,
9 PromptAction,
10 PromptDescriptionDisclosure,
11 PromptHeaderAction,
12 PromptShelf,
13 } from "./PromptShelf";
14
15 const askDraftKey = (scope: string, ask: WireAsk) =>
16 `${scope}\u0000${JSON.stringify([ask.runtimeEpoch ?? "", ask.turnId ?? "", ask.id])}`;
17 type AnswerMode = "option" | "custom";
18 type AskDraft = {
19 sel: Record<string, string[]>;
20 custom: Record<string, string>;
21 answerMode: Record<string, AnswerMode>;
22 active: number;
23 selectedIndex: number;
24 };
25 const askDrafts = createInteractionDraftStore<AskDraft>();
26
27 function readAskDraft(key: string): AskDraft | undefined {
28 const draft = askDrafts.read(key);
29 return draft ? { ...draft, sel: { ...draft.sel }, custom: { ...draft.custom }, answerMode: { ...draft.answerMode } } : undefined;
30 }
31
32 function clearAskDraft(key: string): void {
33 askDrafts.delete(key);
34 }
35
36 // AskCard renders the `ask` tool as a decision shelf near the composer. It
37 // walks multi-question asks one at a time. Single-select choices advance to
38 // the next question immediately; multi-select and custom answers wait for an
39 // explicit confirm, and the final question still requires submission.
40 type AskCardProps = {
41 ask: WireAsk;
42 onAnswer: (id: string, answers: QuestionAnswer[]) => void | Promise<void>;
43 onDismiss?: () => void | Promise<void>;
44 draftScope: string;
45 onStop: () => void | Promise<void>;
46 };
47
48 export function AskCard(props: AskCardProps) {
49 const draftKey = askDraftKey(props.draftScope, props.ask);
50 // The same identity owns both cached drafts and live component state. A
51 // controller can reuse prompt IDs, so changing runtime or turn remounts it.
52 return <AskCardBody key={draftKey} {...props} draftKey={draftKey} />;
53 }
54
55 function AskCardBody({ ask, onAnswer, onStop, draftKey }: AskCardProps & { draftKey: string }) {
56 const t = useT();
57 // Per-question state: selected option labels, and an optional typed answer.
58 const [sel, setSel] = useState<Record<string, string[]>>(() => readAskDraft(draftKey)?.sel ?? {});
59 const [custom, setCustom] = useState<Record<string, string>>(() => readAskDraft(draftKey)?.custom ?? {});
60 const [answerMode, setAnswerMode] = useState<Record<string, AnswerMode>>(() => readAskDraft(draftKey)?.answerMode ?? {});
61 const [customOpen, setCustomOpen] = useState(false);
62 const [active, setActive] = useState(() => readAskDraft(draftKey)?.active ?? 0);
63 // Extra decision row after option labels: custom answer. Skip is a
64 // secondary footer action rather than an answer choice.
65 const [selectedIndex, setSelectedIndex] = useState(() => readAskDraft(draftKey)?.selectedIndex ?? 0);
66 const [expandedDescriptionId, setExpandedDescriptionId] = useState<string | null>(null);
67 const [descriptionTruncated, setDescriptionTruncated] = useState(false);
68 const [answerPending, setSubmitting] = useState(false);
69 const { stopping, stopFailed, stopTask: stopAsk } = usePromptStop(() =>
70 Promise.resolve(onStop()).then(() => clearAskDraft(draftKey)));
71 const submitting = answerPending || stopping;
72 // A newly delivered Ask always starts expanded, matching harness. Collapse
73 // is presentation state and must not leak from an earlier request.
74 const [collapsed, setCollapsed] = useState(false);
75 const shelfRef = useRef<HTMLDivElement | null>(null);
76 const customInputRef = useRef<HTMLInputElement | null>(null);
77 const initializedActiveRef = useRef(false);
78 const instanceId = useId();
79
80 const questions = ask.questions;
81 const q = questions[Math.min(active, questions.length - 1)];
82 const isLast = active >= questions.length - 1;
83 const progress = `${Math.min(active + 1, questions.length)}/${questions.length}`;
84 const hasMultipleQuestions = questions.length > 1;
85
86 // Row layout: [options...] [custom]
87 const optionCount = q?.options.length ?? 0;
88 const customRowIndex = optionCount;
89 const rowCount = optionCount + 1;
90 const selectedOption = selectedIndex >= 0 && selectedIndex < optionCount
91 ? q?.options[selectedIndex]
92 : undefined;
93 const selectedDescriptionId = selectedOption
94 ? `${instanceId}-description-${selectedIndex}`
95 : undefined;
96 const descriptionExpanded = selectedDescriptionId !== undefined && expandedDescriptionId === selectedDescriptionId;
97
98 useEffect(() => {
99 shelfRef.current?.focus();
100 }, []);
101
102 useEffect(() => {
103 try {
104 askDrafts.write(draftKey, { sel: { ...sel }, custom: { ...custom }, answerMode: { ...answerMode }, active, selectedIndex });
105 } catch {
106 // Draft storage is best effort; the live pending ask remains authoritative.
107 }
108 }, [active, answerMode, custom, draftKey, selectedIndex, sel]);
109
110 useEffect(() => {
111 setCustomOpen(false);
112 if (initializedActiveRef.current) setSelectedIndex(0);
113 initializedActiveRef.current = true;
114 }, [active]);
115
116 useEffect(() => {
117 setExpandedDescriptionId(null);
118 }, [active, ask.id]);
119
120 useEffect(() => {
121 if (customOpen) customInputRef.current?.focus();
122 }, [customOpen]);
123
124 const answersFrom = (
125 nextSel: Record<string, string[]> = sel,
126 nextCustom: Record<string, string> = custom,
127 ): QuestionAnswer[] =>
128 questions.map((question) => ({
129 questionId: question.id,
130 selected: answerMode[question.id] === "custom" && nextCustom[question.id]?.trim()
131 ? [nextCustom[question.id].trim()]
132 : (nextSel[question.id] ?? []),
133 }));
134
135 const answerLabel = (question: WireAskQuestion) => {
136 if (answerMode[question.id] === "custom") return custom[question.id]?.trim() ?? "";
137 return (sel[question.id] ?? []).join(", ");
138 };
139
140 const answered = (question: WireAskQuestion) => answerMode[question.id] === "custom"
141 ? (custom[question.id]?.trim() ?? "") !== ""
142 : (sel[question.id]?.length ?? 0) > 0;
143
144 const currentAnswered = q ? answered(q) : false;
145
146 const submitAction = (action: () => void | Promise<void>) => {
147 if (submitting) return;
148 setSubmitting(true);
149 void Promise.resolve()
150 .then(action)
151 .catch(() => setSubmitting(false));
152 };
153
154 const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
155 if (submitting) return;
156 if (isLast) {
157 submitAction(() => Promise.resolve(onAnswer(ask.id, answersFrom(nextSel, nextCustom))).then(() => {
158 clearAskDraft(draftKey);
159 }));
160 return;
161 }
162 setActive((i) => Math.min(i + 1, questions.length - 1));
163 };
164
165 const toggleOption = (question: WireAskQuestion, label: string) => {
166 if (submitting) return;
167 const cur = sel[question.id] ?? [];
168 const nextSel = question.multi
169 ? { ...sel, [question.id]: cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label] }
170 : { ...sel, [question.id]: [label] };
171
172 setAnswerMode((m) => ({ ...m, [question.id]: "option" }));
173 setSel(nextSel);
174 setCustomOpen(false);
175 };
176
177 const setTyped = (question: WireAskQuestion, text: string) => {
178 setSelectedIndex(customRowIndex);
179 setAnswerMode((m) => ({ ...m, [question.id]: "custom" }));
180 setCustom((c) => ({ ...c, [question.id]: text }));
181 if (text.trim()) setSel((s) => ({ ...s, [question.id]: [] }));
182 };
183
184 const goBack = () => {
185 if (submitting) return;
186 setActive((i) => Math.max(0, i - 1));
187 };
188
189 const skipCurrentQuestion = () => {
190 if (submitting || !q) return;
191 const nextSel = { ...sel, [q.id]: [] };
192 const nextCustom = { ...custom, [q.id]: "" };
193 setSel(nextSel);
194 setCustom(nextCustom);
195 setCustomOpen(false);
196 if (!isLast) {
197 setActive((i) => i + 1);
198 return;
199 }
200 submitAction(() => Promise.resolve(onAnswer(ask.id, answersFrom(nextSel, nextCustom))).then(() => {
201 clearAskDraft(draftKey);
202 }));
203 };
204
205 const selectRow = (index: number) => {
206 if (submitting || !q) return;
207 setSelectedIndex(index);
208 if (index < optionCount) {
209 const option = q.options[index];
210 if (!option) return;
211 if (q.multi) {
212 toggleOption(q, option.label);
213 } else {
214 // Single-select follows harness behavior: choose and advance, while
215 // keeping the answer in the draft so Back can revise it.
216 setAnswerMode((m) => ({ ...m, [q.id]: "option" }));
217 setSel((s) => ({ ...s, [q.id]: [option.label] }));
218 setCustomOpen(false);
219 if (active < questions.length - 1) setActive((i) => i + 1);
220 }
221 } else if (index === customRowIndex) {
222 // Opening custom clears option picks for this question.
223 setAnswerMode((m) => ({ ...m, [q.id]: "custom" }));
224 setCustomOpen(true);
225 setSel((s) => ({ ...s, [q.id]: [] }));
226 }
227 };
228
229 const canConfirm = (): boolean => {
230 if (!q || submitting) return false;
231 if (selectedIndex === customRowIndex) {
232 return Boolean(custom[q.id]?.trim());
233 }
234 // Multi-select: answers come from checked options / typed custom, not the
235 // keyboard cursor alone.
236 if (q.multi) return currentAnswered;
237 // Single-select: the keyboard cursor is authoritative for option rows so
238 // initial Enter and ArrowDown+Enter work without a prior click.
239 if (selectedIndex >= 0 && selectedIndex < optionCount) return true;
240 return (sel[q.id]?.length ?? 0) > 0;
241 };
242
243 const confirmSelected = () => {
244 if (!q || submitting || !canConfirm()) return;
245 if (selectedIndex === customRowIndex) {
246 finishOrAdvance();
247 return;
248 }
249 // Ensure the highlighted option is reflected for single-select.
250 if (!q.multi && selectedIndex < optionCount) {
251 const option = q.options[selectedIndex];
252 if (option) {
253 const nextSel = { ...sel, [q.id]: [option.label] };
254 const nextCustom = { ...custom, [q.id]: "" };
255 setSel(nextSel);
256 setCustom(nextCustom);
257 finishOrAdvance(nextSel, nextCustom);
258 return;
259 }
260 }
261 finishOrAdvance();
262 };
263
264 useEffect(() => {
265 const onKeyDown = (event: globalThis.KeyboardEvent) => {
266 if (event.key === "Escape" && submitting) {
267 event.preventDefault();
268 stopAsk();
269 return;
270 }
271 if (submitting || !q) return;
272 const target = event.target instanceof Element ? event.target : null;
273 const tag = target?.tagName.toLowerCase();
274 if (tag === "input" || tag === "textarea" || (target instanceof HTMLElement && target.isContentEditable)) return;
275
276 if (event.key === "Escape") {
277 event.preventDefault();
278 stopAsk();
279 return;
280 }
281 if (event.key === "ArrowUp") {
282 event.preventDefault();
283 setSelectedIndex((i) => (i - 1 + rowCount) % rowCount);
284 return;
285 }
286 if (event.key === "ArrowDown") {
287 event.preventDefault();
288 setSelectedIndex((i) => (i + 1) % rowCount);
289 return;
290 }
291 if (event.key === "Enter") {
292 event.preventDefault();
293 confirmSelected();
294 return;
295 }
296 if ((event.key === "ArrowLeft" || event.key === "Backspace") && active > 0) {
297 event.preventDefault();
298 goBack();
299 return;
300 }
301
302 const index = Number(event.key) - 1;
303 if (!Number.isInteger(index) || index < 0 || index >= optionCount) return;
304 event.preventDefault();
305 selectRow(index);
306 };
307 document.addEventListener("keydown", onKeyDown);
308 return () => document.removeEventListener("keydown", onKeyDown);
309 });
310
311 const answeredSummary = useMemo(
312 () =>
313 questions
314 .slice(0, active)
315 .map((question) => answerLabel(question))
316 .filter(Boolean),
317 [active, custom, questions, sel],
318 );
319
320 if (!q) return null;
321
322 const confirmLabel = isLast
323 ? t("common.submit")
324 : t("ask.next");
325
326 return (
327 <PromptShelf
328 decision
329 className="prompt-shelf--ask"
330 cardCollapsible
331 collapsed={collapsed}
332 onToggleCollapse={() => setCollapsed((value) => !value)}
333 barRef={shelfRef}
334 titleId="ask-shelf-title"
335 title={q.header ?? t("ask.title")}
336 badges={
337 <span className="ask-shelf__header-meta">
338 {hasMultipleQuestions && (
339 <span className="ask-shelf__header-text ask-shelf__header-text--progress">
340 {t("ask.questionProgress", { progress })}
341 </span>
342 )}
343 </span>
344 }
345 meta={q.prompt}
346 headerActions={
347 <>
348 <PromptHeaderAction
349 onClick={() => setCollapsed((value) => !value)}
350 ariaLabel={collapsed ? t("common.expand") : t("common.collapse")}
351 disabled={submitting}
352 >
353 {collapsed ? <ChevronUp size={15} aria-hidden="true" /> : <ChevronDown size={15} aria-hidden="true" />}
354 </PromptHeaderAction>
355 <PromptHeaderAction onClick={stopAsk} ariaLabel={t("decision.stopTask")} disabled={stopping}>
356 <X size={16} aria-hidden="true" />
357 </PromptHeaderAction>
358 </>
359 }
360 actions={
361 <>
362 {q.options.map((o, index) => {
363 const on = (sel[q.id] ?? []).includes(o.label);
364 const cursor = selectedIndex === index;
365 return (
366 <PromptAction
367 key={o.label}
368 actionId={`${instanceId}-row-${index}`}
369 keyLabel={q.options.length <= 9 ? String(index + 1) : ""}
370 label={o.label}
371 description={o.description}
372 descriptionId={`${instanceId}-description-${index}`}
373 descriptionDisclosure
374 onDescriptionOverflowChange={selectedIndex === index ? setDescriptionTruncated : undefined}
375 onClick={() => selectRow(index)}
376 // Single-select: cursor owns selection. Multi-select: selected
377 // means checked; active is the keyboard cursor only.
378 selected={q.multi ? on : cursor}
379 active={q.multi ? cursor : false}
380 disabled={submitting}
381 />
382 );
383 })}
384 <div
385 className={`ask-shelf__custom-row${custom[q.id]?.trim() ? " ask-shelf__custom-row--active" : ""}`}
386 role="group"
387 onClick={() => {
388 setSelectedIndex(customRowIndex);
389 setAnswerMode((m) => ({ ...m, [q.id]: "custom" }));
390 setCustomOpen(true);
391 customInputRef.current?.focus();
392 }}
393 >
394 <span className="ask-shelf__custom-indicator" aria-hidden="true">✎</span>
395 <input
396 ref={customInputRef}
397 className="ask-shelf__custom"
398 aria-label={t("ask.customAnswer")}
399 placeholder={t("ask.customPlaceholder")}
400 value={answerMode[q.id] === "custom" ? custom[q.id] ?? "" : ""}
401 disabled={submitting}
402 onFocus={() => setSelectedIndex(customRowIndex)}
403 onChange={(e) => setTyped(q, e.target.value)}
404 onKeyDown={(e) => {
405 if (e.key === "Enter" && canConfirm()) {
406 e.preventDefault();
407 confirmSelected();
408 }
409 e.stopPropagation();
410 }}
411 />
412 </div>
413 </>
414 }
415 crumbs={
416 answeredSummary.length > 0 && (
417 <div className="ask-shelf__crumbs">
418 {answeredSummary.map((answer, index) => (
419 <span className="ask-shelf__crumb" key={`${index}-${answer}`}>
420 {index + 1}. {answer}
421 </span>
422 ))}
423 </div>
424 )
425 }
426 note={
427 <>
428 {stopFailed && <p role="alert">{t("approval.submitFailed")}</p>}
429 {selectedDescriptionId && descriptionTruncated && (
430 <PromptDescriptionDisclosure
431 descriptionId={`${selectedDescriptionId}-detail`}
432 label={selectedOption?.label}
433 description={selectedOption?.description}
434 expanded={descriptionExpanded}
435 onToggle={() => setExpandedDescriptionId((current) => current === selectedDescriptionId ? null : selectedDescriptionId)}
436 disabled={submitting}
437 />
438 )}
439 </>
440 }
441 footer={
442 <div className="ask-shelf__footer-layout">
443 <div className="ask-shelf__pager" aria-label={t("ask.questionProgress", { progress })}>
444 <button type="button" className="ask-shelf__pager-button" aria-label={t("ask.back")} disabled={active === 0 || submitting} onClick={goBack}>
445 <ChevronLeft size={16} aria-hidden="true" />
446 </button>
447 <span>{progress}</span>
448 <button type="button" className="ask-shelf__pager-button" aria-label={t("ask.next")} disabled={isLast || submitting} onClick={() => setActive((i) => Math.min(i + 1, questions.length - 1))}>
449 <ChevronRight size={16} aria-hidden="true" />
450 </button>
451 </div>
452 <DecisionConfirmBar
453 hint={t("decision.selectHint")}
454 confirmLabel={confirmLabel}
455 onConfirm={confirmSelected}
456 secondaryLabel={t("ask.skipQuestion")}
457 onSecondary={skipCurrentQuestion}
458 disabled={submitting}
459 confirmDisabled={!canConfirm()}
460 />
461 </div>
462 }
463 />
464 );
465 }
466
466 lines Plain Text