@copilotkit/react-core 1.55.0-next.8 → 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 (94) hide show
  1. package/CHANGELOG.md +48 -5
  2. package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-BY5S1-0P.mjs} +2772 -858
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BuhSUZHb.d.mts} +230 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-Bz5-ImDl.cjs} +2776 -832
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-dwDWYpya.d.cts} +230 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  10. package/dist/index.cjs +9 -4
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.mjs +9 -4
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/index.umd.js +1624 -396
  17. package/dist/index.umd.js.map +1 -1
  18. package/dist/v2/index.cjs +13 -1
  19. package/dist/v2/index.css +1 -1
  20. package/dist/v2/index.d.cts +3 -3
  21. package/dist/v2/index.d.mts +3 -3
  22. package/dist/v2/index.mjs +3 -2
  23. package/dist/v2/index.umd.js +2746 -790
  24. package/dist/v2/index.umd.js.map +1 -1
  25. package/package.json +62 -54
  26. package/scripts/scope-preflight.mjs +1 -2
  27. package/src/components/CopilotListeners.tsx +41 -8
  28. package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
  29. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  30. package/src/components/copilot-provider/copilotkit.tsx +3 -3
  31. package/src/components/toast/toast-provider.tsx +269 -194
  32. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
  33. package/src/hooks/use-copilot-chat_internal.ts +15 -4
  34. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  35. package/src/v2/__tests__/utils/test-helpers.tsx +107 -7
  36. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  37. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  38. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  39. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  40. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  41. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  42. package/src/v2/components/chat/CopilotChat.tsx +197 -52
  43. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  44. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  45. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  46. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  47. package/src/v2/components/chat/CopilotChatMessageView.tsx +260 -151
  48. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  49. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  50. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  51. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  52. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  53. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  54. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  55. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  56. package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
  57. package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
  58. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +60 -3
  59. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  60. package/src/v2/components/chat/index.ts +9 -0
  61. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  62. package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
  63. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
  64. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  65. package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
  66. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  67. package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
  68. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  69. package/src/v2/hooks/index.ts +5 -0
  70. package/src/v2/hooks/use-agent.tsx +220 -15
  71. package/src/v2/hooks/use-attachments.tsx +269 -0
  72. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  73. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  74. package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
  75. package/src/v2/hooks/use-threads.tsx +35 -15
  76. package/src/v2/index.ts +5 -1
  77. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  78. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  79. package/src/v2/lib/processPartialHtml.ts +45 -0
  80. package/src/v2/lib/slots.tsx +42 -1
  81. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  82. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  83. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  84. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  85. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  86. package/src/v2/providers/index.ts +7 -0
  87. package/src/v2/styles/globals.css +2 -1
  88. package/src/v2/types/index.ts +1 -0
  89. package/src/v2/types/sandbox-function.ts +11 -0
  90. package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
  91. package/dist/copilotkit-DBzgOMby.d.cts.map +0 -1
  92. package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
  93. package/dist/copilotkit-Dy5w3qEV.d.mts.map +0 -1
  94. package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
@@ -1,4 +1,13 @@
1
- import React, { useEffect, useReducer, useState } from "react";
1
+ import React, {
2
+ useContext,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useReducer,
7
+ useState,
8
+ } from "react";
9
+ import { useVirtualizer } from "@tanstack/react-virtual";
10
+ import { ScrollElementContext } from "./scroll-element-context";
2
11
  import { WithSlots, renderSlot, isReactComponentType } from "../../lib/slots";
3
12
  import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage";
4
13
  import CopilotChatUserMessage from "./CopilotChatUserMessage";
@@ -8,13 +17,43 @@ import {
8
17
  AssistantMessage,
9
18
  Message,
10
19
  ReasoningMessage,
20
+ ToolMessage,
11
21
  UserMessage,
12
22
  } from "@ag-ui/core";
13
23
  import { twMerge } from "tailwind-merge";
14
24
  import { useRenderActivityMessage, useRenderCustomMessages } from "../../hooks";
25
+ import { getThreadClone } from "../../hooks/use-agent";
15
26
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
16
27
  import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
17
28
 
29
+ /**
30
+ * Resolves a slot value into a { Component, slotProps } pair, handling the three
31
+ * slot forms: a component type, a className string, or a partial-props object.
32
+ */
33
+ function resolveSlotComponent<T extends React.ComponentType<any>>(
34
+ slot: unknown,
35
+ DefaultComponent: T,
36
+ ): { Component: T; slotProps: Partial<React.ComponentProps<T>> | undefined } {
37
+ if (isReactComponentType(slot)) {
38
+ return { Component: slot as T, slotProps: undefined };
39
+ }
40
+ if (typeof slot === "string") {
41
+ return {
42
+ Component: DefaultComponent,
43
+ slotProps: { className: slot } as unknown as Partial<
44
+ React.ComponentProps<T>
45
+ >,
46
+ };
47
+ }
48
+ if (slot && typeof slot === "object") {
49
+ return {
50
+ Component: DefaultComponent,
51
+ slotProps: slot as Partial<React.ComponentProps<T>>,
52
+ };
53
+ }
54
+ return { Component: DefaultComponent, slotProps: undefined };
55
+ }
56
+
18
57
  /**
19
58
  * Memoized wrapper for assistant messages to prevent re-renders when other messages change.
20
59
  */
@@ -54,36 +93,32 @@ const MemoizedAssistantMessage = React.memo(
54
93
  if (prevToolCalls?.length !== nextToolCalls?.length) return false;
55
94
  if (prevToolCalls && nextToolCalls) {
56
95
  for (let i = 0; i < prevToolCalls.length; i++) {
57
- const prevTc = prevToolCalls[i];
58
- const nextTc = nextToolCalls[i];
59
- if (!prevTc || !nextTc) return false;
96
+ const prevTc = prevToolCalls[i]!;
97
+ const nextTc = nextToolCalls[i]!;
60
98
  if (prevTc.id !== nextTc.id) return false;
61
99
  if (prevTc.function.arguments !== nextTc.function.arguments)
62
100
  return false;
63
101
  }
64
102
  }
65
103
 
66
- // Check if tool results changed for this message's tool calls
67
- // Tool results are separate messages with role="tool" that reference tool call IDs
104
+ // Check if tool results changed for this message's tool calls.
105
+ // Tool results are separate messages with role="tool" that reference tool call IDs.
68
106
  if (prevToolCalls && prevToolCalls.length > 0) {
69
107
  const toolCallIds = new Set(prevToolCalls.map((tc) => tc.id));
70
108
 
71
109
  const prevToolResults = prevProps.messages.filter(
72
- (m) => m.role === "tool" && toolCallIds.has((m as any).toolCallId),
110
+ (m): m is ToolMessage =>
111
+ m.role === "tool" && toolCallIds.has(m.toolCallId),
73
112
  );
74
113
  const nextToolResults = nextProps.messages.filter(
75
- (m) => m.role === "tool" && toolCallIds.has((m as any).toolCallId),
114
+ (m): m is ToolMessage =>
115
+ m.role === "tool" && toolCallIds.has(m.toolCallId),
76
116
  );
77
117
 
78
- // If number of tool results changed, re-render
79
118
  if (prevToolResults.length !== nextToolResults.length) return false;
80
119
 
81
- // If any tool result content changed, re-render
82
120
  for (let i = 0; i < prevToolResults.length; i++) {
83
- if (
84
- (prevToolResults[i] as any).content !==
85
- (nextToolResults[i] as any).content
86
- )
121
+ if (prevToolResults[i]!.content !== nextToolResults[i]!.content)
87
122
  return false;
88
123
  }
89
124
  }
@@ -293,6 +328,11 @@ export type CopilotChatMessageViewProps = Omit<
293
328
  }) => React.ReactElement;
294
329
  };
295
330
 
331
+ // Above this many messages, activate TanStack Virtual to avoid mounting the
332
+ // full DOM tree. Below the threshold the overhead of virtualization isn't
333
+ // worth it and the simpler flat render is faster.
334
+ const VIRTUALIZE_THRESHOLD = 50;
335
+
296
336
  export function CopilotChatMessageView({
297
337
  messages = [],
298
338
  assistantMessage,
@@ -313,14 +353,18 @@ export function CopilotChatMessageView({
313
353
  // Subscribe to state changes so custom message renderers re-render when state updates.
314
354
  useEffect(() => {
315
355
  if (!config?.agentId) return;
316
- const agent = copilotkit.getAgent(config.agentId);
356
+ const registryAgent = copilotkit.getAgent(config.agentId);
357
+ // Prefer the per-thread clone so that state changes from the running agent
358
+ // (which is the clone, not the registry) trigger re-renders.
359
+ const agent =
360
+ getThreadClone(registryAgent, config.threadId) ?? registryAgent;
317
361
  if (!agent) return;
318
362
 
319
363
  const subscription = agent.subscribe({
320
364
  onStateChanged: forceUpdate,
321
365
  });
322
366
  return () => subscription.unsubscribe();
323
- }, [config?.agentId, copilotkit, forceUpdate]);
367
+ }, [config?.agentId, config?.threadId, copilotkit, forceUpdate]);
324
368
 
325
369
  // Subscribe to interrupt element changes for in-chat rendering.
326
370
  const [interruptElement, setInterruptElement] =
@@ -358,9 +402,10 @@ export function CopilotChatMessageView({
358
402
  // Deduplicate messages by id, keeping the last occurrence of each.
359
403
  // During streaming, AbstractAgent.addMessage() can push duplicate messages
360
404
  // (same id) which causes React "duplicate key" warnings and rendering glitches.
361
- const deduplicatedMessages = [
362
- ...new Map(messages.map((m) => [m.id, m])).values(),
363
- ];
405
+ const deduplicatedMessages = useMemo(
406
+ () => [...new Map(messages.map((m) => [m.id, m])).values()],
407
+ [messages],
408
+ );
364
409
 
365
410
  if (
366
411
  process.env.NODE_ENV === "development" &&
@@ -371,141 +416,174 @@ export function CopilotChatMessageView({
371
416
  );
372
417
  }
373
418
 
374
- const messageElements: React.ReactElement[] = deduplicatedMessages
375
- .flatMap((message) => {
376
- const elements: (React.ReactElement | null | undefined)[] = [];
377
- const stateSnapshot = getStateSnapshotForMessage(message.id);
378
-
379
- // Render custom message before (using memoized wrapper)
380
- if (renderCustomMessage) {
381
- elements.push(
382
- <MemoizedCustomMessage
383
- key={`${message.id}-custom-before`}
384
- message={message}
385
- position="before"
386
- renderCustomMessage={renderCustomMessage}
387
- stateSnapshot={stateSnapshot}
388
- />,
389
- );
390
- }
419
+ // Resolve slot values once per prop change rather than inside renderMessageBlock.
420
+ // resolveSlotComponent returns a new object every call when the slot is a CSS
421
+ // class string, which would defeat MemoizedAssistantMessage's slotProps
422
+ // reference-equality check and cause all completed messages to re-render.
423
+ const { Component: AssistantComponent, slotProps: assistantSlotProps } =
424
+ useMemo(
425
+ () => resolveSlotComponent(assistantMessage, CopilotChatAssistantMessage),
426
+ [assistantMessage],
427
+ );
428
+ const { Component: UserComponent, slotProps: userSlotProps } = useMemo(
429
+ () => resolveSlotComponent(userMessage, CopilotChatUserMessage),
430
+ [userMessage],
431
+ );
432
+ const { Component: ReasoningComponent, slotProps: reasoningSlotProps } =
433
+ useMemo(
434
+ () => resolveSlotComponent(reasoningMessage, CopilotChatReasoningMessage),
435
+ [reasoningMessage],
436
+ );
391
437
 
392
- // Render the main message using memoized wrappers to prevent unnecessary re-renders
393
- if (message.role === "assistant") {
394
- // Determine the component and props from slot value
395
- let AssistantComponent = CopilotChatAssistantMessage;
396
- let assistantSlotProps:
397
- | Partial<React.ComponentProps<typeof CopilotChatAssistantMessage>>
398
- | undefined;
399
-
400
- if (isReactComponentType(assistantMessage)) {
401
- // Custom component (function, forwardRef, memo, etc.)
402
- AssistantComponent =
403
- assistantMessage as typeof CopilotChatAssistantMessage;
404
- } else if (typeof assistantMessage === "string") {
405
- // className string
406
- assistantSlotProps = { className: assistantMessage };
407
- } else if (assistantMessage && typeof assistantMessage === "object") {
408
- // Props object
409
- assistantSlotProps = assistantMessage as Partial<
410
- React.ComponentProps<typeof CopilotChatAssistantMessage>
411
- >;
412
- }
413
-
414
- elements.push(
415
- <MemoizedAssistantMessage
416
- key={message.id}
417
- message={message as AssistantMessage}
418
- messages={messages}
419
- isRunning={isRunning}
420
- AssistantMessageComponent={AssistantComponent}
421
- slotProps={assistantSlotProps}
422
- />,
423
- );
424
- } else if (message.role === "user") {
425
- // Determine the component and props from slot value
426
- let UserComponent = CopilotChatUserMessage;
427
- let userSlotProps:
428
- | Partial<React.ComponentProps<typeof CopilotChatUserMessage>>
429
- | undefined;
430
-
431
- if (isReactComponentType(userMessage)) {
432
- // Custom component (function, forwardRef, memo, etc.)
433
- UserComponent = userMessage as typeof CopilotChatUserMessage;
434
- } else if (typeof userMessage === "string") {
435
- // className string
436
- userSlotProps = { className: userMessage };
437
- } else if (userMessage && typeof userMessage === "object") {
438
- // Props object
439
- userSlotProps = userMessage as Partial<
440
- React.ComponentProps<typeof CopilotChatUserMessage>
441
- >;
442
- }
443
-
444
- elements.push(
445
- <MemoizedUserMessage
446
- key={message.id}
447
- message={message as UserMessage}
448
- UserMessageComponent={UserComponent}
449
- slotProps={userSlotProps}
450
- />,
451
- );
452
- } else if (message.role === "activity") {
453
- // Use memoized wrapper to prevent re-renders when other messages change
454
- const activityMsg = message as ActivityMessage;
455
- elements.push(
456
- <MemoizedActivityMessage
457
- key={message.id}
458
- message={activityMsg}
459
- renderActivityMessage={renderActivityMessage}
460
- />,
461
- );
462
- } else if (message.role === "reasoning") {
463
- // Determine the component and props from slot value
464
- let ReasoningComponent = CopilotChatReasoningMessage;
465
- let reasoningSlotProps:
466
- | Partial<React.ComponentProps<typeof CopilotChatReasoningMessage>>
467
- | undefined;
468
-
469
- if (isReactComponentType(reasoningMessage)) {
470
- ReasoningComponent =
471
- reasoningMessage as typeof CopilotChatReasoningMessage;
472
- } else if (typeof reasoningMessage === "string") {
473
- reasoningSlotProps = { className: reasoningMessage };
474
- } else if (reasoningMessage && typeof reasoningMessage === "object") {
475
- reasoningSlotProps = reasoningMessage as Partial<
476
- React.ComponentProps<typeof CopilotChatReasoningMessage>
477
- >;
478
- }
479
-
480
- elements.push(
481
- <MemoizedReasoningMessage
482
- key={message.id}
483
- message={message as ReasoningMessage}
484
- messages={messages}
485
- isRunning={isRunning}
486
- ReasoningMessageComponent={ReasoningComponent}
487
- slotProps={reasoningSlotProps}
488
- />,
489
- );
490
- }
438
+ // ---------------------------------------------------------------------------
439
+ // Virtualization
440
+ // ---------------------------------------------------------------------------
441
+ // Receive the scroll container from context. ScrollView provides the element
442
+ // as state (not a ref) so this component re-renders reactively when the
443
+ // container first mounts. clientHeight === 0 means no real layout (jsdom) —
444
+ // skip virtualization so tests run the flat path.
445
+ const scrollElementFromCtx = useContext(ScrollElementContext);
446
+ const scrollElement =
447
+ scrollElementFromCtx && scrollElementFromCtx.clientHeight > 0
448
+ ? scrollElementFromCtx
449
+ : null;
450
+
451
+ // Warn once in dev when a scroll element is provided but has no height —
452
+ // this silently disables virtualization (e.g. chat inside display:none tab).
453
+ useEffect(() => {
454
+ if (
455
+ process.env.NODE_ENV !== "production" &&
456
+ scrollElementFromCtx &&
457
+ scrollElementFromCtx.clientHeight === 0
458
+ ) {
459
+ console.warn(
460
+ "[CopilotKit] Chat scroll container has clientHeight=0 — virtualization disabled. " +
461
+ "Ensure the chat is rendered in a visible container with a non-zero height.",
462
+ );
463
+ }
464
+ }, [scrollElementFromCtx]);
465
+
466
+ // Virtualize only when we have a scroll element and enough messages. The
467
+ // `children` render prop delegates layout to the caller, so we keep
468
+ // messageElements flat for that case.
469
+ const shouldVirtualize =
470
+ !!scrollElement &&
471
+ !children &&
472
+ deduplicatedMessages.length > VIRTUALIZE_THRESHOLD;
473
+
474
+ const virtualizer = useVirtualizer({
475
+ // count=0 disables the virtualizer without changing hook call order.
476
+ count: shouldVirtualize ? deduplicatedMessages.length : 0,
477
+ getScrollElement: () => scrollElement,
478
+ // Conservative height estimate. Items are measured by ResizeObserver after
479
+ // first render so the estimate only affects the initial total height.
480
+ estimateSize: () => 100,
481
+ overscan: 5,
482
+ measureElement: (el: Element) => el?.getBoundingClientRect().height ?? 0,
483
+ // Assume a 600 px viewport before the real element is measured so that
484
+ // the first virtual render shows ~6 items rather than 0.
485
+ initialRect: { width: 0, height: 600 },
486
+ });
487
+
488
+ // Scroll to the bottom when virtual mode first activates or the thread changes
489
+ // (detected by the first message ID changing). For streaming new messages,
490
+ // use-stick-to-bottom handles auto-scroll via content height growth detection
491
+ // on the virtualizer's total-size div — same as the flat path. Adding
492
+ // deduplicatedMessages.length here would forcibly yank the user to the bottom
493
+ // on every streaming chunk even if they've scrolled up to read history.
494
+ const firstMessageId = deduplicatedMessages[0]?.id;
495
+ useLayoutEffect(() => {
496
+ if (!shouldVirtualize || !deduplicatedMessages.length) return;
497
+ virtualizer.scrollToIndex(deduplicatedMessages.length - 1, {
498
+ align: "end",
499
+ });
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps
501
+ }, [shouldVirtualize, firstMessageId]);
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Per-message rendering helper (shared by flat and virtual paths)
505
+ // ---------------------------------------------------------------------------
506
+ const renderMessageBlock = (message: Message): React.ReactElement[] => {
507
+ const elements: (React.ReactElement | null | undefined)[] = [];
508
+ const stateSnapshot = getStateSnapshotForMessage(message.id);
509
+
510
+ if (renderCustomMessage) {
511
+ elements.push(
512
+ <MemoizedCustomMessage
513
+ key={`${message.id}-custom-before`}
514
+ message={message}
515
+ position="before"
516
+ renderCustomMessage={renderCustomMessage}
517
+ stateSnapshot={stateSnapshot}
518
+ />,
519
+ );
520
+ }
491
521
 
492
- // Render custom message after (using memoized wrapper)
493
- if (renderCustomMessage) {
494
- elements.push(
495
- <MemoizedCustomMessage
496
- key={`${message.id}-custom-after`}
497
- message={message}
498
- position="after"
499
- renderCustomMessage={renderCustomMessage}
500
- stateSnapshot={stateSnapshot}
501
- />,
502
- );
503
- }
522
+ if (message.role === "assistant") {
523
+ elements.push(
524
+ <MemoizedAssistantMessage
525
+ key={message.id}
526
+ message={message as AssistantMessage}
527
+ messages={messages}
528
+ isRunning={isRunning}
529
+ AssistantMessageComponent={AssistantComponent}
530
+ slotProps={assistantSlotProps}
531
+ />,
532
+ );
533
+ } else if (message.role === "user") {
534
+ elements.push(
535
+ <MemoizedUserMessage
536
+ key={message.id}
537
+ message={message as UserMessage}
538
+ UserMessageComponent={UserComponent}
539
+ slotProps={userSlotProps}
540
+ />,
541
+ );
542
+ } else if (message.role === "activity") {
543
+ elements.push(
544
+ <MemoizedActivityMessage
545
+ key={message.id}
546
+ message={message as ActivityMessage}
547
+ renderActivityMessage={renderActivityMessage}
548
+ />,
549
+ );
550
+ } else if (message.role === "reasoning") {
551
+ elements.push(
552
+ <MemoizedReasoningMessage
553
+ key={message.id}
554
+ message={message as ReasoningMessage}
555
+ messages={messages}
556
+ isRunning={isRunning}
557
+ ReasoningMessageComponent={ReasoningComponent}
558
+ slotProps={reasoningSlotProps}
559
+ />,
560
+ );
561
+ }
562
+
563
+ if (renderCustomMessage) {
564
+ elements.push(
565
+ <MemoizedCustomMessage
566
+ key={`${message.id}-custom-after`}
567
+ message={message}
568
+ position="after"
569
+ renderCustomMessage={renderCustomMessage}
570
+ stateSnapshot={stateSnapshot}
571
+ />,
572
+ );
573
+ }
504
574
 
505
- return elements;
506
- })
507
- .filter(Boolean) as React.ReactElement[];
575
+ return elements.filter(Boolean) as React.ReactElement[];
576
+ };
577
+
578
+ // Build the flat element list only when we're not virtualizing (avoids
579
+ // creating 500 React elements that we'd immediately discard).
580
+ const messageElements: React.ReactElement[] = shouldVirtualize
581
+ ? []
582
+ : deduplicatedMessages.flatMap(renderMessageBlock);
508
583
 
584
+ // ---------------------------------------------------------------------------
585
+ // children render prop (custom layout, always non-virtual)
586
+ // ---------------------------------------------------------------------------
509
587
  if (children) {
510
588
  return (
511
589
  <div data-copilotkit style={{ display: "contents" }}>
@@ -519,6 +597,9 @@ export function CopilotChatMessageView({
519
597
  const lastMessage = messages[messages.length - 1];
520
598
  const showCursor = isRunning && lastMessage?.role !== "reasoning";
521
599
 
600
+ // ---------------------------------------------------------------------------
601
+ // Render — shared wrapper, conditional inner content (virtual vs flat)
602
+ // ---------------------------------------------------------------------------
522
603
  return (
523
604
  <div
524
605
  data-copilotkit
@@ -526,7 +607,35 @@ export function CopilotChatMessageView({
526
607
  className={twMerge("copilotKitMessages cpk:flex cpk:flex-col", className)}
527
608
  {...props}
528
609
  >
529
- {messageElements}
610
+ {shouldVirtualize ? (
611
+ // Virtual path: only visible items are in the DOM; outer div maintains
612
+ // total scroll height so the scrollbar reflects the full list size.
613
+ <div
614
+ style={{ height: virtualizer.getTotalSize(), position: "relative" }}
615
+ >
616
+ {virtualizer.getVirtualItems().map((virtualItem) => {
617
+ const message = deduplicatedMessages[virtualItem.index]!;
618
+ return (
619
+ <div
620
+ key={message.id}
621
+ data-index={virtualItem.index}
622
+ ref={virtualizer.measureElement}
623
+ style={{
624
+ position: "absolute",
625
+ top: 0,
626
+ left: 0,
627
+ width: "100%",
628
+ transform: `translateY(${virtualItem.start}px)`,
629
+ }}
630
+ >
631
+ {renderMessageBlock(message)}
632
+ </div>
633
+ );
634
+ })}
635
+ </div>
636
+ ) : (
637
+ messageElements
638
+ )}
530
639
  {interruptElement}
531
640
  {showCursor && (
532
641
  <div className="cpk:mt-2">
@@ -72,6 +72,7 @@ export const CopilotChatSuggestionView = React.forwardRef<
72
72
  CopilotChatSuggestionPillProps
73
73
  >(suggestionSlot, CopilotChatSuggestionPill, {
74
74
  children: suggestion.title,
75
+ className: suggestion.className,
75
76
  isLoading,
76
77
  type: "button",
77
78
  onClick: () => onSelectSuggestion?.(suggestion, index),
@@ -13,6 +13,13 @@ import {
13
13
  TooltipTrigger,
14
14
  } from "../../components/ui/tooltip";
15
15
  import { renderSlot, WithSlots } from "../../lib/slots";
16
+ import {
17
+ type ImageInputPart,
18
+ type AudioInputPart,
19
+ type VideoInputPart,
20
+ type DocumentInputPart,
21
+ } from "@copilotkit/shared";
22
+ import { CopilotChatAttachmentRenderer } from "./CopilotChatAttachmentRenderer";
16
23
 
17
24
  function flattenUserMessageContent(content?: UserMessage["content"]): string {
18
25
  if (!content) {
@@ -40,6 +47,36 @@ function flattenUserMessageContent(content?: UserMessage["content"]): string {
40
47
  .join("\n");
41
48
  }
42
49
 
50
+ type MediaPart =
51
+ | ImageInputPart
52
+ | AudioInputPart
53
+ | VideoInputPart
54
+ | DocumentInputPart;
55
+
56
+ function getMediaParts(content: UserMessage["content"]): MediaPart[] {
57
+ if (!content || typeof content === "string") return [];
58
+ return content.filter(
59
+ (part): part is MediaPart =>
60
+ part.type === "image" ||
61
+ part.type === "audio" ||
62
+ part.type === "video" ||
63
+ part.type === "document",
64
+ );
65
+ }
66
+
67
+ function getFilename(part: MediaPart): string | undefined {
68
+ const meta = part.metadata;
69
+ if (
70
+ meta != null &&
71
+ typeof meta === "object" &&
72
+ "filename" in meta &&
73
+ typeof meta.filename === "string"
74
+ ) {
75
+ return meta.filename;
76
+ }
77
+ return undefined;
78
+ }
79
+
43
80
  export interface CopilotChatUserMessageOnEditMessageProps {
44
81
  message: UserMessage;
45
82
  }
@@ -91,6 +128,11 @@ export function CopilotChatUserMessage({
91
128
  [message.content],
92
129
  );
93
130
 
131
+ const mediaParts = useMemo(
132
+ () => getMediaParts(message.content),
133
+ [message.content],
134
+ );
135
+
94
136
  const BoundMessageRenderer = renderSlot(
95
137
  messageRenderer,
96
138
  CopilotChatUserMessage.MessageRenderer,
@@ -178,6 +220,18 @@ export function CopilotChatUserMessage({
178
220
  {...props}
179
221
  >
180
222
  {BoundMessageRenderer}
223
+ {mediaParts.length > 0 && (
224
+ <div className="cpk:flex cpk:flex-col cpk:items-end cpk:gap-2 cpk:mt-2">
225
+ {mediaParts.map((part, index) => (
226
+ <CopilotChatAttachmentRenderer
227
+ key={index}
228
+ type={part.type}
229
+ source={part.source}
230
+ filename={getFilename(part)}
231
+ />
232
+ ))}
233
+ </div>
234
+ )}
181
235
  {BoundToolbar}
182
236
  </div>
183
237
  );