@gram-ai/elements 1.37.0 → 1.38.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 (38) hide show
  1. package/dist/components/Markdown.d.ts +7 -0
  2. package/dist/components/MessageContent.d.ts +4 -0
  3. package/dist/components/ui/tool-ui.d.ts +52 -3
  4. package/dist/contexts/ThreadMetaContext.d.ts +20 -0
  5. package/dist/elements.cjs +1 -1
  6. package/dist/elements.css +1 -1
  7. package/dist/elements.js +26 -22
  8. package/dist/hooks/useMCPTools.d.ts +1 -1
  9. package/dist/{index-Em1Ot0b6.js → index--UMkUr53.js} +27562 -21884
  10. package/dist/index--UMkUr53.js.map +1 -0
  11. package/dist/index-Cz9y5YHw.cjs +222 -0
  12. package/dist/index-Cz9y5YHw.cjs.map +1 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/lib/messageConverter.d.ts +2 -0
  15. package/dist/{profiler-BnInDjd4.js → profiler-BHXyuGiY.js} +2 -2
  16. package/dist/{profiler-BnInDjd4.js.map → profiler-BHXyuGiY.js.map} +1 -1
  17. package/dist/{profiler-DIwReaSQ.cjs → profiler-jAEvoPXB.cjs} +2 -2
  18. package/dist/{profiler-DIwReaSQ.cjs.map → profiler-jAEvoPXB.cjs.map} +1 -1
  19. package/dist/{startRecording-P_J6QFPD.js → startRecording-D8IbKhJo.js} +2 -2
  20. package/dist/{startRecording-P_J6QFPD.js.map → startRecording-D8IbKhJo.js.map} +1 -1
  21. package/dist/{startRecording-Cg4fxzWw.cjs → startRecording-Dw4aGDrV.cjs} +2 -2
  22. package/dist/{startRecording-Cg4fxzWw.cjs.map → startRecording-Dw4aGDrV.cjs.map} +1 -1
  23. package/package.json +11 -13
  24. package/src/components/Markdown.tsx +210 -0
  25. package/src/components/MessageContent.tsx +9 -0
  26. package/src/components/assistant-ui/thinking-indicator.tsx +42 -0
  27. package/src/components/assistant-ui/thread-list.tsx +50 -5
  28. package/src/components/ui/tool-ui.tsx +360 -7
  29. package/src/contexts/ElementsProvider.tsx +2 -1
  30. package/src/contexts/ThreadMetaContext.ts +27 -0
  31. package/src/hooks/useGramThreadListAdapter.tsx +101 -20
  32. package/src/hooks/useMCPTools.ts +1 -1
  33. package/src/index.ts +18 -0
  34. package/src/lib/messageConverter.ts +5 -0
  35. package/src/lib/tools.test.ts +24 -12
  36. package/dist/index-Dpk3C8VH.cjs +0 -194
  37. package/dist/index-Dpk3C8VH.cjs.map +0 -1
  38. package/dist/index-Em1Ot0b6.js.map +0 -1
@@ -15,6 +15,10 @@ import {
15
15
  convertGramMessagesToUIMessages,
16
16
  } from "@/lib/messageConverter";
17
17
  import { sleep } from "@/lib/utils";
18
+ import {
19
+ ThreadMetaContext,
20
+ type ThreadMeta,
21
+ } from "@/contexts/ThreadMetaContext";
18
22
  import {
19
23
  useCallback,
20
24
  useEffect,
@@ -108,6 +112,18 @@ interface ListChatsResponse {
108
112
  chats: GramChatOverview[];
109
113
  }
110
114
 
115
+ /**
116
+ * Reads a chat's creation timestamp from the `chat.list` payload. The wire
117
+ * format is snake_case (`created_at`) — the hand-written GramChatOverview type
118
+ * declares camelCase, which never matched the JSON — so check both and return
119
+ * an ISO string, or undefined when absent.
120
+ */
121
+ function readChatCreatedAt(chat: GramChatOverview): string | undefined {
122
+ const raw = chat as unknown as Record<string, unknown>;
123
+ const value = raw["created_at"] ?? raw["createdAt"];
124
+ return typeof value === "string" ? value : undefined;
125
+ }
126
+
111
127
  /**
112
128
  * Resolves request headers from the live adapter options: the async
113
129
  * `getHeaders` (which waits for auth to settle) when provided, otherwise the
@@ -119,6 +135,54 @@ async function resolveAdapterHeaders(
119
135
  return options.getHeaders ? options.getHeaders() : options.headers;
120
136
  }
121
137
 
138
+ // chat.load paginates by seq keyset and returns only the newest page by default.
139
+ // assistant-ui's history adapter is one-shot (it imports the whole thread at
140
+ // once), so page backwards to the start and return the chat with every message
141
+ // in ascending order. Without this a long thread would render only its newest
142
+ // page with no way to reach the rest.
143
+ const FULL_LOAD_PAGE_SIZE = 200;
144
+
145
+ async function loadFullChat(
146
+ apiUrl: string,
147
+ chatId: string,
148
+ headers: Record<string, string>,
149
+ ): Promise<GramChat | null> {
150
+ let beforeSeq: number | undefined;
151
+ let all: GramChatMessage[] = [];
152
+ let base: GramChat | null = null;
153
+
154
+ for (;;) {
155
+ const cursor = beforeSeq !== undefined ? `&before_seq=${beforeSeq}` : "";
156
+ const response = await fetch(
157
+ `${apiUrl}/rpc/chat.load?id=${encodeURIComponent(chatId)}&limit=${FULL_LOAD_PAGE_SIZE}${cursor}`,
158
+ { headers },
159
+ );
160
+ if (!response.ok) {
161
+ // First page failed → nothing to show. A later page failing means we'd
162
+ // silently truncate the thread, so log loudly rather than passing partial
163
+ // history off as complete.
164
+ if (!base) {
165
+ console.error("Failed to load chat:", response.status);
166
+ return null;
167
+ }
168
+ console.error(
169
+ `Failed to load full chat history (status ${response.status}); ` +
170
+ `returning ${all.length} of ${base.numMessages ?? "?"} messages — transcript is truncated.`,
171
+ );
172
+ return { ...base, messages: all };
173
+ }
174
+ const page = (await response.json()) as GramChat;
175
+ if (!base) base = page;
176
+ // Each page is ascending; older pages prepend.
177
+ all = [...page.messages, ...all];
178
+ const oldest = page.messages[0];
179
+ if (!page.has_more_before || !oldest || oldest.seq === undefined) break;
180
+ beforeSeq = oldest.seq;
181
+ }
182
+
183
+ return base ? { ...base, messages: all } : null;
184
+ }
185
+
122
186
  /**
123
187
  * Thread history adapter that loads messages from Gram API.
124
188
  * Note: We use `as ThreadHistoryAdapter` cast because the withFormat generic
@@ -172,17 +236,15 @@ class GramThreadHistoryAdapter {
172
236
  }
173
237
 
174
238
  try {
175
- const response = await fetch(
176
- `${this.apiUrl}/rpc/chat.load?id=${encodeURIComponent(remoteId)}`,
177
- { headers: await this.getHeaders() },
239
+ const chat = await loadFullChat(
240
+ this.apiUrl,
241
+ remoteId,
242
+ await this.getHeaders(),
178
243
  );
179
-
180
- if (!response.ok) {
181
- console.error("Failed to load chat:", response.status);
244
+ if (!chat) {
245
+ console.error("Failed to load chat");
182
246
  return { messages: [], headId: null };
183
247
  }
184
-
185
- const chat = (await response.json()) as GramChat;
186
248
  return convertGramMessagesToExported(this.applyTransform(chat.messages));
187
249
  } catch (error) {
188
250
  console.error("Error loading chat:", error);
@@ -206,17 +268,15 @@ class GramThreadHistoryAdapter {
206
268
  return { messages: [], headId: null };
207
269
  }
208
270
 
209
- const response = await fetch(
210
- `${this.apiUrl}/rpc/chat.load?id=${encodeURIComponent(remoteId)}`,
211
- { headers: await this.getHeaders() },
271
+ const chat = await loadFullChat(
272
+ this.apiUrl,
273
+ remoteId,
274
+ await this.getHeaders(),
212
275
  );
213
-
214
- if (!response.ok) {
215
- console.error("Failed to load chat (withFormat):", response.status);
276
+ if (!chat) {
277
+ console.error("Failed to load chat (withFormat)");
216
278
  return { messages: [], headId: null };
217
279
  }
218
-
219
- const chat = (await response.json()) as GramChat;
220
280
  return convertGramMessagesToUIMessages(
221
281
  this.applyTransform(chat.messages),
222
282
  );
@@ -293,6 +353,14 @@ export function useGramThreadListAdapter(
293
353
  optionsRef.current = options;
294
354
  }, [options]);
295
355
 
356
+ // Side channel for per-chat metadata (creation date) that assistant-ui's
357
+ // RemoteThreadMetadata can't carry. `list()` writes a fresh object into the
358
+ // ref and bumps the counter so the provider passes a new context value and
359
+ // ThreadListItem re-renders with the dates. A ref (not state) holds the data
360
+ // so the stable `list()` closure can write it without a re-created identity.
361
+ const metaRef = useRef<Record<string, ThreadMeta>>({});
362
+ const [, bumpMeta] = useState(0);
363
+
296
364
  // Create stable Provider component using useCallback
297
365
  const unstable_Provider = useCallback(function GramHistoryProvider({
298
366
  children,
@@ -300,9 +368,11 @@ export function useGramThreadListAdapter(
300
368
  const history = useGramThreadHistoryAdapter(optionsRef);
301
369
  const adapters = useMemo(() => ({ history }), [history]);
302
370
  return (
303
- <RuntimeAdapterProvider adapters={adapters}>
304
- {children}
305
- </RuntimeAdapterProvider>
371
+ <ThreadMetaContext.Provider value={metaRef.current}>
372
+ <RuntimeAdapterProvider adapters={adapters}>
373
+ {children}
374
+ </RuntimeAdapterProvider>
375
+ </ThreadMetaContext.Provider>
306
376
  );
307
377
  }, []);
308
378
 
@@ -329,6 +399,15 @@ export function useGramThreadListAdapter(
329
399
  }
330
400
 
331
401
  const data = (await response.json()) as ListChatsResponse;
402
+ // Stash creation dates in the side channel before returning the
403
+ // assistant-ui metadata (which can't carry them) — keyed by chat id,
404
+ // the same value used for remoteId/externalId below.
405
+ const nextMeta: Record<string, ThreadMeta> = { ...metaRef.current };
406
+ for (const chat of data.chats) {
407
+ nextMeta[chat.id] = { createdAt: readChatCreatedAt(chat) };
408
+ }
409
+ metaRef.current = nextMeta;
410
+ bumpMeta((n) => n + 1);
332
411
  return {
333
412
  threads: data.chats.map((chat) => ({
334
413
  remoteId: chat.id,
@@ -474,8 +553,10 @@ export function useGramThreadListAdapter(
474
553
  }
475
554
 
476
555
  try {
556
+ // Only chat metadata (id/title) is needed here, so request the
557
+ // smallest page instead of a full message page.
477
558
  const response = await fetch(
478
- `${optionsRef.current.apiUrl}/rpc/chat.load?id=${encodeURIComponent(threadId)}`,
559
+ `${optionsRef.current.apiUrl}/rpc/chat.load?id=${encodeURIComponent(threadId)}&limit=1`,
479
560
  {
480
561
  headers: await resolveAdapterHeaders(optionsRef.current),
481
562
  },
@@ -1,7 +1,7 @@
1
1
  import { assert } from "@/lib/utils";
2
2
  import type { MCPServerEntry } from "@/types";
3
3
  import { ToolsFilter } from "@/types";
4
- import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp";
4
+ import { createMCPClient } from "@ai-sdk/mcp";
5
5
  import { useQuery, type UseQueryResult } from "@tanstack/react-query";
6
6
  import { useMemo, useRef } from "react";
7
7
  import { trackError } from "@/lib/errorTracking";
package/src/index.ts CHANGED
@@ -20,6 +20,24 @@ export type { ShareButtonProps } from "@/components/ShareButton";
20
20
  export { ToolFallback } from "@/components/assistant-ui/tool-fallback";
21
21
  export { MessageContent } from "@/components/MessageContent";
22
22
  export type { MessageContentProps } from "@/components/MessageContent";
23
+ export { Markdown } from "@/components/Markdown";
24
+ export type { MarkdownProps } from "@/components/Markdown";
25
+
26
+ // Static presentation primitives — render with no ElementsProvider/runtime, so
27
+ // the dashboard's chat detail panel can reuse the elements tool UI directly.
28
+ export {
29
+ ToolUI,
30
+ ToolUISection,
31
+ SyntaxHighlightedCode,
32
+ } from "@/components/ui/tool-ui";
33
+ export type {
34
+ ToolUIProps,
35
+ ToolUISectionProps,
36
+ ToolStatus,
37
+ ContentItem,
38
+ SectionHighlight,
39
+ SectionMatch,
40
+ } from "@/components/ui/tool-ui";
23
41
 
24
42
  // Replay
25
43
  export { Replay } from "@/components/Replay";
@@ -71,6 +71,8 @@ export type GramChatContent = string | GramChatContentPart[];
71
71
  */
72
72
  export interface GramChatMessage {
73
73
  id: string;
74
+ // Monotonic sequence number; used as the keyset cursor when paging chat.load.
75
+ seq?: number;
74
76
  model: string;
75
77
  created_at: Date | string;
76
78
  role: "system" | "developer" | "user" | "assistant" | "tool";
@@ -92,6 +94,9 @@ export interface GramChat {
92
94
  messages: GramChatMessage[];
93
95
  createdAt: Date | string;
94
96
  updatedAt: Date | string;
97
+ // chat.load paginates by seq keyset; true when older messages remain before
98
+ // the first message in `messages` (snake_case to match the wire payload).
99
+ has_more_before?: boolean;
95
100
  }
96
101
 
97
102
  /**
@@ -11,12 +11,12 @@ import {
11
11
  type UIMessage,
12
12
  type UIMessagePart,
13
13
  } from "ai";
14
- import { MockLanguageModelV2 } from "ai/test";
14
+ import { MockLanguageModelV3 } from "ai/test";
15
15
 
16
16
  type MockStream = Extract<
17
17
  NonNullable<
18
18
  NonNullable<
19
- ConstructorParameters<typeof MockLanguageModelV2>[0]
19
+ ConstructorParameters<typeof MockLanguageModelV3>[0]
20
20
  >["doStream"]
21
21
  >,
22
22
  (...a: never[]) => PromiseLike<{ stream: ReadableStream<unknown> }>
@@ -70,8 +70,16 @@ function toolCallChunks(opts: {
70
70
  },
71
71
  {
72
72
  type: "finish",
73
- finishReason: "tool-calls",
74
- usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
73
+ finishReason: { unified: "tool-calls", raw: undefined },
74
+ usage: {
75
+ inputTokens: {
76
+ total: 1,
77
+ noCache: 1,
78
+ cacheRead: undefined,
79
+ cacheWrite: undefined,
80
+ },
81
+ outputTokens: { total: 1, text: 1, reasoning: undefined },
82
+ },
75
83
  },
76
84
  ];
77
85
  }
@@ -118,7 +126,7 @@ async function streamToolCallOnly(toolCallId: string): Promise<UIMessage[]> {
118
126
  },
119
127
  } as unknown as ToolSet;
120
128
 
121
- const model = new MockLanguageModelV2({
129
+ const model = new MockLanguageModelV3({
122
130
  doStream: async () => ({
123
131
  stream: makeStream([
124
132
  ...toolCallChunks({
@@ -174,10 +182,13 @@ describe("frontend tool Skip flow (sendAutomaticallyWhen fix)", () => {
174
182
  false,
175
183
  );
176
184
 
177
- // And the resulting model-message sequence contains a bogus `role: "tool"`
178
- // with empty content the provider will reject this as an invalid tool
179
- // message, surfacing to the user as the "needs role: assistant" error.
180
- const modelMsgs = convertToModelMessages(follow);
185
+ // The resulting model-message sequence has an assistant `tool-call` with no
186
+ // matching tool result. (In ai v5 `convertToModelMessages` fabricated a
187
+ // bogus empty `role: "tool"` message here; ai v6 instead drops the
188
+ // unresolved call entirely and the next turn is the follow-up user message.)
189
+ // Either way the sequence is invalid — a tool-call with no result — which is
190
+ // exactly the state the `sendAutomaticallyWhen` fix prevents.
191
+ const modelMsgs = await convertToModelMessages(follow);
181
192
  const assistantIdx = modelMsgs.findIndex(
182
193
  (m) =>
183
194
  m.role === "assistant" &&
@@ -187,8 +198,9 @@ describe("frontend tool Skip flow (sendAutomaticallyWhen fix)", () => {
187
198
  ),
188
199
  );
189
200
  expect(assistantIdx).toBeGreaterThanOrEqual(0);
190
- expect(modelMsgs[assistantIdx + 1]?.role).toBe("tool");
191
- expect(modelMsgs[assistantIdx + 1]?.content).toEqual([]);
201
+ // No valid tool result was produced for the unresolved tool-call.
202
+ expect(modelMsgs.some((m) => m.role === "tool")).toBe(false);
203
+ expect(modelMsgs[assistantIdx + 1]?.role).not.toBe("tool");
192
204
  });
193
205
 
194
206
  it("once the tool-result is patched onto the message, sendAutomaticallyWhen fires and the sequence is valid", async () => {
@@ -237,7 +249,7 @@ describe("frontend tool Skip flow (sendAutomaticallyWhen fix)", () => {
237
249
 
238
250
  // And the sequence handed to the model is well-formed (assistant
239
251
  // tool-call is followed by a real role:"tool" message with a result).
240
- const modelMsgs = convertToModelMessages(patched);
252
+ const modelMsgs = await convertToModelMessages(patched);
241
253
  const assistantIdx = modelMsgs.findIndex(
242
254
  (m) =>
243
255
  m.role === "assistant" &&