Skip to content

Commit b9aeac1

Browse files
Sg312claude
andcommitted
improvement(chat): question cards are single_select only
Removes multi_select (and its toggle/check UI). The card is one shape: pick one option or type into the always-present 'Something else' row. Catch-all stripping and the transcript pairing/recap behavior are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e102fd5 commit b9aeac1

4 files changed

Lines changed: 65 additions & 136 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const QUESTIONS: QuestionItem[] = [
2323
],
2424
},
2525
{
26-
type: 'multi_select',
26+
type: 'single_select',
2727
prompt: 'What time zone should the daily report run in?',
2828
options: [
2929
{ id: 'est', label: 'EST' },
Lines changed: 50 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { ArrowRight, Button, Check, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn'
4+
import { ArrowRight, Button, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn'
55
import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
66

77
/**
@@ -36,6 +36,16 @@ export function parseQuestionAnswerMessage(
3636
return answers
3737
}
3838

39+
/**
40+
* The free-text input's initial value when (re)visiting a question: restore a
41+
* previously typed answer, but not one that matches an option row (that row is
42+
* highlighted instead).
43+
*/
44+
function freeTextPrefillFor(question: QuestionItem, answer: string | null): string {
45+
if (!answer) return ''
46+
return question.options.some((o) => o.label === answer) ? '' : answer
47+
}
48+
3949
const OPTION_ROW_CLASSES =
4050
'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors'
4151

@@ -69,11 +79,10 @@ interface QuestionDisplayProps {
6979
* Inline renderer for the `<question>` special tag: a chat-inline div with the
7080
* user input's chrome, the current question's prompt at the top left, dismiss
7181
* (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and
72-
* suggested-action option rows beneath. Both question types append a
73-
* free-text "Something else" row. `single_select` answers and advances on
74-
* click; `multi_select` rows toggle and the free-text row's arrow submits the
75-
* step. Answering the last question sends one combined user message and
76-
* collapses the div to a question/answer recap.
82+
* suggested-action option rows beneath, always followed by a free-text
83+
* "Something else" row. Clicking an option (or submitting typed text) answers
84+
* and advances; answering the last question sends one combined user message
85+
* and collapses the div to a question/answer recap.
7786
*/
7887
export function QuestionDisplay({
7988
data,
@@ -83,22 +92,16 @@ export function QuestionDisplay({
8392
const disabled = !onSelect
8493
const [phase, setPhase] = useState<QuestionPhase>('active')
8594
const [step, setStep] = useState(0)
86-
const [selectedByStep, setSelectedByStep] = useState<string[][]>(() => data.map(() => []))
87-
const [customByStep, setCustomByStep] = useState<string[]>(() => data.map(() => ''))
95+
const [answers, setAnswers] = useState<(string | null)[]>(() => data.map(() => null))
8896
const [freeText, setFreeText] = useState('')
8997

9098
const containerClasses =
9199
'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]'
92100

93101
// Transcript answers win over local state: they survive reloads (local
94102
// phase does not) and keep live + rehydrated renders identical.
95-
const localAnswers =
96-
phase === 'answered'
97-
? data.map((question, i) =>
98-
answerFor(question, selectedByStep[i] ?? [], customByStep[i] ?? '')
99-
)
100-
: null
101-
const recapAnswers = transcriptAnswers ?? localAnswers
103+
const recapAnswers =
104+
transcriptAnswers ?? (phase === 'answered' ? answers.map((a) => a ?? '') : null)
102105
if (data.length > 0 && recapAnswers) {
103106
return (
104107
<div className={containerClasses}>
@@ -117,76 +120,30 @@ export function QuestionDisplay({
117120
const question = data[step]
118121
const isLast = step === data.length - 1
119122
const options = question.options
120-
const selected = selectedByStep[step] ?? []
121-
const isMulti = question.type === 'multi_select'
122-
123-
const commitCustom = (): string[] => {
124-
const next = [...customByStep]
125-
next[step] = freeText.trim()
126-
setCustomByStep(next)
127-
return next
128-
}
129123

130124
const goToStep = (next: number) => {
131-
commitCustom()
132125
setStep(next)
133-
setFreeText(customByStep[next] ?? '')
126+
setFreeText(freeTextPrefillFor(data[next], answers[next]))
134127
}
135128

136-
const finishStep = (selections: string[][], customs: string[]) => {
129+
const handleAnswer = (answer: string) => {
130+
const next = [...answers]
131+
next[step] = answer
132+
setAnswers(next)
137133
if (!isLast) {
138-
setStep(step + 1)
139-
setFreeText(customs[step + 1] ?? '')
134+
goToStep(step + 1)
140135
return
141136
}
142137
setPhase('answered')
143138
onSelect?.(
144139
formatQuestionAnswerMessage(
145140
data,
146-
data.map((q, i) => answerFor(q, selections[i] ?? [], customs[i] ?? ''))
141+
next.map((a) => a ?? '')
147142
)
148143
)
149144
}
150145

151-
const handleSingleSelect = (label: string) => {
152-
const selections = [...selectedByStep]
153-
selections[step] = [label]
154-
setSelectedByStep(selections)
155-
const customs = [...customByStep]
156-
customs[step] = ''
157-
setCustomByStep(customs)
158-
setFreeText('')
159-
finishStep(selections, customs)
160-
}
161-
162-
const handleMultiToggle = (label: string) => {
163-
const selections = [...selectedByStep]
164-
const current = selections[step] ?? []
165-
selections[step] = current.includes(label)
166-
? current.filter((l) => l !== label)
167-
: [...current, label]
168-
setSelectedByStep(selections)
169-
}
170-
171-
const submitFreeTextRow = () => {
172-
const customs = commitCustom()
173-
if (isMulti) {
174-
finishStep(selectedByStep, customs)
175-
return
176-
}
177-
const selections = [...selectedByStep]
178-
selections[step] = []
179-
setSelectedByStep(selections)
180-
finishStep(selections, customs)
181-
}
182-
183-
const stepAnswered = (i: number): boolean =>
184-
(selectedByStep[i]?.length ?? 0) > 0 ||
185-
(i === step ? freeText.trim().length > 0 : (customByStep[i] ?? '').trim().length > 0)
186-
187-
// single_select: the arrow submits the typed "Something else" answer.
188-
// multi_select: the arrow submits the step (selections and/or typed text).
189-
const canSubmitRow = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0)
146+
const canSubmitFreeText = !disabled && freeText.trim().length > 0
190147

191148
return (
192149
<div className={containerClasses}>
@@ -219,7 +176,7 @@ export function QuestionDisplay({
219176
onClick={() => goToStep(step + 1)}
220177
// Inert renders (older messages) browse freely; interactive ones
221178
// gate forward movement on the current question being answered.
222-
disabled={isLast || (!disabled && !stepAnswered(step))}
179+
disabled={isLast || (!disabled && answers[step] === null)}
223180
className={cn(
224181
ICON_BUTTON_CLASSES,
225182
'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50'
@@ -247,35 +204,24 @@ export function QuestionDisplay({
247204
</div>
248205
</div>
249206
<div className='flex flex-col'>
250-
{options.map((option, i) => {
251-
const isSelected = selected.includes(option.label)
252-
return (
253-
<button
254-
key={option.id}
255-
type='button'
256-
disabled={disabled}
257-
onClick={() =>
258-
isMulti ? handleMultiToggle(option.label) : handleSingleSelect(option.label)
259-
}
260-
className={cn(
261-
OPTION_ROW_CLASSES,
262-
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]',
263-
i > 0 && 'border-t',
264-
isSelected && 'bg-[var(--surface-5)]'
265-
)}
266-
>
267-
<RowNumber value={i + 1} />
268-
<span className='flex-1 truncate text-[var(--text-body)] text-sm'>
269-
{option.label}
270-
</span>
271-
{isMulti && isSelected ? (
272-
<Check className='size-[16px] shrink-0 text-[var(--text-body)]' />
273-
) : (
274-
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
275-
)}
276-
</button>
277-
)
278-
})}
207+
{options.map((option, i) => (
208+
<button
209+
key={option.id}
210+
type='button'
211+
disabled={disabled}
212+
onClick={() => handleAnswer(option.label)}
213+
className={cn(
214+
OPTION_ROW_CLASSES,
215+
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]',
216+
i > 0 && 'border-t',
217+
answers[step] === option.label && 'bg-[var(--surface-5)]'
218+
)}
219+
>
220+
<RowNumber value={i + 1} />
221+
<span className='flex-1 truncate text-[var(--text-body)] text-sm'>{option.label}</span>
222+
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
223+
</button>
224+
))}
279225
<div className={cn(OPTION_ROW_CLASSES, options.length > 0 && 'border-t')}>
280226
<RowNumber value={options.length + 1} />
281227
<input
@@ -284,9 +230,9 @@ export function QuestionDisplay({
284230
disabled={disabled}
285231
onChange={(e) => setFreeText(e.target.value)}
286232
onKeyDown={(e) => {
287-
if (e.key === 'Enter' && canSubmitRow) {
233+
if (e.key === 'Enter' && canSubmitFreeText) {
288234
e.preventDefault()
289-
submitFreeTextRow()
235+
handleAnswer(freeText.trim())
290236
}
291237
}}
292238
placeholder='Something else'
@@ -296,14 +242,14 @@ export function QuestionDisplay({
296242
<button
297243
type='button'
298244
aria-label='Submit answer'
299-
disabled={!canSubmitRow}
300-
onClick={submitFreeTextRow}
245+
disabled={!canSubmitFreeText}
246+
onClick={() => handleAnswer(freeText.trim())}
301247
className='disabled:cursor-default'
302248
>
303249
<ArrowRight
304250
className={cn(
305251
'size-[16px] shrink-0 transition-colors',
306-
canSubmitRow ? 'text-[var(--text-body)]' : 'text-[var(--text-icon)]'
252+
canSubmitFreeText ? 'text-[var(--text-body)]' : 'text-[var(--text-icon)]'
307253
)}
308254
/>
309255
</button>
@@ -312,16 +258,3 @@ export function QuestionDisplay({
312258
</div>
313259
)
314260
}
315-
316-
/**
317-
* A step's combined answer: selected option labels in option order, with the
318-
* typed "Something else" entry appended last. single_select carries at most
319-
* one selection, so this collapses to the chosen label or the typed text.
320-
*/
321-
function answerFor(question: QuestionItem, selected: string[], custom: string): string {
322-
const ordered = question.options
323-
.map((option) => option.label)
324-
.filter((label) => selected.includes(label))
325-
const parts = custom.trim() ? [...ordered, custom.trim()] : ordered
326-
return parts.join(', ')
327-
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@ const YES_NO = {
2525
],
2626
}
2727

28-
const MULTI_SELECT = {
29-
type: 'multi_select',
30-
prompt: 'Which channels should the report go to?',
28+
const TIMEZONE = {
29+
type: 'single_select',
30+
prompt: 'What time zone should the daily report run in?',
3131
options: [
32-
{ id: 'slack', label: 'Slack' },
33-
{ id: 'email', label: 'Email' },
34-
{ id: 'sheet', label: 'Google Sheet' },
32+
{ id: 'est', label: 'EST' },
33+
{ id: 'pst', label: 'PST' },
3534
],
3635
}
3736

@@ -41,12 +40,8 @@ describe('parseQuestionTagBody', () => {
4140
})
4241

4342
it('preserves array order for multi-step bodies', () => {
44-
const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT]))
45-
expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT])
46-
})
47-
48-
it('accepts multi_select questions', () => {
49-
expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT])
43+
const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, TIMEZONE]))
44+
expect(parsed).toEqual([SINGLE_SELECT, YES_NO, TIMEZONE])
5045
})
5146

5247
it('rejects single_select without options', () => {
@@ -61,11 +56,12 @@ describe('parseQuestionTagBody', () => {
6156
).toBe(null)
6257
})
6358

64-
it('rejects the removed text and confirm types', () => {
59+
it('rejects non-single_select types', () => {
6560
expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe(
6661
null
6762
)
6863
expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null)
64+
expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'multi_select' }))).toBe(null)
6965
})
7066

7167
it('strips agent-supplied catch-all options (the card provides its own)', () => {
@@ -123,9 +119,9 @@ describe('parseSpecialTags with <question>', () => {
123119
})
124120

125121
it('extracts a multi-step array body as one segment', () => {
126-
const content = `<question>${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}</question>`
122+
const content = `<question>${JSON.stringify([SINGLE_SELECT, YES_NO, TIMEZONE])}</question>`
127123
const { segments } = parseSpecialTags(content, false)
128-
expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }])
124+
expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, TIMEZONE] }])
129125
})
130126

131127
it('flags an unclosed question tag as pending while streaming', () => {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export interface FileTagData {
9595
content: string
9696
}
9797

98-
export const QUESTION_TYPES = ['single_select', 'multi_select'] as const
98+
export const QUESTION_TYPES = ['single_select'] as const
9999

100100
export type QuestionType = (typeof QUESTION_TYPES)[number]
101101

@@ -105,8 +105,8 @@ export interface QuestionOption {
105105
}
106106

107107
/**
108-
* One question in a `<question>` tag. Both types require at least one option;
109-
* the card always appends its own free-text "Something else" row, so
108+
* One question in a `<question>` tag: a single_select with at least one real
109+
* option. The card always appends its own free-text "Something else" row, so
110110
* agent-supplied catch-all options ("Other", "Something else", ...) are
111111
* stripped during parsing.
112112
*/

0 commit comments

Comments
 (0)