Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/src/components/ChatView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5412,6 +5412,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable
request={questionForView}
onResolve={resolveQuestion}
originLabel={questionOriginLabel}
className="mx-2.5"
/>
) : (
<>
Expand Down
36 changes: 26 additions & 10 deletions web/src/components/QuestionPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
selectAnswerCustom,
setAnswerCustom,
toggleAnswerSelection,
unselectAnswerCustom,
} from '../lib/questionPicker.js';

const READABLE_TEXT_STYLE = { overflowWrap: 'anywhere', wordBreak: 'break-word' };
Expand Down Expand Up @@ -53,7 +54,7 @@ async function copyText(text) {
}
}

export function QuestionPicker({ request, onResolve, originLabel = '' }) {
export function QuestionPicker({ request, onResolve, originLabel = '', className = '' }) {

const normalized = useMemo(() => normalizeQuestionRequest(request), [request]);
const { questions } = normalized;
Expand Down Expand Up @@ -194,6 +195,15 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
updateAnswer(currentIndex, (item) => setAnswerCustom(item, next, isMulti));
}, [currentIndex, isMulti, updateAnswer]);

const toggleCustom = useCallback(() => {
updateAnswer(currentIndex, (item) => item.customSelected
? unselectAnswerCustom(item)
: selectAnswerCustom(item, isMulti));
setFocusIndex(customIndex);
setEditingCustom(false);
focusSoon(rootRef);
}, [currentIndex, customIndex, isMulti, updateAnswer]);

const moveFocus = useCallback((delta) => {
const count = optionCount + 1;
setFocusIndex(Math.min(count - 1, Math.max(0, activeOptionIndex + delta)));
Expand Down Expand Up @@ -340,7 +350,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
tabIndex={-1}
onKeyDown={onKeyDown}
aria-label="AskUserQuestion"
className="mb-2 shrink min-h-0 rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col"
className={clsx('mb-2 shrink min-h-0 rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col', className)}
>
<div className="min-h-11 shrink-0 px-4 py-2 border-b border-border bg-surface flex items-center gap-2">
<div className="min-w-0 flex-1 overflow-hidden">
Expand Down Expand Up @@ -406,7 +416,8 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
<div className="px-2 py-2.5 min-h-0 flex-1 overflow-y-auto ace-scrollbar">
{question.options.map((opt, index) => {
const selected = answer.selected?.includes(opt.value);
const focused = activeOptionIndex === index;
const focused = focusIndex === index;
const hovered = hoverIndex === index;
const copied = copiedIndex === index;
return (
<div
Expand All @@ -429,10 +440,12 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
className={clsx(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition',
selected
? 'bg-accent-bg border border-accent text-accent'
? 'bg-accent-bg border border-transparent text-accent'
: focused
? 'bg-accent-bg border border-accent'
: 'border border-transparent hover:bg-accent-bg',
? 'bg-accent-bg border border-transparent'
: hovered
? 'border border-transparent bg-accent-bg'
: 'border border-transparent hover:bg-accent-bg',

)}
>
Expand Down Expand Up @@ -498,27 +511,30 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
className={clsx(
'flex items-center gap-3 rounded-lg px-3 py-2.5 transition',
customActive
? 'bg-accent-bg border border-accent'
? 'bg-accent-bg border border-transparent'
: focusIndex === customIndex
? 'bg-accent-bg border border-accent'
? 'bg-accent-bg border border-transparent'
: 'border border-transparent hover:bg-accent-bg',

)}
>
<span
<button
type="button"
onClick={toggleCustom}
className={clsx(
'w-6 h-6 shrink-0 rounded-full flex items-center justify-center border transition',
customActive
? 'bg-accent text-white border-accent'
: 'border-fg-mute text-fg-mute',
)}
aria-label={customActive ? '取消自定义答案' : '选择自定义答案'}
>
{customActive ? (
<VsIcon name="check" size={13} mono={false} />
) : (
<span className="text-[11px] font-semibold tabular-nums">{customIndex + 1}</span>
)}
</span>
</button>
<input
ref={customRef}
type="text"
Expand Down
14 changes: 13 additions & 1 deletion web/src/lib/questionPickerInteraction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,22 @@ run('Esc arming is isolated between requests and its timer is cleared on unmount
assert.equal(picker.timers.size, 0);
});

run('multi-select custom answer can be unchecked while retaining a muted draft', () => {
const picker = harness([q1]);
input(picker.render()).props.onChange({ target: { value: 'retained draft' } });
button(picker.render(), '取消自定义答案').props.onClick();
const draftInput = input(picker.render());
assert.match(draftInput.props.className, /text-fg-mute/);
assert.equal(draftInput.props.value, 'retained draft');
key(picker, 'Enter', { ctrlKey: true });
assert.equal(picker.sent[0].answers[0].custom_text, undefined);
picker.unmount();
});

run('refocusing a retained multi-select custom draft reactivates it', () => {
const picker = harness([q1]);
input(picker.render()).props.onChange({ target: { value: 'retained draft' } });
key(picker, 'Escape');
button(picker.render(), '取消自定义答案').props.onClick();
input(picker.render()).props.onFocus();
key(picker, 'Enter', { ctrlKey: true });
assert.equal(picker.sent[0].answers[0].custom_text, 'retained draft');
Expand Down
9 changes: 7 additions & 2 deletions web/src/lib/questionPickerLayout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const picker = fs.readFileSync(path.join(srcRoot, 'components/QuestionPicker.jsx'), 'utf8');
const chatView = fs.readFileSync(path.join(srcRoot, 'components/ChatView.jsx'), 'utf8');
const toolBlock = fs.readFileSync(path.join(srcRoot, 'components/ToolBlock.jsx'), 'utf8');
const rootClasses = picker.match(/className="([^"]*border-border[^\"]*overflow-hidden[^\"]*)"/)?.[1] || '';
const rootClasses = picker.match(/clsx\('([^']*rounded-\[14px\][^']*)'/)?.[1] || '';

assert.ok(rootClasses, 'QuestionPicker root classes must be discoverable');
assert.match(rootClasses, /rounded-\[14px\]/);
Expand All @@ -32,8 +32,9 @@ const dockEnd = chatView.indexOf('<SessionContentLoading', dockStart);
const dock = chatView.slice(dockStart, dockEnd);
assert.match(dock, /questionForView\s*\?\s*\(\s*<QuestionPicker/);
assert.match(dock, /:\s*\(\s*<>\s*<InputBar/);
assert.match(dock, /<QuestionPicker[\s\S]*?className="mx-2\.5"/);

assert.match(picker, /selected\s*\?\s*'bg-accent-bg[^']*border-accent[^']*text-accent'/);
assert.match(picker, /selected\s*\?\s*'bg-accent-bg border border-transparent text-accent'/);
assert.match(picker, /bg-accent text-white hover:opacity-90/);
assert.doesNotMatch(picker, /selected\s*\?\s*'bg-fg text-bg border-fg'/);
assert.doesNotMatch(picker, /font-medium bg-fg text-bg/);
Expand All @@ -42,6 +43,10 @@ assert.match(picker, /min-h-11 shrink-0 px-4 py-2/);
assert.match(picker, /group flex items-center gap-3 rounded-lg px-3 py-2\.5/);
assert.match(picker, /border border-transparent hover:bg-accent-bg/);
assert.doesNotMatch(picker, /hover:border-accent/);
assert.doesNotMatch(picker, /bg-accent-bg border border-accent/);
assert.match(picker, /const focused = focusIndex === index/);
assert.match(picker, /const hovered = hoverIndex === index/);
assert.match(picker, /hovered\s*\?\s*'border border-transparent bg-accent-bg'/);

assert.match(toolBlock, /const isAskUserQuestionResult = askUserQuestionResult/);
assert.match(toolBlock, /translate\('用户已取消回答'\)/);
Expand Down
Loading