@copilotkit/react-core 1.55.0-next.9 → 1.55.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 (81) hide show
  1. package/CHANGELOG.md +36 -6
  2. package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.cts +1 -1
  12. package/dist/index.d.mts +1 -1
  13. package/dist/index.mjs +1 -1
  14. package/dist/index.umd.js +1400 -238
  15. package/dist/index.umd.js.map +1 -1
  16. package/dist/v2/index.cjs +13 -1
  17. package/dist/v2/index.css +1 -1
  18. package/dist/v2/index.d.cts +3 -3
  19. package/dist/v2/index.d.mts +3 -3
  20. package/dist/v2/index.mjs +3 -2
  21. package/dist/v2/index.umd.js +2442 -552
  22. package/dist/v2/index.umd.js.map +1 -1
  23. package/package.json +62 -54
  24. package/scripts/scope-preflight.mjs +1 -2
  25. package/src/components/CopilotListeners.tsx +41 -8
  26. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  27. package/src/components/toast/toast-provider.tsx +269 -194
  28. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  29. package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
  30. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  31. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  32. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  33. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  34. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  35. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  36. package/src/v2/components/chat/CopilotChat.tsx +193 -50
  37. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  38. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  39. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  40. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  41. package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
  42. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  43. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  44. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  45. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  46. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  47. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  48. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  49. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  50. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
  51. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  52. package/src/v2/components/chat/index.ts +9 -0
  53. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  54. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  55. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  56. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  57. package/src/v2/hooks/index.ts +5 -0
  58. package/src/v2/hooks/use-agent.tsx +95 -10
  59. package/src/v2/hooks/use-attachments.tsx +269 -0
  60. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  61. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  62. package/src/v2/hooks/use-threads.tsx +35 -15
  63. package/src/v2/index.ts +5 -1
  64. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  65. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  66. package/src/v2/lib/processPartialHtml.ts +45 -0
  67. package/src/v2/lib/slots.tsx +42 -1
  68. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  69. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  70. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  71. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  72. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  73. package/src/v2/providers/index.ts +7 -0
  74. package/src/v2/styles/globals.css +2 -1
  75. package/src/v2/types/index.ts +1 -0
  76. package/src/v2/types/sandbox-function.ts +11 -0
  77. package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
  78. package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
  79. package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
  80. package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
  81. package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
@@ -0,0 +1,169 @@
1
+ import React, { useRef, useEffect } from "react";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { useAttachments } from "../use-attachments";
5
+
6
+ describe("useAttachments", () => {
7
+ // -----------------------------------------------------------------------
8
+ // Referential stability — callbacks must not change between renders
9
+ // -----------------------------------------------------------------------
10
+
11
+ describe("referential stability", () => {
12
+ it("all callbacks are stable across re-renders with same config", () => {
13
+ const config = { enabled: true, accept: "image/*" };
14
+ const { result, rerender } = renderHook(() => useAttachments({ config }));
15
+
16
+ const first = result.current;
17
+ rerender();
18
+ const second = result.current;
19
+
20
+ expect(second.processFiles).toBe(first.processFiles);
21
+ expect(second.handleFileUpload).toBe(first.handleFileUpload);
22
+ expect(second.handleDragOver).toBe(first.handleDragOver);
23
+ expect(second.handleDragLeave).toBe(first.handleDragLeave);
24
+ expect(second.handleDrop).toBe(first.handleDrop);
25
+ expect(second.removeAttachment).toBe(first.removeAttachment);
26
+ expect(second.consumeAttachments).toBe(first.consumeAttachments);
27
+ });
28
+
29
+ it("callbacks remain stable when config object reference changes", () => {
30
+ let config = { enabled: true, accept: "image/*" };
31
+ const { result, rerender } = renderHook(() => useAttachments({ config }));
32
+
33
+ const first = result.current;
34
+
35
+ // Create a new config with same values — different reference
36
+ config = { enabled: true, accept: "image/*" };
37
+ rerender();
38
+ const second = result.current;
39
+
40
+ expect(second.processFiles).toBe(first.processFiles);
41
+ expect(second.handleFileUpload).toBe(first.handleFileUpload);
42
+ expect(second.handleDragOver).toBe(first.handleDragOver);
43
+ expect(second.handleDragLeave).toBe(first.handleDragLeave);
44
+ expect(second.handleDrop).toBe(first.handleDrop);
45
+ expect(second.removeAttachment).toBe(first.removeAttachment);
46
+ expect(second.consumeAttachments).toBe(first.consumeAttachments);
47
+ });
48
+
49
+ it("refs are stable across re-renders", () => {
50
+ const { result, rerender } = renderHook(() =>
51
+ useAttachments({ config: undefined }),
52
+ );
53
+
54
+ const first = result.current;
55
+ rerender();
56
+ const second = result.current;
57
+
58
+ expect(second.fileInputRef).toBe(first.fileInputRef);
59
+ expect(second.containerRef).toBe(first.containerRef);
60
+ });
61
+ });
62
+
63
+ // -----------------------------------------------------------------------
64
+ // Re-render counting — hook should not cause unnecessary renders
65
+ // -----------------------------------------------------------------------
66
+
67
+ describe("re-render counting", () => {
68
+ it("does not re-render when consumeAttachments is called on empty queue", () => {
69
+ let renderCount = 0;
70
+
71
+ const { result } = renderHook(() => {
72
+ renderCount++;
73
+ return useAttachments({ config: undefined });
74
+ });
75
+
76
+ const initialRenderCount = renderCount;
77
+
78
+ act(() => {
79
+ result.current.consumeAttachments();
80
+ });
81
+
82
+ // consumeAttachments on empty queue should not trigger a state update
83
+ expect(renderCount).toBe(initialRenderCount);
84
+ });
85
+
86
+ it("does not re-render on repeated consumeAttachments with empty queue", () => {
87
+ let renderCount = 0;
88
+
89
+ const { result } = renderHook(() => {
90
+ renderCount++;
91
+ return useAttachments({ config: undefined });
92
+ });
93
+
94
+ const initialRenderCount = renderCount;
95
+
96
+ act(() => {
97
+ result.current.consumeAttachments();
98
+ result.current.consumeAttachments();
99
+ result.current.consumeAttachments();
100
+ });
101
+
102
+ expect(renderCount).toBe(initialRenderCount);
103
+ });
104
+ });
105
+
106
+ // -----------------------------------------------------------------------
107
+ // State defaults
108
+ // -----------------------------------------------------------------------
109
+
110
+ describe("initial state", () => {
111
+ it("returns empty attachments and disabled by default", () => {
112
+ const { result } = renderHook(() =>
113
+ useAttachments({ config: undefined }),
114
+ );
115
+
116
+ expect(result.current.attachments).toEqual([]);
117
+ expect(result.current.enabled).toBe(false);
118
+ expect(result.current.dragOver).toBe(false);
119
+ });
120
+
121
+ it("returns enabled when config.enabled is true", () => {
122
+ const { result } = renderHook(() =>
123
+ useAttachments({ config: { enabled: true } }),
124
+ );
125
+
126
+ expect(result.current.enabled).toBe(true);
127
+ });
128
+ });
129
+
130
+ // -----------------------------------------------------------------------
131
+ // consumeAttachments behavior
132
+ // -----------------------------------------------------------------------
133
+
134
+ describe("consumeAttachments", () => {
135
+ it("returns empty array when no attachments", () => {
136
+ const { result } = renderHook(() =>
137
+ useAttachments({ config: undefined }),
138
+ );
139
+
140
+ let consumed: any[];
141
+ act(() => {
142
+ consumed = result.current.consumeAttachments();
143
+ });
144
+
145
+ expect(consumed!).toEqual([]);
146
+ });
147
+ });
148
+
149
+ // -----------------------------------------------------------------------
150
+ // removeAttachment
151
+ // -----------------------------------------------------------------------
152
+
153
+ describe("removeAttachment", () => {
154
+ it("is a no-op when id does not exist", () => {
155
+ const { result } = renderHook(() =>
156
+ useAttachments({ config: undefined }),
157
+ );
158
+
159
+ const before = result.current.attachments;
160
+
161
+ act(() => {
162
+ result.current.removeAttachment("nonexistent");
163
+ });
164
+
165
+ // Should still be empty, no crash
166
+ expect(result.current.attachments).toEqual([]);
167
+ });
168
+ });
169
+ });
@@ -383,6 +383,60 @@ describe("useThreads", () => {
383
383
  });
384
384
  });
385
385
 
386
+ it("exposes thread-scoped pagination properties", async () => {
387
+ fetchMock
388
+ .mockReturnValueOnce(
389
+ jsonResponse({
390
+ threads: sampleThreads,
391
+ joinCode: "jc-1",
392
+ nextCursor: "cursor-abc",
393
+ }),
394
+ )
395
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
396
+
397
+ const { result } = renderHook(() => useThreads(defaultInput));
398
+
399
+ await waitFor(() => {
400
+ expect(result.current.isLoading).toBe(false);
401
+ });
402
+
403
+ expect(result.current).toHaveProperty("hasMoreThreads");
404
+ expect(result.current).toHaveProperty("isFetchingMoreThreads");
405
+ expect(result.current).toHaveProperty("fetchMoreThreads");
406
+ expect(result.current).not.toHaveProperty("hasNextPage");
407
+ expect(result.current).not.toHaveProperty("isFetchingNextPage");
408
+ expect(result.current).not.toHaveProperty("fetchNextPage");
409
+
410
+ expect(result.current.hasMoreThreads).toBe(true);
411
+ expect(result.current.isFetchingMoreThreads).toBe(false);
412
+ expect(typeof result.current.fetchMoreThreads).toBe("function");
413
+ });
414
+
415
+ it("does not expose organizationId or createdById on threads", async () => {
416
+ fetchMock
417
+ .mockReturnValueOnce(
418
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
419
+ )
420
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
421
+
422
+ const { result } = renderHook(() => useThreads(defaultInput));
423
+
424
+ await waitFor(() => {
425
+ expect(result.current.isLoading).toBe(false);
426
+ });
427
+
428
+ for (const thread of result.current.threads) {
429
+ expect(thread).not.toHaveProperty("organizationId");
430
+ expect(thread).not.toHaveProperty("createdById");
431
+ expect(thread).toHaveProperty("id");
432
+ expect(thread).toHaveProperty("agentId");
433
+ expect(thread).toHaveProperty("name");
434
+ expect(thread).toHaveProperty("archived");
435
+ expect(thread).toHaveProperty("createdAt");
436
+ expect(thread).toHaveProperty("updatedAt");
437
+ }
438
+ });
439
+
386
440
  it("tears down sockets after repeated connection failures", async () => {
387
441
  fetchMock
388
442
  .mockReturnValueOnce(
@@ -16,3 +16,8 @@ export { useInterrupt } from "./use-interrupt";
16
16
  export type { UseInterruptConfig } from "./use-interrupt";
17
17
  export { useThreads } from "./use-threads";
18
18
  export type { Thread, UseThreadsInput, UseThreadsResult } from "./use-threads";
19
+ export { useAttachments } from "./use-attachments";
20
+ export type {
21
+ UseAttachmentsProps,
22
+ UseAttachmentsReturn,
23
+ } from "./use-attachments";
@@ -24,6 +24,26 @@ export interface UseAgentProps {
24
24
  agentId?: string;
25
25
  threadId?: string;
26
26
  updates?: UseAgentUpdate[];
27
+ /**
28
+ * Throttle interval (in milliseconds) for React re-renders triggered by
29
+ * `OnMessagesChanged` notifications. Useful to reduce re-render frequency
30
+ * during high-frequency message updates such as streaming.
31
+ *
32
+ * Uses leading+trailing: first update fires immediately, subsequent updates
33
+ * within the window are coalesced, and a trailing timer ensures the most
34
+ * recent update fires after the window expires. The trailing edge restarts
35
+ * the throttle window, so no two renders occur within `throttleMs` of each
36
+ * other. Cleanup on unmount cancels any pending trailing timer.
37
+ *
38
+ * Must be a non-negative finite number. Negative or non-finite values fall
39
+ * back to unthrottled behavior with a `console.error`. Only affects
40
+ * `OnMessagesChanged` updates — `OnStateChanged` and `OnRunStatusChanged`
41
+ * always fire immediately. If `updates` does not include
42
+ * `OnMessagesChanged`, this property has no effect.
43
+ *
44
+ * Default: `0` (no throttle).
45
+ */
46
+ throttleMs?: number;
27
47
  }
28
48
 
29
49
  /**
@@ -94,16 +114,40 @@ function getOrCreateThreadClone(
94
114
  return clone;
95
115
  }
96
116
 
97
- export function useAgent({ agentId, threadId, updates }: UseAgentProps = {}) {
117
+ export function useAgent({
118
+ agentId,
119
+ threadId,
120
+ updates,
121
+ throttleMs,
122
+ }: UseAgentProps = {}) {
98
123
  agentId ??= DEFAULT_AGENT_ID;
99
124
 
100
125
  const { copilotkit } = useCopilotKit();
126
+ const providerThrottleMs = copilotkit.defaultThrottleMs;
101
127
  // Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
102
128
  // that useAgent() called without explicit threadId (e.g. inside a custom
103
129
  // message renderer) automatically uses the same per-thread clone as the
104
130
  // CopilotChat component it lives within.
105
131
  const chatConfig = useCopilotChatConfiguration();
106
132
  threadId ??= chatConfig?.threadId;
133
+
134
+ const effectiveThrottleMs = useMemo(() => {
135
+ const resolved = throttleMs ?? providerThrottleMs ?? 0;
136
+ if (!Number.isFinite(resolved) || resolved < 0) {
137
+ // When both throttleMs and providerThrottleMs are undefined, resolved
138
+ // is 0 which passes validation — so one of them must be defined here.
139
+ const source =
140
+ throttleMs !== undefined
141
+ ? "hook-level throttleMs"
142
+ : "provider-level defaultThrottleMs";
143
+ console.error(
144
+ `useAgent: ${source} must be a non-negative finite number, got ${resolved}. Falling back to unthrottled.`,
145
+ );
146
+ return 0;
147
+ }
148
+ return resolved;
149
+ }, [throttleMs, providerThrottleMs]);
150
+
107
151
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
108
152
 
109
153
  const updateFlags = useMemo(
@@ -231,17 +275,52 @@ export function useAgent({ agentId, threadId, updates }: UseAgentProps = {}) {
231
275
  ]);
232
276
 
233
277
  useEffect(() => {
234
- if (updateFlags.length === 0) {
235
- return;
236
- }
278
+ if (updateFlags.length === 0) return;
237
279
 
238
280
  const handlers: Parameters<AbstractAgent["subscribe"]>[0] = {};
281
+ let timerId: ReturnType<typeof setTimeout> | null = null;
282
+ let active = true;
239
283
 
240
284
  if (updateFlags.includes(UseAgentUpdate.OnMessagesChanged)) {
241
- // Content stripping for immutableContent renderers is handled by CopilotKitCoreReact
242
- handlers.onMessagesChanged = () => {
243
- forceUpdate();
244
- };
285
+ const ms = effectiveThrottleMs;
286
+ if (ms > 0) {
287
+ // Throttled onMessagesChanged: leading+trailing pattern.
288
+ // First notification fires immediately, subsequent ones within the
289
+ // window are coalesced. Trailing timer fires after the window to
290
+ // ensure the final state is rendered.
291
+ let throttleActive = false;
292
+ // Tracks whether a notification arrived during the throttle window,
293
+ // so the trailing timer knows whether a re-render is needed.
294
+ let pending = false;
295
+
296
+ const throttledNotify = () => {
297
+ if (!active) return;
298
+ if (!throttleActive) {
299
+ // Leading edge — fire immediately and start the throttle window
300
+ throttleActive = true;
301
+ pending = false;
302
+ forceUpdate();
303
+ timerId = setTimeout(function trailingEdge() {
304
+ timerId = null;
305
+ if (active && pending) {
306
+ // Trailing edge — fire and restart the window
307
+ pending = false;
308
+ forceUpdate();
309
+ timerId = setTimeout(trailingEdge, ms);
310
+ } else {
311
+ // No pending notifications — end the window
312
+ throttleActive = false;
313
+ }
314
+ }, ms);
315
+ } else {
316
+ pending = true;
317
+ }
318
+ };
319
+
320
+ handlers.onMessagesChanged = throttledNotify;
321
+ } else {
322
+ handlers.onMessagesChanged = forceUpdate;
323
+ }
245
324
  }
246
325
 
247
326
  if (updateFlags.includes(UseAgentUpdate.OnStateChanged)) {
@@ -255,9 +334,15 @@ export function useAgent({ agentId, threadId, updates }: UseAgentProps = {}) {
255
334
  }
256
335
 
257
336
  const subscription = agent.subscribe(handlers);
258
- return () => subscription.unsubscribe();
337
+ return () => {
338
+ active = false;
339
+ if (timerId !== null) {
340
+ clearTimeout(timerId);
341
+ }
342
+ subscription.unsubscribe();
343
+ };
259
344
  // eslint-disable-next-line react-hooks/exhaustive-deps
260
- }, [agent, forceUpdate, JSON.stringify(updateFlags)]);
345
+ }, [agent, forceUpdate, effectiveThrottleMs, updateFlags]);
261
346
 
262
347
  // Keep HttpAgent headers fresh without mutating inside useMemo, which is
263
348
  // unsafe in concurrent mode (React may invoke useMemo multiple times and
@@ -0,0 +1,269 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ randomUUID,
4
+ getModalityFromMimeType,
5
+ exceedsMaxSize,
6
+ readFileAsBase64,
7
+ generateVideoThumbnail,
8
+ matchesAcceptFilter,
9
+ formatFileSize,
10
+ } from "@copilotkit/shared";
11
+ import type { Attachment, AttachmentsConfig } from "@copilotkit/shared";
12
+
13
+ export interface UseAttachmentsProps {
14
+ config?: AttachmentsConfig;
15
+ }
16
+
17
+ export interface UseAttachmentsReturn {
18
+ /** Currently selected attachments (uploading + ready). */
19
+ attachments: Attachment[];
20
+ /** Whether attachments are enabled. */
21
+ enabled: boolean;
22
+ /** Whether the user is dragging a file over the drop zone. */
23
+ dragOver: boolean;
24
+ /** Ref for the hidden file input element. */
25
+ fileInputRef: React.RefObject<HTMLInputElement | null>;
26
+ /** Ref for the container element (used for scoped paste handling). */
27
+ containerRef: React.RefObject<HTMLDivElement | null>;
28
+ /** Process an array of files (validate, upload, add to state). */
29
+ processFiles: (files: File[]) => Promise<void>;
30
+ /** Handler for `<input type="file" onChange>`. */
31
+ handleFileUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
32
+ /** Handler for `onDragOver` on the drop zone. */
33
+ handleDragOver: (e: React.DragEvent) => void;
34
+ /** Handler for `onDragLeave` on the drop zone. */
35
+ handleDragLeave: (e: React.DragEvent) => void;
36
+ /** Handler for `onDrop` on the drop zone. */
37
+ handleDrop: (e: React.DragEvent) => Promise<void>;
38
+ /** Remove an attachment by ID. */
39
+ removeAttachment: (id: string) => void;
40
+ /**
41
+ * Consume ready attachments and clear the queue.
42
+ * Returns the attachments that were ready; resets the file input.
43
+ * No-ops if the queue is already empty (no state update triggered).
44
+ */
45
+ consumeAttachments: () => Attachment[];
46
+ }
47
+
48
+ /**
49
+ * Hook that manages file attachment state — uploads, drag-and-drop, paste,
50
+ * and lifecycle. All returned callbacks are referentially stable across
51
+ * renders (via useCallback) to avoid destabilizing downstream memoization.
52
+ */
53
+ export function useAttachments({
54
+ config,
55
+ }: UseAttachmentsProps): UseAttachmentsReturn {
56
+ const enabled = config?.enabled ?? false;
57
+
58
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
59
+ const [dragOver, setDragOver] = useState(false);
60
+ const fileInputRef = useRef<HTMLInputElement>(null);
61
+ const containerRef = useRef<HTMLDivElement>(null);
62
+
63
+ // Keep refs to the latest values so stable callbacks can read current
64
+ // state without appearing in dependency arrays.
65
+ const configRef = useRef(config);
66
+ configRef.current = config;
67
+ const attachmentsRef = useRef<Attachment[]>([]);
68
+ attachmentsRef.current = attachments;
69
+
70
+ // Stable processFiles — reads config from ref, never changes identity
71
+ const processFiles = useCallback(async (files: File[]) => {
72
+ const cfg = configRef.current;
73
+ const accept = cfg?.accept ?? "*/*";
74
+ const maxSize = cfg?.maxSize ?? 20 * 1024 * 1024;
75
+
76
+ const rejectedFiles = files.filter(
77
+ (file) => !matchesAcceptFilter(file, accept),
78
+ );
79
+ for (const file of rejectedFiles) {
80
+ cfg?.onUploadFailed?.({
81
+ reason: "invalid-type",
82
+ file,
83
+ message: `File "${file.name}" is not accepted. Supported types: ${accept}`,
84
+ });
85
+ }
86
+
87
+ const validFiles = files.filter((file) =>
88
+ matchesAcceptFilter(file, accept),
89
+ );
90
+
91
+ for (const file of validFiles) {
92
+ if (exceedsMaxSize(file, maxSize)) {
93
+ cfg?.onUploadFailed?.({
94
+ reason: "file-too-large",
95
+ file,
96
+ message: `File "${file.name}" exceeds the maximum size of ${formatFileSize(maxSize)}`,
97
+ });
98
+ continue;
99
+ }
100
+
101
+ const modality = getModalityFromMimeType(file.type);
102
+ const placeholderId = randomUUID();
103
+ const placeholder: Attachment = {
104
+ id: placeholderId,
105
+ type: modality,
106
+ source: { type: "data", value: "", mimeType: file.type },
107
+ filename: file.name,
108
+ size: file.size,
109
+ status: "uploading",
110
+ };
111
+
112
+ setAttachments((prev) => [...prev, placeholder]);
113
+
114
+ try {
115
+ let source: Attachment["source"];
116
+ let uploadMetadata: Record<string, unknown> | undefined;
117
+
118
+ if (cfg?.onUpload) {
119
+ const { metadata: meta, ...uploadSource } = await cfg.onUpload(file);
120
+ source = uploadSource;
121
+ uploadMetadata = meta;
122
+ } else {
123
+ const base64 = await readFileAsBase64(file);
124
+ source = { type: "data", value: base64, mimeType: file.type };
125
+ }
126
+
127
+ let thumbnail: string | undefined;
128
+ if (modality === "video") {
129
+ thumbnail = await generateVideoThumbnail(file);
130
+ }
131
+
132
+ setAttachments((prev) =>
133
+ prev.map((att) =>
134
+ att.id === placeholderId
135
+ ? {
136
+ ...att,
137
+ source,
138
+ status: "ready" as const,
139
+ thumbnail,
140
+ metadata: uploadMetadata,
141
+ }
142
+ : att,
143
+ ),
144
+ );
145
+ } catch (error) {
146
+ setAttachments((prev) =>
147
+ prev.filter((att) => att.id !== placeholderId),
148
+ );
149
+ console.error(`[CopilotKit] Failed to upload "${file.name}":`, error);
150
+ cfg?.onUploadFailed?.({
151
+ reason: "upload-failed",
152
+ file,
153
+ message:
154
+ error instanceof Error
155
+ ? error.message
156
+ : `Failed to upload "${file.name}"`,
157
+ });
158
+ }
159
+ }
160
+ }, []);
161
+
162
+ const handleFileUpload = useCallback(
163
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
164
+ if (!e.target.files?.length) return;
165
+ try {
166
+ await processFiles(Array.from(e.target.files));
167
+ } catch (error) {
168
+ console.error("[CopilotKit] Upload error:", error);
169
+ }
170
+ },
171
+ [processFiles],
172
+ );
173
+
174
+ const handleDragOver = useCallback((e: React.DragEvent) => {
175
+ if (!configRef.current?.enabled) return;
176
+ e.preventDefault();
177
+ e.stopPropagation();
178
+ setDragOver(true);
179
+ }, []);
180
+
181
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
182
+ e.preventDefault();
183
+ e.stopPropagation();
184
+ setDragOver(false);
185
+ }, []);
186
+
187
+ const handleDrop = useCallback(
188
+ async (e: React.DragEvent) => {
189
+ e.preventDefault();
190
+ e.stopPropagation();
191
+ setDragOver(false);
192
+ if (!configRef.current?.enabled) return;
193
+
194
+ const files = Array.from(e.dataTransfer.files);
195
+ if (files.length > 0) {
196
+ try {
197
+ await processFiles(files);
198
+ } catch (error) {
199
+ console.error("[CopilotKit] Drop error:", error);
200
+ }
201
+ }
202
+ },
203
+ [processFiles],
204
+ );
205
+
206
+ // Clipboard paste handler — scoped to the container
207
+ useEffect(() => {
208
+ if (!enabled) return;
209
+
210
+ const handlePaste = async (e: ClipboardEvent) => {
211
+ const target = e.target as HTMLElement | null;
212
+ if (!target || !containerRef.current?.contains(target)) return;
213
+
214
+ const accept = configRef.current?.accept ?? "*/*";
215
+ const items = Array.from(e.clipboardData?.items || []);
216
+ const fileItems = items.filter(
217
+ (item) =>
218
+ item.kind === "file" &&
219
+ item.getAsFile() !== null &&
220
+ matchesAcceptFilter(item.getAsFile()!, accept),
221
+ );
222
+
223
+ if (fileItems.length === 0) return;
224
+ e.preventDefault();
225
+
226
+ const files = fileItems
227
+ .map((item) => item.getAsFile())
228
+ .filter((f): f is File => f !== null);
229
+
230
+ try {
231
+ await processFiles(files);
232
+ } catch (error) {
233
+ console.error("[CopilotKit] Paste error:", error);
234
+ }
235
+ };
236
+
237
+ document.addEventListener("paste", handlePaste);
238
+ return () => document.removeEventListener("paste", handlePaste);
239
+ }, [enabled, processFiles]);
240
+
241
+ const removeAttachment = useCallback((id: string) => {
242
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
243
+ }, []);
244
+
245
+ const consumeAttachments = useCallback(() => {
246
+ const ready = attachmentsRef.current.filter((a) => a.status === "ready");
247
+ if (ready.length === 0) return ready;
248
+ setAttachments((prev) => prev.filter((a) => a.status !== "ready"));
249
+ if (fileInputRef.current) {
250
+ fileInputRef.current.value = "";
251
+ }
252
+ return ready;
253
+ }, []);
254
+
255
+ return {
256
+ attachments,
257
+ enabled,
258
+ dragOver,
259
+ fileInputRef,
260
+ containerRef,
261
+ processFiles,
262
+ handleFileUpload,
263
+ handleDragOver,
264
+ handleDragLeave,
265
+ handleDrop,
266
+ removeAttachment,
267
+ consumeAttachments,
268
+ };
269
+ }
@@ -22,8 +22,11 @@ export function useFrontendTool<
22
22
  }
23
23
  copilotkit.addTool(tool);
24
24
 
25
- // Register/override renderer by name and agentId through core
26
- if (tool.render && tool.parameters) {
25
+ // Register/override renderer by name and agentId through core.
26
+ // The render function is registered even when tool.parameters is
27
+ // undefined — tools like HITL confirm dialogs have no parameters
28
+ // but still need their UI rendered in the chat.
29
+ if (tool.render) {
27
30
  copilotkit.addHookRenderToolCall({
28
31
  name,
29
32
  args: tool.parameters,