1- import { useState } from 'react' ;
1+ import { useState , useRef } from 'react' ;
22import { MessageCircleQuestion , Check , Send } from 'lucide-react' ;
33import type { ToolCallBlockData } from '../../../types/chat' ;
44import { MarkdownContent } from '../MarkdownContent' ;
@@ -17,19 +17,55 @@ interface Question {
1717 multiSelect : boolean ;
1818}
1919
20+ // Recover the chosen option labels from the persisted tool_result, whose shape is
21+ // `... "question"="label, label" ...`, so a reloaded poll re-highlights the answer.
22+ function parseChosenSelections ( result : string , questions : Question [ ] ) : Map < number , Set < number > > {
23+ const chosen = new Map < number , Set < number > > ( ) ;
24+ questions . forEach ( ( q , qIdx ) => {
25+ const needle = `"${ q . question } "="` ;
26+ const start = result . indexOf ( needle ) ;
27+ if ( start === - 1 ) return ;
28+ const valStart = start + needle . length ;
29+ const valEnd = result . indexOf ( '"' , valStart ) ;
30+ if ( valEnd === - 1 ) return ;
31+ // Recover the chosen labels as exact ", "-joined tokens. (A label that
32+ // itself contains ", " won't re-highlight — cosmetic, reload-only.)
33+ const chosenLabels = new Set ( result . slice ( valStart , valEnd ) . split ( ', ' ) ) ;
34+ const set = new Set < number > ( ) ;
35+ q . options . forEach ( ( opt , oIdx ) => {
36+ if ( chosenLabels . has ( opt . label ) ) set . add ( oIdx ) ;
37+ } ) ;
38+ if ( set . size > 0 ) chosen . set ( qIdx , set ) ;
39+ } ) ;
40+ return chosen ;
41+ }
42+
2043export function QuestionBlock ( { block } : { block : ToolCallBlockData } ) {
2144 const questions = ( block . input . questions as Question [ ] ) || [ ] ;
2245 // Per-question selections: Map<questionIndex, Set<optionIndex>>
2346 const [ selections , setSelections ] = useState < Map < number , Set < number > > > ( new Map ( ) ) ;
2447 const [ submitted , setSubmitted ] = useState ( false ) ;
2548 const [ hoveredOption , setHoveredOption ] = useState < { q : number ; o : number } | null > ( null ) ;
49+ const questionPending = useChatStore ( s => s . pendingInteraction ?. interactionType === 'question' ) ;
50+ // Latch that a live question prompt was seen; once it clears (answered here,
51+ // by a parallel client, or before reconnect replay) the form must lock.
52+ const seenPending = useRef ( false ) ;
53+ if ( questionPending ) seenPending . current = true ;
2654
2755 if ( questions . length === 0 ) return null ;
2856
2957 const isSingleSimple = questions . length === 1 && ! questions [ 0 ] . multiSelect ;
3058
59+ // A persisted tool_result means the interaction is already resolved — render
60+ // read-only so a reloaded session shows the answer instead of re-prompting.
61+ const parsedSelections = block . result !== undefined ? parseChosenSelections ( block . result , questions ) : null ;
62+ const isResolved = submitted || parsedSelections !== null || ( seenPending . current && ! questionPending ) ;
63+ // Prefer parsed answers, but fall back to live selections when the result
64+ // string didn't parse (e.g. a quote in a label) so the highlight isn't lost.
65+ const effSelections = parsedSelections && parsedSelections . size > 0 ? parsedSelections : selections ;
66+
3167 const handleSelect = ( qIdx : number , oIdx : number ) => {
32- if ( submitted ) return ;
68+ if ( isResolved ) return ;
3369 setSelections ( prev => {
3470 const next = new Map ( prev ) ;
3571 const q = questions [ qIdx ] ;
@@ -50,41 +86,23 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
5086
5187 const submitAnswers = ( sel ?: Map < number , Set < number > > ) => {
5288 const s = sel || selections ;
53- setSubmitted ( true ) ;
5489
55- // Check store at call time (not closure) — the interaction event
56- // may arrive after the component rendered but before the user clicks.
90+ // Answer only when a live question interaction is pending. Without one the
91+ // poll is already resolved (reload, or answered by a parallel client), so
92+ // never fall back to posting the answer as a fresh chat message.
5793 const state = useChatStore . getState ( ) ;
58- const pending = state . pendingInteraction ;
59- const hasInteraction = pending ?. interactionType === 'question' ;
60-
61- if ( hasInteraction ) {
62- // Build answers dict for the SDK: { questionText: selectedLabel }
63- const answers : Record < string , string > = { } ;
64- for ( let i = 0 ; i < questions . length ; i ++ ) {
65- const chosen = s . get ( i ) ;
66- if ( ! chosen || chosen . size === 0 ) continue ;
67- const labels = Array . from ( chosen ) . map ( o => questions [ i ] . options [ o ] . label ) ;
68- answers [ questions [ i ] . question ] = labels . join ( ', ' ) ;
69- }
70- state . answerInteraction ( answers ) ;
71- } else {
72- // Fallback: send as a regular message (tool already completed / non-interactive)
73- const parts : string [ ] = [ ] ;
74- for ( let i = 0 ; i < questions . length ; i ++ ) {
75- const chosen = s . get ( i ) ;
76- if ( ! chosen || chosen . size === 0 ) continue ;
77- const labels = Array . from ( chosen ) . map ( o => questions [ i ] . options [ o ] . label ) ;
78- if ( questions . length > 1 ) {
79- parts . push ( `**${ questions [ i ] . header } **: ${ labels . join ( ', ' ) } ` ) ;
80- } else {
81- parts . push ( labels . join ( ', ' ) ) ;
82- }
83- }
84- if ( parts . length > 0 ) {
85- state . sendMessage ( parts . join ( '\n' ) ) ;
86- }
94+ if ( state . pendingInteraction ?. interactionType !== 'question' ) return ;
95+
96+ setSubmitted ( true ) ;
97+ // Build answers dict for the SDK: { questionText: selectedLabel }
98+ const answers : Record < string , string > = { } ;
99+ for ( let i = 0 ; i < questions . length ; i ++ ) {
100+ const chosen = s . get ( i ) ;
101+ if ( ! chosen || chosen . size === 0 ) continue ;
102+ const labels = Array . from ( chosen ) . map ( o => questions [ i ] . options [ o ] . label ) ;
103+ answers [ questions [ i ] . question ] = labels . join ( ', ' ) ;
87104 }
105+ state . answerInteraction ( answers ) ;
88106 } ;
89107
90108 const allAnswered = questions . every ( ( _q , i ) => {
@@ -114,7 +132,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
114132 { /* Options */ }
115133 < div className = "px-3 pb-3 space-y-1.5" >
116134 { q . options . map ( ( opt , oIdx ) => {
117- const isSelected = selections . get ( qIdx ) ?. has ( oIdx ) || false ;
135+ const isSelected = effSelections . get ( qIdx ) ?. has ( oIdx ) || false ;
118136 const isHovered = hoveredOption ?. q === qIdx && hoveredOption ?. o === oIdx ;
119137
120138 return (
@@ -123,9 +141,9 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
123141 onClick = { ( ) => handleSelect ( qIdx , oIdx ) }
124142 onMouseEnter = { ( ) => setHoveredOption ( { q : qIdx , o : oIdx } ) }
125143 onMouseLeave = { ( ) => setHoveredOption ( null ) }
126- disabled = { submitted }
144+ disabled = { isResolved }
127145 className = { `question-option w-full text-left px-3.5 py-2.5 rounded-md border transition-all duration-150 ${
128- submitted
146+ isResolved
129147 ? isSelected
130148 ? 'border-accent/40 bg-accent/10 cursor-default'
131149 : 'border-surface-raised bg-bg-sunken opacity-40 cursor-default'
@@ -151,7 +169,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
151169 </ div >
152170 </ button >
153171
154- { opt . markdown && ( isHovered || ( isSelected && ! submitted ) ) && (
172+ { opt . markdown && ( isHovered || ( isSelected && ! isResolved ) ) && (
155173 < div className = "mx-2 mt-1 mb-0.5 px-3 py-2 bg-bg border border-border-subtle rounded text-[12px] max-h-48 overflow-y-auto" >
156174 < MarkdownContent content = { opt . markdown } />
157175 </ div >
@@ -164,7 +182,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
164182 ) ) }
165183
166184 { /* Submit button — shown for multi-question or multiSelect, hidden for single simple question */ }
167- { ! isSingleSimple && ! submitted && (
185+ { ! isSingleSimple && ! isResolved && (
168186 < div className = "px-3 pb-3" >
169187 < button
170188 onClick = { ( ) => submitAnswers ( ) }
@@ -181,11 +199,14 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
181199 </ div >
182200 ) }
183201
184- { /* Answered confirmation */ }
185- { submitted && (
202+ { /* Resolution confirmation — "Answered", or "Closed" when the
203+ interaction ended without an answer (timeout / cancel / deny). */ }
204+ { isResolved && (
186205 < div className = "px-4 py-2 border-t border-accent/10 flex items-center gap-2" >
187- < Check size = { 12 } className = "text-hue-green" />
188- < span className = "text-[11px] text-hue-green/70" > Answered</ span >
206+ < Check size = { 12 } className = { block . isError ? 'text-text-faint' : 'text-hue-green' } />
207+ < span className = { `text-[11px] ${ block . isError ? 'text-text-faint' : 'text-hue-green/70' } ` } >
208+ { block . isError ? 'Closed' : 'Answered' }
209+ </ span >
189210 </ div >
190211 ) }
191212 </ div >
0 commit comments