@gram-ai/elements 1.22.5 → 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 (56) 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-Dip7A_UI.cjs → index-CVYyyxfm.cjs} +43 -43
  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-D_ZJq5T1.js → index-D-QXb5EF.js} +10930 -10799
  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/plugins/components/PluginLoadingState.d.ts +11 -0
  22. package/dist/plugins/components/index.d.ts +1 -0
  23. package/dist/plugins/generative-ui/component.d.ts +3 -0
  24. package/dist/plugins/generative-ui/index.d.ts +6 -0
  25. package/dist/plugins/index.d.ts +1 -0
  26. package/dist/plugins.cjs +1 -1
  27. package/dist/plugins.js +3 -2
  28. package/dist/{profiler-CTZ-4zgJ.js → profiler-D8-vgPGn.js} +2 -2
  29. package/dist/{profiler-CTZ-4zgJ.js.map → profiler-D8-vgPGn.js.map} +1 -1
  30. package/dist/{profiler-Cucmmy3i.cjs → profiler-Dshm-O8k.cjs} +2 -2
  31. package/dist/{profiler-Cucmmy3i.cjs.map → profiler-Dshm-O8k.cjs.map} +1 -1
  32. package/dist/{startRecording-4CwQzWU_.cjs → startRecording-2p7-xVUh.cjs} +2 -2
  33. package/dist/{startRecording-4CwQzWU_.cjs.map → startRecording-2p7-xVUh.cjs.map} +1 -1
  34. package/dist/{startRecording-DnSD-PJG.js → startRecording-DnWeZRhl.js} +2 -2
  35. package/dist/{startRecording-DnSD-PJG.js.map → startRecording-DnWeZRhl.js.map} +1 -1
  36. package/package.json +7 -2
  37. package/src/components/Chat/stories/Plugins.stories.tsx +116 -0
  38. package/src/components/Chat/stories/Tools.stories.tsx +122 -0
  39. package/src/components/assistant-ui/connection-status-indicator.tsx +134 -0
  40. package/src/components/assistant-ui/thread.tsx +3 -1
  41. package/src/components/ui/generative-ui.tsx +437 -0
  42. package/src/contexts/ConnectionStatusContext.tsx +158 -0
  43. package/src/contexts/ElementsProvider.tsx +133 -25
  44. package/src/contexts/ToolExecutionContext.tsx +101 -0
  45. package/src/lib/generative-ui.ts +18 -0
  46. package/src/plugins/chart/component.tsx +8 -8
  47. package/src/plugins/components/PluginLoadingState.tsx +35 -0
  48. package/src/plugins/components/index.ts +1 -0
  49. package/src/plugins/generative-ui/component.tsx +56 -0
  50. package/src/plugins/generative-ui/index.ts +153 -0
  51. package/src/plugins/index.ts +3 -1
  52. package/dist/index-BdXdd2ZM.js.map +0 -1
  53. package/dist/index-CNVoovK7.cjs +0 -111
  54. package/dist/index-CNVoovK7.cjs.map +0 -1
  55. package/dist/index-D_ZJq5T1.js.map +0 -1
  56. package/dist/index-Dip7A_UI.cjs.map +0 -1
@@ -0,0 +1,158 @@
1
+ 'use client'
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useState,
7
+ useCallback,
8
+ useRef,
9
+ useEffect,
10
+ type ReactNode,
11
+ } from 'react'
12
+
13
+ export type ConnectionState = 'connected' | 'reconnecting' | 'disconnected'
14
+
15
+ interface ConnectionStatusContextValue {
16
+ /** Current connection state */
17
+ state: ConnectionState
18
+ /** Number of reconnection attempts */
19
+ retryCount: number
20
+ /** Whether the browser reports being online */
21
+ isOnline: boolean
22
+ /** Mark connection as failed - will trigger reconnecting state */
23
+ markDisconnected: () => void
24
+ /** Mark connection as restored */
25
+ markConnected: () => void
26
+ /** Reset the connection state */
27
+ reset: () => void
28
+ }
29
+
30
+ const ConnectionStatusContext =
31
+ createContext<ConnectionStatusContextValue | null>(null)
32
+
33
+ interface ConnectionStatusProviderProps {
34
+ children: ReactNode
35
+ }
36
+
37
+ export const ConnectionStatusProvider = ({
38
+ children,
39
+ }: ConnectionStatusProviderProps) => {
40
+ const [state, setState] = useState<ConnectionState>('connected')
41
+ const [retryCount, setRetryCount] = useState(0)
42
+ const [isOnline, setIsOnline] = useState(
43
+ typeof navigator !== 'undefined' ? navigator.onLine : true
44
+ )
45
+ const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
46
+
47
+ // Monitor browser online/offline status
48
+ useEffect(() => {
49
+ if (typeof window === 'undefined') return
50
+
51
+ const handleOnline = () => {
52
+ setIsOnline(true)
53
+
54
+ // Clear any existing timeout
55
+ if (reconnectTimeoutRef.current) {
56
+ clearTimeout(reconnectTimeoutRef.current)
57
+ reconnectTimeoutRef.current = null
58
+ }
59
+
60
+ // Show "Reconnecting..." briefly, then mark as connected
61
+ setState('reconnecting')
62
+ reconnectTimeoutRef.current = setTimeout(() => {
63
+ setState('connected')
64
+ setRetryCount(0)
65
+ }, 1500) // Show reconnecting for 1.5s before clearing
66
+ }
67
+
68
+ const handleOffline = () => {
69
+ setIsOnline(false)
70
+ // Immediately mark as disconnected when browser goes offline
71
+ setState('disconnected')
72
+ if (reconnectTimeoutRef.current) {
73
+ clearTimeout(reconnectTimeoutRef.current)
74
+ reconnectTimeoutRef.current = null
75
+ }
76
+ }
77
+
78
+ window.addEventListener('online', handleOnline)
79
+ window.addEventListener('offline', handleOffline)
80
+
81
+ return () => {
82
+ window.removeEventListener('online', handleOnline)
83
+ window.removeEventListener('offline', handleOffline)
84
+ // Clear any pending timeout to prevent memory leak
85
+ if (reconnectTimeoutRef.current) {
86
+ clearTimeout(reconnectTimeoutRef.current)
87
+ reconnectTimeoutRef.current = null
88
+ }
89
+ }
90
+ }, [])
91
+
92
+ const markDisconnected = useCallback(() => {
93
+ setState('reconnecting')
94
+ setRetryCount((prev) => prev + 1)
95
+
96
+ // After 10 seconds of reconnecting, mark as fully disconnected
97
+ if (reconnectTimeoutRef.current) {
98
+ clearTimeout(reconnectTimeoutRef.current)
99
+ }
100
+ reconnectTimeoutRef.current = setTimeout(() => {
101
+ setState((current) =>
102
+ current === 'reconnecting' ? 'disconnected' : current
103
+ )
104
+ }, 10000)
105
+ }, [])
106
+
107
+ const markConnected = useCallback(() => {
108
+ if (reconnectTimeoutRef.current) {
109
+ clearTimeout(reconnectTimeoutRef.current)
110
+ reconnectTimeoutRef.current = null
111
+ }
112
+ setState('connected')
113
+ setRetryCount(0)
114
+ }, [])
115
+
116
+ const reset = useCallback(() => {
117
+ if (reconnectTimeoutRef.current) {
118
+ clearTimeout(reconnectTimeoutRef.current)
119
+ reconnectTimeoutRef.current = null
120
+ }
121
+ setState('connected')
122
+ setRetryCount(0)
123
+ }, [])
124
+
125
+ return (
126
+ <ConnectionStatusContext.Provider
127
+ value={{
128
+ state,
129
+ retryCount,
130
+ isOnline,
131
+ markDisconnected,
132
+ markConnected,
133
+ reset,
134
+ }}
135
+ >
136
+ {children}
137
+ </ConnectionStatusContext.Provider>
138
+ )
139
+ }
140
+
141
+ export const useConnectionStatus = () => {
142
+ const context = useContext(ConnectionStatusContext)
143
+ if (!context) {
144
+ throw new Error(
145
+ 'useConnectionStatus must be used within a ConnectionStatusProvider'
146
+ )
147
+ }
148
+ return context
149
+ }
150
+
151
+ /**
152
+ * Hook that returns connection status helpers for use in sendMessages.
153
+ * Returns null if not within a ConnectionStatusProvider (for backwards compatibility).
154
+ */
155
+
156
+ export const useConnectionStatusOptional = () => {
157
+ return useContext(ConnectionStatusContext)
158
+ }
@@ -54,6 +54,39 @@ import {
54
54
  import { useAuth } from '../hooks/useAuth'
55
55
  import { ElementsContext } from './contexts'
56
56
  import { ToolApprovalProvider } from './ToolApprovalContext'
57
+ import {
58
+ ConnectionStatusProvider,
59
+ useConnectionStatusOptional,
60
+ } from './ConnectionStatusContext'
61
+ import { ToolExecutionProvider } from './ToolExecutionContext'
62
+
63
+ /**
64
+ * Extracts executable tools from frontend tool definitions.
65
+ * Frontend tools created via defineFrontendTool have an unstable_tool property
66
+ * that contains the tool definition with execute function.
67
+ */
68
+ function extractExecutableTools(
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ frontendTools: Record<string, FrontendTool<any, any>> | undefined
71
+ ): Record<
72
+ string,
73
+ { execute?: (args: unknown, options?: unknown) => Promise<unknown> }
74
+ > {
75
+ if (!frontendTools) return {}
76
+
77
+ return Object.fromEntries(
78
+ Object.entries(frontendTools).map(([name, tool]) => {
79
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
+ const toolDef = (tool as any).unstable_tool
81
+ return [
82
+ name,
83
+ {
84
+ execute: toolDef?.execute,
85
+ },
86
+ ]
87
+ })
88
+ )
89
+ }
57
90
 
58
91
  export interface ElementsProviderProps {
59
92
  children: ReactNode
@@ -159,6 +192,9 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
159
192
  whitelistTool: toolApproval.whitelistTool,
160
193
  })
161
194
 
195
+ // Connection status for tracking network failures
196
+ const connectionStatus = useConnectionStatusOptional()
197
+
162
198
  approvalHelpersRef.current = {
163
199
  requestApproval: toolApproval.requestApproval,
164
200
  isToolApproved: toolApproval.isToolApproved,
@@ -312,13 +348,47 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
312
348
  onError: ({ error }) => {
313
349
  console.error('Stream error in onError callback:', error)
314
350
  trackError(error, { source: 'streaming' })
351
+
352
+ // Check if this is a network/connection error
353
+ const isNetworkError =
354
+ error instanceof TypeError ||
355
+ (error instanceof Error &&
356
+ (error.message.includes('fetch') ||
357
+ error.message.includes('network') ||
358
+ error.message.includes('Failed to fetch') ||
359
+ error.message.includes('NetworkError') ||
360
+ error.message.includes('ECONNREFUSED') ||
361
+ error.message.includes('ETIMEDOUT')))
362
+
363
+ if (isNetworkError) {
364
+ connectionStatus?.markDisconnected()
365
+ }
315
366
  },
316
367
  })
317
368
 
369
+ // Mark as connected when stream starts successfully
370
+ connectionStatus?.markConnected()
371
+
318
372
  return result.toUIMessageStream()
319
373
  } catch (error) {
320
374
  console.error('Error creating stream:', error)
321
375
  trackError(error, { source: 'stream-creation' })
376
+
377
+ // Check if this is a network/connection error
378
+ const isNetworkError =
379
+ error instanceof TypeError ||
380
+ (error instanceof Error &&
381
+ (error.message.includes('fetch') ||
382
+ error.message.includes('network') ||
383
+ error.message.includes('Failed to fetch') ||
384
+ error.message.includes('NetworkError') ||
385
+ error.message.includes('ECONNREFUSED') ||
386
+ error.message.includes('ETIMEDOUT')))
387
+
388
+ if (isNetworkError) {
389
+ connectionStatus?.markDisconnected()
390
+ }
391
+
322
392
  throw error
323
393
  }
324
394
  },
@@ -336,6 +406,7 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
336
406
  apiUrl,
337
407
  auth.headers,
338
408
  auth.isLoading,
409
+ connectionStatus,
339
410
  ]
340
411
  )
341
412
 
@@ -359,6 +430,24 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
359
430
 
360
431
  const frontendTools = config.tools?.frontendTools ?? {}
361
432
 
433
+ // Create combined executable tools for direct tool execution (ActionButton)
434
+ // Uses a simplified type that focuses on the execute function
435
+ type ExecutableToolSet = Record<
436
+ string,
437
+ | { execute?: (args: unknown, options?: unknown) => Promise<unknown> }
438
+ | undefined
439
+ >
440
+ const executableTools = useMemo<ExecutableToolSet>(() => {
441
+ const extractedFrontendTools = extractExecutableTools(
442
+ config.tools?.frontendTools
443
+ )
444
+ // MCP tools and extracted frontend tools both have execute functions
445
+ return {
446
+ ...mcpTools,
447
+ ...extractedFrontendTools,
448
+ } as ExecutableToolSet
449
+ }, [mcpTools, config.tools?.frontendTools])
450
+
362
451
  // Render the appropriate runtime provider based on history config.
363
452
  // We use separate components to avoid conditional hook calls.
364
453
  if (historyEnabled && !auth.isLoading) {
@@ -372,6 +461,7 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
372
461
  frontendTools={frontendTools}
373
462
  localIdToUuidMap={localIdToUuidMapRef.current}
374
463
  currentRemoteIdRef={currentRemoteIdRef}
464
+ executableTools={executableTools}
375
465
  >
376
466
  {children}
377
467
  </ElementsProviderWithHistory>
@@ -384,12 +474,20 @@ const ElementsProviderInner = ({ children, config }: ElementsProviderProps) => {
384
474
  contextValue={contextValue}
385
475
  runtimeRef={runtimeRef}
386
476
  frontendTools={frontendTools}
477
+ executableTools={executableTools}
387
478
  >
388
479
  {children}
389
480
  </ElementsProviderWithoutHistory>
390
481
  )
391
482
  }
392
483
 
484
+ // Shared type for executable tools
485
+ type ExecutableToolSet = Record<
486
+ string,
487
+ | { execute?: (args: unknown, options?: unknown) => Promise<unknown> }
488
+ | undefined
489
+ >
490
+
393
491
  // Separate component for history-enabled mode to avoid conditional hook calls
394
492
  interface ElementsProviderWithHistoryProps {
395
493
  children: ReactNode
@@ -402,6 +500,7 @@ interface ElementsProviderWithHistoryProps {
402
500
  frontendTools: Record<string, AssistantTool | FrontendTool<any, any>>
403
501
  localIdToUuidMap: Map<string, string>
404
502
  currentRemoteIdRef: React.RefObject<string | null>
503
+ executableTools: ExecutableToolSet
405
504
  }
406
505
 
407
506
  /**
@@ -432,6 +531,7 @@ const ElementsProviderWithHistory = ({
432
531
  frontendTools,
433
532
  localIdToUuidMap,
434
533
  currentRemoteIdRef,
534
+ executableTools,
435
535
  }: ElementsProviderWithHistoryProps) => {
436
536
  const threadListAdapter = useGramThreadListAdapter({
437
537
  apiUrl,
@@ -480,17 +580,19 @@ const ElementsProviderWithHistory = ({
480
580
  <ThreadIdSync remoteIdRef={currentRemoteIdRef} />
481
581
  <HistoryProvider>
482
582
  <ElementsContext.Provider value={contextValue}>
483
- <div
484
- className={cn(
485
- ROOT_SELECTOR,
486
- (contextValue?.config.variant === 'standalone' ||
487
- contextValue?.config.variant === 'sidecar') &&
488
- 'h-full'
489
- )}
490
- >
491
- {children}
492
- </div>
493
- <FrontendTools tools={frontendTools} />
583
+ <ToolExecutionProvider tools={executableTools}>
584
+ <div
585
+ className={cn(
586
+ ROOT_SELECTOR,
587
+ (contextValue?.config.variant === 'standalone' ||
588
+ contextValue?.config.variant === 'sidecar') &&
589
+ 'h-full'
590
+ )}
591
+ >
592
+ {children}
593
+ </div>
594
+ <FrontendTools tools={frontendTools} />
595
+ </ToolExecutionProvider>
494
596
  </ElementsContext.Provider>
495
597
  </HistoryProvider>
496
598
  </AssistantRuntimeProvider>
@@ -505,6 +607,7 @@ interface ElementsProviderWithoutHistoryProps {
505
607
  runtimeRef: React.RefObject<ReturnType<typeof useChatRuntime> | null>
506
608
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
507
609
  frontendTools: Record<string, AssistantTool | FrontendTool<any, any>>
610
+ executableTools: ExecutableToolSet
508
611
  }
509
612
 
510
613
  const ElementsProviderWithoutHistory = ({
@@ -513,6 +616,7 @@ const ElementsProviderWithoutHistory = ({
513
616
  contextValue,
514
617
  runtimeRef,
515
618
  frontendTools,
619
+ executableTools,
516
620
  }: ElementsProviderWithoutHistoryProps) => {
517
621
  const runtime = useChatRuntime({ transport })
518
622
 
@@ -524,17 +628,19 @@ const ElementsProviderWithoutHistory = ({
524
628
  return (
525
629
  <AssistantRuntimeProvider runtime={runtime}>
526
630
  <ElementsContext.Provider value={contextValue}>
527
- <div
528
- className={cn(
529
- ROOT_SELECTOR,
530
- (contextValue?.config.variant === 'standalone' ||
531
- contextValue?.config.variant === 'sidecar') &&
532
- 'h-full'
533
- )}
534
- >
535
- {children}
536
- </div>
537
- <FrontendTools tools={frontendTools} />
631
+ <ToolExecutionProvider tools={executableTools}>
632
+ <div
633
+ className={cn(
634
+ ROOT_SELECTOR,
635
+ (contextValue?.config.variant === 'standalone' ||
636
+ contextValue?.config.variant === 'sidecar') &&
637
+ 'h-full'
638
+ )}
639
+ >
640
+ {children}
641
+ </div>
642
+ <FrontendTools tools={frontendTools} />
643
+ </ToolExecutionProvider>
538
644
  </ElementsContext.Provider>
539
645
  </AssistantRuntimeProvider>
540
646
  )
@@ -545,9 +651,11 @@ const queryClient = new QueryClient()
545
651
  export const ElementsProvider = (props: ElementsProviderProps) => {
546
652
  return (
547
653
  <QueryClientProvider client={queryClient}>
548
- <ToolApprovalProvider>
549
- <ElementsProviderInner {...props} />
550
- </ToolApprovalProvider>
654
+ <ConnectionStatusProvider>
655
+ <ToolApprovalProvider>
656
+ <ElementsProviderInner {...props} />
657
+ </ToolApprovalProvider>
658
+ </ConnectionStatusProvider>
551
659
  </QueryClientProvider>
552
660
  )
553
661
  }
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext, useCallback, ReactNode } from 'react'
4
+
5
+ // Define a minimal tool type for direct execution
6
+ // This avoids strict AI SDK type requirements while still being type-safe
7
+ interface ExecutableTool {
8
+ execute?: (args: unknown, options?: unknown) => Promise<unknown>
9
+ }
10
+
11
+ type ExecutableToolSet = Record<string, ExecutableTool | undefined>
12
+
13
+ export interface ToolExecutionResult {
14
+ success: boolean
15
+ result?: unknown
16
+ error?: string
17
+ }
18
+
19
+ interface ToolExecutionContextValue {
20
+ executeTool: (
21
+ toolName: string,
22
+ args: Record<string, unknown>
23
+ ) => Promise<ToolExecutionResult>
24
+ isToolAvailable: (toolName: string) => boolean
25
+ }
26
+
27
+ const ToolExecutionContext = createContext<ToolExecutionContextValue | null>(
28
+ null
29
+ )
30
+
31
+ interface ToolExecutionProviderProps {
32
+ children: ReactNode
33
+ tools: ExecutableToolSet | undefined
34
+ }
35
+
36
+ export function ToolExecutionProvider({
37
+ children,
38
+ tools,
39
+ }: ToolExecutionProviderProps) {
40
+ const executeTool = useCallback(
41
+ async (
42
+ toolName: string,
43
+ args: Record<string, unknown>
44
+ ): Promise<ToolExecutionResult> => {
45
+ if (!tools) {
46
+ return { success: false, error: 'Tools not available' }
47
+ }
48
+
49
+ const tool = tools[toolName]
50
+ if (!tool) {
51
+ return { success: false, error: `Tool "${toolName}" not found` }
52
+ }
53
+
54
+ if (!tool.execute) {
55
+ return {
56
+ success: false,
57
+ error: `Tool "${toolName}" has no execute function`,
58
+ }
59
+ }
60
+
61
+ try {
62
+ // Generate a unique toolCallId for this execution
63
+ const toolCallId = `action-${Date.now()}-${Math.random().toString(36).slice(2)}`
64
+ const result = await tool.execute(args, { toolCallId, messages: [] })
65
+ return { success: true, result }
66
+ } catch (err) {
67
+ const errorMessage =
68
+ err instanceof Error ? err.message : 'Unknown error'
69
+ return { success: false, error: errorMessage }
70
+ }
71
+ },
72
+ [tools]
73
+ )
74
+
75
+ const isToolAvailable = useCallback(
76
+ (toolName: string): boolean => {
77
+ return !!tools?.[toolName]?.execute
78
+ },
79
+ [tools]
80
+ )
81
+
82
+ return (
83
+ <ToolExecutionContext.Provider value={{ executeTool, isToolAvailable }}>
84
+ {children}
85
+ </ToolExecutionContext.Provider>
86
+ )
87
+ }
88
+
89
+ export function useToolExecution(): ToolExecutionContextValue {
90
+ const context = useContext(ToolExecutionContext)
91
+ if (!context) {
92
+ return {
93
+ executeTool: async (): Promise<ToolExecutionResult> => ({
94
+ success: false,
95
+ error: 'ToolExecutionProvider not found',
96
+ }),
97
+ isToolAvailable: () => false,
98
+ }
99
+ }
100
+ return context
101
+ }
@@ -0,0 +1,18 @@
1
+ // Type for json-render tree structure
2
+ export interface JsonRenderNode {
3
+ type: string
4
+ props?: Record<string, unknown>
5
+ children?: JsonRenderNode[]
6
+ }
7
+
8
+ /**
9
+ * Check if content is a json-render compatible tree structure
10
+ */
11
+ export function isJsonRenderTree(content: unknown): content is JsonRenderNode {
12
+ return (
13
+ typeof content === 'object' &&
14
+ content !== null &&
15
+ 'type' in content &&
16
+ typeof (content as JsonRenderNode).type === 'string'
17
+ )
18
+ }
@@ -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
+ }