mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ 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:
@@ -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…",
|
||||
|
||||
@@ -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;
|
||||
+104
@@ -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;
|
||||
+82
@@ -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;
|
||||
+245
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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…',
|
||||
|
||||
Reference in New Issue
Block a user