From 32c293f8c0bd97ba45e94ea0fdf4c1a195d66bf8 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sat, 6 Jun 2026 17:30:19 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(claude-code):=20add=20per-ques?= =?UTF-8?q?tion=20custom=20input=20to=20askUserQuestion=20(#15506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(claude-code): add per-question custom input to askUserQuestion Let users write their own answer as the trailing item in each question's option list, beside picking a numbered choice. Single-select treats the two as mutually exclusive; multi-select appends the custom text as an extra entry. Merged into the question's answer at submit, so the bridge formatter and completed Render need no changes. Draft round-trips via a __custom__: prefix on the existing askUserDraft map. Co-Authored-By: Claude Opus 4.8 * ♻️ refactor(claude-code): split askUserQuestion form & drop draft key prefix Break the single ~530-line AskUserQuestion.tsx into a folder: - draft.ts pure helpers (read/buildSubmitPayload/isQuestionAnswered) - useAskUserForm.ts all state + handlers + draft persistence - OptionCard.tsx / QuestionPanel.tsx presentational pieces - index.tsx thin view Also drop the `__custom__:` draft-key prefix: persist the draft as a typed object { picks, custom, escapeText, escapeActive } instead of a flat string-keyed map. The picks/custom split now lives in named fields, so the only sentinel left is `__freeform__` β€” and only in the submit payload, which is the actual bridge contract. No behaviour change. Co-Authored-By: Claude Opus 4.8 * πŸ› fix(claude-code): make AskUserDraft assignable to setInterventionDraft `setInterventionDraft` takes `Record`; an `interface` isn't assignable to it (open to declaration merging, so no implicit index signature). Switch `AskUserDraft` to a `type` alias, which is closed and satisfies the index signature. Fixes the tsgo TS2345 in CI. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- locales/en-US/tool.json | 1 + locales/zh-CN/tool.json | 1 + .../client/Intervention/AskUserQuestion.tsx | 523 ------------------ .../AskUserQuestion/OptionCard.tsx | 104 ++++ .../AskUserQuestion/QuestionPanel.tsx | 82 +++ .../Intervention/AskUserQuestion/draft.ts | 99 ++++ .../Intervention/AskUserQuestion/index.tsx | 176 ++++++ .../AskUserQuestion/useAskUserForm.ts | 245 ++++++++ src/locales/default/tool.ts | 1 + 9 files changed, 709 insertions(+), 523 deletions(-) delete mode 100644 packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx create mode 100644 packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion/OptionCard.tsx create mode 100644 packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion/QuestionPanel.tsx create mode 100644 packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion/draft.ts create mode 100644 packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion/index.tsx create mode 100644 packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion/useAskUserForm.ts diff --git a/locales/en-US/tool.json b/locales/en-US/tool.json index 00e60f875d..edd8e497d8 100644 --- a/locales/en-US/tool.json +++ b/locales/en-US/tool.json @@ -40,6 +40,7 @@ "agentMarketplace.render.alreadyInLibraryTag": "Already in library", "agentMarketplace.render.alreadyInLibrary_one": "{{count}} already in library", "agentMarketplace.render.alreadyInLibrary_other": "{{count}} already in library", + "claudeCode.askUserQuestion.customOption.placeholder": "Or write your own…", "claudeCode.askUserQuestion.escape.back": "Back to options", "claudeCode.askUserQuestion.escape.enter": "Or type directly", "claudeCode.askUserQuestion.escape.placeholder": "Type your answer here…", diff --git a/locales/zh-CN/tool.json b/locales/zh-CN/tool.json index 8604cad9f0..1379a1269a 100644 --- a/locales/zh-CN/tool.json +++ b/locales/zh-CN/tool.json @@ -40,6 +40,7 @@ "agentMarketplace.render.alreadyInLibraryTag": "ε·²εœ¨εΊ“δΈ­", "agentMarketplace.render.alreadyInLibrary_one": "{{count}} δΈͺε·²εœ¨εΊ“δΈ­", "agentMarketplace.render.alreadyInLibrary_other": "{{count}} δΈͺε·²εœ¨εΊ“δΈ­", + "claudeCode.askUserQuestion.customOption.placeholder": "ζˆ–θ‡ͺε·±θΎ“ε…₯…", "claudeCode.askUserQuestion.escape.back": "θΏ”ε›žι€‰ι‘Ή", "claudeCode.askUserQuestion.escape.enter": "ζˆ–η›΄ζŽ₯θΎ“ε…₯", "claudeCode.askUserQuestion.escape.placeholder": "εœ¨ζ­€θΎ“ε…₯δ½ ηš„ε›žε€β€¦", diff --git a/packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx b/packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx deleted file mode 100644 index 32a211d04a..0000000000 --- a/packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx +++ /dev/null @@ -1,523 +0,0 @@ -'use client'; - -import type { BuiltinInterventionProps } from '@lobechat/types'; -import { Button, Flexbox, Icon, Tabs, Text, TextArea } from '@lobehub/ui'; -import { createStaticStyles, cx } from 'antd-style'; -import { ArrowLeft, Check, PenLine, Send, X } from 'lucide-react'; -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useConversationStore } from '@/features/Conversation/store'; -import { dataSelectors } from '@/features/Conversation/store/slices/data/selectors'; -import { useChatStore } from '@/store/chat'; - -import type { AskUserQuestionArgs, AskUserQuestionItem } from '../../types'; - -/** - * Sentinel key the bridge formatter (`AskUserMcpServer.formatAnswerForCC`) - * looks for to detect escape-mode replies. When present, the payload is just - * `{ __freeform__: }` (multi-choice picks are intentionally absent) - * and the text is forwarded to CC verbatim β€” no `User answers:` framing. - * Matches the convention `lobe-user-interaction` already uses for its own - * "Or type directly" escape hatch. - */ -const FREEFORM_PAYLOAD_KEY = '__freeform__'; - -/** - * Server-side bridge timeout (matches `AskUserMcpServer.pendingTimeoutMs`). - * Not strictly synchronized β€” server is authoritative β€” but keeps the on-screen - * countdown close to reality without plumbing a deadline through every layer. - */ -const COUNTDOWN_MS = 5 * 60 * 1000; - -/** Key under tool message `pluginState` where in-progress draft answers live. */ -const DRAFT_PLUGIN_STATE_KEY = 'askUserDraft'; - -const formatRemaining = (msLeft: number): string => { - const totalSec = Math.max(0, Math.floor(msLeft / 1000)); - const min = Math.floor(totalSec / 60); - const sec = totalSec % 60; - return `${min}:${sec.toString().padStart(2, '0')}`; -}; - -const styles = createStaticStyles(({ css, cssVar }) => ({ - // "Or type directly" / "Back to options" link β€” slim secondary text that - // sits alongside Skip in the action bar; matches the - // `lobe-user-interaction` escape-toggle styling so the two flows feel - // like the same control. - escapeLink: css` - cursor: pointer; - - display: inline-flex; - gap: 4px; - align-items: center; - - transition: color 0.12s ease; - - &:hover { - color: ${cssVar.colorText}; - } - `, - // Card sits inline with the chat β€” no surrounding panel chrome. Hover - // tints the row so the stack reads as clickable; selection swaps to a - // filled `colorPrimaryBg` so the pick is visually weighty. - option: css` - cursor: pointer; - - padding-block: 10px; - padding-inline: 12px; - border-radius: 8px; - - transition: background 0.12s ease; - - &:hover { - background: ${cssVar.colorFillQuaternary}; - } - `, - optionCheck: css` - flex-shrink: 0; - color: ${cssVar.colorPrimary}; - `, - optionDescription: css` - font-size: 12px; - line-height: 1.45; - color: ${cssVar.colorTextSecondary}; - `, - // Neutral 1/2/3/4 chip β€” stays the same colour whether selected or not so - // the selection signal lives on the filled background + checkmark. - optionIndex: css` - flex-shrink: 0; - - box-sizing: border-box; - width: 22px; - height: 22px; - border-radius: 6px; - - font-family: ${cssVar.fontFamilyCode}; - font-size: 12px; - font-weight: 600; - line-height: 22px; - color: ${cssVar.colorTextSecondary}; - text-align: center; - - background: ${cssVar.colorFillTertiary}; - `, - optionLabel: css` - font-weight: 500; - `, - optionSelected: css` - background: ${cssVar.colorPrimaryBg}; - - &:hover { - background: ${cssVar.colorPrimaryBgHover}; - } - `, -})); - -interface OptionCardProps { - description?: string; - disabled?: boolean; - index: number; - label: string; - onToggle: () => void; - selected: boolean; -} - -/** - * One numbered option in a question. Outlined when picked, neutral otherwise; - * a right-side checkmark seals the selection so the state reads cleanly even - * with the number chip kept neutral. - */ -const OptionCard = memo( - ({ index, label, description, selected, disabled, onToggle }) => ( - { - if (!disabled) onToggle(); - }} - > - {index} - - {label} - {description && {description}} - - {selected && } - - ), -); - -OptionCard.displayName = 'CCAskUserQuestionOption'; - -interface QuestionPanelProps { - answer: string | string[] | undefined; - disabled: boolean; - onToggle: (q: AskUserQuestionItem, label: string) => void; - question: AskUserQuestionItem; -} - -const QuestionPanel = memo(({ question, answer, disabled, onToggle }) => { - const { t } = useTranslation('tool'); - const isOptionSelected = (label: string): boolean => - question.multiSelect ? Array.isArray(answer) && answer.includes(label) : answer === label; - - return ( - - - {question.header && {question.header}} - {question.multiSelect && ( - - {t('claudeCode.askUserQuestion.multiSelectTag')} - - )} - - {question.question} - - - {question.options.map((opt, optIdx) => ( - onToggle(question, opt.label)} - /> - ))} - - - ); -}); - -QuestionPanel.displayName = 'CCAskUserQuestionPanel'; - -/** - * CC AskUserQuestion intervention component. - * - * Pure form β€” `onInteractionAction` ({type:'submit'|'skip'}) is the only - * outbound side effect. The framework's `handleInteractionAction` (or the - * hetero branch the chat conversation wires up) is responsible for marking - * `pluginIntervention.status` and forwarding the answer to CC over IPC. - * - * Layout - * - One question β†’ renders the question + options directly, no tab strip. - * - Multiple questions β†’ top tab bar (Q1, Q2, …), one panel visible at a - * time. Picking an answer auto-advances to the next unanswered question - * so the user sweeps through without re-clicking the tabs. - * - * Draft persistence - * - Per-message state lives on the tool message's `pluginState.askUserDraft` - * (see `setInterventionDraft` in the chat store). HMR reloads, store - * re-mounts, and tab switches all keep the partial answers around β€” only - * a fresh `tool_use` (different toolCallId / messageId) starts blank. - */ -const AskUserQuestionIntervention = memo>( - ({ args, messageId, onInteractionAction }) => { - const { t } = useTranslation('tool'); - const questions = args?.questions ?? []; - - // Persisted draft (survives unmount / HMR / refresh) β€” read from the tool - // message's pluginState so the form stays where the user left it. - const persistedDraft = useConversationStore((s) => { - const msg = dataSelectors.getDbMessageById(messageId)(s); - return ( - msg?.pluginState as { [DRAFT_PLUGIN_STATE_KEY]?: Record } - )?.[DRAFT_PLUGIN_STATE_KEY]; - }); - const setInterventionDraft = useChatStore((s) => s.setInterventionDraft); - - // Persisted draft may carry both form picks and escape-mode text under - // `__freeform__`. Split them so `answers` only contains per-question - // picks and `escapeText` owns the freeform string β€” otherwise a stale - // `__freeform__` key would leak into the form-mode submit payload. - const [answers, setAnswers] = useState>(() => { - if (!persistedDraft) return {}; - const { [FREEFORM_PAYLOAD_KEY]: _, ...rest } = persistedDraft; - return rest; - }); - // Escape-mode mirrors `lobe-user-interaction`'s "Or type directly" - // toggle β€” options and freeform are mutually exclusive, not stacked. - // Persisted text under the `__freeform__` key restores the user back - // into escape mode on remount; an empty draft starts in form mode. - const [escapeText, setEscapeText] = useState(() => { - const v = persistedDraft?.[FREEFORM_PAYLOAD_KEY]; - return typeof v === 'string' ? v : ''; - }); - const [escapeActive, setEscapeActive] = useState(() => { - const v = persistedDraft?.[FREEFORM_PAYLOAD_KEY]; - return typeof v === 'string' && v.length > 0; - }); - const [submitting, setSubmitting] = useState(false); - const [activeTab, setActiveTab] = useState(() => { - // Resume on the first unanswered question so coming back lands the user - // where they left off rather than always at Q1. - const initial = persistedDraft ?? {}; - const firstUnanswered = questions.findIndex((q) => { - const a = initial[q.question]; - return q.multiSelect ? !Array.isArray(a) || a.length === 0 : !a; - }); - const idx = firstUnanswered >= 0 ? firstUnanswered : 0; - return String(idx); - }); - - // Mounted-time deadline; server has its own clock and will return - // isError if it expires first. Drift of a few seconds is fine. - const deadline = useMemo(() => Date.now() + COUNTDOWN_MS, []); - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(id); - }, []); - const expired = now >= deadline; - - const handleToggle = useCallback( - (q: AskUserQuestionItem, label: string) => { - setAnswers((prev) => { - let next: Record; - if (q.multiSelect) { - const current = (prev[q.question] as string[] | undefined) ?? []; - const updated = current.includes(label) - ? current.filter((x) => x !== label) - : [...current, label]; - next = { ...prev, [q.question]: updated }; - } else { - next = { ...prev, [q.question]: label }; - } - // Persist picks only. Form mode is mutually exclusive with escape - // mode, so we never co-mingle `__freeform__` into a form-mode draft. - setInterventionDraft(messageId, next); - - // Single-select auto-advance: if there's a next unanswered question, - // jump to it. Multi-select stays on the same panel so the user can - // toggle additional options. - if (!q.multiSelect && questions.length > 1) { - const nextUnanswered = questions.findIndex((qq, idx) => { - if (qq.question === q.question) return false; - const a = next[qq.question]; - if (idx < 0) return false; - return qq.multiSelect ? !Array.isArray(a) || a.length === 0 : !a; - }); - if (nextUnanswered >= 0) setActiveTab(String(nextUnanswered)); - } - return next; - }); - }, - [messageId, questions, setInterventionDraft], - ); - - /** - * Submit `payload` exactly as given. Used by both the explicit "Submit" - * button (with whatever the user picked) and the timeout fallback (with - * option 1 of each unanswered question merged in). - */ - const submitWith = useCallback( - async (payload: Record) => { - if (!onInteractionAction || submitting) return; - setSubmitting(true); - try { - await onInteractionAction({ payload, type: 'submit' }); - } catch (err) { - console.error('[AskUserQuestion] submit failed:', err); - setSubmitting(false); - } - }, - [onInteractionAction, submitting], - ); - - const handleEscapeTextChange = useCallback( - (value: string) => { - setEscapeText(value); - // Only persist the freeform text while escape mode is the active UI. - // Stale `__freeform__` entries left in the draft would re-arm escape - // mode on the next mount, which is not what the user signalled. - setInterventionDraft(messageId, { - ...answers, - [FREEFORM_PAYLOAD_KEY]: value, - }); - }, - [answers, messageId, setInterventionDraft], - ); - - const handleEscapeToggle = useCallback(() => { - setEscapeActive((prev) => { - const next = !prev; - // Mirror the toggle into the draft: turning escape ON saves the - // current text (so a refresh resumes here); turning it OFF strips - // `__freeform__` so the next mount lands back in form mode. - if (next) { - setInterventionDraft(messageId, { - ...answers, - [FREEFORM_PAYLOAD_KEY]: escapeText, - }); - } else { - setInterventionDraft(messageId, answers); - } - return next; - }); - }, [answers, escapeText, messageId, setInterventionDraft]); - - const handleSubmit = useCallback(() => { - if (escapeActive) { - // Escape mode is mutually exclusive with picks β€” send the text alone - // under `__freeform__`. Bridge formatter forwards it to CC verbatim. - void submitWith({ [FREEFORM_PAYLOAD_KEY]: escapeText.trim() }); - } else { - void submitWith(answers); - } - }, [answers, escapeActive, escapeText, submitWith]); - - const handleSkip = useCallback(async () => { - if (!onInteractionAction || submitting) return; - setSubmitting(true); - try { - await onInteractionAction({ type: 'skip' }); - } catch (err) { - console.error('[AskUserQuestion] skip failed:', err); - setSubmitting(false); - } - }, [onInteractionAction, submitting]); - - const allAnswered = useMemo( - () => - questions.every((q) => { - const a = answers[q.question]; - return q.multiSelect ? Array.isArray(a) && a.length > 0 : !!a; - }), - [answers, questions], - ); - - // Timeout fallback: when the countdown hits zero and the user hasn't - // submitted, fill in option 1 of each unanswered question and submit. - // Beats letting the server-side bridge time out into a `cancelled` - // result β€” the model gets a structured answer it can act on instead of - // a "user didn't respond" isError. Single-shot via `submitting` guard. - // - // Escape-mode special case: if the user is in escape mode with non-empty - // text when the countdown hits zero, submit that text as-is rather than - // discarding their work and falling back to option 1. - useEffect(() => { - if (!expired || submitting || questions.length === 0) return; - if (escapeActive && escapeText.trim().length > 0) { - void submitWith({ [FREEFORM_PAYLOAD_KEY]: escapeText.trim() }); - return; - } - const fallback: Record = { ...answers }; - for (const q of questions) { - const a = fallback[q.question]; - const unanswered = q.multiSelect ? !Array.isArray(a) || a.length === 0 : !a; - if (unanswered && q.options.length > 0) { - const first = q.options[0].label; - fallback[q.question] = q.multiSelect ? [first] : first; - } - } - void submitWith(fallback); - }, [expired, submitting, questions, answers, escapeActive, escapeText, submitWith]); - - const isMulti = questions.length > 1; - const activeQuestion = questions[Number(activeTab)] ?? questions[0]; - const isSubmitDisabled = escapeActive - ? !escapeText.trim() || submitting || expired - : !allAnswered || expired || submitting; - - return ( - - {!escapeActive && isMulti && ( - { - const a = answers[q.question]; - const done = q.multiSelect ? Array.isArray(a) && a.length > 0 : !!a; - return { - key: String(idx), - label: ( - - Q{idx + 1} - {done && } - - ), - }; - })} - onChange={(key) => setActiveTab(key as string)} - /> - )} - - {escapeActive ? ( -