@@ -8,6 +8,7 @@ import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
88import { getClientTool } from '@/lib/copilot/tools/client/manager'
99import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
1010import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
11+ import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
1112import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
1213import { getBlock } from '@/blocks/registry'
1314import { CLASS_TOOL_METADATA , useCopilotStore } from '@/stores/panel/copilot/store'
@@ -30,9 +31,41 @@ type OptionItem = string | { title: string; description?: string }
3031interface ParsedTags {
3132 plan ?: Record < string , PlanStep >
3233 options ?: Record < string , OptionItem >
34+ optionsComplete ?: boolean
3335 cleanContent : string
3436}
3537
38+ /**
39+ * Try to parse partial JSON for streaming options.
40+ * Attempts to extract complete key-value pairs from incomplete JSON.
41+ */
42+ function parsePartialOptionsJson ( jsonStr : string ) : Record < string , OptionItem > | null {
43+ // Try parsing as-is first (might be complete)
44+ try {
45+ return JSON . parse ( jsonStr )
46+ } catch {
47+ // Continue to partial parsing
48+ }
49+
50+ // Try to extract complete key-value pairs from partial JSON
51+ // Match patterns like "1": "some text" or "1": {"title": "text"}
52+ const result : Record < string , OptionItem > = { }
53+ // Match complete string values: "key": "value"
54+ const stringPattern = / " ( \d + ) " : \s * " ( [ ^ " ] * ?) " / g
55+ let match
56+ while ( ( match = stringPattern . exec ( jsonStr ) ) !== null ) {
57+ result [ match [ 1 ] ] = match [ 2 ]
58+ }
59+
60+ // Match complete object values: "key": {"title": "value"}
61+ const objectPattern = / " ( \d + ) " : \s * \{ [ ^ } ] * " t i t l e " : \s * " ( [ ^ " ] * ) " [ ^ } ] * \} / g
62+ while ( ( match = objectPattern . exec ( jsonStr ) ) !== null ) {
63+ result [ match [ 1 ] ] = { title : match [ 2 ] }
64+ }
65+
66+ return Object . keys ( result ) . length > 0 ? result : null
67+ }
68+
3669/**
3770 * Parse <plan> and <options> tags from content
3871 */
@@ -50,21 +83,33 @@ export function parseSpecialTags(content: string): ParsedTags {
5083 }
5184 }
5285
53- // Parse <options> tag
86+ // Parse <options> tag - check for complete tag first
5487 const optionsMatch = content . match ( / < o p t i o n s > ( [ \s \S ] * ?) < \/ o p t i o n s > / i)
5588 if ( optionsMatch ) {
5689 try {
5790 result . options = JSON . parse ( optionsMatch [ 1 ] )
91+ result . optionsComplete = true
5892 result . cleanContent = result . cleanContent . replace ( optionsMatch [ 0 ] , '' ) . trim ( )
5993 } catch {
6094 // Invalid JSON, ignore
6195 }
96+ } else {
97+ // Check for streaming/incomplete options tag
98+ const streamingOptionsMatch = content . match ( / < o p t i o n s > ( [ \s \S ] * ) $ / i)
99+ if ( streamingOptionsMatch ) {
100+ const partialOptions = parsePartialOptionsJson ( streamingOptionsMatch [ 1 ] )
101+ if ( partialOptions ) {
102+ result . options = partialOptions
103+ result . optionsComplete = false
104+ }
105+ // Strip the incomplete tag from clean content
106+ result . cleanContent = result . cleanContent . replace ( streamingOptionsMatch [ 0 ] , '' ) . trim ( )
107+ }
62108 }
63109
64110 // Strip any incomplete/partial special tags that are still streaming
65- // This handles cases like "<options>{"1": "op..." or "<plan>{..." during streaming
66- // Matches: <tagname> followed by any content until end of string (no closing tag yet)
67- const incompleteTagPattern = / < ( p l a n | o p t i o n s ) > [ \s \S ] * $ / i
111+ // This handles cases like "<plan>{..." during streaming
112+ const incompleteTagPattern = / < p l a n > [ \s \S ] * $ / i
68113 result . cleanContent = result . cleanContent . replace ( incompleteTagPattern , '' ) . trim ( )
69114
70115 // Also strip partial opening tags like "<opt" or "<pla" at the very end of content
@@ -129,13 +174,17 @@ export function OptionsSelector({
129174 onSelect,
130175 disabled = false ,
131176 enableKeyboardNav = false ,
177+ streaming = false ,
132178} : {
133179 options : Record < string , OptionItem >
134180 onSelect : ( optionKey : string , optionText : string ) => void
135181 disabled ?: boolean
136182 /** Only enable keyboard navigation for the active options (last message) */
137183 enableKeyboardNav ?: boolean
184+ /** When true, looks enabled but interaction is disabled (for streaming state) */
185+ streaming ?: boolean
138186} ) {
187+ const isInteractionDisabled = disabled || streaming
139188 const sortedOptions = useMemo ( ( ) => {
140189 return Object . entries ( options )
141190 . sort ( ( [ a ] , [ b ] ) => {
@@ -159,7 +208,7 @@ export function OptionsSelector({
159208
160209 // Handle keyboard navigation - only for the active options selector
161210 useEffect ( ( ) => {
162- if ( disabled || ! enableKeyboardNav || isLocked ) return
211+ if ( isInteractionDisabled || ! enableKeyboardNav || isLocked ) return
163212
164213 const handleKeyDown = ( e : KeyboardEvent ) => {
165214 // Only handle if the container or document body is focused (not when typing in input)
@@ -198,7 +247,7 @@ export function OptionsSelector({
198247
199248 document . addEventListener ( 'keydown' , handleKeyDown )
200249 return ( ) => document . removeEventListener ( 'keydown' , handleKeyDown )
201- } , [ disabled , enableKeyboardNav , isLocked , sortedOptions , hoveredIndex , onSelect ] )
250+ } , [ isInteractionDisabled , enableKeyboardNav , isLocked , sortedOptions , hoveredIndex , onSelect ] )
202251
203252 if ( sortedOptions . length === 0 ) return null
204253
@@ -213,20 +262,21 @@ export function OptionsSelector({
213262 < div
214263 key = { option . key }
215264 onClick = { ( ) => {
216- if ( ! disabled && ! isLocked ) {
265+ if ( ! isInteractionDisabled && ! isLocked ) {
217266 setChosenKey ( option . key )
218267 onSelect ( option . key , option . title )
219268 }
220269 } }
221270 onMouseEnter = { ( ) => {
222- if ( ! isLocked ) setHoveredIndex ( index )
271+ if ( ! isLocked && ! streaming ) setHoveredIndex ( index )
223272 } }
224273 className = { clsx (
225274 'group flex cursor-pointer items-start gap-2.5 rounded-[8px] p-1' ,
226275 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]' ,
227276 disabled && 'cursor-not-allowed opacity-50' ,
277+ streaming && 'pointer-events-none' ,
228278 isLocked && 'cursor-default' ,
229- isHovered && 'is-hovered bg-[var(--surface-6)] dark:bg-[var(--surface-5)]'
279+ isHovered && ! streaming && 'is-hovered bg-[var(--surface-6)] dark:bg-[var(--surface-5)]'
230280 ) }
231281 >
232282 < Button
@@ -242,7 +292,11 @@ export function OptionsSelector({
242292 isRejected && 'text-[var(--text-tertiary)] line-through opacity-50'
243293 ) }
244294 >
245- < CopilotMarkdownRenderer content = { option . title } />
295+ { streaming ? (
296+ < SmoothStreamingText content = { option . title } isStreaming = { true } />
297+ ) : (
298+ < CopilotMarkdownRenderer content = { option . title } />
299+ ) }
246300 </ span >
247301 </ div >
248302 )
0 commit comments