@copilotkit/react-core 1.55.0-next.9 → 1.55.1-next.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 +46 -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
@@ -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,6 +17,7 @@ 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";
@@ -16,6 +26,34 @@ import { getThreadClone } from "../../hooks/use-agent";
16
26
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
17
27
  import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
18
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
+
19
57
  /**
20
58
  * Memoized wrapper for assistant messages to prevent re-renders when other messages change.
21
59
  */
@@ -55,36 +93,32 @@ const MemoizedAssistantMessage = React.memo(
55
93
  if (prevToolCalls?.length !== nextToolCalls?.length) return false;
56
94
  if (prevToolCalls && nextToolCalls) {
57
95
  for (let i = 0; i < prevToolCalls.length; i++) {
58
- const prevTc = prevToolCalls[i];
59
- const nextTc = nextToolCalls[i];
60
- if (!prevTc || !nextTc) return false;
96
+ const prevTc = prevToolCalls[i]!;
97
+ const nextTc = nextToolCalls[i]!;
61
98
  if (prevTc.id !== nextTc.id) return false;
62
99
  if (prevTc.function.arguments !== nextTc.function.arguments)
63
100
  return false;
64
101
  }
65
102
  }
66
103
 
67
- // Check if tool results changed for this message's tool calls
68
- // 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.
69
106
  if (prevToolCalls && prevToolCalls.length > 0) {
70
107
  const toolCallIds = new Set(prevToolCalls.map((tc) => tc.id));
71
108
 
72
109
  const prevToolResults = prevProps.messages.filter(
73
- (m) => m.role === "tool" && toolCallIds.has((m as any).toolCallId),
110
+ (m): m is ToolMessage =>
111
+ m.role === "tool" && toolCallIds.has(m.toolCallId),
74
112
  );
75
113
  const nextToolResults = nextProps.messages.filter(
76
- (m) => m.role === "tool" && toolCallIds.has((m as any).toolCallId),
114
+ (m): m is ToolMessage =>
115
+ m.role === "tool" && toolCallIds.has(m.toolCallId),
77
116
  );
78
117
 
79
- // If number of tool results changed, re-render
80
118
  if (prevToolResults.length !== nextToolResults.length) return false;
81
119
 
82
- // If any tool result content changed, re-render
83
120
  for (let i = 0; i < prevToolResults.length; i++) {
84
- if (
85
- (prevToolResults[i] as any).content !==
86
- (nextToolResults[i] as any).content
87
- )
121
+ if (prevToolResults[i]!.content !== nextToolResults[i]!.content)
88
122
  return false;
89
123
  }
90
124
  }
@@ -294,6 +328,11 @@ export type CopilotChatMessageViewProps = Omit<
294
328
  }) => React.ReactElement;
295
329
  };
296
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
+
297
336
  export function CopilotChatMessageView({
298
337
  messages = [],
299
338
  assistantMessage,
@@ -363,9 +402,10 @@ export function CopilotChatMessageView({
363
402
  // Deduplicate messages by id, keeping the last occurrence of each.
364
403
  // During streaming, AbstractAgent.addMessage() can push duplicate messages
365
404
  // (same id) which causes React "duplicate key" warnings and rendering glitches.
366
- const deduplicatedMessages = [
367
- ...new Map(messages.map((m) => [m.id, m])).values(),
368
- ];
405
+ const deduplicatedMessages = useMemo(
406
+ () => [...new Map(messages.map((m) => [m.id, m])).values()],
407
+ [messages],
408
+ );
369
409
 
370
410
  if (
371
411
  process.env.NODE_ENV === "development" &&
@@ -376,141 +416,174 @@ export function CopilotChatMessageView({
376
416
  );
377
417
  }
378
418
 
379
- const messageElements: React.ReactElement[] = deduplicatedMessages
380
- .flatMap((message) => {
381
- const elements: (React.ReactElement | null | undefined)[] = [];
382
- const stateSnapshot = getStateSnapshotForMessage(message.id);
383
-
384
- // Render custom message before (using memoized wrapper)
385
- if (renderCustomMessage) {
386
- elements.push(
387
- <MemoizedCustomMessage
388
- key={`${message.id}-custom-before`}
389
- message={message}
390
- position="before"
391
- renderCustomMessage={renderCustomMessage}
392
- stateSnapshot={stateSnapshot}
393
- />,
394
- );
395
- }
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
+ );
396
437
 
397
- // Render the main message using memoized wrappers to prevent unnecessary re-renders
398
- if (message.role === "assistant") {
399
- // Determine the component and props from slot value
400
- let AssistantComponent = CopilotChatAssistantMessage;
401
- let assistantSlotProps:
402
- | Partial<React.ComponentProps<typeof CopilotChatAssistantMessage>>
403
- | undefined;
404
-
405
- if (isReactComponentType(assistantMessage)) {
406
- // Custom component (function, forwardRef, memo, etc.)
407
- AssistantComponent =
408
- assistantMessage as typeof CopilotChatAssistantMessage;
409
- } else if (typeof assistantMessage === "string") {
410
- // className string
411
- assistantSlotProps = { className: assistantMessage };
412
- } else if (assistantMessage && typeof assistantMessage === "object") {
413
- // Props object
414
- assistantSlotProps = assistantMessage as Partial<
415
- React.ComponentProps<typeof CopilotChatAssistantMessage>
416
- >;
417
- }
418
-
419
- elements.push(
420
- <MemoizedAssistantMessage
421
- key={message.id}
422
- message={message as AssistantMessage}
423
- messages={messages}
424
- isRunning={isRunning}
425
- AssistantMessageComponent={AssistantComponent}
426
- slotProps={assistantSlotProps}
427
- />,
428
- );
429
- } else if (message.role === "user") {
430
- // Determine the component and props from slot value
431
- let UserComponent = CopilotChatUserMessage;
432
- let userSlotProps:
433
- | Partial<React.ComponentProps<typeof CopilotChatUserMessage>>
434
- | undefined;
435
-
436
- if (isReactComponentType(userMessage)) {
437
- // Custom component (function, forwardRef, memo, etc.)
438
- UserComponent = userMessage as typeof CopilotChatUserMessage;
439
- } else if (typeof userMessage === "string") {
440
- // className string
441
- userSlotProps = { className: userMessage };
442
- } else if (userMessage && typeof userMessage === "object") {
443
- // Props object
444
- userSlotProps = userMessage as Partial<
445
- React.ComponentProps<typeof CopilotChatUserMessage>
446
- >;
447
- }
448
-
449
- elements.push(
450
- <MemoizedUserMessage
451
- key={message.id}
452
- message={message as UserMessage}
453
- UserMessageComponent={UserComponent}
454
- slotProps={userSlotProps}
455
- />,
456
- );
457
- } else if (message.role === "activity") {
458
- // Use memoized wrapper to prevent re-renders when other messages change
459
- const activityMsg = message as ActivityMessage;
460
- elements.push(
461
- <MemoizedActivityMessage
462
- key={message.id}
463
- message={activityMsg}
464
- renderActivityMessage={renderActivityMessage}
465
- />,
466
- );
467
- } else if (message.role === "reasoning") {
468
- // Determine the component and props from slot value
469
- let ReasoningComponent = CopilotChatReasoningMessage;
470
- let reasoningSlotProps:
471
- | Partial<React.ComponentProps<typeof CopilotChatReasoningMessage>>
472
- | undefined;
473
-
474
- if (isReactComponentType(reasoningMessage)) {
475
- ReasoningComponent =
476
- reasoningMessage as typeof CopilotChatReasoningMessage;
477
- } else if (typeof reasoningMessage === "string") {
478
- reasoningSlotProps = { className: reasoningMessage };
479
- } else if (reasoningMessage && typeof reasoningMessage === "object") {
480
- reasoningSlotProps = reasoningMessage as Partial<
481
- React.ComponentProps<typeof CopilotChatReasoningMessage>
482
- >;
483
- }
484
-
485
- elements.push(
486
- <MemoizedReasoningMessage
487
- key={message.id}
488
- message={message as ReasoningMessage}
489
- messages={messages}
490
- isRunning={isRunning}
491
- ReasoningMessageComponent={ReasoningComponent}
492
- slotProps={reasoningSlotProps}
493
- />,
494
- );
495
- }
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
+ }
496
521
 
497
- // Render custom message after (using memoized wrapper)
498
- if (renderCustomMessage) {
499
- elements.push(
500
- <MemoizedCustomMessage
501
- key={`${message.id}-custom-after`}
502
- message={message}
503
- position="after"
504
- renderCustomMessage={renderCustomMessage}
505
- stateSnapshot={stateSnapshot}
506
- />,
507
- );
508
- }
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
+ }
509
574
 
510
- return elements;
511
- })
512
- .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);
513
583
 
584
+ // ---------------------------------------------------------------------------
585
+ // children render prop (custom layout, always non-virtual)
586
+ // ---------------------------------------------------------------------------
514
587
  if (children) {
515
588
  return (
516
589
  <div data-copilotkit style={{ display: "contents" }}>
@@ -524,6 +597,9 @@ export function CopilotChatMessageView({
524
597
  const lastMessage = messages[messages.length - 1];
525
598
  const showCursor = isRunning && lastMessage?.role !== "reasoning";
526
599
 
600
+ // ---------------------------------------------------------------------------
601
+ // Render — shared wrapper, conditional inner content (virtual vs flat)
602
+ // ---------------------------------------------------------------------------
527
603
  return (
528
604
  <div
529
605
  data-copilotkit
@@ -531,7 +607,35 @@ export function CopilotChatMessageView({
531
607
  className={twMerge("copilotKitMessages cpk:flex cpk:flex-col", className)}
532
608
  {...props}
533
609
  >
534
- {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
+ )}
535
639
  {interruptElement}
536
640
  {showCursor && (
537
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
  );