feat(claude-code): add per-question custom input to askUserQuestion (#15506)

*  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 <noreply@anthropic.com>

* ♻️ 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__:<question>` 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 <noreply@anthropic.com>

* 🐛 fix(claude-code): make AskUserDraft assignable to setInterventionDraft

`setInterventionDraft` takes `Record<string, unknown>`; 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-06 17:30:19 +08:00
committed by GitHub
parent 6f5a633c9f
commit 32c293f8c0
9 changed files with 709 additions and 523 deletions
+1
View File
@@ -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…",
+1
View File
@@ -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": "在此输入你的回复…",
@@ -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__: <text> }` (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<OptionCardProps>(
({ index, label, description, selected, disabled, onToggle }) => (
<Flexbox
horizontal
align="center"
aria-selected={selected}
className={cx(styles.option, selected && styles.optionSelected)}
gap={12}
role="option"
onClick={() => {
if (!disabled) onToggle();
}}
>
<span className={styles.optionIndex}>{index}</span>
<Flexbox flex={1} gap={2}>
<Text className={styles.optionLabel}>{label}</Text>
{description && <span className={styles.optionDescription}>{description}</span>}
</Flexbox>
{selected && <Icon className={styles.optionCheck} icon={Check} size={16} />}
</Flexbox>
),
);
OptionCard.displayName = 'CCAskUserQuestionOption';
interface QuestionPanelProps {
answer: string | string[] | undefined;
disabled: boolean;
onToggle: (q: AskUserQuestionItem, label: string) => void;
question: AskUserQuestionItem;
}
const QuestionPanel = memo<QuestionPanelProps>(({ question, answer, disabled, onToggle }) => {
const { t } = useTranslation('tool');
const isOptionSelected = (label: string): boolean =>
question.multiSelect ? Array.isArray(answer) && answer.includes(label) : answer === label;
return (
<Flexbox gap={10}>
<Flexbox horizontal align="center" gap={8}>
{question.header && <Text type="secondary">{question.header}</Text>}
{question.multiSelect && (
<Text fontSize={12} type="secondary">
{t('claudeCode.askUserQuestion.multiSelectTag')}
</Text>
)}
</Flexbox>
<Text strong>{question.question}</Text>
<Flexbox gap={4} role="listbox">
{question.options.map((opt, optIdx) => (
<OptionCard
description={opt.description}
disabled={disabled}
index={optIdx + 1}
key={opt.label}
label={opt.label}
selected={isOptionSelected(opt.label)}
onToggle={() => onToggle(question, opt.label)}
/>
))}
</Flexbox>
</Flexbox>
);
});
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<BuiltinInterventionProps<AskUserQuestionArgs>>(
({ 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<string, string | string[]> }
)?.[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<Record<string, string | string[]>>(() => {
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<string>(() => {
const v = persistedDraft?.[FREEFORM_PAYLOAD_KEY];
return typeof v === 'string' ? v : '';
});
const [escapeActive, setEscapeActive] = useState<boolean>(() => {
const v = persistedDraft?.[FREEFORM_PAYLOAD_KEY];
return typeof v === 'string' && v.length > 0;
});
const [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<string>(() => {
// 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<string, string | string[]>;
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<string, string | string[]>) => {
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<string, string | string[]> = { ...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 (
<Flexbox gap={12}>
{!escapeActive && isMulti && (
<Tabs
compact
activeKey={activeTab}
items={questions.map((q, idx) => {
const a = answers[q.question];
const done = q.multiSelect ? Array.isArray(a) && a.length > 0 : !!a;
return {
key: String(idx),
label: (
<Flexbox horizontal align="center" gap={6}>
<Text>Q{idx + 1}</Text>
{done && <Icon icon={Check} size={12} />}
</Flexbox>
),
};
})}
onChange={(key) => setActiveTab(key as string)}
/>
)}
{escapeActive ? (
<TextArea
autoSize={{ maxRows: 8, minRows: 3 }}
disabled={expired || submitting}
placeholder={t('claudeCode.askUserQuestion.escape.placeholder')}
value={escapeText}
variant="filled"
onChange={(e) => handleEscapeTextChange(e.target.value)}
/>
) : (
activeQuestion && (
<QuestionPanel
answer={answers[activeQuestion.question]}
disabled={expired || submitting}
question={activeQuestion}
onToggle={handleToggle}
/>
)
)}
<Flexbox horizontal align="center" gap={8} justify="space-between">
<Flexbox horizontal align="center" gap={12}>
{escapeActive ? (
<Text
className={styles.escapeLink}
fontSize={12}
type="secondary"
onClick={expired || submitting ? undefined : handleEscapeToggle}
>
<Icon icon={ArrowLeft} size={12} />
{t('claudeCode.askUserQuestion.escape.back')}
</Text>
) : (
<Text
className={styles.escapeLink}
fontSize={12}
type="secondary"
onClick={expired || submitting ? undefined : handleEscapeToggle}
>
{t('claudeCode.askUserQuestion.escape.enter')}
<Icon icon={PenLine} size={12} />
</Text>
)}
<Text fontSize={12} type="secondary">
{expired
? t('claudeCode.askUserQuestion.timeExpired')
: t('claudeCode.askUserQuestion.timeRemaining', {
time: formatRemaining(deadline - now),
})}
</Text>
</Flexbox>
<Flexbox horizontal gap={8}>
<Button disabled={submitting} icon={X} onClick={handleSkip}>
{t('claudeCode.askUserQuestion.skip')}
</Button>
<Button
disabled={isSubmitDisabled}
icon={Send}
loading={submitting}
type="primary"
onClick={handleSubmit}
>
{t('claudeCode.askUserQuestion.submit')}
</Button>
</Flexbox>
</Flexbox>
</Flexbox>
);
},
);
AskUserQuestionIntervention.displayName = 'CCAskUserQuestionIntervention';
export default AskUserQuestionIntervention;
@@ -0,0 +1,104 @@
'use client';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { Check } from 'lucide-react';
import { memo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
// 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<OptionCardProps>(
({ index, label, description, selected, disabled, onToggle }) => (
<Flexbox
horizontal
align="center"
aria-selected={selected}
className={cx(styles.option, selected && styles.optionSelected)}
gap={12}
role="option"
onClick={() => {
if (!disabled) onToggle();
}}
>
<span className={styles.optionIndex}>{index}</span>
<Flexbox flex={1} gap={2}>
<Text className={styles.optionLabel}>{label}</Text>
{description && <span className={styles.optionDescription}>{description}</span>}
</Flexbox>
{selected && <Icon className={styles.optionCheck} icon={Check} size={16} />}
</Flexbox>
),
);
OptionCard.displayName = 'CCAskUserQuestionOption';
export default OptionCard;
@@ -0,0 +1,82 @@
'use client';
import { Flexbox, Text, TextArea } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AskUserQuestionItem } from '../../../types';
import OptionCard from './OptionCard';
const styles = createStaticStyles(({ css }) => ({
// Per-question "write your own" input — sits as the last row in the option
// stack so it reads as one more choice rather than a separate control.
customInput: css`
margin-block-start: 2px;
`,
}));
interface QuestionPanelProps {
/** The picked option label(s) for this question, if any. */
answer: string | string[] | undefined;
/** The free-text "write your own" value for this question. */
customValue: string;
disabled: boolean;
onCustomChange: (q: AskUserQuestionItem, value: string) => void;
onToggle: (q: AskUserQuestionItem, label: string) => void;
question: AskUserQuestionItem;
}
/**
* A single question: its header/title, the numbered options, and a trailing
* free-text box so the user can answer in their own words instead of picking.
*/
const QuestionPanel = memo<QuestionPanelProps>(
({ question, answer, customValue, disabled, onToggle, onCustomChange }) => {
const { t } = useTranslation('tool');
const isOptionSelected = (label: string): boolean =>
question.multiSelect ? Array.isArray(answer) && answer.includes(label) : answer === label;
return (
<Flexbox gap={10}>
<Flexbox horizontal align="center" gap={8}>
{question.header && <Text type="secondary">{question.header}</Text>}
{question.multiSelect && (
<Text fontSize={12} type="secondary">
{t('claudeCode.askUserQuestion.multiSelectTag')}
</Text>
)}
</Flexbox>
<Text strong>{question.question}</Text>
<Flexbox gap={4} role="listbox">
{question.options.map((opt, optIdx) => (
<OptionCard
description={opt.description}
disabled={disabled}
index={optIdx + 1}
key={opt.label}
label={opt.label}
selected={isOptionSelected(opt.label)}
onToggle={() => onToggle(question, opt.label)}
/>
))}
{/* Last item: let the user write their own answer for this question. */}
<TextArea
autoSize={{ maxRows: 4, minRows: 1 }}
className={styles.customInput}
disabled={disabled}
placeholder={t('claudeCode.askUserQuestion.customOption.placeholder')}
value={customValue}
variant="filled"
onChange={(e) => onCustomChange(question, e.target.value)}
/>
</Flexbox>
</Flexbox>
);
},
);
QuestionPanel.displayName = 'CCAskUserQuestionPanel';
export default QuestionPanel;
@@ -0,0 +1,99 @@
import type { AskUserQuestionItem } from '../../../types';
/**
* The one sentinel key, and it lives in the *submit payload* — never in the
* draft. When the payload sent to CC is exactly `{ __freeform__: <text> }`,
* the bridge formatter (`AskUserMcpServer.formatAnswerForCC`) forwards the
* text verbatim — no `User answers:` framing — matching the escape hatch
* `lobe-user-interaction` uses. The in-progress draft keeps this same text in
* a named `escapeText` field instead, so no sentinel leaks into form state.
*/
export 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.
*/
export const COUNTDOWN_MS = 5 * 60 * 1000;
/** Key under tool message `pluginState` where the in-progress draft lives. */
export const DRAFT_PLUGIN_STATE_KEY = 'askUserDraft';
/**
* In-progress form state, persisted on the tool message's
* `pluginState.askUserDraft` so HMR reloads, store re-mounts, and tab switches
* all keep partial answers around — only a fresh `tool_use` starts blank.
*
* Three independent answer slices, kept apart so picks and custom text can
* coexist on one question (multi-select) and a half-typed escape reply never
* bleeds into the form payload:
* - `picks` → multi-choice selections, keyed by question text
* - `custom` → per-question "write your own" text, keyed by question text
* - `escape*` → the global "Or type directly" box (whole-form bypass)
*
* Storing this as a typed object (rather than a flat string-keyed map) is what
* lets us avoid sentinel key prefixes for the picks/custom split.
*/
export type AskUserDraft = {
custom: Record<string, string>;
escapeActive: boolean;
escapeText: string;
picks: Record<string, string | string[]>;
};
/** Coerce a persisted (possibly partial / legacy) blob into a full draft. */
export const readDraft = (raw: unknown): AskUserDraft => {
const d = (raw ?? {}) as Partial<AskUserDraft>;
return {
custom: d.custom ?? {},
escapeActive: !!d.escapeActive,
escapeText: typeof d.escapeText === 'string' ? d.escapeText : '',
picks: d.picks ?? {},
};
};
/** A question counts as answered when it has a pick or non-empty custom text. */
export const isQuestionAnswered = (
q: AskUserQuestionItem,
picks: Record<string, string | string[]>,
custom: Record<string, string>,
): boolean => {
if (custom[q.question]?.trim()) return true;
const a = picks[q.question];
return q.multiSelect ? Array.isArray(a) && a.length > 0 : !!a;
};
/**
* Merge picks + custom text into the structured payload CC receives: each
* question maps to its picks, its custom text, or both (multi-select appends
* custom as an extra entry). Shared by the Submit button and the timeout
* fallback so the two never diverge.
*/
export const buildSubmitPayload = (
questions: AskUserQuestionItem[],
picks: Record<string, string | string[]>,
custom: Record<string, string>,
): Record<string, string | string[]> => {
const payload: Record<string, string | string[]> = {};
for (const q of questions) {
const text = custom[q.question]?.trim();
if (q.multiSelect) {
const chosen = Array.isArray(picks[q.question]) ? (picks[q.question] as string[]) : [];
const merged = text ? [...chosen, text] : chosen;
if (merged.length > 0) payload[q.question] = merged;
} else if (text) {
payload[q.question] = text;
} else if (picks[q.question]) {
payload[q.question] = picks[q.question];
}
}
return payload;
};
export 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')}`;
};
@@ -0,0 +1,176 @@
'use client';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Button, Flexbox, Icon, Tabs, Text, TextArea } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ArrowLeft, Check, PenLine, Send, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AskUserQuestionArgs } from '../../../types';
import { formatRemaining, isQuestionAnswered } from './draft';
import QuestionPanel from './QuestionPanel';
import { useAskUserForm } from './useAskUserForm';
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};
}
`,
}));
/**
* 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) marks
* `pluginIntervention.status` and forwards the answer to CC over IPC.
*
* Answering a question
* - Pick one of the numbered options, or
* - Write your own in the trailing input. Single-select treats the two as
* mutually exclusive (typing clears the pick and vice-versa); multi-select
* appends the custom text as an extra entry alongside the checked options.
*
* Layout
* - One question → renders the question + options directly, no tab strip.
* - Multiple questions → top tab bar (Q1, Q2, …), one panel at a time. Picking
* an answer auto-advances to the next unanswered question.
*
* State, handlers, and draft persistence all live in `useAskUserForm`; this
* component is just the view.
*/
const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestionArgs>>((props) => {
const { t } = useTranslation('tool');
const {
activeQuestion,
activeTab,
custom,
escapeActive,
escapeText,
expired,
handleCustomChange,
handleEscapeTextChange,
handleEscapeToggle,
handleSkip,
handleSubmit,
handleToggle,
isMulti,
isSubmitDisabled,
picks,
questions,
remainingMs,
setActiveTab,
submitting,
} = useAskUserForm(props);
return (
<Flexbox gap={12}>
{!escapeActive && isMulti && (
<Tabs
compact
activeKey={activeTab}
items={questions.map((q, idx) => {
const done = isQuestionAnswered(q, picks, custom);
return {
key: String(idx),
label: (
<Flexbox horizontal align="center" gap={6}>
<Text>Q{idx + 1}</Text>
{done && <Icon icon={Check} size={12} />}
</Flexbox>
),
};
})}
onChange={(key) => setActiveTab(key as string)}
/>
)}
{escapeActive ? (
<TextArea
autoSize={{ maxRows: 8, minRows: 3 }}
disabled={expired || submitting}
placeholder={t('claudeCode.askUserQuestion.escape.placeholder')}
value={escapeText}
variant="filled"
onChange={(e) => handleEscapeTextChange(e.target.value)}
/>
) : (
activeQuestion && (
<QuestionPanel
answer={picks[activeQuestion.question]}
customValue={custom[activeQuestion.question] ?? ''}
disabled={expired || submitting}
question={activeQuestion}
onCustomChange={handleCustomChange}
onToggle={handleToggle}
/>
)
)}
<Flexbox horizontal align="center" gap={8} justify="space-between">
<Flexbox horizontal align="center" gap={12}>
{escapeActive ? (
<Text
className={styles.escapeLink}
fontSize={12}
type="secondary"
onClick={expired || submitting ? undefined : handleEscapeToggle}
>
<Icon icon={ArrowLeft} size={12} />
{t('claudeCode.askUserQuestion.escape.back')}
</Text>
) : (
<Text
className={styles.escapeLink}
fontSize={12}
type="secondary"
onClick={expired || submitting ? undefined : handleEscapeToggle}
>
{t('claudeCode.askUserQuestion.escape.enter')}
<Icon icon={PenLine} size={12} />
</Text>
)}
<Text fontSize={12} type="secondary">
{expired
? t('claudeCode.askUserQuestion.timeExpired')
: t('claudeCode.askUserQuestion.timeRemaining', {
time: formatRemaining(remainingMs),
})}
</Text>
</Flexbox>
<Flexbox horizontal gap={8}>
<Button disabled={submitting} icon={X} onClick={handleSkip}>
{t('claudeCode.askUserQuestion.skip')}
</Button>
<Button
disabled={isSubmitDisabled}
icon={Send}
loading={submitting}
type="primary"
onClick={handleSubmit}
>
{t('claudeCode.askUserQuestion.submit')}
</Button>
</Flexbox>
</Flexbox>
</Flexbox>
);
});
AskUserQuestionIntervention.displayName = 'CCAskUserQuestionIntervention';
export default AskUserQuestionIntervention;
@@ -0,0 +1,245 @@
import type { BuiltinInterventionProps } from '@lobechat/types';
import { useCallback, useEffect, useMemo, useState } from 'react';
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';
import type { AskUserDraft } from './draft';
import {
buildSubmitPayload,
COUNTDOWN_MS,
DRAFT_PLUGIN_STATE_KEY,
FREEFORM_PAYLOAD_KEY,
isQuestionAnswered,
readDraft,
} from './draft';
/**
* All state + handlers for the CC AskUserQuestion form. Kept out of the view
* so `index.tsx` stays a thin render of the returned values.
*
* Draft persistence: every mutation mirrors the full form state into the tool
* message's `pluginState.askUserDraft` (see `setInterventionDraft`) so HMR,
* remounts, and tab switches resume where the user left off.
*/
export const useAskUserForm = ({
args,
messageId,
onInteractionAction,
}: BuiltinInterventionProps<AskUserQuestionArgs>) => {
const questions = args?.questions ?? [];
// Persisted draft — read from the tool message's pluginState so the form
// stays where the user left it across unmount / HMR / refresh.
const persistedDraft = useConversationStore((s) => {
const msg = dataSelectors.getDbMessageById(messageId)(s);
return (msg?.pluginState as { [DRAFT_PLUGIN_STATE_KEY]?: unknown })?.[DRAFT_PLUGIN_STATE_KEY];
});
const setInterventionDraft = useChatStore((s) => s.setInterventionDraft);
// Plain const (not a hook) so it can read `persistedDraft` without tripping
// exhaustive-deps; consumed only by the once-run useState initializers below.
const initial = readDraft(persistedDraft);
const [picks, setPicks] = useState<Record<string, string | string[]>>(() => initial.picks);
const [custom, setCustom] = useState<Record<string, string>>(() => initial.custom);
const [escapeText, setEscapeText] = useState<string>(() => initial.escapeText);
const [escapeActive, setEscapeActive] = useState<boolean>(() => initial.escapeActive);
const [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<string>(() => {
// Resume on the first unanswered question rather than always at Q1.
const idx = questions.findIndex((q) => !isQuestionAnswered(q, initial.picks, initial.custom));
return String(idx >= 0 ? idx : 0);
});
// 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 writeDraft = useCallback(
(next: AskUserDraft) => setInterventionDraft(messageId, next),
[messageId, setInterventionDraft],
);
const handleToggle = useCallback(
(q: AskUserQuestionItem, label: string) => {
let nextPicks: Record<string, string | string[]>;
if (q.multiSelect) {
const current = (picks[q.question] as string[] | undefined) ?? [];
nextPicks = {
...picks,
[q.question]: current.includes(label)
? current.filter((x) => x !== label)
: [...current, label],
};
} else {
nextPicks = { ...picks, [q.question]: label };
}
// Single-select pick and custom text are mutually exclusive — picking
// drops any "write your own" text. Multi-select keeps it (additive).
let nextCustom = custom;
if (!q.multiSelect && custom[q.question]) {
const { [q.question]: _drop, ...rest } = custom;
nextCustom = rest;
}
setPicks(nextPicks);
if (nextCustom !== custom) setCustom(nextCustom);
writeDraft({ custom: nextCustom, escapeActive, escapeText, picks: nextPicks });
// Single-select auto-advance to the next still-unanswered question, so
// the user sweeps through without re-clicking the tabs.
if (!q.multiSelect && questions.length > 1) {
const next = questions.findIndex(
(qq) => qq.question !== q.question && !isQuestionAnswered(qq, nextPicks, nextCustom),
);
if (next >= 0) setActiveTab(String(next));
}
},
[picks, custom, escapeActive, escapeText, questions, writeDraft],
);
const handleCustomChange = useCallback(
(q: AskUserQuestionItem, value: string) => {
const nextCustom = { ...custom, [q.question]: value };
// Single-select: writing your own answer clears the picked option so the
// two stay mutually exclusive. Multi-select keeps the checks — custom
// text rides along as an additive entry.
let nextPicks = picks;
if (!q.multiSelect && value.trim() && picks[q.question]) {
const { [q.question]: _drop, ...rest } = picks;
nextPicks = rest;
}
setCustom(nextCustom);
if (nextPicks !== picks) setPicks(nextPicks);
writeDraft({ custom: nextCustom, escapeActive, escapeText, picks: nextPicks });
},
[picks, custom, escapeActive, escapeText, writeDraft],
);
/**
* Submit `payload` exactly as given. Used by the Submit button (with the
* user's picks/text) and the timeout fallback (option 1 of each unanswered
* question merged in).
*/
const submitWith = useCallback(
async (payload: Record<string, string | string[]>) => {
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);
// Persist freeform text alongside the (hidden) picks so a refresh resumes
// here; the picks survive a toggle back to the form.
writeDraft({ custom, escapeActive: true, escapeText: value, picks });
},
[custom, picks, writeDraft],
);
const handleEscapeToggle = useCallback(() => {
setEscapeActive((prev) => {
const next = !prev;
writeDraft({ custom, escapeActive: next, escapeText, picks });
return next;
});
}, [custom, escapeText, picks, writeDraft]);
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(buildSubmitPayload(questions, picks, custom));
}
}, [custom, escapeActive, escapeText, picks, questions, 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) => isQuestionAnswered(q, picks, custom)),
[picks, custom, questions],
);
// Timeout fallback: when the countdown hits zero and the user hasn't
// submitted, fill option 1 of each unanswered question and submit. Beats
// letting the bridge time out into a `cancelled` isError — the model gets a
// structured answer it can act on. Single-shot via the `submitting` guard.
//
// Escape-mode special case: if the user is in escape mode with non-empty text
// when the clock hits zero, submit that text as-is rather than discarding it.
useEffect(() => {
if (!expired || submitting || questions.length === 0) return;
if (escapeActive && escapeText.trim().length > 0) {
void submitWith({ [FREEFORM_PAYLOAD_KEY]: escapeText.trim() });
return;
}
// Start from whatever the user picked / typed, then backfill option 1 for
// any question still untouched.
const fallback = buildSubmitPayload(questions, picks, custom);
for (const q of questions) {
if (fallback[q.question] == null && q.options.length > 0) {
const first = q.options[0].label;
fallback[q.question] = q.multiSelect ? [first] : first;
}
}
void submitWith(fallback);
}, [expired, submitting, questions, escapeActive, escapeText, picks, custom, submitWith]);
const activeQuestion = questions[Number(activeTab)] ?? questions[0];
const isSubmitDisabled = escapeActive
? !escapeText.trim() || submitting || expired
: !allAnswered || expired || submitting;
return {
activeQuestion,
activeTab,
custom,
escapeActive,
escapeText,
expired,
handleCustomChange,
handleEscapeTextChange,
handleEscapeToggle,
handleSkip,
handleSubmit,
handleToggle,
isMulti: questions.length > 1,
isSubmitDisabled,
picks,
questions,
remainingMs: deadline - now,
setActiveTab,
submitting,
};
};
+1
View File
@@ -42,6 +42,7 @@ export default {
'agentMarketplace.render.alreadyInLibrary_other': '{{count}} already in library',
'agentMarketplace.picker.failedToLoad': 'Failed to load templates. Please try again later.',
'agentMarketplace.picker.summary': '{{filtered}} / {{total}} templates available.',
'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…',