11'use client'
22
33import { 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'
55import 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+
3949const 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 */
7887export 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- }
0 commit comments