Skip to content

Commit 68a2c88

Browse files
committed
Fixes to options
1 parent 4ab34ae commit 68a2c88

File tree

2 files changed

+89
-16
lines changed

2 files changed

+89
-16
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,28 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
151151
}, [message.content])
152152

153153
// Parse special tags from message content (options, plan)
154+
// During streaming, also check content blocks since message.content may not be updated yet
154155
const parsedTags = useMemo(() => {
155-
if (!message.content || isUser) return null
156-
return parseSpecialTags(message.content)
157-
}, [message.content, isUser])
156+
if (isUser) return null
157+
158+
// Try message.content first
159+
if (message.content) {
160+
const parsed = parseSpecialTags(message.content)
161+
if (parsed.options) return parsed
162+
}
163+
164+
// During streaming, check content blocks for options
165+
if (message.contentBlocks && message.contentBlocks.length > 0) {
166+
for (const block of message.contentBlocks) {
167+
if (block.type === 'text' && block.content) {
168+
const parsed = parseSpecialTags(block.content)
169+
if (parsed.options) return parsed
170+
}
171+
}
172+
}
173+
174+
return message.content ? parseSpecialTags(message.content) : null
175+
}, [message.content, message.contentBlocks, isUser])
158176

159177
// Get sendMessage from store for continuation actions
160178
const sendMessage = useCopilotStore((s) => s.sendMessage)
@@ -490,13 +508,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
490508
</div>
491509
)}
492510

493-
{/* Options selector when agent presents choices */}
494-
{!isStreaming && parsedTags?.options && Object.keys(parsedTags.options).length > 0 && (
511+
{/* Options selector when agent presents choices - streams in but disabled until complete */}
512+
{parsedTags?.options && Object.keys(parsedTags.options).length > 0 && (
495513
<OptionsSelector
496514
options={parsedTags.options}
497515
onSelect={handleOptionSelect}
498516
disabled={isSendingMessage}
499-
enableKeyboardNav={isLastMessage}
517+
enableKeyboardNav={isLastMessage && parsedTags.optionsComplete === true}
518+
streaming={!parsedTags.optionsComplete}
500519
/>
501520
)}
502521
</div>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
88
import { getClientTool } from '@/lib/copilot/tools/client/manager'
99
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
1010
import 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'
1112
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
1213
import { getBlock } from '@/blocks/registry'
1314
import { CLASS_TOOL_METADATA, useCopilotStore } from '@/stores/panel/copilot/store'
@@ -30,9 +31,41 @@ type OptionItem = string | { title: string; description?: string }
3031
interface 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*\{[^}]*"title":\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(/<options>([\s\S]*?)<\/options>/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(/<options>([\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 = /<(plan|options)>[\s\S]*$/i
111+
// This handles cases like "<plan>{..." during streaming
112+
const incompleteTagPattern = /<plan>[\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

Comments
 (0)