@gram-ai/elements 1.25.0 → 1.25.2

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 (50) hide show
  1. package/dist/components/Chat/stories/MessageFeedback.stories.d.ts +1 -1
  2. package/dist/components/Replay.stories.d.ts +16 -0
  3. package/dist/contexts/ChatIdContext.d.ts +11 -0
  4. package/dist/contexts/contexts.d.ts +1 -0
  5. package/dist/elements.cjs +1 -1
  6. package/dist/elements.css +1 -1
  7. package/dist/elements.js +7 -6
  8. package/dist/{index-iUSSoKFz.cjs → index-B8nSCdu4.cjs} +11 -11
  9. package/dist/index-B8nSCdu4.cjs.map +1 -0
  10. package/dist/{index-wBHCO1r-.cjs → index-CAtaLV1E.cjs} +64 -55
  11. package/dist/index-CAtaLV1E.cjs.map +1 -0
  12. package/dist/{index-CtyV0c-T.js → index-CJrwma08.js} +3737 -3730
  13. package/dist/index-CJrwma08.js.map +1 -0
  14. package/dist/{index-DDb23655.js → index-DLWQ91ow.js} +8494 -8418
  15. package/dist/index-DLWQ91ow.js.map +1 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/lib/messageConverter.d.ts +1 -1
  18. package/dist/lib/messageConverter.test.d.ts +1 -0
  19. package/dist/plugins.cjs +1 -1
  20. package/dist/plugins.js +1 -1
  21. package/dist/{profiler-CGIJBY8c.js → profiler-BaG0scxd.js} +2 -2
  22. package/dist/{profiler-CGIJBY8c.js.map → profiler-BaG0scxd.js.map} +1 -1
  23. package/dist/{profiler-CLtQEzfv.cjs → profiler-CuqENACf.cjs} +2 -2
  24. package/dist/{profiler-CLtQEzfv.cjs.map → profiler-CuqENACf.cjs.map} +1 -1
  25. package/dist/{startRecording-x0G7lOpP.js → startRecording-86bHmd-l.js} +2 -2
  26. package/dist/{startRecording-x0G7lOpP.js.map → startRecording-86bHmd-l.js.map} +1 -1
  27. package/dist/{startRecording-DXZPNn9e.cjs → startRecording-BiLmoqZa.cjs} +2 -2
  28. package/dist/{startRecording-DXZPNn9e.cjs.map → startRecording-BiLmoqZa.cjs.map} +1 -1
  29. package/dist/types/index.d.ts +4 -4
  30. package/package.json +1 -1
  31. package/src/components/Chat/stories/MessageFeedback.stories.tsx +6 -6
  32. package/src/components/Chat/stories/ToolApproval.stories.tsx +10 -10
  33. package/src/components/Chat/stories/Tools.stories.tsx +122 -104
  34. package/src/components/Chat/stories/Variants.stories.tsx +1 -1
  35. package/src/components/Replay.stories.tsx +230 -0
  36. package/src/components/ShadowRoot.tsx +5 -1
  37. package/src/components/assistant-ui/message-feedback.tsx +6 -7
  38. package/src/components/assistant-ui/thread.tsx +76 -11
  39. package/src/contexts/ChatIdContext.tsx +21 -0
  40. package/src/contexts/ElementsProvider.tsx +77 -37
  41. package/src/contexts/contexts.ts +2 -0
  42. package/src/hooks/useAuth.ts +1 -2
  43. package/src/index.ts +1 -0
  44. package/src/lib/messageConverter.test.ts +242 -0
  45. package/src/lib/messageConverter.ts +22 -10
  46. package/src/types/index.ts +4 -4
  47. package/dist/index-CtyV0c-T.js.map +0 -1
  48. package/dist/index-DDb23655.js.map +0 -1
  49. package/dist/index-iUSSoKFz.cjs.map +0 -1
  50. package/dist/index-wBHCO1r-.cjs.map +0 -1
@@ -0,0 +1,242 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { describe, expect, it } from 'vitest'
3
+ import {
4
+ convertGramMessagesToExported,
5
+ convertGramMessagesToUIMessages,
6
+ convertGramMessagePartsToUIMessageParts,
7
+ type GramChatMessage,
8
+ } from './messageConverter'
9
+
10
+ /**
11
+ * Helper to create a minimal GramChatMessage for testing.
12
+ */
13
+ function makeMsg(
14
+ overrides: Partial<GramChatMessage> & { role: string }
15
+ ): GramChatMessage {
16
+ return {
17
+ id: crypto.randomUUID(),
18
+ model: 'test-model',
19
+ created_at: new Date().toISOString(),
20
+ ...overrides,
21
+ } as GramChatMessage
22
+ }
23
+
24
+ function makeToolCallsJSON(
25
+ calls: { id: string; name: string; args?: string }[]
26
+ ): string {
27
+ return JSON.stringify(
28
+ calls.map((c) => ({
29
+ id: c.id,
30
+ type: 'function',
31
+ function: { name: c.name, arguments: c.args ?? '{}' },
32
+ }))
33
+ )
34
+ }
35
+
36
+ describe('convertGramMessagePartsToUIMessageParts', () => {
37
+ it('includes tool calls for a single assistant message', () => {
38
+ const msg = makeMsg({
39
+ role: 'assistant',
40
+ content: 'Let me search.',
41
+ tool_calls: makeToolCallsJSON([{ id: 'tc_1', name: 'search_deals' }]),
42
+ } as Partial<GramChatMessage> & { role: string })
43
+
44
+ const parts = convertGramMessagePartsToUIMessageParts(msg as any, new Map())
45
+
46
+ const toolParts = parts.filter((p) => p.type === 'dynamic-tool')
47
+ expect(toolParts).toHaveLength(1)
48
+ expect(toolParts[0]).toMatchObject({
49
+ type: 'dynamic-tool',
50
+ toolCallId: 'tc_1',
51
+ toolName: 'search_deals',
52
+ })
53
+ })
54
+
55
+ it('deduplicates tool calls when seenToolCallIds is provided', () => {
56
+ const seen = new Set(['tc_1'])
57
+ const msg = makeMsg({
58
+ role: 'assistant',
59
+ content: 'Trying again.',
60
+ tool_calls: makeToolCallsJSON([
61
+ { id: 'tc_1', name: 'search_deals' },
62
+ { id: 'tc_2', name: 'search_deals' },
63
+ ]),
64
+ } as Partial<GramChatMessage> & { role: string })
65
+
66
+ const parts = convertGramMessagePartsToUIMessageParts(
67
+ msg as any,
68
+ new Map(),
69
+ seen
70
+ )
71
+
72
+ const toolParts = parts.filter((p) => p.type === 'dynamic-tool')
73
+ expect(toolParts).toHaveLength(1)
74
+ expect(toolParts[0]).toMatchObject({ toolCallId: 'tc_2' })
75
+ expect(seen.has('tc_2')).toBe(true)
76
+ })
77
+ })
78
+
79
+ describe('convertGramMessagesToUIMessages - tool call deduplication', () => {
80
+ /**
81
+ * Simulates the server behavior where each assistant message in a multi-step
82
+ * tool use flow accumulates ALL tool calls from the turn, not just its own.
83
+ *
84
+ * Given 4 sequential tool calls, the server stores:
85
+ * message 1: tool_calls = [tc_1]
86
+ * message 2: tool_calls = [tc_1, tc_2]
87
+ * message 3: tool_calls = [tc_1, tc_2, tc_3]
88
+ * message 4: tool_calls = [tc_1, tc_2, tc_3, tc_4]
89
+ *
90
+ * Without dedup, each message renders all its tool calls → every group shows
91
+ * the accumulated count. With dedup, each message only renders the new ones.
92
+ */
93
+ it('deduplicates accumulated tool calls across assistant messages', () => {
94
+ const messages: GramChatMessage[] = [
95
+ makeMsg({
96
+ role: 'user',
97
+ content: 'Search for deals',
98
+ }),
99
+ makeMsg({
100
+ role: 'assistant',
101
+ content: "I'll search for deals.",
102
+ tool_calls: makeToolCallsJSON([
103
+ { id: 'tc_1', name: 'hubspot_search_deals' },
104
+ ]),
105
+ } as Partial<GramChatMessage> & { role: string }),
106
+ makeMsg({
107
+ role: 'tool',
108
+ content: '{"error": "invalid filter"}',
109
+ tool_call_id: 'tc_1',
110
+ } as Partial<GramChatMessage> & { role: string }),
111
+ makeMsg({
112
+ role: 'assistant',
113
+ content: 'Let me try differently.',
114
+ tool_calls: makeToolCallsJSON([
115
+ { id: 'tc_1', name: 'hubspot_search_deals' },
116
+ { id: 'tc_2', name: 'hubspot_search_deals' },
117
+ ]),
118
+ } as Partial<GramChatMessage> & { role: string }),
119
+ makeMsg({
120
+ role: 'tool',
121
+ content: '{"error": "empty filters"}',
122
+ tool_call_id: 'tc_2',
123
+ } as Partial<GramChatMessage> & { role: string }),
124
+ makeMsg({
125
+ role: 'assistant',
126
+ content: 'Let me try with proper filters.',
127
+ tool_calls: makeToolCallsJSON([
128
+ { id: 'tc_1', name: 'hubspot_search_deals' },
129
+ { id: 'tc_2', name: 'hubspot_search_deals' },
130
+ { id: 'tc_3', name: 'hubspot_search_deals' },
131
+ ]),
132
+ } as Partial<GramChatMessage> & { role: string }),
133
+ makeMsg({
134
+ role: 'tool',
135
+ content: '{"deals": []}',
136
+ tool_call_id: 'tc_3',
137
+ } as Partial<GramChatMessage> & { role: string }),
138
+ makeMsg({
139
+ role: 'assistant',
140
+ content: 'Here are the results.',
141
+ }),
142
+ ]
143
+
144
+ const result = convertGramMessagesToUIMessages(messages)
145
+ const assistantMessages = result.messages.filter(
146
+ (m) => m.message.role === 'assistant'
147
+ )
148
+
149
+ // Each assistant message should only have its OWN tool call, not all accumulated ones
150
+ const firstAssistant = assistantMessages[0]!.message.parts.filter(
151
+ (p) => p.type === 'dynamic-tool'
152
+ )
153
+ expect(firstAssistant).toHaveLength(1)
154
+ expect(firstAssistant[0]).toMatchObject({ toolCallId: 'tc_1' })
155
+
156
+ const secondAssistant = assistantMessages[1]!.message.parts.filter(
157
+ (p) => p.type === 'dynamic-tool'
158
+ )
159
+ expect(secondAssistant).toHaveLength(1)
160
+ expect(secondAssistant[0]).toMatchObject({ toolCallId: 'tc_2' })
161
+
162
+ const thirdAssistant = assistantMessages[2]!.message.parts.filter(
163
+ (p) => p.type === 'dynamic-tool'
164
+ )
165
+ expect(thirdAssistant).toHaveLength(1)
166
+ expect(thirdAssistant[0]).toMatchObject({ toolCallId: 'tc_3' })
167
+
168
+ // Final assistant message has no tool calls
169
+ const fourthAssistant = assistantMessages[3]!.message.parts.filter(
170
+ (p) => p.type === 'dynamic-tool'
171
+ )
172
+ expect(fourthAssistant).toHaveLength(0)
173
+ })
174
+
175
+ it('resets dedup tracking after a user message', () => {
176
+ const messages: GramChatMessage[] = [
177
+ makeMsg({ role: 'user', content: 'First question' }),
178
+ makeMsg({
179
+ role: 'assistant',
180
+ content: 'Searching.',
181
+ tool_calls: makeToolCallsJSON([{ id: 'tc_1', name: 'search' }]),
182
+ } as Partial<GramChatMessage> & { role: string }),
183
+ makeMsg({
184
+ role: 'tool',
185
+ content: '{}',
186
+ tool_call_id: 'tc_1',
187
+ } as Partial<GramChatMessage> & { role: string }),
188
+ makeMsg({ role: 'user', content: 'Second question' }),
189
+ // New turn — tc_1 reused as ID (different conversation turn)
190
+ makeMsg({
191
+ role: 'assistant',
192
+ content: 'Searching again.',
193
+ tool_calls: makeToolCallsJSON([{ id: 'tc_1', name: 'search' }]),
194
+ } as Partial<GramChatMessage> & { role: string }),
195
+ ]
196
+
197
+ const result = convertGramMessagesToUIMessages(messages)
198
+ const assistantMessages = result.messages.filter(
199
+ (m) => m.message.role === 'assistant'
200
+ )
201
+
202
+ // Both assistant messages should have tc_1 since the user message resets tracking
203
+ const first = assistantMessages[0]!.message.parts.filter(
204
+ (p) => p.type === 'dynamic-tool'
205
+ )
206
+ expect(first).toHaveLength(1)
207
+
208
+ const second = assistantMessages[1]!.message.parts.filter(
209
+ (p) => p.type === 'dynamic-tool'
210
+ )
211
+ expect(second).toHaveLength(1)
212
+ })
213
+ })
214
+
215
+ describe('convertGramMessagesToExported - string content with tool calls', () => {
216
+ it('includes tool calls when assistant message has string content', () => {
217
+ const messages: GramChatMessage[] = [
218
+ makeMsg({ role: 'user', content: 'Search for deals' }),
219
+ makeMsg({
220
+ role: 'assistant',
221
+ content: 'Let me search.',
222
+ tool_calls: makeToolCallsJSON([
223
+ { id: 'tc_1', name: 'hubspot_search_deals' },
224
+ ]),
225
+ } as Partial<GramChatMessage> & { role: string }),
226
+ ]
227
+
228
+ const result = convertGramMessagesToExported(messages)
229
+ const assistantEntry = result.messages.find(
230
+ (m) => m.message.role === 'assistant'
231
+ )!
232
+ const toolCallParts = (assistantEntry.message as any).content.filter(
233
+ (p: any) => p.type === 'tool-call'
234
+ )
235
+ expect(toolCallParts).toHaveLength(1)
236
+ expect(toolCallParts[0]).toMatchObject({
237
+ type: 'tool-call',
238
+ toolCallId: 'tc_1',
239
+ toolName: 'hubspot_search_deals',
240
+ })
241
+ })
242
+ })
@@ -146,17 +146,15 @@ function buildAssistantContentParts(
146
146
  return []
147
147
  }
148
148
 
149
+ const parts: ThreadAssistantMessagePart[] = []
150
+
149
151
  if (typeof msg.content === 'string' || !msg.content) {
150
- return [
151
- {
152
- type: 'text',
153
- text: msg.content ?? '',
154
- },
155
- ]
152
+ parts.push({
153
+ type: 'text',
154
+ text: msg.content ?? '',
155
+ })
156
156
  }
157
157
 
158
- const parts: ThreadAssistantMessagePart[] = []
159
-
160
158
  const toolCallsJSON = (msg as any).tool_calls as FIXME<
161
159
  string | undefined,
162
160
  'Fixed by switching to Gram TS SDK.'
@@ -331,6 +329,11 @@ export function convertGramMessagesToUIMessages(messages: GramChatMessage[]): {
331
329
  const uiMessages: { parentId: string | null; message: UIMessage }[] = []
332
330
  let prevId: string | null = null
333
331
 
332
+ // Track tool call IDs across messages to deduplicate. The server accumulates
333
+ // all tool calls from a turn into each message, so without this, every
334
+ // assistant message in a multi-step tool use flow would show the full count.
335
+ const seenToolCallIds = new Set<string>()
336
+
334
337
  for (const msg of messages) {
335
338
  switch (msg.role) {
336
339
  case 'developer':
@@ -361,6 +364,7 @@ export function convertGramMessagesToUIMessages(messages: GramChatMessage[]): {
361
364
  break
362
365
  }
363
366
  case 'user': {
367
+ seenToolCallIds.clear()
364
368
  uiMessages.push({
365
369
  parentId: prevId,
366
370
  message: {
@@ -382,7 +386,8 @@ export function convertGramMessagesToUIMessages(messages: GramChatMessage[]): {
382
386
  role: 'assistant',
383
387
  parts: convertGramMessagePartsToUIMessageParts(
384
388
  msg,
385
- toolCallResults
389
+ toolCallResults,
390
+ seenToolCallIds
386
391
  ),
387
392
  } satisfies UIMessage,
388
393
  }
@@ -403,7 +408,8 @@ export function convertGramMessagesToUIMessages(messages: GramChatMessage[]): {
403
408
 
404
409
  export function convertGramMessagePartsToUIMessageParts(
405
410
  msg: UserMessage | AssistantMessage,
406
- toolResults: Map<string, ToolResponseMessage>
411
+ toolResults: Map<string, ToolResponseMessage>,
412
+ seenToolCallIds?: Set<string>
407
413
  ): UIMessage['parts'] {
408
414
  const uiparts: UIMessage['parts'] = []
409
415
 
@@ -480,6 +486,12 @@ export function convertGramMessagePartsToUIMessageParts(
480
486
  }
481
487
 
482
488
  for (const tc of toolCalls) {
489
+ // The server accumulates all tool calls from a turn into each message's
490
+ // tool_calls field. Deduplicate across messages so each tool call only
491
+ // appears in the first message that references it.
492
+ if (seenToolCallIds?.has(tc.id)) continue
493
+ seenToolCallIds?.add(tc.id)
494
+
483
495
  const content = toolResults.get(tc.id)?.content
484
496
  uiparts.push({
485
497
  type: 'dynamic-tool',
@@ -315,7 +315,7 @@ export interface ElementsConfig {
315
315
  * @example
316
316
  * const config: ElementsConfig = {
317
317
  * thread: {
318
- * experimental_showFeedback: true,
318
+ * showFeedback: true,
319
319
  * followUpSuggestions: true,
320
320
  * },
321
321
  * }
@@ -853,7 +853,7 @@ export interface SidecarConfig extends ExpandableConfig {
853
853
  * @example
854
854
  * const config: ElementsConfig = {
855
855
  * thread: {
856
- * experimental_showFeedback: true,
856
+ * showFeedback: true,
857
857
  * followUpSuggestions: true,
858
858
  * },
859
859
  * }
@@ -862,9 +862,9 @@ export interface ThreadConfig {
862
862
  /**
863
863
  * Whether to show feedback buttons (like/dislike) after assistant messages.
864
864
  * When enabled, users can mark conversations as resolved or provide feedback.
865
- * @default false
865
+ * @default true
866
866
  */
867
- experimental_showFeedback?: boolean
867
+ showFeedback?: boolean
868
868
 
869
869
  /**
870
870
  * Whether to show AI-generated follow-up question suggestions after each assistant response.