@gram-ai/elements 1.23.0 → 1.24.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 (64) hide show
  1. package/README.md +61 -0
  2. package/dist/components/Chat/stories/MessageFeedback.stories.d.ts +28 -0
  3. package/dist/components/Chat/stories/Thread.stories.d.ts +15 -0
  4. package/dist/components/Replay.d.ts +11 -0
  5. package/dist/components/Replay.stories.d.ts +34 -0
  6. package/dist/components/assistant-ui/follow-on-suggestions.d.ts +6 -0
  7. package/dist/components/assistant-ui/message-feedback.d.ts +9 -0
  8. package/dist/contexts/ReplayContext.d.ts +6 -0
  9. package/dist/elements.cjs +1 -1
  10. package/dist/elements.css +1 -1
  11. package/dist/elements.js +10 -8
  12. package/dist/hooks/useFollowOnSuggestions.d.ts +14 -0
  13. package/dist/hooks/useMCPTools.d.ts +4 -3
  14. package/dist/hooks/useRecordCassette.d.ts +32 -0
  15. package/dist/{index-D-QXb5EF.js → index-B_7BIEfu.js} +11591 -10174
  16. package/dist/index-B_7BIEfu.js.map +1 -0
  17. package/dist/index-Bps9R2k6.cjs +178 -0
  18. package/dist/index-Bps9R2k6.cjs.map +1 -0
  19. package/dist/{index-vM3v0unX.js → index-CtyV0c-T.js} +5477 -5450
  20. package/dist/index-CtyV0c-T.js.map +1 -0
  21. package/dist/{index-Co05S1C8.cjs → index-iUSSoKFz.cjs} +12 -12
  22. package/dist/index-iUSSoKFz.cjs.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/lib/cassette.d.ts +46 -0
  25. package/dist/plugins.cjs +1 -1
  26. package/dist/plugins.js +1 -1
  27. package/dist/{profiler-Dshm-O8k.cjs → profiler-B6MySCOn.cjs} +2 -2
  28. package/dist/{profiler-Dshm-O8k.cjs.map → profiler-B6MySCOn.cjs.map} +1 -1
  29. package/dist/{profiler-D8-vgPGn.js → profiler-BWBC_MF5.js} +2 -2
  30. package/dist/{profiler-D8-vgPGn.js.map → profiler-BWBC_MF5.js.map} +1 -1
  31. package/dist/{startRecording-DnWeZRhl.js → startRecording-52IGlVhJ.js} +2 -2
  32. package/dist/{startRecording-DnWeZRhl.js.map → startRecording-52IGlVhJ.js.map} +1 -1
  33. package/dist/{startRecording-2p7-xVUh.cjs → startRecording-9LKBFNDH.cjs} +2 -2
  34. package/dist/{startRecording-2p7-xVUh.cjs.map → startRecording-9LKBFNDH.cjs.map} +1 -1
  35. package/dist/types/index.d.ts +37 -0
  36. package/package.json +5 -4
  37. package/src/components/Chat/stories/MessageFeedback.stories.tsx +169 -0
  38. package/src/components/Chat/stories/Thread.stories.tsx +78 -0
  39. package/src/components/Replay.stories.tsx +326 -0
  40. package/src/components/Replay.tsx +241 -0
  41. package/src/components/assistant-ui/follow-on-suggestions.tsx +122 -0
  42. package/src/components/assistant-ui/markdown-text.tsx +6 -7
  43. package/src/components/assistant-ui/message-feedback.tsx +177 -0
  44. package/src/components/assistant-ui/thread.tsx +319 -80
  45. package/src/components/assistant-ui/tool-group.tsx +27 -16
  46. package/src/components/ui/tool-ui.tsx +59 -17
  47. package/src/contexts/ElementsProvider.tsx +7 -2
  48. package/src/contexts/ReplayContext.ts +7 -0
  49. package/src/global.css +14 -8
  50. package/src/hooks/useAuth.ts +3 -0
  51. package/src/hooks/useFollowOnSuggestions.ts +237 -0
  52. package/src/hooks/useMCPTools.ts +32 -12
  53. package/src/hooks/useRecordCassette.ts +79 -0
  54. package/src/index.ts +10 -0
  55. package/src/lib/cassette.ts +260 -0
  56. package/src/plugins/chart/component.tsx +48 -11
  57. package/src/plugins/components/PluginLoadingState.tsx +2 -2
  58. package/src/types/index.ts +40 -0
  59. package/src/vite-env.d.ts +1 -0
  60. package/dist/index-CVYyyxfm.cjs +0 -147
  61. package/dist/index-CVYyyxfm.cjs.map +0 -1
  62. package/dist/index-Co05S1C8.cjs.map +0 -1
  63. package/dist/index-D-QXb5EF.js.map +0 -1
  64. package/dist/index-vM3v0unX.js.map +0 -1
@@ -4,7 +4,9 @@ import {
4
4
  CheckIcon,
5
5
  ChevronLeftIcon,
6
6
  ChevronRightIcon,
7
+ CircleIcon,
7
8
  CopyIcon,
9
+ DownloadIcon,
8
10
  PencilIcon,
9
11
  Settings2,
10
12
  Square,
@@ -18,11 +20,20 @@ import {
18
20
  ImageMessagePartProps,
19
21
  MessagePrimitive,
20
22
  ThreadPrimitive,
23
+ useAssistantState,
21
24
  } from '@assistant-ui/react'
22
25
 
23
26
  import { LazyMotion, MotionConfig, domAnimation } from 'motion/react'
24
27
  import * as m from 'motion/react-m'
25
- import { useEffect, useRef, useState, type FC } from 'react'
28
+ import {
29
+ createContext,
30
+ useContext,
31
+ useEffect,
32
+ useMemo,
33
+ useRef,
34
+ useState,
35
+ type FC,
36
+ } from 'react'
26
37
  import { AnimatePresence } from 'motion/react'
27
38
 
28
39
  import {
@@ -30,7 +41,9 @@ import {
30
41
  ComposerAttachments,
31
42
  UserMessageAttachments,
32
43
  } from '@/components/assistant-ui/attachment'
44
+ import { FollowOnSuggestions } from '@/components/assistant-ui/follow-on-suggestions'
33
45
  import { MarkdownText } from '@/components/assistant-ui/markdown-text'
46
+ import { MessageFeedback } from '@/components/assistant-ui/message-feedback'
34
47
  import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning'
35
48
  import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
36
49
  import { ToolMentionAutocomplete } from '@/components/assistant-ui/tool-mention-autocomplete'
@@ -46,6 +59,8 @@ import { useThemeProps } from '@/hooks/useThemeProps'
46
59
  import { EASE_OUT_QUINT } from '@/lib/easing'
47
60
  import { MODELS } from '@/lib/models'
48
61
  import { cn } from '@/lib/utils'
62
+ import { useRecordCassette } from '@/hooks/useRecordCassette'
63
+ import { useReplayContext } from '@/contexts/ReplayContext'
49
64
  import { ConnectionStatusIndicatorSafe } from './connection-status-indicator'
50
65
  import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
51
66
  import {
@@ -56,6 +71,23 @@ import {
56
71
  } from '../ui/tooltip'
57
72
  import { ToolGroup } from './tool-group'
58
73
 
74
+ // Context for chat resolution state
75
+ const ChatResolutionContext = createContext<{
76
+ isResolved: boolean
77
+ feedbackHidden: boolean
78
+ setResolved: () => void
79
+ setUnresolved: () => void
80
+ resetFeedbackHidden: () => void
81
+ }>({
82
+ isResolved: false,
83
+ feedbackHidden: false,
84
+ setResolved: () => {},
85
+ setUnresolved: () => {},
86
+ resetFeedbackHidden: () => {},
87
+ })
88
+
89
+ const useChatResolution = () => useContext(ChatResolutionContext)
90
+
59
91
  const StaticSessionWarning = () => (
60
92
  <div className="m-2 rounded-md border border-amber-500 bg-amber-100 px-4 py-3 text-sm text-amber-800 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-200">
61
93
  <strong>Warning:</strong> You are using a static session token in the
@@ -81,52 +113,89 @@ export const Thread: FC<ThreadProps> = ({ className }) => {
81
113
  const { config } = useElements()
82
114
  const components = config.components ?? {}
83
115
  const showStaticSessionWarning = config.api && 'sessionToken' in config.api
116
+ const showFeedback = config.thread?.experimental_showFeedback ?? false
117
+ const [isResolved, setIsResolved] = useState(false)
118
+ const [feedbackHidden, setFeedbackHidden] = useState(false)
119
+
120
+ const setResolved = () => setIsResolved(true)
121
+ const setUnresolved = () => {
122
+ setIsResolved(false)
123
+ setFeedbackHidden(true)
124
+ }
125
+ const resetFeedbackHidden = () => setFeedbackHidden(false)
84
126
 
85
127
  return (
86
- <LazyMotion features={domAnimation}>
87
- <MotionConfig reducedMotion="user">
88
- <ThreadPrimitive.Root
89
- className={cn(
90
- 'aui-root aui-thread-root bg-background @container relative flex h-full flex-col',
91
- themeProps.className,
92
- className
93
- )}
94
- >
95
- <ConnectionStatusIndicatorSafe />
96
- <ThreadPrimitive.Viewport
128
+ <ChatResolutionContext.Provider
129
+ value={{
130
+ isResolved: showFeedback && isResolved,
131
+ feedbackHidden,
132
+ setResolved,
133
+ setUnresolved,
134
+ resetFeedbackHidden,
135
+ }}
136
+ >
137
+ <LazyMotion features={domAnimation}>
138
+ <MotionConfig reducedMotion="user">
139
+ <ThreadPrimitive.Root
97
140
  className={cn(
98
- 'aui-thread-viewport relative mx-auto flex w-full flex-1 flex-col overflow-x-auto overflow-y-scroll pb-0!',
99
- d('p-lg')
141
+ 'aui-root aui-thread-root bg-background @container relative flex h-full flex-col',
142
+ themeProps.className,
143
+ className
100
144
  )}
101
145
  >
102
- <ThreadPrimitive.If empty>
103
- {components.ThreadWelcome ? (
104
- <components.ThreadWelcome />
105
- ) : (
106
- <ThreadWelcome />
146
+ <ConnectionStatusIndicatorSafe />
147
+ <ThreadPrimitive.Viewport
148
+ className={cn(
149
+ 'aui-thread-viewport relative mx-auto flex w-full flex-1 flex-col overflow-x-auto overflow-y-scroll pb-0!',
150
+ d('p-lg')
151
+ )}
152
+ >
153
+ <ThreadPrimitive.If empty>
154
+ {components.ThreadWelcome ? (
155
+ <components.ThreadWelcome />
156
+ ) : (
157
+ <ThreadWelcome />
158
+ )}
159
+ </ThreadPrimitive.If>
160
+
161
+ {showStaticSessionWarning && <StaticSessionWarning />}
162
+
163
+ <ThreadPrimitive.Messages
164
+ components={{
165
+ UserMessage: components.UserMessage ?? UserMessage,
166
+ EditComposer: components.EditComposer ?? EditComposer,
167
+ AssistantMessage:
168
+ components.AssistantMessage ?? AssistantMessage,
169
+ }}
170
+ />
171
+
172
+ <ThreadPrimitive.If empty={false} running={false}>
173
+ <FollowOnSuggestions />
174
+ </ThreadPrimitive.If>
175
+
176
+ <ThreadPrimitive.If empty={false}>
177
+ <div className="aui-thread-viewport-spacer min-h-8 grow" />
178
+ </ThreadPrimitive.If>
179
+
180
+ <Composer showFeedback={showFeedback} />
181
+ </ThreadPrimitive.Viewport>
182
+
183
+ {/* Resolution overlay - subtle readonly effect */}
184
+ <AnimatePresence>
185
+ {showFeedback && isResolved && (
186
+ <m.div
187
+ className="bg-background/40 pointer-events-none absolute inset-0 z-50"
188
+ initial={{ opacity: 0 }}
189
+ animate={{ opacity: 1 }}
190
+ exit={{ opacity: 0 }}
191
+ transition={{ duration: 0.3, ease: EASE_OUT_QUINT }}
192
+ />
107
193
  )}
108
- </ThreadPrimitive.If>
109
-
110
- {showStaticSessionWarning && <StaticSessionWarning />}
111
-
112
- <ThreadPrimitive.Messages
113
- components={{
114
- UserMessage: components.UserMessage ?? UserMessage,
115
- EditComposer: components.EditComposer ?? EditComposer,
116
- AssistantMessage:
117
- components.AssistantMessage ?? AssistantMessage,
118
- }}
119
- />
120
-
121
- <ThreadPrimitive.If empty={false}>
122
- <div className="aui-thread-viewport-spacer min-h-8 grow" />
123
- </ThreadPrimitive.If>
124
-
125
- <Composer />
126
- </ThreadPrimitive.Viewport>
127
- </ThreadPrimitive.Root>
128
- </MotionConfig>
129
- </LazyMotion>
194
+ </AnimatePresence>
195
+ </ThreadPrimitive.Root>
196
+ </MotionConfig>
197
+ </LazyMotion>
198
+ </ChatResolutionContext.Provider>
130
199
  )
131
200
  }
132
201
 
@@ -357,10 +426,57 @@ const ComposerToolMentions: FC<{
357
426
  )
358
427
  }
359
428
 
360
- const Composer: FC = () => {
429
+ // Resets feedbackHidden when a new message starts generating
430
+ const FeedbackHiddenResetter: FC = () => {
431
+ const { resetFeedbackHidden } = useChatResolution()
432
+
433
+ useEffect(() => {
434
+ resetFeedbackHidden()
435
+ }, [resetFeedbackHidden])
436
+
437
+ return null
438
+ }
439
+
440
+ const ComposerFeedback: FC = () => {
441
+ const { isResolved, feedbackHidden, setResolved } = useChatResolution()
442
+
443
+ return (
444
+ <ThreadPrimitive.If empty={false}>
445
+ {/* Reset feedbackHidden when a new message starts generating */}
446
+ <ThreadPrimitive.If running>
447
+ <FeedbackHiddenResetter />
448
+ </ThreadPrimitive.If>
449
+ <ThreadPrimitive.If running={false}>
450
+ <AnimatePresence>
451
+ {!isResolved && !feedbackHidden && (
452
+ <m.div
453
+ initial={{ opacity: 0, y: 10 }}
454
+ animate={{ opacity: 1, y: 0 }}
455
+ exit={{ opacity: 0, y: 10 }}
456
+ transition={{ duration: 0.2, ease: EASE_OUT_QUINT }}
457
+ className="mb-3"
458
+ >
459
+ <MessageFeedback className="mx-auto" onResolved={setResolved} />
460
+ </m.div>
461
+ )}
462
+ </AnimatePresence>
463
+ </ThreadPrimitive.If>
464
+ </ThreadPrimitive.If>
465
+ )
466
+ }
467
+
468
+ interface ComposerProps {
469
+ showFeedback?: boolean
470
+ }
471
+
472
+ const Composer: FC<ComposerProps> = ({ showFeedback = false }) => {
361
473
  const { config, mcpTools } = useElements()
474
+ const { isResolved, setUnresolved } = useChatResolution()
362
475
  const r = useRadius()
363
476
  const d = useDensity()
477
+ const replayCtx = useReplayContext()
478
+
479
+ const isReplay = replayCtx?.isReplay ?? false
364
480
  const composerConfig = config.composer ?? {
365
481
  placeholder: 'Send a message...',
366
482
  attachments: true,
@@ -383,37 +499,61 @@ const Composer: FC = () => {
383
499
  return (
384
500
  <div
385
501
  className={cn(
386
- 'aui-composer-wrapper bg-background sticky bottom-0 flex w-full flex-col overflow-visible',
502
+ 'aui-composer-wrapper bg-background sticky bottom-0 z-[60] flex w-full flex-col overflow-visible',
387
503
  d('gap-md'),
388
504
  d('py-md'),
389
505
  r('xl')
390
506
  )}
391
507
  >
508
+ {showFeedback && <ComposerFeedback />}
392
509
  <ThreadScrollToBottom />
393
- <ComposerPrimitive.Root
394
- ref={composerRootRef}
395
- className={cn(
396
- 'aui-composer-root group/input-group border-input bg-background has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-ring/5 dark:bg-background relative flex w-full flex-col border px-1 pt-2 shadow-xs transition-[color,box-shadow] outline-none has-[textarea:focus-visible]:ring-1',
397
- r('xl')
398
- )}
399
- >
400
- {composerConfig.attachments && <ComposerAttachments />}
401
-
402
- {toolMentionsEnabled && <ComposerToolMentions tools={mcpTools} />}
403
-
404
- <ComposerPrimitive.Input
405
- placeholder={composerConfig.placeholder}
510
+ {showFeedback && isResolved ? (
511
+ <m.div
512
+ className="aui-composer-resolved border-input flex min-h-[118px] flex-col items-center justify-center gap-2 border-t px-1"
513
+ initial={{ opacity: 0 }}
514
+ animate={{ opacity: 1 }}
515
+ transition={{ duration: 0.2, ease: EASE_OUT_QUINT }}
516
+ >
517
+ <span className="text-muted-foreground text-sm">
518
+ This conversation has been resolved
519
+ </span>
520
+ <Button
521
+ variant="outline"
522
+ size="sm"
523
+ className="text-foreground"
524
+ onClick={setUnresolved}
525
+ >
526
+ Reopen conversation
527
+ </Button>
528
+ </m.div>
529
+ ) : (
530
+ <ComposerPrimitive.Root
531
+ ref={composerRootRef}
406
532
  className={cn(
407
- 'aui-composer-input placeholder:text-muted-foreground mb-1 max-h-32 w-full resize-none bg-transparent px-3.5 pt-1.5 pb-3 outline-none focus-visible:ring-0',
408
- d('h-input'),
409
- d('text-base')
533
+ 'aui-composer-root group/input-group border-input bg-background has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-ring/5 dark:bg-background relative flex min-h-[118px] w-full flex-col border px-1 pt-2 shadow-xs transition-[color,box-shadow] outline-none has-[textarea:focus-visible]:ring-1',
534
+ r('xl'),
535
+ isReplay && 'pointer-events-none opacity-50'
410
536
  )}
411
- rows={1}
412
- autoFocus
413
- aria-label="Message input"
414
- />
415
- <ComposerAction />
416
- </ComposerPrimitive.Root>
537
+ >
538
+ {composerConfig.attachments && <ComposerAttachments />}
539
+
540
+ {toolMentionsEnabled && <ComposerToolMentions tools={mcpTools} />}
541
+
542
+ <ComposerPrimitive.Input
543
+ placeholder={composerConfig.placeholder}
544
+ className={cn(
545
+ 'aui-composer-input text-foreground placeholder:text-muted-foreground mb-1 max-h-32 w-full resize-none bg-transparent px-3.5 pt-1.5 pb-3 outline-none focus-visible:ring-0',
546
+ d('h-input'),
547
+ d('text-base')
548
+ )}
549
+ rows={1}
550
+ autoFocus={!isReplay}
551
+ disabled={isReplay}
552
+ aria-label="Message input"
553
+ />
554
+ <ComposerAction />
555
+ </ComposerPrimitive.Root>
556
+ )}
417
557
  </div>
418
558
  )
419
559
  }
@@ -508,13 +648,90 @@ const ComposerModelPicker: FC = () => {
508
648
  )
509
649
  }
510
650
 
651
+ const CASSETTE_RECORDING_ENABLED =
652
+ import.meta.env.VITE_ELEMENTS_ENABLE_CASSETTE_RECORDING === 'true'
653
+
654
+ const ComposerCassetteRecorder: FC = () => {
655
+ const [popoverOpen, setPopoverOpen] = useState(false)
656
+ const [tooltipOpen, setTooltipOpen] = useState(false)
657
+ const { isRecording, startRecording, stopRecording, download } =
658
+ useRecordCassette()
659
+
660
+ useEffect(() => {
661
+ if (popoverOpen) setTooltipOpen(false)
662
+ }, [popoverOpen])
663
+
664
+ return (
665
+ <TooltipProvider>
666
+ <Tooltip open={tooltipOpen && !popoverOpen} onOpenChange={setTooltipOpen}>
667
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
668
+ <TooltipTrigger asChild>
669
+ <PopoverTrigger asChild>
670
+ <Button
671
+ variant="ghost"
672
+ size="icon"
673
+ data-state={popoverOpen ? 'open' : 'closed'}
674
+ className={cn(
675
+ 'aui-composer-cassette-recorder data-[state=open]:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30 flex w-fit items-center gap-2 rounded-full px-2.5 py-1 text-xs font-semibold',
676
+ isRecording && 'text-red-500'
677
+ )}
678
+ aria-label="Cassette Recorder"
679
+ >
680
+ <CircleIcon
681
+ className={cn(
682
+ 'size-5 stroke-[1.5px]',
683
+ isRecording && 'animate-pulse fill-red-500 text-red-500'
684
+ )}
685
+ />
686
+ </Button>
687
+ </PopoverTrigger>
688
+ </TooltipTrigger>
689
+ <PopoverContent side="top" align="start" className="w-64 p-3">
690
+ <div className="flex flex-col gap-3">
691
+ <div className="text-sm font-medium">Cassette Recorder</div>
692
+ {!isRecording ? (
693
+ <Button
694
+ size="sm"
695
+ variant="outline"
696
+ className="w-full justify-start gap-2"
697
+ onClick={startRecording}
698
+ >
699
+ <CircleIcon className="size-3 fill-red-500 text-red-500" />
700
+ Start Recording
701
+ </Button>
702
+ ) : (
703
+ <Button
704
+ size="sm"
705
+ variant="outline"
706
+ className="w-full justify-start gap-2"
707
+ onClick={() => {
708
+ stopRecording()
709
+ download()
710
+ setPopoverOpen(false)
711
+ }}
712
+ >
713
+ <DownloadIcon className="size-3" />
714
+ Stop &amp; Download
715
+ </Button>
716
+ )}
717
+ </div>
718
+ </PopoverContent>
719
+ </Popover>
720
+ <TooltipContent side="bottom" align="start">
721
+ {isRecording ? 'Recording…' : 'Cassette Recorder'}
722
+ </TooltipContent>
723
+ </Tooltip>
724
+ </TooltipProvider>
725
+ )
726
+ }
727
+
511
728
  const ComposerAction: FC = () => {
512
729
  const { config } = useElements()
513
730
  const r = useRadius()
514
731
  const composerConfig = config.composer ?? { attachments: true }
515
732
  return (
516
733
  <div className="aui-composer-action-wrapper relative mx-1 mt-2 mb-2 flex items-center justify-between">
517
- <div className="aui-composer-action-wrapper-inner flex items-center">
734
+ <div className="aui-composer-action-wrapper-inner text-muted-foreground flex items-center">
518
735
  {composerConfig.attachments ? (
519
736
  <ComposerAddAttachment />
520
737
  ) : (
@@ -524,6 +741,8 @@ const ComposerAction: FC = () => {
524
741
  {config.model?.showModelPicker && !config.languageModel && (
525
742
  <ComposerModelPicker />
526
743
  )}
744
+
745
+ {CASSETTE_RECORDING_ENABLED && <ComposerCassetteRecorder />}
527
746
  </div>
528
747
 
529
748
  <ThreadPrimitive.If running={false}>
@@ -572,10 +791,41 @@ const MessageError: FC = () => {
572
791
  )
573
792
  }
574
793
 
794
+ /**
795
+ * Shows the pulsing dot indicator when the message is still running but the
796
+ * last rendered part is a tool call (not text). Without this, there's no
797
+ * visual feedback that the model is still working after a tool call.
798
+ */
799
+ const ToolCallStreamingIndicator: FC = () => {
800
+ const show = useAssistantState(({ message }) => {
801
+ if (message.status?.type !== 'running') return false
802
+ const lastPart = message.parts[message.parts.length - 1]
803
+ return lastPart?.type === 'tool-call'
804
+ })
805
+ if (!show) return null
806
+ return <div className="aui-md mt-2" data-status="running" />
807
+ }
808
+
575
809
  const AssistantMessage: FC = () => {
576
810
  const { config } = useElements()
577
811
  const toolsConfig = config.tools ?? {}
578
812
  const components = config.components ?? {}
813
+
814
+ const partsComponents = useMemo(
815
+ () => ({
816
+ Text: components.Text ?? MarkdownText,
817
+ Image: components.Image ?? Image,
818
+ tools: {
819
+ by_name: toolsConfig.components,
820
+ Fallback: components.ToolFallback ?? ToolFallback,
821
+ },
822
+ Reasoning: components.Reasoning ?? Reasoning,
823
+ ReasoningGroup: components.ReasoningGroup ?? ReasoningGroup,
824
+ ToolGroup: components.ToolGroup ?? ToolGroup,
825
+ }),
826
+ [components, toolsConfig.components]
827
+ )
828
+
579
829
  return (
580
830
  <MessagePrimitive.Root asChild>
581
831
  <div
@@ -583,23 +833,12 @@ const AssistantMessage: FC = () => {
583
833
  data-role="assistant"
584
834
  >
585
835
  <div className="aui-assistant-message-content text-foreground mx-2 leading-7 wrap-break-word">
586
- <MessagePrimitive.Parts
587
- components={{
588
- Text: components.Text ?? MarkdownText,
589
- Image: components.Image ?? Image,
590
- tools: {
591
- by_name: toolsConfig.components,
592
- Fallback: components.ToolFallback ?? ToolFallback,
593
- },
594
- Reasoning: components.Reasoning ?? Reasoning,
595
- ReasoningGroup: components.ReasoningGroup ?? ReasoningGroup,
596
- ToolGroup: components.ToolGroup ?? ToolGroup,
597
- }}
598
- />
836
+ <MessagePrimitive.Parts components={partsComponents} />
837
+ <ToolCallStreamingIndicator />
599
838
  <MessageError />
600
839
  </div>
601
840
 
602
- <div className="aui-assistant-message-footer mt-2 ml-2 flex">
841
+ <div className="aui-assistant-message-footer mt-2 ml-2 flex items-center gap-3">
603
842
  {/* <BranchPicker /> */}
604
843
  <AssistantActionBar />
605
844
  </div>
@@ -7,34 +7,45 @@ import { ToolUIGroup } from '@/components/ui/tool-ui'
7
7
 
8
8
  export const ToolGroup: FC<
9
9
  PropsWithChildren<{ startIndex: number; endIndex: number }>
10
- > = ({ children }) => {
11
- const parts = useAssistantState(({ message }) => message).parts
12
- const toolCallParts = parts.filter((part) => part.type === 'tool-call')
13
- const anyMessagePartsAreRunning = toolCallParts.some(
14
- (part) => part.status?.type === 'running'
15
- )
10
+ > = ({ children, startIndex, endIndex }) => {
11
+ // startIndex/endIndex are inclusive indices into message.parts.
12
+ // assistant-ui only groups consecutive tool-call parts, so every part
13
+ // in the range is a tool-call — the count is simply the range size.
14
+ const toolCount = endIndex - startIndex + 1
15
+
16
+ const firstToolName = useAssistantState(({ message }) => {
17
+ const part = message.parts[startIndex]
18
+ return part?.type === 'tool-call' ? part.toolName : undefined
19
+ })
20
+ const anyMessagePartsAreRunning = useAssistantState(({ message }) => {
21
+ for (let i = startIndex; i <= endIndex; i++) {
22
+ if (message.parts[i]?.status?.type === 'running') return true
23
+ }
24
+ return false
25
+ })
16
26
 
17
27
  const { config } = useElements()
18
28
  const defaultExpanded = config.tools?.expandToolGroupsByDefault ?? false
19
29
 
20
30
  const groupTitle = useMemo(() => {
21
- const toolParts = parts.filter((part) => part.type === 'tool-call')
22
-
23
- if (toolParts.length === 0) return 'No tools called'
24
- if (toolParts.length === 1)
25
- return `Calling ${humanizeToolName(toolParts[0].toolName)}...`
31
+ if (toolCount === 0) return 'No tools called'
32
+ if (toolCount === 1) {
33
+ return firstToolName
34
+ ? `Calling ${humanizeToolName(firstToolName)}...`
35
+ : 'Calling tool...'
36
+ }
26
37
  return anyMessagePartsAreRunning
27
- ? `Calling ${toolParts.length} tools...`
28
- : `Executed ${toolParts.length} tools`
29
- }, [parts, anyMessagePartsAreRunning])
38
+ ? `Calling ${toolCount} tools...`
39
+ : `Executed ${toolCount} tools`
40
+ }, [toolCount, firstToolName, anyMessagePartsAreRunning])
30
41
 
31
42
  // If there's a custom component for the single tool, render children directly
32
- if (config.tools?.components?.[toolCallParts[0]?.toolName]) {
43
+ if (firstToolName && config.tools?.components?.[firstToolName]) {
33
44
  return children
34
45
  }
35
46
 
36
47
  // For single tool calls, render without the group wrapper
37
- if (toolCallParts.length === 1) {
48
+ if (toolCount === 1) {
38
49
  return (
39
50
  <div className={cn('my-4 w-full max-w-xl')}>
40
51
  <div className="border-border bg-card overflow-hidden rounded-lg border">
@@ -181,6 +181,22 @@ function CopyButton({ content }: { content: string }) {
181
181
  * SyntaxHighlightedCode - Code block with shiki syntax highlighting
182
182
  * -------------------------------------------------------------------------- */
183
183
 
184
+ /** Max characters to send through shiki — above this we skip highlighting. */
185
+ const SHIKI_CHAR_LIMIT = 8_000
186
+ /** Max lines shown in the collapsed preview. */
187
+ const PREVIEW_LINE_LIMIT = 50
188
+
189
+ function truncateToLines(text: string, maxLines: number) {
190
+ let pos = 0
191
+ for (let i = 0; i < maxLines; i++) {
192
+ const next = text.indexOf('\n', pos)
193
+ if (next === -1) return { text, truncated: false, totalLines: i + 1 }
194
+ pos = next + 1
195
+ }
196
+ const totalLines = text.split('\n').length
197
+ return { text: text.slice(0, pos), truncated: true, totalLines }
198
+ }
199
+
184
200
  function SyntaxHighlightedCode({
185
201
  text,
186
202
  language,
@@ -191,10 +207,20 @@ function SyntaxHighlightedCode({
191
207
  className?: string
192
208
  }) {
193
209
  const [highlightedCode, setHighlightedCode] = useState<string | null>(null)
210
+ const [expanded, setExpanded] = useState(false)
211
+
212
+ const preview = React.useMemo(
213
+ () => truncateToLines(text, PREVIEW_LINE_LIMIT),
214
+ [text]
215
+ )
216
+ const displayText = expanded ? text : preview.text
217
+ const canHighlight = displayText.length <= SHIKI_CHAR_LIMIT
194
218
 
195
219
  useEffect(() => {
196
- if (!language) return
197
- codeToHtml(text, {
220
+ setHighlightedCode(null)
221
+ if (!language || !canHighlight) return
222
+ let cancelled = false
223
+ codeToHtml(displayText, {
198
224
  lang: language,
199
225
  theme: 'github-dark-default',
200
226
  rootStyle: 'background-color: transparent;',
@@ -206,27 +232,43 @@ function SyntaxHighlightedCode({
206
232
  },
207
233
  },
208
234
  ],
209
- }).then(setHighlightedCode)
210
- }, [text, language])
235
+ }).then((html) => {
236
+ if (!cancelled) setHighlightedCode(html)
237
+ })
238
+ return () => {
239
+ cancelled = true
240
+ }
241
+ }, [displayText, language, canHighlight])
242
+
243
+ const showMoreButton = preview.truncated && !expanded && (
244
+ <button
245
+ type="button"
246
+ onClick={() => setExpanded(true)}
247
+ className="w-full bg-slate-800/90 px-4 py-2 text-left text-xs text-slate-400 transition-colors hover:text-slate-200"
248
+ >
249
+ Show all {preview.totalLines} lines…
250
+ </button>
251
+ )
211
252
 
212
- if (!highlightedCode) {
253
+ if (!canHighlight || !highlightedCode) {
213
254
  return (
214
- <pre
215
- className={cn(
216
- 'w-full bg-slate-800/90 px-4 py-3 text-sm whitespace-pre-wrap text-slate-100',
217
- className
218
- )}
219
- >
220
- {text}
221
- </pre>
255
+ <div className={cn('w-full', className)}>
256
+ <pre className="max-h-[300px] w-full overflow-y-auto bg-slate-800/90 px-4 py-3 text-sm whitespace-pre-wrap text-slate-100">
257
+ {displayText}
258
+ </pre>
259
+ {showMoreButton}
260
+ </div>
222
261
  )
223
262
  }
224
263
 
225
264
  return (
226
- <div
227
- className={cn('w-full bg-slate-800/90', className)}
228
- dangerouslySetInnerHTML={{ __html: highlightedCode }}
229
- />
265
+ <div className={cn('w-full', className)}>
266
+ <div
267
+ className="w-full bg-slate-800/90"
268
+ dangerouslySetInnerHTML={{ __html: highlightedCode }}
269
+ />
270
+ {showMoreButton}
271
+ </div>
230
272
  )
231
273
  }
232
274