@copilotkit/vue 2.0.0-rc.19 → 2.0.0-rc.20

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 (73) hide show
  1. package/dist/index.cjs +1 -1
  2. package/dist/index.mjs +2 -2
  3. package/dist/use-render-activity-message-CsWYyd6r.cjs +91 -0
  4. package/dist/use-render-activity-message-CsWYyd6r.cjs.map +1 -0
  5. package/dist/{use-render-activity-message-g7xEf4ep.js → use-render-activity-message-DntKKNdd.js} +6354 -6747
  6. package/dist/use-render-activity-message-DntKKNdd.js.map +1 -0
  7. package/dist/v2/components/InlineFeatureWarning.vue.d.ts.map +1 -1
  8. package/dist/v2/components/LicenseWarningBanner.vue.d.ts +2 -2
  9. package/dist/v2/components/LicenseWarningBanner.vue.d.ts.map +1 -1
  10. package/dist/v2/components/chat/CopilotChat.vue.d.ts +1 -1
  11. package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts.map +1 -1
  12. package/dist/v2/components/chat/CopilotChatFeedbackCommentPopover.vue.d.ts +1 -1
  13. package/dist/v2/components/chat/CopilotChatFeedbackCommentPopover.vue.d.ts.map +1 -1
  14. package/dist/v2/components/chat/CopilotChatInspectorButton.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/CopilotChatUserMessage.vue.d.ts.map +1 -1
  17. package/dist/v2/components/chat/CopilotChatView.vue.d.ts +1 -1
  18. package/dist/v2/components/chat/CopilotChatWorkSummary.vue.d.ts.map +1 -1
  19. package/dist/v2/components/chat/CopilotModalHeader.vue.d.ts.map +1 -1
  20. package/dist/v2/components/chat/CopilotPopup.vue.d.ts +1 -1
  21. package/dist/v2/components/chat/CopilotPopupView.vue.d.ts +1 -1
  22. package/dist/v2/components/chat/CopilotPopupViewInternal.vue.d.ts +1 -1
  23. package/dist/v2/components/chat/CopilotSidebar.vue.d.ts +1 -1
  24. package/dist/v2/components/chat/CopilotSidebarView.vue.d.ts +1 -1
  25. package/dist/v2/components/chat/CopilotSidebarViewInternal.vue.d.ts +1 -1
  26. package/dist/v2/components/chat/index.d.ts +12 -12
  27. package/dist/v2/components/chat/inspector-shortcuts.d.ts +2 -1
  28. package/dist/v2/components/chat/inspector-shortcuts.d.ts.map +1 -1
  29. package/dist/v2/components/chat/message-feedback.d.ts +18 -49
  30. package/dist/v2/components/chat/message-feedback.d.ts.map +1 -1
  31. package/dist/v2/components/chat/normalize-auto-scroll.d.ts +2 -2
  32. package/dist/v2/components/chat/normalize-auto-scroll.d.ts.map +1 -1
  33. package/dist/v2/components/chat/use-copy-feedback.d.ts +11 -0
  34. package/dist/v2/components/chat/use-copy-feedback.d.ts.map +1 -0
  35. package/dist/v2/hooks/use-keyboard-height.d.ts +1 -1
  36. package/dist/v2/hooks/use-keyboard-height.d.ts.map +1 -1
  37. package/dist/v2/hooks/use-pin-to-send.d.ts +4 -12
  38. package/dist/v2/hooks/use-pin-to-send.d.ts.map +1 -1
  39. package/dist/v2/index.cjs +1 -1
  40. package/dist/v2/index.mjs +1 -1
  41. package/dist/v2/lib/is-mobile-viewport.d.ts +1 -14
  42. package/dist/v2/lib/is-mobile-viewport.d.ts.map +1 -1
  43. package/dist/v2/lib/shallow-stable.d.ts.map +1 -1
  44. package/package.json +8 -8
  45. package/src/v2/components/InlineFeatureWarning.vue +20 -21
  46. package/src/v2/components/LicenseWarningBanner.vue +25 -149
  47. package/src/v2/components/__tests__/LicenseWarningBanner.test.ts +48 -8
  48. package/src/v2/components/chat/CopilotChatAssistantMessage.vue +19 -43
  49. package/src/v2/components/chat/CopilotChatFeedbackCommentPopover.vue +2 -2
  50. package/src/v2/components/chat/CopilotChatInspectorButton.vue +45 -108
  51. package/src/v2/components/chat/CopilotChatReasoningMessage.vue +2 -28
  52. package/src/v2/components/chat/CopilotChatUserMessage.vue +24 -112
  53. package/src/v2/components/chat/CopilotChatWorkSummary.vue +23 -59
  54. package/src/v2/components/chat/CopilotModalHeader.vue +11 -23
  55. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.learningFeedback.test.ts +5 -5
  56. package/src/v2/components/chat/inspector-shortcuts.ts +10 -16
  57. package/src/v2/components/chat/message-feedback.ts +62 -197
  58. package/src/v2/components/chat/normalize-auto-scroll.ts +3 -17
  59. package/src/v2/components/chat/use-copy-feedback.ts +21 -0
  60. package/src/v2/hooks/use-keyboard-height.ts +17 -40
  61. package/src/v2/hooks/use-pin-to-send.ts +15 -99
  62. package/src/v2/lib/__tests__/shallow-stable.test.ts +54 -0
  63. package/src/v2/lib/is-mobile-viewport.ts +5 -23
  64. package/src/v2/lib/shallow-stable.ts +2 -16
  65. package/dist/use-render-activity-message-IH90zF01.cjs +0 -92
  66. package/dist/use-render-activity-message-IH90zF01.cjs.map +0 -1
  67. package/dist/use-render-activity-message-g7xEf4ep.js.map +0 -1
  68. package/dist/v2/lib/message-feedback.d.ts +0 -33
  69. package/dist/v2/lib/message-feedback.d.ts.map +0 -1
  70. package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +0 -39
  71. package/src/v2/lib/__tests__/is-mobile-viewport.test.ts +0 -48
  72. package/src/v2/lib/__tests__/message-feedback.test.ts +0 -152
  73. package/src/v2/lib/message-feedback.ts +0 -120
@@ -1,34 +1,28 @@
1
+ import { ɵinspectorShortcuts } from "@copilotkit/core";
1
2
  import { onBeforeUnmount, readonly, ref } from "vue";
2
3
  import type { Ref } from "vue";
3
4
 
4
- // Page-lifetime preference, shared by every message and chat. Deliberately
5
- // avoid sessionStorage: it survives reloads, while this preference must not.
6
- let shortcutsHidden = false;
7
- const listeners = new Set<() => void>();
8
-
9
5
  /**
10
6
  * Whether the user hid the Inspector buttons in the message toolbars ("Hide
11
- * this icon"), until the page reloads.
7
+ * this icon"), until the page reloads. Core's `ɵinspectorShortcuts` holds
8
+ * the page-lifetime preference.
12
9
  * @internal
13
10
  */
14
11
  export function useInspectorShortcutsHidden(): Readonly<Ref<boolean>> {
15
- const hidden = ref(shortcutsHidden);
16
- const listener = () => {
17
- hidden.value = shortcutsHidden;
18
- };
19
- listeners.add(listener);
20
- onBeforeUnmount(() => listeners.delete(listener));
12
+ const hidden = ref(ɵinspectorShortcuts.hidden);
13
+ const unsubscribe = ɵinspectorShortcuts.subscribe(() => {
14
+ hidden.value = ɵinspectorShortcuts.hidden;
15
+ });
16
+ onBeforeUnmount(unsubscribe);
21
17
  return readonly(hidden);
22
18
  }
23
19
 
24
20
  /** Hides the Inspector buttons of every message until the page reloads. */
25
21
  export function hideInspectorShortcuts(): void {
26
- shortcutsHidden = true;
27
- listeners.forEach((listener) => listener());
22
+ ɵinspectorShortcuts.hide();
28
23
  }
29
24
 
30
25
  /** @internal Shows the buttons again, so each test starts fresh. */
31
26
  export function ɵresetInspectorShortcuts(): void {
32
- shortcutsHidden = false;
33
- listeners.forEach((listener) => listener());
27
+ ɵinspectorShortcuts.reset();
34
28
  }
@@ -1,23 +1,30 @@
1
1
  import type { AssistantMessage } from "@ag-ui/core";
2
2
  import type { CopilotKitCore } from "@copilotkit/core";
3
- import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from "vue";
4
- import type { ComputedRef, InjectionKey, Ref } from "vue";
5
3
  import {
6
- getMessageFeedback,
7
- setMessageFeedback,
8
- subscribeToMessageFeedback,
9
- } from "../../lib/message-feedback";
4
+ ɵFeedbackCommentController,
5
+ ɵgetMessageFeedbackControls,
6
+ ɵisLearningFeedbackEnabled,
7
+ ɵsubscribeToLearningFeedbackEnabled,
8
+ ɵsubscribeToMessageFeedback,
9
+ } from "@copilotkit/core";
10
+ import type {
11
+ ɵMessageFeedbackControls,
12
+ ɵMessageFeedbackScope,
13
+ } from "@copilotkit/core";
14
+ import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from "vue";
10
15
  import type {
11
- MessageFeedback,
12
- MessageFeedbackRating,
13
- } from "../../lib/message-feedback";
16
+ ComputedRef,
17
+ InjectionKey,
18
+ Ref,
19
+ ShallowRef,
20
+ WritableComputedRef,
21
+ } from "vue";
14
22
 
15
23
  /** The chat whose assistant messages collect feedback for Learning. */
16
- export interface MessageFeedbackScope {
17
- readonly copilotkit: CopilotKitCore;
18
- readonly agentId: string;
19
- readonly threadId: string;
20
- }
24
+ export type MessageFeedbackScope = ɵMessageFeedbackScope;
25
+
26
+ /** Current feedback for one assistant message, and how to change it. */
27
+ export type MessageFeedbackControls = ɵMessageFeedbackControls;
21
28
 
22
29
  /**
23
30
  * Provided by the message view while Learning is on for this chat. Assistant
@@ -29,68 +36,27 @@ export const MessageFeedbackScopeKey: InjectionKey<
29
36
  Ref<MessageFeedbackScope | null>
30
37
  > = Symbol("CopilotMessageFeedbackScope");
31
38
 
32
- // Cores that captured since Learning was configured, for chats that mount
33
- // while capture reconnects or is stopped. A Core leaves when its config goes.
34
- const capturedCores = new WeakSet<CopilotKitCore>();
35
-
36
- const rememberCapture = (copilotkit: CopilotKitCore) => {
37
- if (capturedCores.has(copilotkit)) return;
38
- capturedCores.add(copilotkit);
39
- const unsubscribe = copilotkit.ɵsubscribeToLearningConfigured(() => {
40
- if (copilotkit.ɵlearningConfigured) return;
41
- capturedCores.delete(copilotkit);
42
- unsubscribe();
43
- });
44
- };
45
-
46
39
  /**
47
- * Whether the thumbs record feedback for Learning. True once a Trajectory of
48
- * this Core has started and as long as Learning stays configured, also while
49
- * capture reconnects or is stopped, so the buttons do not flicker. Ratings
50
- * given meanwhile still show but, like any Trajectory event, are not sent.
40
+ * Whether the thumbs record feedback for Learning. See core's
41
+ * `ɵisLearningFeedbackEnabled` for the rules.
51
42
  * @internal
52
43
  */
53
44
  export function useLearningFeedbackEnabled(
54
45
  copilotkit: Ref<CopilotKitCore>,
55
46
  ): ComputedRef<boolean> {
56
- const configured = ref(false);
57
- const capturing = ref(false);
58
-
47
+ const enabled = ref(false);
59
48
  watch(
60
49
  copilotkit,
61
50
  (core, _previous, onCleanup) => {
62
51
  const read = () => {
63
- configured.value = core.ɵlearningConfigured;
64
- capturing.value = core.trajectoryId !== null;
65
- if (capturing.value) rememberCapture(core);
52
+ enabled.value = ɵisLearningFeedbackEnabled(core);
66
53
  };
67
54
  read();
68
- const unsubscribeConfigured = core.ɵsubscribeToLearningConfigured(read);
69
- const subscription = core.subscribe({ onTrajectoryChanged: read });
70
- onCleanup(() => {
71
- unsubscribeConfigured();
72
- subscription.unsubscribe();
73
- });
55
+ onCleanup(ɵsubscribeToLearningFeedbackEnabled(core, read));
74
56
  },
75
57
  { immediate: true },
76
58
  );
77
-
78
- return computed(
79
- () =>
80
- configured.value &&
81
- (capturing.value || capturedCores.has(copilotkit.value)),
82
- );
83
- }
84
-
85
- /** Current feedback for one assistant message, and how to change it. */
86
- export interface MessageFeedbackControls {
87
- readonly feedback: MessageFeedback | undefined;
88
- /** Gives `rating`; a different rating than before starts without a comment. */
89
- readonly rate: (rating: MessageFeedbackRating) => void;
90
- /** Removes the rating and its comment. */
91
- readonly clear: () => void;
92
- /** Sets the current rating's comment; an empty one removes it. */
93
- readonly comment: (comment: string) => void;
59
+ return computed(() => enabled.value);
94
60
  }
95
61
 
96
62
  /** Feedback controls for `message`, or `null` when this chat collects none. */
@@ -106,7 +72,7 @@ export function useMessageFeedback(
106
72
  (current, _previous, onCleanup) => {
107
73
  if (!current) return;
108
74
  onCleanup(
109
- subscribeToMessageFeedback(current.agentId, current.threadId, () => {
75
+ ɵsubscribeToMessageFeedback(current.agentId, current.threadId, () => {
110
76
  version.value++;
111
77
  }),
112
78
  );
@@ -118,174 +84,73 @@ export function useMessageFeedback(
118
84
  const current = scope.value;
119
85
  if (!current) return null;
120
86
  void version.value;
121
- const target = message.value;
122
- const feedback = getMessageFeedback(
123
- current.agentId,
124
- current.threadId,
125
- target.id,
126
- );
127
- const save = (next: MessageFeedback | null) =>
128
- setMessageFeedback({ ...current, message: target, feedback: next });
129
- return {
130
- feedback,
131
- rate: (rating) => {
132
- if (feedback?.rating !== rating) save({ rating });
133
- },
134
- clear: () => {
135
- if (feedback) save(null);
136
- },
137
- comment: (comment) => {
138
- if (feedback) save({ rating: feedback.rating, comment });
139
- },
140
- };
87
+ return ɵgetMessageFeedbackControls(current, message.value);
141
88
  });
142
89
  }
143
90
 
144
- /**
145
- * How long the pointer may be away from the rating and its comment before the
146
- * comment closes: enough to cross the gap between them, short enough that
147
- * passing over a rating does not leave it open.
148
- */
149
- export const FEEDBACK_COMMENT_CLOSE_DELAY_MS = 150;
150
-
151
91
  /** The comment popover of one rated message: when it shows and what it holds. */
152
92
  export interface FeedbackComment {
153
- readonly open: Ref<boolean>;
93
+ readonly open: ComputedRef<boolean>;
154
94
  /** The comment being edited. */
155
- readonly draft: Ref<string>;
95
+ readonly draft: WritableComputedRef<string>;
156
96
  /** Grows each time the field should take focus: after a keyboard rating. */
157
- readonly focusRequest: Ref<number>;
97
+ readonly focusRequest: ComputedRef<number>;
158
98
  /** Whether a focus request waits; reading it answers the request. */
159
99
  readonly takeFocusRequest: () => boolean;
160
100
  /** The comment field, for moving focus into it. */
161
- readonly input: Ref<HTMLInputElement | null>;
101
+ readonly input: ShallowRef<HTMLInputElement | null>;
162
102
  /** Opens with `comment` in the field. */
163
- readonly show: (options: { comment: string; focus: boolean }) => void;
103
+ readonly show: ɵFeedbackCommentController["show"];
164
104
  /** The pointer or focus reached the rating or the popover. */
165
- readonly enter: (via?: "pointer" | "focus") => void;
105
+ readonly enter: ɵFeedbackCommentController["enter"];
166
106
  /**
167
107
  * It left them: close soon, unless the user is editing the comment or, when
168
108
  * focus left, the pointer is still on the rating or the popover.
169
109
  */
170
- readonly leave: (via?: "pointer" | "focus") => void;
110
+ readonly leave: ɵFeedbackCommentController["leave"];
171
111
  /** Tracks whether focus is in the comment form. */
172
112
  readonly setEditing: (editing: boolean) => void;
173
113
  /** Closes, saving a changed comment unless `save` is false. */
174
- readonly close: (options?: { save?: boolean }) => void;
114
+ readonly close: ɵFeedbackCommentController["close"];
175
115
  /** Moves focus back to the thumb without opening the comment again. */
176
116
  readonly returnFocus: (target: HTMLElement | null) => void;
177
117
  }
178
118
 
179
119
  /**
180
- * Drives the comment popover of a rated message, as React's
181
- * `useFeedbackComment` does.
182
- *
183
- * It opens when a rating is given and whenever the pointer or keyboard focus
184
- * reaches the chosen thumb, with the saved comment in the field. It closes by
185
- * itself shortly after the pointer leaves the thumb and the popover, unless
186
- * focus is in the field: then it stays until focus moves away, Enter, or
187
- * Escape. Closing saves a changed comment; Escape, a click on a thumb, and
188
- * removing the rating discard it.
120
+ * Drives the comment popover of a rated message with core's
121
+ * `ɵFeedbackCommentController`, which holds the open and close rules.
189
122
  * @internal
190
123
  */
191
124
  export function useFeedbackComment(
192
125
  controls: Ref<MessageFeedbackControls | null>,
193
126
  ): FeedbackComment {
194
- const open = ref(false);
195
- const draft = ref("");
196
- const focusRequest = ref(0);
197
- const input = shallowRef<HTMLInputElement | null>(null);
198
- let timer: ReturnType<typeof setTimeout> | undefined;
199
- let editing = false;
200
- let focusWaiting = false;
201
- // Whether the pointer is on the chosen thumb or the popover. Browsers that
202
- // do not focus a clicked button move focus to nothing while it stays there.
203
- let pointerOver = false;
204
- // Set while focus moves back to the thumb, whose focus would open it again.
205
- let returning = false;
206
-
207
- const takeFocusRequest = () => {
208
- const waiting = focusWaiting;
209
- focusWaiting = false;
210
- return waiting;
211
- };
212
-
213
- const cancelClose = () => {
214
- if (timer === undefined) return;
215
- clearTimeout(timer);
216
- timer = undefined;
217
- };
218
-
219
- const savedComment = () => controls.value?.feedback?.comment ?? "";
220
-
221
- const close = ({ save = true }: { save?: boolean } = {}) => {
222
- cancelClose();
223
- focusWaiting = false;
224
- pointerOver = false;
225
- if (!open.value) return;
226
- const current = controls.value;
227
- if (save && current?.feedback && draft.value.trim() !== savedComment())
228
- current.comment(draft.value);
229
- editing = false;
230
- open.value = false;
231
- };
232
-
233
- const show = ({ comment, focus }: { comment: string; focus: boolean }) => {
234
- cancelClose();
235
- draft.value = comment;
236
- if (focus) {
237
- focusWaiting = true;
238
- focusRequest.value++;
239
- }
240
- open.value = true;
241
- };
242
-
127
+ const controller = new ɵFeedbackCommentController({
128
+ getControls: () => controls.value,
129
+ });
130
+ const state = shallowRef(controller.getSnapshot());
131
+ const unsubscribe = controller.subscribe(() => {
132
+ state.value = controller.getSnapshot();
133
+ });
243
134
  // A message that unmounts (scrolled out of a virtual list) keeps its edit.
244
135
  onBeforeUnmount(() => {
245
- cancelClose();
246
- const current = controls.value;
247
- if (
248
- open.value &&
249
- current?.feedback &&
250
- draft.value.trim() !== savedComment()
251
- )
252
- current.comment(draft.value);
136
+ controller.dispose();
137
+ unsubscribe();
253
138
  });
254
139
 
255
140
  return {
256
- open,
257
- draft,
258
- focusRequest,
259
- takeFocusRequest,
260
- input,
261
- show,
262
- enter: (via = "focus") => {
263
- cancelClose();
264
- if (via === "pointer") pointerOver = true;
265
- if (returning) return;
266
- if (!open.value && controls.value?.feedback)
267
- show({ comment: savedComment(), focus: false });
268
- },
269
- leave: (via = "focus") => {
270
- if (via === "pointer") pointerOver = false;
271
- else if (pointerOver) return;
272
- cancelClose();
273
- timer = setTimeout(() => {
274
- timer = undefined;
275
- if (!editing) close();
276
- }, FEEDBACK_COMMENT_CLOSE_DELAY_MS);
277
- },
278
- setEditing: (value) => {
279
- editing = value;
280
- },
281
- close,
282
- returnFocus: (target) => {
283
- returning = true;
284
- try {
285
- target?.focus();
286
- } finally {
287
- returning = false;
288
- }
289
- },
141
+ open: computed(() => state.value.open),
142
+ draft: computed({
143
+ get: () => state.value.draft,
144
+ set: controller.setDraft,
145
+ }),
146
+ focusRequest: computed(() => state.value.focusRequest),
147
+ takeFocusRequest: controller.takeFocusRequest,
148
+ input: shallowRef<HTMLInputElement | null>(null),
149
+ show: controller.show,
150
+ enter: controller.enter,
151
+ leave: controller.leave,
152
+ setEditing: controller.setEditing,
153
+ close: controller.close,
154
+ returnFocus: controller.returnFocus,
290
155
  };
291
156
  }
@@ -1,17 +1,3 @@
1
- export type AutoScrollMode = "pin-to-bottom" | "pin-to-send" | "none";
2
-
3
- const VALID: readonly AutoScrollMode[] = [
4
- "pin-to-bottom",
5
- "pin-to-send",
6
- "none",
7
- ];
8
-
9
- export function normalizeAutoScroll(
10
- value: AutoScrollMode | boolean | undefined,
11
- ): AutoScrollMode {
12
- if (value === undefined) return "pin-to-bottom";
13
- if (value === true) return "pin-to-bottom";
14
- if (value === false) return "none";
15
- if ((VALID as readonly string[]).includes(value)) return value;
16
- return "pin-to-bottom";
17
- }
1
+ // The rules live in core. This module keeps the import path stable.
2
+ export { ɵnormalizeAutoScroll as normalizeAutoScroll } from "@copilotkit/core";
3
+ export type { ɵAutoScrollMode as AutoScrollMode } from "@copilotkit/core";
@@ -0,0 +1,21 @@
1
+ import { onBeforeUnmount, readonly, ref } from "vue";
2
+ import { ɵCopyFeedback } from "@copilotkit/core";
3
+
4
+ /**
5
+ * The "copied" check mark of a copy button, from core's `ɵCopyFeedback`:
6
+ * `copy(text)` writes to the clipboard and shows the check mark for two
7
+ * seconds; a second copy restarts the wait.
8
+ * @internal
9
+ */
10
+ export function useCopyFeedback() {
11
+ const feedback = new ɵCopyFeedback();
12
+ const copied = ref(feedback.getSnapshot());
13
+ const unsubscribe = feedback.subscribe(() => {
14
+ copied.value = feedback.getSnapshot();
15
+ });
16
+ onBeforeUnmount(() => {
17
+ unsubscribe();
18
+ feedback.dispose();
19
+ });
20
+ return { copied: readonly(copied), copy: feedback.copy };
21
+ }
@@ -1,4 +1,6 @@
1
- import { onBeforeUnmount, onMounted, readonly, ref, type Ref } from "vue";
1
+ import { onBeforeUnmount, onMounted, readonly, ref } from "vue";
2
+ import type { Ref } from "vue";
3
+ import { ɵinitialKeyboardState, ɵwatchKeyboardState } from "@copilotkit/core";
2
4
 
3
5
  /**
4
6
  * Vue-idiomatic result of {@link useKeyboardHeight}. Fields mirror the React
@@ -32,50 +34,25 @@ export interface KeyboardState {
32
34
  * ```
33
35
  */
34
36
  export function useKeyboardHeight(): KeyboardState {
35
- const initialHeight = typeof window !== "undefined" ? window.innerHeight : 0;
36
- const isKeyboardOpen = ref(false);
37
- const keyboardHeight = ref(0);
38
- const availableHeight = ref(initialHeight);
39
- const viewportHeight = ref(initialHeight);
40
-
41
- const updateKeyboardState = () => {
42
- if (typeof window === "undefined") return;
43
- const visualViewport = window.visualViewport;
44
- if (!visualViewport) return;
45
-
46
- const layoutHeight = window.innerHeight;
47
- const visualHeight = visualViewport.height;
48
-
49
- // Keyboard height = layout viewport minus visual viewport, clamped to 0.
50
- const nextKeyboardHeight = Math.max(0, layoutHeight - visualHeight);
51
- // Only treat the keyboard as open past the 150px threshold, matching
52
- // the React implementation's mobile heuristic.
53
- const nextIsOpen = nextKeyboardHeight > 150;
54
-
55
- keyboardHeight.value = nextKeyboardHeight;
56
- isKeyboardOpen.value = nextIsOpen;
57
- availableHeight.value = visualHeight;
58
- viewportHeight.value = layoutHeight;
59
- };
60
-
61
- let visualViewport: VisualViewport | null = null;
37
+ const initial = ɵinitialKeyboardState();
38
+ const isKeyboardOpen = ref(initial.isKeyboardOpen);
39
+ const keyboardHeight = ref(initial.keyboardHeight);
40
+ const availableHeight = ref(initial.availableHeight);
41
+ const viewportHeight = ref(initial.viewportHeight);
42
+ let stopWatching: (() => void) | undefined;
62
43
 
63
44
  onMounted(() => {
64
- if (typeof window === "undefined") return;
65
- visualViewport = window.visualViewport ?? null;
66
- if (!visualViewport) return;
67
-
68
- updateKeyboardState();
69
-
70
- visualViewport.addEventListener("resize", updateKeyboardState);
71
- visualViewport.addEventListener("scroll", updateKeyboardState);
45
+ stopWatching = ɵwatchKeyboardState((state) => {
46
+ keyboardHeight.value = state.keyboardHeight;
47
+ isKeyboardOpen.value = state.isKeyboardOpen;
48
+ availableHeight.value = state.availableHeight;
49
+ viewportHeight.value = state.viewportHeight;
50
+ });
72
51
  });
73
52
 
74
53
  onBeforeUnmount(() => {
75
- if (!visualViewport) return;
76
- visualViewport.removeEventListener("resize", updateKeyboardState);
77
- visualViewport.removeEventListener("scroll", updateKeyboardState);
78
- visualViewport = null;
54
+ stopWatching?.();
55
+ stopWatching = undefined;
79
56
  });
80
57
 
81
58
  return {
@@ -1,5 +1,6 @@
1
1
  import { inject, watch } from "vue";
2
2
  import type { Ref } from "vue";
3
+ import { ɵpinMessageToTop } from "@copilotkit/core";
3
4
  import {
4
5
  LastUserMessageKey,
5
6
  createDefaultLastUserMessageRef,
@@ -19,18 +20,10 @@ export interface UsePinToSendOptions {
19
20
  * pinned near the top of the viewport while the assistant streams a
20
21
  * response beneath it.
21
22
  *
22
- * Implementation parity with React:
23
- * - Reads the latest user message via the `LastUserMessageContext`
24
- * equivalent (`LastUserMessageKey` + `Ref<LastUserMessageState>`).
25
- * - On each `sendNonce` increment, sizes the spacer to
26
- * `viewportHeight - bubbleHeight - topOffset` and scrolls so the bubble
27
- * sits `topOffset` from the viewport top (padding above the bubble is
28
- * pushed off-screen).
29
- * - Installs a `ResizeObserver` on `contentRef` that adjusts the spacer in
30
- * both directions as content below the anchored message changes, keeping
31
- * the bubble pinned.
32
- * - Cancels the scheduled `requestAnimationFrame` and disconnects the
33
- * `ResizeObserver` on subsequent re-runs and on scope dispose.
23
+ * Reads the latest user message via `LastUserMessageKey` and, on each
24
+ * `sendNonce` increment, runs core's `ɵpinMessageToTop`, which sizes the
25
+ * spacer, scrolls, and follows the reply's growth. The previous pin stops on
26
+ * the next run and on scope dispose.
34
27
  */
35
28
  export function usePinToSend({
36
29
  scrollRef,
@@ -45,17 +38,6 @@ export function usePinToSend({
45
38
  );
46
39
 
47
40
  let lastNonce = -1;
48
- let raf: number | null = null;
49
- let ro: ResizeObserver | null = null;
50
-
51
- function teardown() {
52
- if (raf !== null && typeof cancelAnimationFrame === "function") {
53
- cancelAnimationFrame(raf);
54
- }
55
- raf = null;
56
- ro?.disconnect();
57
- ro = null;
58
- }
59
41
 
60
42
  watch(
61
43
  [
@@ -67,90 +49,24 @@ export function usePinToSend({
67
49
  () => topOffset,
68
50
  ],
69
51
  ([id, sendNonce, scrollEl, contentEl, spacerEl], _old, onCleanup) => {
70
- // Always tear down the previous run's RAF / ResizeObserver before
71
- // evaluating any of the early-exit guards below. Mirrors React
72
- // `useEffect` cleanup semantics: the prior run's cleanup runs before
73
- // the next callback executes regardless of why the deps changed.
74
- // Without this, a reactive `autoScroll` mode change (e.g. switching
75
- // away from `"pin-to-send"`) or a `ref` swap that doesn't bump
76
- // `sendNonce` would leak the stale content-element observer because
77
- // the nonce guard skips the body. Registering via `onCleanup` also
78
- // covers watcher stop on scope dispose, so an explicit
79
- // `onScopeDispose(teardown)` is no longer required.
80
- onCleanup(teardown);
81
-
82
52
  if (sendNonce === lastNonce) return;
83
53
  lastNonce = sendNonce;
84
54
 
85
55
  if (!id) return;
86
56
  if (!scrollEl || !contentEl || !spacerEl) return;
87
57
 
88
- const escaped =
89
- typeof CSS !== "undefined" && typeof CSS.escape === "function"
90
- ? CSS.escape(id)
91
- : id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "\\$&");
92
- const targetEl = contentEl.querySelector<HTMLElement>(
93
- `[data-message-id="${escaped}"]`,
58
+ // Registered through `onCleanup`, so the previous pin stops before the
59
+ // next run and when the watcher stops on scope dispose.
60
+ onCleanup(
61
+ ɵpinMessageToTop({
62
+ scrollEl,
63
+ contentEl,
64
+ spacerEl,
65
+ messageId: id,
66
+ topOffset,
67
+ }),
94
68
  );
95
- if (!targetEl) return;
96
-
97
- // The target message's element has a top padding (e.g. `pt-10`) that
98
- // creates breathing room above the visible bubble. When we "anchor at
99
- // the top", we mean anchor the *bubble*, not the element's padded box.
100
- // So we scroll past the padding (it goes above the viewport, hiding
101
- // whatever was above the element too — including the previous message's
102
- // trailing copy button).
103
- const viewportHeight = scrollEl.clientHeight;
104
- const userMessageHeight = targetEl.getBoundingClientRect().height;
105
- const paddingTop = parseFloat(getComputedStyle(targetEl).paddingTop) || 0;
106
- const bubbleHeight = Math.max(0, userMessageHeight - paddingTop);
107
- const spacerHeight = Math.max(
108
- 0,
109
- viewportHeight - bubbleHeight - topOffset,
110
- );
111
-
112
- spacerEl.style.height = `${spacerHeight}px`;
113
-
114
- if (typeof requestAnimationFrame === "function") {
115
- raf = requestAnimationFrame(() => {
116
- // Scroll so the BUBBLE is `topOffset` from the viewport top — the
117
- // padding above the bubble ends up scrolled off-screen.
118
- const targetTop =
119
- computeOffsetTop(targetEl, scrollEl) + paddingTop - topOffset;
120
- scrollEl.scrollTo({
121
- top: Math.max(0, targetTop),
122
- behavior: "smooth",
123
- });
124
- });
125
- }
126
-
127
- // As content below the anchored user message changes, adjust the spacer
128
- // by the same amount so total scrollable space below the bubble stays
129
- // constant and the bubble stays pinned.
130
- if (typeof ResizeObserver !== "undefined") {
131
- ro = new ResizeObserver(() => {
132
- if (!contentEl || !spacerEl || !scrollEl) return;
133
- const contentHeight = contentEl.getBoundingClientRect().height;
134
- const targetOffsetWithinContent = computeOffsetTop(
135
- targetEl,
136
- contentEl,
137
- );
138
- const consumedBelow =
139
- contentHeight - targetOffsetWithinContent - userMessageHeight;
140
- const remaining = Math.max(0, spacerHeight - consumedBelow);
141
- spacerEl.style.height = `${remaining}px`;
142
- });
143
- ro.observe(contentEl);
144
- }
145
69
  },
146
70
  { immediate: true, flush: "post" },
147
71
  );
148
72
  }
149
-
150
- // Compute the offset of el relative to stopAt, accounting for stopAt's current scrollTop.
151
- // Uses getBoundingClientRect so it works regardless of CSS positioning (including position:static).
152
- function computeOffsetTop(el: HTMLElement, stopAt: HTMLElement): number {
153
- const elRect = el.getBoundingClientRect();
154
- const stopRect = stopAt.getBoundingClientRect();
155
- return elRect.top - stopRect.top + stopAt.scrollTop;
156
- }