@gram-ai/elements 1.21.2 → 1.22.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 (57) hide show
  1. package/dist/components/Chat/stories/StyleIsolation.stories.d.ts +6 -0
  2. package/dist/components/Chat/stories/Theme.stories.d.ts +8 -0
  3. package/dist/components/Chat/stories/ToolMentions.stories.d.ts +19 -0
  4. package/dist/components/ChatHistory.d.ts +5 -0
  5. package/dist/components/ShadowRoot.d.ts +8 -0
  6. package/dist/components/assistant-ui/mentioned-tools-badges.d.ts +10 -0
  7. package/dist/components/assistant-ui/tool-mention-autocomplete.d.ts +12 -0
  8. package/dist/elements.cjs +1 -1
  9. package/dist/elements.css +1 -1
  10. package/dist/elements.js +1 -1
  11. package/dist/embedded.d.ts +0 -0
  12. package/dist/hooks/useGramThreadListAdapter.d.ts +7 -0
  13. package/dist/hooks/useMCPTools.d.ts +2 -1
  14. package/dist/hooks/useToolMentions.d.ts +17 -0
  15. package/dist/index-DSGCuihM.cjs +145 -0
  16. package/dist/index-DSGCuihM.cjs.map +1 -0
  17. package/dist/index-DiaPGyWF.js +38113 -0
  18. package/dist/index-DiaPGyWF.js.map +1 -0
  19. package/dist/index.d.ts +2 -2
  20. package/dist/lib/tool-mentions.d.ts +20 -0
  21. package/dist/{profiler-DPH9zydw.cjs → profiler-Cr1UEa8B.cjs} +2 -2
  22. package/dist/{profiler-DPH9zydw.cjs.map → profiler-Cr1UEa8B.cjs.map} +1 -1
  23. package/dist/{profiler-D_HNXmyv.js → profiler-DL_h66-O.js} +2 -2
  24. package/dist/{profiler-D_HNXmyv.js.map → profiler-DL_h66-O.js.map} +1 -1
  25. package/dist/{startRecording-DOMzQAAr.js → startRecording-BW2zkd0-.js} +2 -2
  26. package/dist/{startRecording-DOMzQAAr.js.map → startRecording-BW2zkd0-.js.map} +1 -1
  27. package/dist/{startRecording-B97e4Mlu.cjs → startRecording-D8-ckYMS.cjs} +2 -2
  28. package/dist/{startRecording-B97e4Mlu.cjs.map → startRecording-D8-ckYMS.cjs.map} +1 -1
  29. package/dist/types/index.d.ts +20 -0
  30. package/package.json +13 -4
  31. package/src/components/Chat/index.tsx +4 -13
  32. package/src/components/Chat/stories/StyleIsolation.stories.tsx +41 -0
  33. package/src/components/Chat/stories/Theme.stories.tsx +74 -0
  34. package/src/components/Chat/stories/ToolApproval.stories.tsx +0 -1
  35. package/src/components/Chat/stories/ToolMentions.stories.tsx +62 -0
  36. package/src/components/ChatHistory.tsx +16 -0
  37. package/src/components/ShadowRoot.tsx +90 -0
  38. package/src/components/assistant-ui/markdown-text.tsx +0 -2
  39. package/src/components/assistant-ui/mentioned-tools-badges.tsx +105 -0
  40. package/src/components/assistant-ui/thread-list.tsx +1 -3
  41. package/src/components/assistant-ui/thread.tsx +106 -1
  42. package/src/components/assistant-ui/tool-mention-autocomplete.tsx +253 -0
  43. package/src/components/ui/tool-ui.tsx +1 -1
  44. package/src/contexts/ElementsProvider.tsx +67 -10
  45. package/src/embedded.ts +3 -0
  46. package/src/global.css +45 -0
  47. package/src/hooks/useGramThreadListAdapter.tsx +90 -9
  48. package/src/hooks/useMCPTools.ts +4 -1
  49. package/src/hooks/useToolMentions.ts +93 -0
  50. package/src/index.ts +2 -1
  51. package/src/lib/tool-mentions.ts +106 -0
  52. package/src/types/index.ts +23 -0
  53. package/src/vite-env.d.ts +5 -0
  54. package/dist/index-BVvrv2G3.cjs +0 -169
  55. package/dist/index-BVvrv2G3.cjs.map +0 -1
  56. package/dist/index-OU3wjArm.js +0 -54670
  57. package/dist/index-OU3wjArm.js.map +0 -1
package/src/global.css CHANGED
@@ -247,3 +247,48 @@
247
247
  [data-radius='pill'] .gram-elements {
248
248
  --radius: 9999px;
249
249
  }
250
+
251
+ /* assistant-ui loading dot styles (from @assistant-ui/react-markdown/styles/dot.css) */
252
+ @keyframes aui-pulse {
253
+ 50% {
254
+ opacity: 0.5;
255
+ }
256
+ }
257
+
258
+ .gram-elements :where(.aui-md[data-status='running']):empty::after,
259
+ .gram-elements
260
+ :where(.aui-md[data-status='running'])
261
+ > :where(:not(ol):not(ul):not(pre)):last-child::after,
262
+ .gram-elements :where(.aui-md[data-status='running']) > pre:last-child code::after,
263
+ .gram-elements
264
+ :where(.aui-md[data-status='running'])
265
+ > :where(:is(ol, ul):last-child)
266
+ > :where(li:last-child:not(:has(* > li)))::after,
267
+ .gram-elements
268
+ :where(.aui-md[data-status='running'])
269
+ > :where(:is(ol, ul):last-child)
270
+ > :where(li:last-child)
271
+ > :where(:is(ol, ul):last-child)
272
+ > :where(li:last-child:not(:has(* > li)))::after,
273
+ .gram-elements
274
+ :where(.aui-md[data-status='running'])
275
+ > :where(:is(ol, ul):last-child)
276
+ > :where(li:last-child)
277
+ > :where(:is(ol, ul):last-child)
278
+ > :where(li:last-child)
279
+ > :where(:is(ol, ul):last-child)
280
+ > :where(li:last-child)::after {
281
+ animation: aui-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
282
+ font-family:
283
+ ui-sans-serif,
284
+ system-ui,
285
+ sans-serif,
286
+ 'Apple Color Emoji',
287
+ 'Segoe UI Emoji',
288
+ 'Segoe UI Symbol',
289
+ 'Noto Color Emoji';
290
+ --aui-content: '\25cf';
291
+ content: var(--aui-content);
292
+ margin-left: 0.25rem;
293
+ margin-right: 0.25rem;
294
+ }
@@ -6,7 +6,7 @@ import {
6
6
  useAssistantApi,
7
7
  type AssistantApi,
8
8
  } from '@assistant-ui/react'
9
- import type { AssistantStream } from 'assistant-stream'
9
+ import { createAssistantStream, type AssistantStream } from 'assistant-stream'
10
10
  import {
11
11
  GramChatOverview,
12
12
  GramChat,
@@ -21,9 +21,26 @@ import {
21
21
  type PropsWithChildren,
22
22
  } from 'react'
23
23
 
24
+ /**
25
+ * Prefix used by assistant-ui for local thread IDs that haven't been persisted yet.
26
+ * This is an internal implementation detail of assistant-ui's RemoteThreadListThreadListRuntimeCore.
27
+ * If the library changes this prefix, we only need to update it here.
28
+ */
29
+ const LOCAL_THREAD_ID_PREFIX = '__LOCALID_'
30
+
31
+ /**
32
+ * Checks if a thread ID is a local (unpersisted) thread ID.
33
+ * Local IDs are generated by assistant-ui before the thread is initialized with a remote ID.
34
+ */
35
+ export function isLocalThreadId(threadId: string | undefined): boolean {
36
+ return !!threadId?.startsWith(LOCAL_THREAD_ID_PREFIX)
37
+ }
38
+
24
39
  export interface ThreadListAdapterOptions {
25
40
  apiUrl: string
26
41
  headers: Record<string, string>
42
+ /** Map to translate local thread IDs to UUIDs (shared with transport) */
43
+ localIdToUuidMap?: Map<string, string>
27
44
  }
28
45
 
29
46
  interface ListChatsResponse {
@@ -227,6 +244,26 @@ export function useGramThreadListAdapter(
227
244
  },
228
245
 
229
246
  async initialize(threadId: string) {
247
+ // For new threads (local IDs), check if sendMessages already created a UUID
248
+ if (isLocalThreadId(threadId)) {
249
+ // Check if transport already generated a UUID for this local ID
250
+ const existingUuid =
251
+ optionsRef.current.localIdToUuidMap?.get(threadId)
252
+ if (existingUuid) {
253
+ return {
254
+ remoteId: existingUuid,
255
+ externalId: existingUuid,
256
+ }
257
+ }
258
+ // Otherwise generate a new one and store it
259
+ const uuid = crypto.randomUUID()
260
+ optionsRef.current.localIdToUuidMap?.set(threadId, uuid)
261
+ return {
262
+ remoteId: uuid,
263
+ externalId: uuid,
264
+ }
265
+ }
266
+ // For existing threads, use the ID as-is
230
267
  return {
231
268
  remoteId: threadId,
232
269
  externalId: threadId,
@@ -250,21 +287,65 @@ export function useGramThreadListAdapter(
250
287
  },
251
288
 
252
289
  async generateTitle(
253
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
254
- _remoteId: string,
290
+ remoteId: string,
255
291
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
256
292
  _messages: readonly ThreadMessage[]
257
293
  ): Promise<AssistantStream> {
258
- // Return an empty stream that immediately completes
259
- // Server generates titles automatically, so we just provide a placeholder
260
- return new ReadableStream({
261
- start(controller) {
294
+ // Skip if this is a local ID that hasn't been persisted yet
295
+ if (!remoteId || isLocalThreadId(remoteId)) {
296
+ return createAssistantStream((controller) => {
262
297
  controller.close()
263
- },
264
- }) as AssistantStream
298
+ })
299
+ }
300
+
301
+ // Call the server to generate and persist the title
302
+ try {
303
+ const response = await fetch(
304
+ `${optionsRef.current.apiUrl}/rpc/chat.generateTitle`,
305
+ {
306
+ method: 'POST',
307
+ headers: {
308
+ ...optionsRef.current.headers,
309
+ 'Content-Type': 'application/json',
310
+ },
311
+ body: JSON.stringify({ id: remoteId }),
312
+ }
313
+ )
314
+
315
+ if (response.ok) {
316
+ const result = (await response.json()) as { title: string }
317
+ const title = result.title || 'New Chat'
318
+
319
+ // Return a stream that emits the title as text
320
+ return createAssistantStream((controller) => {
321
+ controller.appendText(title)
322
+ controller.close()
323
+ })
324
+ }
325
+
326
+ // 404 is expected for new chats that haven't been persisted yet
327
+ if (response.status !== 404) {
328
+ console.error('Error generating title:', response.status)
329
+ }
330
+ } catch (error) {
331
+ console.error('Error generating title:', error)
332
+ }
333
+
334
+ // Fallback: return empty stream
335
+ return createAssistantStream((controller) => {
336
+ controller.close()
337
+ })
265
338
  },
266
339
 
267
340
  async fetch(threadId: string) {
341
+ // Skip if this is a local ID that hasn't been persisted yet
342
+ if (!threadId || isLocalThreadId(threadId)) {
343
+ return {
344
+ remoteId: threadId,
345
+ status: 'regular' as const,
346
+ }
347
+ }
348
+
268
349
  try {
269
350
  const response = await fetch(
270
351
  `${optionsRef.current.apiUrl}/rpc/chat.load?id=${encodeURIComponent(threadId)}`,
@@ -11,17 +11,19 @@ export function useMCPTools({
11
11
  auth,
12
12
  mcp,
13
13
  environment,
14
+ gramEnvironment,
14
15
  }: {
15
16
  auth: Auth
16
17
  mcp: string | undefined
17
18
  environment: Record<string, unknown>
19
+ gramEnvironment?: string
18
20
  }): UseQueryResult<MCPToolsResult, Error> {
19
21
  const authQueryKey = Object.entries(auth.headers ?? {}).map(
20
22
  (k, v) => `${k}:${v}`
21
23
  )
22
24
 
23
25
  const queryResult = useQuery({
24
- queryKey: ['mcpTools', mcp, ...authQueryKey],
26
+ queryKey: ['mcpTools', mcp, gramEnvironment, ...authQueryKey],
25
27
  queryFn: async () => {
26
28
  assert(!auth.isLoading, 'No auth found')
27
29
  assert(mcp, 'No MCP URL found')
@@ -34,6 +36,7 @@ export function useMCPTools({
34
36
  headers: {
35
37
  ...transformEnvironmentToHeaders(environment ?? {}),
36
38
  ...auth.headers,
39
+ ...(gramEnvironment && { 'Gram-Environment': gramEnvironment }),
37
40
  },
38
41
  },
39
42
  })
@@ -0,0 +1,93 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react'
2
+ import { useAssistantApi, useAssistantState } from '@assistant-ui/react'
3
+ import {
4
+ MentionableTool,
5
+ parseMentionedTools,
6
+ removeToolMention,
7
+ toolSetToMentionableTools,
8
+ } from '@/lib/tool-mentions'
9
+
10
+ export interface UseToolMentionsOptions {
11
+ tools: Record<string, unknown> | undefined
12
+ enabled?: boolean
13
+ }
14
+
15
+ export interface UseToolMentionsReturn {
16
+ mentionableTools: MentionableTool[]
17
+ mentionedToolIds: string[]
18
+ value: string
19
+ cursorPosition: number
20
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>
21
+ updateCursorPosition: () => void
22
+ handleAutocompleteChange: (value: string, cursorPosition: number) => void
23
+ removeMention: (toolId: string) => void
24
+ isActive: boolean
25
+ }
26
+
27
+ export function useToolMentions({
28
+ tools,
29
+ enabled = true,
30
+ }: UseToolMentionsOptions): UseToolMentionsReturn {
31
+ const [cursorPosition, setCursorPosition] = useState(0)
32
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
33
+ const api = useAssistantApi()
34
+ const composerText = useAssistantState(({ composer }) => composer.text)
35
+
36
+ const mentionableTools = useMemo(
37
+ () => toolSetToMentionableTools(tools),
38
+ [tools]
39
+ )
40
+
41
+ const mentionedToolIds = useMemo(
42
+ () => (enabled ? parseMentionedTools(composerText, tools) : []),
43
+ [composerText, tools, enabled]
44
+ )
45
+
46
+ const updateCursorPosition = useCallback(() => {
47
+ const textarea = textareaRef.current
48
+ if (textarea) {
49
+ setCursorPosition(textarea.selectionStart)
50
+ }
51
+ }, [])
52
+
53
+ const handleAutocompleteChange = useCallback(
54
+ (newValue: string, newCursorPosition: number) => {
55
+ api.composer().setText(newValue)
56
+ setCursorPosition(newCursorPosition)
57
+
58
+ setTimeout(() => {
59
+ const textarea = textareaRef.current
60
+ if (textarea) {
61
+ textarea.focus()
62
+ textarea.setSelectionRange(newCursorPosition, newCursorPosition)
63
+ }
64
+ }, 0)
65
+ },
66
+ [api]
67
+ )
68
+
69
+ const removeMention = useCallback(
70
+ (toolId: string) => {
71
+ const tool = mentionableTools.find((t) => t.id === toolId)
72
+ if (tool) {
73
+ const newValue = removeToolMention(composerText, tool.name)
74
+ api.composer().setText(newValue)
75
+ }
76
+ },
77
+ [composerText, mentionableTools, api]
78
+ )
79
+
80
+ const isActive = enabled && mentionableTools.length > 0
81
+
82
+ return {
83
+ mentionableTools,
84
+ mentionedToolIds,
85
+ value: composerText,
86
+ cursorPosition,
87
+ textareaRef,
88
+ updateCursorPosition,
89
+ handleAutocompleteChange,
90
+ removeMention,
91
+ isActive,
92
+ }
93
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export { useElements } from './hooks/useElements'
9
9
 
10
10
  // Core Components
11
11
  export { Chat } from '@/components/Chat'
12
- export { ThreadList as ChatHistory } from '@/components/assistant-ui/thread-list'
12
+ export { ChatHistory } from '@/components/ChatHistory'
13
13
 
14
14
  // Frontend Tools
15
15
  export { defineFrontendTool } from './lib/tools'
@@ -43,6 +43,7 @@ export type {
43
43
  SidecarConfig,
44
44
  Suggestion,
45
45
  ThemeConfig,
46
+ ToolMentionsConfig,
46
47
  ToolsConfig,
47
48
  Variant,
48
49
  VARIANTS,
@@ -0,0 +1,106 @@
1
+ export type ToolRecord = Record<string, unknown> | undefined
2
+
3
+ export interface MentionableTool {
4
+ id: string
5
+ name: string
6
+ description?: string
7
+ }
8
+
9
+ export interface MentionContext {
10
+ isInMention: boolean
11
+ query: string
12
+ atPosition: number
13
+ }
14
+
15
+ const MENTION_PATTERN = /@(\w+)/g
16
+
17
+ export function toolSetToMentionableTools(
18
+ tools: ToolRecord
19
+ ): MentionableTool[] {
20
+ if (!tools) return []
21
+
22
+ return Object.entries(tools).map(([name, tool]) => ({
23
+ id: name,
24
+ name,
25
+ description:
26
+ typeof tool === 'object' && tool !== null && 'description' in tool
27
+ ? String((tool as { description?: unknown }).description ?? '')
28
+ : undefined,
29
+ }))
30
+ }
31
+
32
+ export function parseMentionedTools(text: string, tools: ToolRecord): string[] {
33
+ if (!tools || !text) return []
34
+
35
+ const toolNames = Object.keys(tools)
36
+ const mentions: string[] = []
37
+ let match: RegExpExecArray | null
38
+
39
+ MENTION_PATTERN.lastIndex = 0
40
+ while ((match = MENTION_PATTERN.exec(text)) !== null) {
41
+ mentions.push(match[1].toLowerCase())
42
+ }
43
+
44
+ const matchedToolIds = toolNames.filter((name) =>
45
+ mentions.includes(name.toLowerCase())
46
+ )
47
+
48
+ return [...new Set(matchedToolIds)]
49
+ }
50
+
51
+ export function detectMentionContext(
52
+ text: string,
53
+ cursorPosition: number
54
+ ): MentionContext {
55
+ const textBeforeCursor = text.slice(0, cursorPosition)
56
+ const lastAtSymbol = textBeforeCursor.lastIndexOf('@')
57
+
58
+ if (lastAtSymbol === -1) {
59
+ return { isInMention: false, query: '', atPosition: -1 }
60
+ }
61
+
62
+ const textAfterAt = textBeforeCursor.slice(lastAtSymbol + 1)
63
+
64
+ if (textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
65
+ return { isInMention: false, query: '', atPosition: -1 }
66
+ }
67
+
68
+ return {
69
+ isInMention: true,
70
+ query: textAfterAt.toLowerCase(),
71
+ atPosition: lastAtSymbol,
72
+ }
73
+ }
74
+
75
+ export function filterToolsByQuery(
76
+ tools: MentionableTool[],
77
+ query: string
78
+ ): MentionableTool[] {
79
+ if (!query) return tools
80
+
81
+ const queryLower = query.toLowerCase()
82
+
83
+ return tools.filter((tool) => {
84
+ const nameMatch = tool.name.toLowerCase().includes(queryLower)
85
+ const descMatch = tool.description?.toLowerCase().includes(queryLower)
86
+ return nameMatch || descMatch
87
+ })
88
+ }
89
+
90
+ export function insertToolMention(
91
+ text: string,
92
+ toolName: string,
93
+ atPosition: number,
94
+ cursorPosition: number
95
+ ): { text: string; cursorPosition: number } {
96
+ const beforeMention = text.slice(0, atPosition)
97
+ const afterCursor = text.slice(cursorPosition)
98
+ const newText = `${beforeMention}@${toolName} ${afterCursor}`
99
+ const newCursorPosition = atPosition + toolName.length + 2
100
+ return { text: newText, cursorPosition: newCursorPosition }
101
+ }
102
+
103
+ export function removeToolMention(text: string, toolName: string): string {
104
+ const pattern = new RegExp(`@${toolName}\\s?`, 'gi')
105
+ return text.replace(pattern, '')
106
+ }
@@ -111,6 +111,13 @@ export interface ElementsConfig {
111
111
  */
112
112
  environment?: Record<string, unknown>
113
113
 
114
+ /**
115
+ * The environment slug to use for resolving secrets.
116
+ * When specified, this is sent as the Gram-Environment header to select
117
+ * which environment's secrets to use for tool execution.
118
+ */
119
+ gramEnvironment?: string
120
+
114
121
  /**
115
122
  * The layout variant for the chat interface.
116
123
  *
@@ -747,6 +754,13 @@ export interface ComposerConfig {
747
754
  * @default true
748
755
  */
749
756
  attachments?: boolean | AttachmentsConfig
757
+
758
+ /**
759
+ * Configuration for @tool mentions in the composer.
760
+ * Set to `false` to disable, `true` for defaults, or an object for fine-grained control.
761
+ * @default true
762
+ */
763
+ toolMentions?: boolean | ToolMentionsConfig
750
764
  }
751
765
 
752
766
  /**
@@ -774,6 +788,14 @@ export interface AttachmentsConfig {
774
788
  maxSize?: number
775
789
  }
776
790
 
791
+ export interface ToolMentionsConfig {
792
+ /** @default true */
793
+ enabled?: boolean
794
+ /** @default 10 */
795
+ maxSuggestions?: number
796
+ placeholder?: string
797
+ }
798
+
777
799
  export interface SidecarConfig extends ExpandableConfig {
778
800
  /**
779
801
  * The title displayed in the sidecar header.
@@ -822,4 +844,5 @@ export type ElementsContextType = {
822
844
  isOpen: boolean
823
845
  setIsOpen: (isOpen: boolean) => void
824
846
  plugins: Plugin[]
847
+ mcpTools: Record<string, unknown> | undefined
825
848
  }
package/src/vite-env.d.ts CHANGED
@@ -13,3 +13,8 @@ interface ImportMetaEnv {
13
13
  interface ImportMeta {
14
14
  readonly env: ImportMetaEnv
15
15
  }
16
+
17
+ declare module '*.css?inline' {
18
+ const content: string
19
+ export default content
20
+ }