@copilotkit/vue 1.69.0 → 1.69.1

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 (60) hide show
  1. package/PARITY.md +1 -0
  2. package/README.md +1 -1
  3. package/dist/components/copilot-provider/types.d.ts +1 -1
  4. package/dist/index.cjs +1 -1
  5. package/dist/index.mjs +2 -2
  6. package/dist/use-render-activity-message-BtaJ2iQc.cjs +97 -0
  7. package/dist/use-render-activity-message-BtaJ2iQc.cjs.map +1 -0
  8. package/dist/{use-render-activity-message-BQADVWev.js → use-render-activity-message-CADUB--8.js} +4347 -3966
  9. package/dist/use-render-activity-message-CADUB--8.js.map +1 -0
  10. package/dist/v2/components/CopilotKitInspector.vue.d.ts +2 -0
  11. package/dist/v2/components/CopilotKitInspector.vue.d.ts.map +1 -1
  12. package/dist/v2/components/OpenGenerativeUIRenderer.d.ts +1 -1
  13. package/dist/v2/components/OpenGenerativeUIRenderer.d.ts.map +1 -1
  14. package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
  15. package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts.map +1 -1
  16. package/dist/v2/components/chat/CopilotChatInput.vue.d.ts +18 -0
  17. package/dist/v2/components/chat/CopilotChatInput.vue.d.ts.map +1 -1
  18. package/dist/v2/components/chat/CopilotChatMessageView.vue.d.ts.map +1 -1
  19. package/dist/v2/components/chat/CopilotChatReasoningMessage.vue.d.ts.map +1 -1
  20. package/dist/v2/components/chat/CopilotChatToolCallsView.vue.d.ts.map +1 -1
  21. package/dist/v2/components/chat/SaveSnippetBeside.vue.d.ts +20 -0
  22. package/dist/v2/components/chat/SaveSnippetBeside.vue.d.ts.map +1 -0
  23. package/dist/v2/components/chat/save-snippet-beside.d.ts +20 -0
  24. package/dist/v2/components/chat/save-snippet-beside.d.ts.map +1 -0
  25. package/dist/v2/components/icons/index.d.ts +1 -1
  26. package/dist/v2/components/icons/index.d.ts.map +1 -1
  27. package/dist/v2/hooks/use-threads.d.ts +1 -1
  28. package/dist/v2/index.cjs +1 -1
  29. package/dist/v2/index.mjs +1 -1
  30. package/dist/v2/providers/CopilotKitProvider.types.d.ts +1 -1
  31. package/dist/v2/providers/CopilotKitProvider.vue.d.ts.map +1 -1
  32. package/dist/v2/providers/keys.d.ts +36 -0
  33. package/dist/v2/providers/keys.d.ts.map +1 -1
  34. package/dist/v2/providers/types.d.ts +3 -0
  35. package/dist/v2/providers/types.d.ts.map +1 -1
  36. package/dist/v2/providers/use-inspector-thread-override.d.ts +10 -0
  37. package/dist/v2/providers/use-inspector-thread-override.d.ts.map +1 -0
  38. package/package.json +5 -5
  39. package/src/components/copilot-provider/types.ts +1 -1
  40. package/src/v2/components/CopilotKitInspector.vue +19 -1
  41. package/src/v2/components/OpenGenerativeUIRenderer.ts +44 -10
  42. package/src/v2/components/chat/CopilotChat.vue +64 -4
  43. package/src/v2/components/chat/CopilotChatAssistantMessage.vue +52 -1
  44. package/src/v2/components/chat/CopilotChatMessageView.vue +57 -23
  45. package/src/v2/components/chat/CopilotChatReasoningMessage.vue +68 -32
  46. package/src/v2/components/chat/CopilotChatToolCallsView.vue +70 -16
  47. package/src/v2/components/chat/SaveSnippetBeside.vue +82 -0
  48. package/src/v2/components/chat/save-snippet-beside.ts +84 -0
  49. package/src/v2/components/icons/__tests__/icons.test.ts +2 -0
  50. package/src/v2/components/icons/index.ts +1 -0
  51. package/src/v2/hooks/use-threads.ts +1 -1
  52. package/src/v2/providers/CopilotKitProvider.types.ts +1 -1
  53. package/src/v2/providers/CopilotKitProvider.vue +66 -6
  54. package/src/v2/providers/__tests__/inspector-thread-override.test.ts +214 -0
  55. package/src/v2/providers/keys.ts +47 -0
  56. package/src/v2/providers/types.ts +3 -0
  57. package/src/v2/providers/use-inspector-thread-override.ts +99 -0
  58. package/dist/use-render-activity-message-BQADVWev.js.map +0 -1
  59. package/dist/use-render-activity-message-DYb_lsQs.cjs +0 -85
  60. package/dist/use-render-activity-message-DYb_lsQs.cjs.map +0 -1
@@ -20,6 +20,7 @@ import {
20
20
  useSlots,
21
21
  watch,
22
22
  } from "vue";
23
+ import { CopilotKitCoreErrorCode } from "@copilotkit/core";
23
24
  import type { Suggestion } from "@copilotkit/core";
24
25
  import CopilotChatConfigurationProvider from "../../providers/CopilotChatConfigurationProvider.vue";
25
26
  import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
@@ -39,6 +40,7 @@ import { LastUserMessageKey } from "./last-user-message-context";
39
40
  import type { LastUserMessageState } from "./last-user-message-context";
40
41
  import type { Message } from "@ag-ui/core";
41
42
  import type { InputContent } from "@copilotkit/shared";
43
+ import { useInspectorThreadOverride } from "../../providers/use-inspector-thread-override";
42
44
  import type {
43
45
  CopilotChatInputSlotProps,
44
46
  CopilotChatProps,
@@ -105,6 +107,7 @@ type ActiveConnectCycle = {
105
107
  core: object;
106
108
  agent: AbstractAgent;
107
109
  threadId: string;
110
+ inspectorRequestId: string | null;
108
111
  abortController: AbortController;
109
112
  detached: boolean;
110
113
  };
@@ -122,16 +125,27 @@ const resolvedAgentId = computed(
122
125
  const providedThreadId = computed(
123
126
  () => props.threadId ?? existingConfig.value?.threadId,
124
127
  );
125
- const resolvedThreadId = computed(
128
+ const baseThreadId = computed(
126
129
  () => providedThreadId.value ?? generatedThreadId.value,
127
130
  );
128
131
  // "Explicit" means the caller actually picked this thread — via the
129
132
  // `threadId` prop on CopilotChat or a wrapping provider that flagged its
130
133
  // threadId as caller-chosen. An auto-minted UUID leaking down through a
131
134
  // CopilotChatConfigurationProvider does NOT count.
132
- const hasExplicitThreadId = computed(
135
+ const baseHasExplicitThreadId = computed(
133
136
  () => !!props.threadId || !!existingConfig.value?.hasExplicitThreadId,
134
137
  );
138
+ const { inspectorThreadId, inspectorRequestId, failInspectorOverride } =
139
+ useInspectorThreadOverride({
140
+ agentId: resolvedAgentId,
141
+ baseThreadId,
142
+ });
143
+ const resolvedThreadId = computed(
144
+ () => inspectorThreadId.value ?? baseThreadId.value,
145
+ );
146
+ const hasExplicitThreadId = computed(
147
+ () => inspectorThreadId.value !== null || baseHasExplicitThreadId.value,
148
+ );
135
149
  const lastConnectedThreadId = ref<string | null>(null);
136
150
  const isConnecting = computed(
137
151
  () =>
@@ -297,6 +311,28 @@ watch(
297
311
  { immediate: true },
298
312
  );
299
313
 
314
+ watch(
315
+ [
316
+ () => copilotkit.value,
317
+ resolvedAgentId,
318
+ resolvedThreadId,
319
+ inspectorRequestId,
320
+ ],
321
+ ([core, agentId, threadId, requestId], _old, onCleanup) => {
322
+ if (!requestId) return;
323
+ const subscription = core.subscribe({
324
+ onError: (event) => {
325
+ if (event.code !== CopilotKitCoreErrorCode.AGENT_CONNECT_FAILED) return;
326
+ if (event.context?.agentId !== agentId) return;
327
+ if (event.context?.threadId !== threadId) return;
328
+ failInspectorOverride(requestId);
329
+ },
330
+ });
331
+ onCleanup(() => subscription.unsubscribe());
332
+ },
333
+ { immediate: true },
334
+ );
335
+
300
336
  watch(
301
337
  [
302
338
  isMounted,
@@ -304,8 +340,13 @@ watch(
304
340
  () => agent.value,
305
341
  resolvedThreadId,
306
342
  hasExplicitThreadId,
343
+ inspectorRequestId,
307
344
  ],
308
- ([mounted, core, currentAgent, threadId, isExplicit], _old, onCleanup) => {
345
+ (
346
+ [mounted, core, currentAgent, threadId, isExplicit, requestId],
347
+ _old,
348
+ onCleanup,
349
+ ) => {
309
350
  if (!mounted) {
310
351
  return;
311
352
  }
@@ -316,6 +357,20 @@ watch(
316
357
  return;
317
358
  }
318
359
 
360
+ const previousCycle = activeConnectCycle.value;
361
+ const inspectorTransition =
362
+ previousCycle !== null &&
363
+ previousCycle.threadId !== threadId &&
364
+ previousCycle.inspectorRequestId !== requestId &&
365
+ (previousCycle.inspectorRequestId !== null || requestId !== null);
366
+ if (inspectorTransition) {
367
+ try {
368
+ core.stopAgent({ agent: currentAgent });
369
+ } catch {
370
+ // No live run to stop.
371
+ }
372
+ }
373
+
319
374
  // Pin the thread this chat renders onto its agent, before anything issues a
320
375
  // request. `CopilotKitCore.connectAgent` reads `agent.threadId` synchronously
321
376
  // to decide whether this is a fresh restore, so a later assignment would let
@@ -357,7 +412,8 @@ watch(
357
412
  existingCycle &&
358
413
  existingCycle.core === (core as object) &&
359
414
  existingCycle.agent === currentAgent &&
360
- existingCycle.threadId === threadId;
415
+ existingCycle.threadId === threadId &&
416
+ existingCycle.inspectorRequestId === requestId;
361
417
 
362
418
  let cycle: ActiveConnectCycle;
363
419
  if (hasSameDeps && existingCycle) {
@@ -372,6 +428,7 @@ watch(
372
428
  core: core as object,
373
429
  agent: currentAgent,
374
430
  threadId,
431
+ inspectorRequestId: requestId,
375
432
  abortController: connectAbortController,
376
433
  detached: false,
377
434
  };
@@ -387,6 +444,9 @@ watch(
387
444
  return;
388
445
  }
389
446
  console.error("CopilotChat: connectAgent failed", error);
447
+ if (requestId) {
448
+ failInspectorOverride(requestId);
449
+ }
390
450
  })
391
451
  .finally(() => {
392
452
  // Whether the connect succeeded or failed, we're no longer in
@@ -4,6 +4,7 @@ import {
4
4
  defineComponent,
5
5
  getCurrentInstance,
6
6
  h,
7
+ inject,
7
8
  onBeforeUnmount,
8
9
  onMounted,
9
10
  ref,
@@ -13,6 +14,7 @@ import { StreamMarkdown } from "streamdown-vue";
13
14
  import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
14
15
  import { CopilotChatDefaultLabels } from "../../providers/types";
15
16
  import {
17
+ IconBookmark,
16
18
  IconCheck,
17
19
  IconCopy,
18
20
  IconDownload,
@@ -34,6 +36,7 @@ import type {
34
36
  CopilotChatAssistantMessageToolbarSlotProps,
35
37
  } from "./types";
36
38
  import { useKatexStyles } from "../../hooks/use-katex-styles";
39
+ import { InspectorKey } from "../../providers/keys";
37
40
 
38
41
  useKatexStyles();
39
42
 
@@ -88,6 +91,10 @@ const emit = defineEmits<{
88
91
 
89
92
  const config = useCopilotChatConfiguration();
90
93
  const labels = computed(() => config.value?.labels ?? CopilotChatDefaultLabels);
94
+ const inspector = inject(InspectorKey, null);
95
+ const isLocalInspectorEnabled = computed(
96
+ () => inspector?.isLocalInspectorEnabled.value === true,
97
+ );
91
98
  const instance = getCurrentInstance();
92
99
  const copied = ref(false);
93
100
  let copiedResetTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -661,10 +668,31 @@ const isLatestAssistantMessage = computed(
661
668
  const shouldShowToolbar = computed(
662
669
  () =>
663
670
  props.toolbarVisible &&
664
- hasContent.value &&
671
+ (hasContent.value || isLocalInspectorEnabled.value) &&
665
672
  !(props.isRunning && isLatestAssistantMessage.value),
666
673
  );
667
674
 
675
+ function handleViewInspector() {
676
+ inspector?.openInspector({
677
+ messageId: props.message.id,
678
+ threadId: config.value?.threadId,
679
+ agentId: config.value?.agentId,
680
+ });
681
+ }
682
+
683
+ function handleSaveSnippet() {
684
+ if (!hasContent.value) {
685
+ return;
686
+ }
687
+ void inspector?.saveEventSnippet({
688
+ kind: "text",
689
+ messageId: props.message.id,
690
+ content: props.message.content ?? "",
691
+ threadId: config.value?.threadId,
692
+ agentId: config.value?.agentId,
693
+ });
694
+ }
695
+
668
696
  function resetCopiedStateWithDelay() {
669
697
  if (copiedResetTimeout) {
670
698
  clearTimeout(copiedResetTimeout);
@@ -814,6 +842,29 @@ onBeforeUnmount(() => {
814
842
  </button>
815
843
  </slot>
816
844
 
845
+ <button
846
+ v-if="isLocalInspectorEnabled"
847
+ data-testid="copilot-inspector-button"
848
+ type="button"
849
+ :class="toolbarButtonClass"
850
+ :aria-label="`${labels.assistantMessageToolbarInspectorLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
851
+ :title="`${labels.assistantMessageToolbarInspectorLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
852
+ @click="handleViewInspector"
853
+ >
854
+ I
855
+ </button>
856
+ <button
857
+ v-if="isLocalInspectorEnabled && hasContent"
858
+ data-testid="copilot-save-snippet-button"
859
+ type="button"
860
+ :class="toolbarButtonClass"
861
+ :aria-label="`${labels.assistantMessageToolbarSaveSnippetLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
862
+ :title="`${labels.assistantMessageToolbarSaveSnippetLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
863
+ @click="handleSaveSnippet"
864
+ >
865
+ <IconBookmark class="cpk:size-[18px]" />
866
+ </button>
867
+
817
868
  <slot
818
869
  v-if="hasThumbsUp"
819
870
  name="thumbs-up-button"
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, useSlots, watch } from "vue";
2
+ import { computed, inject, ref, useSlots, watch } from "vue";
3
3
  import type { Component } from "vue";
4
4
  import type {
5
5
  ActivityMessage,
@@ -19,6 +19,9 @@ import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfi
19
19
  import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage.vue";
20
20
  import CopilotChatReasoningMessage from "./CopilotChatReasoningMessage.vue";
21
21
  import CopilotChatUserMessage from "./CopilotChatUserMessage.vue";
22
+ import { InspectorKey } from "../../providers/keys";
23
+ import { IconBookmark } from "../icons";
24
+ import SaveSnippetBeside from "./SaveSnippetBeside.vue";
22
25
 
23
26
  interface MessageMetaProps {
24
27
  message: Message;
@@ -38,6 +41,23 @@ interface ActivitySlotProps {
38
41
  agent: unknown;
39
42
  }
40
43
 
44
+ const inspector = inject(InspectorKey, null);
45
+ const canSaveActivity = (message: ActivityMessage) =>
46
+ inspector?.isLocalInspectorEnabled.value === true &&
47
+ (message.activityType === "a2ui-surface" ||
48
+ message.activityType === "open-generative-ui");
49
+
50
+ function saveActivitySnippet(message: ActivityMessage) {
51
+ void inspector?.saveEventSnippet({
52
+ kind: "activity",
53
+ messageId: message.id,
54
+ activityType: message.activityType,
55
+ content: message.content,
56
+ threadId: config.value?.threadId,
57
+ agentId: config.value?.agentId,
58
+ });
59
+ }
60
+
41
61
  type InterruptSlotProps = InterruptRenderProps<unknown, unknown>;
42
62
  type CustomMessagePosition = VueCustomMessageRendererProps["position"];
43
63
  type ResolvedCustomMessageRenderer = {
@@ -414,28 +434,42 @@ function resolveToolMessage(
414
434
  />
415
435
  </slot>
416
436
 
417
- <slot
418
- v-else-if="message.role === 'activity'"
419
- :name="getActivitySlotName(message.activityType)"
420
- :activity-type="message.activityType"
421
- :content="message.content"
422
- :message="message"
423
- :agent="resolvedThreadAgent"
424
- >
425
- <slot
426
- name="activity-message"
427
- :activity-type="message.activityType"
428
- :content="message.content"
429
- :message="message"
430
- :agent="resolvedThreadAgent"
431
- >
432
- <component
433
- v-if="resolveActivityRenderer(message)"
434
- :is="resolveActivityRenderer(message)!.renderer"
435
- v-bind="resolveActivityRenderer(message)!.props"
436
- />
437
- </slot>
438
- </slot>
437
+ <div v-else-if="message.role === 'activity'">
438
+ <SaveSnippetBeside :enabled="canSaveActivity(message)">
439
+ <slot
440
+ :name="getActivitySlotName(message.activityType)"
441
+ :activity-type="message.activityType"
442
+ :content="message.content"
443
+ :message="message"
444
+ :agent="resolvedThreadAgent"
445
+ >
446
+ <slot
447
+ name="activity-message"
448
+ :activity-type="message.activityType"
449
+ :content="message.content"
450
+ :message="message"
451
+ :agent="resolvedThreadAgent"
452
+ >
453
+ <component
454
+ v-if="resolveActivityRenderer(message)"
455
+ :is="resolveActivityRenderer(message)!.renderer"
456
+ v-bind="resolveActivityRenderer(message)!.props"
457
+ />
458
+ </slot>
459
+ </slot>
460
+ <template #save>
461
+ <button
462
+ type="button"
463
+ class="cpk:inline-flex cpk:h-8 cpk:w-8 cpk:items-center cpk:justify-center cpk:rounded-md cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8] cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]"
464
+ data-testid="copilot-activity-save-snippet-button"
465
+ :aria-label="`${config?.labels.assistantMessageToolbarSaveSnippetLabel ?? 'Save as snippet'} (Local Only)`"
466
+ @click="saveActivitySnippet(message)"
467
+ >
468
+ <IconBookmark class="cpk:size-[18px]" />
469
+ </button>
470
+ </template>
471
+ </SaveSnippetBeside>
472
+ </div>
439
473
 
440
474
  <slot
441
475
  v-if="componentSlots['message-after']"
@@ -1,8 +1,11 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, ref, watch } from "vue";
2
+ import { computed, inject, onBeforeUnmount, ref, watch } from "vue";
3
3
  import type { Message, ReasoningMessage } from "@ag-ui/core";
4
4
  import { StreamMarkdown } from "streamdown-vue";
5
- import { IconChevronRight } from "../icons";
5
+ import { IconBookmark, IconChevronRight } from "../icons";
6
+ import { InspectorKey } from "../../providers/keys";
7
+ import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
8
+ import { CopilotChatDefaultLabels } from "../../providers/types";
6
9
 
7
10
  const props = withDefaults(
8
11
  defineProps<{
@@ -139,6 +142,27 @@ function toggleOpen() {
139
142
  userToggledDuringStreaming.value = true;
140
143
  isOpen.value = !isOpen.value;
141
144
  }
145
+
146
+ const inspector = inject(InspectorKey, null);
147
+ const chatConfiguration = useCopilotChatConfiguration();
148
+ const canSaveReasoning = computed(
149
+ () => inspector?.isLocalInspectorEnabled.value === true && hasContent.value,
150
+ );
151
+ const saveLabel = computed(
152
+ () =>
153
+ chatConfiguration.value?.labels.assistantMessageToolbarSaveSnippetLabel ??
154
+ CopilotChatDefaultLabels.assistantMessageToolbarSaveSnippetLabel,
155
+ );
156
+
157
+ function saveReasoningSnippet() {
158
+ void inspector?.saveEventSnippet({
159
+ kind: "reasoning",
160
+ messageId: props.message.id,
161
+ content: normalizedContent.value,
162
+ threadId: chatConfiguration.value?.threadId,
163
+ agentId: chatConfiguration.value?.agentId,
164
+ });
165
+ }
142
166
  </script>
143
167
 
144
168
  <template>
@@ -169,41 +193,53 @@ function toggleOpen() {
169
193
  },
170
194
  }"
171
195
  >
172
- <slot
173
- name="header"
174
- :is-open="isOpen"
175
- :label="label"
176
- :has-content="hasContent"
177
- :is-streaming="isStreaming"
178
- :on-click="hasContent ? toggleOpen : undefined"
179
- >
180
- <button
181
- type="button"
182
- class="cpk:inline-flex cpk:items-center cpk:gap-1 cpk:py-1 cpk:text-sm cpk:text-muted-foreground cpk:transition-colors cpk:select-none"
183
- :class="
184
- hasContent
185
- ? 'cpk:hover:text-foreground cpk:cursor-pointer'
186
- : 'cpk:cursor-default'
187
- "
188
- :aria-expanded="hasContent ? isOpen : undefined"
189
- @click="hasContent ? toggleOpen() : undefined"
196
+ <div class="cpk:flex cpk:items-center cpk:gap-1">
197
+ <slot
198
+ name="header"
199
+ :is-open="isOpen"
200
+ :label="label"
201
+ :has-content="hasContent"
202
+ :is-streaming="isStreaming"
203
+ :on-click="hasContent ? toggleOpen : undefined"
190
204
  >
191
- <span class="cpk:font-medium">{{ label }}</span>
192
- <span
193
- v-if="isStreaming && !hasContent"
194
- class="cpk:inline-flex cpk:items-center cpk:ml-1"
205
+ <button
206
+ type="button"
207
+ class="cpk:inline-flex cpk:items-center cpk:gap-1 cpk:py-1 cpk:text-sm cpk:text-muted-foreground cpk:transition-colors cpk:select-none"
208
+ :class="
209
+ hasContent
210
+ ? 'cpk:hover:text-foreground cpk:cursor-pointer'
211
+ : 'cpk:cursor-default'
212
+ "
213
+ :aria-expanded="hasContent ? isOpen : undefined"
214
+ @click="hasContent ? toggleOpen() : undefined"
195
215
  >
216
+ <span class="cpk:font-medium">{{ label }}</span>
196
217
  <span
197
- class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
218
+ v-if="isStreaming && !hasContent"
219
+ class="cpk:inline-flex cpk:items-center cpk:ml-1"
220
+ >
221
+ <span
222
+ class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
223
+ />
224
+ </span>
225
+ <IconChevronRight
226
+ v-if="hasContent"
227
+ class="cpk:size-3.5 cpk:shrink-0 cpk:transition-transform cpk:duration-200"
228
+ :class="{ 'cpk:rotate-90': isOpen }"
198
229
  />
199
- </span>
200
- <IconChevronRight
201
- v-if="hasContent"
202
- class="cpk:size-3.5 cpk:shrink-0 cpk:transition-transform cpk:duration-200"
203
- :class="{ 'cpk:rotate-90': isOpen }"
204
- />
230
+ </button>
231
+ </slot>
232
+ <button
233
+ v-if="canSaveReasoning"
234
+ type="button"
235
+ class="cpk:inline-flex cpk:h-8 cpk:w-8 cpk:items-center cpk:justify-center cpk:rounded-md cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8] cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]"
236
+ data-testid="copilot-save-snippet-button"
237
+ :aria-label="`${saveLabel} (Local Only)`"
238
+ @click="saveReasoningSnippet"
239
+ >
240
+ <IconBookmark class="cpk:size-[18px]" />
205
241
  </button>
206
- </slot>
242
+ </div>
207
243
 
208
244
  <slot
209
245
  name="toggle"
@@ -1,10 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { useSlots } from "vue";
2
+ import { inject, useSlots } from "vue";
3
3
  import type { AssistantMessage, Message } from "@ag-ui/core";
4
4
  import type { CopilotChatToolCallRenderSlotProps } from "./types";
5
5
  import CopilotChatToolCallItem from "./CopilotChatToolCallItem.vue";
6
+ import SaveSnippetBeside from "./SaveSnippetBeside.vue";
7
+ import { InspectorKey } from "../../providers/keys";
8
+ import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
9
+ import { CopilotChatDefaultLabels } from "../../providers/types";
10
+ import { IconBookmark } from "../icons";
6
11
 
7
- withDefaults(
12
+ const props = withDefaults(
8
13
  defineProps<{
9
14
  message: AssistantMessage;
10
15
  messages?: Message[];
@@ -29,21 +34,70 @@ const componentSlots = useSlots() as Record<
29
34
  function getForwardedSlotNames(): ToolCallSlotName[] {
30
35
  return Object.keys(componentSlots) as ToolCallSlotName[];
31
36
  }
37
+
38
+ // A streaming tool call has truncated arguments. Do not offer to capture it
39
+ // until the JSON is complete, or the snippet holds a broken partial payload.
40
+ function hasCompleteArgs(args: string | undefined): boolean {
41
+ const trimmed = (args ?? "").trim();
42
+ if (!trimmed) {
43
+ return true;
44
+ }
45
+ try {
46
+ JSON.parse(trimmed);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ const inspector = inject(InspectorKey, null);
54
+ const chatConfiguration = useCopilotChatConfiguration();
55
+ const canSave = (toolCall: { function: { arguments: string } }) =>
56
+ inspector?.isLocalInspectorEnabled.value === true &&
57
+ hasCompleteArgs(toolCall.function.arguments);
58
+ const saveLabel = () =>
59
+ chatConfiguration.value?.labels.assistantMessageToolbarSaveSnippetLabel ??
60
+ CopilotChatDefaultLabels.assistantMessageToolbarSaveSnippetLabel;
61
+
62
+ function saveToolCall(toolCall: {
63
+ id: string;
64
+ function: { name: string; arguments: string };
65
+ }) {
66
+ void inspector?.saveEventSnippet({
67
+ kind: "tool-call",
68
+ messageId: props.message.id,
69
+ toolCallId: toolCall.id,
70
+ toolName: toolCall.function.name,
71
+ argsJson: toolCall.function.arguments || "{}",
72
+ threadId: chatConfiguration.value?.threadId,
73
+ agentId: chatConfiguration.value?.agentId,
74
+ });
75
+ }
32
76
  </script>
33
77
 
34
78
  <template>
35
- <CopilotChatToolCallItem
36
- v-for="toolCall in message.toolCalls ?? []"
37
- :key="toolCall.id"
38
- :tool-call="toolCall"
39
- :messages="messages"
40
- >
41
- <template
42
- v-for="slotName in getForwardedSlotNames()"
43
- :key="slotName"
44
- #[slotName]="slotProps"
45
- >
46
- <slot :name="slotName" v-bind="slotProps ?? {}" />
47
- </template>
48
- </CopilotChatToolCallItem>
79
+ <div v-for="toolCall in message.toolCalls ?? []" :key="toolCall.id">
80
+ <SaveSnippetBeside :enabled="canSave(toolCall)">
81
+ <CopilotChatToolCallItem :tool-call="toolCall" :messages="messages">
82
+ <template
83
+ v-for="slotName in getForwardedSlotNames()"
84
+ :key="slotName"
85
+ #[slotName]="slotProps"
86
+ >
87
+ <slot :name="slotName" v-bind="slotProps ?? {}" />
88
+ </template>
89
+ </CopilotChatToolCallItem>
90
+ <template #save>
91
+ <button
92
+ type="button"
93
+ class="cpk:inline-flex cpk:h-8 cpk:w-8 cpk:items-center cpk:justify-center cpk:rounded-md cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8] cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]"
94
+ data-testid="copilot-tool-save-snippet-button"
95
+ :aria-label="`${saveLabel()} (Local Only)`"
96
+ @click="saveToolCall(toolCall)"
97
+ >
98
+ <IconBookmark class="cpk:size-[18px]" />
99
+ </button>
100
+ </template>
101
+ </SaveSnippetBeside>
102
+ </div>
49
103
  </template>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, onMounted, ref, watch } from "vue";
3
+ import {
4
+ SAVE_SNIPPET_BESIDE_BODY_CLASS,
5
+ SAVE_SNIPPET_BESIDE_SAVE_CLASS,
6
+ SAVE_SNIPPET_BESIDE_WRAP_CLASS,
7
+ findOverflowAncestor,
8
+ measureSaveSnippetSide,
9
+ saveSnippetBesideStyle,
10
+ } from "./save-snippet-beside";
11
+
12
+ const props = defineProps<{
13
+ enabled: boolean;
14
+ }>();
15
+
16
+ const wrapRef = ref<HTMLElement | null>(null);
17
+ const bodyRef = ref<HTMLElement | null>(null);
18
+ const side = ref<"left" | "right">("right");
19
+ let observer: ResizeObserver | undefined;
20
+
21
+ function measure() {
22
+ const wrap = wrapRef.value;
23
+ const body = bodyRef.value;
24
+ if (!props.enabled || !wrap || !body) {
25
+ return;
26
+ }
27
+ side.value = measureSaveSnippetSide(wrap, body);
28
+ }
29
+
30
+ function bindObserver() {
31
+ observer?.disconnect();
32
+ const wrap = wrapRef.value;
33
+ const body = bodyRef.value;
34
+ if (!props.enabled || !wrap || !body) {
35
+ return;
36
+ }
37
+ measure();
38
+ if (typeof ResizeObserver === "undefined") {
39
+ return;
40
+ }
41
+ observer = new ResizeObserver(measure);
42
+ observer.observe(wrap);
43
+ observer.observe(body);
44
+ const clip = findOverflowAncestor(wrap);
45
+ if (clip !== wrap) {
46
+ observer.observe(clip);
47
+ }
48
+ }
49
+
50
+ onMounted(() => {
51
+ watch([wrapRef, bodyRef, () => props.enabled], () => bindObserver(), {
52
+ flush: "post",
53
+ immediate: true,
54
+ });
55
+ window.addEventListener("resize", measure);
56
+ });
57
+
58
+ onBeforeUnmount(() => {
59
+ observer?.disconnect();
60
+ window.removeEventListener("resize", measure);
61
+ });
62
+ </script>
63
+
64
+ <template>
65
+ <slot v-if="!enabled" />
66
+ <div
67
+ v-else
68
+ ref="wrapRef"
69
+ :class="SAVE_SNIPPET_BESIDE_WRAP_CLASS"
70
+ :data-save-snippet-side="side"
71
+ >
72
+ <div ref="bodyRef" :class="SAVE_SNIPPET_BESIDE_BODY_CLASS">
73
+ <slot />
74
+ </div>
75
+ <div
76
+ :class="SAVE_SNIPPET_BESIDE_SAVE_CLASS"
77
+ :style="saveSnippetBesideStyle(side)"
78
+ >
79
+ <slot name="save" />
80
+ </div>
81
+ </div>
82
+ </template>