@copilotkit/vue 2.0.0-rc.25 → 2.0.0-rc.27

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 (64) hide show
  1. package/dist/index.cjs +1 -1
  2. package/dist/index.mjs +2 -2
  3. package/dist/use-render-activity-message-C7b4K-TN.cjs +113 -0
  4. package/dist/use-render-activity-message-C7b4K-TN.cjs.map +1 -0
  5. package/dist/{use-render-activity-message-sj4EDF5d.js → use-render-activity-message-Dpqx4DE8.js} +5687 -5984
  6. package/dist/use-render-activity-message-Dpqx4DE8.js.map +1 -0
  7. package/dist/v2/components/a2ui/A2UIBuiltInToolCallRenderer.d.ts +3 -6
  8. package/dist/v2/components/a2ui/A2UIBuiltInToolCallRenderer.d.ts.map +1 -1
  9. package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
  10. package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts.map +1 -1
  11. package/dist/v2/components/chat/CopilotChatFeedbackCommentPopover.vue.d.ts.map +1 -1
  12. package/dist/v2/components/chat/CopilotChatInput.vue.d.ts +1 -16
  13. package/dist/v2/components/chat/CopilotChatInput.vue.d.ts.map +1 -1
  14. package/dist/v2/components/chat/CopilotChatMessageView.vue.d.ts.map +1 -1
  15. package/dist/v2/components/chat/CopilotChatReasoningMessage.vue.d.ts.map +1 -1
  16. package/dist/v2/components/chat/CopilotChatView.vue.d.ts.map +1 -1
  17. package/dist/v2/components/chat/CopilotPopupViewInternal.vue.d.ts.map +1 -1
  18. package/dist/v2/components/chat/CopilotSidebarViewInternal.vue.d.ts.map +1 -1
  19. package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts.map +1 -1
  20. package/dist/v2/components/chat/message-feedback.d.ts +20 -0
  21. package/dist/v2/components/chat/message-feedback.d.ts.map +1 -1
  22. package/dist/v2/hooks/use-configure-suggestions.d.ts.map +1 -1
  23. package/dist/v2/hooks/use-default-render-tool.d.ts +2 -7
  24. package/dist/v2/hooks/use-default-render-tool.d.ts.map +1 -1
  25. package/dist/v2/hooks/use-frontend-tool.d.ts +2 -2
  26. package/dist/v2/hooks/use-frontend-tool.d.ts.map +1 -1
  27. package/dist/v2/hooks/use-render-activity-message.d.ts.map +1 -1
  28. package/dist/v2/hooks/use-suggestions.d.ts.map +1 -1
  29. package/dist/v2/hooks/use-threads.d.ts +6 -15
  30. package/dist/v2/hooks/use-threads.d.ts.map +1 -1
  31. package/dist/v2/index.cjs +1 -1
  32. package/dist/v2/index.mjs +1 -1
  33. package/dist/v2/lib/vue-core.d.ts +16 -2
  34. package/dist/v2/lib/vue-core.d.ts.map +1 -1
  35. package/dist/v2/providers/CopilotKitProvider.vue.d.ts.map +1 -1
  36. package/package.json +8 -8
  37. package/src/__tests__/build-entries.test.ts +3 -1
  38. package/src/v2/components/a2ui/A2UIBuiltInToolCallRenderer.ts +4 -19
  39. package/src/v2/components/chat/CopilotChat.vue +50 -218
  40. package/src/v2/components/chat/CopilotChatAssistantMessage.vue +28 -141
  41. package/src/v2/components/chat/CopilotChatFeedbackCommentPopover.vue +11 -76
  42. package/src/v2/components/chat/CopilotChatInput.vue +82 -269
  43. package/src/v2/components/chat/CopilotChatMessageView.vue +14 -64
  44. package/src/v2/components/chat/CopilotChatReasoningMessage.vue +25 -52
  45. package/src/v2/components/chat/CopilotChatView.vue +92 -135
  46. package/src/v2/components/chat/CopilotPopupViewInternal.vue +18 -58
  47. package/src/v2/components/chat/CopilotSidebarViewInternal.vue +22 -45
  48. package/src/v2/components/chat/CopilotThreadsDrawer.vue +6 -3
  49. package/src/v2/components/chat/__tests__/CopilotChatInput.test.ts +43 -0
  50. package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.ts +43 -1
  51. package/src/v2/components/chat/message-feedback.ts +33 -1
  52. package/src/v2/hooks/use-configure-suggestions.ts +3 -4
  53. package/src/v2/hooks/use-default-render-tool.ts +7 -68
  54. package/src/v2/hooks/use-frontend-tool.ts +10 -20
  55. package/src/v2/hooks/use-render-activity-message.ts +4 -2
  56. package/src/v2/hooks/use-suggestions.ts +9 -4
  57. package/src/v2/hooks/use-threads.ts +6 -15
  58. package/src/v2/lib/vue-core.ts +19 -2
  59. package/src/v2/providers/CopilotKitProvider.vue +107 -184
  60. package/src/v2/providers/__tests__/CopilotKitProvider.a2ui.test.ts +18 -0
  61. package/src/v2/providers/__tests__/CopilotKitProvider.test.ts +35 -2
  62. package/dist/use-render-activity-message-BwRo8uxl.cjs +0 -114
  63. package/dist/use-render-activity-message-BwRo8uxl.cjs.map +0 -1
  64. package/dist/use-render-activity-message-sj4EDF5d.js.map +0 -1
@@ -20,7 +20,6 @@ import type {
20
20
  UserMessage,
21
21
  } from "@ag-ui/core";
22
22
  import {
23
- DEFAULT_AGENT_ID,
24
23
  commitRowKeyStore,
25
24
  createRowKeyStore,
26
25
  resolveRowRenderKeysById,
@@ -29,8 +28,10 @@ import {
29
28
  ɵdeduplicateMessages,
30
29
  ɵcreateMessageViewModel,
31
30
  ɵduplicateMessagesWarning,
31
+ ɵgetCustomMessageRenderProps,
32
32
  ɵgetToolCallRendererNames,
33
33
  ɵresolveActivityRenderer,
34
+ ɵresolveAgentId,
34
35
  ɵresolveCustomMessageRenderers,
35
36
  } from "@copilotkit/core";
36
37
  import type {
@@ -156,8 +157,8 @@ const stateTick = ref(0);
156
157
  const interruptState = ref<InterruptSlotProps | null>(null);
157
158
  const componentSlots = useSlots() as Record<string, (props?: any) => unknown>;
158
159
  const forwardedSlotNames = computed(() => Object.keys(componentSlots));
159
- const resolvedAgentId = computed(
160
- () => config.value?.agentId ?? DEFAULT_AGENT_ID,
160
+ const resolvedAgentId = computed(() =>
161
+ ɵresolveAgentId({ chatAgentId: config.value?.agentId }),
161
162
  );
162
163
  const resolvedThreadAgent = computed(() => {
163
164
  // Read the registry so this recomputes when agents are added or replaced.
@@ -343,71 +344,20 @@ watch(
343
344
  { immediate: true },
344
345
  );
345
346
 
346
- function getMeta(
347
- message: Message,
348
- ): Omit<MessageMetaProps, "position" | "message"> {
349
- stateTick.value += 0;
350
- const agentId = resolvedAgentId.value;
351
- const threadId = config.value?.threadId ?? "";
352
- const core = copilotkit.value;
353
-
354
- const resolvedRunId =
355
- core.getRunIdForMessage(agentId, threadId, message.id) ??
356
- core.getRunIdsForThread(agentId, threadId).slice(-1)[0];
357
- const runId = resolvedRunId ?? `missing-run-id:${message.id}`;
358
-
359
- const agent = resolvedThreadAgent.value ?? core.getAgent(agentId);
360
-
361
- const messageIdsInRun =
362
- resolvedRunId && agent
363
- ? agent.messages
364
- .filter(
365
- (msg) =>
366
- core.getRunIdForMessage(agentId, threadId, msg.id) ===
367
- resolvedRunId,
368
- )
369
- .map((msg) => msg.id)
370
- : [message.id];
371
-
372
- const rawMessageIndex = agent
373
- ? agent.messages.findIndex((msg) => msg.id === message.id)
374
- : -1;
375
- const messageIndex = rawMessageIndex >= 0 ? rawMessageIndex : 0;
376
- const messageIndexInRun = resolvedRunId
377
- ? Math.max(messageIdsInRun.indexOf(message.id), 0)
378
- : 0;
379
- const numberOfMessagesInRun = resolvedRunId ? messageIdsInRun.length : 1;
380
-
381
- // `getStateByRun` deep-clones the whole run state, so resolve it only when
382
- // a consumer reads `stateSnapshot`, and at most once per meta object.
383
- let stateSnapshot: unknown;
384
- let stateSnapshotResolved = false;
385
-
386
- return {
387
- runId,
388
- messageIndex,
389
- messageIndexInRun,
390
- numberOfMessagesInRun,
391
- agentId,
392
- get stateSnapshot() {
393
- if (!stateSnapshotResolved) {
394
- stateSnapshotResolved = true;
395
- stateSnapshot = resolvedRunId
396
- ? core.getStateByRun(agentId, threadId, resolvedRunId)
397
- : undefined;
398
- }
399
- return stateSnapshot;
400
- },
401
- };
402
- }
403
-
404
- // `Object.assign` onto the meta object keeps the lazy `stateSnapshot` getter.
405
- // A spread would call it.
347
+ // Core builds the props with a lazy `stateSnapshot` getter, so slots that
348
+ // never read it do not clone the run state.
406
349
  function getMessageSlotProps(
407
350
  message: Message,
408
351
  position: MessageMetaProps["position"],
409
352
  ): MessageMetaProps {
410
- return Object.assign(getMeta(message), { message, position });
353
+ stateTick.value += 0;
354
+ return ɵgetCustomMessageRenderProps(copilotkit.value, {
355
+ agentId: resolvedAgentId.value,
356
+ threadId: config.value?.threadId ?? "",
357
+ agent: resolvedThreadAgent.value,
358
+ message,
359
+ position,
360
+ });
411
361
  }
412
362
 
413
363
  // Calls a `#message-before` / `#message-after` slot with the meta object
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, ref, watch } from "vue";
2
+ import { computed, onBeforeUnmount, shallowRef, watch } from "vue";
3
3
  import type { Message, ReasoningMessage } from "@ag-ui/core";
4
4
  import { IconChevronRight } from "../icons";
5
5
  import StreamingMarkdownDefault from "./StreamingMarkdownDefault.vue";
6
- import { ɵformatLabel, ɵformatReasoningDurationLabel } from "@copilotkit/core";
6
+ import {
7
+ ɵgetReasoningHeaderLabel,
8
+ ɵReasoningDisclosure,
9
+ } from "@copilotkit/core";
7
10
  import { useChatLabels } from "./use-chat-labels";
8
11
 
9
12
  const props = withDefaults(
@@ -78,66 +81,36 @@ const isLatest = computed(
78
81
  );
79
82
  const isStreaming = computed(() => !!(props.isRunning && isLatest.value));
80
83
 
81
- const elapsed = ref(0);
82
- const isOpen = ref(isStreaming.value);
83
- const userToggledDuringStreaming = ref(false);
84
- let startTimeMs: number | null = null;
85
- let elapsedInterval: ReturnType<typeof setInterval> | null = null;
86
-
87
- function clearElapsedInterval() {
88
- if (elapsedInterval) {
89
- clearInterval(elapsedInterval);
90
- elapsedInterval = null;
91
- }
92
- }
93
-
94
- function updateElapsedNow() {
95
- if (startTimeMs !== null) {
96
- elapsed.value = (Date.now() - startTimeMs) / 1000;
97
- }
98
- }
99
-
100
- watch(
101
- isStreaming,
102
- (nextIsStreaming) => {
103
- if (nextIsStreaming) {
104
- if (startTimeMs === null) {
105
- startTimeMs = Date.now();
106
- }
107
- clearElapsedInterval();
108
- elapsedInterval = setInterval(updateElapsedNow, 1000);
109
- userToggledDuringStreaming.value = false;
110
- isOpen.value = true;
111
- return;
112
- }
113
-
114
- clearElapsedInterval();
115
- updateElapsedNow();
116
- if (!userToggledDuringStreaming.value) {
117
- isOpen.value = false;
118
- }
119
- },
120
- { immediate: true },
121
- );
122
-
84
+ // Open while streaming, auto-collapse when it ends unless the user toggled,
85
+ // and the elapsed time: see core's ɵReasoningDisclosure.
86
+ const disclosure = new ɵReasoningDisclosure({ isStreaming: isStreaming.value });
87
+ const disclosureState = shallowRef(disclosure.getSnapshot());
88
+ const unsubscribe = disclosure.subscribe(() => {
89
+ disclosureState.value = disclosure.getSnapshot();
90
+ });
91
+ watch(isStreaming, (next) => disclosure.setStreaming(next), {
92
+ immediate: true,
93
+ });
123
94
  onBeforeUnmount(() => {
124
- clearElapsedInterval();
95
+ unsubscribe();
96
+ disclosure.dispose();
125
97
  });
126
98
 
99
+ const elapsed = computed(() => disclosureState.value.elapsedSeconds);
100
+ const isOpen = computed(() => disclosureState.value.isOpen);
101
+
127
102
  const labels = useChatLabels();
128
103
  /** `labels.reasoningMessageThinkingLabel` while streaming, then "Thought for …". */
129
104
  const label = computed(() =>
130
- isStreaming.value
131
- ? labels.value.reasoningMessageThinkingLabel
132
- : ɵformatLabel(labels.value.reasoningMessageThoughtForLabel, {
133
- duration: ɵformatReasoningDurationLabel(elapsed.value, labels.value),
134
- }),
105
+ ɵgetReasoningHeaderLabel(
106
+ { isStreaming: isStreaming.value, elapsedSeconds: elapsed.value },
107
+ labels.value,
108
+ ),
135
109
  );
136
110
 
137
111
  function toggleOpen() {
138
112
  if (!hasContent.value) return;
139
- userToggledDuringStreaming.value = true;
140
- isOpen.value = !isOpen.value;
113
+ disclosure.toggle();
141
114
  }
142
115
  </script>
143
116
 
@@ -1,20 +1,14 @@
1
1
  <script setup lang="ts">
2
- import {
3
- computed,
4
- nextTick,
5
- onBeforeUnmount,
6
- onMounted,
7
- ref,
8
- useSlots,
9
- watch,
10
- } from "vue";
2
+ import { computed, ref, useSlots, watch } from "vue";
11
3
  import type { Message } from "@ag-ui/core";
12
4
  import {
5
+ ɵcreateStickToBottom,
13
6
  ɵgetChatViewState,
14
- ɵisScrolledToBottom,
7
+ ɵisContentScrolledToBottom,
15
8
  ɵnormalizeAutoScroll,
9
+ ɵwatchElementHeight,
16
10
  } from "@copilotkit/core";
17
- import type { Suggestion } from "@copilotkit/core";
11
+ import type { Suggestion, ɵStickToBottom } from "@copilotkit/core";
18
12
  import type { Attachment } from "@copilotkit/shared";
19
13
  import { useChatLabels } from "./use-chat-labels";
20
14
  import { useKeyboardHeight } from "../../hooks/use-keyboard-height";
@@ -138,6 +132,9 @@ const spacerRef = ref<HTMLElement | null>(null);
138
132
  const inputContainerRef = ref<HTMLElement | null>(null);
139
133
  const inputContainerHeight = ref(0);
140
134
  const isAtBottom = ref(true);
135
+ // The end of the messages is in view. Without following ("pin-to-send" and
136
+ // "none"), this, not the scroll height, decides the scroll-to-bottom button.
137
+ const isContentAtBottom = ref(true);
141
138
  const isControlledInput = computed(() => props.inputValue !== undefined);
142
139
  const localInputValue = ref(props.inputValue ?? "");
143
140
 
@@ -212,126 +209,111 @@ const messagePaddingBottom = computed(
212
209
  () => `${viewState.value.paddingBottom}px`,
213
210
  );
214
211
  const showScrollToBottomButton = computed(
215
- () => !shouldShowWelcomeScreen.value && !isAtBottom.value,
212
+ () =>
213
+ !shouldShowWelcomeScreen.value &&
214
+ !(isPinToBottomMode.value ? isAtBottom.value : isContentAtBottom.value),
216
215
  );
217
216
 
218
- let inputResizeObserver: ResizeObserver | null = null;
219
- let contentResizeObserver: ResizeObserver | null = null;
220
-
221
- function attachInputOverlayObserver() {
222
- if (typeof ResizeObserver === "undefined") return;
223
-
224
- inputResizeObserver?.disconnect();
225
- inputResizeObserver = null;
226
-
227
- const el = inputContainerRef.value;
228
- if (!el) return;
229
-
230
- inputResizeObserver = new ResizeObserver((entries) => {
231
- const wasAtBottom = isAtBottom.value;
232
- const measured = entries[0]?.contentRect?.height;
233
- if (typeof measured === "number") {
234
- inputContainerHeight.value = measured;
235
- } else {
236
- syncInputContainerHeight();
237
- }
238
- updateIsAtBottom();
239
- if (isPinToBottomMode.value && wasAtBottom) {
240
- scrollToBottom("auto");
217
+ // Core keeps the list at the bottom while content arrives in `pin-to-bottom`
218
+ // mode, and tracks the position for the scroll-to-bottom button in every
219
+ // mode. It attaches when the scroll container mounts, also after the welcome
220
+ // screen gives way to the chat.
221
+ let stickToBottom: ɵStickToBottom | null = null;
222
+ watch(
223
+ [scrollContainerRef, scrollContentRef],
224
+ ([scrollElement, contentElement], _old, onCleanup) => {
225
+ if (!scrollElement) {
226
+ isAtBottom.value = true;
227
+ return;
241
228
  }
242
- });
243
- inputResizeObserver.observe(el);
244
- }
245
-
246
- function attachScrollContentObserver() {
247
- if (typeof ResizeObserver === "undefined") return;
248
-
249
- contentResizeObserver?.disconnect();
250
- contentResizeObserver = null;
251
-
252
- const el = scrollContentRef.value;
253
- if (!el) return;
229
+ const controller = ɵcreateStickToBottom({
230
+ scrollElement,
231
+ contentElement: contentElement ?? scrollElement,
232
+ follow: isPinToBottomMode.value,
233
+ onChange: (value) => {
234
+ isAtBottom.value = value;
235
+ },
236
+ });
237
+ stickToBottom = controller;
238
+ isAtBottom.value = controller.isAtBottom;
239
+ onCleanup(() => {
240
+ controller.dispose();
241
+ if (stickToBottom === controller) stickToBottom = null;
242
+ });
243
+ },
244
+ { flush: "post" },
245
+ );
254
246
 
255
- contentResizeObserver = new ResizeObserver(() => {
256
- const wasAtBottom = isAtBottom.value;
257
- updateIsAtBottom();
258
- if (isPinToBottomMode.value && wasAtBottom) {
259
- scrollToBottom("auto");
260
- }
261
- });
262
- contentResizeObserver.observe(el);
247
+ watch(isPinToBottomMode, (follow) => stickToBottom?.setFollow(follow));
248
+
249
+ // Without following, the button shows while messages sit below the viewport.
250
+ // That is the end of the messages, not the scroll height: in "pin-to-send"
251
+ // mode an empty spacer under them lets a sent message reach the top, and
252
+ // scrolling into it would only clear the screen (parity with React).
253
+ function checkContentAtBottom() {
254
+ const scrollElement = scrollContainerRef.value;
255
+ const contentElement = scrollContentRef.value;
256
+ isContentAtBottom.value =
257
+ !scrollElement ||
258
+ !contentElement ||
259
+ ɵisContentScrolledToBottom(scrollElement, contentElement);
263
260
  }
264
261
 
265
- // When the welcome screen disappears and the main chat view (with the input
266
- // overlay) appears, the template-ref elements become non-null. Re-attach
267
- // observers that were skipped during the initial mount.
268
- watch(shouldShowWelcomeScreen, async (show) => {
269
- if (show) return;
270
- await nextTick();
271
- syncInputContainerHeight();
272
- attachInputOverlayObserver();
273
- attachScrollContentObserver();
274
- });
275
-
276
262
  watch(
277
- () => props.inputValue,
278
- (next) => {
279
- if (isControlledInput.value) {
280
- localInputValue.value = next ?? "";
281
- }
263
+ [scrollContainerRef, scrollContentRef],
264
+ ([scrollElement, contentElement], _old, onCleanup) => {
265
+ checkContentAtBottom();
266
+ if (!scrollElement) return;
267
+ scrollElement.addEventListener("scroll", checkContentAtBottom);
268
+ // A reply that streams past the bottom needs the button before anyone
269
+ // scrolls, so check again when the viewport or the messages resize.
270
+ const resizeObserver =
271
+ typeof ResizeObserver === "undefined"
272
+ ? null
273
+ : new ResizeObserver(checkContentAtBottom);
274
+ resizeObserver?.observe(scrollElement);
275
+ if (contentElement) resizeObserver?.observe(contentElement);
276
+ onCleanup(() => {
277
+ scrollElement.removeEventListener("scroll", checkContentAtBottom);
278
+ resizeObserver?.disconnect();
279
+ });
282
280
  },
281
+ { flush: "post" },
283
282
  );
284
283
 
284
+ // The input overlay's height becomes the bottom padding of the messages.
285
285
  watch(
286
- [() => props.messages, () => props.suggestions, () => props.isRunning],
287
- async () => {
288
- const wasAtBottom = isAtBottom.value;
289
- await nextTick();
290
- if (!isPinToBottomMode.value || !wasAtBottom) {
286
+ inputContainerRef,
287
+ (element, _old, onCleanup) => {
288
+ if (!element) {
289
+ inputContainerHeight.value = 0;
291
290
  return;
292
291
  }
293
- scrollToBottom("auto");
294
- if (typeof requestAnimationFrame === "function") {
295
- requestAnimationFrame(() => {
296
- scrollToBottom("auto");
297
- });
298
- }
292
+ onCleanup(
293
+ ɵwatchElementHeight(element, ({ height }) => {
294
+ inputContainerHeight.value = height;
295
+ }),
296
+ );
299
297
  },
300
- { deep: true },
298
+ { flush: "post" },
301
299
  );
302
300
 
303
- function updateIsAtBottom() {
304
- const element = scrollContainerRef.value;
305
- if (!element) {
306
- isAtBottom.value = true;
307
- return;
308
- }
309
-
310
- isAtBottom.value = ɵisScrolledToBottom(element);
311
- }
312
-
313
- function syncInputContainerHeight() {
314
- const element = inputContainerRef.value;
315
- inputContainerHeight.value = element?.offsetHeight ?? 0;
316
- }
301
+ watch(
302
+ () => props.inputValue,
303
+ (next) => {
304
+ if (isControlledInput.value) {
305
+ localInputValue.value = next ?? "";
306
+ }
307
+ },
308
+ );
317
309
 
318
310
  function scrollToBottom(behavior: ScrollBehavior = "smooth") {
319
- const element = scrollContainerRef.value;
320
- if (!element) return;
321
-
322
- if (typeof element.scrollTo === "function") {
323
- element.scrollTo({
324
- top: element.scrollHeight,
325
- behavior,
326
- });
327
- } else {
328
- element.scrollTop = element.scrollHeight;
329
- }
330
- updateIsAtBottom();
311
+ stickToBottom?.scrollToBottom(behavior);
331
312
  }
332
313
 
333
314
  function handleScroll() {
334
- updateIsAtBottom();
315
+ stickToBottom?.update();
316
+ checkContentAtBottom();
335
317
  }
336
318
 
337
319
  function handleInputValueChange(value: string) {
@@ -416,30 +398,6 @@ const inputEventProps = computed(() => {
416
398
  const canStop = computed(() => !!props.onStop);
417
399
  const canAddFile = computed(() => !!props.onAddFile);
418
400
  const canTranscribe = computed(() => !!props.onStartTranscribe);
419
-
420
- onMounted(async () => {
421
- await nextTick();
422
- syncInputContainerHeight();
423
- updateIsAtBottom();
424
- if (isPinToBottomMode.value) {
425
- scrollToBottom("auto");
426
- if (typeof requestAnimationFrame === "function") {
427
- requestAnimationFrame(() => {
428
- scrollToBottom("auto");
429
- });
430
- }
431
- }
432
-
433
- attachInputOverlayObserver();
434
- attachScrollContentObserver();
435
- });
436
-
437
- onBeforeUnmount(() => {
438
- inputResizeObserver?.disconnect();
439
- contentResizeObserver?.disconnect();
440
- inputResizeObserver = null;
441
- contentResizeObserver = null;
442
- });
443
401
  </script>
444
402
 
445
403
  <template>
@@ -583,7 +541,6 @@ onBeforeUnmount(() => {
583
541
  ref="scrollContainerRef"
584
542
  data-testid="copilot-chat-view-scroll"
585
543
  class="cpk-chat-view-scroll-area cpk-chat-view-scroll-viewport"
586
- @scroll="handleScroll"
587
544
  >
588
545
  <div class="cpk-chat-view-scroll-content">
589
546
  <div
@@ -1,16 +1,10 @@
1
1
  <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref, useAttrs, watch } from "vue";
2
3
  import {
3
- computed,
4
- nextTick,
5
- onBeforeUnmount,
6
- onMounted,
7
- ref,
8
- useAttrs,
9
- watch,
10
- } from "vue";
11
- import {
12
- ɵPOPUP_ANIMATION_MS,
4
+ ɵPOPUP_DEFAULT_HEIGHT,
5
+ ɵPOPUP_DEFAULT_WIDTH,
13
6
  ɵbindPopupDismiss,
7
+ ɵcreateExitPresence,
14
8
  ɵdimensionToCss,
15
9
  } from "@copilotkit/core";
16
10
  import { CopilotChatDefaultLabels } from "../../providers/types";
@@ -35,10 +29,6 @@ import type {
35
29
 
36
30
  defineOptions({ inheritAttrs: false });
37
31
 
38
- const DEFAULT_POPUP_WIDTH = 420;
39
- const DEFAULT_POPUP_HEIGHT = 560;
40
- const POPUP_ANIMATION_MS = ɵPOPUP_ANIMATION_MS;
41
-
42
32
  const props = withDefaults(
43
33
  defineProps<Omit<CopilotPopupViewProps, "defaultOpen">>(),
44
34
  {
@@ -102,11 +92,19 @@ const attrs = useAttrs();
102
92
  const config = useCopilotChatConfiguration();
103
93
 
104
94
  const containerRef = ref<HTMLElement | null>(null);
105
- const isRendered = ref(config.value?.isModalOpen ?? false);
106
- const isAnimatingOut = ref(false);
107
- let animationTimer: ReturnType<typeof setTimeout> | undefined;
108
-
109
95
  const isPopupOpen = computed(() => config.value?.isModalOpen ?? false);
96
+
97
+ // Core keeps the closed popup rendered while its exit animation plays.
98
+ const presence = ɵcreateExitPresence(isPopupOpen.value);
99
+ const isRendered = ref(presence.getState().isRendered);
100
+ const isAnimatingOut = ref(presence.getState().isClosing);
101
+ presence.subscribe((state) => {
102
+ isRendered.value = state.isRendered;
103
+ isAnimatingOut.value = state.isClosing;
104
+ });
105
+ watch(isPopupOpen, (open) => presence.setOpen(open));
106
+ onBeforeUnmount(() => presence.dispose());
107
+
110
108
  const labels = computed(() => config.value?.labels ?? CopilotChatDefaultLabels);
111
109
  const headerTitle = computed(() => labels.value.modalHeaderTitle);
112
110
  const popupAnimationClass = computed(() =>
@@ -119,11 +117,11 @@ const popupStyle = computed(
119
117
  ({
120
118
  "--copilot-popup-width": ɵdimensionToCss(
121
119
  props.width,
122
- DEFAULT_POPUP_WIDTH,
120
+ ɵPOPUP_DEFAULT_WIDTH,
123
121
  ),
124
122
  "--copilot-popup-height": ɵdimensionToCss(
125
123
  props.height,
126
- DEFAULT_POPUP_HEIGHT,
124
+ ɵPOPUP_DEFAULT_HEIGHT,
127
125
  ),
128
126
  "--copilot-popup-max-width": "calc(100vw - 3rem)",
129
127
  "--copilot-popup-max-height": "calc(100dvh - 7.5rem)",
@@ -212,34 +210,6 @@ const chatViewBindings = computed(() => ({
212
210
  ...chatViewEventProps.value,
213
211
  }));
214
212
 
215
- watch(
216
- isPopupOpen,
217
- (open) => {
218
- if (animationTimer) {
219
- clearTimeout(animationTimer);
220
- animationTimer = undefined;
221
- }
222
-
223
- if (open) {
224
- isRendered.value = true;
225
- isAnimatingOut.value = false;
226
- return;
227
- }
228
-
229
- if (!isRendered.value) {
230
- return;
231
- }
232
-
233
- isAnimatingOut.value = true;
234
- animationTimer = setTimeout(() => {
235
- isRendered.value = false;
236
- isAnimatingOut.value = false;
237
- animationTimer = undefined;
238
- }, POPUP_ANIMATION_MS);
239
- },
240
- { immediate: true },
241
- );
242
-
243
213
  // Escape, a click outside (when enabled), and focus once the popup opens.
244
214
  watch(
245
215
  [isPopupOpen, () => props.clickOutsideToClose],
@@ -257,16 +227,6 @@ watch(
257
227
  },
258
228
  { immediate: true },
259
229
  );
260
-
261
- onMounted(async () => {
262
- await nextTick();
263
- });
264
-
265
- onBeforeUnmount(() => {
266
- if (animationTimer) {
267
- clearTimeout(animationTimer);
268
- }
269
- });
270
230
  </script>
271
231
 
272
232
  <template>