@gram-ai/elements 1.22.4 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/components/Chat/stories/Plugins.stories.d.ts +6 -0
  2. package/dist/components/Chat/stories/Tools.stories.d.ts +15 -0
  3. package/dist/components/assistant-ui/connection-status-indicator.d.ts +16 -0
  4. package/dist/components/ui/button.d.ts +1 -1
  5. package/dist/components/ui/buttonVariants.d.ts +1 -1
  6. package/dist/components/ui/generative-ui.d.ts +13 -0
  7. package/dist/contexts/ConnectionStatusContext.d.ts +27 -0
  8. package/dist/contexts/ToolExecutionContext.d.ts +21 -0
  9. package/dist/elements.cjs +1 -1
  10. package/dist/elements.css +1 -1
  11. package/dist/elements.js +2 -2
  12. package/dist/index-CVYyyxfm.cjs +147 -0
  13. package/dist/index-CVYyyxfm.cjs.map +1 -0
  14. package/dist/index-Co05S1C8.cjs +251 -0
  15. package/dist/index-Co05S1C8.cjs.map +1 -0
  16. package/dist/{index-DqUXeR5-.js → index-D-QXb5EF.js} +10579 -10282
  17. package/dist/index-D-QXb5EF.js.map +1 -0
  18. package/dist/{index-BdXdd2ZM.js → index-vM3v0unX.js} +8442 -7926
  19. package/dist/index-vM3v0unX.js.map +1 -0
  20. package/dist/lib/generative-ui.d.ts +9 -0
  21. package/dist/lib/messageConverter.d.ts +13 -7
  22. package/dist/plugins/components/PluginLoadingState.d.ts +11 -0
  23. package/dist/plugins/components/index.d.ts +1 -0
  24. package/dist/plugins/generative-ui/component.d.ts +3 -0
  25. package/dist/plugins/generative-ui/index.d.ts +6 -0
  26. package/dist/plugins/index.d.ts +1 -0
  27. package/dist/plugins.cjs +1 -1
  28. package/dist/plugins.js +3 -2
  29. package/dist/{profiler-Bc9bwSPu.js → profiler-D8-vgPGn.js} +2 -2
  30. package/dist/{profiler-Bc9bwSPu.js.map → profiler-D8-vgPGn.js.map} +1 -1
  31. package/dist/{profiler-BBSi_UtN.cjs → profiler-Dshm-O8k.cjs} +2 -2
  32. package/dist/{profiler-BBSi_UtN.cjs.map → profiler-Dshm-O8k.cjs.map} +1 -1
  33. package/dist/{startRecording-DsKSBkyg.cjs → startRecording-2p7-xVUh.cjs} +2 -2
  34. package/dist/{startRecording-DsKSBkyg.cjs.map → startRecording-2p7-xVUh.cjs.map} +1 -1
  35. package/dist/{startRecording-DC-9cgyZ.js → startRecording-DnWeZRhl.js} +2 -2
  36. package/dist/{startRecording-DC-9cgyZ.js.map → startRecording-DnWeZRhl.js.map} +1 -1
  37. package/package.json +8 -2
  38. package/src/components/Chat/stories/Plugins.stories.tsx +116 -0
  39. package/src/components/Chat/stories/Tools.stories.tsx +122 -0
  40. package/src/components/assistant-ui/connection-status-indicator.tsx +134 -0
  41. package/src/components/assistant-ui/thread.tsx +3 -1
  42. package/src/components/ui/generative-ui.tsx +437 -0
  43. package/src/contexts/ConnectionStatusContext.tsx +158 -0
  44. package/src/contexts/ElementsProvider.tsx +133 -25
  45. package/src/contexts/ToolExecutionContext.tsx +101 -0
  46. package/src/hooks/useGramThreadListAdapter.tsx +43 -46
  47. package/src/lib/generative-ui.ts +18 -0
  48. package/src/lib/messageConverter.ts +330 -57
  49. package/src/lib.d.ts +1 -0
  50. package/src/plugins/chart/component.tsx +8 -8
  51. package/src/plugins/components/PluginLoadingState.tsx +35 -0
  52. package/src/plugins/components/index.ts +1 -0
  53. package/src/plugins/generative-ui/component.tsx +56 -0
  54. package/src/plugins/generative-ui/index.ts +153 -0
  55. package/src/plugins/index.ts +3 -1
  56. package/dist/index-BdXdd2ZM.js.map +0 -1
  57. package/dist/index-C1TX1kmi.cjs +0 -145
  58. package/dist/index-C1TX1kmi.cjs.map +0 -1
  59. package/dist/index-CNVoovK7.cjs +0 -111
  60. package/dist/index-CNVoovK7.cjs.map +0 -1
  61. package/dist/index-DqUXeR5-.js.map +0 -1
@@ -1,3 +1,5 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
1
3
  /**
2
4
  * Message format converter for Gram API <-> assistant-ui.
3
5
  *
@@ -17,19 +19,22 @@ import type {
17
19
  ThreadAssistantMessagePart,
18
20
  TextMessagePart,
19
21
  } from '@assistant-ui/react'
22
+ import type {
23
+ Message,
24
+ UserMessage,
25
+ AssistantMessage,
26
+ ToolResponseMessage,
27
+ } from '@openrouter/sdk/models'
28
+ import { UIMessage } from 'ai'
20
29
 
21
30
  /**
22
31
  * Represents a chat message from the Gram API.
23
32
  * This mirrors the ChatMessage type from @gram/sdk without requiring the SDK dependency.
24
33
  */
25
- export interface GramChatMessage {
34
+ export type GramChatMessage = Message & {
26
35
  id: string
27
- role: string
28
- content?: string
29
36
  model: string
30
- toolCallId?: string
31
- toolCalls?: string
32
- createdAt: Date | string
37
+ created_at: Date | string
33
38
  }
34
39
 
35
40
  /**
@@ -57,17 +62,6 @@ export interface GramChatOverview {
57
62
  updatedAt: Date | string
58
63
  }
59
64
 
60
- /**
61
- * Normalizes a role string to valid ThreadMessage roles.
62
- */
63
- function normalizeRole(role: string): 'user' | 'assistant' | 'system' {
64
- if (role === 'user') return 'user'
65
- if (role === 'assistant') return 'assistant'
66
- if (role === 'system') return 'system'
67
- // Tool role messages should be handled differently, but for now treat as assistant
68
- return 'assistant'
69
- }
70
-
71
65
  /**
72
66
  * Parses a date that might be a string or Date object.
73
67
  */
@@ -79,21 +73,64 @@ function parseDate(date: Date | string): Date {
79
73
  * Builds content parts for a user message.
80
74
  */
81
75
  function buildUserContentParts(msg: GramChatMessage): ThreadUserMessagePart[] {
82
- const parts: ThreadUserMessagePart[] = []
76
+ if (msg.role !== 'user') {
77
+ return []
78
+ }
83
79
 
84
- if (msg.content) {
85
- parts.push({
86
- type: 'text',
87
- text: msg.content,
88
- } as TextMessagePart)
80
+ if (typeof msg.content === 'string' || !msg.content) {
81
+ return [
82
+ {
83
+ type: 'text',
84
+ text: msg.content ?? '',
85
+ },
86
+ ]
89
87
  }
90
88
 
91
- // Return at least an empty text part if no content
92
- if (parts.length === 0) {
93
- parts.push({
94
- type: 'text',
95
- text: '',
96
- } as TextMessagePart)
89
+ const parts: ThreadUserMessagePart[] = []
90
+
91
+ for (const item of msg.content) {
92
+ switch (item.type) {
93
+ case 'text':
94
+ parts.push({
95
+ type: 'text',
96
+ text: item.text,
97
+ })
98
+ break
99
+ case 'image_url':
100
+ parts.push({
101
+ type: 'image',
102
+ image: (item as any).image_url?.url as FIXME<
103
+ string,
104
+ 'Fixed by switching to Gram TS SDK.'
105
+ >,
106
+ })
107
+ break
108
+ case 'input_audio': {
109
+ const format = (item as any).input_audio?.format as FIXME<
110
+ string,
111
+ 'Fixed by switching to Gram TS SDK.'
112
+ >
113
+ if (format === 'mp3' || format === 'wav') {
114
+ parts.push({
115
+ type: 'audio',
116
+ audio: {
117
+ data: (item as any).input_audio.data as FIXME<
118
+ string,
119
+ 'Fixed by switching to Gram TS SDK.'
120
+ >,
121
+ format: format,
122
+ },
123
+ })
124
+ }
125
+ break
126
+ }
127
+ default:
128
+ parts.push({
129
+ type: 'text',
130
+ text: '',
131
+ })
132
+ break
133
+ }
97
134
  }
98
135
 
99
136
  return parts
@@ -105,33 +142,43 @@ function buildUserContentParts(msg: GramChatMessage): ThreadUserMessagePart[] {
105
142
  function buildAssistantContentParts(
106
143
  msg: GramChatMessage
107
144
  ): ThreadAssistantMessagePart[] {
145
+ if (msg.role !== 'assistant') {
146
+ return []
147
+ }
148
+
149
+ if (typeof msg.content === 'string' || !msg.content) {
150
+ return [
151
+ {
152
+ type: 'text',
153
+ text: msg.content ?? '',
154
+ },
155
+ ]
156
+ }
157
+
108
158
  const parts: ThreadAssistantMessagePart[] = []
109
159
 
110
- if (msg.content) {
111
- parts.push({
112
- type: 'text',
113
- text: msg.content,
114
- } as TextMessagePart)
160
+ const toolCallsJSON = (msg as any).tool_calls as FIXME<
161
+ string | undefined,
162
+ 'Fixed by switching to Gram TS SDK.'
163
+ >
164
+
165
+ let toolCalls = tryParseJSON(toolCallsJSON || '[]')
166
+ if (!Array.isArray(toolCalls)) {
167
+ console.warn('Invalid tool_calls format, expected an array.')
168
+ toolCalls = []
115
169
  }
116
170
 
117
- if (msg.toolCalls) {
118
- try {
119
- const toolCalls = JSON.parse(msg.toolCalls)
120
- for (const tc of toolCalls) {
121
- const args = tc.function?.arguments ?? tc.args ?? {}
122
- const argsText = typeof args === 'string' ? args : JSON.stringify(args)
123
- parts.push({
124
- type: 'tool-call',
125
- toolCallId: tc.id ?? tc.toolCallId ?? '',
126
- toolName: tc.function?.name ?? tc.toolName ?? '',
127
- args: typeof args === 'string' ? JSON.parse(args) : args,
128
- argsText,
129
- result: undefined,
130
- } as ThreadAssistantMessagePart)
131
- }
132
- } catch {
133
- // Ignore JSON parse errors for tool calls
134
- }
171
+ for (const tc of toolCalls) {
172
+ const args = tc.function?.arguments ?? tc.args ?? {}
173
+ const argsText = typeof args === 'string' ? args : JSON.stringify(args)
174
+ parts.push({
175
+ type: 'tool-call',
176
+ toolCallId: tc.id ?? tc.toolCallId ?? '',
177
+ toolName: tc.function?.name ?? tc.toolName ?? '',
178
+ args: typeof args === 'string' ? JSON.parse(args) : args,
179
+ argsText,
180
+ result: undefined,
181
+ } as ThreadAssistantMessagePart)
135
182
  }
136
183
 
137
184
  // Return at least an empty text part if no content
@@ -145,14 +192,34 @@ function buildAssistantContentParts(
145
192
  return parts
146
193
  }
147
194
 
195
+ function buildSystemContentParts(msg: GramChatMessage): [TextMessagePart] {
196
+ if (msg.role !== 'system') {
197
+ return [{ type: 'text', text: '' }]
198
+ }
199
+
200
+ if (typeof msg.content === 'string' || !msg.content) {
201
+ return [{ type: 'text', text: msg.content ?? '' }]
202
+ }
203
+
204
+ const text: string[] = []
205
+
206
+ for (const item of msg.content) {
207
+ if (item.type !== 'text') {
208
+ continue
209
+ }
210
+ text.push(item.text)
211
+ }
212
+
213
+ return [{ type: 'text', text: text.join('\n') }]
214
+ }
215
+
148
216
  /**
149
217
  * Converts a single Gram ChatMessage to a ThreadMessage.
150
218
  */
151
219
  function convertGramMessageToThreadMessage(
152
220
  msg: GramChatMessage
153
221
  ): ThreadMessage {
154
- const role = normalizeRole(msg.role)
155
- const createdAt = parseDate(msg.createdAt)
222
+ const createdAt = parseDate(msg.created_at)
156
223
 
157
224
  const baseMetadata = {
158
225
  unstable_state: undefined,
@@ -163,7 +230,7 @@ function convertGramMessageToThreadMessage(
163
230
  custom: {},
164
231
  }
165
232
 
166
- if (role === 'user') {
233
+ if (msg.role === 'user') {
167
234
  return {
168
235
  id: msg.id,
169
236
  role: 'user',
@@ -174,12 +241,12 @@ function convertGramMessageToThreadMessage(
174
241
  }
175
242
  }
176
243
 
177
- if (role === 'system') {
244
+ if (msg.role === 'system') {
178
245
  return {
179
246
  id: msg.id,
180
247
  role: 'system',
181
248
  createdAt,
182
- content: [{ type: 'text', text: msg.content ?? '' }],
249
+ content: buildSystemContentParts(msg),
183
250
  metadata: baseMetadata,
184
251
  }
185
252
  }
@@ -239,3 +306,209 @@ export function convertGramMessagesToExported(
239
306
  headId: prevId,
240
307
  }
241
308
  }
309
+
310
+ export function convertGramMessagesToUIMessages(messages: GramChatMessage[]): {
311
+ headId: string | null
312
+ messages: { parentId: string | null; message: UIMessage }[]
313
+ } {
314
+ if (messages.length === 0) {
315
+ return { messages: [], headId: null }
316
+ }
317
+
318
+ const toolCallResults = new Map<string, ToolResponseMessage>()
319
+ for (const msg of messages) {
320
+ if (msg.role !== 'tool') {
321
+ continue
322
+ }
323
+ const id = (msg as any).tool_call_id
324
+ if (typeof id !== 'string') {
325
+ continue
326
+ }
327
+
328
+ toolCallResults.set(id, msg as ToolResponseMessage)
329
+ }
330
+
331
+ const uiMessages: { parentId: string | null; message: UIMessage }[] = []
332
+ let prevId: string | null = null
333
+
334
+ for (const msg of messages) {
335
+ switch (msg.role) {
336
+ case 'developer':
337
+ case 'tool':
338
+ continue
339
+ case 'system': {
340
+ uiMessages.push({
341
+ parentId: prevId,
342
+ message: {
343
+ id: msg.id,
344
+ role: 'system',
345
+ parts: [
346
+ {
347
+ type: 'text',
348
+ text:
349
+ typeof msg.content === 'string'
350
+ ? msg.content
351
+ : Array.isArray(msg.content)
352
+ ? msg.content
353
+ .filter((item) => item.type === 'text')
354
+ .map((item) => item.text)
355
+ .join('\n')
356
+ : '',
357
+ },
358
+ ],
359
+ },
360
+ })
361
+ break
362
+ }
363
+ case 'user': {
364
+ uiMessages.push({
365
+ parentId: prevId,
366
+ message: {
367
+ id: msg.id,
368
+ role: 'user',
369
+ parts: convertGramMessagePartsToUIMessageParts(
370
+ msg,
371
+ toolCallResults
372
+ ),
373
+ },
374
+ })
375
+ break
376
+ }
377
+ case 'assistant': {
378
+ const uiMessage = {
379
+ parentId: prevId,
380
+ message: {
381
+ id: msg.id,
382
+ role: 'assistant',
383
+ parts: convertGramMessagePartsToUIMessageParts(
384
+ msg,
385
+ toolCallResults
386
+ ),
387
+ } satisfies UIMessage,
388
+ }
389
+ uiMessages.push(uiMessage)
390
+
391
+ break
392
+ }
393
+ }
394
+
395
+ prevId = msg.id
396
+ }
397
+
398
+ return {
399
+ messages: uiMessages,
400
+ headId: prevId,
401
+ }
402
+ }
403
+
404
+ export function convertGramMessagePartsToUIMessageParts(
405
+ msg: UserMessage | AssistantMessage,
406
+ toolResults: Map<string, ToolResponseMessage>
407
+ ): UIMessage['parts'] {
408
+ const uiparts: UIMessage['parts'] = []
409
+
410
+ if (typeof msg.content === 'string' && msg.content) {
411
+ uiparts.push({
412
+ type: 'text',
413
+ text: msg.content,
414
+ })
415
+ }
416
+
417
+ const content = Array.isArray(msg.content) ? msg.content : []
418
+ for (const p of content) {
419
+ switch (p.type) {
420
+ case 'text': {
421
+ uiparts.push({
422
+ type: 'text',
423
+ text: p.text,
424
+ })
425
+ break
426
+ }
427
+ case 'image_url': {
428
+ const url = (p as any).image_url?.url as FIXME<
429
+ string | undefined,
430
+ 'Fixed by switching to Gram TS SDK.'
431
+ >
432
+ if (!url) {
433
+ break
434
+ }
435
+
436
+ uiparts.push({
437
+ type: 'file',
438
+ url,
439
+ mediaType: mediaTypeFromURL(url),
440
+ })
441
+ break
442
+ }
443
+ case 'input_audio': {
444
+ const url = (p as any).input_audio?.data as FIXME<
445
+ string | undefined,
446
+ 'Fixed by switching to Gram TS SDK.'
447
+ >
448
+ if (!url) {
449
+ break
450
+ }
451
+
452
+ uiparts.push({
453
+ type: 'file',
454
+ url,
455
+ mediaType: mediaTypeFromURL(url),
456
+ })
457
+ break
458
+ }
459
+ }
460
+ }
461
+
462
+ if (msg.role === 'assistant' && msg.reasoning) {
463
+ uiparts.push({
464
+ type: 'reasoning',
465
+ text: msg.reasoning,
466
+ })
467
+ }
468
+
469
+ if (msg.role === 'assistant' && (msg as any).tool_calls) {
470
+ const toolCallsJSON = (msg as any).tool_calls as FIXME<
471
+ string,
472
+ 'Fixed by switching to Gram TS SDK.'
473
+ >
474
+ let toolCalls = tryParseJSON<AssistantMessage['toolCalls']>(
475
+ toolCallsJSON || '[]'
476
+ )
477
+ if (!Array.isArray(toolCalls)) {
478
+ console.warn('Invalid tool_calls format, expected an array.')
479
+ toolCalls = []
480
+ }
481
+
482
+ for (const tc of toolCalls) {
483
+ const content = toolResults.get(tc.id)?.content
484
+ uiparts.push({
485
+ type: 'dynamic-tool',
486
+ toolCallId: tc.id,
487
+ toolName: tc.function?.name ?? '',
488
+ state: 'output-available',
489
+ input: tc.function?.arguments ?? {},
490
+ output: typeof content === 'string' ? tryParseJSON(content) : '',
491
+ })
492
+ }
493
+ }
494
+
495
+ return uiparts
496
+ }
497
+
498
+ function mediaTypeFromURL(url: string): string {
499
+ const unspecified = 'unknown/unknown'
500
+ if (!url.startsWith('data:')) {
501
+ return unspecified
502
+ }
503
+
504
+ const match = url.match(/^data:([^;]+);/)
505
+ return match?.[1] || unspecified
506
+ }
507
+
508
+ function tryParseJSON<T = any>(str: string): T | null {
509
+ try {
510
+ return JSON.parse(str) as T
511
+ } catch {
512
+ return null
513
+ }
514
+ }
package/src/lib.d.ts ADDED
@@ -0,0 +1 @@
1
+ type FIXME<T, S extends string> = (T & S) | T
@@ -8,6 +8,7 @@ import { AlertCircleIcon } from 'lucide-react'
8
8
  import { FC, useEffect, useMemo, useRef, useState } from 'react'
9
9
  import { parse, View, Warn } from 'vega'
10
10
  import { expressionInterpreter } from 'vega-interpreter'
11
+ import { PluginLoadingState } from '../components/PluginLoadingState'
11
12
 
12
13
  export const ChartRenderer: FC<SyntaxHighlighterProps> = ({ code }) => {
13
14
  const containerRef = useRef<HTMLDivElement>(null)
@@ -84,21 +85,20 @@ export const ChartRenderer: FC<SyntaxHighlighterProps> = ({ code }) => {
84
85
  }
85
86
  }, [shouldRender, parsedSpec])
86
87
 
88
+ // Show loading state while JSON is incomplete/streaming
89
+ if (!shouldRender && !error) {
90
+ return <PluginLoadingState text="Rendering chart..." />
91
+ }
92
+
87
93
  return (
88
94
  <div
89
95
  className={cn(
90
96
  // the after:hidden is to prevent assistant-ui from showing its default code block loading indicator
91
- 'relative min-h-[400px] w-fit max-w-full min-w-[400px] overflow-auto border p-6 after:hidden',
97
+ 'border-border relative min-h-[400px] w-fit max-w-full min-w-[400px] overflow-auto border after:hidden',
92
98
  r('lg'),
93
99
  d('p-lg')
94
100
  )}
95
101
  >
96
- {!shouldRender && !error && (
97
- <div className="shimmer text-muted-foreground bg-background/80 absolute inset-0 z-10 flex items-center justify-center">
98
- Rendering chart...
99
- </div>
100
- )}
101
-
102
102
  {error && (
103
103
  <div className="bg-background absolute inset-0 z-10 flex items-center justify-center gap-2 text-rose-500">
104
104
  <AlertCircleIcon name="alert-circle" className="h-4 w-4" />
@@ -106,7 +106,7 @@ export const ChartRenderer: FC<SyntaxHighlighterProps> = ({ code }) => {
106
106
  </div>
107
107
  )}
108
108
 
109
- <div ref={containerRef} className={!shouldRender ? 'hidden' : 'block'} />
109
+ <div ref={containerRef} className={error ? 'hidden' : 'block'} />
110
110
  </div>
111
111
  )
112
112
  }
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ import { useRadius } from '@/hooks/useRadius'
4
+ import { cn } from '@/lib/utils'
5
+ import { FC } from 'react'
6
+
7
+ interface PluginLoadingStateProps {
8
+ text: string
9
+ className?: string
10
+ }
11
+
12
+ /**
13
+ * Shared loading state component for plugins.
14
+ * Displays a shimmer effect with loading text.
15
+ */
16
+ export const PluginLoadingState: FC<PluginLoadingStateProps> = ({
17
+ text,
18
+ className,
19
+ }) => {
20
+ const r = useRadius()
21
+
22
+ return (
23
+ <div
24
+ className={cn(
25
+ 'border-border bg-card relative min-h-[400px] w-fit max-w-full min-w-[400px] overflow-hidden border after:hidden',
26
+ r('lg'),
27
+ className
28
+ )}
29
+ >
30
+ <div className="shimmer text-muted-foreground absolute inset-0 flex items-center justify-center">
31
+ {text}
32
+ </div>
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1 @@
1
+ export { PluginLoadingState } from './PluginLoadingState'
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import { GenerativeUI } from '@/components/ui/generative-ui'
4
+ import { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
5
+ import { FC, useMemo } from 'react'
6
+ import { PluginLoadingState } from '../components/PluginLoadingState'
7
+
8
+ const loadingMessages = [
9
+ 'Preparing your data...',
10
+ 'Building your view...',
11
+ 'Generating results...',
12
+ 'Loading content...',
13
+ 'Fetching information...',
14
+ 'Processing your request...',
15
+ 'Almost ready...',
16
+ 'Setting things up...',
17
+ ]
18
+
19
+ function getRandomLoadingMessage() {
20
+ return loadingMessages[Math.floor(Math.random() * loadingMessages.length)]
21
+ }
22
+
23
+ export const GenerativeUIRenderer: FC<SyntaxHighlighterProps> = ({ code }) => {
24
+ // Parse JSON - returns null if invalid (still streaming)
25
+ const content = useMemo(() => {
26
+ const trimmedCode = code.trim()
27
+ if (!trimmedCode) return null
28
+
29
+ try {
30
+ const parsed = JSON.parse(trimmedCode)
31
+ // Validate it has a type field (basic json-render structure)
32
+ if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
33
+ return null
34
+ }
35
+ return parsed
36
+ } catch {
37
+ // JSON is incomplete (still streaming) - return null to show loading state
38
+ return null
39
+ }
40
+ }, [code])
41
+
42
+ // Memoize the loading message so it doesn't change on every render
43
+ const loadingMessage = useMemo(() => getRandomLoadingMessage(), [])
44
+
45
+ // Show loading shimmer while JSON is incomplete/streaming
46
+ if (!content) {
47
+ return <PluginLoadingState text={loadingMessage} />
48
+ }
49
+
50
+ // Render without outer border - the Card component inside provides the border
51
+ return (
52
+ <div className="overflow-hidden after:hidden">
53
+ <GenerativeUI content={content} />
54
+ </div>
55
+ )
56
+ }